Merge branch 'main' into fix/internal-214-gosum-vanity-import
This commit is contained in:
commit
360321db53
@ -285,12 +285,26 @@ _passed_clauses=""
|
||||
_failed_clauses=""
|
||||
|
||||
for _raw_clause in $EXPR; do
|
||||
# Normalise: strip parens, split on comma, trim whitespace.
|
||||
_clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# Normalise: strip parens, replace commas with spaces so bash word-split
|
||||
# can iterate the OR-set members. The previous form
|
||||
# _clause=$(echo ... | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# collapsed every member into one concatenated token because
|
||||
# `tr -d '[:space:]'` strips the very newlines that just separated them
|
||||
# ("engineers,managers,ceo" -> "engineersmanagersceo"), so the OR-clause
|
||||
# only ever evaluated as a single nonsense team name and never matched
|
||||
# APPROVER_TEAMS. Fixed in #229: leave the comma-separated members as
|
||||
# space-separated tokens for `for _t in $_clause`.
|
||||
_no_parens=${_raw_clause//[()]/}
|
||||
_clause=${_no_parens//,/ }
|
||||
_clause_passed="no"
|
||||
_clause_names=""
|
||||
for _t in $_clause; do
|
||||
_clause_names="${_clause_names:+, }${_t}"
|
||||
# Append (don't overwrite) team name to the human-readable accumulator.
|
||||
# The previous form `_clause_names="${_clause_names:+, }${_t}"`
|
||||
# rewrote the variable on every iteration, so the FAIL message only
|
||||
# ever showed the LAST team. Fixed: prepend prior value before the
|
||||
# comma-separator, then append the new team name.
|
||||
_clause_names="${_clause_names}${_clause_names:+, }${_t}"
|
||||
# Skip teams not yet in Gitea (qa??? / security??? placeholders).
|
||||
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
|
||||
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
|
||||
@ -311,11 +325,12 @@ for _raw_clause in $EXPR; do
|
||||
_label=$(echo "$_raw_clause" | tr -d '()' | tr ',' '/' | tr -d '[:space:]' | sed 's/???//g')
|
||||
|
||||
if [ "$_clause_passed" = "yes" ]; then
|
||||
_passed_clauses="${_passed_clauses:+, }$_label"
|
||||
# Append (don't overwrite) — same accumulator bug as _clause_names above.
|
||||
_passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label"
|
||||
echo "::notice::clause [$_label]: PASS — satisfied by approving reviewer(s)"
|
||||
else
|
||||
_failed_clauses="${_failed_clauses:+, }$_label"
|
||||
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams${_clause_names}. Set SOP_DEBUG=1 to see per-team probe results."
|
||||
_failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label"
|
||||
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams (${_clause_names}). Set SOP_DEBUG=1 to see per-team probe results."
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
101
.gitea/scripts/tests/test_sop_tier_check_clause_split.sh
Executable file
101
.gitea/scripts/tests/test_sop_tier_check_clause_split.sh
Executable file
@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for #229 — sop-tier-check tier:low OR-clause splitter.
|
||||
#
|
||||
# Bug (PR #225 → still broken after PR #231):
|
||||
# Line ~289 of sop-tier-check.sh used:
|
||||
# _clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
# `tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just
|
||||
# inserted, collapsing "engineers,managers,ceo" into a single token
|
||||
# "engineersmanagersceo". The for-loop then iterates ONCE on a name
|
||||
# that matches no team, so every tier:low PR fails:
|
||||
# ::error::clause [engineers/managers/ceo]: FAIL — no approving
|
||||
# reviewer belongs to any of these teamsengineersmanagersceo
|
||||
# (note also: missing separators in the error string is bug #2 —
|
||||
# `_clause_names` used "${var:+, }$x" which OVERWRITES per iteration).
|
||||
#
|
||||
# Fix shape (this PR):
|
||||
# _no_parens=${_raw_clause//[()]/}
|
||||
# _clause=${_no_parens//,/ } # comma -> space, bash word-split iterates
|
||||
# _clause_names="${_clause_names}${_clause_names:+, }${_t}" # APPEND, not overwrite
|
||||
#
|
||||
# This test extracts the splitter logic and asserts it produces the right
|
||||
# token list for each of the three tier expressions live in the script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local label="$1"
|
||||
local expected="$2"
|
||||
local got="$3"
|
||||
if [ "$expected" = "$got" ]; then
|
||||
echo " PASS $label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $label"
|
||||
echo " expected: <$expected>"
|
||||
echo " got: <$got>"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ----- Splitter under test (mirrors the fixed sop-tier-check.sh block) -----
|
||||
split_clause() {
|
||||
local raw="$1"
|
||||
local no_parens=${raw//[()]/}
|
||||
local clause=${no_parens//,/ }
|
||||
local out=""
|
||||
for _t in $clause; do
|
||||
out="${out}${out:+|}$_t"
|
||||
done
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
echo "test: tier:low OR-clause splits to 3 tokens"
|
||||
assert_eq "tier:low" "engineers|managers|ceo" "$(split_clause "engineers,managers,ceo")"
|
||||
|
||||
echo "test: tier:medium AND-expression — bash word-split on \$EXPR yields 5 tokens"
|
||||
EXPR="managers AND engineers AND qa???,security???"
|
||||
out=""
|
||||
for _raw in $EXPR; do
|
||||
out="${out}${out:+ ; }$(split_clause "$_raw")"
|
||||
done
|
||||
assert_eq "tier:medium" "managers ; AND ; engineers ; AND ; qa???|security???" "$out"
|
||||
|
||||
echo "test: tier:high single-team OR-clause"
|
||||
assert_eq "tier:high" "ceo" "$(split_clause "ceo")"
|
||||
|
||||
echo "test: paren-wrapped OR-set unwraps + splits"
|
||||
assert_eq "paren OR" "managers|ceo" "$(split_clause "(managers,ceo)")"
|
||||
|
||||
# ----- _clause_names accumulator (was overwriting per iteration) -----
|
||||
acc=""
|
||||
for t in engineers managers ceo; do
|
||||
acc="${acc}${acc:+, }${t}"
|
||||
done
|
||||
assert_eq "_clause_names append" "engineers, managers, ceo" "$acc"
|
||||
|
||||
# ----- _failed_clauses / _passed_clauses accumulator across raw clauses -----
|
||||
acc=""
|
||||
for c in clauseA clauseB clauseC; do
|
||||
acc="${acc}${acc:+, }${c}"
|
||||
done
|
||||
assert_eq "_failed_clauses append" "clauseA, clauseB, clauseC" "$acc"
|
||||
|
||||
# ----- End-to-end OR-gate: simulate APPROVER_TEAMS[core-lead]=' managers ' -----
|
||||
# The script's case pattern is *${_t}* with a space-padded value.
|
||||
APPROVER_TEAMS_VAL=" managers "
|
||||
matched=""
|
||||
for _t in $(split_clause "engineers,managers,ceo" | tr '|' ' '); do
|
||||
case "$APPROVER_TEAMS_VAL" in
|
||||
*${_t}*) matched="$_t"; break ;;
|
||||
esac
|
||||
done
|
||||
assert_eq "OR-gate matches managers" "managers" "$matched"
|
||||
|
||||
echo
|
||||
echo "------"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
155
.gitea/workflows/publish-workspace-server-image.yml
Normal file
155
.gitea/workflows/publish-workspace-server-image.yml
Normal file
@ -0,0 +1,155 @@
|
||||
name: publish-workspace-server-image
|
||||
|
||||
# Gitea Actions port of .github/workflows/publish-workspace-server-image.yml.
|
||||
#
|
||||
# Ported 2026-05-10 (issue #228). Key differences from the GitHub version:
|
||||
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
|
||||
# - Dropped `environment:` declarations — Gitea Actions does not support
|
||||
# named environments (used by GitHub OIDC token gates)
|
||||
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/heads/}`
|
||||
# — Gitea Actions exposes GITHUB_REF in the same format as GitHub Actions
|
||||
# - docker/setup-buildx-action and aws-actions/configure-aws-credentials are
|
||||
# GitHub Marketplace actions; they are installed by Gitea Actions runners and
|
||||
# work identically here
|
||||
# - All other variables (GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_OUTPUT,
|
||||
# secrets.*) use the same syntax as GitHub Actions
|
||||
#
|
||||
# Image tags produced:
|
||||
# :staging-<sha> — per-commit digest, stable for canary verify
|
||||
# :staging-latest — tracks most recent build on this branch
|
||||
#
|
||||
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
|
||||
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'manifest.json'
|
||||
- 'scripts/**'
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per-branch so two rapid staging pushes don't race the same
|
||||
# :staging-latest tag retag. Allow staging and main to run in parallel
|
||||
# (different GITHUB_REF → different concurrency group) since they
|
||||
# produce different :staging-<sha> tags and last-write-wins on
|
||||
# :staging-latest is acceptable across branches.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
concurrency:
|
||||
group: publish-workspace-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Pre-clone manifest deps before docker build.
|
||||
#
|
||||
# Why: workspace-template-* repos on Gitea are private. The pre-fix
|
||||
# Dockerfile.tenant ran `git clone` inside an in-image stage with no
|
||||
# auth path — every CI build failed. We clone in the trusted CI
|
||||
# context where AUTO_SYNC_TOKEN is available and Dockerfile.tenant
|
||||
# just COPYs from .tenant-bundle-deps/.
|
||||
#
|
||||
# Token: AUTO_SYNC_TOKEN is the devops-engineer persona PAT.
|
||||
# clone-manifest.sh embeds it as basic-auth for the clones, then
|
||||
# strips .git dirs — the token never enters the image.
|
||||
- name: Pre-clone manifest deps
|
||||
env:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build + push platform image (inline ECR auth — mirrors the operator-host
|
||||
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
|
||||
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
|
||||
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ECR_REGISTRY="${IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
|
||||
--tag "${IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${IMAGE_NAME}:${TAG_LATEST}"
|
||||
|
||||
# Build + push tenant image (Go platform + Next.js canvas in one image).
|
||||
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
|
||||
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
|
||||
TAG_LATEST: staging-latest
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
--file ./workspace-server/Dockerfile.tenant \
|
||||
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
2
.github/workflows/publish-runtime.yml
vendored
2
.github/workflows/publish-runtime.yml
vendored
@ -180,7 +180,7 @@ jobs:
|
||||
# environment pypi-publish. The action mints a short-lived OIDC
|
||||
# token and exchanges it for a PyPI upload credential — no static
|
||||
# API token in this repo's secrets.
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: ${{ runner.temp }}/runtime-build/dist/
|
||||
|
||||
|
||||
2
.github/workflows/secret-pattern-drift.yml
vendored
2
.github/workflows/secret-pattern-drift.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@ -13,7 +13,8 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function extractMessageText(body: Record<string, unknown> | null): string {
|
||||
/** Exported for unit testing — see ConversationTraceModal.test.ts */
|
||||
export function extractMessageText(body: Record<string, unknown> | null): string {
|
||||
if (!body) return "";
|
||||
try {
|
||||
// Simple task format from MCP server: {task: "..."}
|
||||
|
||||
@ -317,7 +317,7 @@ export function Toolbar() {
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open quick help"
|
||||
aria-label="Open shortcuts and tips"
|
||||
title="Help — shortcuts & quick start"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
@ -327,24 +327,35 @@ export function Toolbar() {
|
||||
</button>
|
||||
|
||||
{helpOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Shortcuts and tips"
|
||||
aria-modal="false"
|
||||
className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md z-50"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Shortcuts & tips</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
aria-label="Close help dialog"
|
||||
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." />
|
||||
<HelpRow shortcut="Esc" text="Clear selection, close menus, dismiss dialogs." />
|
||||
<HelpRow shortcut="Enter" text="Zoom into selected team and select its first child node." />
|
||||
<HelpRow shortcut="Shift+Enter" text="Select the parent of the selected node." />
|
||||
<HelpRow shortcut="⌘]" text="Bring selected node forward in the z-order." />
|
||||
<HelpRow shortcut="⌘[" text="Send selected node backward in the z-order." />
|
||||
<HelpRow shortcut="Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
||||
<HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." />
|
||||
<HelpRow shortcut="Right-click" text="Use node actions for duplicate, export, restart, or delete." />
|
||||
<HelpRow shortcut="Chat" text="If a task is still running, the chat tab resumes that session automatically." />
|
||||
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
|
||||
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
|
||||
<HelpRow shortcut="Dbl-click" text="On a team node: expand and zoom to show all sub-workspaces." />
|
||||
<HelpRow shortcut="Shift+click" text="Multi-select: add or remove a node from the batch selection." />
|
||||
</div>
|
||||
{/* Link to the full keyboard shortcuts dialog */}
|
||||
<button
|
||||
|
||||
156
canvas/src/components/__tests__/ConversationTraceModal.test.tsx
Normal file
156
canvas/src/components/__tests__/ConversationTraceModal.test.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ConversationTraceModal's extractMessageText helper.
|
||||
*
|
||||
* Covers: MCP simple task format, request params.message.parts extraction,
|
||||
* response result.parts extraction, result.root.text extraction, plain string
|
||||
* result, null input, malformed input, empty strings.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractMessageText } from "../ConversationTraceModal";
|
||||
|
||||
describe("extractMessageText — MCP simple task format", () => {
|
||||
it("extracts text from body.task field", () => {
|
||||
const body = { task: "Deploy the agent to production" };
|
||||
expect(extractMessageText(body)).toBe("Deploy the agent to production");
|
||||
});
|
||||
|
||||
it("returns empty string when body is null", () => {
|
||||
expect(extractMessageText(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when body is undefined", () => {
|
||||
expect(extractMessageText(undefined as unknown as null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — request params.message format", () => {
|
||||
it("extracts text from params.message.parts[].text", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [{ text: "Hello world" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("joins multiple parts with newlines", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [
|
||||
{ text: "First part" },
|
||||
{ text: "Second part" },
|
||||
{ text: "Third part" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("First part\nSecond part\nThird part");
|
||||
});
|
||||
|
||||
it("ignores parts without text field", () => {
|
||||
const body = {
|
||||
params: {
|
||||
message: {
|
||||
parts: [{ text: "Hello" }, { other: "field" }, { text: "World" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Hello\nWorld");
|
||||
});
|
||||
|
||||
it("returns empty string when params.message is absent", () => {
|
||||
const body = { params: {} };
|
||||
expect(extractMessageText(body)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — response result format", () => {
|
||||
it("extracts text from result.parts[].text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ text: "Agent response" }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Agent response");
|
||||
});
|
||||
|
||||
it("extracts text from result.parts[].root.text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ root: { text: "Root response text" } }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Root response text");
|
||||
});
|
||||
|
||||
it("prefers parts[].text over parts[].root.text", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
{ text: "Direct text" },
|
||||
{ root: { text: "Root text" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Both are non-empty strings, so the first one wins (filter picks the first)
|
||||
// The implementation: rText from rParts[0].text = "Direct text"
|
||||
expect(extractMessageText(body)).toBe("Direct text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — plain string result", () => {
|
||||
it("returns body.result when it is a plain string", () => {
|
||||
const body = { result: "Simple string response" };
|
||||
expect(extractMessageText(body)).toBe("Simple string response");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — priority order", () => {
|
||||
it("prefers task format over params format", () => {
|
||||
const body = {
|
||||
task: "Task text",
|
||||
params: { message: { parts: [{ text: "Params text" }] } },
|
||||
};
|
||||
// Implementation: checks task first, returns if non-empty
|
||||
expect(extractMessageText(body)).toBe("Task text");
|
||||
});
|
||||
|
||||
it("prefers params format over result format", () => {
|
||||
const body = {
|
||||
params: { message: { parts: [{ text: "Params text" }] } },
|
||||
result: { parts: [{ text: "Result text" }] },
|
||||
};
|
||||
// Implementation: checks params.message.parts first (after task)
|
||||
expect(extractMessageText(body)).toBe("Params text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText — error resilience", () => {
|
||||
it("returns empty string on malformed input", () => {
|
||||
expect(extractMessageText({})).toBe("");
|
||||
expect(extractMessageText({ params: null })).toBe("");
|
||||
expect(extractMessageText({ result: null })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when all fields are absent", () => {
|
||||
expect(extractMessageText({ random: "field" })).toBe("");
|
||||
});
|
||||
|
||||
it("handles missing parts array gracefully", () => {
|
||||
const body = { params: { message: {} } };
|
||||
expect(extractMessageText(body)).toBe("");
|
||||
});
|
||||
|
||||
it("handles parts with undefined text gracefully", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ text: undefined }, { text: "valid" }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("valid");
|
||||
});
|
||||
});
|
||||
69
canvas/src/components/__tests__/MissingKeysModal.test.tsx
Normal file
69
canvas/src/components/__tests__/MissingKeysModal.test.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for MissingKeysModal's providerIdForModel helper.
|
||||
*
|
||||
* Covers: model match, no match, empty modelId, whitespace-only modelId,
|
||||
* model with no required_env, models undefined, single vs multiple env vars,
|
||||
* stable sort order for env var ordering.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerIdForModel } from "../MissingKeysModal";
|
||||
|
||||
describe("providerIdForModel — match behavior", () => {
|
||||
it("returns sorted-joined env vars when model is found", () => {
|
||||
const models = [
|
||||
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
];
|
||||
expect(providerIdForModel("claude-3-5-sonnet", models)).toBe("ANTHROPIC_API_KEY");
|
||||
});
|
||||
|
||||
it("returns null when model is not found", () => {
|
||||
const models = [
|
||||
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
];
|
||||
expect(providerIdForModel("unknown-model", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when models is undefined", () => {
|
||||
expect(providerIdForModel("claude-3-5-sonnet", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when modelId is empty string", () => {
|
||||
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
|
||||
expect(providerIdForModel("", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when modelId is whitespace-only", () => {
|
||||
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
|
||||
expect(providerIdForModel(" ", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace from modelId before matching", () => {
|
||||
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
|
||||
expect(providerIdForModel(" claude ", models)).toBe("KEY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerIdForModel — required_env variations", () => {
|
||||
it("returns null when model has no required_env", () => {
|
||||
const models = [{ id: "local-model", name: "Local Model", required_env: [] }];
|
||||
expect(providerIdForModel("local-model", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when model.required_env is undefined", () => {
|
||||
const models = [{ id: "local-model", name: "Local Model" }] as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
required_env?: string[];
|
||||
}>;
|
||||
expect(providerIdForModel("local-model", models)).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts and joins multiple required_env alphabetically", () => {
|
||||
const models = [
|
||||
{ id: "openrouter", name: "OpenRouter", required_env: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] },
|
||||
];
|
||||
// Expected: alphabetically sorted = ANTHROPIC_API_KEY|OPENAI_API_KEY
|
||||
expect(providerIdForModel("openrouter", models)).toBe("ANTHROPIC_API_KEY|OPENAI_API_KEY");
|
||||
});
|
||||
});
|
||||
75
canvas/src/components/__tests__/createMessage.test.ts
Normal file
75
canvas/src/components/__tests__/createMessage.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for createMessage — the ChatMessage factory from types.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createMessage } from "../tabs/chat/types";
|
||||
|
||||
describe("createMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Freeze time so timestamp is deterministic.
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
|
||||
// Stub crypto.randomUUID so message IDs are deterministic.
|
||||
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates a message with the correct role", () => {
|
||||
const userMsg = createMessage("user", "hello");
|
||||
expect(userMsg.role).toBe("user");
|
||||
|
||||
const agentMsg = createMessage("agent", "hi there");
|
||||
expect(agentMsg.role).toBe("agent");
|
||||
|
||||
const systemMsg = createMessage("system", "prompt loaded");
|
||||
expect(systemMsg.role).toBe("system");
|
||||
});
|
||||
|
||||
it("creates a message with the correct content", () => {
|
||||
const msg = createMessage("user", "Deploy the agent now");
|
||||
expect(msg.content).toBe("Deploy the agent now");
|
||||
});
|
||||
|
||||
it("sets a deterministic id via crypto.randomUUID", () => {
|
||||
const msg = createMessage("agent", "response");
|
||||
expect(msg.id).toBe("fixed-uuid-1234");
|
||||
});
|
||||
|
||||
it("sets a deterministic ISO timestamp", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits attachments field when none provided", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits attachments field when empty array is provided", () => {
|
||||
const msg = createMessage("agent", "result", []);
|
||||
expect(msg.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes attachments field when non-empty array is provided", () => {
|
||||
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
|
||||
const msg = createMessage("agent", "see attached", atts);
|
||||
expect(msg.attachments).toEqual(atts);
|
||||
});
|
||||
|
||||
it("returns a frozen object (prevents accidental mutation)", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.isFrozen(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a plain object with expected keys", () => {
|
||||
const msg = createMessage("user", "hello");
|
||||
expect(Object.keys(msg).sort()).toEqual(
|
||||
["id", "role", "content", "timestamp"].sort()
|
||||
);
|
||||
});
|
||||
});
|
||||
104
canvas/src/components/__tests__/getIcon.test.ts
Normal file
104
canvas/src/components/__tests__/getIcon.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for getIcon — the pure icon-selector from FilesTab/tree.ts.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getIcon } from "../tabs/FilesTab/tree";
|
||||
|
||||
describe("getIcon", () => {
|
||||
// ─── Directories ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📁 for directories regardless of extension", () => {
|
||||
expect(getIcon("src", true)).toBe("📁");
|
||||
expect(getIcon("node_modules", true)).toBe("📁");
|
||||
expect(getIcon(".claude", true)).toBe("📁");
|
||||
expect(getIcon("foo/bar/baz", true)).toBe("📁");
|
||||
});
|
||||
|
||||
it("returns 📁 even for paths that look like files", () => {
|
||||
expect(getIcon("foo.txt", true)).toBe("📁");
|
||||
expect(getIcon("script.sh", true)).toBe("📁");
|
||||
});
|
||||
|
||||
// ─── Files by extension ────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for .md files", () => {
|
||||
expect(getIcon("README.md", false)).toBe("📄");
|
||||
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
|
||||
expect(getIcon("docs/guide.md", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns ⚙ for .yaml and .yml files", () => {
|
||||
expect(getIcon("config.yaml", false)).toBe("⚙");
|
||||
expect(getIcon("values.yml", false)).toBe("⚙");
|
||||
expect(getIcon("deploy.yaml", false)).toBe("⚙");
|
||||
});
|
||||
|
||||
it("returns 🐍 for .py files", () => {
|
||||
expect(getIcon("main.py", false)).toBe("🐍");
|
||||
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
|
||||
});
|
||||
|
||||
it("returns 💠 for .ts and .tsx files", () => {
|
||||
expect(getIcon("index.ts", false)).toBe("💠");
|
||||
expect(getIcon("Component.tsx", false)).toBe("💠");
|
||||
expect(getIcon("types.d.ts", false)).toBe("💠");
|
||||
});
|
||||
|
||||
it("returns 📜 for .js files", () => {
|
||||
expect(getIcon("bundle.js", false)).toBe("📜");
|
||||
expect(getIcon("src/index.js", false)).toBe("📜");
|
||||
});
|
||||
|
||||
it("returns {} for .json files", () => {
|
||||
expect(getIcon("package.json", false)).toBe("{}");
|
||||
expect(getIcon("config.json", false)).toBe("{}");
|
||||
});
|
||||
|
||||
it("returns 🌐 for .html files", () => {
|
||||
expect(getIcon("index.html", false)).toBe("🌐");
|
||||
expect(getIcon("templates/page.html", false)).toBe("🌐");
|
||||
});
|
||||
|
||||
it("returns 🎨 for .css files", () => {
|
||||
expect(getIcon("style.css", false)).toBe("🎨");
|
||||
expect(getIcon("src/app.css", false)).toBe("🎨");
|
||||
});
|
||||
|
||||
it("returns ▸ for .sh files", () => {
|
||||
expect(getIcon("deploy.sh", false)).toBe("▸");
|
||||
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Fallback ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns 📄 for unknown extensions", () => {
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("notes.txt", false)).toBe("📄");
|
||||
expect(getIcon("archive.tar.gz", false)).toBe("📄");
|
||||
});
|
||||
|
||||
it("returns 📄 for paths with no extension", () => {
|
||||
expect(getIcon("Makefile", false)).toBe("📄");
|
||||
expect(getIcon("README", false)).toBe("📄");
|
||||
expect(getIcon("Dockerfile", false)).toBe("📄");
|
||||
});
|
||||
|
||||
// ─── Case sensitivity ──────────────────────────────────────────────────────
|
||||
|
||||
it("is case-insensitive for extension lookup", () => {
|
||||
expect(getIcon("image.PNG", false)).toBe("📄");
|
||||
expect(getIcon("data.JSON", false)).toBe("{}");
|
||||
expect(getIcon("script.SH", false)).toBe("▸");
|
||||
});
|
||||
|
||||
// ─── Nested paths ─────────────────────────────────────────────────────────
|
||||
|
||||
it("uses the leaf extension for nested paths", () => {
|
||||
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
|
||||
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
|
||||
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
|
||||
});
|
||||
});
|
||||
313
canvas/src/components/tabs/config/__tests__/yaml-utils.test.ts
Normal file
313
canvas/src/components/tabs/config/__tests__/yaml-utils.test.ts
Normal file
@ -0,0 +1,313 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for yaml-utils.ts — parseYaml and toYaml pure functions.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseYaml, toYaml } from "../yaml-utils";
|
||||
import type { ConfigData } from "../form-inputs";
|
||||
|
||||
const FULL_CONFIG: ConfigData = {
|
||||
name: "my-agent",
|
||||
description: "A helpful assistant",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
model: "claude-4-7",
|
||||
runtime: "claude-code",
|
||||
runtime_config: { model: "claude-4-7", required_env: ["ANTHROPIC_API_KEY"], timeout: 120 },
|
||||
effort: "medium",
|
||||
task_budget: 100,
|
||||
prompt_files: ["system.md"],
|
||||
skills: ["web-search", "code"],
|
||||
tools: ["bash"],
|
||||
a2a: { port: 8000, streaming: true, push_notifications: true },
|
||||
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
|
||||
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
|
||||
};
|
||||
|
||||
const MINIMAL_CONFIG: ConfigData = {
|
||||
name: "",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
tier: 1,
|
||||
model: "",
|
||||
runtime: "",
|
||||
prompt_files: [],
|
||||
skills: [],
|
||||
tools: [],
|
||||
a2a: { port: 8000, streaming: true, push_notifications: true },
|
||||
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
|
||||
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
|
||||
};
|
||||
|
||||
// ─── parseYaml ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseYaml", () => {
|
||||
it("returns empty object for empty input", () => {
|
||||
expect(parseYaml("")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for blank lines only", () => {
|
||||
expect(parseYaml("\n\n \n")).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for comment-only input", () => {
|
||||
expect(parseYaml("# hello\n# world")).toEqual({});
|
||||
});
|
||||
|
||||
it("parses simple key-value pairs", () => {
|
||||
const result = parseYaml("name: hello\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("trims whitespace around values", () => {
|
||||
const result = parseYaml("name: hello \nversion: 1.0 ");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("parses boolean true", () => {
|
||||
expect(parseYaml("streaming: true")).toEqual({ streaming: true });
|
||||
});
|
||||
|
||||
it("parses boolean false", () => {
|
||||
expect(parseYaml("streaming: false")).toEqual({ streaming: false });
|
||||
});
|
||||
|
||||
it("parses integer numbers", () => {
|
||||
expect(parseYaml("port: 8000\ntimeout: 120")).toEqual({ port: 8000, timeout: 120 });
|
||||
});
|
||||
|
||||
it("parses string values that look like numbers", () => {
|
||||
// Keys that have no space before colon would have been parsed as numbers
|
||||
// but since the YAML has `key: value` format, it should be string
|
||||
expect(parseYaml("model: claude-4-7")).toEqual({ model: "claude-4-7" });
|
||||
});
|
||||
|
||||
it("parses a top-level list", () => {
|
||||
const result = parseYaml("skills:\n - web-search\n - code");
|
||||
expect(result).toEqual({ skills: ["web-search", "code"] });
|
||||
});
|
||||
|
||||
it("parses a top-level object", () => {
|
||||
const result = parseYaml("a2a:\n port: 8000\n streaming: true");
|
||||
expect(result).toEqual({ a2a: { port: 8000, streaming: true } });
|
||||
});
|
||||
|
||||
it("skips blank lines within content", () => {
|
||||
const result = parseYaml("name: hello\n\nversion: 1.0\n\n");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("skips comment lines within content", () => {
|
||||
const result = parseYaml("name: hello\n# this is a comment\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
|
||||
it("parses a 2-level nested list (env.required pattern)", () => {
|
||||
const result = parseYaml("env:\n required:\n - ANTHROPIC_API_KEY\n - OPENAI_API_KEY");
|
||||
expect(result).toEqual({ env: { required: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] } });
|
||||
});
|
||||
|
||||
it("parses empty list marker `[]`", () => {
|
||||
const result = parseYaml("prompt_files: []");
|
||||
expect(result).toEqual({ prompt_files: [] });
|
||||
});
|
||||
|
||||
it("handles multiple mixed structures in one document", () => {
|
||||
const yaml = `name: test-agent
|
||||
version: 1.0.0
|
||||
tier: 4
|
||||
runtime: claude-code
|
||||
skills:
|
||||
- web-search
|
||||
a2a:
|
||||
port: 8000
|
||||
streaming: true`;
|
||||
const result = parseYaml(yaml);
|
||||
expect(result).toEqual({
|
||||
name: "test-agent",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
runtime: "claude-code",
|
||||
skills: ["web-search"],
|
||||
a2a: { port: 8000, streaming: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrecognised top-level lines as-is (skipped)", () => {
|
||||
// Lines that don't match the pattern are skipped
|
||||
const result = parseYaml("name: hello\n[invalid line]\nversion: 1.0");
|
||||
expect(result).toEqual({ name: "hello", version: "1.0" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── toYaml ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("toYaml", () => {
|
||||
it("produces output for minimal config (required fields only)", () => {
|
||||
const out = toYaml(MINIMAL_CONFIG);
|
||||
// skills: [] and tools: [] are always emitted
|
||||
expect(out).toContain("version: 1.0.0");
|
||||
expect(out).toContain("tier: 1");
|
||||
expect(out).toContain("skills: []");
|
||||
expect(out).toContain("tools: []");
|
||||
expect(out).toContain("a2a:");
|
||||
expect(out).toContain("delegation:");
|
||||
expect(out).toContain("sandbox:");
|
||||
});
|
||||
|
||||
it("writes name and description fields", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, name: "my-agent", description: "desc" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("name: my-agent");
|
||||
expect(out).toContain("description: desc");
|
||||
});
|
||||
|
||||
it("writes version and tier", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, tier: 4 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("version: 1.0.0");
|
||||
expect(out).toContain("tier: 4");
|
||||
});
|
||||
|
||||
it("writes runtime with a blank line separator before it", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("runtime: claude-code");
|
||||
});
|
||||
|
||||
it("writes runtime_config as a nested block", () => {
|
||||
const cfg: ConfigData = {
|
||||
...MINIMAL_CONFIG,
|
||||
runtime: "claude-code",
|
||||
runtime_config: { model: "claude-4-7", required_env: ["KEY"], timeout: 120 },
|
||||
};
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("runtime_config:");
|
||||
expect(out).toContain(" model: claude-4-7");
|
||||
expect(out).toContain(" required_env:");
|
||||
expect(out).toContain(" - KEY");
|
||||
expect(out).toContain(" timeout: 120");
|
||||
});
|
||||
|
||||
it("omits runtime_config when empty", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
|
||||
const out = toYaml(cfg);
|
||||
// runtime_config key should not appear
|
||||
expect(out).not.toContain("runtime_config:");
|
||||
});
|
||||
|
||||
it("writes effort when set", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "high" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("effort: high");
|
||||
});
|
||||
|
||||
it("omits effort when empty string", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "" };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("effort:");
|
||||
});
|
||||
|
||||
it("writes task_budget when positive", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 100 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("task_budget: 100");
|
||||
});
|
||||
|
||||
it("omits task_budget when zero", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 0 };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("task_budget:");
|
||||
});
|
||||
|
||||
it("writes prompt_files as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, prompt_files: ["system.md", "ethics.md"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("prompt_files:");
|
||||
expect(out).toContain(" - system.md");
|
||||
expect(out).toContain(" - ethics.md");
|
||||
});
|
||||
|
||||
it("writes skills as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, skills: ["web-search", "code"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("skills:");
|
||||
expect(out).toContain(" - web-search");
|
||||
expect(out).toContain(" - code");
|
||||
});
|
||||
|
||||
it("writes tools as a list block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, tools: ["bash", "read"] };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("tools:");
|
||||
expect(out).toContain(" - bash");
|
||||
expect(out).toContain(" - read");
|
||||
});
|
||||
|
||||
it("writes a2a as a nested block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, a2a: { port: 9000, streaming: false, push_notifications: false } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("a2a:");
|
||||
expect(out).toContain(" port: 9000");
|
||||
expect(out).toContain(" streaming: false");
|
||||
expect(out).toContain(" push_notifications: false");
|
||||
});
|
||||
|
||||
it("writes delegation as a nested block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, delegation: { retry_attempts: 5, retry_delay: 10, timeout: 60, escalate: false } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("delegation:");
|
||||
expect(out).toContain(" retry_attempts: 5");
|
||||
expect(out).toContain(" retry_delay: 10");
|
||||
expect(out).toContain(" timeout: 60");
|
||||
expect(out).toContain(" escalate: false");
|
||||
});
|
||||
|
||||
it("writes sandbox backend block", () => {
|
||||
const cfg: ConfigData = { ...MINIMAL_CONFIG, sandbox: { backend: "aws-lambda", memory_limit: "512m", timeout: 15 } };
|
||||
const out = toYaml(cfg);
|
||||
expect(out).toContain("sandbox:");
|
||||
expect(out).toContain(" backend: aws-lambda");
|
||||
expect(out).toContain(" memory_limit: 512m");
|
||||
expect(out).toContain(" timeout: 15");
|
||||
});
|
||||
|
||||
it("omits empty/null/undefined fields entirely", () => {
|
||||
const cfg: ConfigData = {
|
||||
...MINIMAL_CONFIG,
|
||||
name: "test",
|
||||
model: "", // omitted
|
||||
description: "", // omitted
|
||||
};
|
||||
const out = toYaml(cfg);
|
||||
expect(out).not.toContain("model:");
|
||||
expect(out).not.toContain("description:");
|
||||
expect(out).toContain("name: test");
|
||||
});
|
||||
|
||||
it("produces a trailing newline", () => {
|
||||
const out = toYaml(MINIMAL_CONFIG);
|
||||
expect(out.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips FULL_CONFIG through parse → toYaml → parse", () => {
|
||||
// parseYaml produces plain Record, so a2a/delegation/sandbox
|
||||
// come out as objects — toYaml handles them via the cast.
|
||||
const round = parseYaml(toYaml(FULL_CONFIG));
|
||||
expect(round).toMatchObject({
|
||||
name: "my-agent",
|
||||
description: "A helpful assistant",
|
||||
version: "1.0.0",
|
||||
tier: 4,
|
||||
runtime: "claude-code",
|
||||
effort: "medium",
|
||||
task_budget: 100,
|
||||
prompt_files: ["system.md"],
|
||||
skills: ["web-search", "code"],
|
||||
tools: ["bash"],
|
||||
});
|
||||
expect(round.a2a).toMatchObject({ port: 8000, streaming: true, push_notifications: true });
|
||||
expect(round.delegation).toMatchObject({ retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true });
|
||||
expect(round.sandbox).toMatchObject({ backend: "docker", memory_limit: "256m", timeout: 30 });
|
||||
});
|
||||
});
|
||||
67
canvas/src/lib/__tests__/cssVar.test.ts
Normal file
67
canvas/src/lib/__tests__/cssVar.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for cssVar — maps ColorToken to a CSS variable string.
|
||||
*
|
||||
* Exists for the rare case where an inline style="" or SVG fill needs
|
||||
* a token value rather than a Tailwind class. The returned var(--color-foo)
|
||||
* string follows the live theme without re-renders.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cssVar } from "../theme";
|
||||
import type { ColorToken } from "../theme";
|
||||
|
||||
describe("cssVar", () => {
|
||||
it("returns 'var(--color-surface)' for 'surface'", () => {
|
||||
expect(cssVar("surface")).toBe("var(--color-surface)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-ink)' for 'ink'", () => {
|
||||
expect(cssVar("ink")).toBe("var(--color-ink)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-accent)' for 'accent'", () => {
|
||||
expect(cssVar("accent")).toBe("var(--color-accent)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-good)' for 'good'", () => {
|
||||
expect(cssVar("good")).toBe("var(--color-good)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-bad)' for 'bad'", () => {
|
||||
expect(cssVar("bad")).toBe("var(--color-bad)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-warn)' for 'warn'", () => {
|
||||
expect(cssVar("warn")).toBe("var(--color-warn)");
|
||||
});
|
||||
|
||||
it("handles all surface variants", () => {
|
||||
const surfaces: ColorToken[] = ["surface", "surface-elevated", "surface-sunken", "surface-card"];
|
||||
for (const t of surfaces) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles all ink variants", () => {
|
||||
const inks: ColorToken[] = ["ink", "ink-mid", "ink-soft", "ink-mute", "ink-dim"];
|
||||
for (const t of inks) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles always-dark tokens", () => {
|
||||
const dark: ColorToken[] = ["bg", "bg-elev", "bg-card", "line-strong", "accent-dim", "plasma"];
|
||||
for (const t of dark) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a pure function — same input always returns same output", () => {
|
||||
const tokens: ColorToken[] = ["surface", "accent", "good", "bad", "warm"];
|
||||
for (const t of tokens) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
78
canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts
Normal file
78
canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for resolveRuntime — the template-id → runtime-name mapper in deploy-preflight.ts.
|
||||
*
|
||||
* Lives in lib/__tests__/ alongside deploy-preflight.test.ts so the
|
||||
* two share the same describe block convention and the fixture types
|
||||
* are close at hand. Separate file keeps the deploy-preflight fixture
|
||||
* count bounded.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveRuntime } from "../deploy-preflight";
|
||||
|
||||
describe("resolveRuntime", () => {
|
||||
describe("explicit runtime-map entries", () => {
|
||||
it('maps "langgraph" to "langgraph"', () => {
|
||||
expect(resolveRuntime("langgraph")).toBe("langgraph");
|
||||
});
|
||||
|
||||
it('maps "claude-code-default" to "claude-code"', () => {
|
||||
expect(resolveRuntime("claude-code-default")).toBe("claude-code");
|
||||
});
|
||||
|
||||
it('maps "openclaw" to "openclaw"', () => {
|
||||
expect(resolveRuntime("openclaw")).toBe("openclaw");
|
||||
});
|
||||
|
||||
it('maps "deepagents" to "deepagents"', () => {
|
||||
expect(resolveRuntime("deepagents")).toBe("deepagents");
|
||||
});
|
||||
|
||||
it('maps "crewai" to "crewai"', () => {
|
||||
expect(resolveRuntime("crewai")).toBe("crewai");
|
||||
});
|
||||
|
||||
it('maps "autogen" to "autogen"', () => {
|
||||
expect(resolveRuntime("autogen")).toBe("autogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity fallback for modern template ids", () => {
|
||||
it("returns the id unchanged when not in the map", () => {
|
||||
expect(resolveRuntime("hermes")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips trailing -default suffix as fallback", () => {
|
||||
expect(resolveRuntime("hermes-default")).toBe("hermes");
|
||||
});
|
||||
|
||||
it("strips -default only when it is the suffix", () => {
|
||||
// "default-something" should NOT strip
|
||||
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
|
||||
});
|
||||
|
||||
it("returns the id unchanged when id has no -default suffix", () => {
|
||||
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
|
||||
});
|
||||
|
||||
it("handles custom template ids from community templates", () => {
|
||||
expect(resolveRuntime("my-custom-template")).toBe("my-custom-template");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty string", () => {
|
||||
// Falls through to the replace branch
|
||||
expect(resolveRuntime("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles id that is just '-default'", () => {
|
||||
expect(resolveRuntime("-default")).toBe("");
|
||||
});
|
||||
|
||||
it("multiple -default suffixes only strips the last one", () => {
|
||||
// The JS replace only replaces the first match by default
|
||||
expect(resolveRuntime("claude-code-default-default")).toBe("claude-code-default");
|
||||
});
|
||||
});
|
||||
});
|
||||
89
canvas/src/lib/__tests__/runtimeProfiles.test.ts
Normal file
89
canvas/src/lib/__tests__/runtimeProfiles.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for runtimeProfiles.ts — getRuntimeProfile and provisionTimeoutForRuntime.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRuntimeProfile,
|
||||
provisionTimeoutForRuntime,
|
||||
DEFAULT_RUNTIME_PROFILE,
|
||||
RUNTIME_PROFILES,
|
||||
} from "../runtimeProfiles";
|
||||
|
||||
describe("getRuntimeProfile", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is undefined and no overrides", () => {
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when runtime is empty string", () => {
|
||||
const result = getRuntimeProfile("");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("falls back to DEFAULT_RUNTIME_PROFILE for an unknown runtime", () => {
|
||||
const result = getRuntimeProfile("unknown-lang");
|
||||
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
|
||||
});
|
||||
|
||||
it("returns DEFAULT_RUNTIME_PROFILE when RUNTIME_PROFILES is empty (current state)", () => {
|
||||
// RUNTIME_PROFILES is currently {} — verify the empty-map path works
|
||||
expect(RUNTIME_PROFILES).toEqual({});
|
||||
const result = getRuntimeProfile("claude-code");
|
||||
expect(result.provisionTimeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
it("uses overrides.provisionTimeoutMs when provided (highest priority)", () => {
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 300_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(300_000);
|
||||
});
|
||||
|
||||
it("overrides wins over RUNTIME_PROFILES entry", () => {
|
||||
// Even if RUNTIME_PROFILES had an entry, overrides take priority
|
||||
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 999_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(999_000);
|
||||
});
|
||||
|
||||
it("uses overrides even when runtime is undefined", () => {
|
||||
const result = getRuntimeProfile(undefined, { provisionTimeoutMs: 60_000 });
|
||||
expect(result.provisionTimeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("returns Required<Pick> — always has provisionTimeoutMs", () => {
|
||||
// The return type is guaranteed non-nullable
|
||||
const result = getRuntimeProfile(undefined);
|
||||
expect(typeof result.provisionTimeoutMs).toBe("number");
|
||||
expect(result.provisionTimeoutMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisionTimeoutForRuntime", () => {
|
||||
it("returns DEFAULT_RUNTIME_PROFILE value when no runtime or overrides", () => {
|
||||
expect(provisionTimeoutForRuntime(undefined)).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("returns overrides value when overrides provided", () => {
|
||||
expect(provisionTimeoutForRuntime("claude-code", { provisionTimeoutMs: 90_000 })).toBe(90_000);
|
||||
});
|
||||
|
||||
it("returns 120_000 for any unknown runtime", () => {
|
||||
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
|
||||
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
|
||||
});
|
||||
|
||||
it("convenience: same as getRuntimeProfile().provisionTimeoutMs", () => {
|
||||
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
|
||||
[undefined, undefined],
|
||||
["claude-code", undefined],
|
||||
["langgraph", { provisionTimeoutMs: 500_000 }],
|
||||
[undefined, { provisionTimeoutMs: 45_000 }],
|
||||
];
|
||||
for (const [runtime, overrides] of cases) {
|
||||
const profile = getRuntimeProfile(runtime, overrides);
|
||||
const direct = provisionTimeoutForRuntime(runtime, overrides);
|
||||
expect(direct).toBe(profile.provisionTimeoutMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
106
canvas/src/lib/__tests__/statusDotClass.test.ts
Normal file
106
canvas/src/lib/__tests__/statusDotClass.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for statusDotClass — maps a workspace status string to the
|
||||
* CSS tailwind class used on the status indicator dot.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { statusDotClass, TIER_CONFIG, COMM_TYPE_LABELS } from "../design-tokens";
|
||||
|
||||
describe("statusDotClass", () => {
|
||||
it('returns "bg-emerald-400" for "online"', () => {
|
||||
expect(statusDotClass("online")).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it('returns "bg-zinc-500" for "offline"', () => {
|
||||
expect(statusDotClass("offline")).toBe("bg-zinc-500");
|
||||
});
|
||||
|
||||
it('returns "bg-indigo-400" for "paused"', () => {
|
||||
expect(statusDotClass("paused")).toBe("bg-indigo-400");
|
||||
});
|
||||
|
||||
it('returns "bg-amber-400" for "degraded"', () => {
|
||||
expect(statusDotClass("degraded")).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it('returns "bg-red-400" for "failed"', () => {
|
||||
expect(statusDotClass("failed")).toBe("bg-red-400");
|
||||
});
|
||||
|
||||
it('returns "bg-sky-400 motion-safe:animate-pulse" for "provisioning"', () => {
|
||||
expect(statusDotClass("provisioning")).toBe("bg-sky-400 motion-safe:animate-pulse");
|
||||
});
|
||||
|
||||
it('returns "bg-amber-300" for "not_configured"', () => {
|
||||
expect(statusDotClass("not_configured")).toBe("bg-amber-300");
|
||||
});
|
||||
|
||||
it("falls back to bg-zinc-500 for unknown status strings", () => {
|
||||
expect(statusDotClass("unknown")).toBe("bg-zinc-500");
|
||||
expect(statusDotClass("")).toBe("bg-zinc-500");
|
||||
expect(statusDotClass("ONLINE")).toBe("bg-zinc-500"); // case-sensitive
|
||||
expect(statusDotClass(" online")).toBe("bg-zinc-500"); // whitespace-sensitive
|
||||
expect(statusDotClass("online\n")).toBe("bg-zinc-500");
|
||||
});
|
||||
|
||||
it("is a pure function — same input always returns same output", () => {
|
||||
const result = statusDotClass("online");
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(statusDotClass("online")).toBe(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── TIER_CONFIG ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TIER_CONFIG", () => {
|
||||
it("has entries for all four tier levels", () => {
|
||||
expect(TIER_CONFIG).toHaveProperty(1);
|
||||
expect(TIER_CONFIG).toHaveProperty(2);
|
||||
expect(TIER_CONFIG).toHaveProperty(3);
|
||||
expect(TIER_CONFIG).toHaveProperty(4);
|
||||
});
|
||||
|
||||
it("each tier has label, color, and border fields", () => {
|
||||
for (const tier of [1, 2, 3, 4]) {
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("label");
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("color");
|
||||
expect(TIER_CONFIG[tier]).toHaveProperty("border");
|
||||
}
|
||||
});
|
||||
|
||||
it("tier labels match expected values", () => {
|
||||
expect(TIER_CONFIG[1].label).toBe("T1");
|
||||
expect(TIER_CONFIG[2].label).toBe("T2");
|
||||
expect(TIER_CONFIG[3].label).toBe("T3");
|
||||
expect(TIER_CONFIG[4].label).toBe("T4");
|
||||
});
|
||||
|
||||
it("is immutable at runtime — same key always returns same shape", () => {
|
||||
const result = TIER_CONFIG[2];
|
||||
expect(TIER_CONFIG[2]).toBe(result);
|
||||
});
|
||||
});
|
||||
|
||||
// ── COMM_TYPE_LABELS ────────────────────────────────────────────────────────
|
||||
|
||||
describe("COMM_TYPE_LABELS", () => {
|
||||
it("has labels for all known communication types", () => {
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_send");
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_receive");
|
||||
expect(COMM_TYPE_LABELS).toHaveProperty("task_update");
|
||||
});
|
||||
|
||||
it("labels are non-empty strings", () => {
|
||||
for (const key of Object.keys(COMM_TYPE_LABELS)) {
|
||||
expect(typeof COMM_TYPE_LABELS[key]).toBe("string");
|
||||
expect(COMM_TYPE_LABELS[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a static map — same key always returns same label", () => {
|
||||
expect(COMM_TYPE_LABELS["a2a_send"]).toBe("sent");
|
||||
expect(COMM_TYPE_LABELS["a2a_receive"]).toBe("received");
|
||||
expect(COMM_TYPE_LABELS["task_update"]).toBe("task update");
|
||||
});
|
||||
});
|
||||
47
canvas/src/lib/__tests__/theme-cookie.test.ts
Normal file
47
canvas/src/lib/__tests__/theme-cookie.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for readThemeCookie — parses a cookie value into a ThemePreference.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readThemeCookie } from "../theme-cookie";
|
||||
|
||||
describe("readThemeCookie", () => {
|
||||
it('returns "light" when cookie value is "light"', () => {
|
||||
expect(readThemeCookie("light")).toBe("light");
|
||||
});
|
||||
|
||||
it('returns "dark" when cookie value is "dark"', () => {
|
||||
expect(readThemeCookie("dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it('returns "system" when cookie value is "system"', () => {
|
||||
expect(readThemeCookie("system")).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for undefined', () => {
|
||||
expect(readThemeCookie(undefined)).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for empty string', () => {
|
||||
expect(readThemeCookie("")).toBe("system");
|
||||
});
|
||||
|
||||
it('returns "system" for any non-matching value', () => {
|
||||
expect(readThemeCookie("auto")).toBe("system");
|
||||
expect(readThemeCookie("dark-mode")).toBe("system");
|
||||
expect(readThemeCookie("DARK")).toBe("system"); // case-sensitive
|
||||
expect(readThemeCookie("light\n")).toBe("system"); // whitespace-sensitive
|
||||
expect(readThemeCookie(" system ")).toBe("system");
|
||||
expect(readThemeCookie("null")).toBe("system");
|
||||
expect(readThemeCookie("0")).toBe("system");
|
||||
});
|
||||
|
||||
it("is pure — same input always returns same output", () => {
|
||||
const inputs = ["light", "dark", "system", undefined, ""];
|
||||
for (const input of inputs) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(readThemeCookie(input)).toBe(readThemeCookie(input));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
134
canvas/src/lib/__tests__/ws-url.test.ts
Normal file
134
canvas/src/lib/__tests__/ws-url.test.ts
Normal file
@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for deriveWsBaseUrl — WebSocket base URL derivation from env / window.location.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { deriveWsBaseUrl } from "../ws-url";
|
||||
|
||||
const ORIGINAL_WS = process.env.NEXT_PUBLIC_WS_URL;
|
||||
const ORIGINAL_PLATFORM = process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "");
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (ORIGINAL_WS !== undefined) vi.stubEnv("NEXT_PUBLIC_WS_URL", ORIGINAL_WS);
|
||||
else delete process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (ORIGINAL_PLATFORM !== undefined) vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", ORIGINAL_PLATFORM);
|
||||
else delete process.env.NEXT_PUBLIC_PLATFORM_URL;
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — NEXT_PUBLIC_WS_URL (priority 1)", () => {
|
||||
it("uses NEXT_PUBLIC_WS_URL when set", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("strips trailing /ws suffix from NEXT_PUBLIC_WS_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("uses ws:// for HTTP NEXT_PUBLIC_WS_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "ws://localhost:8080/ws");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
|
||||
});
|
||||
|
||||
it("wins over NEXT_PUBLIC_PLATFORM_URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform.example.com");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
|
||||
it("wins over window.location", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — NEXT_PUBLIC_PLATFORM_URL (priority 2)", () => {
|
||||
it("derives ws:// from http:// platform URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:8080");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
|
||||
});
|
||||
|
||||
it("derives wss:// from https:// platform URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
|
||||
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
|
||||
});
|
||||
|
||||
it("preserves non-standard ports", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:9000");
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:9000");
|
||||
});
|
||||
|
||||
it("wins over window.location", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — window.location (priority 3)", () => {
|
||||
it("uses wss:// when page is served over HTTPS", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com");
|
||||
});
|
||||
|
||||
it("uses ws:// when page is served over HTTP", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "http:", host: "localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
|
||||
});
|
||||
|
||||
it("includes the host with port", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "https:", host: "canvas.example.com:8443" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com:8443");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — fallback (priority 4)", () => {
|
||||
it("falls back to localhost when no env vars or window is unavailable", () => {
|
||||
// process.env is empty (already stubbed), window is not stubbed but we
|
||||
// can't remove it entirely in jsdom — the function checks typeof window
|
||||
// which is always defined. Since we have no env vars, it falls through
|
||||
// to the window branch; we test the final fallback by stubbing window
|
||||
// location to undefined (not possible in jsdom — skip this edge case).
|
||||
// The test below verifies the no-env-var path works.
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { protocol: "http:", host: "localhost:3000" },
|
||||
writable: true,
|
||||
});
|
||||
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWsBaseUrl — protocol derivation", () => {
|
||||
it("derives ws:// from http:// and keeps it", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform:8080");
|
||||
expect(deriveWsBaseUrl()).toMatch(/^ws:/);
|
||||
});
|
||||
|
||||
it("derives wss:// from https:// and keeps it", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform:8080");
|
||||
expect(deriveWsBaseUrl()).toMatch(/^wss:/);
|
||||
});
|
||||
});
|
||||
251
canvas/src/store/__tests__/canvas-topology-pure.test.ts
Normal file
251
canvas/src/store/__tests__/canvas-topology-pure.test.ts
Normal file
@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for pure utility functions in canvas-topology.ts:
|
||||
* sortParentsBeforeChildren, defaultChildSlot, childSlotInGrid,
|
||||
* parentMinSize, parentMinSizeFromChildren.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sortParentsBeforeChildren,
|
||||
defaultChildSlot,
|
||||
childSlotInGrid,
|
||||
parentMinSize,
|
||||
parentMinSizeFromChildren,
|
||||
} from "../canvas-topology";
|
||||
|
||||
// ─── sortParentsBeforeChildren ─────────────────────────────────────────────────
|
||||
|
||||
describe("sortParentsBeforeChildren", () => {
|
||||
it("returns [] for empty input", () => {
|
||||
expect(sortParentsBeforeChildren([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single node unchanged", () => {
|
||||
const nodes = [{ id: "a", parentId: undefined }];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("places parent before child", () => {
|
||||
// Deliberately reversed so naive iteration would place child first
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("parent");
|
||||
expect(result[1].id).toBe("child");
|
||||
});
|
||||
|
||||
it("places grandparent before parent before child (deep chain)", () => {
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "grandchild", parentId: "child" },
|
||||
{ id: "parent", parentId: "grandparent" },
|
||||
{ id: "grandparent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
const ids = result.map((n) => n.id);
|
||||
expect(ids).toEqual(["grandparent", "parent", "child", "grandchild"]);
|
||||
});
|
||||
|
||||
it("siblings share the same parent", () => {
|
||||
const nodes = [
|
||||
{ id: "b", parentId: "a" },
|
||||
{ id: "a", parentId: undefined },
|
||||
{ id: "c", parentId: "a" },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result[0].id).toBe("a");
|
||||
expect(new Set(result.slice(1).map((n) => n.id))).toEqual(new Set(["b", "c"]));
|
||||
});
|
||||
|
||||
it("no-ops when children already precede parents", () => {
|
||||
// Already sorted — output should be in the same order
|
||||
const nodes = [
|
||||
{ id: "root", parentId: undefined },
|
||||
{ id: "child", parentId: "root" },
|
||||
];
|
||||
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
|
||||
});
|
||||
|
||||
it("handles orphan nodes (no parentId)", () => {
|
||||
const nodes = [{ id: "a" }, { id: "b" }];
|
||||
expect(sortParentsBeforeChildren(nodes).map((n) => n.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns a new array (does not mutate input)", () => {
|
||||
const nodes = [{ id: "child", parentId: "parent" }, { id: "parent", parentId: undefined }];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result).not.toBe(nodes);
|
||||
});
|
||||
|
||||
it("deduplicates already-visited nodes", () => {
|
||||
// Child's parent is also in the list — visited guard prevents loops
|
||||
const nodes = [
|
||||
{ id: "child", parentId: "parent" },
|
||||
{ id: "parent", parentId: undefined },
|
||||
];
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["parent", "child"]);
|
||||
});
|
||||
|
||||
it("does not crash when parentId references a missing node", () => {
|
||||
const nodes = [
|
||||
{ id: "orphan", parentId: "ghost" },
|
||||
{ id: "root", parentId: undefined },
|
||||
];
|
||||
// Missing parent is skipped; orphan placed after root
|
||||
const result = sortParentsBeforeChildren(nodes);
|
||||
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── defaultChildSlot ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("defaultChildSlot — 2-column grid (240×130 cards)", () => {
|
||||
it("slot 0 → column 0, row 0", () => {
|
||||
const s = defaultChildSlot(0);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 1 → column 1, row 0", () => {
|
||||
const s = defaultChildSlot(1);
|
||||
expect(s.x).toBe(16 + 240 + 14); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("slot 2 → column 0, row 1", () => {
|
||||
const s = defaultChildSlot(2);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + 130 + 14); // row 0 height + gutter
|
||||
});
|
||||
|
||||
it("slot 3 → column 1, row 1", () => {
|
||||
const s = defaultChildSlot(3);
|
||||
expect(s.x).toBe(16 + 240 + 14);
|
||||
expect(s.y).toBe(130 + 130 + 14);
|
||||
});
|
||||
|
||||
it("slot 4 → column 0, row 2", () => {
|
||||
const s = defaultChildSlot(4);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + (130 + 14) * 2); // row 1 end + gutter
|
||||
});
|
||||
});
|
||||
|
||||
// ─── childSlotInGrid ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("childSlotInGrid — variable-size siblings", () => {
|
||||
it("empty siblingSizes returns side-padded position", () => {
|
||||
const s = childSlotInGrid(0, []);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
});
|
||||
|
||||
it("slot 0 in uniform-size siblings matches defaultChildSlot", () => {
|
||||
const sizes = [{ width: 240, height: 130 }, { width: 240, height: 130 }];
|
||||
const s = childSlotInGrid(0, sizes);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130);
|
||||
});
|
||||
|
||||
it("taller sibling bumps next row down", () => {
|
||||
// Column width = max(200, 240) = 240; row 0 height = max(300, 130) = 300
|
||||
const sizes = [{ width: 200, height: 300 }, { width: 240, height: 130 }];
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
// Slot 1 is in column 1, row 0; x = 16 + 1*(240+14)
|
||||
expect(slot1.x).toBe(16 + 240 + 14);
|
||||
expect(slot1.y).toBe(130);
|
||||
// Slot 2 (col 0, row 1) — y must include row 0 height + gutter
|
||||
const slot2 = childSlotInGrid(2, sizes);
|
||||
expect(slot2.x).toBe(16);
|
||||
expect(slot2.y).toBe(130 + 300 + 14);
|
||||
});
|
||||
|
||||
it("colW is the maximum sibling width, not the column of the target slot", () => {
|
||||
// Column width is always the max — slot at col 0 uses colW of wider col 1 sibling
|
||||
const sizes = [{ width: 100, height: 100 }, { width: 300, height: 100 }];
|
||||
const slot0 = childSlotInGrid(0, sizes);
|
||||
expect(slot0.x).toBe(16); // col 0
|
||||
// x for col 1 would be 16 + 300 + 14 = 330
|
||||
const slot1 = childSlotInGrid(1, sizes);
|
||||
expect(slot1.x).toBe(16 + 300 + 14);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSize ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSize — uniform-size children", () => {
|
||||
it("0 children → compact default (210×120)", () => {
|
||||
expect(parentMinSize(0)).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("1 child → 1 col, 1 row", () => {
|
||||
const s = parentMinSize(1);
|
||||
// width = 16*2 + 1*240 + 0 = 272; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 240);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("2 children → 2 cols, 1 row", () => {
|
||||
const s = parentMinSize(2);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
});
|
||||
|
||||
it("3 children → 2 cols, 2 rows", () => {
|
||||
const s = parentMinSize(3);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
// height = 130 + 2*130 + 1*14 + 16 = 416
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("4 children → 2 cols, 2 rows (full grid)", () => {
|
||||
const s = parentMinSize(4);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
});
|
||||
|
||||
it("5 children → 2 cols, 3 rows", () => {
|
||||
const s = parentMinSize(5);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 3 * 130 + 2 * 14 + 16);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parentMinSizeFromChildren ────────────────────────────────────────────────
|
||||
|
||||
describe("parentMinSizeFromChildren — variable-size children", () => {
|
||||
it("empty array → compact default (210×120)", () => {
|
||||
expect(parentMinSizeFromChildren([])).toEqual({ width: 210, height: 120 });
|
||||
});
|
||||
|
||||
it("single child matches defaultChildSlot bounding box", () => {
|
||||
const s = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
// cols=1, rows=1, colW=240
|
||||
expect(s.width).toBe(16 * 2 + 240); // 272
|
||||
expect(s.height).toBe(130 + 130 + 16); // 276
|
||||
});
|
||||
|
||||
it("two equal-width children → same as parentMinSize(2)", () => {
|
||||
const fromChildren = parentMinSizeFromChildren([
|
||||
{ width: 240, height: 130 },
|
||||
{ width: 240, height: 130 },
|
||||
]);
|
||||
expect(fromChildren.width).toBe(parentMinSize(2).width);
|
||||
expect(fromChildren.height).toBe(parentMinSize(2).height);
|
||||
});
|
||||
|
||||
it("taller child increases height", () => {
|
||||
const tall = parentMinSizeFromChildren([{ width: 240, height: 400 }]);
|
||||
const short = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
|
||||
expect(tall.height).toBeGreaterThan(short.height);
|
||||
});
|
||||
|
||||
it("wider child increases width", () => {
|
||||
const wide = parentMinSizeFromChildren([{ width: 500, height: 130 }]);
|
||||
const narrow = parentMinSizeFromChildren([{ width: 200, height: 130 }]);
|
||||
expect(wide.width).toBeGreaterThan(narrow.width);
|
||||
});
|
||||
});
|
||||
@ -88,6 +88,7 @@ PR: `fix/ink-soft-wcag-contrast`.
|
||||
- Arrow keys move selected node 10px (50px with Shift) — keyboard node drag (PR #182) ✅
|
||||
- `Cmd/Ctrl+Arrow` resize selected node (↑↓ height, ←→ width, 10px, Shift 2px) ✅
|
||||
- Hierarchy navigation (Enter/Shift+Enter), z-order (Cmd+]/[), zoom-to-team (Z) ✅
|
||||
- Toolbar help dialog ("Shortcuts & tips") documents all shortcuts + mouse interactions ✅
|
||||
|
||||
### Focus Management ✅ (strong)
|
||||
- Skip link → `#canvas-main` ✅
|
||||
|
||||
Loading…
Reference in New Issue
Block a user