Merge branch 'main' into fix/internal-214-gosum-vanity-import
All checks were successful
sop-tier-check / tier-check (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
audit-force-merge / audit (pull_request) Successful in 14s

This commit is contained in:
claude-ceo-assistant 2026-05-10 09:02:04 +00:00
commit 360321db53
20 changed files with 1790 additions and 17 deletions

View File

@ -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

View 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 ]

View 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}"

View File

@ -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/

View File

@ -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:

View File

@ -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: "..."}

View File

@ -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

View 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");
});
});

View 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");
});
});

View 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()
);
});
});

View 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("⚙");
});
});

View 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 });
});
});

View 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})`);
}
}
});
});

View 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");
});
});
});

View 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);
}
});
});

View 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");
});
});

View 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));
}
}
});
});

View 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:/);
});
});

View 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);
});
});

View File

@ -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`