From d35403d40211b495d1714336792ef932c45d8985 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 02:54:54 +0000 Subject: [PATCH 01/18] test(canvas): add tests for extractMessageText and providerIdForModel extractMessageText (ConversationTraceModal): MCP task/task format, params.message.parts, result.parts/root.text, plain string result, priority order, error resilience. providerIdForModel (MissingKeysModal): model match, no match, whitespace trimming, undefined models, no required_env, multi-env sort. Also exports extractMessageText from ConversationTraceModal for testing. Co-Authored-By: Claude Opus 4.7 --- .../src/components/ConversationTraceModal.tsx | 4 +- .../__tests__/ConversationTraceModal.test.tsx | 156 ++++++++++++++++++ .../__tests__/MissingKeysModal.test.tsx | 69 ++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 canvas/src/components/__tests__/ConversationTraceModal.test.tsx create mode 100644 canvas/src/components/__tests__/MissingKeysModal.test.tsx diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 41dd9f80..4dfd380f 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -13,7 +13,8 @@ interface Props { onClose: () => void; } -function extractMessageText(body: Record | null): string { +/** Exported for unit testing — see ConversationTraceModal.test.ts */ +export function extractMessageText(body: Record | null): string { if (!body) return ""; try { // Simple task format from MCP server: {task: "..."} @@ -84,6 +85,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos }); }, [open, nodes]); + /** Exported for unit testing — see ConversationTraceModal.test.ts */ const isA2A = (e: ActivityEntry) => e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; diff --git a/canvas/src/components/__tests__/ConversationTraceModal.test.tsx b/canvas/src/components/__tests__/ConversationTraceModal.test.tsx new file mode 100644 index 00000000..39d16a86 --- /dev/null +++ b/canvas/src/components/__tests__/ConversationTraceModal.test.tsx @@ -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"); + }); +}); diff --git a/canvas/src/components/__tests__/MissingKeysModal.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.test.tsx new file mode 100644 index 00000000..23db886f --- /dev/null +++ b/canvas/src/components/__tests__/MissingKeysModal.test.tsx @@ -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"); + }); +}); From 2f9996a88d68ee9a6a44ba3e94999574da9a390c Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 02:58:22 +0000 Subject: [PATCH 02/18] trigger From 71174544ef8b57c8a03aa7a09b49df499041d52a Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 03:29:46 +0000 Subject: [PATCH 03/18] Revert "Re-export extractMessageText for ConversationTraceModal tests" This reverts the JSDoc-comment removal that happened during merge, keeping the function exported so ConversationTraceModal.test.ts can import it. Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/ConversationTraceModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 4dfd380f..63afe664 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -85,7 +85,6 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos }); }, [open, nodes]); - /** Exported for unit testing — see ConversationTraceModal.test.ts */ const isA2A = (e: ActivityEntry) => e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; From 5d8a57026b8b727e3aeba9afb7ac3f9dad101f53 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Sun, 10 May 2026 04:11:18 +0000 Subject: [PATCH 04/18] fix(ci): port publish-workspace-server-image.yml from .github/ to .gitea/workflows/ (issue #228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub Actions workflow is dormant because the GitHub org is suspended. Gitea Actions reads .gitea/workflows/ only, so Dockerfile.tenant changes no longer trigger platform image rebuilds — new tenants get the broken pre-#223 image. Port follows the same pattern as the publish-runtime.yml port (issue #206): - Gitea Actions reads .gitea/workflows/ (drop .github/workflows/ version) - Drop `environment:` declarations (Gitea has no named environments) - Replace `github.ref_name` with `${GITHUB_REF#refs/heads/}` (same variable format available in Gitea runners) - All other vars (GITHUB_SHA, GITHUB_REPOSITORY, secrets.*, GITHUB_OUTPUT) use identical syntax to GitHub Actions - Inline `aws ecr get-login-password | docker login` (same as GitHub version; no GitHub-specific actions needed) Co-Authored-By: Claude Opus 4.7 --- .../publish-workspace-server-image.yml | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 .gitea/workflows/publish-workspace-server-image.yml diff --git a/.gitea/workflows/publish-workspace-server-image.yml b/.gitea/workflows/publish-workspace-server-image.yml new file mode 100644 index 00000000..96a03b7e --- /dev/null +++ b/.gitea/workflows/publish-workspace-server-image.yml @@ -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- — 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- 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- + 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- + 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}" From 0345d9872c3618869dd2ee95f0a91712ebf3880e Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 04:32:51 +0000 Subject: [PATCH 05/18] trigger: re-run sop-tier-check after #229 fix From 1dcd0c1dd1a05a6c3f6cb20f9f8e6df22cf4f0c3 Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 04:34:32 +0000 Subject: [PATCH 06/18] trigger: re-run sop-tier-check after #229 fix From a5eabae637a7d5d4fdbd7629a379640015d32bee Mon Sep 17 00:00:00 2001 From: Molecule AI Core Platform Lead Date: Sun, 10 May 2026 04:40:32 +0000 Subject: [PATCH 07/18] trigger: re-run sop-tier-check post-#231 merge (orchestrator drain) From 9b91bda2edc57379aeec8d01ae98f58f492f0767 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 03:37:44 +0000 Subject: [PATCH 08/18] test(canvas/config): add pure-function tests for parseYaml and toYaml Cover parseYaml: empty input, blanks, comments, booleans, numbers, lists, objects, 2-level nesting (env.required pattern), round-trip. Cover toYaml: name/desc, version/tier, runtime, runtime_config, effort/task_budget, prompt_files/skills/tools lists, a2a/delegation/ sandbox nested blocks, null-omission, trailing newline, full round-trip. Co-Authored-By: Claude Opus 4.7 --- .../tabs/config/__tests__/yaml-utils.test.ts | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 canvas/src/components/tabs/config/__tests__/yaml-utils.test.ts diff --git a/canvas/src/components/tabs/config/__tests__/yaml-utils.test.ts b/canvas/src/components/tabs/config/__tests__/yaml-utils.test.ts new file mode 100644 index 00000000..6e2e35f0 --- /dev/null +++ b/canvas/src/components/tabs/config/__tests__/yaml-utils.test.ts @@ -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 }); + }); +}); From 4c6cfef912498e8526331e7b918c486756edb11a Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 03:59:32 +0000 Subject: [PATCH 09/18] test(canvas): add pure-function tests for runtimeProfiles, getIcon, createMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtimeProfiles.test.ts: getRuntimeProfile and provisionTimeoutForRuntime covering undefined/unknown runtime, overrides precedence, convenience equivalence. - getIcon.test.ts: 23 cases — dirs, all FILE_ICONS extensions (.md/.yaml/.py/.ts/.tsx/.js/.json/.html/.css/.sh), fallback, case insensitivity, nested paths. - createMessage.test.ts: role, content, id, timestamp, attachment handling, Object.isFrozen, key shape. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/createMessage.test.ts | 75 +++++++++++++ .../src/components/__tests__/getIcon.test.ts | 104 ++++++++++++++++++ .../src/lib/__tests__/runtimeProfiles.test.ts | 89 +++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 canvas/src/components/__tests__/createMessage.test.ts create mode 100644 canvas/src/components/__tests__/getIcon.test.ts create mode 100644 canvas/src/lib/__tests__/runtimeProfiles.test.ts diff --git a/canvas/src/components/__tests__/createMessage.test.ts b/canvas/src/components/__tests__/createMessage.test.ts new file mode 100644 index 00000000..6ce40c06 --- /dev/null +++ b/canvas/src/components/__tests__/createMessage.test.ts @@ -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() + ); + }); +}); diff --git a/canvas/src/components/__tests__/getIcon.test.ts b/canvas/src/components/__tests__/getIcon.test.ts new file mode 100644 index 00000000..c681e334 --- /dev/null +++ b/canvas/src/components/__tests__/getIcon.test.ts @@ -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("⚙"); + }); +}); diff --git a/canvas/src/lib/__tests__/runtimeProfiles.test.ts b/canvas/src/lib/__tests__/runtimeProfiles.test.ts new file mode 100644 index 00000000..c0ce3746 --- /dev/null +++ b/canvas/src/lib/__tests__/runtimeProfiles.test.ts @@ -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 — 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); + } + }); +}); From dc0c3e7a27e9315ed753f728e079f34007e98f0f Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 04:10:03 +0000 Subject: [PATCH 10/18] test(canvas): add pure-function tests for resolveRuntime and canvas-topology utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preflight-resolveRuntime.test.ts: resolveRuntime from deploy-preflight.ts covering explicit runtime-map entries, identity fallback, -default suffix stripping, edge cases (empty string, multiple suffixes). - canvas-topology-pure.test.ts: sortParentsBeforeChildren (topological sort, orphan handling, no-op, non-mutating), defaultChildSlot (2-col grid), childSlotInGrid (variable-size siblings, uniform-grid fallback), parentMinSize (0–5 children, grid dimensions), parentMinSizeFromChildren (variable sizes, empty array, width/height correctness). Co-Authored-By: Claude Opus 4.7 --- .../preflight-resolveRuntime.test.ts | 78 ++++++ .../__tests__/canvas-topology-pure.test.ts | 251 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts create mode 100644 canvas/src/store/__tests__/canvas-topology-pure.test.ts diff --git a/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts b/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts new file mode 100644 index 00000000..609e756d --- /dev/null +++ b/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts @@ -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"); + }); + }); +}); diff --git a/canvas/src/store/__tests__/canvas-topology-pure.test.ts b/canvas/src/store/__tests__/canvas-topology-pure.test.ts new file mode 100644 index 00000000..2f3c02f1 --- /dev/null +++ b/canvas/src/store/__tests__/canvas-topology-pure.test.ts @@ -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); + }); +}); From 10e60d66cbe8651cdfa4c80418299fd672bf1e17 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 04:26:11 +0000 Subject: [PATCH 11/18] test(canvas): add pure-function tests for deriveWsBaseUrl, statusDotClass, and readThemeCookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ws-url.test.ts: deriveWsBaseUrl — all 4 priority paths tested: NEXT_PUBLIC_WS_URL (strips /ws suffix), NEXT_PUBLIC_PLATFORM_URL (http→ws, https→wss), window.location (https→wss, http→ws), precedence over lower-priority paths. - statusDotClass.test.ts: all STATUS_CONFIG entries (online/offline/paused/ degraded/failed/provisioning/not_configured), fallback to bg-zinc-500, case-sensitivity, purity. - theme-cookie.test.ts: readThemeCookie — valid values (light/dark/system), undefined/empty fallback, invalid value handling, case-sensitivity, purity. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/__tests__/statusDotClass.test.ts | 52 +++++++ canvas/src/lib/__tests__/theme-cookie.test.ts | 47 ++++++ canvas/src/lib/__tests__/ws-url.test.ts | 134 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 canvas/src/lib/__tests__/statusDotClass.test.ts create mode 100644 canvas/src/lib/__tests__/theme-cookie.test.ts create mode 100644 canvas/src/lib/__tests__/ws-url.test.ts diff --git a/canvas/src/lib/__tests__/statusDotClass.test.ts b/canvas/src/lib/__tests__/statusDotClass.test.ts new file mode 100644 index 00000000..3107f8e5 --- /dev/null +++ b/canvas/src/lib/__tests__/statusDotClass.test.ts @@ -0,0 +1,52 @@ +// @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 } 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); + } + }); +}); diff --git a/canvas/src/lib/__tests__/theme-cookie.test.ts b/canvas/src/lib/__tests__/theme-cookie.test.ts new file mode 100644 index 00000000..018e382e --- /dev/null +++ b/canvas/src/lib/__tests__/theme-cookie.test.ts @@ -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)); + } + } + }); +}); diff --git a/canvas/src/lib/__tests__/ws-url.test.ts b/canvas/src/lib/__tests__/ws-url.test.ts new file mode 100644 index 00000000..4b882443 --- /dev/null +++ b/canvas/src/lib/__tests__/ws-url.test.ts @@ -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:/); + }); +}); From b75187d11c2fe59f6e76cfb0f097467e4b816633 Mon Sep 17 00:00:00 2001 From: dev-lead Date: Sat, 9 May 2026 22:03:12 -0700 Subject: [PATCH 12/18] fix(sop-tier-check): clause splitter strips newlines, OR-set collapses to one token (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #225 introduced the AND-composition clause evaluator. PR #231 patched the per-team case-pattern matching but did NOT fix the underlying clause-splitter bug. This PR fixes the actual root cause behind issue #229. Root cause (.gitea/scripts/sop-tier-check.sh ~line 289): _clause=$(echo "$_raw_clause" \ | tr -d '()' \ | tr ',' '\n' \ | tr -d '[:space:]' \ | grep -v '^$') `tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just inserted. For tier:low (expression "engineers,managers,ceo") the intermediate value is: engineers\nmanagers\nceo then `tr -d '[:space:]'` flattens it to: engineersmanagersceo The for-loop iterates ONCE over this single bogus token. The case pattern `*engineersmanagersceo*` never matches APPROVER_TEAMS values like " managers ", so EVERY tier:low PR fails: ::error::clause [engineers/managers/ceo]: FAIL — no approving reviewer belongs to any of these teamsengineersmanagersceo ::error::sop-tier-check FAILED for tier:low (Note: the missing separators in the error string `teamsengineersmanagersceo` were a SECOND, masked bug — `_clause_names="${_clause_names:+, }${_t}"` overwrites the variable on every iteration instead of appending. With the splitter bug, the inner loop only ran once so the overwrite was invisible. Fixing the splitter unmasks the accumulator bug, so we fix both atomically.) Fix: _no_parens=${_raw_clause//[()]/} _clause=${_no_parens//,/ } # comma -> space, bash word-split iterates # Append, don't overwrite: _clause_names="${_clause_names}${_clause_names:+, }${_t}" _passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label" _failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label" Per-tier policy is UNCHANGED — this is a parser fix, not a policy relaxation: tier:low — engineers,managers,ceo (OR-set, ANY ONE suffices) tier:medium — managers AND engineers AND qa???,security??? tier:high — ceo Test: .gitea/scripts/tests/test_sop_tier_check_clause_split.sh asserts the splitter, accumulators, and end-to-end OR-gate matching against APPROVER_TEAMS=" managers " (the exact shape PRs #233-238 hit). 7/7 pass on the new logic. Refs: #229, supersedes attempted fix in #231 for the same root cause. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/scripts/sop-tier-check.sh | 27 +++-- .../tests/test_sop_tier_check_clause_split.sh | 101 ++++++++++++++++++ 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100755 .gitea/scripts/tests/test_sop_tier_check_clause_split.sh diff --git a/.gitea/scripts/sop-tier-check.sh b/.gitea/scripts/sop-tier-check.sh index 3a8964e6..c7b2c820 100755 --- a/.gitea/scripts/sop-tier-check.sh +++ b/.gitea/scripts/sop-tier-check.sh @@ -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 diff --git a/.gitea/scripts/tests/test_sop_tier_check_clause_split.sh b/.gitea/scripts/tests/test_sop_tier_check_clause_split.sh new file mode 100755 index 00000000..3671faba --- /dev/null +++ b/.gitea/scripts/tests/test_sop_tier_check_clause_split.sh @@ -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 ] From 3884580aaaed3753bf85156c2e1defd1fec3e987 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sun, 10 May 2026 04:42:10 +0000 Subject: [PATCH 13/18] =?UTF-8?q?test(canvas):=20add=20cssVar=20unit=20tes?= =?UTF-8?q?ts=20for=20theme=20token=20=E2=86=92=20CSS=20variable=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all ColorToken variants (surface, ink, accent, good, bad, warm, bg, warn, plasma), pure-function property (deterministic output). Co-Authored-By: Claude Opus 4.7 --- canvas/src/lib/__tests__/cssVar.test.ts | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 canvas/src/lib/__tests__/cssVar.test.ts diff --git a/canvas/src/lib/__tests__/cssVar.test.ts b/canvas/src/lib/__tests__/cssVar.test.ts new file mode 100644 index 00000000..148602f7 --- /dev/null +++ b/canvas/src/lib/__tests__/cssVar.test.ts @@ -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})`); + } + } + }); +}); From 3c934dfce0d0c8840a42c09b90e6ce529a18d637 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Sat, 9 May 2026 21:47:33 +0000 Subject: [PATCH 14/18] feat(canvas): document all keyboard shortcuts and interactions in the help dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: MEDIUM priority from canvas accessibility audit (2026-05-09). The existing Quick Start help dialog in Toolbar omitted most keyboard shortcuts from useKeyboardShortcuts.ts — users couldn't discover them visually. Changes: - Toolbar.tsx: enhance the help dialog (role="dialog") to include all documented shortcuts: Esc, Enter, Shift+Enter, Cmd+], Cmd+[, Z, plus mouse interaction tips for Palette, Right-click, Dbl-click, Shift+click. Renamed from "Quick start" to "Shortcuts & tips". - canvas-audit-items.md: update Keyboard Shortcuts section from PARTIAL to complete; mark help dialog item as done. Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/Toolbar.tsx | 27 +++++++++++++++++------- docs/design-system/canvas-audit-items.md | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index b81d8b56..01bddc3b 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -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" >
-
- Quick start +
+
+ Shortcuts & tips
-
+
+ + + + + + - - - + +
{/* Link to the full keyboard shortcuts dialog */}