Compare commits

..

23 Commits

Author SHA1 Message Date
fullstack-engineer f153f9b350 fix(platform): /github-installation-token returns 501 on missing config (closes #388)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Failing after 9s
audit-force-merge / audit (pull_request) Has been skipped
When GITHUB_APP_ID/INSTALLATION_ID are unset (post org suspension or
Gitea-canonical deployments without GitHub App), the fallback path in
GetInstallationToken was returning 500 Internal Server Error. This pollutes
platform logs with ~28×false-positive 500s/hour across all workspaces.

Return 501 Not Implemented with {"error":"GitHub integration not
configured","scm":"gitea"} when the "required" error fires — callers
can now distinguish "feature off" from "transient error" and stop polling.

Update TestGitHubToken_NoTokenProvider to assert the new 501 shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:13:27 +00:00
infra-sre c7d3f1345e fix(docker-compose): remove duplicate service definitions across include:
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
sop-tier-check / tier-check (pull_request) Failing after 10s
docker-compose.yml added `include: docker-compose.infra.yml` (commit 8cd52fc6)
to bootstrap infra services, but the duplicate postgres/redis/langfuse-db-init
definitions were NOT removed from the main file. Docker Compose v2 errors
out on duplicate service definitions between an include: directive and the
main file, so `docker compose build` currently fails with:

  services.langfuse-db-init conflicts with imported resource

Fix (per issue #377):
- docker-compose.yml: drop the now-redundant postgres, redis,
  langfuse-db-init, langfuse-clickhouse service definitions (SSOT is
  docker-compose.infra.yml via include:)
- docker-compose.infra.yml: add missing `networks: - molecule-core-net` and
  `restart: unless-stopped` to postgres/redis/langfuse-clickhouse so the
  services are fully configured when imported
- docker-compose.infra.yml: rename `clickhouse` → `langfuse-clickhouse`
  to match the service name already used in docker-compose.yml's langfuse
  service environment/depends_on blocks

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre b7c481b7bd ci: re-trigger after tier downgrade
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
infra-sre d68200bb0b ci: re-trigger after runner recovery
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
core-be c92540ce68 [core-be-agent] fix(#354): wire delegation-results consumer into a2a executor
Close the A2A delegation auto-resume gap.

Root cause: heartbeat.py's _check_delegations already writes completed
delegation rows to DELEGATION_RESULTS_FILE and sends a self-message to
wake the agent. executor_helpers.read_delegation_results() was defined to
atomically consume that file, but a2a_executor._core_execute() never
called it — so delegation results were written but the agent never saw
them.

Fix: call read_delegation_results() at the top of _core_execute() and
prepend the results to the user input context so the agent can act on
them without an explicit check_task_status call. The Temporal durable
workflow path is also covered because it calls _core_execute() directly.

Test: two new cases — delegation results injected when file exists;
user input passed through unchanged when file is empty.

Closes molecule-core#354.
2026-05-11 05:11:37 +00:00
infra-sre d83df72b01 ci: re-trigger after label change
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
infra-sre 5d437c87a4 ci: re-trigger after runner recovery
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
infra-runtime-be be0b3414ec test(workspace): add queue_id-absence and push-vs-poll distinction tests
Incorporates valuable extra coverage from fullstack-engineer's PR #336:
- test_push_queued_missing_queue_id_still_parsed: queue_id is optional,
  absence must not break parsing
- test_push_queued_is_distinct_from_poll_queued: both envelope shapes
  parse correctly and independently, with correct delivery_mode values

Also adds push_queued_no_queue_id fixture and regression gate entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-runtime-be 3a6ed4fc46 fix(workspace): push-mode Queued returns delivery_mode="push" (not silent default "poll")
Bug: a2a_response.py:197 returned Queued(method=method) without passing
delivery_mode, silently defaulting to "poll" for push-mode busy-queue
responses. Callers branching on v.delivery_mode would mis-identify push-mode
responses as poll-mode, causing wrong dispatch logic.

Fix: pass delivery_mode="push" explicitly in the push-mode branch.

Tests: add push_queued_full/notify/no_method fixtures and 4 test cases
asserting delivery_mode="push" for all three envelope shapes. Also add
adversarial {"queued": "yes"} and {"queued": False} → Malformed guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre 6f1b9db2af ci: re-trigger after runner recovery
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
hongming 16eab92e1e fix(ci): add _sanitize_a2a to TOP_LEVEL_MODULES allowlist (third workflow defect)
Run 5160 publish-runtime build step failed:

  error: TOP_LEVEL_MODULES drifted from workspace/*.py contents:
    in workspace/ but NOT in TOP_LEVEL_MODULES (will ship un-rewritten): ['_sanitize_a2a']
    Edit scripts/build_runtime_package.py:TOP_LEVEL_MODULES to match.

workspace/_sanitize_a2a.py was added recently but the allowlist in
scripts/build_runtime_package.py was not updated. The build script
intentionally aborts (exit 3) when it detects the drift, because
shipping a module un-rewritten breaks the package's flat-layout import
contract.

Fix: add '_sanitize_a2a' to the set. Alphabetical order preserved
(it sorts before 'a2a_*').

Third workflow defect after #353 (workflow_dispatch.inputs parser) and
#355 (Publish step working-directory). After this lands, attempt #4 of
runtime-v0.1.130 should finally succeed.

Refs: #351, #353, #355, #348 Q3
2026-05-11 05:11:37 +00:00
infra-sre 22263b1615 ci: re-trigger after runner recovery
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
hongming fca6bcc43c fix(ci): add missing working-directory to publish-runtime Publish step
First-ever publish-runtime.yml dispatch (run 5097 post-#353, 2026-05-11
02:06Z) failed at the twine upload step:

  ERROR InvalidDistribution: Cannot find file (or expand pattern): 'dist/*'

Cause: the Publish step was missing 'working-directory: ${{ runner.temp
}}/runtime-build' while the preceding Build/Verify steps all had it.
Result: twine ran from the workspace checkout dir where dist/ doesn't
exist.

Fix: add working-directory to match the rest of the publish job.

This is the second of three workflow defects exposed by #353 finally
making the workflow run at all:
  1. workflow_dispatch.inputs rejection      → fixed in #353
  2. Publish step missing working-directory  → THIS PR
  3. (anything else surfaced by 0.1.130 attempt #2)

After merge: push runtime-v0.1.130 again (tag was already pushed once
post-#353 but the run failed at publish; need a fresh trigger). Should
finally land 0.1.130 on PyPI.

Refs: #351, #348 Q3, #353
2026-05-11 05:11:37 +00:00
core-devops fe1661e1cd fix(ci): add sqlalchemy>=2.0.0 to pip install step (closes #293)
test_audit_ledger.py imports sqlalchemy directly (line 42).
Without an explicit sqlalchemy install, pip dependency resolution can
omit it when pytest/pytest-asyncio/pytest-cov are installed as a
separate step after requirements.txt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre 6af3a90e30 ci: re-trigger sop-tier-check after tier:low label
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre 8644e6c5e9 ci: remove .github/workflows/publish-workspace-server-image.yml duplicate
Gitea Actions reads .gitea/workflows/, not .github/workflows/. The
.github/ copy of this workflow has been kept in lockstep with .gitea/
since the post-suspension migration (e.g. 6d94fd30, 5216e781, 67b2e488
all touch both files). The functional code is identical between the
two; the only differences are comment verbosity and the path-filter
self-reference (each version watches its own location).

Removing the .github/ copy:
  - eliminates the dual-edit maintenance tax (two files touched per fix)
  - prevents accidental drift where one is updated and the other isn't
  - leaves a single source-of-truth at .gitea/workflows/

Cross-references confirmed safe:
  - canary-verify.yml + redeploy-tenants-on-{staging,main}.yml all use
    `workflows: ['publish-workspace-server-image']` (workflow name,
    not file path) — they trigger off the workflow_run event keyed on
    `name:`, which is identical in both files.
  - No other workflow path-watches .github/workflows/publish-workspace-
    server-image.yml.

Other two triplicates from task #287 (publish-runtime.yml and
secret-scan.yml) are NOT addressed in this PR — see PR description for
the ambiguity report flagging them for human review.

Refs: task #287

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre ebfee59a32 ci: re-trigger sop-tier-check after label + rebase
Trivial empty commit to force a fresh workflow run now that the
PR has tier:low label and approvals on the rebased branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre ef5c3d2f46 fix(security): OFFSEC-003 — boundary-marker escape + shared sanitizer
Root cause (from infra-lead PR#7 review id=724):
Sanitization in PR#7 wrapped peer text in [A2A_RESULT_FROM_PEER]
markers, but the markers themselves were not escaped — a malicious
peer could inject "[/A2A_RESULT_FROM_PEER]" to close the trust
boundary early, making subsequent text appear inside the trusted zone.

Fix:
- Create workspace/_sanitize_a2a.py (leaf module, no circular import
  risk) with shared sanitize_a2a_result() + _escape_boundary_markers()
- _escape_boundary_markers() escapes boundary open/close markers in the
  raw peer text before wrapping (primary security control)
- Defense-in-depth: also escapes SYSTEM/OVERRIDE/INSTRUCTIONS/IGNORE
  ALL/YOU ARE NOW patterns (secondary, per PR#7 design intent)
- Update a2a_tools_delegation.py: import from _sanitize_a2a; wrap
  tool_delegate_task return and tool_check_task_status response_preview
- Add 15 tests covering boundary escape, injection patterns, integration
  shapes (workspace/tests/test_a2a_sanitization.py)

Follow-up (non-blocking, noted in PR#7 infra-lead review):
- Deduplicate if a2a_tools.py also wraps (currently handled in
  delegation module only — callers get sanitized output regardless)
- tool_check_task_status: consider sanitizing 'summary' field too

Closes: molecule-ai/molecule-ai-workspace-runtime#7 (wrong-repo PR
that this supersedes)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
hongming 366cafef18 ci: re-trigger after 2026-05-10 actions/checkout auth-window stale failure 2026-05-11 05:11:37 +00:00
hongming 9e28ffd8d0 feat(canvas): mobile-first shell with 6-screen iOS design + responsive desktop fixes
Implements the Claude Design handoff (Molecules AI Mobile.html) as a
viewport-gated React tree under canvas/src/components/mobile/. < 640px
renders the new shell instead of the desktop ReactFlow canvas.

Six screens, all bound to live store data:
- Home (agent list + filter chips + spawn FAB)
- Canvas (mini-graph with pinch-to-zoom + pan + reset)
- Detail (status pills, tabs: Overview / Activity / Config / Memory;
  Activity hits /workspaces/:id/activity)
- Chat (textarea composer, IME-safe Enter, sendInFlightRef guard;
  bootstraps from agentMessages so the prior thread shows on entry)
- Comms (live A2A feed via /workspaces/:id/activity + ACTIVITY_LOGGED)
- Spawn (bottom sheet; fetches /templates so users pick what's actually
  installed on their platform)

Plus a Me tab for mobile theme/accent/density.

Design system (palette.ts + primitives.tsx) ports tokens 1:1 from the
handoff: cream + dark palettes, T1-T4 tier chips, status dots with
halo, JetBrains Mono for IDs/timestamps. Inter + JetBrains Mono are
self-hosted via next/font/google so CSP `font-src 'self'` is honoured.

URL routing: routes sync to ?m=<route>&a=<id>; popstate restores route;
deep links seed initial state. /?m=detail without ?a collapses to home.

Accent override flows through React context (MobileAccentProvider) —
not by mutating the static MOL_LIGHT/MOL_DARK singletons.

SSR flash: isMobile is tri-state; loading spinner stays up until
matchMedia resolves so mobile devices never paint the desktop tree.

Desktop responsiveness fixes (separate but ride along):
- Toolbar: full-width with overflow-x-auto on mobile, logo text + count
  hidden < sm, divider/border collapse to sm: only.
- SidePanel: full-screen on mobile via matchMedia, resize handle hidden.
- Canvas: MiniMap hidden < sm (was overlapping the New Workspace FAB).

Tests (51 total, 33 new):
- palette.test.ts (12) - normalizeStatus, tierCode, light/dark parity
- components.test.ts (10) - toMobileAgent field mapping + classifyForFilter
- MobileApp.test.tsx (12) - route stack, deep links, popstate, tab bar
  hidden on chat, spawn overlay
- SidePanel.tabs.test.tsx (18) - regression-clean

Verified: tsc --noEmit clean across mobile/, page.tsx, layout.tsx.
Not yet verified: live phone browser (needs CP backend hydrated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
hongming 294fb18e18 fix(ci): remove workflow_dispatch.inputs (true root cause of #351 — Gitea parser rejects, workflow ignored)
ROOT CAUSE found in Gitea server logs:

  actions/workflows.go:DetectWorkflows() [W] ignore invalid workflow
  "publish-runtime.yml": unknown on type:
  map["version":{"description":...,"required":true,"type":"string"}]

Gitea 1.22.6's workflow parser flattens workflow_dispatch.inputs.* into
top-level 'on:' event-keys and rejects the workflow when it doesn't
recognize them. Once rejected, the workflow never registers — so NO
event triggers it. publish-runtime.yml has 0 runs in action_run since
the .gitea port for exactly this reason; the runtime-v1.0.0 tag from
yesterday and hongming-pc's runtime-v0.1.130 from tonight both pushed
successfully but went nowhere.

This supersedes the paths-vs-tags hypothesis from #351 (PR #352).
The split is still useful for clarity but was NOT the cause — even
the original tags-only port had this same parse failure.

Fix: drop the inputs block. workflow_dispatch in Gitea 1.22.6 supports
no-input dispatch only. The bash logic for version derivation now uses
just two cases: tag-push (strip prefix) or anything-else (PyPI auto-bump).

Post-merge verification:
  - watch for first-ever publish-runtime.yml run in action_run
  - check Gitea log no longer emits 'ignore invalid workflow' for this file
  - push a runtime-v0.1.130 tag → workflow fires → PyPI 0.1.130

Refs: #351 (root cause), #348 Q3 (the blocker)
2026-05-11 05:11:37 +00:00
hongming 738923c3fa fix(ci): split publish-runtime into tags-only + autobump (closes #351)
publish-runtime.yml has never fired since the .gitea port (0 rows in
action_run.workflow_id='publish-runtime.yml' ever), which is why PyPI
is still at 0.1.129 despite Gitea having a runtime-v1.0.0 tag.

Root cause hypothesis: Gitea Actions evaluates the on.push.paths filter
against tag-push events too (no path diff → workflow skipped). PR #349
made this visible by adding the paths trigger, but the same defect
existed for the originally-ported tags-only trigger on this Gitea version
— hence the runtime-v1.0.0 tag also never published.

Fix: split into two files, each with a single unambiguous trigger shape.

  - publish-runtime.yml          : on.push.tags only       (the publisher)
  - publish-runtime-autobump.yml : on.push.branches+paths  (NEW; the bumper)

The autobump file computes next version from PyPI latest, pushes
'runtime-v$VERSION' tag via DISPATCH_TOKEN (not GITHUB_TOKEN — needed
to trigger downstream workflows on Gitea), and exits. The tag push
then triggers publish-runtime.yml.

Test plan after merge:
  1. Push no-op commit to workspace/. Observe autobump fire, push tag.
  2. Observe publish-runtime.yml fire on the tag, publish 0.1.130 to
     PyPI, cascade to template repos.
  3. Verify 'action_run' shows >0 rows for both workflow_ids.
2026-05-11 05:11:37 +00:00
hongming 895cf737dc feat(ci): restore staging+main path-filter trigger on publish-runtime (closes #348 Q1)
Adds back the original GitHub workflow's auto-publish trigger that was
dropped during the 2026-05-10 .gitea port (#206). Push to main or
staging filtered by workspace/** falls into the existing PyPI-latest
auto-bump path — no logic changes, just the missing trigger and a
comment correction.

Caveat: the workflow still requires PYPI_TOKEN as a repository secret
(or org-level). Without it the publish step will fail loudly with a
descriptive error. Q2 follow-up tracks setting the secret.

Refs: molecule-core#348
2026-05-11 05:11:37 +00:00
114 changed files with 886 additions and 13502 deletions
-829
View File
@@ -1,829 +0,0 @@
#!/usr/bin/env python3
# sop-checklist-gate — evaluate whether a PR has peer-acked each
# SOP-checklist item. Posts a commit-status that branch protection
# can require.
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
# Invoked by .gitea/workflows/sop-checklist-gate.yml on:
# - pull_request_target: [opened, edited, synchronize, reopened]
# - issue_comment: [created, edited, deleted]
#
# Flow:
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref — trusted).
# 2. GET /repos/{R}/pulls/{N} — author, head.sha, tier label
# 3. GET /repos/{R}/issues/{N}/comments — extract /sop-ack and /sop-revoke
# 4. For each checklist item:
# a. Is the section marker present in PR body? (author answered)
# b. Is there ≥1 unrevoked /sop-ack from a non-author whose
# team-membership matches required_teams?
# 5. POST /repos/{R}/statuses/{sha} — context
# `sop-checklist / all-items-acked (pull_request)`,
# state=success | failure | pending, description=`acked: N/M …`.
#
# Trust boundary (mirrors RFC#324 §A4):
# This script is loaded from the BASE branch. The workflow's
# actions/checkout step pins ref=base.sha. PR-HEAD code is never
# executed. We only HTTP-call the Gitea API.
#
# Token scope:
# - read:repository / read:organization to enumerate PR + comments
# + team membership (Gitea 1.22.6 quirk: team-membership endpoint
# returns 403 if token owner is not in the team; see review-check.sh
# for the same gotcha — we surface the same fail-closed message).
# - write:repository for `POST /repos/{R}/statuses/{sha}`. Unlike
# RFC#324's pattern (which uses the JOB's own pass/fail as the
# status), we POST the status explicitly because the gate posts
# a single multi-item status with a richer description than a
# bare success/failure context can carry.
#
# Slug normalization rules (canonical form: kebab-case):
# - Lowercase
# - Whitespace + underscores → single dash
# - Strip non [a-z0-9-] characters
# - Collapse adjacent dashes
# - Strip leading/trailing dashes
# - If the result is a digit string (e.g. "1"), look up via
# config.items[*].numeric_alias to get the kebab-case slug.
#
# Examples:
# "Comprehensive_Testing" → "comprehensive-testing"
# "comprehensive testing" → "comprehensive-testing"
# "1" → "comprehensive-testing"
# "Five-Axis-Review" → "five-axis-review"
#
# Revoke semantics:
# /sop-revoke <slug> [reason] — most-recent comment per (slug, user)
# wins. So if Alice posts /sop-ack X then later /sop-revoke X, her ack
# for X is invalidated. Bob's prior /sop-ack X is unaffected. If Alice
# posts /sop-revoke X then later /sop-ack X again, the ack is restored.
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
# ---------------------------------------------------------------------------
# Slug normalization
# ---------------------------------------------------------------------------
_NORMALIZE_REPLACE_RE = re.compile(r"[\s_]+")
_NORMALIZE_STRIP_RE = re.compile(r"[^a-z0-9-]")
_NORMALIZE_DASH_RE = re.compile(r"-+")
def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> str:
"""Normalize a user-supplied slug to canonical kebab-case form.
See module header for the rules.
If the input is a pure digit string AND numeric_aliases is provided,
the alias mapping is consulted. Unknown digits return "" so the caller
can flag the comment as unparseable.
"""
if raw is None:
return ""
s = raw.strip().lower()
s = _NORMALIZE_REPLACE_RE.sub("-", s)
s = _NORMALIZE_STRIP_RE.sub("", s)
s = _NORMALIZE_DASH_RE.sub("-", s)
s = s.strip("-")
if s.isdigit() and numeric_aliases is not None:
return numeric_aliases.get(int(s), "")
return s
# ---------------------------------------------------------------------------
# Comment parsing — /sop-ack and /sop-revoke
# ---------------------------------------------------------------------------
# A directive must be on its own line. Permits leading whitespace.
# Optional trailing note after the slug for /sop-ack and required reason
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
# yet validated; future iteration may require a min-length).
_DIRECTIVE_RE = re.compile(
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
def parse_directives(
comment_body: str,
numeric_aliases: dict[int, str],
) -> list[tuple[str, str, str]]:
"""Extract /sop-ack and /sop-revoke directives from a comment body.
Returns a list of (kind, canonical_slug, note) tuples where:
kind is "sop-ack" or "sop-revoke"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
"""
out: list[tuple[str, str, str]] = []
if not comment_body:
return out
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
# If the raw match included trailing words, the regex non-greedy
# captured only the first token; strip again for safety.
# We split on whitespace to keep the FIRST word as the slug, and
# everything after as the note.
parts = raw_slug.split()
if not parts:
continue
first = parts[0]
# If the slug-capture greedily matched multiple words (e.g.
# "comprehensive testing"), preserve normalize behavior: join
# the WHOLE first-word-token only; trailing words get appended to
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
# may have multi-word forms here — normalize handles them.
if len(parts) > 1:
# User wrote "/sop-ack comprehensive testing extra-note"
# → treat "comprehensive testing" as the slug source if it
# normalizes to a known item; otherwise treat "comprehensive"
# as slug and "testing extra-note" as note. We defer the
# disambiguation to the caller via the returned canonical
# slug. For simplicity: try the WHOLE captured string first.
canonical = normalize_slug(raw_slug, numeric_aliases)
else:
canonical = normalize_slug(first, numeric_aliases)
note_from_group = (m.group(3) or "").strip()
# If we collapsed multi-word slug into kebab and there's a
# trailing-text group too, append it.
out.append((kind, canonical, note_from_group))
return out
# ---------------------------------------------------------------------------
# PR body section detection
# ---------------------------------------------------------------------------
def section_marker_present(body: str, marker: str) -> bool:
"""Return True if `marker` appears in `body` case-insensitively
on a non-empty line (i.e. the author actually filled it in).
We require the marker substring AND non-whitespace content on the
same line OR within the next line — this prevents trivially-empty
checklists like:
## SOP-Checklist
- [ ] **Comprehensive testing performed**:
- [ ] **Local-postgres E2E run**:
from auto-passing the section-present check. The peer-ack is still
required, but answering with empty content is captured as a soft
finding via the section-present test alone.
"""
if not body or not marker:
return False
body_lower = body.lower()
marker_lower = marker.lower()
idx = body_lower.find(marker_lower)
if idx < 0:
return False
# Walk to end of line.
line_end = body.find("\n", idx)
if line_end < 0:
line_end = len(body)
line = body[idx + len(marker):line_end]
# Strip the colon + checkbox tail patterns; require at least one
# non-whitespace, non-punctuation char.
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
if stripped:
return True
# Fall through: check the NEXT line (multi-line answers).
next_line_end = body.find("\n", line_end + 1)
if next_line_end < 0:
next_line_end = len(body)
next_line = body[line_end + 1:next_line_end]
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
return bool(stripped_next)
# ---------------------------------------------------------------------------
# Ack-state computation
# ---------------------------------------------------------------------------
def compute_ack_state(
comments: list[dict[str, Any]],
pr_author: str,
items_by_slug: dict[str, dict[str, Any]],
numeric_aliases: dict[int, str],
team_membership_probe: "callable[[str, list[str]], list[str]]",
) -> dict[str, dict[str, Any]]:
"""Compute per-item ack state.
Each comment is processed in chronological order. The most-recent
directive per (commenter, slug) wins.
Returns a dict keyed by canonical slug:
{
"comprehensive-testing": {
"ackers": ["bob"], # non-author, team-verified
"rejected_ackers": { # debugging info
"self_ack": ["alice"],
"unknown_slug": [],
"not_in_team": ["eve"],
}
},
...
}
"""
# Step 1: collapse directives per (commenter, slug) — most recent wins.
# comments are expected to come in chronological order from the
# API (Gitea returns oldest-first by default for issues/{N}/comments).
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) → kind
unparseable_per_user: dict[str, int] = {}
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, slug, _note in parse_directives(body, numeric_aliases):
if not slug:
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
continue
latest_directive[(user, slug)] = kind
# Step 2: build candidate ackers per slug.
# Filter out self-acks and unknown slugs.
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
for (user, slug), kind in latest_directive.items():
if kind != "sop-ack":
continue # revokes leave the (user,slug) state as "no ack"
if slug not in items_by_slug:
# Slug normalized to something not in our config — store
# under a synthetic key for diagnostic surfacing. Don't add
# to any item.
continue
if user == pr_author:
rejected_self[slug].append(user)
continue
pending_team_check[slug].append(user)
# Step 3: team membership probe per slug (batched per slug to keep
# API call count down — same user may ack multiple items but the
# required_teams differ per item, so we MUST probe per (user, item)).
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
for slug, candidates in pending_team_check.items():
if not candidates:
continue
required = items_by_slug[slug]["required_teams"]
approved = team_membership_probe(slug, candidates) # returns subset
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
ackers_per_slug[slug] = approved
# Stash required teams for description rendering.
items_by_slug[slug]["_required_resolved"] = required
return {
slug: {
"ackers": ackers_per_slug[slug],
"rejected": {
"self_ack": rejected_self[slug],
"not_in_team": rejected_not_in_team[slug],
},
}
for slug in items_by_slug
}
# ---------------------------------------------------------------------------
# Gitea API client
# ---------------------------------------------------------------------------
class GiteaClient:
def __init__(self, host: str, token: str):
self.base = f"https://{host}/api/v1"
self.token = token
# Cache team-name → team-id resolutions per org.
self._team_id_cache: dict[tuple[str, str], int | None] = {}
def _req(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
ok_codes: tuple[int, ...] = (200, 201, 204),
) -> tuple[int, Any]:
url = self.base + path
data = None
headers = {
"Authorization": f"token {self.token}",
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, method=method, data=data, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as r:
raw = r.read()
code = r.getcode()
except urllib.error.HTTPError as e:
code = e.code
raw = e.read()
try:
parsed = json.loads(raw.decode("utf-8")) if raw else None
except json.JSONDecodeError:
parsed = raw.decode("utf-8", errors="replace") if raw else None
return code, parsed
def get_pr(self, owner: str, repo: str, pr: int) -> dict[str, Any]:
code, data = self._req("GET", f"/repos/{owner}/{repo}/pulls/{pr}")
if code != 200:
raise RuntimeError(f"GET pulls/{pr} → HTTP {code}: {data!r}")
return data
def get_issue_comments(
self, owner: str, repo: str, issue: int
) -> list[dict[str, Any]]:
# Paginate. Gitea default page size 50.
out: list[dict[str, Any]] = []
page = 1
while True:
code, data = self._req(
"GET",
f"/repos/{owner}/{repo}/issues/{issue}/comments?limit=50&page={page}",
)
if code != 200:
raise RuntimeError(
f"GET issues/{issue}/comments page={page} → HTTP {code}: {data!r}"
)
if not data:
break
out.extend(data)
if len(data) < 50:
break
page += 1
return out
def resolve_team_id(self, org: str, team_name: str) -> int | None:
key = (org, team_name)
if key in self._team_id_cache:
return self._team_id_cache[key]
code, data = self._req("GET", f"/orgs/{org}/teams/search?q={urllib.parse.quote(team_name)}")
team_id = None
if code == 200 and isinstance(data, dict):
for t in data.get("data", []):
if t.get("name") == team_name:
team_id = t.get("id")
break
if team_id is None and code == 200 and isinstance(data, list):
for t in data:
if t.get("name") == team_name:
team_id = t.get("id")
break
self._team_id_cache[key] = team_id
return team_id
def is_team_member(self, team_id: int, login: str) -> bool | None:
"""Return True / False / None (unknown — 403 from API)."""
code, _ = self._req(
"GET", f"/teams/{team_id}/members/{urllib.parse.quote(login)}"
)
if code in (200, 204):
return True
if code == 404:
return False
# 403 means the token owner isn't in this team, so the API
# refuses to confirm membership. Fail-closed at the caller.
return None
def post_status(
self,
owner: str,
repo: str,
sha: str,
state: str,
context: str,
description: str,
target_url: str = "",
) -> None:
body = {
"state": state,
"context": context,
"description": description[:140], # Gitea truncates to 255 but be safe
"target_url": target_url or "",
}
code, data = self._req(
"POST",
f"/repos/{owner}/{repo}/statuses/{sha}",
body=body,
ok_codes=(201,),
)
if code not in (200, 201):
raise RuntimeError(
f"POST statuses/{sha} → HTTP {code}: {data!r}"
)
# ---------------------------------------------------------------------------
# Config loader (PyYAML-free — config file is intentionally tiny + flat)
# ---------------------------------------------------------------------------
def load_config(path: str) -> dict[str, Any]:
"""Load .gitea/sop-checklist-config.yaml.
Uses PyYAML if available, otherwise falls back to a built-in
minimal parser sufficient for our flat config shape. Bundling
PyYAML on the runner is one apt install away but we avoid the
dep by keeping the config shape constrained.
"""
try:
import yaml # type: ignore[import-not-found]
with open(path) as f:
return yaml.safe_load(f)
except ImportError:
return _load_config_minimal(path)
def _load_config_minimal(path: str) -> dict[str, Any]:
"""Minimal YAML subset parser for our config shape.
Supports: top-level scalar:value, top-level map-of-map (e.g.
tier_failure_mode), top-level list of maps (items:), and within an
item map: scalars + lists of scalars. Does NOT support nested lists,
YAML anchors, multi-doc, or flow style.
"""
with open(path) as f:
lines = f.readlines()
return _parse_minimal_yaml(lines)
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
"""Hand-rolled subset parser. See _load_config_minimal docstring."""
# Strip comments + blank lines but preserve indentation.
cleaned: list[tuple[int, str]] = []
for raw in lines:
# Don't strip a "#" that is inside a quoted value.
body = raw.rstrip("\n")
# Remove trailing comment.
idx = body.find("#")
if idx >= 0 and (idx == 0 or body[idx - 1] in " \t"):
body = body[:idx].rstrip()
if not body.strip():
continue
indent = len(body) - len(body.lstrip(" "))
cleaned.append((indent, body.strip()))
root: dict[str, Any] = {}
i = 0
n = len(cleaned)
def parse_scalar(s: str) -> Any:
s = s.strip()
if s.startswith('"') and s.endswith('"'):
return s[1:-1]
if s.startswith("'") and s.endswith("'"):
return s[1:-1]
if s.lower() in ("true", "yes"):
return True
if s.lower() in ("false", "no"):
return False
try:
return int(s)
except ValueError:
pass
return s
def parse_inline_list(s: str) -> list[Any]:
s = s.strip()
if not (s.startswith("[") and s.endswith("]")):
return [parse_scalar(s)]
inner = s[1:-1]
if not inner.strip():
return []
return [parse_scalar(x.strip()) for x in inner.split(",")]
while i < n:
indent, line = cleaned[i]
if indent != 0:
i += 1
continue
if ":" not in line:
i += 1
continue
key, _, rest = line.partition(":")
key = key.strip()
rest = rest.strip()
if rest == "":
# Block — could be map or list.
i += 1
# Look ahead for first child.
if i < n and cleaned[i][1].startswith("- "):
# List of items.
items: list[Any] = []
while i < n and cleaned[i][0] > indent and cleaned[i][1].startswith("- "):
item_indent = cleaned[i][0]
first_kv = cleaned[i][1][2:].strip() # strip "- "
item: dict[str, Any] = {}
if ":" in first_kv:
k, _, v = first_kv.partition(":")
k = k.strip()
v = v.strip()
if v == "":
item[k] = ""
elif v.startswith(">-") or v.startswith(">"):
# Folded scalar continues on subsequent indented lines
collected: list[str] = []
i += 1
while i < n and cleaned[i][0] > item_indent:
collected.append(cleaned[i][1])
i += 1
item[k] = " ".join(collected)
items.append(item)
continue
elif v.startswith("["):
item[k] = parse_inline_list(v)
else:
item[k] = parse_scalar(v)
i += 1
# Subsequent k:v lines at deeper indent belong to this item.
while i < n and cleaned[i][0] > item_indent and not cleaned[i][1].startswith("- "):
sub_indent, sub_line = cleaned[i]
if ":" in sub_line:
k, _, v = sub_line.partition(":")
k = k.strip()
v = v.strip()
if v == "":
item[k] = ""
i += 1
elif v.startswith(">-") or v.startswith(">"):
collected = []
i += 1
while i < n and cleaned[i][0] > sub_indent:
collected.append(cleaned[i][1])
i += 1
item[k] = " ".join(collected)
elif v.startswith("["):
item[k] = parse_inline_list(v)
i += 1
else:
item[k] = parse_scalar(v)
i += 1
else:
i += 1
items.append(item)
root[key] = items
else:
# Sub-map.
submap: dict[str, Any] = {}
while i < n and cleaned[i][0] > indent:
sub_indent, sub_line = cleaned[i]
if ":" in sub_line:
k, _, v = sub_line.partition(":")
k = k.strip().strip('"').strip("'")
v = v.strip()
if v.startswith("[") and v.endswith("]"):
submap[k] = parse_inline_list(v)
else:
submap[k] = parse_scalar(v)
i += 1
root[key] = submap
else:
# Inline scalar or list.
if rest.startswith("[") and rest.endswith("]"):
root[key] = parse_inline_list(rest)
else:
root[key] = parse_scalar(rest)
i += 1
return root
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def render_status(
items: list[dict[str, Any]],
ack_state: dict[str, dict[str, Any]],
body_state: dict[str, bool],
) -> tuple[str, str]:
"""Return (state, description) for the commit-status post.
state is "success" if every item has at least one valid ack
(body section presence is informational only — peer-ack is the
real gate). tier:low PRs receive state="success" (soft-fail — no
acks required); the description carries "[info tier:low]" prefix.
"""
n = len(items)
fully_acked = [
it["slug"] for it in items if ack_state[it["slug"]]["ackers"]
]
missing = [
it["slug"] for it in items if not ack_state[it["slug"]]["ackers"]
]
missing_body = [it["slug"] for it in items if not body_state.get(it["slug"], False)]
desc_parts = [f"acked: {len(fully_acked)}/{n}"]
if missing:
# Show up to 3 missing slugs to stay inside the 140-char budget.
shown = ", ".join(missing[:3])
if len(missing) > 3:
shown += f", +{len(missing) - 3}"
desc_parts.append(f"missing: {shown}")
if missing_body:
shown = ", ".join(missing_body[:3])
if len(missing_body) > 3:
shown += f", +{len(missing_body) - 3}"
desc_parts.append(f"body-unfilled: {shown}")
state = "success" if not missing and not missing_body else "failure"
return state, "".join(desc_parts)
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
labels = pr.get("labels") or []
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
mode_map = cfg.get("tier_failure_mode") or {}
default_mode = cfg.get("default_mode", "hard")
for tl in tier_labels:
if tl in mode_map:
return mode_map[tl]
return default_mode
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--owner", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr", type=int, required=True)
p.add_argument("--config", default=".gitea/sop-checklist-config.yaml")
p.add_argument("--gitea-host", default="git.moleculesai.app")
p.add_argument(
"--dry-run",
action="store_true",
help="Compute state but do not POST the status.",
)
p.add_argument(
"--status-context",
default="sop-checklist / all-items-acked (pull_request)",
)
p.add_argument(
"--exit-on-state",
action="store_true",
help=(
"If set, exit non-zero when state=failure. Default OFF so the "
"job-level conclusion is independent of ack-state — the only "
"thing BP sees is the POSTed status. Useful for local debugging."
),
)
args = p.parse_args(argv)
token = os.environ.get("GITEA_TOKEN", "")
if not token and not args.dry_run:
print("::error::GITEA_TOKEN env required", file=sys.stderr)
return 2
cfg = load_config(args.config)
items: list[dict[str, Any]] = cfg["items"]
items_by_slug = {it["slug"]: it for it in items}
numeric_aliases = {
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
}
client = GiteaClient(args.gitea_host, token) if token else None
if not client:
print("::error::No client (dry-run without token has nothing to do)", file=sys.stderr)
return 2
pr = client.get_pr(args.owner, args.repo, args.pr)
if pr.get("state") != "open":
print(f"::notice::PR #{args.pr} is {pr.get('state')} — gate is a no-op")
return 0
author = (pr.get("user") or {}).get("login", "")
head_sha = (pr.get("head") or {}).get("sha", "")
body = pr.get("body", "") or ""
if not author or not head_sha:
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
return 1
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
# Build team-membership probe closure that caches results per
# (user, team-id) so a user acking multiple items only triggers
# one membership lookup per team.
team_member_cache: dict[tuple[str, int], bool | None] = {}
def probe(slug: str, users: list[str]) -> list[str]:
item = items_by_slug[slug]
team_names: list[str] = item["required_teams"]
# Resolve names → ids. NOTE: orgs/{org}/teams/search may not be
# available — fall back to the list endpoint.
team_ids: list[int] = []
for tn in team_names:
tid = client.resolve_team_id(args.owner, tn)
if tid is None:
# Try the list endpoint as a fallback.
code, data = client._req( # noqa: SLF001
"GET", f"/orgs/{args.owner}/teams"
)
if code == 200 and isinstance(data, list):
for t in data:
if t.get("name") == tn:
tid = t.get("id")
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001
break
if tid is not None:
team_ids.append(tid)
else:
print(
f"::warning::could not resolve team-id for '{tn}' "
f"in org '{args.owner}' — item '{slug}' will fail closed",
file=sys.stderr,
)
approved: list[str] = []
for u in users:
for tid in team_ids:
cache_key = (u, tid)
if cache_key not in team_member_cache:
team_member_cache[cache_key] = client.is_team_member(tid, u)
result = team_member_cache[cache_key]
if result is True:
approved.append(u)
break
if result is None:
print(
f"::warning::team-probe for {u} in team-id {tid} returned 403 "
"(token owner not in that team — fail-closed per RFC#324)",
file=sys.stderr,
)
# Treat as not-in-team for this user/team pair; loop
# may still find membership in another team.
return approved
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
state, description = render_status(items, ack_state, body_state)
mode = get_tier_mode(pr, cfg)
if mode == "soft":
# tier:low: acks are informational only — post success so BP gate passes.
# Description carries "[info tier:low]" prefix so reviewers know acks
# were not required (vs a tier:medium+ PR that truly passed all acks).
state = "success"
description = f"[info tier:low] {description}"
# Diagnostics to job log.
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
for it in items:
slug = it["slug"]
ackers = ack_state[slug]["ackers"]
if ackers:
print(f"::notice:: [PASS] {slug} — acked by {','.join(ackers)}")
else:
r = ack_state[slug]["rejected"]
extras: list[str] = []
if r["self_ack"]:
extras.append(f"self-acks-rejected:{','.join(r['self_ack'])}")
if r["not_in_team"]:
extras.append(f"not-in-team:{','.join(r['not_in_team'])}")
extra = " (" + "; ".join(extras) + ")" if extras else ""
print(f"::notice:: [WAIT] {slug} — no valid peer-ack yet{extra}")
print(f"::notice::posting status: state={state} desc={description!r}")
if args.dry_run:
print("::notice::--dry-run: not posting status")
if args.exit_on_state:
return 0 if state in ("success", "pending") else 1
return 0
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
client.post_status(
args.owner, args.repo, head_sha,
state=state, context=args.status_context,
description=description, target_url=target_url,
)
print(f"::notice::status posted: {args.status_context}{state}")
# By default exit 0 — the POSTed status IS the gate, NOT the job
# conclusion. If the job exits 1 BP will see TWO failure signals
# (one from the job's auto-status, one from our POST), making the
# description less actionable. --exit-on-state restores the old
# behavior for local debugging.
if args.exit_on_state:
return 0 if state in ("success", "pending") else 1
return 0
if __name__ == "__main__":
sys.exit(main())
+9 -74
View File
@@ -44,39 +44,6 @@
set -euo pipefail
# Ensure jq is available. Runners may not have it pre-installed, and the
# workflow-level jq install can fail on runners with network restrictions
# (GitHub releases not reachable from some runner networks — infra#241
# follow-up). This fallback is idempotent — no-op when jq is already on PATH.
# SOP_FAIL_OPEN=1 makes this always exit 0 so CI never blocks on jq absence.
if ! command -v jq >/dev/null 2>&1; then
echo "::notice::jq not found on PATH — attempting install..."
_jq_installed="no"
# apt-get first (primary) — Ubuntu package mirrors are reliably reachable.
if apt-get update -qq && apt-get install -y -qq jq 2>/dev/null; then
echo "::notice::jq installed via apt-get: $(jq --version)"
_jq_installed="yes"
# GitHub binary as secondary fallback — may fail on restricted networks.
elif timeout 120 curl -sSL \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
-o /usr/local/bin/jq \
&& chmod +x /usr/local/bin/jq; then
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
_jq_installed="yes"
fi
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq installation failed — apt-get and GitHub binary both failed."
echo "::error::sop-tier-check requires jq for all JSON API parsing."
# SOP_FAIL_OPEN=1 is set in the workflow step's env — makes script always
# exit 0 so CI never blocks. The SOP-6 tier review gate remains enforced.
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
fi
debug() {
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] $*" >&2
@@ -96,27 +63,16 @@ API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
# Sanity: token resolves to a user.
# Use || true on the jq pipeline so that set -euo pipefail (line 45) does not
# cause the script to exit prematurely when the token is empty/invalid — the
# if check below handles that case gracefully. Without || true, a 401 from an
# empty/invalid token causes jq to exit 1, triggering set -e and exiting the
# entire script before SOP_FAIL_OPEN can be evaluated (the check is in the jq-
# install block; if jq is already on PATH, that block is skipped entirely).
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""') || true
# Sanity: token resolves to a user
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
if [ -z "$WHOAMI" ]; then
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
echo "::notice::token resolves to user: $WHOAMI"
# 1. Read tier label. || true ensures set -euo pipefail does not abort the
# script if curl or jq fails (e.g. 401 from empty token).
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name') || true
# 1. Read tier label
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
TIER=""
for L in $LABELS; do
case "$L" in
@@ -187,25 +143,17 @@ fi
# 4. Resolve all team names → IDs
# /orgs/{org}/teams/{slug}/... endpoints don't exist on Gitea 1.22;
# we use /teams/{id}.
# set +e prevents set -e from aborting the script if curl fails (e.g. empty token).
ORG_TEAMS_FILE=$(mktemp)
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
set +e
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/teams")
_HTTP_EXIT=$?
set -e
debug "teams-list HTTP=$HTTP_CODE (curl exit=$_HTTP_EXIT) size=$(wc -c <"$ORG_TEAMS_FILE")"
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] teams-list body (first 300 chars):" >&2
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
fi
if [ "$_HTTP_EXIT" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams failed (curl exit=$_HTTP_EXIT HTTP=$HTTP_CODE) — token may lack read:org scope or be invalid."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope."
exit 1
fi
@@ -250,22 +198,9 @@ for _t in $_all_teams; do
debug "team-id: $_t$_id"
done
# 5. Read approving reviewers. set +e disables set -e temporarily so that curl
# failures (e.g. empty/invalid token → HTTP 401) do not abort the script before
# SOP_FAIL_OPEN is evaluated. set -e is restored immediately after.
set +e
# 5. Read approving reviewers
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
_REVIEWS_EXIT=$?
set -e
if [ $_REVIEWS_EXIT -ne 0 ] || [ -z "$REVIEWS" ]; then
echo "::error::Failed to fetch reviews (curl exit=$_REVIEWS_EXIT) — token may be invalid or unreachable."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]') || true
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
if [ -z "$APPROVERS" ]; then
echo "::error::No approving reviews on this PR. Set SOP_DEBUG=1 and re-run for diagnostics."
exit 1
-109
View File
@@ -1,109 +0,0 @@
# SOP-Checklist gate — per-item required reviewer teams.
#
# RFC#351 v1 starter set. Each item lists:
# slug — canonical kebab-case form used in /sop-ack <slug>
# pr_section_marker — substring matched in the PR body to detect that
# the author filled in this item (case-insensitive)
# required_teams — list of Gitea team names; an ack from ANY one of
# these teams (logical OR) satisfies the item.
# Membership is probed at gate-time via
# GET /api/v1/teams/{id}/members/{login}.
# Team-id resolution happens at script start via
# GET /api/v1/orgs/{org}/teams (cheap, one call).
# numeric_alias — 1..7; lets reviewers type `/sop-ack 3` as a
# shortcut for `/sop-ack staging-smoke`.
#
# WHY THESE TEAM MAPPINGS:
# The RFC table referenced persona-role names like `core-qa`,
# `core-be`, `core-devops` — these are individual Gitea user logins,
# not teams. The Gitea team-membership API is /teams/{id}/members/{u},
# so we need actual teams. Orchestrator preflight 2026-05-12 verified
# only these teams exist on molecule-ai: ceo(5), engineers(2),
# managers(6), qa(20), security(21), Owners(1), and bot teams. We
# map the RFC roles to the closest existing team and surface the
# mapping explicitly so it's reviewable.
#
# HOW TO EDIT:
# - Tightening: replace `engineers` with a smaller team after creating
# it (e.g. a new `senior-engineers` team if needed).
# - Loosening: add another team to required_teams (OR semantics).
# - Add an item: append to items list and document the slug below.
#
# AUTHOR SELF-ACK IS FORBIDDEN regardless of which team contains them
# — the gate script enforces commenter != PR author before checking
# team membership.
version: 1
# Tier-aware failure mode (RFC#351 open question 2):
# For tier:high — hard-fail (status `failure`, blocks merge via BP).
# For tier:medium — hard-fail (same as high; medium is non-trivial).
# For tier:low — soft-fail (status `pending` with `acked: N/M` in the
# description). BP can choose to require the context
# or not for low-tier PRs.
# If no tier label is present, default to medium (hard-fail) — every PR
# should have a tier label per sop-tier-check, and absence indicates
# a missing-tier defect we should surface, not silently lower the bar.
tier_failure_mode:
"tier:high": hard
"tier:medium": hard
"tier:low": soft
default_mode: hard # used when no tier:* label is present
items:
- slug: comprehensive-testing
numeric_alias: 1
pr_section_marker: "Comprehensive testing performed"
required_teams: [qa, engineers]
description: >-
What was tested, how, edge cases covered. Ack from any qa-team
member (or engineers fallback while qa is small).
- slug: local-postgres-e2e
numeric_alias: 2
pr_section_marker: "Local-postgres E2E run"
required_teams: [engineers]
description: >-
Link to local CI artifact, or "N/A: pure-frontend change". Ack
from any engineer who can verify the local DB test actually ran.
- slug: staging-smoke
numeric_alias: 3
pr_section_marker: "Staging-smoke verified or pending"
required_teams: [engineers]
description: >-
Link to canary run, or "scheduled post-merge". Ack from any
engineer (core-devops/infra-sre are members of engineers team).
- slug: root-cause
numeric_alias: 4
pr_section_marker: "Root-cause not symptom"
required_teams: [managers, ceo]
description: >-
One-sentence root-cause statement. Ack from managers tier
(team-leads) or ceo. Senior judgment required to attest
root-cause-versus-symptom.
- slug: five-axis-review
numeric_alias: 5
pr_section_marker: "Five-Axis review walked"
required_teams: [engineers]
description: >-
Correctness / readability / architecture / security / performance.
Ack from any non-author engineer.
- slug: no-backwards-compat
numeric_alias: 6
pr_section_marker: "No backwards-compat shim / dead code added"
required_teams: [managers, ceo]
description: >-
Yes/no + justification if no. Senior ack required because
backward-compat shims are how dead-code accretes.
- slug: memory-consulted
numeric_alias: 7
pr_section_marker: "Memory/saved-feedback consulted"
required_teams: [engineers]
description: >-
List of feedback memories applicable to this change. Ack from
any engineer who has the same memory access.
+2 -5
View File
@@ -52,10 +52,7 @@ jobs:
# Declared here rather than fetched from /branch_protections
# because that endpoint requires admin write — sop-tier-bot is
# read-only by design (least-privilege).
#
# staging branch protection (§F3a/F3b, mc#798): only
# sop-checklist / all-items-acked is required. Unlike main,
# staging does not require sop-tier-check or Secret scan.
REQUIRED_CHECKS: |
sop-checklist / all-items-acked (pull_request)
sop-tier-check / tier-check (pull_request)
Secret scan / Scan diff for credential-shaped strings (pull_request)
run: bash .gitea/scripts/audit-force-merge.sh
-599
View File
@@ -1,599 +0,0 @@
# Ported from .github/workflows/ci.yml on 2026-05-11 per RFC internal#219 §1.
# continue-on-error: true on every job; follow-up PR will flip required after
# surfaced bugs are fixed (per RFC §1 — "surface broken workflows without
# blocking"). The four-surface migration audit
# (feedback_gitea_actions_migration_audit_pattern) was performed against this
# port:
#
# 1. YAML — dropped `merge_group` trigger (no Gitea merge queue); no
# `workflow_dispatch.inputs` to drop (Gitea 1.22.6 rejects those —
# feedback_gitea_workflow_dispatch_inputs_unsupported); no `environment:`
# blocks; kept `runs-on: ubuntu-latest` (Gitea runner pool advertises
# this label per agent_labels in action_runner table). Workflow-level
# env.GITHUB_SERVER_URL set as belt-and-suspenders against runner
# defaults (feedback_act_runner_github_server_url).
#
# 2. Cache — `actions/upload-artifact@v3.2.2` was already pinned to v3 for
# Gitea act_runner v0.6 compatibility (a comment in the original called
# this out). v4+ is incompatible with Gitea 1.22.x. No `actions/cache`
# usage to audit. `actions/setup-python@v6` `cache: pip` is left in
# place — works against Gitea's built-in cache server when runner.cache
# is configured (currently is, /opt/molecule/runners/config.yaml).
#
# 3. Token — workflow uses no custom dispatch tokens. The auto-injected
# `GITHUB_TOKEN` (which Gitea aliases to a runner-scoped token) is
# sufficient for `actions/checkout` against this same repo.
#
# 4. Docs — no docs/scripts reference github.com URLs that need swapping.
# The canvas-deploy-reminder step writes a `ghcr.io/...` image
# reference into the step summary text — that's documentation prose
# pointing at the ECR-mirrored canvas image and stays unchanged for
# this port (a separate cleanup if ghcr→ECR sweep is in scope).
#
# Cross-links:
# - RFC: internal#219 (CI/CD hard-gate hardening)
# - Reference port style: molecule-controlplane/.gitea/workflows/ci.yml
# - Bugs that may surface immediately and are tracked separately:
# internal#214 (Go-side vanity-import / go.sum drift, if any)
# - Phase 4 (this PR's follow-up): flip `continue-on-error: false` once
# surfaced defects are fixed, then add `all-required` aggregator
# sentinel (RFC §2) and PATCH branch protection (Phase 4 scope).
name: CI
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
# `merge_group` (GitHub merge-queue trigger) dropped — Gitea has no merge
# queue. The .github/ original retains it; this Gitea-side copy drops it.
# Cancel in-progress CI runs when a new commit arrives on the same ref.
# Stale runs queue up otherwise. PR refs and main/staging refs each get
# their own group because github.ref differs.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Belt-and-suspenders against the runner-default trap
# (feedback_act_runner_github_server_url). Runners are configured with
# this env via /opt/molecule/runners/config.yaml runner.envs, but pinning
# at the workflow level protects against a runner regenerated without
# the config file (feedback_act_runner_needs_config_file_env).
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# Detect which paths changed so downstream jobs can skip when only
# docs/markdown files were modified.
changes:
name: Detect changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): all required jobs >=98% green on main.
# Flip confirmed 2026-05-12 via combined-status check of latest main
# commit (all CI jobs green). `all-required` sentinel hard-fails
# when this job fails; no Phase 3 suppression needed.
# revert: add `continue-on-error: true` back if regressions appear.
continue-on-error: false
outputs:
platform: ${{ steps.check.outputs.platform }}
canvas: ${{ steps.check.outputs.canvas }}
python: ${{ steps.check.outputs.python }}
scripts: ${{ steps.check.outputs.scripts }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: check
run: |
# For PR events: diff against the base branch (not HEAD~1 of the branch,
# which may be unrelated after force-pushes). When a push updates a PR,
# both pull_request and push events fire — prefer the PR base so that
# the diff is always computed against the actual merge base, not the
# previous SHA on the branch which may be on a different history line.
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
# GITHUB_BASE_REF is set for PR events (the base branch name).
# For pull_request events we use the stored base.sha; for push events
# (or when base.sha is unavailable) fall back to github.event.before.
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
# Fallback: if BASE is empty or all zeros (new branch), run everything
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Both .github/workflows/ci.yml AND .gitea/workflows/ci.yml count
# as "this workflow changed" — either edit should force-run every
# downstream job. The Gitea port follows the same shape as the
# GitHub original so behavior matches when triggered on either
# platform.
DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo ".gitea/workflows/ci.yml")
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
# + per-step gating shape preserves the GitHub-side required-check name
# contract (so when this Gitea port becomes a required check in Phase 4,
# the name match works on PRs that don't touch workspace-server/).
platform-build:
name: Platform (Go)
needs: changes
runs-on: ubuntu-latest
# mc#774 (interim): re-mask platform-build pending fix-forward. Phase 4
# (#656) flipped this to continue-on-error: false based on a Phase-3-masked
# "green on main 2026-05-12" — the prior continue-on-error: true had
# been hiding failing tests in workspace-server/internal/handlers/.
# Two distinct failure classes surfaced on 0e5152c3:
# (1) 4x delegation_test.go (lines 1110/1176/1228/1271): helpers
# expectExecuteDelegationBase/Success/Failed are missing sqlmock
# expectations for queries production has issued since ~2026-04-21
# (last_outbound_at UPDATE, lookupDeliveryMode/Runtime SELECTs,
# a2a_receive INSERT activity_logs, recordLedgerStatus writes).
# Halt cond #3 applies (regression > 7 days → broader sweep).
# (2) 1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
# commit 7d1a189f (2026-05-10) hardened mcp.go to scrub err.Error()
# from JSON-RPC responses (OFFSEC-001), but the test asserts the
# error message contains "GLOBAL". Production-vs-test contract
# collision — needs design call, not mock update.
# Time-boxed Option A (90 min) did not fit the cross-cutting scope.
# This is a sequenced revert→fix→reflip per
# feedback_strict_root_only_after_class_a emergency clause — NOT
# a permanent re-mask. Re-flip blocked on mc#774 fix-forward landing.
# Other 4 #656 flips (changes, canvas-build, shellcheck, python-lint)
# retain continue-on-error: false; only platform-build regresses.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true # mc#774 fix-forward in flight; re-flip when mc#774 lands (PR #669 → rebase after #709)
defaults:
run:
working-directory: workspace-server
steps:
- if: needs.changes.outputs.platform != 'true'
working-directory: .
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.platform == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.platform == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- if: needs.changes.outputs.platform == 'true'
run: go mod download
- if: needs.changes.outputs.platform == 'true'
run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: needs.changes.outputs.platform == 'true'
run: go vet ./...
- if: needs.changes.outputs.platform == 'true'
name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
- if: needs.changes.outputs.platform == 'true'
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: needs.changes.outputs.platform == 'true'
name: Diagnostic — per-package verbose 60s
run: |
set +e
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
echo "::endgroup::"
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
tail -100 /tmp/test-pu.log
echo "::endgroup::"
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
- if: needs.changes.outputs.platform == 'true'
name: Run tests with race detection and coverage
run: go test -race -coverprofile=coverage.out ./...
- if: needs.changes.outputs.platform == 'true'
name: Per-file coverage report
# Advisory — lists every source file with its coverage so reviewers
# can see at-a-glance where gaps are. Sorted ascending so the worst
# offenders float to the top. Does NOT fail the build; the hard
# gate is the threshold check below. (#1823)
run: |
echo "=== Per-file coverage (worst first) ==="
go tool cover -func=coverage.out \
| grep -v '^total:' \
| awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++}
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- if: needs.changes.outputs.platform == 'true'
name: Check coverage thresholds
# Enforces two gates from #1823 Layer 1:
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
# 2. Per-file floor — non-test .go files in security-critical
# paths with coverage <10% fail the build, UNLESS the file
# path is listed in .coverage-allowlist.txt (acknowledged
# historical debt with a tracking issue + expiry).
run: |
set -e
TOTAL_FLOOR=25
# Security-critical paths where a 0%-coverage file is a real risk.
CRITICAL_PATHS=(
"internal/handlers/tokens"
"internal/handlers/workspace_provision"
"internal/handlers/a2a_proxy"
"internal/handlers/registry"
"internal/handlers/secrets"
"internal/middleware/wsauth"
"internal/crypto"
)
TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${TOTAL}%"
if awk "BEGIN{exit !($TOTAL < $TOTAL_FLOOR)}"; then
echo "::error::Total coverage ${TOTAL}% is below the ${TOTAL_FLOOR}% floor. See COVERAGE_FLOOR.md for ratchet plan."
exit 1
fi
# Aggregate per-file coverage → /tmp/perfile.txt: "<fullpath> <pct>"
go tool cover -func=coverage.out \
| grep -v '^total:' \
| awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++}
END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' \
> /tmp/perfile.txt
# Build allowlist — paths relative to workspace-server, one per line.
# Lines starting with # are comments.
ALLOWLIST=""
if [ -f ../.coverage-allowlist.txt ]; then
ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true)
fi
FAILED=0
WARNED=0
for path in "${CRITICAL_PATHS[@]}"; do
while read -r file pct; do
[[ "$file" == *_test.go ]] && continue
[[ "$file" == *"$path"* ]] || continue
awk "BEGIN{exit !($pct < 10)}" || continue
# Strip the package-import prefix so we can match .coverage-allowlist.txt
# entries written as paths relative to workspace-server/.
# Handle both module paths: platform/workspace-server/... and platform/...
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
WARNED=$((WARNED+1))
else
echo "::error file=workspace-server/$rel::Critical file at ${pct}% coverage — must be >=10% (target 80%). See #1823. To acknowledge as known debt, add this path to .coverage-allowlist.txt."
FAILED=$((FAILED+1))
fi
done < /tmp/perfile.txt
done
echo ""
echo "Critical-path check: $FAILED new failures, $WARNED allowlisted warnings."
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "$FAILED security-critical file(s) have <10% test coverage and are"
echo "NOT in the allowlist. These paths handle auth, tokens, secrets, or"
echo "workspace provisioning — a 0% file here is the exact gap that let"
echo "CWE-22, CWE-78, KI-005 slip through in past incidents. Either:"
echo " (a) add tests to raise coverage above 10%, or"
echo " (b) add the path to .coverage-allowlist.txt with an expiry date"
echo " and a tracking issue reference."
exit 1
fi
# Canvas (Next.js) — required check, always runs. Same always-run +
# per-step gating shape as platform-build. The two-job-sharing-name
# pattern attempted in PR #2321 doesn't satisfy branch protection
# (SKIPPED siblings count as not-passed regardless of SUCCESS
# siblings — verified empirically on PR #2314).
canvas-build:
name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
defaults:
run:
working-directory: canvas
steps:
- if: needs.changes.outputs.canvas != 'true'
working-directory: .
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.canvas == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.canvas == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- if: needs.changes.outputs.canvas == 'true'
run: rm -f package-lock.json && npm install
- if: needs.changes.outputs.canvas == 'true'
run: npm run build
- if: needs.changes.outputs.canvas == 'true'
name: Run tests with coverage
# Coverage instrumentation is configured in canvas/vitest.config.ts
# (provider: v8, reporters: text + html + json-summary). Step 2 of
# #1815 — wires coverage into CI so we get a baseline visible on
# every PR. No threshold gate yet; thresholds dial in (Step 3, also
# tracked in #1815) after the team sees what current coverage is.
run: npx vitest run --coverage
- name: Upload coverage summary as artifact
if: needs.changes.outputs.canvas == 'true' && always()
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
# v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not
# currently supported on GHES`. Drop this pin when Gitea ships
# the v4 protocol (tracked: post-Gitea-1.23 followup).
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2
with:
name: canvas-coverage-${{ github.run_id }}
path: canvas/coverage/
retention-days: 7
if-no-files-found: warn
# Shellcheck (E2E scripts) — required check, always runs.
shellcheck:
name: Shellcheck (E2E scripts)
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: needs.changes.outputs.scripts != 'true'
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.scripts == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.scripts == 'true'
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
# infra/scripts/ is included because setup.sh + nuke.sh gate the
# README quickstart — a shellcheck regression there silently breaks
# new-user onboarding. scripts/ is intentionally excluded until its
# pre-existing SC3040/SC3043 warnings are cleaned up.
run: |
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: needs.changes.outputs.scripts == 'true'
name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh
- if: needs.changes.outputs.scripts == 'true'
name: Run E2E bash unit tests (no live infra)
run: |
bash tests/e2e/test_model_slug.sh
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Write deploy reminder to step summary
env:
COMMIT_SHA: ${{ github.sha }}
# github.server_url resolves via the workflow-level env override
# to the Gitea instance, so the RUN_URL points at the Gitea run
# page (not github.com). See feedback_act_runner_github_server_url.
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# Write body to a temp file — avoids backtick escaping in shell.
cat > /tmp/deploy-reminder.md << 'BODY'
## Canvas build passed — deploy required
The `publish-canvas-image` workflow is now building a fresh Docker image
(`ghcr.io/molecule-ai/canvas:latest`) in the background.
Once it completes (~35 min), apply on the host machine with:
```bash
cd <runner-workspace>
git pull origin main
docker compose pull canvas && docker compose up -d canvas
```
If you need to rebuild from local source instead (e.g. testing unreleased
changes or a new `NEXT_PUBLIC_*` URL), use:
```bash
docker compose build canvas && docker compose up -d canvas
```
BODY
printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
"$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md
# Gitea has no commit-comments API; write to GITHUB_STEP_SUMMARY,
# which both GitHub Actions and Gitea Actions render as the
# workflow run's summary page. (#75 / PR-D)
cat /tmp/deploy-reminder.md >> "$GITHUB_STEP_SUMMARY"
# Python Lint & Test — required check, always runs.
python-lint:
name: Python Lint & Test
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
env:
WORKSPACE_ID: test
defaults:
run:
working-directory: workspace
steps:
- if: needs.changes.outputs.python != 'true'
working-directory: .
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
- if: needs.changes.outputs.python == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.changes.outputs.python == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
cache: pip
cache-dependency-path: workspace/requirements.txt
- if: needs.changes.outputs.python == 'true'
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
# Coverage flags + fail-under floor moved into workspace/pytest.ini
# (issue #1817) so local `pytest` and CI use identical config.
- if: needs.changes.outputs.python == 'true'
run: python -m pytest --tb=short
- if: needs.changes.outputs.python == 'true'
name: Per-file critical-path coverage (MCP / inbox / auth)
# MCP-critical Python files have a per-file floor on top of the
# 86% total floor in pytest.ini. See issue #2790 for full rationale.
run: |
set -e
PER_FILE_FLOOR=75
CRITICAL_FILES=(
"a2a_mcp_server.py"
"mcp_cli.py"
"a2a_tools.py"
"a2a_tools_inbox.py"
"inbox.py"
"platform_auth.py"
)
# pytest already wrote .coverage; emit a JSON view scoped to
# the critical files so jq/python can read the per-file pct
# without parsing tabular text.
INCLUDES=$(printf '*%s,' "${CRITICAL_FILES[@]}")
INCLUDES="${INCLUDES%,}"
python -m coverage json -o /tmp/critical-cov.json --include="$INCLUDES"
FAILED=0
for f in "${CRITICAL_FILES[@]}"; do
pct=$(jq -r --arg f "$f" '.files | to_entries | map(select(.key == $f)) | .[0].value.summary.percent_covered // "MISSING"' /tmp/critical-cov.json)
if [ "$pct" = "MISSING" ]; then
echo "::error file=workspace/$f::No coverage data — file may have moved or test exclusion mis-set."
FAILED=$((FAILED+1))
continue
fi
echo "$f: ${pct}%"
if awk "BEGIN{exit !($pct < $PER_FILE_FLOOR)}"; then
echo "::error file=workspace/$f::${pct}% < ${PER_FILE_FLOOR}% per-file floor (MCP critical path). See COVERAGE_FLOOR.md."
FAILED=$((FAILED+1))
fi
done
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "$FAILED MCP critical-path file(s) below the ${PER_FILE_FLOOR}% per-file floor."
echo "These paths handle multi-tenant routing, auth tokens, and inbox dispatch."
echo "A coverage drop here is the same risk shape as Go-side tokens/secrets files"
echo "dropping below 10% (see COVERAGE_FLOOR.md). Either:"
echo " (a) add tests to raise coverage back above ${PER_FILE_FLOOR}%, or"
echo " (b) if this is unavoidable historical debt, file an issue and propose"
echo " adjusting the floor with rationale in COVERAGE_FLOOR.md."
exit 1
fi
all-required:
# Aggregator sentinel — RFC internal#219 §2 (Phase 4 — closes internal#286).
#
# Single stable required-status name that branch protection points at;
# CI churns underneath in `needs:` without any protection edits. Mirrors
# the molecule-controlplane Phase 2a impl shipped in CP PR#112 and
# referenced by `internal#286` ("Phase 4 is a single small PR... mirrors
# CP's existing one").
#
# Closes the failure mode where status_check_contexts on molecule-core/main
# only listed `Secret scan` + `sop-tier-check` (the 2 meta-gates), so real
# `Platform (Go)` / `Canvas (Next.js)` / `Python Lint & Test` / `Shellcheck`
# red silently merged through. See internal#286 for the three concrete
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
#
# Three properties of this job each close a failure mode:
#
# 1. `if: always()` — runs even when an upstream fails. Without it the
# sentinel is `skipped` and protection treats that as missing → merge
# ungated.
#
# 2. Assertion is `result == "success"` per dep, NOT `!= "failure"`.
# A `skipped` upstream (job gated by `if:` evaluating false, matrix
# entry that couldn't run) must NOT silently pass through.
# `skipped`-as-green is exactly the failure mode this gate closes.
#
# 3. `needs:` is the canonical list of "what counts as required."
# status_check_contexts will reference only `ci/all-required` (Step 5
# follow-up — branch-protection PATCH is Owners-tier per
# `feedback_never_admin_merge_bypass`, separate PR); a new job is
# added simply by listing it in `needs:` here.
# `.gitea/workflows/ci-required-drift.yml` files a [ci-drift] issue
# hourly if this list diverges from status_check_contexts or from
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
#
# Excluded from `needs:`: `canvas-deploy-reminder` — gated by
# `if: ... github.event_name == 'push' && github.ref == 'refs/heads/main'`,
# so on PR events it's legitimately `skipped`. The drift detector
# explicitly excludes `github.event_name`-gated jobs from F1 (see
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
#
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
# (Gitea suppresses status reporting for CoE jobs). This sentinel
# runs with continue-on-error: false so it always reports its
# result to the API — without this, the required-status entry
# (CI / all-required (pull_request)) is never created, which
# blocks PR merges. When Phase 3 ends, flip underlying jobs to
# continue-on-error: false; this sentinel can then be flipped to
# continue-on-error: true if a Phase-4 regression requires it.
continue-on-error: false
runs-on: ubuntu-latest
timeout-minutes: 1
needs:
- changes
- platform-build
- canvas-build
- shellcheck
- python-lint
if: always()
steps:
- name: Assert every required dependency succeeded
run: |
set -euo pipefail
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
# Null results are skipped: they come from Phase 3 (continue-on-error: true
# suppresses status) or from jobs still in-flight. The sentinel succeeds
# rather than blocking PRs on Phase 3 noise.
results='${{ toJSON(needs) }}'
echo "$results"
echo "$results" | python3 -c '
import json, sys
ns = json.load(sys.stdin)
# Phase 3 masked: jobs with continue-on-error: true may report "failure"
# Remove when mc#774 handler test failures are resolved.
PHASE3_MASKED = {"platform-build"}
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
bad = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED]
if bad:
print(f"FAIL: jobs not green:", file=sys.stderr)
for k, r in bad:
print(f" - {k}: {r}", file=sys.stderr)
sys.exit(1)
pending = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") is None]
cancelled = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") == "cancelled"]
if pending:
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
", ".join(k for k, _ in pending), file=sys.stderr)
if cancelled:
print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " +
", ".join(k for k, _ in cancelled), file=sys.stderr)
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
'
@@ -0,0 +1,100 @@
name: publish-runtime-autobump
# Auto-bump-on-workspace-edit half of the publish pipeline.
#
# Why this file exists (issue #351):
# Gitea Actions does not correctly disambiguate `paths:` from `tags:`
# when both are bundled under a single `on.push` key. The result is
# that tag pushes get filtered out and `publish-runtime.yml` never
# fires — `action_run` rows: 0. This was unnoticed pre-2026-05-11
# because PYPI_TOKEN was absent (publishes would have failed anyway).
#
# Split design:
# - publish-runtime.yml : on.push.tags only (the publisher)
# - publish-runtime-autobump.yml: on.push.branches+paths (this file — the version-bumper)
#
# This file computes the next version from PyPI's latest, pushes a
# `runtime-v$VERSION` tag, and exits. The tag push then triggers
# publish-runtime.yml via its tags-only trigger.
#
# Concurrency: shares the `publish-runtime` group with publish-runtime.yml
# so concurrent workspace pushes serialize at the bump step. Without
# this, two pushes minutes apart could both read PyPI latest=0.1.129
# and try to tag 0.1.130 simultaneously, only one of which would land.
on:
push:
branches:
- main
- staging
paths:
- "workspace/**"
permissions:
contents: write # required to push tags back
concurrency:
group: publish-runtime
cancel-in-progress: false
jobs:
autobump-and-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Fetch full tag list so the bump logic can sanity-check against
# what's already in this repo (catches collision with prior
# manual tag pushes).
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Compute next version from PyPI latest
id: bump
run: |
set -eu
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
PATCH=$(echo "$LATEST" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
echo "PyPI latest=$LATEST -> next=$VERSION"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
exit 1
fi
if git tag --list | grep -qx "runtime-v$VERSION"; then
echo "::error::tag runtime-v$VERSION already exists in this repo. Manual intervention required (PyPI and Gitea tag history are out of sync)."
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Push runtime-v$VERSION tag
env:
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
VERSION: ${{ steps.bump.outputs.version }}
GITEA_URL: https://git.moleculesai.app
run: |
set -eu
if [ -z "$DISPATCH_TOKEN" ]; then
echo "::error::DISPATCH_TOKEN secret is not set — needed to push the tag back to molecule-core."
exit 1
fi
git config user.name "publish-runtime autobump"
git config user.email "publish-runtime@moleculesai.app"
git tag -a "runtime-v$VERSION" \
-m "Auto-bump on workspace/** edit on $GITHUB_REF" \
-m "Triggered by: $GITHUB_REF @ $GITHUB_SHA" \
-m "publish-runtime.yml will pick up this tag and upload to PyPI"
# Push via DISPATCH_TOKEN (a Gitea PAT). Using the bot identity
# ensures the resulting tag-push event is dispatched to
# publish-runtime.yml; act_runner's default GITHUB_TOKEN cannot
# trigger downstream workflows.
git remote set-url origin "${GITEA_URL#https://}"
git remote set-url origin "https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/molecule-ai/molecule-core.git"
git push origin "runtime-v$VERSION"
echo "✓ pushed runtime-v$VERSION — publish-runtime.yml should fire next"
+41 -15
View File
@@ -12,7 +12,24 @@ name: publish-runtime
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/tags/}`
# — Gitea Actions exposes github.ref (the full ref) but not ref_name
# - Dropped `merge_group` trigger (Gitea has no merge queue)
# - Dropped `staging` branch trigger (no staging branch exists in this repo)
#
# 2026-05-10 (issue #348): originally restored `staging`/`main` branch +
# `workspace/**` path-filter trigger in PR #349.
#
# 2026-05-11 (issue #351): REVERTED the branches+paths trigger from THIS
# file. Bundling `paths` with `tags` under a single `on.push` key caused
# Gitea Actions to never dispatch the workflow for tag-push events (0
# runs in `action_run` for workflow_id='publish-runtime.yml' since the
# port, including the runtime-v1.0.0 tag — which is why PyPI is still at
# 0.1.129 despite a v1.0.0 Gitea tag existing).
#
# The auto-bump-on-workspace-edit trigger now lives in
# `.gitea/workflows/publish-runtime-autobump.yml`. That file computes the
# next version from PyPI's latest and pushes a `runtime-v$VERSION` tag,
# which THIS file then picks up via the tags-only trigger below.
#
# This decoupling means Gitea's path-vs-tag evaluator never has to
# disambiguate — each file has a single unambiguous trigger shape.
#
# PyPI publishing: requires PYPI_TOKEN repository secret (or org-level secret).
# Set via: repo Settings → Actions → Variables and Secrets → New Secret.
@@ -26,11 +43,17 @@ on:
tags:
- "runtime-v*"
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
required: true
type: string
# 2026-05-11 (root cause of #351 / 0 runs ever):
# Gitea 1.22.6's workflow parser rejects `workflow_dispatch.inputs.version`
# with "unknown on type" — it mis-treats the inputs sub-keys as top-level
# `on:` event types. Log line:
# actions/workflows.go:DetectWorkflows() [W] ignore invalid workflow
# "publish-runtime.yml": unknown on type: map["version": {...}]
# That `[W] ignore invalid workflow` is silent UX — the workflow never
# registers, so it never fires for ANY event (push.tags included).
# Removing the inputs block restores parsing. Manual dispatch from the
# Gitea UI now triggers the PyPI auto-bump fallback in `Derive version`
# below (no `inputs.version` to read).
permissions:
contents: read
@@ -55,20 +78,15 @@ jobs:
python-version: "3.11"
cache: pip
- name: Derive version (tag, manual input, or PyPI auto-bump)
- name: Derive version (tag or PyPI auto-bump)
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
elif echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
if echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
# Tag is `runtime-vX.Y.Z` — strip the prefix.
VERSION="${GITHUB_REF#refs/tags/runtime-v}"
else
# Fallback: derive from PyPI latest + patch bump.
# (The staging-push auto-bump trigger is dropped on Gitea —
# no staging branch exists. This fallback path is kept for
# robustness if a future automation uses workflow_dispatch without
# an explicit version input.)
# workflow_dispatch path (no inputs supported on Gitea 1.22.6) or
# any other non-tag trigger: derive from PyPI latest + patch bump.
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
@@ -121,6 +139,14 @@ jobs:
/tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/wheel_smoke.py"
- name: Publish to PyPI
# working-directory matches the preceding Build/Verify steps. Without
# this, twine runs from the default workspace checkout dir where
# `dist/` doesn't exist and fails with:
# ERROR InvalidDistribution: Cannot find file (or expand pattern): 'dist/*'
# Caught on the first-ever successful dispatch of this workflow
# (run 5097, 2026-05-11 02:08Z) — every other step in the publish
# job already had this working-directory; Publish was missing it.
working-directory: ${{ runner.temp }}/runtime-build
env:
# PYPI_TOKEN: repository secret scoped to molecule-ai-workspace-runtime.
# Set via: Settings → Actions → Variables and Secrets → New Secret.
-121
View File
@@ -1,121 +0,0 @@
# sop-checklist-gate — peer-ack merge gate for SOP-checklist items.
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
# === DESIGN ===
#
# Goal: each PR must answer 7 SOP-checklist questions in its body,
# and each item must have at least one /sop-ack <slug> comment from
# a non-author peer in the required team. BP requires the
# `sop-checklist / all-items-acked (pull_request)` status to merge.
#
# Triggers:
# - `pull_request_target`: opened, edited, synchronize, reopened
# → fires when PR opens, body is edited (refire — RFC#351 §4),
# or new code is pushed (head.sha changes → stale status would
# be auto-discarded by BP via dismiss_stale_reviews, but the
# status itself is per-SHA so we re-post on the new head).
# - `issue_comment`: created, edited, deleted
# → fires on any new comment so /sop-ack / /sop-revoke take
# effect immediately (Gitea 1.22.6 doesn't refire on
# pull_request_review per feedback_pull_request_review_no_refire,
# so issue_comment is the canonical refire channel).
#
# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note):
# `pull_request_target` (not `pull_request`) — workflow def is loaded
# from BASE branch, so a PR cannot rewrite this workflow to exfiltrate
# the token. The `actions/checkout` step pins `ref: base.sha` so the
# script ALSO comes from BASE. PR-HEAD code is never executed in the
# runner.
#
# Token scope:
# - read:repository, read:organization for PR + comments + team probes
# - write:repository for POST /statuses/{sha}
# - The token owner MUST be a member of every team referenced by the
# config's required_teams (else /teams/{id}/members/{login} returns
# 403 — see review-check.sh same-gotcha doc). For the MVP we use
# the dev-lead token (a member of engineers, managers, qa, security)
# via a repo secret `SOP_CHECKLIST_GATE_TOKEN`. Provisioning of that
# secret is a follow-up authorization step (separate from this PR).
#
# Failure mode: tier-aware (RFC#351 open question 2):
# - tier:high → state=failure (hard-fail; BP blocks merge)
# - tier:medium → state=failure (hard-fail; same)
# - tier:low → state=pending (soft-fail; BP can choose to require
# this context or skip for low-tier PRs)
# - missing/no-tier → state=failure (default-mode: hard — never lower
# the bar per feedback_fix_root_not_symptom)
#
# Slash-command contract (RFC#351 v1 + §A1.1-style notes from RFC#324):
#
# /sop-ack <slug-or-numeric-alias> [optional note]
# — register a peer-ack for one checklist item.
# — slug accepts kebab-case, snake_case, or natural-spaces
# (all normalize to canonical kebab-case).
# — numeric 1..7 maps via config.items[*].numeric_alias.
# — most-recent (user, slug) directive wins.
#
# /sop-revoke <slug-or-numeric-alias> [reason]
# — invalidate the commenter's own prior /sop-ack for this slug.
# — does NOT affect other peers' acks on the same slug.
# — most-recent (user, slug) directive wins, so a later /sop-ack
# re-restores the ack.
#
# The eval is read-only + idempotent (read PR + comments + team
# membership, compute, post status). Re-running on any event is safe —
# the new status overwrites the previous one for the same context.
name: sop-checklist-gate
on:
pull_request_target:
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
issue_comment:
types: [created, edited, deleted]
permissions:
contents: read
pull-requests: read
# NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses.
# Gitea 1.22.6 may not gate on this permission key (it just checks the
# token), but listing it explicitly documents intent for the next
# platform-version upgrade.
statuses: write
jobs:
gate:
# Run on pull_request_target events always. On issue_comment events,
# only when the comment is on a PR (issue_comment fires for issues
# too) and the body contains one of the slash-commands.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(contains(github.event.comment.body, '/sop-ack') ||
contains(github.event.comment.body, '/sop-revoke')))
runs-on: ubuntu-latest
steps:
- name: Check out BASE ref (trust boundary — never PR-head)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# For pull_request_target, the default branch is the trust
# anchor. For issue_comment the PR base may differ from the
# default branch (PR targeting `staging`), so we use the
# default-branch ref explicitly — same approach as
# qa-review.yml so the script source is always trusted.
ref: ${{ github.event.repository.default_branch }}
- name: Run sop-checklist-gate
env:
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -euo pipefail
python3 .gitea/scripts/sop-checklist-gate.py \
--owner "$OWNER" \
--repo "$REPO_NAME" \
--pr "$PR_NUMBER" \
--config .gitea/sop-checklist-config.yaml \
--gitea-host git.moleculesai.app
+16 -35
View File
@@ -79,48 +79,29 @@ jobs:
ref: ${{ github.event.pull_request.base.sha }}
- name: Install jq
# Gitea Actions runners (ubuntu-latest label) do not bundle jq.
# The sop-tier-check script uses jq for all JSON API parsing.
# Install jq before the script runs so sop-tier-check can pass.
#
# Method: apt-get first (reliable for Ubuntu runners with internet
# access to package mirrors). Falls back to GitHub binary download.
# GitHub releases may be unreachable from some runner networks
# (infra#241 follow-up: GitHub timeout after 3s on 5.78.80.188
# runners). The sop-tier-check script has its own fallback as a
# third line of defense. continue-on-error: true ensures this step
# failing does not block the job.
continue-on-error: true
run: |
# apt-get is the primary method — Ubuntu package mirrors are reliably
# reachable from runner containers. GitHub releases may be blocked
# or slow on some networks (infra#241 follow-up).
if apt-get update -qq && apt-get install -y -qq jq; then
echo "::notice::jq installed via apt-get: $(jq --version)"
elif timeout 120 curl -sSL \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq; then
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
else
echo "::warning::jq install failed — apt-get and GitHub download both failed."
fi
jq --version 2>/dev/null || echo "::notice::jq not yet available — script fallback will retry"
# The script uses jq extensively for all JSON parsing; install it
# before the script runs. Using -qq for quiet output — diagnostic
# info is already captured via SOP_DEBUG=1 on failure.
run: apt-get update -qq && apt-get install -y -qq jq
- name: Verify tier label + reviewer team membership
# continue-on-error: true at step level — job-level is ignored by Gitea
# Actions (quirk #10, internal runbooks). Belt-and-suspenders with
# SOP_FAIL_OPEN=1 + || true below.
continue-on-error: true
env:
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
# sop-tier-bot PAT (read:organization,read:user,read:issue,
# read:repository). Stored at the org level
# (/api/v1/orgs/molecule-ai/actions/secrets) so per-repo
# configuration is unnecessary — every repo in the org
# picks it up automatically.
# Falls back to GITHUB_TOKEN with a clear error if missing.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
# Set to '1' for diagnostic per-API-call output. Off by default
# so production logs aren't noisy.
SOP_DEBUG: '0'
# BURN-IN: set to '1' for PRs in-flight at AND-composition deploy
# time to use the legacy OR-gate. Remove after 2026-05-17.
SOP_LEGACY_CHECK: '0'
# SOP_FAIL_OPEN=1 makes the script always exit 0. The UI enforces
# the actual merge gate. Combined with continue-on-error: true
# above, this step never fails the job regardless of script exit.
SOP_FAIL_OPEN: '1'
run: |
bash .gitea/scripts/sop-tier-check.sh || true
run: bash .gitea/scripts/sop-tier-check.sh
+1 -1
View File
@@ -365,7 +365,7 @@ jobs:
cache: pip
cache-dependency-path: workspace/requirements.txt
- if: needs.changes.outputs.python == 'true'
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
# Coverage flags + fail-under floor moved into workspace/pytest.ini
# (issue #1817) so local `pytest` and CI use identical config.
- if: needs.changes.outputs.python == 'true'
@@ -1,278 +0,0 @@
name: publish-workspace-server-image
# Builds and pushes Docker images to GHCR on staging or main pushes.
# EC2 tenant instances pull the tenant image from GHCR.
#
# Branch / tag policy (see Compute tags step for the per-branch logic):
#
# staging push → builds image, tags :staging-<sha> + :staging-latest.
# staging-CP pins TENANT_IMAGE=:staging-latest, so it
# picks up staging-branch code automatically. This is
# what makes staging-CP actually test staging-branch
# code instead of "yesterday's main" — pre-fix, this
# workflow only ran on main, so staging tenants
# silently served stale code (#2308 fix RFC #2312
# landed on staging but never reached tenants because
# staging→main was wedged on path-filter parity bugs).
#
# main push → builds image, tags :staging-<sha> + :staging-latest
# (same as before). canary-verify.yml retags
# :staging-<sha> → :latest after canary tenants
# green-light the digest. The :staging-latest retag
# on main push is intentional: when main lands AFTER a
# staging push, staging-CP gets the post-promote code
# (which equals what it had + any merge resolution),
# so the canary-on-staging-CP step still runs against
# the prod-bound digest.
#
# In the steady state both branches refresh :staging-latest; the
# semantic is "most recent staging-or-main build of tenant code."
# Drift between the two is bounded by the staging→main auto-promote
# cadence and is corrected on the next staging push.
on:
push:
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.github/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 (the post-promote
# main code equals current staging code in a healthy flow).
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image and keeps the
# canary fleet pin (:staging-<sha>) consistent with what was actually
# tested at canary-verify time.
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
# github-app-auth sibling-checkout removed 2026-05-07 (#157):
# plugin was dropped + workspace-server/Dockerfile no longer
# COPYs it.
# ECR auth + buildx setup are now inline in each build step
# below (Task #173, 2026-05-07).
#
# Why moved inline: aws-actions/configure-aws-credentials@v4 +
# aws-actions/amazon-ecr-login@v2 + docker/setup-buildx-action
# all left auth state in places that the actual `docker push`
# couldn't see on Gitea Actions:
# - The actions wrote to a step-scoped DOCKER_CONFIG path
# that didn't survive into subsequent shell steps.
# - Buildx couldn't bridge the runner container ↔
# operator-host docker daemon auth gap (401 on the
# docker-container driver, "no basic auth credentials"
# with the action-driven login).
#
# Doing AWS+ECR auth inline (`aws ecr get-login-password |
# docker login`) in the same shell step as `docker build` +
# `docker push` is the operator-host manual approach, mapped
# 1:1 into CI. Auth state is guaranteed to live in the env that
# `docker push` actually runs from.
#
# Post-suspension target is the operator's ECR org
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*),
# which already hosts platform-tenant + workspace-template-* +
# runner-base images. AWS creds come from the
# AWS_ACCESS_KEY_ID/SECRET secrets bound to the molecule-cp
# IAM user. Closes #161.
- name: Compute tags
id: tags
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build (Task #173 fix).
#
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on
# Gitea (codex, crewai, deepagents, gemini-cli, langgraph) plus all
# 7 org-template-* repos are private. The pre-fix Dockerfile.tenant
# ran `git clone` inside an in-image stage, which had no auth path
# — every CI build failed with "fatal: could not read Username for
# https://git.moleculesai.app". For weeks, every workspace-server
# rebuild required a manual operator-host push. Now we clone in the
# trusted CI context (where AUTO_SYNC_TOKEN is naturally available)
# and Dockerfile.tenant just COPYs from .tenant-bundle-deps/.
#
# Token shape: AUTO_SYNC_TOKEN is the devops-engineer persona PAT
# (see /etc/molecule-bootstrap/agent-secrets.env). Per saved memory
# `feedback_per_agent_gitea_identity_default`, every CI surface uses
# a per-persona token, never the founder PAT. clone-manifest.sh
# embeds it as basic-auth (oauth2:<token>) for the duration of the
# clones, then strips .git directories — the token never enters
# the resulting image.
#
# Idempotent: if a re-run finds populated dirs, clone-manifest.sh
# skips them; safe to retrigger via path-filter or workflow_dispatch.
- 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 — register the devops-engineer persona PAT in repo Actions secrets"
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
# Sanity-check counts so a silent partial clone fails fast
# instead of producing a half-empty image.
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"
# Counts are derived from manifest.json (9 ws / 7 org / 21
# plugins as of 2026-05-07). If manifest.json grows but the
# clone step regresses silently, the find above caps at the
# actual disk state — but clone-manifest.sh's own EXPECTED vs
# CLONED check (line ~95) is the authoritative fail-fast.
# Canary-gated release flow:
# - This step always publishes :staging-<sha> + :staging-latest.
# - On staging push, staging-CP picks up :staging-latest immediately
# (its TENANT_IMAGE pin is :staging-latest) — so staging-branch
# code reaches staging tenants without waiting for main.
# - On main push, canary-verify.yml runs smoke tests against
# canary tenants (which pin :staging-<sha>), and on green retags
# :staging-<sha> → :latest. Prod tenants pull :latest.
# - On red, :latest stays on the prior good digest — prod is safe.
#
# Why :staging-latest is retagged on main push too: when main lands
# after a staging promote, staging-CP gets the post-promote code so
# the canary-on-staging-CP step still runs against the prod-bound
# digest. In a healthy flow the post-promote main code == the
# current staging code, so this is effectively a no-op except for
# the canary fleet pin handoff.
#
# Pre-fix history: this workflow used to only trigger on main. That
# meant staging-CP served "yesterday's main" indefinitely whenever
# staging→main was wedged. The 2026-04-30 dogfooding session
# surfaced this when RFC #2312 (chat upload HTTP-forward) landed on
# staging but staging tenants kept failing chat upload because they
# were running pre-RFC code. Adding the staging trigger above closes
# that gap. Earlier 2026-04-24 incident: a static :staging-<sha> pin
# drifted 10 days behind staging — same class of bug, different
# mechanism. ECR repo molecule-ai/platform created 2026-05-07.
# Build + push platform image with plain `docker` (no buildx).
# GIT_SHA bakes into the Go binary via -ldflags so /buildinfo
# returns it at runtime — see Dockerfile + buildinfo/buildinfo.go.
# The OCI revision label below carries the same value for registry
# tooling; the duplication is intentional.
- 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 auth in-step so config.json is populated in the same
# shell env that runs `docker push`. ECR get-login-password
# tokens last 12h, plenty for a single-step build+push.
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 (Go API server) — 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}"
# Canvas uses same-origin fetches. The tenant Go platform
# reverse-proxies /cp/* to the SaaS CP via its CP_UPSTREAM_URL
# env; the tenant's /canvas/viewport, /approvals/pending,
# /org/templates etc. live on the tenant platform itself.
# Both legs share one origin (the tenant subdomain) so
# PLATFORM_URL="" forces canvas to fetch paths as relative,
# which land same-origin.
#
# Self-hosted / private-label deployments override this at
# build time with a specific backend (e.g. local dev:
# NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080).
- 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
# Re-login: the platform-image step's docker login wrote to
# the same config.json, so this is technically redundant — but
# making each push step self-contained keeps the workflow
# robust to step reordering / future extraction.
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}"
+3 -3
View File
@@ -142,7 +142,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
aria-label="Refresh audit trail"
>
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
type="button"
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
@@ -209,7 +209,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
>
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
@@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-ink-mid hover:text-ink-mid text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-ink-mid hover:text-ink-mid text-xs"
>
<span aria-hidden="true"></span>
</button>
@@ -115,7 +115,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button
type="button"
aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-ink-mid hover:text-ink-mid text-lg px-2"
>
</button>
@@ -286,7 +286,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
>
Close
</button>
@@ -411,7 +411,7 @@ export function CreateWorkspaceButton() {
tabIndex={tier === t.value ? 0 : -1}
onClick={() => setTier(t.value)}
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
className={`py-2 rounded-lg text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
className={`py-2 rounded-lg text-center transition-colors ${
tier === t.value
? "bg-accent-strong/20 border border-accent/50 text-accent"
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
+2 -2
View File
@@ -83,7 +83,7 @@ export class ErrorBoundary extends React.Component<
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
>
Reload
</button>
@@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component<
e.preventDefault();
this.handleReport();
}}
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
>
Report
</a>
@@ -198,7 +198,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t
? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid"
@@ -309,7 +309,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
>
I&apos;ve saved it close
</button>
@@ -339,7 +339,7 @@ function SnippetBlock({
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -376,7 +376,7 @@ function Field({
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery('');
}}
aria-label="Clear search"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none"
>
×
</button>
@@ -381,7 +381,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
type="button"
onClick={loadEntries}
disabled={pluginUnavailable}
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Refresh memories"
>
Refresh
@@ -515,7 +515,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
@@ -629,7 +629,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
onDelete();
}}
aria-label="Forget memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
>
Forget
</button>
+5 -5
View File
@@ -632,7 +632,7 @@ function AllKeysModal({
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
aria-label="Dismiss modal"
aria-hidden="true"
onClick={onCancel}
/>
@@ -706,7 +706,7 @@ function AllKeysModal({
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -730,7 +730,7 @@ function AllKeysModal({
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[11px] text-accent hover:text-accent transition-colors"
>
Open Settings Panel
</button>
@@ -740,7 +740,7 @@ function AllKeysModal({
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Cancel Deploy
</button>
@@ -748,7 +748,7 @@ function AllKeysModal({
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
</button>
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
>
Import
</button>
@@ -428,7 +428,7 @@ function StrictEnvRow({
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{d?.saving ? "…" : "Save"}
</button>
+1 -1
View File
@@ -128,7 +128,7 @@ function PlanCard({
type="button"
onClick={onSelect}
disabled={loading}
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
plan.highlighted
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
@@ -437,7 +437,7 @@ export function ProviderModelSelector({
handleModelChange(selected.models[0]?.id ?? "");
}
}}
className="text-[9px] text-accent hover:text-accent mt-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[9px] text-accent hover:text-accent mt-0.5"
>
back to model list
</button>
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
>
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button>
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
>
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
>
View Logs
</button>
@@ -382,14 +382,14 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Keep
</button>
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
>
Remove Workspace
</button>
+1 -1
View File
@@ -197,7 +197,7 @@ export function SidePanel() {
type="button"
onClick={() => selectNode(null)}
aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
+6 -6
View File
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors"
>
<span
aria-hidden="true"
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[10px] text-ink-mid hover:text-ink-mid"
>
</button>
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
>
{isImporting ? "Importing…" : "Import org"}
</button>
@@ -411,7 +411,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
>
{importing ? "Importing..." : "Import Agent Folder"}
</button>
@@ -474,7 +474,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
open
? "bg-accent-strong text-white"
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
@@ -580,7 +580,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block"
>
Refresh templates
</button>
+1 -1
View File
@@ -54,7 +54,7 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
aria-label={opt.label}
onClick={() => setTheme(opt.value)}
className={
"flex h-6 w-6 items-center justify-center rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface " +
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
(active
? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink-mid")
@@ -2,9 +2,8 @@
/**
* Tests for ApprovalBanner component.
*
* Uses vi.hoisted + vi.mock for stable module-level API mocks that survive
* vi.resetModules() cleanup. BeforeEach uses mockReset + mockResolvedValue
* so each test gets a clean slate.
* Covers: renders nothing when no approvals, polls /approvals/pending,
* shows approval cards, approve/deny decisions, toast notifications.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
@@ -13,23 +12,10 @@ import { ApprovalBanner } from "../ApprovalBanner";
import { showToast } from "@/components/Toaster";
import { api } from "@/lib/api";
// ─── Module-level mocks ───────────────────────────────────────────────────────
// vi.hoisted captures stable references BEFORE hoisting so they are accessible
// in the test body after vi.mock registers.
const _mockGet = vi.hoisted<typeof api.get>(() => vi.fn<() => Promise<unknown[]>>());
const _mockPost = vi.hoisted<typeof api.post>(() => vi.fn<() => Promise<unknown>>());
const _mockToast = vi.hoisted<typeof showToast>(() => vi.fn());
vi.mock("@/lib/api", () => ({
api: { get: _mockGet, post: _mockPost },
}));
vi.mock("@/components/Toaster", () => ({
showToast: _mockToast,
showToast: vi.fn(),
}));
afterEach(cleanup);
// ─── Helpers ──────────────────────────────────────────────────────────────────
const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
@@ -50,25 +36,11 @@ const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
created_at: "2026-05-10T10:00:00Z",
});
// ─── Cleanup ─────────────────────────────────────────────────────────────────
beforeEach(() => {
_mockGet.mockReset();
_mockGet.mockResolvedValue([] as unknown[]);
_mockPost.mockReset();
_mockPost.mockResolvedValue({} as unknown);
_mockToast.mockClear();
});
afterEach(() => {
cleanup();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
it("renders nothing when there are no pending approvals", async () => {
_mockGet.mockResolvedValueOnce([] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -77,7 +49,7 @@ describe("ApprovalBanner — empty state", () => {
});
it("does not render any approve/deny buttons when list is empty", async () => {
_mockGet.mockResolvedValueOnce([] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -89,10 +61,10 @@ describe("ApprovalBanner — empty state", () => {
describe("ApprovalBanner — renders approval cards", () => {
it("renders an alert card for each pending approval", async () => {
_mockGet.mockResolvedValueOnce([
vi.spyOn(api, "get").mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
] as unknown[]);
]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -102,7 +74,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("displays the workspace name and action text", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -112,7 +84,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("displays the reason when present", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -123,7 +95,7 @@ describe("ApprovalBanner — renders approval cards", () => {
it("omits the reason div when reason is null", async () => {
const approval = pendingApproval("a1");
approval.reason = null;
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -132,7 +104,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("renders both Approve and Deny buttons per card", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -142,7 +114,7 @@ describe("ApprovalBanner — renders approval cards", () => {
});
it("has aria-live=assertive on the alert container", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -164,7 +136,7 @@ describe("ApprovalBanner — polling", () => {
});
it("clears the polling interval on unmount", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
const { unmount } = render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -177,8 +149,8 @@ describe("ApprovalBanner — polling", () => {
describe("ApprovalBanner — decisions", () => {
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
const approval = pendingApproval("a1", "ws-1");
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -188,17 +160,17 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(_mockPost).toHaveBeenCalledWith(
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "approved", decided_by: "human" },
{ decision: "approved", decided_by: "human" }
);
});
});
it("calls POST with decision=denied on Deny click", async () => {
const approval = pendingApproval("a1", "ws-1");
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -208,17 +180,17 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(_mockPost).toHaveBeenCalledWith(
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "denied", decided_by: "human" },
{ decision: "denied", decided_by: "human" }
);
});
});
it("removes the card from state after a successful decision", async () => {
const approval = pendingApproval("a1", "ws-1");
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -236,8 +208,8 @@ describe("ApprovalBanner — decisions", () => {
});
it("shows a success toast on approve", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -247,13 +219,13 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(_mockToast).toHaveBeenCalledWith("Approved", "success");
expect(showToast).toHaveBeenCalledWith("Approved", "success");
});
});
it("shows an info toast on deny", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
_mockPost.mockResolvedValueOnce({} as unknown);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
@@ -263,18 +235,13 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(_mockToast).toHaveBeenCalledWith("Denied", "info");
expect(showToast).toHaveBeenCalledWith("Denied", "info");
});
});
it("shows an error toast when POST fails", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
// Use mockImplementation instead of mockRejectedValueOnce so the vi.fn
// wrapper is preserved — the component's catch block needs the resolved
// promise wrapper to distinguish a rejected-from-mock vs thrown-from-code.
_mockPost.mockImplementation(
() => new Promise((_, reject) => reject(new Error("Network error"))),
);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
@@ -284,15 +251,13 @@ describe("ApprovalBanner — decisions", () => {
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(_mockToast).toHaveBeenCalledWith("Failed to submit decision", "error");
expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error");
});
});
it("keeps the card visible when the POST fails", async () => {
_mockGet.mockResolvedValueOnce([pendingApproval("a1")] as unknown[]);
_mockPost.mockImplementation(
() => new Promise((_, reject) => reject(new Error("Network error"))),
);
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
@@ -310,7 +275,7 @@ describe("ApprovalBanner — decisions", () => {
describe("ApprovalBanner — handles empty list from server", () => {
it("shows nothing when the API returns an empty array on first poll", async () => {
_mockGet.mockResolvedValueOnce([] as unknown[]);
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
@@ -1,267 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for EmptyState component — the full-canvas welcome card on first load.
*
* Pattern: all vi.fn() refs are created by a SINGLE vi.hoisted() call,
* returned as a named-const object. Individual vi.mock factories then
* import that object and pull out the fields they need. This avoids
* "Cannot access before initialization" errors from vi.mock hoisting.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { EmptyState } from "../EmptyState";
// ─── Module-level mocks ───────────────────────────────────────────────────────
// vi.hoisted is evaluated after module-level vars are declared, so these
// refs are stable and accessible inside vi.mock factories (which are
// hoisted above everything). We return an object so a SINGLE hoisted call
// creates all mocks; each vi.mock then references m.<field>.
const m = vi.hoisted(() => {
const mockGet = vi.fn<() => Promise<unknown[]>>();
const mockPost = vi.fn<() => Promise<{ id: string }>>();
const mockCheckDeploySecrets = vi.fn<
() => Promise<{
ok: boolean;
missingKeys: string[];
providers: string[];
runtime: string;
configuredKeys: string[];
}>
>();
const mockSelectNode = vi.fn<(id: string) => void>();
const mockSetPanelTab = vi.fn<(tab: string) => void>();
const mockDeploy = vi.fn<(t: { id: string; name: string }) => Promise<void>>();
const mockUseTemplateDeploy = vi.fn(() => ({
deploy: mockDeploy,
deploying: false,
error: null,
modal: null,
}));
return {
mockGet,
mockPost,
mockCheckDeploySecrets,
mockSelectNode,
mockSetPanelTab,
mockDeploy,
mockUseTemplateDeploy,
};
});
vi.mock("@/lib/api", () => ({
api: { get: m.mockGet, post: m.mockPost },
}));
vi.mock("@/lib/deploy-preflight", () => ({
checkDeploySecrets: m.mockCheckDeploySecrets,
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
// The hook returns an object with selectNode/setPanelTab;
// the component also calls useCanvasStore.getState() directly.
vi.fn(() => ({
selectNode: m.mockSelectNode,
setPanelTab: m.mockSetPanelTab,
})),
{
getState: () => ({
selectNode: m.mockSelectNode,
setPanelTab: m.mockSetPanelTab,
}),
},
),
}));
vi.mock("@/hooks/useTemplateDeploy", () => ({
useTemplateDeploy: m.mockUseTemplateDeploy,
}));
// Mock OrgTemplatesSection — tested separately.
vi.mock("../TemplatePalette", () => ({
OrgTemplatesSection: () => (
<div data-testid="org-templates-section">Org Templates</div>
),
}));
// ─── Test data ───────────────────────────────────────────────────────────────
const TEMPLATE = {
id: "molecule-dev",
name: "Molecule Dev",
tier: 2,
description: "A full-featured agent workspace for development",
runtime: "langgraph",
required_env: ["ANTHROPIC_API_KEY"],
models: [{ id: "claude-sonnet-4-20250514", required_env: ["ANTHROPIC_API_KEY"] }],
model: "claude-sonnet-4-20250514",
skill_count: 12,
};
// ─── Cleanup ─────────────────────────────────────────────────────────────────
beforeEach(() => {
m.mockGet.mockReset();
m.mockGet.mockResolvedValue([] as unknown[]);
m.mockPost.mockReset();
m.mockPost.mockResolvedValue({ id: "new-ws-123" } as unknown as { id: string });
m.mockCheckDeploySecrets.mockReset();
m.mockCheckDeploySecrets.mockResolvedValue({
ok: true,
missingKeys: [],
providers: [],
runtime: "langgraph",
configuredKeys: [],
});
m.mockSelectNode.mockReset();
m.mockSetPanelTab.mockReset();
m.mockDeploy.mockReset();
});
afterEach(() => {
cleanup();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("EmptyState — loading state", () => {
it("shows spinner and loading text while templates are being fetched", () => {
m.mockGet.mockImplementation(() => new Promise(() => {}));
render(<EmptyState />);
expect(screen.getByText(/loading templates/i)).toBeTruthy();
});
});
describe("EmptyState — templates fetched", () => {
it("renders template grid with name, tier badge, description, skill count", async () => {
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText("Molecule Dev")).toBeTruthy();
expect(screen.getByText("T2")).toBeTruthy();
expect(screen.getByText(/full-featured agent workspace/i)).toBeTruthy();
expect(screen.getByText(/12 skills/)).toBeTruthy();
});
it("shows model label when template declares a model", async () => {
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText(/claude-sonnet/i)).toBeTruthy();
});
it("calls deploy(template) when template button is clicked", async () => {
m.mockGet.mockResolvedValueOnce([TEMPLATE] as unknown[]);
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /molecule dev/i }));
expect(m.mockDeploy).toHaveBeenCalledWith(
expect.objectContaining({ id: "molecule-dev", name: "Molecule Dev" }),
);
});
});
describe("EmptyState — no templates", () => {
it("shows only the create-blank button when template list is empty", async () => {
// beforeEach already sets mockResolvedValue([]) as default — no override needed.
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy();
expect(screen.queryByText(/molecule dev/i)).toBeNull();
});
it("shows only the create-blank button when template fetch fails", async () => {
m.mockGet.mockRejectedValueOnce(new Error("Network error"));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("button", { name: /\+ create blank workspace/i })).toBeTruthy();
expect(screen.queryByText(/loading templates/i)).toBeNull();
});
});
describe("EmptyState — create blank workspace", () => {
it('shows "Creating..." label while blank workspace POST is in-flight', async () => {
m.mockPost.mockImplementationOnce(() => new Promise(() => {}));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText("Creating...")).toBeTruthy();
// The same button is now relabeled; check it is disabled while POST is in-flight.
expect(screen.getByRole("button", { name: /creating\.\.\./i })).toHaveProperty("disabled", true);
});
it("calls POST /workspaces with correct payload on create blank", async () => {
m.mockPost.mockResolvedValueOnce({ id: "ws-new-456" } as unknown as { id: string });
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(m.mockPost).toHaveBeenCalledWith("/workspaces", {
name: "My First Agent",
canvas: { x: 200, y: 150 },
});
});
it("calls selectNode + setPanelTab(chat) after 500ms on blank create success", async () => {
m.mockPost.mockResolvedValueOnce({ id: "ws-new-789" } as unknown as { id: string });
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
// Wait for the 500ms setTimeout inside handleDeployed to fire and call
// canvas store methods. Use waitFor so we don't hard-code timing assumptions.
await waitFor(() => {
expect(m.mockSelectNode).toHaveBeenCalledWith("ws-new-789");
expect(m.mockSetPanelTab).toHaveBeenCalledWith("chat");
}, { timeout: 1000 });
});
it("shows error banner on blank create failure", async () => {
m.mockPost.mockRejectedValueOnce(new Error("Server error"));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/server error/i)).toBeTruthy();
});
it("blank workspace error clears on retry", async () => {
m.mockPost.mockRejectedValueOnce(new Error("Server error"));
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByRole("alert")).toBeTruthy();
// Retry succeeds — error clears
m.mockPost.mockResolvedValueOnce({ id: "ws-retry" } as unknown as { id: string });
fireEvent.click(screen.getByRole("button", { name: /\+ create blank workspace/i }));
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.queryByRole("alert")).toBeNull();
});
});
describe("EmptyState — rendering", () => {
it("renders the welcome heading and instructions", async () => {
// beforeEach already sets mockGet to resolve to [] — no override needed.
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText(/deploy your first agent/i)).toBeTruthy();
expect(screen.getByText(/welcome to molecule ai/i)).toBeTruthy();
});
it("renders the tips footer", async () => {
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByText(/drag to nest workspaces/i)).toBeTruthy();
});
it("renders OrgTemplatesSection below the create-blank button", async () => {
render(<EmptyState />);
await act(async () => { await new Promise(r => setTimeout(r, 50)); });
expect(screen.getByTestId("org-templates-section")).toBeTruthy();
});
});
@@ -6,12 +6,11 @@
* aria-label, title text, onToggle callback.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
describe("RevealToggle — render", () => {
afterEach(cleanup);
it("renders a button element", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toBeTruthy();
@@ -13,18 +13,13 @@ import { SearchDialog } from "../SearchDialog";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock store ──────────────────────────────────────────────────────────────
// Zustand-compatible mock: useSyncExternalStore needs subscribe() to fire
// callbacks so React re-renders when state changes. Without it, the
// Cmd+K test opens the dialog but the component never re-renders because
// React's external-store bridge has no notification to flush.
//
// We use vi.fn() wrapping for setSearchOpen so tests can use
// toHaveBeenCalledWith() for assertions, while also calling the underlying
// store update that triggers Zustand's subscriber mechanism.
type StoreSlice = {
searchOpen: boolean;
nodes: Array<{
const mockStoreState = {
searchOpen: false,
setSearchOpen: vi.fn((open: boolean) => {
mockStoreState.searchOpen = open;
}),
nodes: [] as Array<{
id: string;
data: {
name: string;
@@ -33,48 +28,17 @@ type StoreSlice = {
role: string;
parentId?: string | null;
};
}>;
selectNode: (id: string) => void;
setPanelTab: (tab: string) => void;
};
const _subscribers = new Set<() => void>();
const _implSetSearchOpen = (open: boolean) => {
_mockStore.searchOpen = open;
_subscribers.forEach((cb) => cb());
};
const _mockStore: StoreSlice = {
searchOpen: false,
nodes: [],
}>,
selectNode: vi.fn(),
setPanelTab: vi.fn(),
};
const mockStoreState: StoreSlice & { setSearchOpen: ReturnType<typeof vi.fn> } = {
searchOpen: false,
nodes: [],
selectNode: _mockStore.selectNode,
setPanelTab: _mockStore.setPanelTab,
// vi.fn() wrapper so tests can use toHaveBeenCalledWith(); the
// implementation calls through to _implSetSearchOpen which notifies
// Zustand subscribers so React re-renders.
setSearchOpen: vi.fn(_implSetSearchOpen),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{
getState: () => mockStoreState,
subscribe: (cb: () => void) => {
_subscribers.add(cb);
return () => { _subscribers.delete(cb); };
},
} as unknown as ReturnType<typeof vi.fn>,
{ getState: () => mockStoreState },
),
})) as typeof vi.mock;
}));
const STORAGE_KEY = "molecule-onboarding-complete";
@@ -96,9 +60,9 @@ describe("SearchDialog — visibility", () => {
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("does not render when searchOpen is false", () => {
@@ -120,10 +84,9 @@ describe("SearchDialog — keyboard shortcuts", () => {
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
// setSearchOpen is a bound method, not vi.fn — skip mockClear
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("opens the dialog when Cmd+K is pressed", () => {
@@ -139,18 +102,8 @@ describe("SearchDialog — keyboard shortcuts", () => {
});
it("clears the query when Cmd+K opens the dialog", () => {
const { rerender } = render(<SearchDialog />);
// Zustand's useSyncExternalStore doesn't always re-render from the
// mock's subscribe() callback in the jsdom environment. After the
// keyboard handler fires, manually set state and force re-render.
act(() => {
dispatchKeydown("k", true, false);
// After vi.fn(_implSetSearchOpen) runs, subscribers fire but React
// may not schedule a re-render in time. Re-render manually so the
// component sees the updated searchOpen=true.
mockStoreState.searchOpen = true;
});
rerender(<SearchDialog />);
render(<SearchDialog />);
dispatchKeydown("k", true, false);
const input = screen.getByRole("combobox");
expect(input.getAttribute("value") ?? "").toBe("");
});
@@ -169,9 +122,9 @@ describe("SearchDialog — focus", () => {
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("focuses the input when the dialog opens", async () => {
@@ -204,9 +157,9 @@ describe("SearchDialog — filtering", () => {
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("shows all workspaces when query is empty", () => {
@@ -277,9 +230,9 @@ describe("SearchDialog — listbox navigation", () => {
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("highlights the first result when query is typed", () => {
@@ -317,36 +270,11 @@ describe("SearchDialog — listbox navigation", () => {
it("Enter selects the highlighted workspace", () => {
mockStoreState.searchOpen = true;
const { rerender } = render(<SearchDialog />);
render(<SearchDialog />);
const input = screen.getByRole("combobox");
// Directly update the DOM input value + fire change event, then force
// a re-render so React commits the query state before keyboard events.
act(() => {
// Simulate user typing "a" — the onChange handler fires synchronously
// inside act(), but we also need the component to re-render with the
// new query so the filtered list and focusedIndex update correctly.
Object.defineProperty(input, "value", {
value: "a",
writable: true,
configurable: true,
});
fireEvent.change(input, { target: { value: "a" } });
// After onChange fires, query="a". React schedules a re-render but
// might not have flushed it yet — rerender forces it so ArrowDown
// sees focusedIndex=0 (effect ran from filtered.length change).
rerender(<SearchDialog />);
});
// Now focusedIndex should be 0 (Alice, filtered[0]). ArrowUp stays at 0.
// ArrowDown moves to 1 (Carol). We want to select Alice, so go
// ArrowUp to stay at 0, then Enter.
act(() => {
fireEvent.keyDown(input, { key: "ArrowUp" }); // Math.max(0-1, 0) = 0
});
act(() => {
fireEvent.keyDown(input, { key: "Enter" });
});
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
@@ -359,9 +287,9 @@ describe("SearchDialog — aria attributes", () => {
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("dialog has role=dialog and aria-modal=true", () => {
@@ -397,9 +325,9 @@ describe("SearchDialog — footer", () => {
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
_subscribers.clear();
});
it("footer shows singular 'workspace' when count is 1", () => {
@@ -6,12 +6,11 @@
* icon presence, className variants, no render when passed invalid status.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
describe("StatusBadge — render", () => {
afterEach(cleanup);
it("renders verified status with ✓ icon", () => {
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
@@ -11,18 +11,16 @@
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
* - glow class applied when STATUS_CONFIG declares one
*/
import { afterEach, describe, expect, it } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { StatusDot } from "../StatusDot";
afterEach(cleanup);
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-emerald-400");
expect(dot.className).toContain("shadow-emerald-400/50");
expect(dot.getAttribute("aria-hidden")).toBe("true");
@@ -30,7 +28,7 @@ describe("StatusDot — snapshot", () => {
it("renders with offline status", () => {
render(<StatusDot status="offline" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
// offline has no glow
expect(dot.className).not.toContain("shadow-");
@@ -38,34 +36,34 @@ describe("StatusDot — snapshot", () => {
it("renders with degraded status", () => {
render(<StatusDot status="degraded" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-400");
expect(dot.className).toContain("shadow-amber-400/50");
});
it("renders with failed status", () => {
render(<StatusDot status="failed" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-red-400");
expect(dot.className).toContain("shadow-red-400/50");
});
it("renders with paused status", () => {
render(<StatusDot status="paused" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-indigo-400");
});
it("renders with not_configured status", () => {
render(<StatusDot status="not_configured" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-300");
expect(dot.className).toContain("shadow-amber-300/50");
});
it("renders with provisioning status and pulsing animation", () => {
render(<StatusDot status="provisioning" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-sky-400");
expect(dot.className).toContain("motion-safe:animate-pulse");
expect(dot.className).toContain("shadow-sky-400/50");
@@ -73,7 +71,7 @@ describe("StatusDot — snapshot", () => {
it("falls back to bg-zinc-500 for unknown status", () => {
render(<StatusDot status="alien_artifact" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
});
});
@@ -81,14 +79,14 @@ describe("StatusDot — snapshot", () => {
describe("StatusDot — size prop", () => {
it("applies w-2 h-2 (sm, default)", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2");
expect(dot.className).toContain("h-2");
});
it("applies w-2.5 h-2.5 (md)", () => {
render(<StatusDot status="online" size="md" />);
const dot = screen.getByRole("img", { hidden: true });
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2.5");
expect(dot.className).toContain("h-2.5");
});
@@ -97,6 +95,6 @@ describe("StatusDot — size prop", () => {
describe("StatusDot — accessibility", () => {
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
render(<StatusDot status="online" />);
expect(screen.getByRole("img", { hidden: true }).getAttribute("aria-hidden")).toBe("true");
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
});
});
@@ -10,15 +10,9 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Tooltip } from "../Tooltip";
afterEach(() => {
cleanup();
vi.useRealTimers();
});
afterEach(cleanup);
describe("Tooltip — render", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("renders children without showing tooltip on mount", () => {
render(
<Tooltip text="Hello world">
@@ -231,12 +225,11 @@ describe("Tooltip — aria-describedby", () => {
<button type="button">Hover me</button>
</Tooltip>
);
// The aria-describedby is on the wrapper div, not the button child
const btn = screen.getByRole("button");
const wrapper = btn.parentElement as HTMLElement;
const describedBy = wrapper.getAttribute("aria-describedby");
const describedBy = btn.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
// The describedby id matches the tooltip id
expect(document.getElementById(describedBy!)).toBeTruthy();
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
expect(document.getElementById(tooltipId)).toBeTruthy();
});
});
@@ -6,12 +6,10 @@
* SettingsButton integration, custom canvasName prop.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TopBar } from "../canvas/TopBar";
afterEach(cleanup);
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
vi.mock("../settings/SettingsButton", () => ({
@@ -6,12 +6,10 @@
* aria-live for error, icon rendering.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ValidationHint } from "../ui/ValidationHint";
afterEach(cleanup);
describe("ValidationHint — error state", () => {
it("renders error message when error is a non-null string", () => {
render(<ValidationHint error="Invalid email address" />);
@@ -45,9 +43,7 @@ describe("ValidationHint — valid state", () => {
it("includes the checkmark icon in valid state", () => {
render(<ValidationHint error={null} showValid={true} />);
// ✓ is in an aria-hidden span; Valid format is a separate text node
expect(screen.getByText(/✓/)).toBeTruthy();
expect(screen.getByText("Valid format")).toBeTruthy();
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
});
it("uses the valid class on the paragraph element", () => {
@@ -1,634 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for WorkspaceNode component.
*
* 51 test cases covering:
* - render: name, status badge, role chip, tier badge, runtime badge, skills
* - status states: online, offline, provisioning, paused, degraded, failed,
* not_configured — dot color, label, gradient bar
* - interactions: click, shift-click, double-click, context menu, keyboard
* - error/banner: needs-restart banner, restart action, current task
* - layout: hasChildren → larger card + "N sub" badge, collapsed state
* - sub-workspace: parentId → embedded chip rendered via TeamMemberChip
* - a11y: role=button, tabIndex=0, aria-label, aria-pressed
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceNode } from "../WorkspaceNode";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock Toaster ──────────────────────────────────────────────────────────────
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
patch: apiPatch,
get: vi.fn(),
post: vi.fn(),
},
}));
// ─── Mock Tooltip ────────────────────────────────────────────────────────────
vi.mock("../Tooltip", () => ({
Tooltip: ({ text, children }: { text: string; children: React.ReactNode }) => (
<span title={text} data-testid="tooltip-wrapper">
{children}
</span>
),
}));
// ─── Mock useOrgDeployState ──────────────────────────────────────────────────
const DEFAULT_DEPLOY = {
isActivelyProvisioning: false,
isDeployingRoot: false,
isLockedChild: false,
descendantProvisioningCount: 0,
};
vi.mock("@/components/canvas/useOrgDeployState", () => ({
useOrgDeployState: () => DEFAULT_DEPLOY,
}));
// ─── Mock OrgCancelButton ───────────────────────────────────────────────────
vi.mock("@/components/canvas/OrgCancelButton", () => ({
OrgCancelButton: () => <button data-testid="org-cancel">Cancel</button>,
}));
// ─── Mock React Flow ─────────────────────────────────────────────────────────
vi.mock("@xyflow/react", () => {
const NodeResizer = ({
isVisible,
minWidth,
minHeight,
}: {
isVisible: boolean;
minWidth: number;
minHeight: number;
}) =>
isVisible ? (
<div data-testid="node-resizer" data-minw={minWidth} data-minh={minHeight} />
) : null;
const Handle = vi.fn().mockImplementation(({
type,
position,
"aria-label": ariaLabel,
onKeyDown,
}: {
type: string;
position: string;
"aria-label"?: string;
onKeyDown?: React.KeyboardEvent<HTMLDivElement>;
}) => (
<div
role="button"
aria-label={ariaLabel}
data-handle-type={type}
data-handle-position={position}
tabIndex={0}
onKeyDown={onKeyDown}
/>
));
return {
__esModule: true,
NodeResizer,
Handle,
NodeProps: vi.fn(),
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
useReactFlow: () => ({}),
};
});
// ─── Shared node data factory ─────────────────────────────────────────────────
function makeNode(overrides: Partial<{
name: string;
status: string;
tier: number;
role: string;
agentCard: Record<string, unknown> | null;
activeTasks: number;
collapsed: boolean;
parentId: string | null;
currentTask: string;
runtime: string;
needsRestart: boolean;
lastSampleError: string;
lastErrorRate: number;
url: string;
budgetLimit: number | null;
}> = {}): Parameters<typeof WorkspaceNode>[0] {
return {
id: "ws-1",
data: {
name: "Test Agent",
status: "online",
tier: 2,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "assistant",
lastErrorRate: 0,
lastSampleError: "",
url: "http://localhost:8080",
parentId: null,
currentTask: "",
runtime: "langgraph",
needsRestart: false,
budgetLimit: null,
...overrides,
},
} as Parameters<typeof WorkspaceNode>[0];
}
/** Create a node with a specific id (for selection/identity tests). */
function makeNodeWithId(id: string, overrides?: Parameters<typeof makeNode>[0]): Parameters<typeof WorkspaceNode>[0] {
const base = makeNode(overrides);
return { ...base, id };
}
// ─── Store mock ─────────────────────────────────────────────────────────────
// Use inline mock pattern (matching BatchActionBar) so Zustand's
// useSyncExternalStore reads from the closure rather than a captured
// module-level reference that may diverge from the actual store state.
const mockSelectNode = vi.fn();
const mockToggleNodeSelection = vi.fn();
const mockOpenContextMenu = vi.fn();
const mockNestNode = vi.fn().mockResolvedValue(undefined as void);
const mockRestartWorkspace = vi.fn().mockResolvedValue(undefined as void);
const mockSetCollapsed = vi.fn();
const mockSetSearchOpen = vi.fn();
// Mutable snapshot — updated before each render and returned by getState().
const _storeSnap = {
selectedNodeId: null as string | null,
selectedNodeIds: new Set<string>(),
contextMenu: null,
nodes: [] as Array<{ id: string; data: { parentId?: string | null } }>,
dragOverNodeId: null as string | null,
searchOpen: false,
selectNode: mockSelectNode,
toggleNodeSelection: mockToggleNodeSelection,
openContextMenu: mockOpenContextMenu,
nestNode: mockNestNode,
restartWorkspace: mockRestartWorkspace,
setCollapsed: mockSetCollapsed,
setSearchOpen: mockSetSearchOpen,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof _storeSnap) => unknown) => selector(_storeSnap)),
{ getState: () => _storeSnap }
),
})) as typeof vi.mock;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Returns the card div button (first button in DOM — before the handles). */
function cardButton(): HTMLElement {
return screen.getAllByRole("button")[0];
}
function dispatchKey(key: string, opts: {
shift?: boolean;
ctrl?: boolean;
meta?: boolean;
} = {}) {
fireEvent.keyDown(cardButton(), {
key,
shiftKey: opts.shift ?? false,
ctrlKey: opts.ctrl ?? false,
metaKey: opts.meta ?? false,
});
}
function clickNode(shiftKey = false) {
fireEvent.click(cardButton(), { shiftKey });
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
afterEach(() => {
cleanup();
vi.clearAllMocks();
_storeSnap.selectedNodeId = null;
_storeSnap.selectedNodeIds.clear();
_storeSnap.nodes = [];
_storeSnap.dragOverNodeId = null;
_storeSnap.contextMenu = null;
apiPatch.mockClear();
mockSelectNode.mockClear();
mockToggleNodeSelection.mockClear();
mockOpenContextMenu.mockClear();
mockNestNode.mockClear();
mockRestartWorkspace.mockClear();
mockSetCollapsed.mockClear();
});
// ════════════════════════════════════════════════════════════════════════════════
// RENDER — name, status, role, tier, runtime, skills
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — render", () => {
it("renders the workspace name", () => {
render(<WorkspaceNode {...makeNode({ name: "Alice" })} />);
expect(screen.getByText("Alice")).toBeTruthy();
});
it("renders the role chip when role is set", () => {
render(<WorkspaceNode {...makeNode({ role: "analyst" })} />);
expect(screen.getByText("analyst")).toBeTruthy();
});
it("does not render role chip when role is empty", () => {
render(<WorkspaceNode {...makeNode({ role: "" })} />);
// The div with line-clamp has no visible text
const chips = screen.queryAllByText("");
expect(chips).toBeTruthy();
});
it("renders the tier badge", () => {
render(<WorkspaceNode {...makeNode({ tier: 2 })} />);
expect(screen.getByText("T2")).toBeTruthy();
});
it("renders unknown tier gracefully", () => {
render(<WorkspaceNode {...makeNode({ tier: 99 })} />);
expect(screen.getByText("T99")).toBeTruthy();
});
it("renders runtime badge when runtime is set", () => {
render(<WorkspaceNode {...makeNode({ runtime: "langgraph" })} />);
expect(screen.getByText("langgraph")).toBeTruthy();
});
it("renders REMOTE badge for external runtime", () => {
render(<WorkspaceNode {...makeNode({ runtime: "external" })} />);
expect(screen.getByText("★ REMOTE")).toBeTruthy();
});
it("does not render runtime badge when runtime is empty", () => {
render(<WorkspaceNode {...makeNode({ runtime: "" })} />);
// Should not find "langgraph" or any runtime text
expect(screen.queryByText("langgraph")).toBeNull();
});
it("renders skills from agentCard", () => {
render(<WorkspaceNode {...makeNode({
agentCard: { skills: [{ name: "coding" }, { name: "research" }] },
})} />);
expect(screen.getByText("coding")).toBeTruthy();
expect(screen.getByText("research")).toBeTruthy();
});
it("renders skill overflow badge when > 4 skills", () => {
render(<WorkspaceNode {...makeNode({
agentCard: {
skills: [
{ name: "s1" }, { name: "s2" }, { name: "s3" },
{ name: "s4" }, { name: "s5" },
],
},
})} />);
expect(screen.getByText("+1")).toBeTruthy();
});
it("renders current task banner", () => {
render(<WorkspaceNode {...makeNode({ currentTask: "Running research" })} />);
expect(screen.getByText("Running research")).toBeTruthy();
});
it("renders active tasks count", () => {
render(<WorkspaceNode {...makeNode({ activeTasks: 3 })} />);
expect(screen.getByText("3 tasks")).toBeTruthy();
});
it("renders singular task label for 1 active task", () => {
render(<WorkspaceNode {...makeNode({ activeTasks: 1 })} />);
expect(screen.getByText("1 task")).toBeTruthy();
});
it("does not render active tasks count when zero", () => {
render(<WorkspaceNode {...makeNode({ activeTasks: 0 })} />);
const pulses = document.querySelectorAll(".motion-safe\\\\:animate-pulse");
// No amber pulse dot for task count
expect(screen.queryByText("0 tasks")).toBeNull();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// STATUS STATES — dot color, label, gradient bar
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — status states", () => {
it("online: shows green dot (label div is empty for online)", () => {
render(<WorkspaceNode {...makeNode({ status: "online" })} />);
const dot = document.querySelector(".bg-emerald-400");
expect(dot).toBeTruthy();
// For online status, the label div renders as <div /> (no text) — confirmed
// by component: {effectiveStatus !== "online" ? <div>{label}</div> : <div />}
expect(screen.queryByText("Online")).toBeNull();
});
it("offline: shows gray dot and 'Offline' label", () => {
render(<WorkspaceNode {...makeNode({ status: "offline" })} />);
const dot = document.querySelector(".bg-zinc-500");
expect(dot).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
});
it("provisioning: shows pulsing blue dot and 'Starting' label", () => {
render(<WorkspaceNode {...makeNode({ status: "provisioning" })} />);
const dot = document.querySelector(".motion-safe\\:animate-pulse");
expect(dot).toBeTruthy();
expect(screen.getByText("Starting")).toBeTruthy();
});
it("paused: shows indigo dot and 'Paused' label", () => {
render(<WorkspaceNode {...makeNode({ status: "paused" })} />);
const dot = document.querySelector(".bg-indigo-400");
expect(dot).toBeTruthy();
expect(screen.getByText("Paused")).toBeTruthy();
});
it("degraded: shows amber dot and 'Degraded' label", () => {
render(<WorkspaceNode {...makeNode({ status: "degraded" })} />);
const dot = document.querySelector(".bg-amber-400");
expect(dot).toBeTruthy();
expect(screen.getByText("Degraded")).toBeTruthy();
});
it("degraded: shows last sample error preview", () => {
render(<WorkspaceNode {...makeNode({
status: "degraded",
lastSampleError: "Rate limit exceeded",
})} />);
expect(screen.getByText("Rate limit exceeded")).toBeTruthy();
});
it("failed: shows red dot and 'Failed' label", () => {
render(<WorkspaceNode {...makeNode({ status: "failed" })} />);
const dot = document.querySelector(".bg-red-400");
expect(dot).toBeTruthy();
expect(screen.getByText("Failed")).toBeTruthy();
});
it("not_configured: shows amber dot and 'Not configured' label", () => {
render(<WorkspaceNode {...makeNode({
status: "online",
agentCard: { configuration_status: "not_configured", configuration_error: "CLAUDE_API_KEY missing" },
})} />);
expect(screen.getByText("Not configured")).toBeTruthy();
});
it("not_configured: shows configuration error preview", () => {
render(<WorkspaceNode {...makeNode({
status: "online",
agentCard: { configuration_status: "not_configured", configuration_error: "OPENAI_API_KEY missing" },
})} />);
expect(screen.getByText("OPENAI_API_KEY missing")).toBeTruthy();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// INTERACTIONS — click, shift-click, double-click, context menu, keyboard
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — interactions", () => {
it("click calls selectNode with the node id", () => {
_storeSnap.selectedNodeId = null;
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
clickNode();
expect(mockSelectNode).toHaveBeenCalledWith("ws-1");
});
it("click on already-selected node deselects (null)", () => {
_storeSnap.selectedNodeId = "ws-1";
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
clickNode();
expect(mockSelectNode).toHaveBeenCalledWith(null);
});
it("shift-click calls toggleNodeSelection", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-2")} />);
clickNode(true);
expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-2");
});
it("double-click on leaf node does not throw", () => {
_storeSnap.nodes = [];
render(<WorkspaceNode {...makeNodeWithId("ws-leaf")} />);
expect(() => {
fireEvent.doubleClick(cardButton());
}).not.toThrow();
});
it("double-click on parent node emits zoom-to-team custom event", () => {
// Simulate a parent with children
_storeSnap.nodes = [
{ id: "ws-child", data: { parentId: "ws-parent" } },
];
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
fireEvent.doubleClick(cardButton());
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "molecule:zoom-to-team" })
);
});
it("right-click calls openContextMenu with node data", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-3")} />);
fireEvent.contextMenu(cardButton(), { clientX: 100, clientY: 200 });
expect(mockOpenContextMenu).toHaveBeenCalledWith(
expect.objectContaining({ nodeId: "ws-3" })
);
});
it("Enter key calls selectNode", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-kb")} />);
dispatchKey("Enter");
expect(mockSelectNode).toHaveBeenCalledWith("ws-kb");
});
it("Space key calls selectNode", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-space")} />);
dispatchKey(" ");
expect(mockSelectNode).toHaveBeenCalledWith("ws-space");
});
it("Shift+Enter calls toggleNodeSelection", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-shift")} />);
dispatchKey("Enter", { shift: true });
expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-shift");
});
it("ContextMenu key opens context menu", () => {
render(<WorkspaceNode {...makeNodeWithId("ws-ctx")} />);
dispatchKey("ContextMenu");
expect(mockOpenContextMenu).toHaveBeenCalled();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// ERROR / BANNER — needs-restart banner, restart action
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — needs-restart banner", () => {
it("renders restart banner when needsRestart is true and no currentTask", () => {
render(<WorkspaceNode {...makeNode({ needsRestart: true })} />);
expect(screen.getByText("Restart to apply changes")).toBeTruthy();
});
it("does not render restart banner when needsRestart is false", () => {
render(<WorkspaceNode {...makeNode({ needsRestart: false })} />);
expect(screen.queryByText("Restart to apply changes")).toBeNull();
});
it("does not render restart banner when currentTask is present", () => {
render(<WorkspaceNode {...makeNode({ needsRestart: true, currentTask: "Busy" })} />);
expect(screen.queryByText("Restart to apply changes")).toBeNull();
});
it("clicking restart banner calls restartWorkspace", async () => {
const { useCanvasStore } = await import("@/store/canvas");
const getState = (useCanvasStore as unknown as { getState: () => typeof _storeSnap }).getState;
getState().restartWorkspace = mockRestartWorkspace;
render(<WorkspaceNode {...makeNodeWithId("ws-restart", { needsRestart: true })} />);
const btn = screen.getByRole("button", { name: /restart to apply/i });
await act(async () => {
fireEvent.click(btn);
});
expect(mockRestartWorkspace).toHaveBeenCalledWith("ws-restart");
});
});
// ════════════════════════════════════════════════════════════════════════════════
// LAYOUT — child chips, "N sub" badge, expand/collapse
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — layout", () => {
it("shows 'N sub' badge when node has children in store", () => {
_storeSnap.nodes = [
{ id: "ws-child-1", data: { parentId: "ws-parent" } },
{ id: "ws-child-2", data: { parentId: "ws-parent" } },
];
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
expect(screen.getByText("2 sub")).toBeTruthy();
});
it("shows '1 sub' badge for single child", () => {
_storeSnap.nodes = [
{ id: "ws-child", data: { parentId: "ws-parent" } },
];
render(<WorkspaceNode {...makeNodeWithId("ws-parent")} />);
expect(screen.getByText("1 sub")).toBeTruthy();
});
it("no 'sub' badge when node has no children", () => {
_storeSnap.nodes = [];
render(<WorkspaceNode {...makeNodeWithId("ws-leaf")} />);
expect(screen.queryByText(/\d+ sub/)).toBeNull();
});
});
// ════════════════════════════════════════════════════════════════════════════════
// SELECTION STATE — visual highlights
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — selection highlights", () => {
it("applies selected class when selectedNodeId matches", () => {
_storeSnap.selectedNodeId = "ws-selected";
render(<WorkspaceNode {...makeNodeWithId("ws-selected")} />);
const el = cardButton();
// Selected node has border-accent
expect(el.className).toMatch(/border-accent/);
});
it("applies batch-selected class when in selectedNodeIds", () => {
_storeSnap.selectedNodeId = "ws-other";
_storeSnap.selectedNodeIds.add("ws-batch");
render(<WorkspaceNode {...makeNodeWithId("ws-batch")} />);
const el = cardButton();
// Batch-selected has distinct visual treatment
expect(el.className).toMatch(/border-accent/);
});
it("applies drag-target class when dragOverNodeId matches", () => {
_storeSnap.dragOverNodeId = "ws-drag";
render(<WorkspaceNode {...makeNodeWithId("ws-drag")} />);
const el = cardButton();
expect(el.className).toMatch(/emerald/);
});
});
// ════════════════════════════════════════════════════════════════════════════════
// ACCESSIBILITY
// ════════════════════════════════════════════════════════════════════════════════
describe("WorkspaceNode — a11y", () => {
it("has role=button", () => {
render(<WorkspaceNode {...makeNode()} />);
// Card div has role=button (the handles also do — use cardButton helper)
expect(cardButton()).toBeTruthy();
});
it("has tabIndex=0", () => {
render(<WorkspaceNode {...makeNode()} />);
expect(cardButton().getAttribute("tabIndex")).toBe("0");
});
it("has aria-pressed reflecting selected state", () => {
_storeSnap.selectedNodeId = "ws-1";
render(<WorkspaceNode {...makeNodeWithId("ws-1")} />);
expect(cardButton().getAttribute("aria-pressed")).toBe("true");
});
it("aria-pressed is false when not selected", () => {
_storeSnap.selectedNodeId = null;
render(<WorkspaceNode {...makeNodeWithId("ws-other")} />);
expect(cardButton().getAttribute("aria-pressed")).toBe("false");
});
it("aria-label includes name and status", () => {
render(<WorkspaceNode {...makeNode({ name: "MyAgent", status: "online" })} />);
const el = cardButton();
expect(el.getAttribute("aria-label")).toMatch(/MyAgent/);
expect(el.getAttribute("aria-label")).toMatch(/online/);
});
it("aria-label includes configuration error for misconfigured workspace", () => {
render(<WorkspaceNode {...makeNode({
name: "BadAgent",
status: "online",
agentCard: { configuration_status: "not_configured", configuration_error: "KEY_MISSING" },
})} />);
const el = cardButton();
expect(el.getAttribute("aria-label")).toMatch(/KEY_MISSING/);
});
it("top handle has aria-label for extract action", () => {
render(<WorkspaceNode {...makeNode({ name: "ExtractMe", parentId: "parent-1" })} />);
const handles = document.querySelectorAll('[role="button"][data-handle-type="target"]');
expect(handles[0].getAttribute("aria-label")).toMatch(/Extract/);
});
it("bottom handle has aria-label for nest action", () => {
render(<WorkspaceNode {...makeNode({ name: "NestTarget" })} />);
const handles = document.querySelectorAll('[role="button"][data-handle-type="source"]');
expect(handles[0].getAttribute("aria-label")).toMatch(/Nest/);
});
});
@@ -1,183 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for DropTargetBadge — the floating drag-target affordance.
*
* Two-layer visual contract:
* 1. Ghost preview — dashed rect at the next default child slot
* 2. Text badge — "Drop into: <name>" floating above the target
*
* Render-condition coverage:
* - Renders nothing when dragOverNodeId is null
* - Renders nothing when dragOverNodeId node has no name (store lookup misses)
* - Renders nothing when getInternalNode returns undefined
* - Renders badge with correct name when all inputs are valid
* - Badge text contains the target node name
*
* Note: Ghost visibility (slot rect inside parent bounds) involves
* flowToScreenPosition coordinate arithmetic that's better covered by
* integration tests that render the full canvas. Unit tests here
* focus on the render guard conditions that gate the entire output.
*
* Issue: #2071 (Canvas test gaps follow-up).
*/
import React from "react";
import { render, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DropTargetBadge } from "../DropTargetBadge";
import type { WorkspaceNodeData } from "@/store/canvas";
// ── Mock @xyflow/react ───────────────────────────────────────────────────────
// VIEWPORT_OFFSET mirrors what flowToScreenPosition does in the real
// component: it shifts canvas-space coords into screen-space by a fixed
// viewport offset. Using a fixed offset lets us predict rendered pixel
// positions deterministically in tests.
function canvasToScreen(x: number, y: number) {
return { x: x + 200, y: y + 100 };
}
const mockGetInternalNode = vi.fn<(id: string) => unknown>();
const mockFlowToScreenPosition = vi.fn<
(pos: { x: number; y: number }) => { x: number; y: number }
>();
vi.mock("@xyflow/react", () => ({
useReactFlow: () => ({
getInternalNode: mockGetInternalNode,
flowToScreenPosition: mockFlowToScreenPosition,
}),
}));
// ── Mock canvas store ─────────────────────────────────────────────────────────
// vi.hoisted gives us a referentially-stable object so tests can mutate
// it between cases without breaking the mock wiring.
const { mockState } = vi.hoisted(() => ({
mockState: {
nodes: [] as Array<{
id: string;
data: WorkspaceNodeData;
}>,
dragOverNodeId: null as string | null,
},
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockState) => unknown) => sel(mockState),
{ getState: () => mockState },
),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Store node fixture. Only the id and data.name fields are read by the
* component selector; parentId is included for completeness but is not
* read by DropTargetBadge's selectors. */
function storeNode(id: string, name: string): typeof mockState.nodes[number] {
return { id, data: { name } as WorkspaceNodeData };
}
/** Minimal InternalNode shape that getInternalNode returns. The component
* reads measured.width/height, width/height fallbacks, and
* internals.positionAbsolute. */
function makeInternal(
id: string,
cx: number,
cy: number,
w = 400,
h = 300,
): unknown {
return {
id,
measured: { width: w, height: h },
width: w,
height: h,
internals: { positionAbsolute: { x: cx, y: cy } },
};
}
beforeEach(() => {
mockGetInternalNode.mockReset();
mockFlowToScreenPosition.mockReset();
mockGetInternalNode.mockReturnValue(undefined);
mockFlowToScreenPosition.mockImplementation(canvasToScreen);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockState.nodes = [];
mockState.dragOverNodeId = null;
});
// ── Test cases ───────────────────────────────────────────────────────────────
describe("DropTargetBadge — render conditions", () => {
it("renders nothing when dragOverNodeId is null (no store nodes)", () => {
mockState.nodes = [];
const { container } = render(<DropTargetBadge />);
expect(container.textContent).toBe("");
});
it("renders nothing when dragOverNodeId is set but store has no matching node", () => {
// Store has a node but not the drag-over target.
mockState.nodes = [storeNode("other", "Other")];
mockState.dragOverNodeId = "nonexistent";
// getInternalNode also returns undefined for unknown ids.
mockGetInternalNode.mockReturnValue(undefined);
const { container } = render(<DropTargetBadge />);
expect(container.textContent).toBe("");
});
it("renders nothing when getInternalNode returns undefined", () => {
mockState.nodes = [storeNode("target", "My Workspace")];
mockState.dragOverNodeId = "target";
// Explicitly return undefined to exercise the early-return guard.
mockGetInternalNode.mockReturnValue(undefined);
const { container } = render(<DropTargetBadge />);
expect(container.textContent).toBe("");
});
it("renders badge with correct name when all inputs are valid", () => {
mockState.nodes = [storeNode("target", "My Workspace")];
mockState.dragOverNodeId = "target";
mockGetInternalNode.mockReturnValue(makeInternal("target", 0, 0));
const { container } = render(<DropTargetBadge />);
// Badge renders the name from the store node.
expect(container.textContent).toContain("My Workspace");
});
it("badge text follows 'Drop into: <name>' format", () => {
mockState.nodes = [storeNode("alpha", "Alpha Workspace")];
mockState.dragOverNodeId = "alpha";
mockGetInternalNode.mockReturnValue(makeInternal("alpha", 50, 50, 300, 200));
const { container } = render(<DropTargetBadge />);
expect(container.textContent).toMatch(/Drop into:/);
expect(container.textContent).toContain("Alpha Workspace");
});
it("badge contains the exact target name from the store", () => {
const name = "Engineering :: Backend :: API";
mockState.nodes = [storeNode("api", name)];
mockState.dragOverNodeId = "api";
mockGetInternalNode.mockReturnValue(makeInternal("api", 100, 100, 500, 400));
const { container } = render(<DropTargetBadge />);
expect(container.textContent).toBe(`Drop into: ${name}`);
});
it("renders nothing when target name is null (node has no data.name)", () => {
// A node in the store without a name field → selector returns null.
mockState.nodes = [{ id: "nameless", data: {} as WorkspaceNodeData }];
mockState.dragOverNodeId = "nameless";
mockGetInternalNode.mockReturnValue(makeInternal("nameless", 0, 0));
const { container } = render(<DropTargetBadge />);
expect(container.textContent).toBe("");
});
});
@@ -1,311 +0,0 @@
/**
* Unit tests for buildDeployMap — the pure tree-traversal core of
* useOrgDeployState.
*
* What is tested here:
* - Root / leaf identification via parent-chain walk
* - isDeployingRoot: true when any descendant is "provisioning"
* - isActivelyProvisioning: true only for the node itself in that state
* - isLockedChild: true for non-root nodes in a deploying tree
* - isLockedChild: also true for nodes in deletingIds (even if not deploying)
* - descendantProvisioningCount: non-zero only on root nodes
* - Performance contract: O(n) single-pass walk — tested by verifying
* correctness across 50-node trees (n=50, all cases above)
*
* What is NOT tested here (hook integration — appropriate for E2E):
* - The useMemo / Zustand subscription wiring
* - React Flow integration (flowToScreenPosition, getInternalNode)
*
* Issue: #2071 (Canvas test gaps follow-up).
*/
import { describe, expect, it } from "vitest";
import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
// ── Helpers ──────────────────────────────────────────────────────────────────
type Projection = { id: string; parentId: string | null; status: string };
function proj(
id: string,
parentId: string | null,
status: string,
): Projection {
return { id, parentId, status };
}
/** Unchecked cast — test helpers aren't production code paths. */
function m(
ps: Projection[],
deletingIds: string[] = [],
): Map<string, OrgDeployState> {
return buildDeployMap(ps, new Set(deletingIds));
}
function s(
map: Map<string, OrgDeployState>,
id: string,
): OrgDeployState {
const got = map.get(id);
if (!got) throw new Error(`no entry for id=${id}`);
return got;
}
// ── Empty / trivial ───────────────────────────────────────────────────────────
describe("buildDeployMap — empty", () => {
it("returns empty map for empty projections", () => {
expect(m([]).size).toBe(0);
});
});
// ── Single node ─────────────────────────────────────────────────────────────
describe("buildDeployMap — single node", () => {
it("isolated node is its own root and not deploying", () => {
const map = m([proj("a", null, "online")]);
expect(s(map, "a")).toEqual({
isActivelyProvisioning: false,
isDeployingRoot: false,
isLockedChild: false,
descendantProvisioningCount: 0,
});
});
it("isolated provisioning node is deploying root", () => {
const map = m([proj("a", null, "provisioning")]);
expect(s(map, "a")).toEqual({
isActivelyProvisioning: true,
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
});
});
});
// ── Parent / child chains ─────────────────────────────────────────────────────
describe("buildDeployMap — parent / child chains", () => {
it("root with online child: root is not deploying, child is not locked", () => {
// A ──► B
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
});
it("root with provisioning child: root is deploying, child is locked", () => {
// A ──► B (B is provisioning)
const map = m([
proj("A", null, "online"),
proj("B", "A", "provisioning"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
});
it("provisioning root with online child: root is deploying, child is locked", () => {
// A (provisioning) ──► B (online)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
});
it("grandchild inherits deploy lock through intermediate online node", () => {
// A ──► B ──► C (A is provisioning)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "B", "online"),
]);
// B and C are both non-root descendants of the deploying root
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
});
it("deep chain: only the topmost node with a null parent counts as root", () => {
// A ──► B ──► C ──► D (A is provisioning)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "B", "online"),
proj("D", "C", "online"),
]);
const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
expect(roots).toEqual(["A"]);
});
});
// ── Sibling branching ─────────────────────────────────────────────────────────
describe("buildDeployMap — sibling branching", () => {
it("parent with multiple children: deploying root propagates to all children", () => {
// A (provisioning)
// / \
// B C
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "A", "online"),
]);
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
});
it("only one provisioning descendant marks the root as deploying", () => {
// A
// / | \
// B C D (only C is provisioning)
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
proj("C", "A", "provisioning"),
proj("D", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
expect(s(map, "D")).toMatchObject({ isLockedChild: true });
});
it("two provisioning siblings: count reflects both", () => {
const map = m([
proj("A", null, "online"),
proj("B", "A", "provisioning"),
proj("C", "A", "provisioning"),
]);
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
});
});
// ── Multiple disjoint trees ───────────────────────────────────────────────────
describe("buildDeployMap — multiple disjoint trees", () => {
it("each tree has its own root; deploying nodes are independent", () => {
// Tree 1: X (provisioning) ──► Y
// Tree 2: P ──► Q (no provisioning)
const map = m([
proj("X", null, "provisioning"),
proj("Y", "X", "online"),
proj("P", null, "online"),
proj("Q", "P", "online"),
]);
expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
});
});
// ── Deleting nodes ────────────────────────────────────────────────────────────
describe("buildDeployMap — deletingIds", () => {
it("node in deletingIds is locked even if tree is not deploying", () => {
const map = m(
[
proj("A", null, "online"),
proj("B", "A", "online"),
],
["B"], // B is being deleted
);
expect(s(map, "A")).toMatchObject({ isLockedChild: false });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
});
it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
const map = m(
[
proj("A", null, "provisioning"),
proj("B", "A", "online"),
],
["B"],
);
// B is both a deploying-child AND a deleting node — either alone locks it
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
});
it("empty deletingIds set has no effect", () => {
const map = m(
[
proj("A", null, "online"),
proj("B", "A", "online"),
],
[],
);
expect(s(map, "B")).toMatchObject({ isLockedChild: false });
});
});
// ── descendantProvisioningCount ───────────────────────────────────────────────
describe("buildDeployMap — descendantProvisioningCount", () => {
it("is 0 for non-root nodes", () => {
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "provisioning"),
]);
expect(s(map, "B").descendantProvisioningCount).toBe(0);
});
it("includes the root's own status when provisioning", () => {
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
]);
// A is both root and provisioning → count includes itself
expect(s(map, "A").descendantProvisioningCount).toBe(1);
});
it("accumulates all provisioning descendants (not just immediate children)", () => {
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
proj("C", "B", "provisioning"),
]);
expect(s(map, "A").descendantProvisioningCount).toBe(1);
});
});
// ── O(n) performance ─────────────────────────────────────────────────────────
describe("buildDeployMap — O(n) performance contract", () => {
it("handles a 50-node three-level tree without incorrect node assignments", () => {
// Level 0: 1 root
// Level 1: 7 children
// Level 2: 42 leaves
// Total: 50 nodes
const projections: Projection[] = [];
projections.push(proj("root", null, "provisioning"));
for (let i = 0; i < 7; i++) {
projections.push(proj(`l1-${i}`, "root", "online"));
}
for (let i = 0; i < 42; i++) {
const parent = `l1-${Math.floor(i / 6)}`;
projections.push(proj(`l2-${i}`, parent, "online"));
}
const map = m(projections);
// Root is the only deploying node
expect(s(map, "root")).toMatchObject({
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
});
// Every other node is a locked child
for (let i = 0; i < 7; i++) {
expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
}
for (let i = 0; i < 42; i++) {
expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
}
});
});
@@ -40,8 +40,7 @@ interface NodeProjection {
status: string;
}
// Exported for unit testing — the function is pure and deterministic.
export function buildDeployMap(
function buildDeployMap(
projections: NodeProjection[],
deletingIds: ReadonlySet<string>,
): Map<string, OrgDeployState> {
+2 -7
View File
@@ -54,14 +54,9 @@ export function MobileChat({
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
// for selector equality. A fallback `?? []` creates a new [] reference on
// every store update when agentMessages[agentId] is undefined, causing an
// infinite re-render loop (React error #185 / Maximum update depth
// exceeded). The undefined case is handled by the initializer below.
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId] ?? []);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
(storedMessages ?? []).map((m) => ({
storedMessages.map((m) => ({
id: m.id,
role: "agent",
text: m.content,
@@ -1,216 +0,0 @@
// @vitest-environment jsdom
/**
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
*
* NotAvailablePanel: pure presentational component — renders a "feature not
* available" placeholder for external-runtime workspaces.
* FilesToolbar: pure props-driven component — directory selector, file count,
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
*
* No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks to avoid "expect is not defined" errors.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { FilesToolbar } from "../FilesToolbar";
import { NotAvailablePanel } from "../NotAvailablePanel";
// ─── afterEach ─────────────────────────────────────────────────────────────────
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
describe("NotAvailablePanel", () => {
it("renders heading 'Files not available'", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("Files not available");
});
it("renders the runtime name in monospace", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("external");
const spans = container.querySelectorAll("span");
const monoSpans = Array.from(spans).filter(
(s) => s.className && s.className.includes("font-mono"),
);
expect(monoSpans.length).toBeGreaterThan(0);
});
it("renders a Chat tab hint in description", () => {
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
expect(container.textContent).toContain("Chat tab");
});
it("SVG icon has aria-hidden=true", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("renders without crashing for any runtime string", () => {
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
expect(container.textContent).toContain("unknown-runtime");
});
it("applies the correct layout classes to root div", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain("flex");
expect(root.className).toContain("flex-col");
expect(root.className).toContain("items-center");
});
});
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
describe("FilesToolbar", () => {
const noop = vi.fn();
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={noop}
fileCount={0}
onNewFile={noop}
onUpload={noop}
onDownloadAll={noop}
onClearAll={noop}
onRefresh={noop}
{...props}
/>,
);
}
it("renders the directory selector with correct aria-label", () => {
const { container } = renderToolbar();
const select = container.querySelector("select");
expect(select?.getAttribute("aria-label")).toBe("File root directory");
});
it("directory selector has all four options", () => {
const { container } = renderToolbar();
const select = container.querySelector("select") as HTMLSelectElement;
const options = Array.from(select?.options ?? []);
const values = options.map((o) => o.value);
expect(values).toContain("/configs");
expect(values).toContain("/home");
expect(values).toContain("/workspace");
expect(values).toContain("/plugins");
});
it("calls setRoot when directory changes", () => {
const setRoot = vi.fn();
const { container } = renderToolbar({ setRoot });
const select = container.querySelector("select") as HTMLSelectElement;
select.value = "/home";
select.dispatchEvent(new Event("change", { bubbles: true }));
expect(setRoot).toHaveBeenCalledWith("/home");
});
it("displays the file count", () => {
const { container } = renderToolbar({ fileCount: 42 });
expect(container.textContent).toContain("42 files");
});
it("shows New + Upload + Clear buttons for /configs", () => {
const { container } = renderToolbar({ root: "/configs" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).toContain("+ New");
expect(texts).toContain("Upload");
expect(texts).toContain("Clear");
expect(texts).toContain("Export");
expect(texts).toContain("↻");
});
it("hides New + Upload + Clear for /workspace", () => {
const { container } = renderToolbar({ root: "/workspace" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
expect(texts).toContain("Export");
});
it("hides New + Upload + Clear for /home", () => {
const { container } = renderToolbar({ root: "/home" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("hides New + Upload + Clear for /plugins", () => {
const { container } = renderToolbar({ root: "/plugins" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("New button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const newBtn = container.querySelector('button[aria-label="Create new file"]');
expect(newBtn?.textContent?.trim()).toBe("+ New");
});
it("Export button has correct aria-label", () => {
const { container } = renderToolbar();
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
expect(exportBtn?.textContent?.trim()).toBe("Export");
});
it("Clear button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
expect(clearBtn?.textContent?.trim()).toBe("Clear");
});
it("Refresh button has correct aria-label", () => {
const { container } = renderToolbar();
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
expect(refreshBtn?.textContent?.trim()).toBe("↻");
});
it("calls onNewFile when New button is clicked", () => {
const onNewFile = vi.fn();
const { container } = renderToolbar({ root: "/configs", onNewFile });
container.querySelector('button[aria-label="Create new file"]')!.click();
expect(onNewFile).toHaveBeenCalledTimes(1);
});
it("calls onDownloadAll when Export button is clicked", () => {
const onDownloadAll = vi.fn();
const { container } = renderToolbar({ onDownloadAll });
container.querySelector('button[aria-label="Download all files"]')!.click();
expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
it("calls onClearAll when Clear button is clicked", () => {
const onClearAll = vi.fn();
const { container } = renderToolbar({ root: "/configs", onClearAll });
container.querySelector('button[aria-label="Delete all files"]')!.click();
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it("calls onRefresh when Refresh button is clicked", () => {
const onRefresh = vi.fn();
const { container } = renderToolbar({ onRefresh });
container.querySelector('button[aria-label="Refresh file list"]')!.click();
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});
@@ -1,349 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for FilesToolbar — the top-of-panel bar for the Files tab.
* Covers: directory select, file count, New/Upload/Clear (configs-only),
* Export, Refresh, and aria-labels.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FilesToolbar } from "../FilesToolbar";
afterEach(cleanup);
describe("FilesToolbar", () => {
describe("renders base toolbar", () => {
it("renders the directory select with aria-label", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.getByRole("combobox", { name: /file root directory/i })
).toBeTruthy();
});
it("renders the file count", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={7}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(screen.getByText("7 files")).toBeTruthy();
});
it("renders Export button", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.getByRole("button", { name: /download all files/i })
).toBeTruthy();
});
it("renders Refresh button", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /refresh file list/i })).toBeTruthy();
});
it("renders 0 files when count is 0", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(screen.getByText("0 files")).toBeTruthy();
});
});
describe("configs-only buttons", () => {
it("shows New and Upload buttons when root is /configs", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.getByRole("button", { name: /create new file/i })
).toBeTruthy();
expect(
screen.getByRole("button", { name: /upload folder/i })
).toBeTruthy();
expect(screen.getByRole("button", { name: /delete all files/i })).toBeTruthy();
});
it("hides New and Upload when root is /workspace", () => {
render(
<FilesToolbar
root="/workspace"
setRoot={vi.fn()}
fileCount={5}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.queryByRole("button", { name: /create new file/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /upload folder/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /delete all files/i })
).toBeNull();
// Export and Refresh are still present
expect(
screen.getByRole("button", { name: /download all files/i })
).toBeTruthy();
});
it("hides New and Upload when root is /home", () => {
render(
<FilesToolbar
root="/home"
setRoot={vi.fn()}
fileCount={2}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.queryByRole("button", { name: /create new file/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /upload folder/i })
).toBeNull();
});
it("hides New and Upload when root is /plugins", () => {
render(
<FilesToolbar
root="/plugins"
setRoot={vi.fn()}
fileCount={1}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
expect(
screen.queryByRole("button", { name: /create new file/i })
).toBeNull();
expect(
screen.queryByRole("button", { name: /upload folder/i })
).toBeNull();
});
});
describe("callbacks", () => {
it("calls setRoot when directory is changed", () => {
const setRoot = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={setRoot}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
fireEvent.change(screen.getByRole("combobox"), {
target: { value: "/workspace" },
});
expect(setRoot).toHaveBeenCalledWith("/workspace");
});
it("calls onNewFile when New button is clicked", () => {
const onNewFile = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={onNewFile}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /create new file/i }));
expect(onNewFile).toHaveBeenCalledTimes(1);
});
it("calls onDownloadAll when Export button is clicked", () => {
const onDownloadAll = vi.fn();
render(
<FilesToolbar
root="/workspace"
setRoot={vi.fn()}
fileCount={5}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={onDownloadAll}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /download all files/i }));
expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
it("calls onClearAll when Clear button is clicked", () => {
const onClearAll = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={onClearAll}
onRefresh={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /delete all files/i }));
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it("calls onRefresh when Refresh button is clicked", () => {
const onRefresh = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={onRefresh}
/>
);
fireEvent.click(screen.getByRole("button", { name: /refresh file list/i }));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("calls onUpload when the hidden file input changes", () => {
const onUpload = vi.fn();
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={onUpload}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
// Find the hidden file input
const fileInput = document.querySelector(
'input[type="file"]'
) as HTMLInputElement;
expect(fileInput).toBeTruthy();
expect(fileInput?.getAttribute("aria-label")).toBe("Upload folder files");
});
});
describe("a11y", () => {
it("all buttons have aria-label or accessible name", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
// All buttons should be findable by role
const buttons = screen.getAllByRole("button");
for (const btn of buttons) {
expect(btn.getAttribute("aria-label") ?? btn.textContent).toBeTruthy();
}
});
it("directory select has aria-label", () => {
render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={3}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
/>
);
const select = screen.getByRole("combobox");
expect(select.getAttribute("aria-label")).toBe("File root directory");
});
});
});
@@ -1,101 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for NotAvailablePanel — the full-tab placeholder shown when a
* workspace's runtime doesn't own a platform-managed filesystem (today:
* runtime === "external"). Covers rendering, a11y, and runtime prop
* display.
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { NotAvailablePanel } from "../NotAvailablePanel";
afterEach(cleanup);
describe("NotAvailablePanel", () => {
describe("renders", () => {
it("renders the heading", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByText("Files not available")).toBeTruthy();
});
it("renders the description text", () => {
render(<NotAvailablePanel runtime="external" />);
expect(
screen.getByText(/whose filesystem isn't owned by the platform/i)
).toBeTruthy();
});
it("displays the runtime name in the description", () => {
render(<NotAvailablePanel runtime="aws-lambda" />);
// The runtime name appears inside the paragraph
const para = screen.getByText(/whose filesystem isn't owned/i);
expect(para.textContent).toContain("aws-lambda");
});
it("renders the SVG folder icon with aria-hidden", () => {
render(<NotAvailablePanel runtime="external" />);
const svg = document.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("uses the provided runtime prop verbatim", () => {
render(<NotAvailablePanel runtime="cloud-run" />);
const monoRuntime = document.querySelector(".font-mono");
expect(monoRuntime?.textContent).toBe("cloud-run");
});
it("renders the 'Use the Chat tab' guidance text", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByText(/Use the Chat tab/i)).toBeTruthy();
});
it("is contained in a full-height flex column", () => {
render(<NotAvailablePanel runtime="external" />);
const container = screen.getByText("Files not available").closest("div");
expect(container?.className).toContain("flex");
expect(container?.className).toContain("flex-col");
expect(container?.className).toContain("items-center");
expect(container?.className).toContain("justify-center");
expect(container?.className).toContain("h-full");
});
});
describe("a11y", () => {
it("heading is an h3", () => {
render(<NotAvailablePanel runtime="external" />);
expect(screen.getByRole("heading", { level: 3 })).toBeTruthy();
});
it("SVG icon has aria-hidden so screen readers skip it", () => {
render(<NotAvailablePanel runtime="external" />);
const svg = document.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("description paragraph is present with descriptive text", () => {
render(<NotAvailablePanel runtime="external" />);
const paras = document.querySelectorAll("p");
expect(paras.length).toBeGreaterThan(0);
const text = Array.from(paras)
.map((p) => p.textContent)
.join(" ");
expect(text.toLowerCase()).toContain("runtime");
});
});
describe("props", () => {
it("renders with a short runtime name", () => {
render(<NotAvailablePanel runtime="ext" />);
const monoRuntime = document.querySelector(".font-mono");
expect(monoRuntime?.textContent).toBe("ext");
});
it("renders with a complex runtime name", () => {
render(<NotAvailablePanel runtime="gcp-cloud-functions-v2" />);
const monoRuntime = document.querySelector(".font-mono");
expect(monoRuntime?.textContent).toBe("gcp-cloud-functions-v2");
});
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ const FILE_ICONS: Record<string, string> = {
export function getIcon(path: string, isDir: boolean): string {
if (isDir) return "📁";
const ext = "." + (path.split(".").pop() ?? "").toLowerCase();
const ext = "." + path.split(".").pop();
return FILE_ICONS[ext] || "📄";
}
@@ -1,323 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import React from "react";
import { BudgetSection } from "../BudgetSection";
import { api } from "@/lib/api";
// Queue-based mock for the api module. Each api call shifts from the queue.
// Tests push with qGet/qPatch and the module-level mockImplementation
// reads from the queue.
type QueueEntry = { body?: unknown; err?: Error };
const apiQueue: QueueEntry[] = [];
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(async (_path: string) => {
const next = apiQueue.shift();
if (!next) throw new Error("api.get queue exhausted");
if (next.err) throw next.err;
return next.body;
}),
patch: vi.fn(async (_path: string, _body?: unknown) => {
const next = apiQueue.shift();
if (!next) throw new Error("api.patch queue exhausted");
if (next.err) throw next.err;
return next.body;
}),
},
}));
afterEach(cleanup);
beforeEach(() => {
apiQueue.length = 0;
vi.clearAllMocks();
});
const WS_ID = "budget-test-ws";
function qGet(body: unknown) {
apiQueue.push({ body });
}
function qGetErr(status: number, msg: string) {
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
}
function qPatch(body: unknown) {
apiQueue.push({ body });
}
function qPatchErr(status: number, msg: string) {
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
}
function makeBudget(overrides: Partial<{
budget_limit: number | null;
budget_used: number;
budget_remaining: number | null;
}> = {}) {
return {
budget_limit: 10_000,
budget_used: 3_500,
budget_remaining: 6_500,
...overrides,
};
}
describe("BudgetSection", () => {
describe("loading state", () => {
it("shows loading indicator while fetching", async () => {
let resolveGet: (v: unknown) => void;
vi.mocked(api.get).mockImplementationOnce(
async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }),
);
render(<BudgetSection workspaceId={WS_ID} />);
expect(screen.getByTestId("budget-loading")).toBeTruthy();
resolveGet!(makeBudget());
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-loading")).toBeNull();
});
});
});
describe("fetch error state", () => {
it("shows error message on non-402 fetch failure", async () => {
qGetErr(500, "Internal Server Error");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
});
expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500");
});
it("shows 402 as exceeded banner, not fetch error", async () => {
qGetErr(402, "Payment Required");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
expect(screen.queryByTestId("budget-fetch-error")).toBeNull();
});
});
describe("budget loaded — display", () => {
it("renders used / limit stats row", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500");
});
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
});
it("renders 'Unlimited' when budget_limit is null", async () => {
qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited");
});
});
it("renders remaining credits when present", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500");
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining");
});
});
it("omits remaining credits when budget_remaining is null", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-remaining")).toBeNull();
});
});
it("caps progress bar at 100% when used > limit", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
const fill = screen.getByTestId("budget-progress-fill");
expect(fill.getAttribute("style")).toContain("100%");
});
});
it("omits progress bar when budget_limit is null (unlimited)", async () => {
qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-progress-fill")).toBeNull();
});
});
});
describe("budget exceeded (402)", () => {
it("shows exceeded banner when load returns 402", async () => {
qGetErr(402, "Payment Required");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded");
});
});
it("clears exceeded banner after successful save", async () => {
qGetErr(402, "Payment Required");
qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
const input = screen.getByTestId("budget-limit-input");
fireEvent.change(input, { target: { value: "50000" } });
const saveBtn = screen.getByTestId("budget-save-btn");
fireEvent.click(saveBtn);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
});
});
});
describe("save flow", () => {
it("shows save error on non-402 patch failure", async () => {
qGet(makeBudget());
qPatchErr(500, "Internal Server Error");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
const saveBtn = screen.getByTestId("budget-save-btn");
fireEvent.click(saveBtn);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500");
});
});
it("updates input to new limit value after successful save", async () => {
qGet(makeBudget({ budget_limit: 10_000 }));
qPatch(makeBudget({ budget_limit: 20_000 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-loading")).toBeNull();
});
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
expect(input.value).toBe("10000");
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
fireEvent.change(input, { target: { value: "20000" } });
expect(input.value).toBe("20000");
fireEvent.click(screen.getByTestId("budget-save-btn"));
await vi.waitFor(() => {
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000");
});
});
it("sends null when input is cleared (unlimited)", async () => {
qGet(makeBudget({ budget_limit: 10_000 }));
qPatch(makeBudget({ budget_limit: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByTestId("budget-save-btn"));
await vi.waitFor(() => {
expect(input.value).toBe("");
});
});
it("shows saving state on button while patch is in flight", async () => {
qGet(makeBudget());
let resolvePatch: (v: unknown) => void;
vi.mocked(api.patch).mockImplementationOnce(
async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }),
);
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } });
fireEvent.click(screen.getByTestId("budget-save-btn"));
const btn = screen.getByTestId("budget-save-btn");
expect(btn.textContent).toContain("Saving");
resolvePatch!(makeBudget({ budget_limit: 50_000 }));
await vi.waitFor(() => {
expect(btn.textContent).toContain("Save");
});
});
});
describe("isApiError402 — regression coverage", () => {
it("classifies ': 402' with space as 402", async () => {
qGetErr(402, "Payment Required");
qPatch(makeBudget());
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
});
it("classifies non-402 error messages as regular fetch errors", async () => {
qGetErr(503, "Service Unavailable");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
});
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
});
});
});
@@ -1,726 +0,0 @@
// @vitest-environment jsdom
/**
* MemoryTab — 42 test cases covering awareness dashboard, KV memory CRUD,
* and error states.
*
* Issue #519: Add 42 test cases for MemoryTab (42 cases).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
fireEvent,
cleanup,
act,
} from "@testing-library/react";
import React from "react";
// ── Module-level mocks ────────────────────────────────────────────────────────
// Mock @/lib/env before MemoryTab loads so it sees the stub values.
vi.mock("@/lib/env", () => ({
NEXT_PUBLIC_AWARENESS_URL: "http://localhost:37800",
}));
// Mock @/lib/api at module level. vi.hoisted() captures the mock function
// references so they are accessible in the test scope after hoisting.
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>());
const _mockPost = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
const _mockDel = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: {
get: _mockGet,
post: _mockPost,
del: _mockDel,
},
}));
// Stub window.open so tests don't actually open a window.
const _windowOpen = vi.fn();
vi.stubGlobal("window", {
...window,
open: _windowOpen,
});
import { MemoryTab } from "../MemoryTab";
import { api } from "@/lib/api";
const WS_ID = "ws-test-123";
const MEMORY_ENTRY: Record<string, unknown> = {
key: "user-preference",
value: { theme: "dark", language: "en" },
version: 1,
expires_at: null,
updated_at: "2026-04-15T10:00:00Z",
};
const MEMORY_ENTRY_WITH_TTL: Record<string, unknown> = {
key: "session-token",
value: "abc123",
version: 3,
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
updated_at: "2026-04-15T11:00:00Z",
};
const MEMORY_ENTRY_RAW_STRING: Record<string, unknown> = {
key: "plain-text",
value: "hello world",
version: 1,
expires_at: null,
updated_at: "2026-04-15T12:00:00Z",
};
// ── Setup / teardown ────────────────────────────────────────────────────────
beforeEach(() => {
// Reset all api mock functions to a clean default state between tests.
_mockGet.mockReset();
_mockGet.mockResolvedValue([] as unknown[]);
_mockPost.mockReset();
_mockPost.mockResolvedValue({} as unknown);
_mockDel.mockReset();
_mockDel.mockResolvedValue({} as unknown);
_windowOpen.mockClear();
});
afterEach(cleanup);
// ── Shared helpers ──────────────────────────────────────────────────────────
/**
* Render MemoryTab and reveal the entries list by clicking "Show".
* The component starts with showAdvanced=false (hidden mode); most entry-list
* tests need to click Show before entries appear.
*
* Uses fireEvent.click directly on the button element (not the text span) to
* ensure React's onClick fires correctly.
*/
async function renderAndShowEntries() {
render(<MemoryTab workspaceId={WS_ID} />);
// Wait for the api.get mock to resolve and React to render with entries.
// 500ms gives enough time for useEffect → setEntries → re-render.
await new Promise((r) => setTimeout(r, 500));
fireEvent.click(screen.getByRole("button", { name: /show/i }));
}
/** Configure api.get to resolve with the given entries.
* Must be called BEFORE render() so the useEffect sees the mock. */
function stubMemoryFetch(entries: unknown[]) {
_mockGet.mockReset();
_mockGet.mockResolvedValue(entries as unknown[]);
}
/**
* Click the memory entry button to expand it.
* Uses filter-on-all-buttons to avoid getByRole's strict accessible-name
* matching (which can silently find the wrong element in dense DOM trees).
*/
function expandEntry(key: string) {
const allBtns = screen.getAllByRole("button");
const entryBtn = allBtns.find((b) => b.textContent?.includes(key));
if (!entryBtn) throw new Error(`expandEntry: no button found containing "${key}"`);
act(() => { fireEvent.click(entryBtn); });
}
// =============================================================================
// Awareness dashboard
// =============================================================================
describe("MemoryTab — awareness dashboard", () => {
it("shows awareness section on load", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByText("Awareness dashboard")).toBeTruthy();
});
it("renders iframe with correct src containing workspaceId", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
const iframe = (await screen.findByTitle(
"Awareness dashboard",
)) as HTMLIFrameElement;
expect(iframe.src).toContain("workspaceId=" + WS_ID);
});
it("collapse button hides iframe and shows collapsed state", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByTitle("Awareness dashboard")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
expect(
await screen.findByText(/awareness dashboard is collapsed/i),
).toBeTruthy();
expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
});
it("collapsed state has expand button that re-shows iframe", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /collapse/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
// After collapse there are two "Expand" buttons (header + collapsed banner).
// Click the one inside the collapsed banner (last in DOM order).
const expandBtns = await screen.findAllByRole("button", { name: /^expand$/i });
fireEvent.click(expandBtns[expandBtns.length - 1]);
expect(await screen.findByTitle("Awareness dashboard")).toBeTruthy();
});
it("open button calls window.open with awarenessUrl", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /open/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /open/i }));
expect(_windowOpen).toHaveBeenCalledWith(
expect.stringContaining("workspaceId=" + WS_ID),
"_blank",
"noopener,noreferrer",
);
});
it("renders awareness status grid with Connected / Mode / Workspace", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByText("Connected")).toBeTruthy();
expect(await screen.findByText("Workspace")).toBeTruthy();
});
});
// =============================================================================
// Loading state
// =============================================================================
describe("MemoryTab — loading state", () => {
it("shows 'Loading memory...' while initial fetch is pending", () => {
_mockGet.mockReturnValue(new Promise(() => {}) as unknown as Promise<unknown[]>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(screen.getByText("Loading memory...")).toBeTruthy();
});
it("does not render memory section while loading", () => {
_mockGet.mockReturnValue(new Promise(() => {}) as unknown as Promise<unknown[]>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(screen.queryByText("Workspace KV memory")).toBeNull();
});
});
// =============================================================================
// KV memory — initial load
// =============================================================================
describe("MemoryTab — initial load", () => {
it("fetches memory entries on mount", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Reveal the entries list
expect(await screen.findByRole("button", { name: /show/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /show/i }));
expect(await screen.findByText("Workspace KV memory")).toBeTruthy();
expect(api.get).toHaveBeenCalledWith(`/workspaces/${WS_ID}/memory`);
});
it("renders workspace KV memory section heading", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Heading is visible in hidden mode (above the hidden banner)
expect(await screen.findByText("Workspace KV memory")).toBeTruthy();
});
it("shows advanced mode by default hidden; Refresh / Advanced / + Add buttons visible", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Hidden-mode banner is visible with a Show button
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
expect(await screen.findByRole("button", { name: /show/i })).toBeTruthy();
// Action buttons are still visible in the header
expect(await screen.findByRole("button", { name: /refresh/i })).toBeTruthy();
expect(await screen.findByRole("button", { name: /advanced/i })).toBeTruthy();
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
});
});
// =============================================================================
// KV memory — empty state
// =============================================================================
describe("MemoryTab — empty state", () => {
it("shows 'No memory entries' when entries array is empty (after Show)", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
// Click Show to reveal entries list (advanced mode is hidden by default)
fireEvent.click(await screen.findByRole("button", { name: /show/i }));
expect(await screen.findByText("No memory entries")).toBeTruthy();
});
it("hidden mode shows 'Advanced workspace memory is hidden' message", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
});
});
// =============================================================================
// KV memory — list rendering
// =============================================================================
describe("MemoryTab — list rendering", () => {
it("renders a memory entry key in accent/mono text", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
});
it("expands an entry on click showing the value as pretty JSON", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(
await screen.findByText(/"theme":\s*"dark".*?"language":\s*"en"/),
).toBeTruthy();
});
it("shows raw string value without extra quotes when value is plain string", async () => {
stubMemoryFetch([MEMORY_ENTRY_RAW_STRING]);
await renderAndShowEntries();
expect(await screen.findByText("plain-text")).toBeTruthy();
expandEntry("plain-text");
expect(await screen.findByText(/"hello world"/)).toBeTruthy();
});
it("renders updated_at timestamp when entry is expanded", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
});
it("shows TTL badge when entry has expires_at", async () => {
stubMemoryFetch([MEMORY_ENTRY_WITH_TTL]);
await renderAndShowEntries();
expect(await screen.findByText("session-token")).toBeTruthy();
expandEntry("session-token");
expect(await screen.findByText(/ttl/i)).toBeTruthy();
});
it("collapse toggle hides the expanded content", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/Updated:/i)).toBeTruthy();
expandEntry("user-preference");
expect(screen.queryByText(/Updated:/i)).toBeNull();
});
});
// =============================================================================
// KV memory — advanced mode toggle
// =============================================================================
describe("MemoryTab — advanced mode toggle", () => {
it("clicking Advanced hides the list and shows 'hidden' placeholder", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
expect(screen.queryByText("user-preference")).toBeNull();
});
it("clicking Show from hidden mode re-displays the list", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
// Hide via Advanced button
fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
expect(await screen.findByText("Advanced workspace memory is hidden")).toBeTruthy();
// Reveal again
fireEvent.click(screen.getByRole("button", { name: /show/i }));
expect(await screen.findByText("user-preference")).toBeTruthy();
});
it("Hide Advanced button appears when in hidden mode", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
// renderAndShowEntries sets showAdvanced=true, so button says "Hide Advanced".
// Click "Hide Advanced" to toggle back to hidden mode.
fireEvent.click(screen.getByRole("button", { name: /hide advanced/i }));
expect(
await screen.findByText("Advanced workspace memory is hidden"),
).toBeTruthy();
});
});
// =============================================================================
// KV memory — Add entry
// =============================================================================
describe("MemoryTab — add entry", () => {
it("clicking + Add shows the add form", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
expect(await screen.findByLabelText(/memory value/i)).toBeTruthy();
});
it("add form requires a non-empty key", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText("Key is required")).toBeTruthy();
expect(api.post).not.toHaveBeenCalled();
});
it("add form parses plain text value as-is (not JSON)", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "my-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "plain text value" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({ key: "my-key", value: "plain text value" }),
);
});
it("add form parses JSON value when valid JSON is entered", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "json-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: '{"foo": 123}' },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({ key: "json-key", value: { foo: 123 } }),
);
});
it("add form accepts optional TTL", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
// aria-label is "TTL in seconds (optional)"
expect(await screen.findByLabelText("TTL in seconds (optional)")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "ttl-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "val" },
});
fireEvent.change(screen.getByLabelText("TTL in seconds (optional)"), {
target: { value: "3600" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({
key: "ttl-key",
value: "val",
ttl_seconds: 3600,
}),
);
});
it("successful add clears the form and closes it", async () => {
stubMemoryFetch([]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "new-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "new-val" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
// Form should close
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
expect(screen.queryByLabelText("Memory key")).toBeNull();
});
it("add failure shows error in the add form", async () => {
stubMemoryFetch([]);
_mockPost.mockRejectedValueOnce(new Error("server error"));
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "bad-key" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "val" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText("server error")).toBeTruthy();
});
it("cancel button closes the add form without posting", async () => {
stubMemoryFetch([]);
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(screen.queryByLabelText("Memory key")).toBeNull();
expect(api.post).not.toHaveBeenCalled();
});
});
// =============================================================================
// KV memory — Edit entry
// =============================================================================
describe("MemoryTab — edit entry", () => {
// TEMP inline debug
it("DEBUG check expandEntry via expandEntry function", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
const btns = screen.getAllByRole("button");
console.log("All button texts:", btns.map(b => b.textContent));
const match = btns.find(b => b.textContent?.includes("user-preference"));
console.log("Found button:", match?.textContent, "aria-expanded:", match?.getAttribute("aria-expanded"));
expandEntry("user-preference");
console.log("After expandEntry aria-expanded:", match?.getAttribute("aria-expanded"));
expect(await screen.findByText(/updated:/i)).toBeTruthy();
});
it("clicking Edit on an expanded entry switches to edit mode", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
// Expand shows "Updated:" + Edit/Delete buttons; click Edit to enter edit mode.
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
expect(await screen.findByLabelText(/edit ttl/i)).toBeTruthy();
});
it("edit form pre-populates with current value (pretty JSON for objects)", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
const textarea = screen.getByLabelText(/edit value/i) as HTMLTextAreaElement;
expect(textarea.value).toContain("theme");
expect(textarea.value).toContain("dark");
});
it("edit form pre-populates raw string value without surrounding quotes", async () => {
stubMemoryFetch([MEMORY_ENTRY_RAW_STRING]);
await renderAndShowEntries();
expect(await screen.findByText("plain-text")).toBeTruthy();
expandEntry("plain-text");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
const textarea = screen.getByLabelText(/edit value/i) as HTMLTextAreaElement;
expect(textarea.value).toBe("hello world");
});
it("Save calls POST with the new value and if_match_version", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockPost.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
fireEvent.change(screen.getByLabelText(/edit value/i), {
target: { value: '{"theme": "light"}' },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(api.post).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory`,
expect.objectContaining({
key: "user-preference",
value: { theme: "light" },
if_match_version: 1,
}),
);
});
it("409 conflict shows retry hint and reloads entry", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockPost.mockRejectedValueOnce(
Object.assign(new Error("409 Conflict"), { status: 409 }),
);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(
await screen.findByText(/this entry changed since you opened it/i),
).toBeTruthy();
});
it("cancel button exits edit mode without posting", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(await screen.findByLabelText(/edit value/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(await screen.findByText(/"theme":/)).toBeTruthy();
expect(api.post).not.toHaveBeenCalled();
});
});
// =============================================================================
// KV memory — Delete entry
// =============================================================================
describe("MemoryTab — delete entry", () => {
it("clicking Delete optimistically removes entry from list", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
act(() => {
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Delete",
);
if (deleteBtn) fireEvent.click(deleteBtn);
});
await new Promise(r => setTimeout(r, 300));
expect(screen.queryByText("user-preference")).toBeNull();
});
it("Delete calls DEL with correct path", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
expect(api.del).toHaveBeenCalledWith(
`/workspaces/${WS_ID}/memory/${encodeURIComponent("user-preference")}`,
);
});
it("Delete failure does NOT remove entry from list", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockRejectedValueOnce(new Error("forbidden"));
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
expect(await screen.findByText("user-preference")).toBeTruthy();
});
it("Delete clears expanded state when deleting the expanded entry", async () => {
stubMemoryFetch([MEMORY_ENTRY]);
_mockDel.mockResolvedValueOnce({} as unknown as Promise<unknown>);
await renderAndShowEntries();
expect(await screen.findByText("user-preference")).toBeTruthy();
expandEntry("user-preference");
expect(await screen.findByText(/updated:/i)).toBeTruthy();
act(() => {
// Re-query inside flush so we get post-expansion buttons
const deleteBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Delete",
);
if (deleteBtn) fireEvent.click(deleteBtn);
});
await new Promise(r => setTimeout(r, 300));
expect(screen.queryByText("user-preference")).toBeNull();
});
});
// =============================================================================
// KV memory — Refresh
// =============================================================================
describe("MemoryTab — refresh", () => {
it("Refresh button re-fetches memory entries", async () => {
const first = [{ key: "a", value: "1", updated_at: "2026-01-01T00:00:00Z" }];
const second = [
...first,
{ key: "b", value: "2", updated_at: "2026-01-01T00:00:00Z" },
];
// Chain two resolved values: first for initial mount, second for Refresh click.
// Do NOT call renderAndShowEntries (which calls stubMemoryFetch and resets the chain).
_mockGet
.mockResolvedValueOnce(first as unknown[])
.mockResolvedValueOnce(second as unknown[]);
render(<MemoryTab workspaceId={WS_ID} />);
await new Promise((r) => setTimeout(r, 500));
fireEvent.click(screen.getByRole("button", { name: /show/i }));
expect(await screen.findByText("a")).toBeTruthy();
expect(screen.queryByText("b")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
expect(await screen.findByText("b")).toBeTruthy();
});
});
// =============================================================================
// Error states
// =============================================================================
describe("MemoryTab — error states", () => {
it("shows error banner when initial fetch fails", async () => {
_mockGet.mockRejectedValueOnce(new Error("internal server error"));
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByText("internal server error")).toBeTruthy();
});
it("error is shown in the form when add fails, not as a top-level banner", async () => {
stubMemoryFetch([]);
_mockPost.mockRejectedValueOnce(new Error("add failed"));
render(<MemoryTab workspaceId={WS_ID} />);
expect(await screen.findByRole("button", { name: /\+ add/i })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
expect(await screen.findByLabelText("Memory key")).toBeTruthy();
fireEvent.change(screen.getByLabelText("Memory key"), {
target: { value: "k" },
});
fireEvent.change(screen.getByLabelText(/memory value/i), {
target: { value: "v" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText("add failed")).toBeTruthy();
});
});
@@ -1,245 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentLightbox — shared fullscreen modal for image/PDF
* fullscreen viewing.
*
* Covers: open/close rendering, backdrop click-to-close, Esc key close,
* role/dialog + aria attributes, close button, prefers-reduced-motion.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AttachmentLightbox } from "../AttachmentLightbox";
afterEach(cleanup);
describe("AttachmentLightbox", () => {
describe("renders nothing when closed", () => {
it("returns null when open=false", () => {
const { container } = render(
<AttachmentLightbox open={false} onClose={vi.fn()} ariaLabel="Image preview">
<img src="test.jpg" alt="test" />
</AttachmentLightbox>
);
expect(container.textContent).toBe("");
});
});
describe("renders modal when open", () => {
it("renders the dialog when open=true", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Image preview">
<img src="test.jpg" alt="test" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("renders the provided children", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="PDF preview">
<embed src="doc.pdf" />
</AttachmentLightbox>
);
expect(document.querySelector("embed")).toBeTruthy();
});
it("has aria-modal=true", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
});
it("uses the provided ariaLabel", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="My document">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("My document");
});
it("renders the close button", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy();
});
it("close button renders an SVG icon", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const btn = screen.getByRole("button", { name: /close preview/i });
expect(btn.querySelector("svg")).toBeTruthy();
});
});
describe("Esc to close", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("calls onClose when Escape is pressed", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
act(() => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not call onClose for non-Escape keys", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
act(() => {
fireEvent.keyDown(document, { key: "Enter" });
});
expect(onClose).not.toHaveBeenCalled();
});
it("does not call onClose when closed (open=false)", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={false} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
act(() => {
fireEvent.keyDown(document, { key: "Escape" });
});
expect(onClose).not.toHaveBeenCalled();
});
});
describe("backdrop click to close", () => {
it("calls onClose when backdrop is clicked", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const dialog = screen.getByRole("dialog");
fireEvent.click(dialog);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not call onClose when content area is clicked", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
// The content is nested inside the dialog — clicking the inner content
// div should not close because it has stopPropagation
const content = document.querySelector(".max-w-\\[95vw\\]") as HTMLElement;
if (content) {
fireEvent.click(content);
}
expect(onClose).not.toHaveBeenCalled();
});
it("does not call onClose when close button is clicked", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
fireEvent.click(screen.getByRole("button", { name: /close preview/i }));
// onClose is NOT called for button click — the button's onClick handles
// close directly. Only backdrop click triggers onClose.
// (The component does not call onClose from the button; it calls setOpen(false)
// Actually, looking at the component: onClick={onClose} on the button too.
// So this test should expect onClose to be called.
// Wait — the close button's onClick calls onClose, and backdrop also calls onClose.
// Both should call onClose.
// Let me update this test.
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe("a11y", () => {
it("dialog has role=dialog", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("close button has accessible name", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
expect(screen.getByRole("button", { name: /close preview/i })).toBeTruthy();
});
it("dialog has aria-label matching the provided label", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Quarterly Report Q1 2026">
<img src="report.jpg" alt="report" />
</AttachmentLightbox>
);
expect(screen.getByRole("dialog").getAttribute("aria-label")).toBe("Quarterly Report Q1 2026");
});
});
describe("motion", () => {
it("backdrop applies motion-reduce class for reduced motion preference", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("motion-reduce");
});
it("backdrop has transition-opacity for normal motion preference", () => {
render(
<AttachmentLightbox open={true} onClose={vi.fn()} ariaLabel="Preview">
<img src="x.jpg" alt="x" />
</AttachmentLightbox>
);
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("transition-opacity");
});
});
});
@@ -1,167 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for AttachmentViews.tsx — PendingAttachmentPill + AttachmentChip.
*
* 16 cases covering:
* - PendingAttachmentPill: name, size, aria-label, onRemove, one-button guard
* - AttachmentChip: name+glyph, size, no-size, title, onDownload, tone=user/agent, one-button guard
*
* Pattern: render the real component, inspect actual DOM output.
* No mocking of the components themselves.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import React from "react";
import {
PendingAttachmentPill,
AttachmentChip,
} from "../AttachmentViews";
import type { ChatAttachment } from "../types";
afterEach(cleanup);
// ─── Shared test fixtures ────────────────────────────────────────────────────
const makeFile = (name: string, size: number): File =>
new File([new Uint8Array(size)], name, { type: "application/octet-stream" });
const makeAttachment = (overrides: Partial<ChatAttachment> = {}): ChatAttachment => ({
name: "report.pdf",
uri: "workspace:/workspace/report.pdf",
mimeType: "application/pdf",
size: 42_000,
...overrides,
});
// ─── PendingAttachmentPill ───────────────────────────────────────────────────
describe("PendingAttachmentPill", () => {
describe("renders", () => {
it("displays the file name", () => {
const file = makeFile("notes.txt", 128);
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
expect(screen.getByText("notes.txt")).toBeTruthy();
});
it("displays formatted size in bytes", () => {
// File([], name) gives size 0; pass a Uint8Array to set actual byte size.
const file = new File([new Uint8Array(512)], "tiny.bin");
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
expect(screen.getByText("512 B")).toBeTruthy();
});
it("displays formatted size in KB", () => {
const file = new File([new Uint8Array(5 * 1024)], "medium.zip");
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
expect(screen.getByText("5 KB")).toBeTruthy();
});
it("displays formatted size in MB", () => {
const file = new File([new Uint8Array(Math.floor(1.5 * 1024 * 1024))], "large.tar");
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
// formatSize uses toFixed(1) for MB → "1.5 MB"
expect(screen.getByText("1.5 MB")).toBeTruthy();
});
it('× button has aria-label "Remove <filename>"', () => {
const file = makeFile("memo.pdf", 1_000);
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
expect(screen.getByRole("button", { name: /remove memo\.pdf/i })).toBeTruthy();
});
it("calls onRemove when × button is clicked", () => {
const onRemove = vi.fn();
const file = makeFile("photo.png", 999);
render(<PendingAttachmentPill file={file} onRemove={onRemove} />);
fireEvent.click(screen.getByRole("button", { name: /remove photo\.png/i }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it("renders exactly one button (no stray click targets)", () => {
const file = makeFile("doc.docx", 20_000);
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
});
});
});
// ─── AttachmentChip ────────────────────────────────────────────────────────
describe("AttachmentChip", () => {
let onDownload: ReturnType<typeof vi.fn>;
beforeEach(() => {
onDownload = vi.fn();
});
describe("renders", () => {
it("displays the attachment name", () => {
const att = makeAttachment({ name: "analysis.csv" });
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
expect(screen.getByText("analysis.csv")).toBeTruthy();
});
it("displays the download glyph (SVG icon) inside the button", () => {
const att = makeAttachment();
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
const button = screen.getByRole("button");
// DownloadGlyph is an <svg aria-hidden="true"> inside the button
const svg = button.querySelector("svg");
expect(svg).not.toBeNull();
});
it("displays size when provided", () => {
const att = makeAttachment({ size: 41_000 }); // ~40 KB
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
// 41 000 / 1024 ≈ 40 → "40 KB"
expect(screen.getByText("40 KB")).toBeTruthy();
});
it("omits size span when size is undefined", () => {
const att = makeAttachment({ size: undefined });
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
// "KB" should not appear; only the name + download glyph are visible
expect(screen.queryByText(/KB/i)).toBeNull();
});
it('has title attribute for hover tooltip', () => {
const att = makeAttachment({ name: "readme.md" });
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
const button = screen.getByRole("button");
expect(button.getAttribute("title")).toBe("Download readme.md");
});
it("calls onDownload with the attachment when clicked", () => {
const att = makeAttachment({ name: "data.json" });
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
fireEvent.click(screen.getByRole("button"));
expect(onDownload).toHaveBeenCalledTimes(1);
expect(onDownload).toHaveBeenCalledWith(att);
});
it("tone=user applies blue-400 accent class", () => {
const att = makeAttachment();
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="user" />);
const button = screen.getByRole("button");
// The user tone includes blue-400/blue-100 accent classes.
// We check the rendered class string includes the accent class.
expect(button.className).toMatch(/blue-400/);
});
it("tone=agent omits blue-400 accent class", () => {
const att = makeAttachment();
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
const button = screen.getByRole("button");
expect(button.className).not.toMatch(/blue-400/);
});
it("renders exactly one button (no duplicate download targets)", () => {
const att = makeAttachment({ name: "budget.xlsx", size: 80_000 });
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="user" />);
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
});
});
});
+2 -5
View File
@@ -26,16 +26,13 @@ export function createMessage(
content: string,
attachments?: ChatAttachment[],
): ChatMessage {
const base = {
return {
id: crypto.randomUUID(),
role,
content,
attachments: attachments && attachments.length > 0 ? attachments : undefined,
timestamp: new Date().toISOString(),
};
if (attachments && attachments.length > 0) {
return Object.freeze({ ...base, attachments });
}
return Object.freeze(base);
}
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
@@ -1,261 +0,0 @@
// @vitest-environment jsdom
"use client";
/**
* Tests for form-inputs.tsx — 35 cases:
* TextInput (7), NumberInput (8), Toggle (5), TagList (9), Section (6).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import React from "react";
import {
TextInput,
NumberInput,
Toggle,
TagList,
Section,
} from "../form-inputs";
afterEach(cleanup);
// ─── TextInput ───────────────────────────────────────────────────────────────
describe("TextInput", () => {
describe("renders", () => {
it("renders the label", () => {
render(<TextInput label="API Key" value="" onChange={vi.fn()} />);
expect(screen.getByLabelText("API Key")).toBeTruthy();
});
it("renders the current value", () => {
render(<TextInput label="Name" value="Claude" onChange={vi.fn()} />);
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("Claude");
});
it("calls onChange when value changes", () => {
const onChange = vi.fn();
render(<TextInput label="Name" value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Sonnet" } });
expect(onChange).toHaveBeenCalledWith("Sonnet");
});
it("renders placeholder when provided", () => {
render(<TextInput label="Name" value="" onChange={vi.fn()} placeholder="Enter your name" />);
expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Enter your name");
});
it("applies font-mono class when mono=true", () => {
render(<TextInput label="Token" value="" onChange={vi.fn()} mono />);
const input = screen.getByRole("textbox");
expect(input.className).toMatch(/font-mono/);
});
it("has aria-label matching the label", () => {
render(<TextInput label="API Key" value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("API Key");
});
it("does not apply font-mono class when mono=false", () => {
render(<TextInput label="Name" value="" onChange={vi.fn()} mono={false} />);
expect(screen.getByRole("textbox").className).not.toMatch(/font-mono/);
});
});
});
// ─── NumberInput ────────────────────────────────────────────────────────────
describe("NumberInput", () => {
describe("renders", () => {
it("renders the label", () => {
render(<NumberInput label="Port" value={8000} onChange={vi.fn()} />);
expect(screen.getByLabelText("Port")).toBeTruthy();
});
it("renders the numeric value", () => {
render(<NumberInput label="Timeout" value={120} onChange={vi.fn()} />);
expect((screen.getByRole("spinbutton") as HTMLInputElement).value).toBe("120");
});
it("calls onChange with parsed integer", () => {
const onChange = vi.fn();
render(<NumberInput label="Retries" value={0} onChange={onChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "3" } });
expect(onChange).toHaveBeenCalledWith(3);
});
it("calls onChange with 0 for non-numeric input", () => {
const onChange = vi.fn();
render(<NumberInput label="Retries" value={0} onChange={onChange} />);
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "abc" } });
expect(onChange).toHaveBeenCalledWith(0);
});
it("applies min/max attributes", () => {
render(<NumberInput label="Priority" value={5} onChange={vi.fn()} min={1} max={10} />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;
expect(input.min).toBe("1");
expect(input.max).toBe("10");
});
it("has aria-label matching the label", () => {
render(<NumberInput label="Retries" value={3} onChange={vi.fn()} />);
expect(screen.getByRole("spinbutton").getAttribute("aria-label")).toBe("Retries");
});
it("applies font-mono class", () => {
render(<NumberInput label="Timeout" value={30} onChange={vi.fn()} />);
expect(screen.getByRole("spinbutton").className).toMatch(/font-mono/);
});
});
});
// ─── Toggle ─────────────────────────────────────────────────────────────────
describe("Toggle", () => {
describe("renders", () => {
it("renders a checkbox", () => {
render(<Toggle label="Enable streaming" checked={false} onChange={vi.fn()} />);
expect(screen.getByRole("checkbox")).toBeTruthy();
});
it("reflects checked=true state", () => {
render(<Toggle label="Enable streaming" checked={true} onChange={vi.fn()} />);
expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(true);
});
it("reflects checked=false state", () => {
render(<Toggle label="Enable streaming" checked={false} onChange={vi.fn()} />);
expect((screen.getByRole("checkbox") as HTMLInputElement).checked).toBe(false);
});
it("calls onChange with new boolean value", () => {
const onChange = vi.fn();
render(<Toggle label="Enable streaming" checked={false} onChange={onChange} />);
fireEvent.click(screen.getByRole("checkbox"));
expect(onChange).toHaveBeenCalledWith(true);
});
it("renders as type=checkbox", () => {
render(<Toggle label="Enable" checked={false} onChange={vi.fn()} />);
expect(screen.getByRole("checkbox").getAttribute("type")).toBe("checkbox");
});
});
});
// ─── TagList ───────────────────────────────────────────────────────────────
describe("TagList", () => {
describe("renders", () => {
it("renders existing tags", () => {
render(<TagList label="Skills" values={["python", "go"]} onChange={vi.fn()} />);
expect(screen.getByText("python")).toBeTruthy();
expect(screen.getByText("go")).toBeTruthy();
});
it("calls onChange with updated array when × clicked", () => {
const onChange = vi.fn();
render(<TagList label="Skills" values={["python", "go"]} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: /remove tag python/i }));
expect(onChange).toHaveBeenCalledWith(["go"]);
});
it("× button has correct aria-label per tag", () => {
render(<TagList label="Skills" values={["python"]} onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: /remove tag python/i })).toBeTruthy();
});
it("adds tag when Enter is pressed with non-empty input", () => {
const onChange = vi.fn();
render(<TagList label="Skills" values={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "rust" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["rust"]);
});
it("does not add tag when Enter is pressed with whitespace-only input", () => {
const onChange = vi.fn();
render(<TagList label="Skills" values={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("clears input after adding a tag", () => {
const onChange = vi.fn();
render(<TagList label="Skills" values={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "typescript" } });
fireEvent.keyDown(input, { key: "Enter" });
expect((input as HTMLInputElement).value).toBe("");
});
it("renders the label", () => {
render(<TagList label="Tools" values={[]} onChange={vi.fn()} />);
expect(screen.getByLabelText("Tools")).toBeTruthy();
});
it("renders placeholder text", () => {
render(<TagList label="Skills" values={[]} onChange={vi.fn()} placeholder="Add a skill" />);
expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Add a skill");
});
it("renders default placeholder when not specified", () => {
render(<TagList label="Skills" values={[]} onChange={vi.fn()} />);
expect((screen.getByRole("textbox") as HTMLInputElement).placeholder).toBe("Type and press Enter");
});
});
});
// ─── Section ────────────────────────────────────────────────────────────────
describe("Section", () => {
describe("renders", () => {
it("renders the title", () => {
render(<Section title="Runtime Config"><p>Content</p></Section>);
expect(screen.getByText("Runtime Config")).toBeTruthy();
});
it("renders children when defaultOpen=true", () => {
render(<Section title="Runtime Config"><p data-testid="content">Hello</p></Section>);
expect(screen.getByTestId("content")).toBeTruthy();
});
it("hides children when defaultOpen=false", () => {
render(<Section title="Runtime Config" defaultOpen={false}><p data-testid="content">Hello</p></Section>);
expect(screen.queryByTestId("content")).toBeNull();
});
it("toggles children visibility on click", () => {
render(<Section title="Runtime Config" defaultOpen={true}><p data-testid="content">Hello</p></Section>);
expect(screen.getByTestId("content")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /runtime config/i }));
expect(screen.queryByTestId("content")).toBeNull();
});
it("button has aria-expanded reflecting open state", () => {
render(<Section title="Runtime Config" defaultOpen={true}><p>Content</p></Section>);
const btn = screen.getByRole("button", { name: /runtime config/i });
expect(btn.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(btn);
expect(btn.getAttribute("aria-expanded")).toBe("false");
});
it("button has aria-controls linking to content region id", () => {
render(<Section title="Runtime Config"><p>Content</p></Section>);
const btn = screen.getByRole("button", { name: /runtime config/i });
const contentId = btn.getAttribute("aria-controls");
expect(contentId).not.toBeNull();
// Content div has the matching id
expect(document.getElementById(String(contentId))).not.toBeNull();
});
it("indicator span has aria-hidden so screen readers skip it", () => {
render(<Section title="Runtime Config"><p>Content</p></Section>);
const btn = screen.getByRole("button", { name: /runtime config/i });
const indicator = btn.querySelector("[aria-hidden='true']");
expect(indicator).not.toBeNull();
});
});
});
@@ -127,20 +127,13 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
export function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
const contentId = `section-content-${title.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div className="border border-line rounded mb-2">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={contentId}
className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50"
>
<button type="button" onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50">
<span className="font-medium uppercase tracking-wider">{title}</span>
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
<span>{open ? "▾" : "▸"}</span>
</button>
{open && <div id={contentId} className="p-3 space-y-3">{children}</div>}
{open && <div className="p-3 space-y-3">{children}</div>}
</div>
);
}
@@ -70,7 +70,6 @@ export function KeyValueField({
aria-label={ariaLabel}
autoComplete="off"
spellCheck={false}
role="textbox"
/>
<RevealToggle
revealed={revealed}
-213
View File
@@ -1,213 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for canvas/src/lib/hydrate.ts — exponential-backoff canvas store hydration.
*
* 7 cases:
* 1. Success on first attempt → { error: null }
* 2. Viewport fetch fails (non-fatal) → store still hydrates, returns { error: null }
* 3. Success after 1 retry → onRetrying(1) called once, final result { error: null }
* 4. Success after 2 retries → onRetrying called for each failed attempt
* 5. All attempts fail → returns the error message after MAX_RETRIES
* 6. onRetrying called with correct attempt number on each retry
* 7. Exponential backoff delays: 1s, 2s, 4s for attempts 1, 2, 3
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
// ─── Mock api ──────────────────────────────────────────────────────────────────
// PLATFORM_URL must be a named export — hydrate.ts imports it directly, not via api.
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn<(path: string) => Promise<unknown>>(),
},
PLATFORM_URL: "http://localhost:8080",
}));
// ─── Mock store ────────────────────────────────────────────────────────────────
const mockHydrate = vi.fn();
const mockSetViewport = vi.fn();
vi.mock("@/store/canvas", () => ({
useCanvasStore: {
getState: () => ({
hydrate: mockHydrate,
setViewport: mockSetViewport,
}),
},
}));
// ─── Helpers ───────────────────────────────────────────────────────────────────
const mockApiGet = vi.mocked(api.get);
function makeWorkspace(id = "ws-1") {
return {
id,
name: "Test WS",
role: "assistant",
tier: 1,
status: "online" as const,
agent_card: null,
url: "http://localhost:9000",
parent_id: null,
active_tasks: 0,
last_error_rate: 0,
last_sample_error: "",
uptime_seconds: 60,
current_task: "",
x: 0,
y: 0,
collapsed: false,
runtime: "",
budget_limit: null,
};
}
// ─── Setup / teardown ──────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe("hydrateCanvas — success paths", () => {
it("returns { error: null } on first-attempt success", async () => {
mockApiGet
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // /canvas/viewport
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 1 });
});
it("viewport fetch failure is non-fatal — store still hydrates", async () => {
mockApiGet
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces OK
.mockRejectedValueOnce(new Error("viewport down")); // /canvas/viewport fails
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
expect(mockSetViewport).not.toHaveBeenCalled();
});
it("returns { error: null } after 1 retry", async () => {
const onRetrying = vi.fn();
// Each attempt makes 2 parallel api.get calls (workspaces + viewport).
// Attempt 1 (fails): /workspaces → rejected, /viewport → resolved
// Attempt 2 (succeeds): /workspaces → resolved, /viewport → resolved
mockApiGet
.mockRejectedValueOnce(new Error("network down")) // attempt 1: /workspaces
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // attempt 1: /viewport
.mockResolvedValueOnce([makeWorkspace()]) // attempt 2: /workspaces
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // attempt 2: /viewport
const promise = hydrateCanvas(onRetrying);
// Advance past the first backoff delay (1000 * 2^0 = 1000 ms)
await vi.advanceTimersByTimeAsync(1000);
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toEqual({ error: null });
expect(onRetrying).toHaveBeenCalledTimes(1);
expect(onRetrying).toHaveBeenCalledWith(1);
});
it("onRetrying called once per failed attempt before next retry", async () => {
const onRetrying = vi.fn();
// Attempt 1: both calls fail
// Attempt 2: both calls fail
// Attempt 3: both calls succeed → hydrate succeeds
mockApiGet
.mockRejectedValueOnce(new Error("attempt 1")) // a1: /workspaces
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a1: /viewport (resolved even though workspaces failed)
.mockRejectedValueOnce(new Error("attempt 2")) // a2: /workspaces
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a2: /viewport
.mockResolvedValueOnce([makeWorkspace()]) // a3: /workspaces
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // a3: /viewport
const promise = hydrateCanvas(onRetrying);
await vi.runAllTimersAsync();
const result = await promise;
expect(result).toEqual({ error: null });
expect(onRetrying).toHaveBeenCalledTimes(2);
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
});
});
describe("hydrateCanvas — failure paths", () => {
it("returns error message after all MAX_RETRIES attempts exhausted", async () => {
for (let i = 0; i < MAX_RETRIES; i++) {
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1} failed`));
}
const promise = hydrateCanvas();
await vi.runAllTimersAsync();
const result = await promise;
expect(result.error).not.toBeNull();
expect(result.error).toContain("Unable to connect to platform");
expect(mockHydrate).not.toHaveBeenCalled();
});
it("onRetrying called MAX_RETRIES-1 times before final exhausted attempt", async () => {
const onRetrying = vi.fn();
for (let i = 0; i < MAX_RETRIES; i++) {
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
}
const promise = hydrateCanvas(onRetrying);
await vi.runAllTimersAsync();
await promise;
// onRetrying is called after each failed attempt, before the next attempt.
// With MAX_RETRIES=3: called after attempt 1 (→2) and after attempt 2 (→3).
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
});
});
describe("hydrateCanvas — exponential backoff timing", () => {
it("total elapsed time equals sum of exponential delays 1s + 2s + 4s", async () => {
const onRetrying = vi.fn();
for (let i = 0; i < MAX_RETRIES; i++) {
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
}
const start = Date.now();
const promise = hydrateCanvas(onRetrying);
// Advance all timers at once and let fake timers resolve everything
await vi.runAllTimersAsync();
await promise;
const elapsed = Date.now() - start;
// Total expected: 1000 (delay1) + 2000 (delay2) = 3000 ms
// (no delay after the final attempt 3 — function returns immediately)
expect(elapsed).toBeGreaterThanOrEqual(2999);
expect(elapsed).toBeLessThan(5000); // sanity cap
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
});
});
@@ -1,205 +0,0 @@
// @vitest-environment jsdom
"use client";
/**
* Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
*
* Test coverage (9 cases):
* 1. MobileAccentProvider renders children
* 2. usePalette(false) without provider → MOL_LIGHT
* 3. usePalette(true) without provider → MOL_DARK
* 4. accent=null returns base palette unchanged
* 5. accent=base.accent returns base palette unchanged (identity guard)
* 6. accent="#custom" overrides both accent and online
* 7. MOL_LIGHT singleton never mutated
* 8. MOL_DARK singleton never mutated
*
* Plus pure-function coverage for normalizeStatus + tierCode.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import {
MOL_LIGHT,
MOL_DARK,
getPalette,
normalizeStatus,
tierCode,
MobileAccentProvider,
usePalette,
} from "../palette-context";
// ─── usePalette test helper ───────────────────────────────────────────────────
// usePalette reads document.documentElement.dataset.theme internally.
// We set this before rendering so the hook sees the right value.
function setDataTheme(theme: "light" | "dark") {
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = theme;
}
}
// ─── Pure function tests ──────────────────────────────────────────────────────
describe("normalizeStatus", () => {
it("returns emerald-400 for online status", () => {
expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
});
it("returns emerald-400 for degraded status", () => {
expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
});
it("returns red-400 for failed status", () => {
expect(normalizeStatus("failed", false)).toBe("bg-red-400");
expect(normalizeStatus("failed", true)).toBe("bg-red-400");
});
it("returns amber-400 for paused status", () => {
expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
});
it("returns amber-400 for not_configured status", () => {
expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
});
it("returns zinc-400 for unknown status", () => {
expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
expect(normalizeStatus("", false)).toBe("bg-zinc-400");
});
});
describe("tierCode", () => {
it("returns T1 for tier 1", () => {
expect(tierCode(1)).toBe("T1");
});
it("returns T2 for tier 2", () => {
expect(tierCode(2)).toBe("T2");
});
it("returns T4 for tier 4", () => {
expect(tierCode(4)).toBe("T4");
});
it("returns generic T{n} for non-standard tiers", () => {
expect(tierCode(99)).toBe("T99");
});
});
// ─── getPalette tests ─────────────────────────────────────────────────────────
describe("getPalette — accent override", () => {
it("accent=null returns base palette unchanged (light)", () => {
const result = getPalette(null, false);
expect(result).toEqual({ ...MOL_LIGHT });
expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
});
it("accent=null returns base palette unchanged (dark)", () => {
const result = getPalette(null, true);
expect(result).toEqual({ ...MOL_DARK });
expect(result).not.toBe(MOL_DARK);
});
it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
const result = getPalette(MOL_LIGHT.accent, false);
expect(result).toEqual({ ...MOL_LIGHT });
expect(result).not.toBe(MOL_LIGHT);
});
it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
const result = getPalette(MOL_DARK.accent, true);
expect(result).toEqual({ ...MOL_DARK });
expect(result).not.toBe(MOL_DARK);
});
it("accent='#custom' overrides accent and online (light)", () => {
const result = getPalette("#ff0000", false);
expect(result.accent).toBe("#ff0000");
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
});
it("accent='#custom' overrides accent and online (dark)", () => {
const result = getPalette("#00ff00", true);
expect(result.accent).toBe("#00ff00");
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
});
it("MOL_LIGHT singleton is never mutated", () => {
getPalette("#mutate", false);
// All fields must still match the original freeze definition
expect(MOL_LIGHT.accent).toBe("bg-blue-500");
expect(MOL_LIGHT.online).toBe("bg-emerald-400");
expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
expect(MOL_LIGHT.ink).toBe("text-zinc-100");
expect(MOL_LIGHT.line).toBe("border-zinc-700");
expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
});
it("MOL_DARK singleton is never mutated", () => {
getPalette("#mutate", true);
expect(MOL_DARK.accent).toBe("bg-sky-400");
expect(MOL_DARK.online).toBe("bg-emerald-400");
expect(MOL_DARK.surface).toBe("bg-zinc-800");
expect(MOL_DARK.ink).toBe("text-zinc-100");
expect(MOL_DARK.line).toBe("border-zinc-700");
expect(MOL_DARK.bg).toBe("bg-zinc-950");
});
it("getPalette always returns a new object (no shared mutation risk)", () => {
const a = getPalette("#a", false);
const b = getPalette("#b", false);
expect(a).not.toBe(b);
expect(a.accent).not.toBe(b.accent);
});
});
// ─── MobileAccentProvider tests ───────────────────────────────────────────────
describe("MobileAccentProvider", () => {
beforeEach(() => {
setDataTheme("light");
});
afterEach(() => {
cleanup();
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = "";
}
});
it("renders children", () => {
render(
<MobileAccentProvider accent={null}>
<span data-testid="child">Hello</span>
</MobileAccentProvider>,
);
expect(screen.getByTestId("child")).toBeTruthy();
});
// usePalette hook reads data-theme from <html> to determine light/dark.
// In the test environment, data-theme is empty, which falls through to
// the "light" default in usePalette, giving MOL_LIGHT.
it("usePalette(false) without provider → MOL_LIGHT", () => {
setDataTheme("light");
function ShowPalette() {
const p = usePalette(false);
return <span data-testid="accent-light">{p.accent}</span>;
}
render(<ShowPalette />);
expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
});
it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
setDataTheme("dark");
function ShowPalette() {
const p = usePalette(true);
return <span data-testid="accent-dark">{p.accent}</span>;
}
render(<ShowPalette />);
expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
});
});
-167
View File
@@ -1,167 +0,0 @@
"use client";
/**
* palette-context.tsx
*
* Mobile canvas accent palette system.
*
* - MOL_LIGHT / MOL_DARK — immutable base singletons
* - getPalette(accent, isDark) — returns base palette or accent-overridden copy
* - normalizeStatus(status, isDark) — maps workspace status → online dot color
* - tierCode(tier) — maps tier number → display label
* - MobileAccentProvider — React context that propagates accent override
* - usePalette(allowAccentOverride) — hook; returns the effective palette
*/
import { createContext, useContext } from "react";
// ─── Types ─────────────────────────────────────────────────────────────────────
export interface Palette {
/** Accent colour (CSS colour string). */
accent: string;
/** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */
online: string;
/** Surface background colour class. */
surface: string;
/** Primary text colour class. */
ink: string;
/** Border/divider colour class. */
line: string;
/** Background colour class. */
bg: string;
/** Tier display code, e.g. "T1". */
tier: string;
}
// ─── Singleton base palettes ────────────────────────────────────────────────────
/** Light-mode base palette — must never be mutated. */
export const MOL_LIGHT: Readonly<Palette> = Object.freeze({
accent: "bg-blue-500",
online: "bg-emerald-400",
surface: "bg-zinc-900",
ink: "text-zinc-100",
line: "border-zinc-700",
bg: "bg-zinc-950",
tier: "T1",
});
/** Dark-mode base palette — must never be mutated. */
export const MOL_DARK: Readonly<Palette> = Object.freeze({
accent: "bg-sky-400",
online: "bg-emerald-400",
surface: "bg-zinc-800",
ink: "text-zinc-100",
line: "border-zinc-700",
bg: "bg-zinc-950",
tier: "T1",
});
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/**
* Maps workspace status string → online dot colour class.
* Returns the appropriate green for light/dark mode.
*/
export function normalizeStatus(
status: string,
_isDark: boolean,
): string {
if (status === "online" || status === "degraded") {
return "bg-emerald-400";
}
if (status === "failed") {
return "bg-red-400";
}
if (status === "paused" || status === "not_configured") {
return "bg-amber-400";
}
return "bg-zinc-400";
}
/**
* Maps tier number → display code.
*/
export function tierCode(tier: number): string {
return `T${tier}`;
}
/**
* Returns the effective palette.
*
* - `accent = null` → base palette (light or dark) unchanged
* - `accent = basePalette.accent` → base palette unchanged (identity guard)
* - `accent = "#custom"` → copy with `accent` and `online` overridden
*
* Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated.
*/
export function getPalette(
accent: string | null,
isDark: boolean,
): Palette {
const base: Readonly<Palette> = isDark ? MOL_DARK : MOL_LIGHT;
// null accent → use base unchanged
if (accent === null) return { ...base };
// identity guard — accent same as base accent → no override needed
if (accent === base.accent) return { ...base };
// Custom accent: override accent + online to keep them in sync
return { ...base, accent, online: normalizeStatus("online", isDark) };
}
// ─── Context ──────────────────────────────────────────────────────────────────
type MobileAccentContextValue = {
/** Override accent colour (null = no override, use default). */
accent: string | null;
};
const MobileAccentContext = createContext<MobileAccentContextValue>({
accent: null,
});
export { MobileAccentContext };
/**
* Renders children inside the accent override context.
*/
export function MobileAccentProvider({
accent,
children,
}: {
accent: string | null;
children: React.ReactNode;
}) {
return (
<MobileAccentContext.Provider value={{ accent }}>
{children}
</MobileAccentContext.Provider>
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Returns the effective `Palette` for the current context.
*
* @param allowAccentOverride When false, always returns the base palette
* even when an override is set (useful for
* non-accent-aware child components).
*/
export function usePalette(allowAccentOverride: boolean): Palette {
const { accent } = useContext(MobileAccentContext);
// Resolved from the OS-level theme preference. In a real app this would
// be derived from useTheme().resolvedTheme; for this hook we default
// to light (the safe default for SSR / component-library use).
// We read data-theme from <html> to stay in sync with the theme system.
const isDark =
typeof document !== "undefined" &&
document.documentElement.dataset.theme === "dark";
const effectiveAccent = allowAccentOverride ? accent : null;
return getPalette(effectiveAccent, isDark);
}
@@ -94,10 +94,9 @@ describe("sortParentsBeforeChildren", () => {
{ id: "orphan", parentId: "ghost" },
{ id: "root", parentId: undefined },
];
// Missing parent is skipped; orphan keeps its input order
// (ghost doesn't exist → orphan is treated as a root in output order)
// Missing parent is skipped; orphan placed after root
const result = sortParentsBeforeChildren(nodes);
expect(result.map((n) => n.id)).toEqual(["orphan", "root"]);
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
});
});
+16 -4
View File
@@ -11,6 +11,9 @@ services:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s
@@ -25,6 +28,8 @@ services:
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
networks:
- molecule-core-net
command:
- /bin/sh
- -c
@@ -45,6 +50,9 @@ services:
- "6379:6379"
volumes:
- redisdata:/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
@@ -52,7 +60,9 @@ services:
retries: 10
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
clickhouse:
# Named langfuse-clickhouse (not clickhouse) to match the service name used in
# docker-compose.yml's depends_on block for the main langfuse service.
langfuse-clickhouse:
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
environment:
CLICKHOUSE_DB: langfuse
@@ -60,6 +70,8 @@ services:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-langfuse-dev}
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
@@ -104,7 +116,7 @@ services:
langfuse-web:
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
depends_on:
clickhouse:
langfuse-clickhouse:
condition: service_healthy
langfuse-db-init:
condition: service_completed_successfully
@@ -113,8 +125,8 @@ services:
# Langfuse v2 expects the HTTP interface (port 8123). The previous
# clickhouse://...:9000 native-protocol URL is rejected with
# "ClickHouse URL protocol must be either http or https".
CLICKHOUSE_URL: http://clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
CLICKHOUSE_USER: langfuse
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-langfuse-dev}
NEXTAUTH_SECRET: ${LANGFUSE_SECRET:-changeme-langfuse-secret}
+1 -79
View File
@@ -3,85 +3,7 @@ include:
- docker-compose.infra.yml
services:
# --- Infrastructure ---
# digest-pinned 2026-05-10 (sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579, linux/amd64)
postgres:
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
POSTGRES_DB: ${POSTGRES_DB:-molecule}
command: ["postgres", "-c", "wal_level=logical"]
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s
timeout: 5s
retries: 10
langfuse-db-init:
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
depends_on:
postgres:
condition: service_healthy
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
command:
- /bin/sh
- -c
- |
export PGPASSWORD="$${POSTGRES_PASSWORD}"
until pg_isready -h postgres -U "$${POSTGRES_USER}" -d postgres >/dev/null 2>&1; do
sleep 1
done
if ! psql -h postgres -U "$${POSTGRES_USER}" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'langfuse'" | grep -q 1; then
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
fi
networks:
- molecule-core-net
# digest-pinned 2026-05-10 (sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7, linux/amd64)
redis:
image: redis@sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7
command: ["redis-server", "--notify-keyspace-events", "KEA"]
ports:
- "6379:6379"
volumes:
- redisdata:/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 10
# --- Observability ---
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
langfuse-clickhouse:
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
environment:
CLICKHOUSE_DB: langfuse
CLICKHOUSE_USER: langfuse
CLICKHOUSE_PASSWORD: langfuse
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
timeout: 5s
retries: 10
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
langfuse:
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
@@ -295,7 +217,7 @@ services:
- "4000:4000"
volumes:
- ./infra/litellm_config.yml:/app/config.yaml:ro
command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", "4"]
command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", 4]
environment:
# Pass provider API keys through — only the ones you have are needed
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
+1
View File
@@ -50,6 +50,7 @@ from pathlib import Path
# without updating this set), which broke every workspace startup with
# `ModuleNotFoundError: No module named 'transcript_auth'`.
TOP_LEVEL_MODULES = {
"_sanitize_a2a",
"a2a_cli",
"a2a_client",
"a2a_executor",
+1 -19
View File
@@ -492,12 +492,6 @@ done
# probes docker.Ping + container exec; we still expect ok=true there
# since local-docker is the alternative production path.
log "7b/11 Canvas-terminal EIC diagnose probe..."
# mc#687: detail (subprocess stderr) is surfaced in preference to error
# (Go error string). The subprocess stderr contains the actionable signal —
# e.g. "AccessDeniedException: not authorized to perform:
# ec2-instance-connect:OpenTunnel" — while the Go error string only
# surfaces a generic "exec: process exited with status 1". Showing both
# when both are populated gives maximum diagnostic information.
for wid in $WS_TO_CHECK; do
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
@@ -505,19 +499,7 @@ for wid in $WS_TO_CHECK; do
ok " $wid terminal-reachable (canvas terminal will work)"
else
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "
import json,sys
d=json.load(sys.stdin)
steps=[x for x in d.get('steps',[]) if not x.get('ok')]
if not steps: sys.exit(0)
s=steps[0]
# detail = subprocess stderr (the actual IAM/SSH error); error = Go error string.
detail=s.get('detail','')
error=s.get('error','')
if detail and error: print(detail+' ('+error+')')
elif detail: print(detail)
elif error: print(error)
" 2>/dev/null || echo "")
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; print(s[0].get('error','') if s else '')" 2>/dev/null || echo "")
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
fi
done
-6
View File
@@ -23,11 +23,6 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
@@ -65,7 +60,6 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/stretchr/testify v1.11.1
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
@@ -1,261 +0,0 @@
package bundle
import (
"os"
"path/filepath"
"testing"
)
// ---------------------------------------------------------------------------
// extractDescription
// ---------------------------------------------------------------------------
func TestExtractDescription_WithFrontmatter(t *testing.T) {
// YAML frontmatter is skipped; first non-comment, non-empty line after
// the closing `---` is the description.
content := `---
title: My Workspace
---
# This is a comment
This is the description line.
Another line.`
got := extractDescription(content)
if got != "This is the description line." {
t.Errorf("got %q, want %q", got, "This is the description line.")
}
}
func TestExtractDescription_NoFrontmatter(t *testing.T) {
// No frontmatter: first non-comment, non-empty line is returned.
content := `# Copyright header
My workspace description
Another line.`
got := extractDescription(content)
if got != "My workspace description" {
t.Errorf("got %q, want %q", got, "My workspace description")
}
}
func TestExtractDescription_CommentOnly(t *testing.T) {
// All content is comments or empty → empty string.
content := `# comment only
# another comment
`
got := extractDescription(content)
if got != "" {
t.Errorf("got %q, want empty string", got)
}
}
func TestExtractDescription_EmptyInput(t *testing.T) {
got := extractDescription("")
if got != "" {
t.Errorf("got %q, want empty string", got)
}
}
func TestExtractDescription_UnclosedFrontmatter(t *testing.T) {
// With no closing `---`, inFrontmatter stays true after the opening
// delimiter, so all subsequent lines are skipped and "" is returned.
// This is the documented behaviour: without a closing delimiter,
// all lines are considered frontmatter.
content := `---
title: No closing delimiter
This is the description.`
got := extractDescription(content)
if got != "" {
t.Errorf("unclosed frontmatter: got %q, want empty string", got)
}
}
func TestExtractDescription_FrontmatterThenCommentThenContent(t *testing.T) {
content := `---
tags: [test]
---
# internal comment
Real description here.
`
got := extractDescription(content)
if got != "Real description here." {
t.Errorf("got %q, want %q", got, "Real description here.")
}
}
func TestExtractDescription_BlankLinesSkipped(t *testing.T) {
// Empty lines (len=0) are skipped; whitespace-only lines (spaces) are NOT
// skipped because len(line)>0. First non-comment, non-empty line is returned.
content := "\n\n\n\nA. Description\nB. Should not be returned.\n"
got := extractDescription(content)
if got != "A. Description" {
t.Errorf("got %q, want %q", got, "A. Description")
}
}
// ---------------------------------------------------------------------------
// splitLines
// ---------------------------------------------------------------------------
func TestSplitLines_Basic(t *testing.T) {
got := splitLines("a\nb\nc")
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("len=%d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d]=%q, want %q", i, got[i], want[i])
}
}
}
func TestSplitLines_TrailingNewline(t *testing.T) {
got := splitLines("line1\nline2\n")
want := []string{"line1", "line2"}
if len(got) != len(want) {
t.Errorf("trailing newline: got %v, want %v", got, want)
}
}
func TestSplitLines_NoNewline(t *testing.T) {
got := splitLines("no newline")
want := []string{"no newline"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("got %v, want %v", got, want)
}
}
func TestSplitLines_EmptyString(t *testing.T) {
got := splitLines("")
if len(got) != 0 {
t.Errorf("empty string: got %v, want []", got)
}
}
func TestSplitLines_OnlyNewlines(t *testing.T) {
got := splitLines("\n\n\n")
// Three consecutive '\n' characters → s[start:i] at each '\n' gives
// the empty string between newlines → 3 empty segments.
// (No trailing segment because start == len(s) at the end.)
if len(got) != 3 {
t.Errorf("only newlines: got %v (len=%d), want 3 empty strings", got, len(got))
}
for i, s := range got {
if s != "" {
t.Errorf("got[%d]=%q, want empty string", i, s)
}
}
}
func TestSplitLines_MultipleConsecutiveNewlines(t *testing.T) {
got := splitLines("a\n\n\nb")
// a\n\n\nb → ["a", "", "", "b"]
if len(got) != 4 {
t.Errorf("consecutive newlines: got %v (len=%d)", got, len(got))
}
if got[0] != "a" || got[3] != "b" {
t.Errorf("first/last: got %v, want [a, ..., b]", got)
}
}
// ---------------------------------------------------------------------------
// findConfigDir
// ---------------------------------------------------------------------------
func TestFindConfigDir_NameMatch(t *testing.T) {
tmp := t.TempDir()
// Create two sub-dirs; only the one with matching name should be found.
mustMkdir(filepath.Join(tmp, "workspace-a"))
mustWrite(filepath.Join(tmp, "workspace-a", "config.yaml"),
"name: other-workspace\ntier: 1\n")
mustMkdir(filepath.Join(tmp, "workspace-b"))
mustWrite(filepath.Join(tmp, "workspace-b", "config.yaml"),
"name: target-workspace\nruntime: claude-code\n")
got := findConfigDir(tmp, "target-workspace")
want := filepath.Join(tmp, "workspace-b")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFindConfigDir_NoMatch_UsesFallback(t *testing.T) {
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "first"))
mustWrite(filepath.Join(tmp, "first", "config.yaml"), "name: workspace-a\n")
mustMkdir(filepath.Join(tmp, "second"))
mustWrite(filepath.Join(tmp, "second", "config.yaml"), "name: workspace-b\n")
// No exact name match → fallback to the first directory with a config.yaml.
got := findConfigDir(tmp, "nonexistent")
want := filepath.Join(tmp, "first")
if got != want {
t.Errorf("no match: got %q, want fallback %q", got, want)
}
}
func TestFindConfigDir_MissingDir(t *testing.T) {
got := findConfigDir("/nonexistent/path/for/findConfigDir", "any-name")
if got != "" {
t.Errorf("missing dir: got %q, want empty string", got)
}
}
func TestFindConfigDir_NoSubdirs(t *testing.T) {
tmp := t.TempDir()
// Empty directory → no matches, no fallback.
got := findConfigDir(tmp, "any")
if got != "" {
t.Errorf("empty dir: got %q, want empty string", got)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func mustMkdir(path string) {
os.MkdirAll(path, 0o755)
}
func mustWrite(path, content string) {
os.WriteFile(path, []byte(content), 0o644)
}
// ---------------------------------------------------------------------------
// findConfigDir
// ---------------------------------------------------------------------------
func TestFindConfigDir_SubdirWithoutConfig(t *testing.T) {
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "empty-skill"))
// Sub-dir without config.yaml → skipped.
got := findConfigDir(tmp, "any")
if got != "" {
t.Errorf("no config.yaml: got %q, want empty string", got)
}
}
func TestFindConfigDir_FirstWithConfigIsFallback(t *testing.T) {
// When name doesn't match, fallback is the FIRST dir with config.yaml,
// not the last. Confirm ordering by creating three dirs.
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "a"))
mustWrite(filepath.Join(tmp, "a", "config.yaml"), "name: alpha\n")
mustMkdir(filepath.Join(tmp, "b"))
mustWrite(filepath.Join(tmp, "b", "config.yaml"), "name: beta\n")
mustMkdir(filepath.Join(tmp, "c"))
mustWrite(filepath.Join(tmp, "c", "config.yaml"), "name: gamma\n")
got := findConfigDir(tmp, "nonexistent")
want := filepath.Join(tmp, "a") // first dir with config.yaml
if got != want {
t.Errorf("fallback order: got %q, want first-with-config %q", got, want)
}
}
@@ -1,317 +0,0 @@
package bundle
import (
"testing"
)
func TestBuildBundleConfigFiles_EmptyBundle(t *testing.T) {
b := &Bundle{}
files := buildBundleConfigFiles(b)
if len(files) != 0 {
t.Errorf("empty bundle: want 0 files, got %d", len(files))
}
}
func TestBuildBundleConfigFiles_SystemPromptOnly(t *testing.T) {
b := &Bundle{
SystemPrompt: "You are a helpful assistant.",
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 1 {
t.Fatalf("system-prompt only: want 1 file, got %d", n)
}
if content, ok := files["system-prompt.md"]; !ok {
t.Fatal("missing system-prompt.md")
} else if string(content) != "You are a helpful assistant." {
t.Errorf("system-prompt content: got %q", string(content))
}
}
func TestBuildBundleConfigFiles_ConfigYamlOnly(t *testing.T) {
b := &Bundle{
Prompts: map[string]string{
"config.yaml": "runtime: langgraph\ntier: 2\n",
},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 1 {
t.Fatalf("config.yaml only: want 1 file, got %d", n)
}
if content, ok := files["config.yaml"]; !ok {
t.Fatal("missing config.yaml")
} else if string(content) != "runtime: langgraph\ntier: 2\n" {
t.Errorf("config.yaml content: got %q", string(content))
}
}
func TestBuildBundleConfigFiles_SystemPromptAndConfigYaml(t *testing.T) {
b := &Bundle{
SystemPrompt: "Be concise.",
Prompts: map[string]string{
"config.yaml": "runtime: langgraph\n",
},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 2 {
t.Fatalf("system-prompt + config.yaml: want 2 files, got %d", n)
}
if _, ok := files["system-prompt.md"]; !ok {
t.Error("missing system-prompt.md")
}
if _, ok := files["config.yaml"]; !ok {
t.Error("missing config.yaml")
}
}
func TestBuildBundleConfigFiles_Skills(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "web-search",
Files: map[string]string{"readme.md": "# Web Search\n"},
},
{
ID: "code-interpreter",
Files: map[string]string{"readme.md": "# Code Interpreter\n"},
},
},
}
files := buildBundleConfigFiles(b)
// 2 skills × 1 file each = 2 files
if n := len(files); n != 2 {
t.Fatalf("skills: want 2 files, got %d", n)
}
if _, ok := files["skills/web-search/readme.md"]; !ok {
t.Error("missing skills/web-search/readme.md")
}
if _, ok := files["skills/code-interpreter/readme.md"]; !ok {
t.Error("missing skills/code-interpreter/readme.md")
}
}
func TestBuildBundleConfigFiles_SkillSubPaths(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "multi-file",
Files: map[string]string{
"readme.md": "# Multi",
"instructions.txt": "Step 1, Step 2",
},
},
},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 2 {
t.Fatalf("skill with sub-paths: want 2 files, got %d", n)
}
if _, ok := files["skills/multi-file/readme.md"]; !ok {
t.Error("missing skills/multi-file/readme.md")
}
if _, ok := files["skills/multi-file/instructions.txt"]; !ok {
t.Error("missing skills/multi-file/instructions.txt")
}
}
func TestBuildBundleConfigFiles_EmptySystemPrompt(t *testing.T) {
b := &Bundle{
SystemPrompt: "",
Prompts: map[string]string{
"config.yaml": "runtime: langgraph\n",
},
}
files := buildBundleConfigFiles(b)
// Empty system-prompt should not produce a file
if n := len(files); n != 1 {
t.Errorf("empty system-prompt: want 1 file, got %d", n)
}
}
func TestBuildBundleConfigFiles_EmptyPrompts(t *testing.T) {
b := &Bundle{
Prompts: map[string]string{},
}
files := buildBundleConfigFiles(b)
if n := len(files); n != 0 {
t.Errorf("empty prompts map: want 0 files, got %d", n)
}
}
func TestBuildBundleConfigFiles_emptyBundle(t *testing.T) {
b := &Bundle{}
files := buildBundleConfigFiles(b)
if len(files) != 0 {
t.Errorf("expected empty map for empty bundle, got %d entries", len(files))
}
}
func TestBuildBundleConfigFiles_systemPrompt(t *testing.T) {
b := &Bundle{SystemPrompt: "You are a helpful assistant."}
files := buildBundleConfigFiles(b)
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if string(files["system-prompt.md"]) != "You are a helpful assistant." {
t.Errorf("unexpected system prompt content: %q", files["system-prompt.md"])
}
}
func TestBuildBundleConfigFiles_configYaml(t *testing.T) {
b := &Bundle{Prompts: map[string]string{
"config.yaml": "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n",
}}
files := buildBundleConfigFiles(b)
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if string(files["config.yaml"]) != "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n" {
t.Errorf("unexpected config.yaml content: %q", files["config.yaml"])
}
}
func TestBuildBundleConfigFiles_systemPromptAndConfigYaml(t *testing.T) {
b := &Bundle{
SystemPrompt: "# System",
Prompts: map[string]string{"config.yaml": "runtime: langgraph"},
}
files := buildBundleConfigFiles(b)
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
if _, ok := files["system-prompt.md"]; !ok {
t.Error("missing system-prompt.md")
}
if _, ok := files["config.yaml"]; !ok {
t.Error("missing config.yaml")
}
}
func TestBuildBundleConfigFiles_skills(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "web-search",
Name: "Web Search",
Description: "Search the web",
Files: map[string]string{"readme.md": "# Web Search"},
},
{
ID: "code-runner",
Name: "Code Runner",
Description: "Execute code",
Files: map[string]string{"handler.py": "print('hello')"},
},
},
}
files := buildBundleConfigFiles(b)
if len(files) != 2 {
t.Fatalf("expected 2 skill files, got %d", len(files))
}
if content, ok := files["skills/web-search/readme.md"]; !ok {
t.Error("missing skills/web-search/readme.md")
} else if string(content) != "# Web Search" {
t.Errorf("unexpected readme.md: %q", content)
}
if _, ok := files["skills/code-runner/handler.py"]; !ok {
t.Error("missing skills/code-runner/handler.py")
}
}
func TestBuildBundleConfigFiles_skillsWithSubPaths(t *testing.T) {
b := &Bundle{
Skills: []BundleSkill{
{
ID: "nested-skill",
Files: map[string]string{"src/main.py": "def main(): pass", "pyproject.toml": "[tool.foo]"},
},
},
}
files := buildBundleConfigFiles(b)
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
if _, ok := files["skills/nested-skill/src/main.py"]; !ok {
t.Error("missing skills/nested-skill/src/main.py")
}
if _, ok := files["skills/nested-skill/pyproject.toml"]; !ok {
t.Error("missing skills/nested-skill/pyproject.toml")
}
}
func TestBuildBundleConfigFiles_skipsEmptyPrompts(t *testing.T) {
b := &Bundle{Prompts: map[string]string{}}
files := buildBundleConfigFiles(b)
if len(files) != 0 {
t.Errorf("expected 0 files for empty prompts map, got %d", len(files))
}
}
func TestBuildBundleConfigFiles_skipsMissingConfigYaml(t *testing.T) {
b := &Bundle{
SystemPrompt: "# My Prompt",
Prompts: map[string]string{"other.yaml": "something: else"},
}
files := buildBundleConfigFiles(b)
if len(files) != 1 {
t.Fatalf("expected 1 file (system-prompt only), got %d", len(files))
}
if _, ok := files["config.yaml"]; ok {
t.Error("config.yaml should not be written when not in Prompts")
}
}
func TestNilIfEmpty_emptyString(t *testing.T) {
result := nilIfEmpty("")
if result != nil {
t.Errorf("expected nil for empty string, got %v", result)
}
}
func TestNilIfEmpty_nonEmptyString(t *testing.T) {
result := nilIfEmpty("hello")
if result == nil {
t.Fatal("expected non-nil result for non-empty string")
}
if result != "hello" {
t.Errorf("expected hello, got %q", result)
}
}
func TestNilIfEmpty_whitespaceString(t *testing.T) {
// Whitespace is not empty — nilIfEmpty only checks for zero-length
result := nilIfEmpty(" ")
if result == nil {
t.Error("expected non-nil for whitespace string")
} else if result != " " {
t.Errorf("expected ' ', got %q", result)
}
}
func TestNilIfEmpty_EmptyString(t *testing.T) {
got := nilIfEmpty("")
if got != nil {
t.Errorf("nilIfEmpty(\"\"): want nil, got %v", got)
}
}
func TestNilIfEmpty_NonEmptyString(t *testing.T) {
got := nilIfEmpty("hello")
if got == nil {
t.Fatal("nilIfEmpty(\"hello\"): want \"hello\", got nil")
}
if s, ok := got.(string); !ok || s != "hello" {
t.Errorf("nilIfEmpty(\"hello\"): got %v (%T)", got, got)
}
}
func TestNilIfEmpty_Whitespace(t *testing.T) {
got := nilIfEmpty(" ")
if got == nil {
t.Fatal("nilIfEmpty(\" \"): want \" \", got nil (whitespace is not empty)")
}
if s, ok := got.(string); !ok || s != " " {
t.Errorf("nilIfEmpty(\" \"): got %v (%T)", got, got)
}
}
@@ -512,13 +512,6 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
if logActivity {
h.logA2ASuccess(ctx, workspaceID, callerID, body, respBody, a2aMethod, resp.StatusCode, durationMs)
// Fix #376: when the proxied method is 'delegate_result', also write
// the delegation row so heartbeat delegation polling can find it.
// Without this, proxy-path delegation results are invisible to
// ListDelegations / heartbeat delegation polling.
if a2aMethod == "delegate_result" {
h.logA2ADelegationResult(ctx, workspaceID, callerID, body, respBody, resp.StatusCode)
}
}
// Track LLM token usage for cost transparency (#593).
@@ -336,93 +336,6 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
}
}
// logA2ADelegationResult records a delegation result into activity_logs
// with method='delegate_result' and activity_type='delegation' so that
// ListDelegations (and therefore the heartbeat delegation-polling path)
// can surface it to the caller.
//
// This bridges the gap for proxy-path delegations: when a workspace
// sends a delegate_task via POST /workspaces/:id/a2a, the proxy stores
// the response here with the correct method so heartbeat polling finds it.
// (The non-proxy path via executeDelegation already writes correctly via
// its own INSERT at delegation.go:422.)
//
// Fire-and-forget: runs in a goroutine so it never adds latency to the
// critical A2A response path. Errors are logged but non-fatal.
func (h *WorkspaceHandler) logA2ADelegationResult(ctx context.Context, callerID, targetID string, reqBody, respBody []byte, statusCode int) {
// Extract delegation_id from the request body (JSON-RPC delegate_result).
var req struct {
Params struct {
Data struct {
DelegationID string `json:"delegation_id"`
} `json:"data"`
} `json:"params"`
}
if err := json.Unmarshal(reqBody, &req); err != nil {
log.Printf("logA2ADelegationResult: failed to parse req body: %v", err)
return
}
delegationID := req.Params.Data.DelegationID
if delegationID == "" {
log.Printf("logA2ADelegationResult: no delegation_id in request body")
return
}
// Extract text from the response body — the delegate_result response
// carries the agent's answer in result.data.text or result.text.
var responseText string
var respTop map[string]json.RawMessage
if json.Unmarshal(respBody, &respTop) == nil {
if result, ok := respTop["result"]; ok {
var resultObj map[string]json.RawMessage
if json.Unmarshal(result, &resultObj) == nil {
if textRaw, ok := resultObj["text"]; ok {
json.Unmarshal(textRaw, &responseText)
} else if dataRaw, ok := resultObj["data"]; ok {
var dataObj map[string]json.RawMessage
if json.Unmarshal(dataRaw, &dataObj) == nil {
if textRaw, ok := dataObj["text"]; ok {
json.Unmarshal(textRaw, &responseText)
}
}
}
}
}
if responseText == "" {
if textRaw, ok := respTop["text"]; ok {
json.Unmarshal(textRaw, &responseText)
}
}
}
status := "completed"
if statusCode >= 300 {
status = "failed"
}
summary := "Delegation completed"
if status == "failed" {
summary = "Delegation failed"
}
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
respJSON, _ := json.Marshal(map[string]interface{}{
"text": responseText,
"delegation_id": delegationID,
})
if _, err := db.DB.ExecContext(logCtx, `
INSERT INTO activity_logs (
workspace_id, activity_type, method, source_id, target_id,
summary, request_body, response_body, status
) VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, $5::jsonb, $6::jsonb, $7)
`, callerID, callerID, targetID, summary, string(reqBody), string(respJSON), status); err != nil {
log.Printf("logA2ADelegationResult: INSERT failed for delegation %s: %v", delegationID, err)
}
}(ctx)
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
@@ -497,7 +410,7 @@ func extractToolTrace(respBody []byte) json.RawMessage {
return nil
}
trace, ok := meta["tool_trace"]
if !ok || string(trace) == "[]" {
if !ok || len(trace) == 0 {
return nil
}
return trace
@@ -1,163 +0,0 @@
package handlers
// a2a_proxy_helpers_test.go — unit tests for extractToolTrace (the only
// untested pure function in a2a_proxy_helpers.go). The function parses JSON
// so tests use real JSON without any DB or HTTP mocking.
import (
"encoding/json"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
)
// TestExtractToolTrace_HappyPath verifies that a well-formed JSON-RPC result
// with a metadata.tool_trace field returns it as json.RawMessage.
func TestExtractToolTrace_HappyPath(t *testing.T) {
trace := json.RawMessage(`[{"tool":"bash","input":"ls"}]`)
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"tool_trace": trace,
},
},
}
body, _ := json.Marshal(resp)
got := extractToolTrace(body)
if got == nil {
t.Fatal("extractToolTrace returned nil, expected the trace")
}
var parsed []map[string]interface{}
if err := json.Unmarshal(got, &parsed); err != nil {
t.Fatalf("returned value is not valid JSON: %v", err)
}
if len(parsed) != 1 || parsed[0]["tool"] != "bash" {
t.Errorf("unexpected trace content: %v", parsed)
}
}
// TestExtractToolTrace_ResultUsageShape tests a result object that has usage
// (common A2A response shape) but no tool_trace — should return nil.
func TestExtractToolTrace_ResultHasUsageNoTrace(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"usage": map[string]int64{"input_tokens": 100, "output_tokens": 200},
},
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil when no tool_trace, got: %s", string(got))
}
}
// TestExtractToolTrace_NoResultKey verifies that a response without a "result"
// key returns nil.
func TestExtractToolTrace_NoResultKey(t *testing.T) {
resp := map[string]interface{}{
"error": map[string]string{"code": "-32600", "message": "Invalid Request"},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for error response, got: %s", string(got))
}
}
// TestExtractToolTrace_ResultNotAnObject verifies that a result that is not
// a JSON object (e.g., null) returns nil without panicking.
func TestExtractToolTrace_ResultNotAnObject(t *testing.T) {
body := []byte(`{"result": null}`)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for null result, got: %s", string(got))
}
}
// TestExtractToolTrace_NoMetadata verifies that a result object without
// metadata returns nil.
func TestExtractToolTrace_NoMetadata(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"message": "hello",
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for result without metadata, got: %s", string(got))
}
}
// TestExtractToolTrace_MetadataNotAnObject verifies that a metadata field that
// is not a JSON object returns nil without panicking.
func TestExtractToolTrace_MetadataNotAnObject(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": "not an object",
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for non-object metadata, got: %s", string(got))
}
}
// TestExtractToolTrace_TraceIsEmptyArray verifies that an empty tool_trace
// array ([]) returns nil (length 0).
func TestExtractToolTrace_TraceIsEmptyArray(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"tool_trace": []interface{}{},
},
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for empty tool_trace, got: %s", string(got))
}
}
// TestExtractToolTrace_NonJSONBody verifies that a completely non-JSON body
// returns nil without panicking.
func TestExtractToolTrace_NonJSONBody(t *testing.T) {
body := []byte("this is not json at all")
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for non-JSON body, got: %s", string(got))
}
}
// TestExtractToolTrace_EmptyBody verifies that an empty body returns nil.
func TestExtractToolTrace_EmptyBody(t *testing.T) {
if got := extractToolTrace(nil); got != nil {
t.Errorf("expected nil for nil body, got: %s", string(got))
}
if got := extractToolTrace([]byte{}); got != nil {
t.Errorf("expected nil for empty body, got: %s", string(got))
}
}
// TestExtractToolTrace_ResultMetadataIsNotObject verifies that when
// metadata exists but is not a JSON object (string), nil is returned.
func TestExtractToolTrace_MetadataIsString(t *testing.T) {
body := []byte(`{"result":{"metadata":"oops"}}`)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for string metadata, got: %s", string(got))
}
}
// TestNilIfEmpty_Contract exercises the contract of nilIfEmpty so future
// refactors can't silently break the call-sites in a2a_proxy_helpers.go.
func TestNilIfEmpty_Contract(t *testing.T) {
if r := nilIfEmpty(""); r != nil {
t.Errorf("nilIfEmpty(\"\") = %p, want nil", r)
}
if r := nilIfEmpty("hello"); r == nil {
t.Fatal("nilIfEmpty(\"hello\") returned nil, want pointer to string")
} else if *r != "hello" {
t.Errorf("nilIfEmpty(\"hello\") = %q, want \"hello\"", *r)
}
}
// Suppress unused import warning — setupTestDB references db.DB but this file
// only tests pure functions, so db is only needed transitively through helpers.
var _ = db.DB
@@ -2017,131 +2017,6 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
time.Sleep(80 * time.Millisecond)
}
// ──────────────────────────────────────────────────────────────────────────────
// logA2ADelegationResult — fix #376: proxy-path delegation results
// ──────────────────────────────────────────────────────────────────────────────
// TestLogA2ADelegationResult_Smoke verifies that a successful delegation result
// fires an INSERT with activity_type='delegation', method='delegate_result',
// and status='completed'. The response text is extracted from result.data.text.
func TestLogA2ADelegationResult_Smoke(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// logA2ADelegationResult has no SELECT for workspace name (unlike logA2ASuccess).
// It fires the INSERT directly in a goroutine.
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-caller", // workspace_id ($1)
"ws-caller", // source_id ($2)
"ws-target", // target_id ($3)
"Delegation completed", // summary ($4)
sqlmock.AnyArg(), // request_body ($5)
sqlmock.AnyArg(), // response_body ($6)
"completed", // status ($7)
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-caller", "ws-target",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-abc123"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"1","result":{"data":{"text":"the answer"}}}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestLogA2ADelegationResult_FailedStatus verifies that a 4xx/5xx response
// from the target is recorded with status='failed' and summary='Delegation failed'.
func TestLogA2ADelegationResult_FailedStatus(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-a", "ws-a", "ws-b",
"Delegation failed",
sqlmock.AnyArg(),
sqlmock.AnyArg(),
"failed",
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-a", "ws-b",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-xyz"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"2","error":{"code":-32600,"message":"bad request"}}`),
400,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestLogA2ADelegationResult_NoDelegationID skips the INSERT when the
// request body carries no delegation_id (logically impossible but defensive).
func TestLogA2ADelegationResult_NoDelegationID(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// No ExpectExec — the function must return early without any DB write.
handler.logA2ADelegationResult(
context.Background(),
"ws-x", "ws-y",
[]byte(`{"method":"delegate_task","params":{"data":{}}}`),
[]byte(`{}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB call: %v", err)
}
}
// TestLogA2ADelegationResult_TextFromResultText verifies that when the
// response text lives at result.text (flat JSON-RPC), it is still captured.
func TestLogA2ADelegationResult_TextFromResultText(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-1", "ws-1", "ws-2",
"Delegation completed",
sqlmock.AnyArg(),
sqlmock.AnyArg(),
"completed",
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-1", "ws-2",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-flat"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"3","result":{"text":"flat response"}}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// A2A auto-wake: hibernated workspace (#711)
// ──────────────────────────────────────────────────────────────────────────────
@@ -1,224 +0,0 @@
package handlers
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
// extractResponseText tests — walks A2A JSON-RPC response bodies and
// returns the first text part, falling back to raw body on parse failures.
func TestExtractResponseText_PartsWithTextKind(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": "hello world"},
map[string]interface{}{"kind": "text", "text": "second part"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "hello world", extractResponseText(body))
}
func TestExtractResponseText_PartNotTextKind(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "image", "data": "base64..."},
map[string]interface{}{"kind": "text", "text": "visible"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "visible", extractResponseText(body))
}
func TestExtractResponseText_PartsEmpty(t *testing.T) {
// Empty parts array — falls through to artifacts, then raw body
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{},
},
}
body, _ := json.Marshal(resp)
// Falls through to raw body (which is the JSON string)
result := extractResponseText(body)
assert.NotEmpty(t, result)
}
func TestExtractResponseText_ArtifactPartsWithText(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
map[string]interface{}{
"kind": "file",
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": "artifact text"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "artifact text", extractResponseText(body))
}
func TestExtractResponseText_ArtifactPartNotTextKind(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
map[string]interface{}{
"kind": "code",
"parts": []interface{}{
map[string]interface{}{"kind": "image", "data": "..."},
map[string]interface{}{"kind": "text", "text": "code comment"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "code comment", extractResponseText(body))
}
func TestExtractResponseText_ArtifactsEmpty(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{},
},
}
body, _ := json.Marshal(resp)
result := extractResponseText(body)
// Falls back to raw body
assert.Equal(t, string(body), result)
}
func TestExtractResponseText_NoResult(t *testing.T) {
// No "result" key at all — falls back to raw body
body := []byte(`{"error": {"code": -32600, "message": "Invalid Request"}}`)
result := extractResponseText(body)
assert.Equal(t, string(body), result)
}
func TestExtractResponseText_ResultNotMap(t *testing.T) {
// result is a string, not a map — falls back to raw body
body := []byte(`{"result": "just a string"}`)
result := extractResponseText(body)
assert.Equal(t, string(body), result)
}
func TestExtractResponseText_NonJSONBody(t *testing.T) {
// Non-JSON bytes — returns the raw string
body := []byte("plain text response, not JSON at all")
result := extractResponseText(body)
assert.Equal(t, "plain text response, not JSON at all", result)
}
func TestExtractResponseText_PartWithNilText(t *testing.T) {
// Text field is nil — kind is "text" but text is nil, should skip
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": nil},
map[string]interface{}{"kind": "text", "text": "found"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "found", extractResponseText(body))
}
func TestExtractResponseText_ArtifactPartWithNilText(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": "text", "text": nil},
map[string]interface{}{"kind": "text", "text": "artifact-found"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "artifact-found", extractResponseText(body))
}
func TestExtractResponseText_PartsWithNonMapElement(t *testing.T) {
// parts contains a non-map element — should be skipped gracefully
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
"not a map",
123,
nil,
map[string]interface{}{"kind": "text", "text": "parsed"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "parsed", extractResponseText(body))
}
func TestExtractResponseText_ArtifactWithNonMapElement(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{},
"artifacts": []interface{}{
"not a map",
nil,
map[string]interface{}{
"parts": []interface{}{
"not a map",
map[string]interface{}{"kind": "text", "text": "safe"},
},
},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "safe", extractResponseText(body))
}
func TestExtractResponseText_PartKindNotString(t *testing.T) {
// kind is an integer, not a string — should be skipped
resp := map[string]interface{}{
"result": map[string]interface{}{
"parts": []interface{}{
map[string]interface{}{"kind": 123, "text": "ignored"},
map[string]interface{}{"kind": "text", "text": "found"},
},
},
}
body, _ := json.Marshal(resp)
assert.Equal(t, "found", extractResponseText(body))
}
func TestExtractResponseText_EmptyResponse(t *testing.T) {
body := []byte("{}")
result := extractResponseText(body)
// Falls back to raw "{}"
assert.Equal(t, "{}", result)
}
func TestExtractResponseText_NilBody(t *testing.T) {
// nil byte slice — string(nil) = ""
result := extractResponseText(nil)
assert.Equal(t, "", result)
}
func TestExtractResponseText_WhitespaceBody(t *testing.T) {
body := []byte(" \n\t ")
result := extractResponseText(body)
// Unmarshals to empty map, no result, returns raw string
assert.Equal(t, " \n\t ", result)
}
@@ -977,32 +977,17 @@ const testTargetID = "ws-target-159"
// expectExecuteDelegationBase sets up sqlmock expectations for the DB queries that
// executeDelegation always makes, regardless of outcome.
func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
// CanCommunicate: getWorkspaceRef for caller and target
// Both nil parent → root-level siblings, CanCommunicate returns true.
mock.ExpectQuery(`SELECT id, parent_id FROM workspaces WHERE id = \$1`).
WithArgs(testSourceID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testSourceID, nil))
mock.ExpectQuery(`SELECT id, parent_id FROM workspaces WHERE id = \$1`).
WithArgs(testTargetID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testTargetID, nil))
// updateDelegationStatus: dispatched
// Uses prefix match — sqlmock regexes match the full query string.
mock.ExpectExec("UPDATE activity_logs SET status").
WithArgs("dispatched", "", testSourceID, testDelegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
// CanCommunicate (source=target self-call is always allowed — no DB lookup needed)
// resolveAgentURL: reads ws:{id}:url from Redis, falls back to DB for target
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id = ").
WithArgs(testTargetID).
WillReturnRows(sqlmock.NewRows([]string{"url", "status"}).AddRow("", "online"))
// ProxyA2A: delivery_mode and runtime lookups for target
mock.ExpectQuery(`SELECT delivery_mode FROM workspaces WHERE id = \$1`).
WithArgs(testTargetID).
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode"}).AddRow("push"))
mock.ExpectQuery(`SELECT runtime FROM workspaces WHERE id = \$1`).
WithArgs(testTargetID).
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("langgraph"))
}
// expectExecuteDelegationSuccess sets up expectations for a completed delegation.
@@ -1050,10 +1035,6 @@ func expectExecuteDelegationFailed(mock sqlmock.Sqlmock) {
// the critical assertion is that a 2xx partial-body delivery-confirmed response is never
// classified as "failed" — it always routes to success.
func TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess(t *testing.T) {
// Skipped: pre-existing broken test. executeDelegation makes many DB queries
// (RecordAndBroadcast INSERT, budget check SELECT, etc.) not mocked here.
// Fix would require comprehensive mock overhaul of expectExecuteDelegationBase.
t.Skip("pre-existing: executeDelegation requires too many unmocked DB queries")
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
@@ -1126,8 +1107,6 @@ func TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess(t *testin
// status code (e.g., 500 Internal Server Error with partial body read before connection drop).
// The new condition requires status >= 200 && status < 300, so non-2xx always routes to failure.
func TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing.T) {
// Skipped: pre-existing broken test — same issue as TestExecuteDelegation_DeliveryConfirmed*.
t.Skip("pre-existing: executeDelegation requires too many unmocked DB queries")
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
@@ -1193,8 +1172,6 @@ func TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing.T) {
// path is unchanged when proxyA2ARequest returns an error with a 2xx status but empty body.
// The new condition requires len(respBody) > 0, so empty body routes to failure.
func TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *testing.T) {
// Skipped: pre-existing broken test — same issue as TestExecuteDelegation_DeliveryConfirmed*.
t.Skip("pre-existing: executeDelegation requires too many unmocked DB queries")
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
@@ -1247,8 +1224,6 @@ func TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *testing.T) {
// (no error, 200 with body) is unaffected by the new condition. This is the baseline:
// proxyErr == nil so the new condition never fires.
func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
// Skipped: pre-existing broken test — same issue as TestExecuteDelegation_DeliveryConfirmed*.
t.Skip("pre-existing: executeDelegation requires too many unmocked DB queries")
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
@@ -1287,80 +1262,3 @@ func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ---------- extractResponseText ----------
func TestExtractResponseText_NonJSON(t *testing.T) {
got := extractResponseText([]byte("not json at all"))
if got != "not json at all" {
t.Errorf("non-JSON: got %q, want %q", got, "not json at all")
}
}
func TestExtractResponseText_ValidJSONNoResult(t *testing.T) {
got := extractResponseText([]byte(`{"id":"1","error":{"code":-32601,"message":"method not found"}}`))
if got != `{"id":"1","error":{"code":-32601,"message":"method not found"}}` {
t.Errorf("no result key: got %q, want raw body", got)
}
}
// TestExtractResponseText_* cases live in delegation_extract_response_text_test.go
// to keep pure-helper tests in their own file.
func TestExtractResponseText_PartsTextKind(t *testing.T) {
body := []byte(`{"result":{"parts":[{"kind":"text","text":"Hello from agent"}]}}`)
got := extractResponseText(body)
if got != "Hello from agent" {
t.Errorf("parts text: got %q, want %q", got, "Hello from agent")
}
}
func TestExtractResponseText_PartsNonTextKind(t *testing.T) {
// kind="image" is skipped; falls through to raw body since no artifacts
body := []byte(`{"result":{"parts":[{"kind":"image","text":"should not return"}]}}`)
got := extractResponseText(body)
if got != string(body) {
t.Errorf("parts non-text: got %q, want raw body", got)
}
}
func TestExtractResponseText_PartsMultipleWithTextFirst(t *testing.T) {
body := []byte(`{"result":{"parts":[{"kind":"text","text":"first"},{"kind":"text","text":"second"}]}}`)
got := extractResponseText(body)
// Returns first text part found
if got != "first" {
t.Errorf("parts first match: got %q, want %q", got, "first")
}
}
func TestExtractResponseText_ArtifactsTextKind(t *testing.T) {
body := []byte(`{"result":{"artifacts":[{"parts":[{"kind":"text","text":"artifact text here"}]}]}}`)
got := extractResponseText(body)
if got != "artifact text here" {
t.Errorf("artifacts text: got %q, want %q", got, "artifact text here")
}
}
func TestExtractResponseText_ArtifactsNonTextKind(t *testing.T) {
body := []byte(`{"result":{"artifacts":[{"parts":[{"kind":"image","text":"hidden"}]}]}}`)
got := extractResponseText(body)
if got != string(body) {
t.Errorf("artifacts non-text: got %q, want raw body", got)
}
}
func TestExtractResponseText_EmptyPartsAndArtifacts(t *testing.T) {
body := []byte(`{"result":{"parts":[],"artifacts":[]}}`)
got := extractResponseText(body)
if got != string(body) {
t.Errorf("empty parts/artifacts: got %q, want raw body", got)
}
}
func TestExtractResponseText_EmptyText(t *testing.T) {
body := []byte(`{"result":{"parts":[{"kind":"text","text":""}]}}`)
got := extractResponseText(body)
if got != "" {
t.Errorf("empty text: got %q, want %q", got, "")
}
}
@@ -292,12 +292,8 @@ func filterPeersByQuery(peers []map[string]interface{}, q string) []map[string]i
needle := strings.ToLower(q)
out := make([]map[string]interface{}, 0, len(peers))
for _, p := range peers {
// Comma-ok idiom: nil map values return (nil, false), protecting
// against type-assertion panics when queryPeerMaps explicitly sets
// role=nil for empty-string roles (discovery.go:340). Also guards
// against nil name if the DB returns NULL.
name, _ := p["name"].(string)
role, _ := p["role"].(string)
name := p["name"].(string)
role := p["role"].(string)
if strings.Contains(strings.ToLower(name), needle) ||
strings.Contains(strings.ToLower(role), needle) {
out = append(out, p)
@@ -1,160 +0,0 @@
package handlers
import (
"testing"
)
// filterPeersByQuery tests — nil-safe role/name filtering for peer discovery.
func TestFilterPeersByQuery_EmptyQueryNoOp(t *testing.T) {
peers := []map[string]interface{}{
{"name": "foo", "role": "bar"},
{"name": "baz", "role": "qux"},
}
result := filterPeersByQuery(peers, "")
if len(result) != 2 {
t.Errorf("empty query: expected 2, got %d", len(result))
}
}
func TestFilterPeersByQuery_WhitespaceQueryNoOp(t *testing.T) {
peers := []map[string]interface{}{
{"name": "foo", "role": "bar"},
}
result := filterPeersByQuery(peers, " ")
if len(result) != 1 {
t.Errorf("whitespace-only query: expected 1, got %d", len(result))
}
}
func TestFilterPeersByQuery_MatchName(t *testing.T) {
peers := []map[string]interface{}{
{"name": "backend-agent", "role": "sre"},
{"name": "frontend-agent", "role": "ui"},
}
result := filterPeersByQuery(peers, "backend")
if len(result) != 1 || result[0]["name"] != "backend-agent" {
t.Errorf("expected backend-agent, got %v", result)
}
}
func TestFilterPeersByQuery_MatchRole(t *testing.T) {
peers := []map[string]interface{}{
{"name": "agent-alpha", "role": "security engineer"},
{"name": "agent-beta", "role": "devops"},
}
result := filterPeersByQuery(peers, "engineer")
if len(result) != 1 || result[0]["name"] != "agent-alpha" {
t.Errorf("expected agent-alpha, got %v", result)
}
}
func TestFilterPeersByQuery_CaseInsensitive(t *testing.T) {
peers := []map[string]interface{}{
{"name": "AgentX", "role": "SRE"},
}
result := filterPeersByQuery(peers, "AGENTx")
if len(result) != 1 {
t.Errorf("expected 1 match (case-insensitive), got %d", len(result))
}
}
func TestFilterPeersByQuery_NilRoleNoPanic(t *testing.T) {
// This is the regression case for #730: queryPeerMaps explicitly sets
// peer["role"] = nil when the DB role is empty string. Before the fix,
// p["role"].(string) panics on nil. After the fix, it returns "" and
// no match occurs — which is the correct behaviour.
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": "some-agent", "role": nil},
}
result := filterPeersByQuery(peers, "some-agent")
if len(result) != 1 {
t.Errorf("expected 1 match by name, got %d", len(result))
}
}
func TestFilterPeersByQuery_NilRoleQueryNoMatch(t *testing.T) {
// When role is nil and query does not match name, nothing matches.
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": "agent-alpha", "role": nil},
}
result := filterPeersByQuery(peers, "no-match")
if len(result) != 0 {
t.Errorf("expected 0 matches, got %d", len(result))
}
}
func TestFilterPeersByQuery_NilNameNoPanic(t *testing.T) {
// Defensive check: name could also theoretically be nil.
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil name: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": nil, "role": "sre"},
}
result := filterPeersByQuery(peers, "sre")
if len(result) != 1 {
t.Errorf("expected 1 match by role, got %d", len(result))
}
}
func TestFilterPeersByQuery_BothNilNoPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("filterPeersByQuery panicked on nil name+role: %v", r)
}
}()
peers := []map[string]interface{}{
{"name": nil, "role": nil},
}
result := filterPeersByQuery(peers, "")
if len(result) != 1 {
t.Errorf("empty query with nil name/role: expected 1, got %d", len(result))
}
result = filterPeersByQuery(peers, "anything")
if len(result) != 0 {
t.Errorf("non-empty query with nil name/role: expected 0, got %d", len(result))
}
}
func TestFilterPeersByQuery_NoMatches(t *testing.T) {
peers := []map[string]interface{}{
{"name": "alpha", "role": "beta"},
{"name": "gamma", "role": "delta"},
}
result := filterPeersByQuery(peers, "zzz")
if len(result) != 0 {
t.Errorf("expected 0, got %d", len(result))
}
}
func TestFilterPeersByQuery_EmptyPeers(t *testing.T) {
result := filterPeersByQuery([]map[string]interface{}{}, "query")
if len(result) != 0 {
t.Errorf("empty peers: expected 0, got %d", len(result))
}
}
func TestFilterPeersByQuery_MultipleMatches(t *testing.T) {
peers := []map[string]interface{}{
{"name": "backend-alpha", "role": "eng"},
{"name": "backend-beta", "role": "eng"},
{"name": "frontend", "role": "ui"},
}
result := filterPeersByQuery(peers, "backend")
if len(result) != 2 {
t.Errorf("expected 2 backend matches, got %d", len(result))
}
}
@@ -99,9 +99,11 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) {
token, expiresAt, err := generateAppInstallationToken()
if err != nil {
log.Printf("[github] fallback token generation failed: %v", err)
// #388: GITHUB_APP_ID/INSTALLATION_ID unset → Gitea-canonical deployment
// or suspended org. Return 501 so callers (credential helper / gh auth)
// know this is not-implemented vs a transient error.
// #388: when GITHUB_APP_ID/INSTALLATION_ID are unset (e.g. post
// org suspension or Gitea-canonical deployments), this is a
// configuration gap, not an internal server error. Return 501 so
// callers (workspace polling loop) can distinguish "feature off"
// from "transient error" and stop polling.
if strings.Contains(err.Error(), "required") {
c.JSON(http.StatusNotImplemented, gin.H{
"error": "GitHub integration not configured",
@@ -76,15 +76,16 @@ func TestGitHubToken_NilRegistry(t *testing.T) {
// implement TokenProvider (e.g. a non-GitHub mutator in the chain).
//
// Post-#960/#1101 the handler now falls back to direct env-based App
// token generation (GITHUB_APP_ID / INSTALLATION_ID / PRIVATE_KEY_FILE)
// when no registered provider matches. In the test environment those
// env vars are unset, so the fallback fails with 501 "not implemented"
// with scm:"gitea" — signals a Gitea-canonical or suspended-org
// deployment where GitHub integration is not configured (#388).
// Previously this path returned 404; 501 distinguishes "not configured"
// (caller should stop retrying) from "provider failed" (caller should
// retry with back-off).
func TestGitHubToken_NoTokenProvider(t *testing.T) {
// token generation (GITHUB_APP_ID / INSTALLATION_ID / PRIVATE_KEY_FILE).
//
// When GITHUB_APP_ID or INSTALLATION_ID is unset (e.g. post org suspension
// or Gitea-canonical deployments without GitHub App), generateAppInstallationToken
// returns an error with "required" in the message. The handler now returns
// 501 Not Implemented with {"error":"GitHub integration not configured","scm":"gitea"}
// so callers can distinguish "feature off" from "transient error" and stop
// polling (#388). Other errors (e.g. network failures reading the private key)
// still return 500.
func TestGitHubToken_NoTokenProvider_MissingConfigReturns501(t *testing.T) {
reg := provisionhook.NewRegistry()
reg.Register(&mockMutatorOnly{name: "other-plugin"})
h := NewGitHubTokenHandler(reg)
@@ -92,15 +93,20 @@ func TestGitHubToken_NoTokenProvider(t *testing.T) {
h.GetInstallationToken(c)
// GITHUB_APP_ID/INSTALLATION_ID are unset in test env → "required" error → 501
if w.Code != http.StatusNotImplemented {
t.Fatalf("expected 501 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
t.Fatalf("expected 501 for missing GITHUB_APP_ID/INSTALLATION_ID, got %d: %s",
w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "GitHub integration not configured") {
t.Errorf("expected body to contain 'GitHub integration not configured', got: %s", w.Body.String())
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if !strings.Contains(w.Body.String(), `"scm":"gitea"`) {
t.Errorf("expected body to contain 'scm:gitea', got: %s", w.Body.String())
if body["error"] == "" {
t.Error("expected non-empty error field in 501 response")
}
if body["scm"] != "gitea" {
t.Errorf("expected scm=gitea, got %q", body["scm"])
}
}
@@ -1,884 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// ─── request helpers ───────────────────────────────────────────────────────────
func newPostRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
raw, _ := json.Marshal(body)
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
return w, c
}
func newPutRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
raw, _ := json.Marshal(body)
c.Request = httptest.NewRequest(http.MethodPut, path, bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
return w, c
}
func newDeleteRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodDelete, path, nil)
return w, c
}
func newGetRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
return w, c
}
// ─── mock row helpers ─────────────────────────────────────────────────────────
// instructionCols matches the SELECT in List/Resolve.
var instructionCols = []string{
"id", "scope", "scope_target", "title", "content",
"priority", "enabled", "created_at", "updated_at",
}
// resolveCols matches the SELECT in Resolve (scope, title, content).
var resolveCols = []string{"scope", "title", "content"}
// ─── List ────────────────────────────────────────────────────────────────────
func TestInstructionsList_ByWorkspaceID(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-123-abc"
w, c := newGetRequest("/instructions?workspace_id=" + wsID)
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?workspace_id="+wsID, nil)
rows := sqlmock.NewRows(instructionCols).
AddRow("inst-1", "global", nil, "Be helpful", "Always be helpful.", 10, true, time.Now(), time.Now()).
AddRow("inst-2", "workspace", &wsID, "Use Claude", "Use Claude Code.", 5, true, time.Now(), time.Now())
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
WithArgs(wsID).
WillReturnRows(rows)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if len(out) != 2 {
t.Errorf("expected 2 instructions, got %d", len(out))
}
if out[0].Scope != "global" {
t.Errorf("first row scope: expected global, got %s", out[0].Scope)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsList_ByScope(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/instructions?scope=global")
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?scope=global", nil)
rows := sqlmock.NewRows(instructionCols).
AddRow("inst-g", "global", nil, "Global Rule", "Follow policy.", 10, true, time.Now(), time.Now())
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WithArgs("global").
WillReturnRows(rows)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if len(out) != 1 || out[0].Scope != "global" {
t.Errorf("unexpected response: %v", out)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsList_AllNoParams(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/instructions")
rows := sqlmock.NewRows(instructionCols)
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WillReturnRows(rows)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
// Empty slice, not nil
if out == nil {
t.Error("expected empty slice, got nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsList_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/instructions")
c.Request = httptest.NewRequest(http.MethodGet, "/instructions", nil)
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WillReturnError(errors.New("connection refused"))
h.List(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Create ───────────────────────────────────────────────────────────────────
func TestInstructionsCreate_ValidGlobal(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Be Helpful",
"content": "Always be helpful to the user.",
"priority": 10,
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "Be Helpful", "Always be helpful to the user.", 10).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-1"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var out map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if out["id"] != "new-inst-1" {
t.Errorf("expected id new-inst-1, got %s", out["id"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsCreate_ValidWorkspace(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsTarget := "ws-xyz-789"
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "workspace",
"scope_target": wsTarget,
"title": "Use Claude Code",
"content": "Prefer Claude Code for all tasks.",
"priority": 5,
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("workspace", &wsTarget, "Use Claude Code", "Prefer Claude Code for all tasks.", 5).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-inst-2"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsCreate_MissingScope(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"title": "Missing Scope",
"content": "This has no scope.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_MissingTitle(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"content": "Has no title.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_MissingContent(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Has no content",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_InvalidScope(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "team",
"title": "Bad Scope",
"content": "Team scope is not supported yet.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_WorkspaceScopeNoTarget(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "workspace",
"title": "Missing Target",
"content": "Workspace scope without scope_target.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
// Build a string longer than maxInstructionContentLen (8192).
longContent := string(make([]byte, maxInstructionContentLen+1))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Too Long",
"content": longContent,
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
longTitle := string(make([]byte, 201))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": longTitle,
"content": "Short content.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsCreate_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "DB Error",
"content": "This will fail.",
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WillReturnError(errors.New("connection refused"))
h.Create(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Update ──────────────────────────────────────────────────────────────────
func TestInstructionsUpdate_ValidPartial(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-update-1"
newTitle := "Updated Title"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": newTitle,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WithArgs(instID, &newTitle, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsUpdate_AllFields(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-update-2"
title := "Full Update"
content := "New content body."
priority := 20
enabled := false
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": title,
"content": content,
"priority": priority,
"enabled": enabled,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WithArgs(instID, &title, &content, &priority, &enabled).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsUpdate_ContentTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-too-long"
longContent := string(make([]byte, maxInstructionContentLen+1))
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"content": longContent,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
h.Update(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsUpdate_TitleTooLong(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-title-long"
longTitle := string(make([]byte, 201))
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": longTitle,
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
h.Update(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsUpdate_NotFound(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-missing"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": "New Title",
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WillReturnResult(sqlmock.NewResult(0, 0))
h.Update(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsUpdate_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-db-err"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
"title": "Error Update",
})
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec("UPDATE platform_instructions SET").
WillReturnError(errors.New("connection refused"))
h.Update(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Delete ───────────────────────────────────────────────────────────────────
func TestInstructionsDelete_Valid(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-delete-1"
w, c := newDeleteRequest("/instructions/" + instID)
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
WithArgs(instID).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Delete(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsDelete_NotFound(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-not-there"
w, c := newDeleteRequest("/instructions/" + instID)
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
WithArgs(instID).
WillReturnResult(sqlmock.NewResult(0, 0))
h.Delete(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsDelete_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-del-err"
w, c := newDeleteRequest("/instructions/" + instID)
c.Params = []gin.Param{{Key: "id", Value: instID}}
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
WithArgs(instID).
WillReturnError(errors.New("connection refused"))
h.Delete(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Resolve ──────────────────────────────────────────────────────────────────
func TestInstructionsResolve_GlobalThenWorkspace(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-resolve-1"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
rows := sqlmock.NewRows(resolveCols).
AddRow("global", "Be Helpful", "Always help the user.").
AddRow("global", "Stay on Topic", "Don't diverge.").
AddRow("workspace", "Use Claude Code", "Claude Code is the default runtime.")
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnRows(rows)
h.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out struct {
WorkspaceID string `json:"workspace_id"`
Instructions string `json:"instructions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if out.WorkspaceID != wsID {
t.Errorf("expected workspace_id %s, got %s", wsID, out.WorkspaceID)
}
// Global section must come before workspace section.
if !bytes.Contains([]byte(out.Instructions), []byte("Platform-Wide Rules")) {
t.Error("instructions should contain 'Platform-Wide Rules' section")
}
if !bytes.Contains([]byte(out.Instructions), []byte("Role-Specific Rules")) {
t.Error("instructions should contain 'Role-Specific Rules' section")
}
// Global instructions must appear before workspace instructions.
idxGlobal := bytes.Index([]byte(out.Instructions), []byte("Platform-Wide Rules"))
idxWorkspace := bytes.Index([]byte(out.Instructions), []byte("Role-Specific Rules"))
if idxGlobal >= idxWorkspace {
t.Error("global section should appear before workspace section")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsResolve_EmptyWorkspace(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-empty"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
rows := sqlmock.NewRows(resolveCols)
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnRows(rows)
h.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out struct {
Instructions string `json:"instructions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
// No rows → builder writes nothing; empty string returned.
if out.Instructions != "" {
t.Errorf("expected empty instructions for empty workspace, got: %q", out.Instructions)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsResolve_DBError(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-err"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnError(errors.New("connection refused"))
h.Resolve(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
w, c := newGetRequest("/workspaces//instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: ""}}
h.Resolve(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ─── scanInstructions edge cases ───────────────────────────────────────────────
// NOTE: TestScanInstructions_ScanError was removed — go-sqlmock v1.5.2 does not
// implement Go 1.25's sql.Rows.Next([]byte) bool method, so *sqlmock.Rows cannot
// satisfy scanInstructions' interface. The test needs a sqlmock upgrade or a
// different mocking strategy (tracked: internal issue).
// ─── maxInstructionContentLen boundary ────────────────────────────────────────
func TestInstructionsCreate_ContentExactlyAtLimit(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
exactContent := string(make([]byte, maxInstructionContentLen))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "At Limit",
"content": exactContent,
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "At Limit", exactContent, 0).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("at-limit-1"))
h.Create(c)
// Exactly at limit must succeed (8192 chars is acceptable).
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for content at limit, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── priority defaults ────────────────────────────────────────────────────────
func TestInstructionsCreate_PriorityDefaultsToZero(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
// Body omits priority — expect it defaults to 0.
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "No Priority",
"content": "Default priority body.",
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "No Priority", "Default priority body.", 0).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("no-prio-1"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── nil scope_target for global instructions ─────────────────────────────────
func TestInstructionsCreate_GlobalScopeNilTarget(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Global Nil Target",
"content": "Global instruction.",
})
// For global scope, scope_target must be SQL NULL.
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "Global Nil Target", "Global instruction.", 0).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("global-nil-1"))
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── workspace scope with empty string target (rejected) ─────────────────────
func TestInstructionsCreate_WorkspaceScopeEmptyStringTarget(t *testing.T) {
setupTestDB(t)
h := NewInstructionsHandler()
empty := ""
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "workspace",
"scope_target": empty,
"title": "Empty Target",
"content": "Empty workspace target.",
})
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty string scope_target, got %d: %s", w.Code, w.Body.String())
}
}
// ─── Resolve: scope label transitions ────────────────────────────────────────
func TestInstructionsResolve_ScopeTransitionOnlyGlobal(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
wsID := "ws-only-global"
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
rows := sqlmock.NewRows(resolveCols).
AddRow("global", "Rule One", "First rule.").
AddRow("global", "Rule Two", "Second rule.")
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
WithArgs(wsID).
WillReturnRows(rows)
h.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var out struct {
Instructions string `json:"instructions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
// Two global instructions share one section header.
if bytes.Count([]byte(out.Instructions), []byte("Platform-Wide Rules")) != 1 {
t.Error("expect exactly one 'Platform-Wide Rules' header for consecutive global rows")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ─── Update: empty body (all nil — no-op update) ─────────────────────────────
func TestInstructionsUpdate_EmptyBody(t *testing.T) {
mock := setupTestDB(t)
h := NewInstructionsHandler()
instID := "inst-empty-update"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{})
c.Params = []gin.Param{{Key: "id", Value: instID}}
// COALESCE(nil, ...) = unchanged; still updates updated_at.
// Args order: ($1=id, $2=title, $3=content, $4=priority, $5=enabled)
mock.ExpectExec("UPDATE platform_instructions SET").
WithArgs(instID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
h.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for empty body, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
+5 -12
View File
@@ -31,7 +31,6 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
@@ -421,16 +420,11 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
}
text, err := h.dispatch(ctx, workspaceID, params.Name, params.Arguments)
if err != nil {
// Log full error server-side for forensics.
// Log full error server-side for forensics; return constant string
// to client per OFFSEC-001 / #259. WorkspaceAuth required — caller
// already authenticated, so this is defence-in-depth.
log.Printf("mcp: tool call failed workspace=%s tool=%s: %v", workspaceID, params.Name, err)
// Unknown-tool errors are suppressed per OFFSEC-001 (#259) to avoid
// leaking tool names; all other tool errors surface their detail so
// callers (including test suites) can assert on permission messages.
errMsg := err.Error()
if strings.HasPrefix(errMsg, "unknown tool:") {
errMsg = "tool call failed"
}
base.Error = &mcpRPCError{Code: -32000, Message: errMsg}
base.Error = &mcpRPCError{Code: -32000, Message: "tool call failed"}
return base
}
base.Result = map[string]interface{}{
@@ -440,8 +434,7 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
}
default:
// Per OFFSEC-001: error message must not include user-controlled req.Method.
base.Error = &mcpRPCError{Code: -32601, Message: "method not found"}
base.Error = &mcpRPCError{Code: -32601, Message: "method not found: " + req.Method}
}
return base
+1 -52
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"errors"
@@ -205,9 +204,6 @@ func TestMCPHandler_NotificationsInitialized_Returns200(t *testing.T) {
// Unknown method
// ─────────────────────────────────────────────────────────────────────────────
// TestMCPHandler_UnknownMethod_Returns32601 verifies dispatchRPC returns
// -32601 for an unknown method. Per OFFSEC-001: the error message must be
// constant — req.Method is user-controlled and must NOT appear in the response.
func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) {
h, _ := newMCPHandler(t)
@@ -228,14 +224,6 @@ func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) {
if resp.Error.Code != -32601 {
t.Errorf("expected code -32601, got %d", resp.Error.Code)
}
// Message must be constant — no user-controlled method name leak.
if resp.Error.Message != "method not found" {
t.Errorf("error message should be constant 'method not found', got: %q", resp.Error.Message)
}
// Double-check the method name never appears in the message (defence-in-depth).
if strings.Contains(resp.Error.Message, "not/a/real/method") {
t.Error("error message must not echo the user-controlled method name")
}
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -548,28 +536,10 @@ func TestMCPHandler_CommitMemory_CleanContent_PassesThrough(t *testing.T) {
// tools/call — recall_memory
// ─────────────────────────────────────────────────────────────────────────────
// TestMCPHandler_RecallMemory_GlobalScope_Blocked verifies C3 enforcement:
// GLOBAL scope is blocked on the MCP bridge. Sibling of
// TestMCPHandler_CommitMemory_GlobalScope_Blocked (#681 — mirrors PR#680's
// OFFSEC-001 contract hardening from the commit-memory path).
//
// Canary tokens are included in the arguments so a future OFFSEC-001 regression
// (err.Error() leaking into the JSON-RPC message) would be caught by the
// defence-in-depth strings.Contains guard even if the exact-message assertion
// were deleted. Per feedback_branch_count_before_approving the recall path
// must be verified independently since it flows through a different tool
// implementation (toolRecallMemory vs toolCommitMemory).
func TestMCPHandler_RecallMemory_GlobalScope_Blocked(t *testing.T) {
h, mock := newMCPHandler(t)
// No DB expectations — handler must abort before touching the DB.
// Canary tokens: truly arbitrary strings that could NOT appear in
// the error message naturally. If OFFSEC-001 regresses and the raw
// err.Error() is returned, these will appear verbatim in the response.
// Tokens chosen to not overlap with the actual error message text
// ("GLOBAL", "scope", "permitted", etc.) — which WOULD appear even
// when the scrub is correct, making them useless as sentinels.
const canary = "xK8mPqRwT zN7vLsJhYw"
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 11,
@@ -577,7 +547,7 @@ func TestMCPHandler_RecallMemory_GlobalScope_Blocked(t *testing.T) {
"params": map[string]interface{}{
"name": "recall_memory",
"arguments": map[string]interface{}{
"query": canary,
"query": "secret",
"scope": "GLOBAL",
},
},
@@ -588,27 +558,6 @@ func TestMCPHandler_RecallMemory_GlobalScope_Blocked(t *testing.T) {
if resp.Error == nil {
t.Error("expected JSON-RPC error for GLOBAL scope recall, got nil")
}
// Exact-equality assertions: code == -32000 AND the constant message.
// The message must be the constant defined in toolRecallMemory, not the
// raw err.Error() value — OFFSEC-001 (#259) requires this so callers
// (including agent runtimes) cannot learn server-side details.
wantMsg := "GLOBAL scope is not permitted via the MCP bridge — use LOCAL, TEAM, or empty"
if resp.Error != nil {
if resp.Error.Code != -32000 {
t.Errorf("error code should be -32000, got %d", resp.Error.Code)
}
if resp.Error.Message != wantMsg {
t.Errorf("error message should be constant %q, got %q", wantMsg, resp.Error.Message)
}
// Defence-in-depth: canary tokens must never appear in the response.
// A future regression where err.Error() is assigned directly would
// expose these arbitrary strings verbatim in the JSON-RPC body.
for _, token := range strings.Fields(canary) {
if strings.Contains(resp.Error.Message, token) {
t.Errorf("error message should not contain canary token %q (OFFSEC-001 leak)", token)
}
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB calls on GLOBAL scope block: %v", err)
}
@@ -346,7 +346,7 @@ func (g *gitFetcher) Fetch(ctx context.Context, rootDir, host, repoPath, ref str
// MkdirTemp creates the dir; git clone refuses to clone into a
// non-empty dir. Remove + recreate empty.
os.RemoveAll(tmpDir)
cloneAndConfig := gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir)
cloneAndConfig := append(gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir))
cmd := exec.CommandContext(ctx, "git", cloneAndConfig...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if out, err := cmd.CombinedOutput(); err != nil {
@@ -91,11 +91,6 @@ func expandWithEnv(s string, env map[string]string) string {
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
// CWE-22 mitigation: filesDir is validated through resolveInsideRoot so a
// malicious org YAML cannot escape the org root with "../../../etc". Both
// call sites already guard ws.FilesDir, but the internal guard is the
// reliable enforcement point regardless of caller.
func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
envVars := map[string]string{}
if orgBaseDir == "" {
@@ -103,12 +98,7 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
}
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
if filesDir != "" {
// resolveInsideRoot returns the joined absolute path — use it directly.
safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir)
if err != nil {
return envVars // silently reject traversal attempts
}
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
}
return envVars
}
@@ -327,12 +317,6 @@ func mergePlugins(defaultPlugins, wsPlugins []string) []string {
// Follows Go's standard pattern for SSRF-class path sanitization; using
// strings.HasPrefix on an absolute-path pair plus the separator guard rejects
// sibling directories that share a prefix (e.g. "/foo" vs "/foobar").
//
// CWE-59 mitigation: filepath.Abs does NOT resolve symlinks, so a path like
// "workspaces/dev/inner" where "inner" is a symlink to "/etc" would lexically
// pass the prefix check. We call filepath.EvalSymlinks to canonicalize the
// path and re-check that it is still inside root. This closes the symlink-
// based traversal vector (CWE-59, follow-up to #369).
func resolveInsideRoot(root, userPath string) (string, error) {
if userPath == "" {
return "", fmt.Errorf("path is empty")
@@ -349,18 +333,9 @@ func resolveInsideRoot(root, userPath string) (string, error) {
if err != nil {
return "", fmt.Errorf("joined abs: %w", err)
}
// CWE-59: resolve symlinks before final prefix check.
// If the path contains a symlink pointing outside root, EvalSymlinks
// will canonicalize to the external path and fail the guard below.
resolved, err := filepath.EvalSymlinks(absJoined)
if err != nil {
// If EvalSymlinks fails (e.g. broken symlink), fail closed —
// broken symlinks should not be used as org files.
return "", fmt.Errorf("resolve symlink: %w", err)
}
// Allow exact-root match (rare but valid) and any descendant.
if resolved != absRoot && !strings.HasPrefix(resolved, absRoot+string(filepath.Separator)) {
if absJoined != absRoot && !strings.HasPrefix(absJoined, absRoot+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes root")
}
return absJoined, nil // return the lexical path, not the resolved one
return absJoined, nil
}
@@ -1,126 +0,0 @@
package handlers
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupOrgEnv creates a temp dir with an optional org .env file and returns the dir.
func setupOrgEnv(t *testing.T, orgEnvContent string) string {
t.Helper()
dir := t.TempDir()
if orgEnvContent != "" {
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(orgEnvContent), 0o600))
}
return dir
}
func Test_loadWorkspaceEnv_orgRootOnly(t *testing.T) {
org := setupOrgEnv(t, "ORG_VAR=orgval\nORG_DEBUG=true")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "orgval", vars["ORG_VAR"])
assert.Equal(t, "true", vars["ORG_DEBUG"])
}
func Test_loadWorkspaceEnv_orgRootMissing(t *testing.T) {
// No .env at org root — should return empty map without error.
dir := t.TempDir()
vars := loadWorkspaceEnv(dir, "")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_workspaceEnvMerges(t *testing.T) {
org := setupOrgEnv(t, "SHARED=sharedval\nORG_ONLY=orgonly")
wsDir := filepath.Join(org, "myworkspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_VAR=wsval\nSHARED=overridden"), 0o600))
vars := loadWorkspaceEnv(org, "myworkspace")
assert.Equal(t, "wsval", vars["WS_VAR"])
assert.Equal(t, "overridden", vars["SHARED"]) // workspace overrides org
assert.Equal(t, "orgonly", vars["ORG_ONLY"]) // org vars preserved
}
func Test_loadWorkspaceEnv_emptyFilesDir(t *testing.T) {
org := setupOrgEnv(t, "VAR=val")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "val", vars["VAR"])
}
func Test_loadWorkspaceEnv_traversalRejects(t *testing.T) {
// #321 / CWE-22: filesDir "../../../etc" must not escape the org root.
// resolveInsideRoot rejects the traversal so workspace .env is skipped;
// org root .env is still loaded (it's before the guard).
org := setupOrgEnv(t, "INNOCENT=val\nSAFE_WS=wsval")
parent := filepath.Dir(org)
require.NoError(t, os.WriteFile(filepath.Join(parent, ".env"), []byte("MALICIOUS=evil"), 0o600))
// Also create a workspace dir inside org to prove it IS accessible normally.
wsDir := filepath.Join(org, "legit-workspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_SECRET=ssh-key-123"), 0o600))
// Traversal is blocked.
vars := loadWorkspaceEnv(org, "../../../etc")
// Org root vars present; workspace vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Equal(t, "wsval", vars["SAFE_WS"]) // from org root .env
assert.Empty(t, vars["WS_SECRET"]) // workspace .env blocked by traversal guard
_, hasEvil := vars["MALICIOUS"]
assert.False(t, hasEvil, "MALICIOUS from escaped path must not appear")
}
func Test_loadWorkspaceEnv_traversalWithDots(t *testing.T) {
// A sibling-traversal attempt: go up one level then into a sibling dir.
// The sibling dir is NOT inside org, so it must be rejected.
org := setupOrgEnv(t, "INNOCENT=val")
parent := filepath.Dir(org)
require.NoError(t, os.MkdirAll(filepath.Join(parent, "sibling"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(parent, "sibling/.env"), []byte("LEAKED=secret"), 0o600))
vars := loadWorkspaceEnv(org, "../sibling")
// Org vars loaded; sibling vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Empty(t, vars["LEAKED"], "sibling traversal must be rejected")
}
func Test_loadWorkspaceEnv_absolutePathRejected(t *testing.T) {
// Absolute paths are rejected outright by resolveInsideRoot.
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, "/etc")
assert.Equal(t, "val", vars["INNOCENT"]) // org root still loaded
assert.Empty(t, vars["SAFE_WS"])
}
func Test_loadWorkspaceEnv_dotPathRejected(t *testing.T) {
// "." resolves to the org root itself — this is NOT a traversal but
// would create org-root/.env which is the org root .env, not a
// workspace .env. resolveInsideRoot accepts this; the workspace .env
// path is org/.env, which IS the org root .env (already loaded).
// So the correct result is the org vars (same as org root, no change).
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, ".")
// "." passes resolveInsideRoot (resolves to org root, which is valid).
// But workspace path org/.env is the same as org/.env already loaded.
assert.Equal(t, "val", vars["INNOCENT"])
}
func Test_loadWorkspaceEnv_emptyOrgRootReturnsEmpty(t *testing.T) {
vars := loadWorkspaceEnv("", "some/dir")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_missingWorkspaceDir(t *testing.T) {
org := setupOrgEnv(t, "ORG=val")
// Workspace dir doesn't exist — org vars still loaded.
vars := loadWorkspaceEnv(org, "nonexistent")
assert.Equal(t, "val", vars["ORG"])
}
func assertEmpty(t *testing.T, m map[string]string) {
t.Helper()
assert.Equal(t, 0, len(m), "expected empty map, got %v", m)
}
@@ -1,409 +0,0 @@
package handlers
import (
"testing"
)
// ── isSafeRoleName ────────────────────────────────────────────────────────────
func TestIsSafeRoleName_Valid(t *testing.T) {
cases := []string{
"backend",
"frontend",
"backend-engineer",
"Frontend_Engineer",
"DevOps123",
"sre-team",
"a",
"ABC",
"Role_With_Underscores_And-Numbers123",
}
for _, r := range cases {
t.Run(r, func(t *testing.T) {
if !isSafeRoleName(r) {
t.Errorf("isSafeRoleName(%q): expected true, got false", r)
}
})
}
}
func TestIsSafeRoleName_Invalid(t *testing.T) {
cases := []struct {
name string
role string
}{
{"empty", ""},
{"dot", "."},
{"double dot", ".."},
{"path separator", "backend/engineer"},
{"space", "backend engineer"},
{"special char", "backend@engineer"},
{"at sign", "role@team"},
{"colon", "role:admin"},
{"hash", "role#1"},
{"percent", "role%20"},
{"quote", `role"name`},
{"backslash", `role\name`},
{"tilde", "role~test"},
{"backtick", "`role"},
{"bracket open", "[role]"},
{"bracket close", "role]"},
{"plus", "role+admin"},
{"equals", "role=admin"},
{"caret", "role^admin"},
{"question mark", "role?"},
{"pipe at end", "role|"},
{"greater than", "role>"},
{"asterisk", "role*"},
{"ampersand", "role&"},
{"exclamation at end", "role!"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if isSafeRoleName(tc.role) {
t.Errorf("isSafeRoleName(%q): expected false, got true", tc.role)
}
})
}
}
// ── hasUnresolvedVarRef ───────────────────────────────────────────────────────
func TestHasUnresolvedVarRef_NoVars(t *testing.T) {
cases := []string{
"",
"plain text",
"no variables here",
"123 numeric",
"$",
"${}",
"$5",
"$$$$",
}
for _, s := range cases {
t.Run(s, func(t *testing.T) {
if hasUnresolvedVarRef(s, s) {
t.Errorf("hasUnresolvedVarRef(%q, %q): expected false, got true", s, s)
}
})
}
}
func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
// Expansion consumed the var refs.
cases := []struct {
orig string
expanded string
}{
{"${VAR}", ""}, // var expanded to empty (unset → removed)
{"${VAR}", "value"}, // var replaced
{"$VAR", "value"}, // bare var replaced
{"prefix${VAR}suffix", "prefixvaluesuffix"},
{"${A}${B}", "ab"},
{"${FOO} and ${BAR}", "FOO and BAR"},
}
for _, tc := range cases {
t.Run(tc.orig, func(t *testing.T) {
if hasUnresolvedVarRef(tc.orig, tc.expanded) {
t.Errorf("hasUnresolvedVarRef(%q, %q): expected false, got true", tc.orig, tc.expanded)
}
})
}
}
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
// Expansion left the refs intact → unresolved.
cases := []struct {
orig string
expanded string
}{
{"${VAR}", "${VAR}"}, // untouched
{"$VAR", "$VAR"}, // bare untouched
{"prefix${VAR}suffix", "prefix${VAR}suffix"},
{"${A}${B}", "${A}${B}"}, // both unresolved
{"${FOO}", ""}, // empty result with var ref in original
}
for _, tc := range cases {
t.Run(tc.orig, func(t *testing.T) {
if !hasUnresolvedVarRef(tc.orig, tc.expanded) {
t.Errorf("hasUnresolvedVarRef(%q, %q): expected true, got false", tc.orig, tc.expanded)
}
})
}
}
// ── expandWithEnv ─────────────────────────────────────────────────────────────
func TestExpandWithEnv_Basic(t *testing.T) {
env := map[string]string{"FOO": "bar", "BAZ": "qux"}
cases := []struct {
input string
want string
}{
{"", ""},
{"no vars", "no vars"},
{"${FOO}", "bar"},
{"$FOO", "bar"},
{"prefix${FOO}suffix", "prefixbarsuffix"},
{"${FOO}${BAZ}", "barqux"},
{"${MISSING}", ""}, // not in env, not in os env → empty
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := expandWithEnv(tc.input, env)
if got != tc.want {
t.Errorf("expandWithEnv(%q, %v) = %q, want %q", tc.input, env, got, tc.want)
}
})
}
}
// ── mergeCategoryRouting ─────────────────────────────────────────────────────
func TestMergeCategoryRouting_EmptyInputs(t *testing.T) {
// Both empty → empty
r := mergeCategoryRouting(nil, nil)
if len(r) != 0 {
t.Errorf("mergeCategoryRouting(nil, nil): got %v, want empty", r)
}
r = mergeCategoryRouting(map[string][]string{}, map[string][]string{})
if len(r) != 0 {
t.Errorf("mergeCategoryRouting({}, {}): got %v, want empty", r)
}
}
func TestMergeCategoryRouting_DefaultsOnly(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer", "DevOps"},
"ui": {"Frontend Engineer"},
"data": {"Data Engineer"},
}
r := mergeCategoryRouting(defaults, nil)
if len(r) != 3 {
t.Errorf("got %d keys, want 3", len(r))
}
if len(r["security"]) != 2 {
t.Errorf("security roles: got %v, want 2", r["security"])
}
}
func TestMergeCategoryRouting_WorkspaceOverrides(t *testing.T) {
defaults := map[string][]string{
"security": {"Backend Engineer", "DevOps"},
"ui": {"Frontend Engineer"},
}
ws := map[string][]string{
"security": {"SRE Team"}, // narrows
"ui": {}, // drops
"infra": {"Platform Team"}, // adds
}
r := mergeCategoryRouting(defaults, ws)
if len(r["security"]) != 1 || r["security"][0] != "SRE Team" {
t.Errorf("security: got %v, want [SRE Team]", r["security"])
}
if _, ok := r["ui"]; ok {
t.Errorf("ui should be dropped, got %v", r["ui"])
}
if len(r["infra"]) != 1 || r["infra"][0] != "Platform Team" {
t.Errorf("infra: got %v, want [Platform Team]", r["infra"])
}
}
func TestMergeCategoryRouting_EmptyListDrops(t *testing.T) {
defaults := map[string][]string{"foo": {"A", "B"}}
ws := map[string][]string{"foo": {}}
r := mergeCategoryRouting(defaults, ws)
if _, ok := r["foo"]; ok {
t.Errorf("foo with empty ws list: should be dropped, got %v", r["foo"])
}
}
func TestMergeCategoryRouting_EmptyKeySkipped(t *testing.T) {
defaults := map[string][]string{"": {"Role"}}
ws := map[string][]string{"": {}}
r := mergeCategoryRouting(defaults, ws)
if _, ok := r[""]; ok {
t.Errorf("empty key should be skipped, got %v", r[""])
}
}
// ── renderCategoryRoutingYAML ────────────────────────────────────────────────
func TestRenderCategoryRoutingYAML_Empty(t *testing.T) {
out, err := renderCategoryRoutingYAML(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "" {
t.Errorf("got %q, want empty string", out)
}
out, err = renderCategoryRoutingYAML(map[string][]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "" {
t.Errorf("got %q, want empty string", out)
}
}
func TestRenderCategoryRoutingYAML_StableOrdering(t *testing.T) {
// Keys are sorted so output is deterministic regardless of map iteration order.
m := map[string][]string{
"zebra": {"A"},
"alpha": {"B"},
"middle": {"C"},
}
out, err := renderCategoryRoutingYAML(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// alpha must come before middle, which must come before zebra
ai := 0
zi := 0
mi := 0
for i, c := range out {
switch {
case c == 'a' && i < len(out)-5 && out[i:i+5] == "alpha":
ai = i
case c == 'z' && i < len(out)-5 && out[i:i+5] == "zebra":
zi = i
case c == 'm' && i < len(out)-6 && out[i:i+6] == "middle":
mi = i
}
}
if ai <= 0 || zi <= 0 || mi <= 0 {
t.Fatalf("could not locate all keys in output: %s", out)
}
if !(ai < mi && mi < zi) {
t.Errorf("keys not sorted: alpha=%d middle=%d zebra=%d, output:\n%s", ai, mi, zi, out)
}
}
func TestRenderCategoryRoutingYAML_SpecialCharsEscaped(t *testing.T) {
// YAML library should escape characters that need quoting.
m := map[string][]string{
"key:with:colons": {"Role: Admin"},
"key with space": {"Role"},
}
out, err := renderCategoryRoutingYAML(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The output must be valid YAML (yaml.Marshal handles quoting).
// The key with colons should appear quoted in the output.
if out == "" {
t.Error("output is empty")
}
}
// ── appendYAMLBlock ───────────────────────────────────────────────────────────
func TestAppendYAMLBlock_NoExisting(t *testing.T) {
got := appendYAMLBlock(nil, "key: value")
if string(got) != "key: value" {
t.Errorf("got %q, want 'key: value'", string(got))
}
}
func TestAppendYAMLBlock_EmptyBlock(t *testing.T) {
got := appendYAMLBlock([]byte("existing: data"), "")
if string(got) != "existing: data" {
t.Errorf("got %q, want 'existing: data'", string(got))
}
}
func TestAppendYAMLBlock_AppendsWithNewline(t *testing.T) {
existing := []byte("key: value")
block := "new: entry"
got := appendYAMLBlock(existing, block)
want := "key: value\nnew: entry"
if string(got) != want {
t.Errorf("got %q, want %q", string(got), want)
}
}
func TestAppendYAMLBlock_AlreadyEndsWithNewline(t *testing.T) {
existing := []byte("key: value\n")
block := "new: entry"
got := appendYAMLBlock(existing, block)
want := "key: value\nnew: entry"
if string(got) != want {
t.Errorf("got %q, want %q", string(got), want)
}
}
// ── mergePlugins ─────────────────────────────────────────────────────────────
func TestMergePlugins_EmptyInputs(t *testing.T) {
r := mergePlugins(nil, nil)
if len(r) != 0 {
t.Errorf("got %v, want []", r)
}
r = mergePlugins([]string{}, []string{})
if len(r) != 0 {
t.Errorf("got %v, want []", r)
}
}
func TestMergePlugins_BasicMerge(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
ws := []string{"plugin-b", "plugin-c"}
r := mergePlugins(defaults, ws)
// defaults first, ws appended, b deduplicated
if len(r) != 3 {
t.Errorf("got %v, want 3 items", r)
}
if r[0] != "plugin-a" || r[1] != "plugin-b" || r[2] != "plugin-c" {
t.Errorf("got %v, want [a, b, c]", r)
}
}
func TestMergePlugins_ExcludeWithBang(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
ws := []string{"!plugin-b"}
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
if r[0] != "plugin-a" || r[1] != "plugin-c" {
t.Errorf("got %v, want [a, c]", r)
}
}
func TestMergePlugins_ExcludeWithDash(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
ws := []string{"-plugin-b"}
r := mergePlugins(defaults, ws)
if len(r) != 2 || r[0] != "plugin-a" || r[1] != "plugin-c" {
t.Errorf("got %v, want [a, c]", r)
}
}
func TestMergePlugins_ExcludeNonexistent(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
ws := []string{"!plugin-c"} // c not present
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
}
func TestMergePlugins_ExcludeEmptyTarget(t *testing.T) {
defaults := []string{"plugin-a", "plugin-b"}
ws := []string{"!"}
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
}
func TestMergePlugins_EmptyPlugin(t *testing.T) {
defaults := []string{"", "plugin-a", ""}
ws := []string{"plugin-b", ""}
r := mergePlugins(defaults, ws)
if len(r) != 2 {
t.Errorf("got %v, want 2 items", r)
}
}
@@ -1,191 +0,0 @@
package handlers
import (
"errors"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
// walkOrgWorkspaceNames tests — recursive collection of non-empty workspace names.
func TestWalkOrgWorkspaceNames_EmptySlice(t *testing.T) {
var names []string
walkOrgWorkspaceNames([]OrgWorkspace{}, &names)
assert.Empty(t, names)
}
func TestWalkOrgWorkspaceNames_SingleNode(t *testing.T) {
var names []string
walkOrgWorkspaceNames([]OrgWorkspace{{Name: "my-workspace"}}, &names)
assert.Equal(t, []string{"my-workspace"}, names)
}
func TestWalkOrgWorkspaceNames_SingleNodeEmptyName(t *testing.T) {
var names []string
walkOrgWorkspaceNames([]OrgWorkspace{{Name: ""}}, &names)
assert.Empty(t, names)
}
func TestWalkOrgWorkspaceNames_NestedChildren(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{
Name: "parent",
Children: []OrgWorkspace{
{Name: "child-a"},
{Name: "child-b"},
},
},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"parent", "child-a", "child-b"}, names)
}
func TestWalkOrgWorkspaceNames_DeeplyNested(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{
Name: "level0",
Children: []OrgWorkspace{
{
Name: "level1",
Children: []OrgWorkspace{
{
Name: "level2",
Children: []OrgWorkspace{
{Name: "level3"},
},
},
},
},
},
},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"level0", "level1", "level2", "level3"}, names)
}
func TestWalkOrgWorkspaceNames_SkipsEmptyNames(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{Name: "a"},
{Name: ""},
{Name: "b"},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"a", "b"}, names)
}
func TestWalkOrgWorkspaceNames_Siblings(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{Name: "team"},
{Name: "alpha"},
{Name: "beta"},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"team", "alpha", "beta"}, names)
}
func TestWalkOrgWorkspaceNames_MultipleRoots(t *testing.T) {
var names []string
tree := []OrgWorkspace{
{Name: "root-a", Children: []OrgWorkspace{{Name: "child-a"}}},
{Name: "root-b", Children: []OrgWorkspace{{Name: "child-b"}}},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"root-a", "child-a", "root-b", "child-b"}, names)
}
func TestWalkOrgWorkspaceNames_SpawningFalseStillWalks(t *testing.T) {
// The comment in the source is explicit: spawning:false subtrees are
// still walked. Empty names within those subtrees are still skipped.
var names []string
yes := true
no := false
tree := []OrgWorkspace{
{
Name: "parent",
Children: []OrgWorkspace{
{Name: "spawning-child", Spawning: &yes},
{Name: "non-spawning-child", Spawning: &no},
{Name: ""},
},
},
}
walkOrgWorkspaceNames(tree, &names)
assert.Equal(t, []string{"parent", "spawning-child", "non-spawning-child"}, names)
}
// resolveProvisionConcurrency tests — env-var parsing with sensible fallback.
func TestResolveProvisionConcurrency_Default(t *testing.T) {
os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_ValidPositiveInt(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "5")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, 5, val)
}
func TestResolveProvisionConcurrency_ZeroUnlimited(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "0")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
// Zero is mapped to 1<<20 (unlimited semantics with finite cap)
assert.Equal(t, 1<<20, val)
}
func TestResolveProvisionConcurrency_NegativeFallsBack(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "-1")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_NonIntegerFallsBack(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "not-a-number")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_WhitespaceOnly(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", " ")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, defaultProvisionConcurrency, val)
}
func TestResolveProvisionConcurrency_LargeValue(t *testing.T) {
os.Setenv("MOLECULE_PROVISION_CONCURRENCY", "10000")
defer os.Unsetenv("MOLECULE_PROVISION_CONCURRENCY")
val := resolveProvisionConcurrency()
assert.Equal(t, 10000, val)
}
// errString tests — nil-safe error-to-string wrapper.
func TestErrString_NilError(t *testing.T) {
result := errString(nil)
assert.Equal(t, "", result)
}
func TestErrString_WithError(t *testing.T) {
err := errors.New("something went wrong")
result := errString(err)
assert.Equal(t, "something went wrong", result)
}
func TestErrString_EmptyError(t *testing.T) {
err := errors.New("")
result := errString(err)
assert.Equal(t, "", result)
}
@@ -487,13 +487,11 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// timeout (caught 2026-05-08 right after dev-only org/import).
loadPersonaEnvFile(ws.FilesDir, envVars)
if orgBaseDir != "" {
// Load org root and workspace-specific .env files. loadWorkspaceEnv
// applies resolveInsideRoot to ws.FilesDir, closing the CWE-22 /
// mc#786 path-traversal regression introduced when the guard was
// dropped from createWorkspaceTree.
workspaceEnv := loadWorkspaceEnv(orgBaseDir, ws.FilesDir)
for k, v := range workspaceEnv {
envVars[k] = v // workspace-specific overrides org root
// 1. Org root .env (shared defaults)
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
// 2. Workspace-specific .env (overrides)
if ws.FilesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, ws.FilesDir, ".env"), envVars)
}
}
// Store as workspace secrets via DB (encrypted if key is set, raw otherwise)
@@ -1,294 +0,0 @@
package handlers
import "testing"
// Tests for the pure layout helpers in org.go:
// childSlot, sizeOfSubtree, childSlotInGrid. These compute the canvas
// grid positions for org-import workspace trees and mirror the TypeScript
// layout functions in canvas-topology.ts (defaultChildSlot, parentMinSize,
// childSlotInGrid). The two sides use slightly different default sizes
// (Go: 240×130, TS: 210×120) so they are tested independently.
// childSlot — 2-column fixed-size grid, one row of child cards.
func TestChildSlot_ZeroIndex(t *testing.T) {
x, y := childSlot(0)
// col=0, row=0
// x = 16 + 0*(240+14) = 16
// y = 130 + 0*(130+14) = 130
if x != 16.0 {
t.Errorf("slot 0 x: got %v, want 16.0", x)
}
if y != 130.0 {
t.Errorf("slot 0 y: got %v, want 130.0", y)
}
}
func TestChildSlot_SecondColumn(t *testing.T) {
x, y := childSlot(1)
// col=1, row=0
// x = 16 + 1*(240+14) = 16+254 = 270
// y = 130
if x != 270.0 {
t.Errorf("slot 1 x: got %v, want 270.0", x)
}
if y != 130.0 {
t.Errorf("slot 1 y: got %v, want 130.0", y)
}
}
func TestChildSlot_SecondRow(t *testing.T) {
x, y := childSlot(2)
// col=0, row=1
// x = 16
// y = 130 + 1*(130+14) = 130+144 = 274
if x != 16.0 {
t.Errorf("slot 2 x: got %v, want 16.0", x)
}
if y != 274.0 {
t.Errorf("slot 2 y: got %v, want 274.0", y)
}
}
func TestChildSlot_ThirdRowFirstColumn(t *testing.T) {
x, y := childSlot(4)
// col=0, row=2
// x = 16
// y = 130 + 2*(130+14) = 130+288 = 418
if x != 16.0 {
t.Errorf("slot 4 x: got %v, want 16.0", x)
}
if y != 418.0 {
t.Errorf("slot 4 y: got %v, want 418.0", y)
}
}
// sizeOfSubtree — bounding-box computation for org-import layout.
func TestSizeOfSubtree_Leaf(t *testing.T) {
ws := OrgWorkspace{Name: "leaf"}
s := sizeOfSubtree(ws)
// Leaf → childDefaultWidth × childDefaultHeight
if s.width != 240.0 {
t.Errorf("leaf width: got %v, want 240.0", s.width)
}
if s.height != 130.0 {
t.Errorf("leaf height: got %v, want 130.0", s.height)
}
}
func TestSizeOfSubtree_OneChild(t *testing.T) {
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{{Name: "child"}}}
s := sizeOfSubtree(ws)
// 1 child → cols=1, rows=1
// child subtree = (240, 130)
// width = 16*2 + 240*1 + 14*0 = 272
// height = 130 + 130 + 14*0 + 16 = 276
if s.width != 272.0 {
t.Errorf("1-child width: got %v, want 272.0", s.width)
}
if s.height != 276.0 {
t.Errorf("1-child height: got %v, want 276.0", s.height)
}
}
func TestSizeOfSubtree_TwoChildren(t *testing.T) {
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
{Name: "c0"}, {Name: "c1"},
}}
s := sizeOfSubtree(ws)
// 2 children → cols=2, rows=1
// maxColW = 240, totalRowH = 130
// width = 16*2 + 240*2 + 14*1 = 32+480+14 = 526
// height = 130 + 130 + 14*0 + 16 = 276
if s.width != 526.0 {
t.Errorf("2-child width: got %v, want 526.0", s.width)
}
if s.height != 276.0 {
t.Errorf("2-child height: got %v, want 276.0", s.height)
}
}
func TestSizeOfSubtree_ThreeChildren(t *testing.T) {
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
{Name: "c0"}, {Name: "c1"}, {Name: "c2"},
}}
s := sizeOfSubtree(ws)
// 3 children → cols=2 (< 3 so capped at 2), rows=2
// each child = (240, 130), maxColW=240, rowHeights=[130,130]
// totalRowH = 130+130 = 260
// width = 16*2 + 240*2 + 14*1 = 526
// height = 130 + 260 + 14*1 + 16 = 420
if s.width != 526.0 {
t.Errorf("3-child width: got %v, want 526.0", s.width)
}
if s.height != 420.0 {
t.Errorf("3-child height: got %v, want 420.0", s.height)
}
}
func TestSizeOfSubtree_FourChildren(t *testing.T) {
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
{Name: "c0"}, {Name: "c1"}, {Name: "c2"}, {Name: "c3"},
}}
s := sizeOfSubtree(ws)
// 4 children → cols=2, rows=2
// width = 16*2 + 240*2 + 14*1 = 526
// height = 130 + 260 + 14*1 + 16 = 420
if s.width != 526.0 {
t.Errorf("4-child width: got %v, want 526.0", s.width)
}
if s.height != 420.0 {
t.Errorf("4-child height: got %v, want %v", s.height, 420.0)
}
}
func TestSizeOfSubtree_FiveChildren(t *testing.T) {
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
{Name: "c0"}, {Name: "c1"}, {Name: "c2"}, {Name: "c3"}, {Name: "c4"},
}}
s := sizeOfSubtree(ws)
// 5 children → cols=2, rows=3
// rowHeights = [130, 130, 130], totalRowH = 390
// width = 16*2 + 240*2 + 14*1 = 526
// height = 130 + 390 + 14*2 + 16 = 564
if s.width != 526.0 {
t.Errorf("5-child width: got %v, want 526.0", s.width)
}
if s.height != 564.0 {
t.Errorf("5-child height: got %v, want 564.0", s.height)
}
}
func TestSizeOfSubtree_NestedTree(t *testing.T) {
// Grandparent → [Parent(→ child), leaf]
// parent subtree (1 child): width=272, height=276
// grandparent:
// children = [parent, leaf]
// maxColW = max(272, 240) = 272
// cols=2, rows=1
// width = 16*2 + 272*2 + 14*1 = 590
// height = 130 + max(276, 130) + 14*0 + 16 = 422
parent := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{{Name: "grandchild"}}}
ws := OrgWorkspace{Name: "grandparent", Children: []OrgWorkspace{parent, {Name: "leaf"}}}
s := sizeOfSubtree(ws)
if s.width != 590.0 {
t.Errorf("nested width: got %v, want 590.0", s.width)
}
if s.height != 422.0 {
t.Errorf("nested height: got %v, want 422.0", s.height)
}
}
// childSlotInGrid — sibling-aware slot computation; taller siblings push
// subsequent rows down without displacing the column grid.
func TestChildSlotInGrid_EmptySiblings(t *testing.T) {
x, y := childSlotInGrid(0, nil)
x2, y2 := childSlotInGrid(0, []nodeSize{})
// Both nil and empty slice return the top-left padded origin.
got1, got2 := struct{ x, y float64 }{x, y}, struct{ x, y float64 }{x2, y2}
for _, g := range []struct{ x, y float64 }{got1, got2} {
if g.x != 16.0 || g.y != 130.0 {
t.Errorf("empty siblings: got (%.0f, %.0f), want (16, 130)", g.x, g.y)
}
}
}
func TestChildSlotInGrid_Slot0MatchesDefaultChildSlot(t *testing.T) {
// With uniform 240×130 siblings, slot 0 should equal childSlot(0).
sizes := []nodeSize{{width: 240, height: 130}, {width: 240, height: 130}}
x, y := childSlotInGrid(0, sizes)
cx, cy := childSlot(0)
if x != cx || y != cy {
t.Errorf("uniform siblings slot 0: got (%.0f, %.0f), want childSlot (%.0f, %.0f)", x, y, cx, cy)
}
}
func TestChildSlotInGrid_Slot1MatchesDefaultChildSlot(t *testing.T) {
sizes := []nodeSize{{width: 240, height: 130}, {width: 240, height: 130}}
x, y := childSlotInGrid(1, sizes)
cx, cy := childSlot(1)
if x != cx || y != cy {
t.Errorf("uniform siblings slot 1: got (%.0f, %.0f), want childSlot (%.0f, %.0f)", x, y, cx, cy)
}
}
func TestChildSlotInGrid_TallerSiblingBumpsNextRow(t *testing.T) {
// Sibling at index 1 is taller (height=300 vs 130).
// Slot 0: col=0, row=0 → x=16, y=130
// Slot 1: col=1, row=0 → x=270, y=130
// Slot 2: col=0, row=1 → x=16, y = 130 + 300 + 14 = 444
sizes := []nodeSize{
{width: 240, height: 130},
{width: 240, height: 300}, // taller — pushes row 2 down
{width: 240, height: 130},
}
x0, y0 := childSlotInGrid(0, sizes)
if x0 != 16.0 || y0 != 130.0 {
t.Errorf("slot 0: got (%.0f, %.0f), want (16, 130)", x0, y0)
}
x1, y1 := childSlotInGrid(1, sizes)
if x1 != 270.0 || y1 != 130.0 {
t.Errorf("slot 1: got (%.0f, %.0f), want (270, 130)", x1, y1)
}
x2, y2 := childSlotInGrid(2, sizes)
// y = parentHeaderPadding + rowHeights[0] + childGutter
// rowHeights[0] = max(130, 300) = 300
// y = 130 + 300 + 14 = 444
if x2 != 16.0 || y2 != 444.0 {
t.Errorf("slot 2: got (%.0f, %.0f), want (16, 444) — taller sibling pushed row down", x2, y2)
}
}
func TestChildSlotInGrid_UniformWideSiblingSetsColumnWidth(t *testing.T) {
// Sibling at index 0 is wider (300 vs 240).
// Slot 0: x=16, y=130
// Slot 1: col=1 → x = 16 + 300 + 14 = 330 (NOT 270 = 16+240+14)
// y=130
sizes := []nodeSize{
{width: 300, height: 130}, // wider — sets column width
{width: 240, height: 130},
}
x1, y1 := childSlotInGrid(1, sizes)
if x1 != 330.0 || y1 != 130.0 {
t.Errorf("slot 1: got (%.0f, %.0f), want (330, 130) — col width set by wider sibling", x1, y1)
}
}
func TestChildSlotInGrid_Slot3OverflowToSecondRow(t *testing.T) {
// 4 siblings in 2-column grid → rows=2
// Slot 0: col=0, row=0
// Slot 1: col=1, row=0
// Slot 2: col=0, row=1
// Slot 3: col=1, row=1
sizes := []nodeSize{
{width: 240, height: 130},
{width: 240, height: 130},
{width: 240, height: 130},
{width: 240, height: 130},
}
x3, y3 := childSlotInGrid(3, sizes)
// y = 130 + 130 + 14 = 274
if x3 != 270.0 || y3 != 274.0 {
t.Errorf("slot 3: got (%.0f, %.0f), want (270, 274)", x3, y3)
}
}
func TestChildSlotInGrid_MixedSizesCorrectRowAccumulation(t *testing.T) {
// 3 siblings: [short(130), tall(300), medium(200)]
// cols=2, rows=2
// rowHeights[0] = max(130, 300) = 300
// rowHeights[1] = max(200, 0) = 200
// slot 0: col=0, row=0 → x=16, y=130
// slot 1: col=1, row=0 → x=330, y=130
// slot 2: col=0, row=1 → x=16, y=130+300+14=444
sizes := []nodeSize{
{width: 240, height: 130},
{width: 240, height: 300},
{width: 240, height: 200},
}
x2, y2 := childSlotInGrid(2, sizes)
if x2 != 16.0 || y2 != 444.0 {
t.Errorf("slot 2: got (%.0f, %.0f), want (16, 444)", x2, y2)
}
}
@@ -78,51 +78,6 @@ func TestResolveInsideRoot_RejectsPrefixSibling(t *testing.T) {
}
}
// TestResolveInsideRoot_RejectsSymlinkTraversal is a regression test for
// CWE-59 (symlink-based path traversal). An attacker plants a symlink inside
// the allowed directory that points outside; the function must reject it.
func TestResolveInsideRoot_RejectsSymlinkTraversal(t *testing.T) {
tmp := t.TempDir()
// Create a subdirectory inside root.
inner := filepath.Join(tmp, "workspaces", "dev")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Fatal(err)
}
// Plant a symlink that resolves outside root.
sym := filepath.Join(inner, "leaked")
if err := os.Symlink("/etc", sym); err != nil {
t.Fatal(err)
}
// Lexically, "workspaces/dev/leaked" is inside tmp — but after symlink
// resolution it points to /etc and must be rejected.
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "leaked")); err == nil {
t.Error("symlink pointing outside root must be rejected (CWE-59)")
}
// Symlink that stays inside root is fine.
safe := filepath.Join(inner, "safe")
if err := os.MkdirAll(filepath.Join(tmp, "other"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(tmp, "other"), safe); err != nil {
t.Fatal(err)
}
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "safe")); err != nil {
t.Errorf("symlink staying inside root must be allowed: %v", err)
}
// Broken symlink (target does not exist) must also be rejected — broken
// symlinks cannot be valid org files.
broken := filepath.Join(inner, "broken")
if err := os.Symlink("/nonexistent/broken", broken); err != nil {
t.Fatal(err)
}
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "broken")); err == nil {
t.Error("broken symlink must be rejected")
}
}
func TestResolveInsideRoot_DeepSubpath(t *testing.T) {
tmp := t.TempDir()
deep := filepath.Join(tmp, "a", "b", "c")
+33 -3
View File
@@ -354,9 +354,39 @@ func TestExpandWithEnv_UnsetVar(t *testing.T) {
}
}
// TestHasUnresolvedVarRef_* cases live in org_helpers_pure_test.go to keep
// pure-helper tests in their own file. Keep TestExpandWithEnv_UnsetVar here
// since expandWithEnv is used across multiple org handlers.
func TestHasUnresolvedVarRef_NoVars(t *testing.T) {
if hasUnresolvedVarRef("plain text", "plain text") {
t.Error("plain text should not be flagged")
}
}
func TestHasUnresolvedVarRef_LiteralDollar(t *testing.T) {
// "$5" is a literal price, not a var ref — should NOT be flagged
if hasUnresolvedVarRef("price: $5", "price: $5") {
t.Error("literal $5 should not be flagged as unresolved")
}
}
func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
// Original had ${VAR}, expanded to "value" — fully resolved
if hasUnresolvedVarRef("${VAR}", "value") {
t.Error("fully resolved var should not be flagged")
}
}
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
// Original had ${VAR}, expanded to "" — unresolved
if !hasUnresolvedVarRef("${VAR}", "") {
t.Error("unresolved var should be flagged")
}
}
func TestHasUnresolvedVarRef_DollarVarSyntax(t *testing.T) {
// $VAR syntax (no braces) — also a real ref
if !hasUnresolvedVarRef("$MISSING_VAR", "") {
t.Error("$VAR syntax should be detected as ref when unresolved")
}
}
func eqStringSlice(a, b []string) bool {
if len(a) != len(b) {
@@ -1,310 +0,0 @@
package handlers
// plugins_atomic_tar_test.go — unit tests for tarWalk (the only non-trivial
// function in plugins_atomic_tar.go). The file contains only pure tar-walk
// logic with no DB or HTTP dependencies, so tests use real temp directories
// with no mocking.
import (
"archive/tar"
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// ─── newTarWriter ─────────────────────────────────────────────────────────────
func TestNewTarWriter_Basic(t *testing.T) {
var buf bytes.Buffer
tw := newTarWriter(&buf)
if tw == nil {
t.Fatal("newTarWriter returned nil")
}
// Write a header to prove the writer is functional.
hdr := &tar.Header{
Name: "test.txt",
Mode: 0644,
Size: 5,
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader failed: %v", err)
}
if _, err := tw.Write([]byte("hello")); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
}
// ─── tarWalk: empty directory ─────────────────────────────────────────────────
func TestTarWalk_EmptyDir(t *testing.T) {
tmp := t.TempDir()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "prefix", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("tw.Close error: %v", err)
}
// An empty directory should still emit one header (the dir itself).
rdr := tar.NewReader(&buf)
hdr, err := rdr.Next()
if err != nil {
t.Fatalf("expected at least the dir header, got error: %v", err)
}
if !strings.HasSuffix(hdr.Name, "/") {
t.Errorf("expected directory name ending in '/', got %q", hdr.Name)
}
// No more entries.
if _, err := rdr.Next(); err != io.EOF {
t.Errorf("expected only one header, got more: %v", err)
}
}
// ─── tarWalk: single file ─────────────────────────────────────────────────────
func TestTarWalk_SingleFile(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "hello.txt"), []byte("world"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "mydir", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Should have 2 entries: the dir prefix, then hello.txt.
entries := 0
names := []string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected error reading tar: %v", err)
}
entries++
names = append(names, hdr.Name)
if hdr.Name == "mydir/hello.txt" {
if hdr.Size != 5 {
t.Errorf("expected size 5, got %d", hdr.Size)
}
content := make([]byte, 5)
if _, err := rdr.Read(content); err != nil && err != io.EOF {
t.Fatalf("read error: %v", err)
}
if string(content) != "world" {
t.Errorf("expected 'world', got %q", string(content))
}
}
}
if entries != 2 {
t.Errorf("expected 2 entries, got %d: %v", entries, names)
}
}
// ─── tarWalk: nested directories ───────────────────────────────────────────────
func TestTarWalk_NestedDirs(t *testing.T) {
tmp := t.TempDir()
subdir := filepath.Join(tmp, "a", "b", "c")
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "deep.txt"), []byte("nested"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "root", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Collect all file paths (not dirs) with content.
files := map[string]string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && hdr.Size > 0 {
content := make([]byte, hdr.Size)
rdr.Read(content)
files[hdr.Name] = string(content)
}
}
expected := "root/a/b/c/deep.txt"
if _, ok := files[expected]; !ok {
t.Errorf("expected file %q in tar; got: %v", expected, files)
} else if files[expected] != "nested" {
t.Errorf("expected content 'nested', got %q", files[expected])
}
}
// ─── tarWalk: symlinks are skipped ────────────────────────────────────────────
func TestTarWalk_SymlinksSkipped(t *testing.T) {
tmp := t.TempDir()
// Create a real file.
realPath := filepath.Join(tmp, "real.txt")
if err := os.WriteFile(realPath, []byte("real content"), 0644); err != nil {
t.Fatal(err)
}
// Create a symlink to it.
linkPath := filepath.Join(tmp, "link.txt")
if err := os.Symlink(realPath, linkPath); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "prefix", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Only real.txt should appear; link.txt should be absent.
names := []string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
names = append(names, hdr.Name)
}
foundLink := false
for _, n := range names {
if strings.Contains(n, "link") {
foundLink = true
}
}
if foundLink {
t.Errorf("symlink should be skipped; got names: %v", names)
}
}
// ─── tarWalk: prefix trailing slash is normalized ─────────────────────────────
func TestTarWalk_PrefixTrailingSlashNormalized(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "f.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
// Pass prefix WITH trailing slash — should produce same archive as without.
if err := tarWalk(tmp, "foo/", tw); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// The file should be under "foo/", not "foo//".
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "f.txt") {
if strings.Contains(hdr.Name, "//") {
t.Errorf("double slash found in path %q — trailing slash not normalized", hdr.Name)
}
if !strings.HasPrefix(hdr.Name, "foo/") {
t.Errorf("expected path to start with 'foo/', got %q", hdr.Name)
}
}
}
}
// ─── tarWalk: prefix = "." emits flat paths ───────────────────────────────────
func TestTarWalk_PrefixDotEmitsFlatPaths(t *testing.T) {
tmp := t.TempDir()
subdir := filepath.Join(tmp, "sub")
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "file.txt"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, ".", tw); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// With prefix ".", paths should NOT start with "./" (filepath.Clean normalizes it).
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "file.txt") {
if strings.HasPrefix(hdr.Name, "./") {
t.Errorf("prefix '.' should not emit './' prefix; got %q", hdr.Name)
}
}
}
}
// ─── tarWalk: walk error propagates ───────────────────────────────────────────
func TestTarWalk_NonexistentDir(t *testing.T) {
nonexistent := filepath.Join(t.TempDir(), "does-not-exist")
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
err := tarWalk(nonexistent, "x", tw)
if err == nil {
t.Error("expected error for nonexistent directory, got nil")
}
}
@@ -1,80 +0,0 @@
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
)
// supportsRuntime tests — plugin runtime compatibility checking.
func TestSupportsRuntime_EmptyRuntimes(t *testing.T) {
// Empty runtimes = unspecified, try it → always compatible.
info := pluginInfo{Name: "test", Runtimes: nil}
assert.True(t, info.supportsRuntime("claude_code"))
assert.True(t, info.supportsRuntime("any_runtime"))
}
func TestSupportsRuntime_ExactMatch(t *testing.T) {
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code", "anthropic"}}
assert.True(t, info.supportsRuntime("claude_code"))
assert.True(t, info.supportsRuntime("anthropic"))
}
func TestSupportsRuntime_NoMatch(t *testing.T) {
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
assert.False(t, info.supportsRuntime("openai"))
}
func TestSupportsRuntime_HyphenUnderscoreNormalized(t *testing.T) {
// "claude-code" and "claude_code" are considered equal.
info := pluginInfo{Name: "test", Runtimes: []string{"claude-code"}}
assert.True(t, info.supportsRuntime("claude_code"))
assert.True(t, info.supportsRuntime("anthropic_claude"))
}
func TestSupportsRuntime_HyphenVsUnderscoreReverse(t *testing.T) {
// Plugin declares underscore form; runtime uses hyphen.
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
assert.True(t, info.supportsRuntime("claude-code"))
}
func TestSupportsRuntime_EmptyStringRuntime(t *testing.T) {
info := pluginInfo{Name: "test", Runtimes: []string{"claude_code"}}
// Empty runtime string: should not match any plugin.
assert.False(t, info.supportsRuntime(""))
}
func TestSupportsRuntime_SingleRuntimeMatch(t *testing.T) {
// Multiple declared runtimes: only matching one is sufficient.
info := pluginInfo{Name: "test", Runtimes: []string{"python", "nodejs", "claude_code"}}
assert.True(t, info.supportsRuntime("claude_code"))
assert.False(t, info.supportsRuntime("ruby"))
}
func TestSupportsRuntime_AllHyphenForms(t *testing.T) {
// Both plugin and runtime use hyphen form.
info := pluginInfo{Name: "test", Runtimes: []string{"claude-code"}}
assert.True(t, info.supportsRuntime("claude-code"))
}
func TestSupportsRuntime_MultipleHyphenNormalization(t *testing.T) {
// Mixed hyphen/underscore forms normalize to the same.
info := pluginInfo{Name: "test", Runtimes: []string{"some-runtime-name"}}
assert.True(t, info.supportsRuntime("some_runtime_name"))
assert.True(t, info.supportsRuntime("some-runtime-name"))
}
func TestSupportsRuntime_EmptyPluginRuntimesWithAnyInput(t *testing.T) {
// Empty Runtimes on plugin = try it regardless of runtime.
info := pluginInfo{Name: "test", Runtimes: []string{}}
assert.True(t, info.supportsRuntime(""))
assert.True(t, info.supportsRuntime("any"))
assert.True(t, info.supportsRuntime("unknown"))
}
func TestSupportsRuntime_ZeroLengthRuntimes(t *testing.T) {
// Empty slice vs nil: both should be treated as "unspecified".
info := pluginInfo{Name: "test"}
assert.True(t, info.supportsRuntime("anything"))
}
@@ -24,9 +24,6 @@ import (
// - response is HTTP 200 (the endpoint always returns 200; failure is
// in the JSON body so callers don't need branch-on-status)
func TestHandleDiagnose_RoutesToRemote(t *testing.T) {
if _, err := exec.LookPath("ssh-keygen"); err != nil {
t.Skip("ssh-keygen not in PATH")
}
mock := setupTestDB(t)
setupTestRedis(t)
@@ -170,9 +167,6 @@ func TestHandleDiagnose_KI005_RejectsCrossWorkspace(t *testing.T) {
// to differentiate "IAM broke" (send-key fails) from "sshd broke" (probe
// fails) from "SG/network broke" (wait-for-port fails).
func TestDiagnoseRemote_StopsAtSSHProbe(t *testing.T) {
if _, err := exec.LookPath("ssh-keygen"); err != nil {
t.Skip("ssh-keygen not in PATH")
}
mock := setupTestDB(t)
setupTestRedis(t)
@@ -8,7 +8,6 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -286,51 +285,17 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "delivery_mode must be 'push' or 'poll'"})
return
}
// Insert workspace with runtime + delivery_mode persisted in DB (inside transaction).
//
// Auto-suffix on (parent_id, name) collision via insertWorkspaceWithNameRetry:
// the partial-unique index `workspaces_parent_name_uniq` (migration
// 20260506000000) protects /org/import from TOCTOU duplicates, but the
// pre-fix Canvas Create path bubbled the raw pq violation as a 500 on
// double-click. Helper retries with " (2)", " (3)", … up to maxNameSuffix,
// returns the actually-persisted name (which we MUST thread back into
// payload + broadcast so the canvas displays what the DB has).
const insertWorkspaceSQL = `
// Insert workspace with runtime + delivery_mode persisted in DB (inside transaction)
_, err := tx.ExecContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12)
`
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
persistedName, currentTx, err := insertWorkspaceWithNameRetry(
ctx,
tx,
// Closure captures ctx so the retry tx uses the same request context;
// nil opts mirrors the original BeginTx call above.
func(ctx context.Context) (*sql.Tx, error) { return db.DB.BeginTx(ctx, nil) },
payload.Name,
1, // args[1] is name
insertWorkspaceSQL,
insertArgs,
)
`, id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode)
if err != nil {
if currentTx != nil {
currentTx.Rollback() //nolint:errcheck
}
if errors.Is(err, errWorkspaceNameExhausted) {
log.Printf("Create workspace: name suffix exhausted for base %q under parent %v", payload.Name, payload.ParentID)
c.JSON(http.StatusConflict, gin.H{"error": "workspace name already in use; please pick a different name"})
return
}
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"})
return
}
// Helper may have rolled back the original tx and returned a fresh one;
// rebind so the remaining secrets-INSERT + Commit run on the live tx.
tx = currentTx
if persistedName != payload.Name {
log.Printf("Create workspace %s: name collision auto-suffix %q -> %q", id, payload.Name, persistedName)
payload.Name = persistedName
}
// Persist initial secrets from the create payload (inside same transaction).
// nil/empty map is a no-op. Any failure rolls back the workspace insert
@@ -1,183 +0,0 @@
package handlers
// workspace_create_name.go — disambiguate workspace names on the
// Canvas POST /workspaces path so a double-clicked template card
// does not surface raw Postgres errors.
//
// Background (#2872 + post-2026-05-06 follow-up):
// - Migration 20260506000000_workspaces_unique_parent_name added a
// partial UNIQUE index on (COALESCE(parent_id, sentinel), name)
// WHERE status != 'removed'. It exists to close the TOCTOU race in
// /org/import that previously let two concurrent POSTs both INSERT
// the same (parent_id, name) row.
// - /org/import handles the constraint via `ON CONFLICT DO NOTHING`
// + idempotent re-select (handlers/org_import.go).
// - The Canvas Create handler (handlers/workspace.go) did NOT — a
// duplicate POST returned an opaque HTTP 500 with the raw pq error
// in the server log. Repro path: user clicks a template card twice
// in canvas before the first response paints.
//
// Resolution: auto-suffix the user-typed name on collision. The
// uniqueness constraint required for #2872 stays in place; only the
// Canvas Create path's reaction to it changes. Names become a
// free-form display label that the platform disambiguates; row
// identity is carried by the workspace id (UUID).
//
// Suffix shape: " (2)", " (3)", … up to N=maxNameSuffix. Chosen over
// numeric "-2" / "_2" because the parenthesised form is the standard
// disambiguation pattern users already expect from Finder / Explorer
// / Google Docs / file managers. Stays under the 255-char name cap
// (#688 — validated by validateWorkspaceFields) for any reasonable
// base name; parens are not in yamlSpecialChars so the existing YAML-
// safety guard is unaffected.
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/lib/pq"
)
// maxNameSuffix bounds the suffix-retry loop. 20 is well above any
// plausible accidental-double-click rate (typical: 2-3 races) and
// keeps the worst-case handler latency to ~20 round-trips. If a
// caller actually wants 21+ workspaces with the same base name, they
// can pre-disambiguate client-side; the platform refuses to spin
// indefinitely.
const maxNameSuffix = 20
// workspacesUniqueIndexName is the partial-unique index this handler
// is reacting to. Pinned to the migration's index name so we
// distinguish "the base name collision we know how to handle" from
// every other unique violation (which we surface as 409 without
// retry — silently auto-suffixing a name on the wrong constraint
// would mask real bugs).
const workspacesUniqueIndexName = "workspaces_parent_name_uniq"
// errWorkspaceNameExhausted is returned when maxNameSuffix retries
// all fail because every candidate name in the (base, " (2)", …,
// " (N)") sequence is taken. The caller maps this to HTTP 409
// Conflict — the user must rename and re-try.
var errWorkspaceNameExhausted = errors.New("workspace name exhausted: too many duplicates of base name under same parent")
// dbExec is the minimum surface our retry helper needs from
// *sql.Tx (or *sql.DB). Declared as an interface so tests can
// substitute a fake without standing up a real DB connection.
type dbExec interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// insertWorkspaceWithNameRetry runs the workspace INSERT and, if it
// hits the parent-name unique-violation, retries with a suffixed
// name. Returns the name actually persisted (which the caller MUST
// use in the response and in broadcast payloads — without it the
// canvas would show the user-typed name while the DB has the
// suffixed one, and the next poll would surprise the user with the
// "real" name).
//
// The query string is intentionally a parameter (not hardcoded) so
// the helper composes with future schema additions without growing
// a new arity each time. Only the FIRST arg of args must be the
// name placeholder ($1) — the helper rewrites args[0] on retry; all
// other args pass through verbatim. (This matches the workspace.go
// INSERT below where $1 is the id and $2 is name, so the caller
// passes nameArgIndex=1.)
//
// On the unique-violation, the original tx is rolled back and a
// fresh one is begun before retry — Postgres marks the tx aborted
// on any error, so re-using it would silently no-op every
// subsequent statement.
//
// `beginTx` is a closure (not a *sql.DB) so the caller controls the
// transaction-options + the context. Returning the fresh tx each
// retry means the caller can commit it once the helper succeeds.
//
// `query` MUST be parameterized — the name placeholder is rewritten
// via args[nameArgIndex], not via string substitution. Passing a
// fmt.Sprintf'd query string would silently disable the safety.
func insertWorkspaceWithNameRetry(
ctx context.Context,
tx *sql.Tx,
beginTx func(ctx context.Context) (*sql.Tx, error),
baseName string,
nameArgIndex int,
query string,
args []any,
) (finalName string, finalTx *sql.Tx, err error) {
if nameArgIndex < 0 || nameArgIndex >= len(args) {
return "", tx, fmt.Errorf("insertWorkspaceWithNameRetry: nameArgIndex %d out of range for %d args", nameArgIndex, len(args))
}
current := tx
for attempt := 0; attempt <= maxNameSuffix; attempt++ {
candidate := baseName
if attempt > 0 {
candidate = fmt.Sprintf("%s (%d)", baseName, attempt+1)
}
args[nameArgIndex] = candidate
_, execErr := current.ExecContext(ctx, query, args...)
if execErr == nil {
return candidate, current, nil
}
if !isParentNameUniqueViolation(execErr) {
// Any other error (encoding, connection, FK violation,
// other unique index) — return as-is. Caller decides
// status code.
return "", current, execErr
}
// Hit the partial-unique index. Postgres has aborted this
// tx — roll it back and start fresh before retrying with a
// new candidate name.
_ = current.Rollback()
if attempt == maxNameSuffix {
break
}
next, txErr := beginTx(ctx)
if txErr != nil {
return "", nil, fmt.Errorf("begin retry tx after name collision: %w", txErr)
}
current = next
}
// Exhausted: the helper rolled back the last tx already. Return
// nil tx so the caller does not try to commit/rollback again.
return "", nil, errWorkspaceNameExhausted
}
// isParentNameUniqueViolation reports whether err is the specific
// partial-unique-index violation we know how to auto-suffix. We pin
// on BOTH the SQLSTATE 23505 (unique_violation) AND the constraint
// name so we don't silently rename around an unrelated unique index
// (e.g. a future workspaces.slug unique).
//
// errors.As is used (not a `.(*pq.Error)` type assertion) because
// lib/pq wraps the error through fmt.Errorf in some paths.
//
// Defensive fallback: if Constraint is empty (older pq builds, or
// the error came through a wrapper that dropped the field), match
// on the error message as well. The message form is brittle
// (postgres locale-dependent) but every English-locale Postgres
// emits the index name verbatim.
func isParentNameUniqueViolation(err error) bool {
if err == nil {
return false
}
var pqErr *pq.Error
if errors.As(err, &pqErr) {
if pqErr.Code != "23505" {
return false
}
if pqErr.Constraint == workspacesUniqueIndexName {
return true
}
// Fallback for builds that drop Constraint metadata.
return strings.Contains(pqErr.Message, workspacesUniqueIndexName)
}
// Last-resort string match — the pq.Error type was lost
// through wrapping. Same English-locale caveat as above; keeps
// the helper robust in test seams that synthesize errors via
// fmt.Errorf("pq: …").
return strings.Contains(err.Error(), workspacesUniqueIndexName)
}
@@ -1,251 +0,0 @@
//go:build integration
// +build integration
// workspace_create_name_integration_test.go — REAL Postgres
// integration test for the duplicate-name auto-suffix retry
// helper.
//
// Run with:
//
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
// go test -tags=integration ./internal/handlers/ -run Integration_WorkspaceCreate_NameRetry -v
//
// CI: piggybacks on .github/workflows/handlers-postgres-integration.yml
// (path-filter includes workspace-server/internal/handlers/**, which
// covers this file).
//
// Why this is NOT a sqlmock test
// ------------------------------
// sqlmock CANNOT verify the actual partial-unique-index
// behaviour. The unit tests in workspace_create_name_test.go pin
// the helper's retry contract under a fake driver error, but only
// a real Postgres can confirm:
//
// - The migration 20260506000000 actually created the index.
// - lib/pq emits SQLSTATE 23505 with Constraint =
// "workspaces_parent_name_uniq" (not a synonym, not the message
// fallback).
// - The COALESCE(parent_id, sentinel) target collapses NULL
// parent_ids so two root-level workspaces with the same name
// collide as the migration intends.
// - The WHERE status != 'removed' partial filter exempts
// tombstoned rows from blocking re-use.
//
// Per feedback_mandatory_local_e2e_before_ship: ship-mode requires
// the helper to be exercised against a real Postgres before the PR
// merges.
package handlers
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
// integrationDB_WorkspaceCreateName opens $INTEGRATION_DB_URL,
// applies the parent-name partial unique index if missing
// (idempotent), wipes the test row range, and returns the
// connection.
//
// We intentionally do NOT wipe every row in `workspaces` because
// the integration DB may be shared with other tests in this
// package; we tag inserts with a per-test UUID prefix and clean up
// only those.
func integrationDB_WorkspaceCreateName(t *testing.T) *sql.DB {
t.Helper()
url := os.Getenv("INTEGRATION_DB_URL")
if url == "" {
t.Skip("INTEGRATION_DB_URL not set; skipping (see file header)")
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { conn.Close() })
// Ensure the constraint we're testing exists. If the migration
// already ran (the dev/CI default), this is a fast no-op via
// IF NOT EXISTS. If the test DB was created from a snapshot
// taken before 2026-05-06, we apply it here.
if _, err := conn.ExecContext(context.Background(), `
CREATE UNIQUE INDEX IF NOT EXISTS workspaces_parent_name_uniq
ON workspaces (
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
)
WHERE status != 'removed'
`); err != nil {
t.Fatalf("ensure constraint: %v", err)
}
return conn
}
// cleanupTestRows removes any rows inserted under the given name
// prefix. Called via t.Cleanup so a failing test still leaves the
// DB usable for the next run.
func cleanupTestRows(t *testing.T, conn *sql.DB, namePrefix string) {
t.Helper()
if _, err := conn.ExecContext(context.Background(),
`DELETE FROM workspaces WHERE name LIKE $1`, namePrefix+"%"); err != nil {
t.Logf("cleanup (non-fatal): %v", err)
}
}
// TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision
// exercises the helper end-to-end against a real Postgres:
//
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
// 2. Run insertWorkspaceWithNameRetry with the same name —
// partial-unique violation fires, helper retries with
// " (2)", that succeeds.
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
// 4. Run helper AGAIN — second collision, helper retries with
// " (3)".
//
// This is the live-test that proves the partial-index behaviour
// matches the migration's intent — sqlmock cannot reach this depth.
func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testing.T) {
conn := integrationDB_WorkspaceCreateName(t)
ctx := context.Background()
// Per-test prefix so concurrent test runs don't collide on the
// shared integration DB; also tags rows for cleanupTestRows.
prefix := fmt.Sprintf("itest-namesuffix-%s", uuid.New().String()[:8])
t.Cleanup(func() { cleanupTestRows(t, conn, prefix) })
baseName := prefix + "-Repro"
// Step 1 — seed an existing row to collide against. Uses a
// minimal column set (the production INSERT has many more
// columns; we only need the ones the partial-unique index
// targets + the NOT NULL columns required by the schema).
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed first row: %v", err)
}
// Step 2 — same name, helper must auto-suffix to " (2)".
beginTx := func(ctx context.Context) (*sql.Tx, error) { return conn.BeginTx(ctx, nil) }
tx, err := beginTx(ctx)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
if err != nil {
t.Fatalf("retry helper on second insert: %v", err)
}
if persistedName != baseName+" (2)" {
t.Fatalf("persistedName = %q, want exactly %q", persistedName, baseName+" (2)")
}
if err := finalTx.Commit(); err != nil {
t.Fatalf("commit second: %v", err)
}
// Step 3 — verify DB state matches helper's return value.
var actualName string
if err := conn.QueryRowContext(ctx,
`SELECT name FROM workspaces WHERE id = $1`, secondID).Scan(&actualName); err != nil {
t.Fatalf("re-select second: %v", err)
}
if actualName != baseName+" (2)" {
t.Fatalf("DB row name = %q, want exactly %q (helper return value lied to caller)",
actualName, baseName+" (2)")
}
// Step 4 — third collision must produce " (3)".
tx3, err := beginTx(ctx)
if err != nil {
t.Fatalf("begin tx3: %v", err)
}
thirdID := uuid.New().String()
args3 := []any{thirdID, baseName, "workspace:" + thirdID}
persistedName3, finalTx3, err := insertWorkspaceWithNameRetry(
ctx, tx3, beginTx, baseName, 1, query, args3,
)
if err != nil {
t.Fatalf("retry helper on third insert: %v", err)
}
if persistedName3 != baseName+" (3)" {
t.Fatalf("third persistedName = %q, want exactly %q",
persistedName3, baseName+" (3)")
}
if err := finalTx3.Commit(); err != nil {
t.Fatalf("commit third: %v", err)
}
}
// TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide
// confirms the partial-index `WHERE status != 'removed'` predicate
// matches the helper's assumptions: a deleted (status='removed')
// workspace MUST NOT block re-creation under the same name.
//
// This is the post-2026-05-06 contract /org/import already relies
// on; the helper inherits it for the Canvas Create path. A
// regression in the migration's predicate would silently break
// both surfaces.
func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *testing.T) {
conn := integrationDB_WorkspaceCreateName(t)
ctx := context.Background()
prefix := fmt.Sprintf("itest-tombstone-%s", uuid.New().String()[:8])
t.Cleanup(func() { cleanupTestRows(t, conn, prefix) })
baseName := prefix + "-RevivedName"
// Seed a row, then tombstone it.
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'removed')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed tombstoned row: %v", err)
}
// New INSERT with the same name MUST succeed without any
// suffix — the partial index excludes the tombstoned row.
beginTx := func(ctx context.Context) (*sql.Tx, error) { return conn.BeginTx(ctx, nil) }
tx, err := beginTx(ctx)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
if err != nil {
t.Fatalf("retry helper after tombstone: %v", err)
}
if persistedName != baseName {
t.Fatalf("persistedName = %q, want %q (tombstoned row should NOT force a suffix)",
persistedName, baseName)
}
if err := finalTx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
}
@@ -1,302 +0,0 @@
package handlers
// workspace_create_name_test.go — unit + table tests for the
// duplicate-name auto-suffix retry helper.
//
// Phase 3 of the dev-SOP: write the test first, watch it fail in
// the way you predicted, then watch the fix make it pass. The fix
// landed in workspace_create_name.go; these tests pin its contract
// so a refactor that drops the retry (or auto-suffixes on the
// WRONG constraint) blows up loud.
//
// sqlmock CANNOT verify the real partial-index behaviour — that
// lives in the companion integration test
// workspace_create_name_integration_test.go (real Postgres).
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/lib/pq"
)
// fakePqUniqueViolation reproduces the SQLSTATE/Constraint shape
// the real lib/pq driver emits when an INSERT hits
// workspaces_parent_name_uniq. Used by the unit test to drive the
// retry path without standing up a real Postgres.
func fakePqUniqueViolation(constraint string) error {
return &pq.Error{
Code: "23505",
Constraint: constraint,
Message: fmt.Sprintf("duplicate key value violates unique constraint %q", constraint),
}
}
// TestIsParentNameUniqueViolation_PinsTheConstraint exhaustively
// pins which error shapes the helper considers "auto-suffix
// eligible." A regression that broadens this predicate (e.g.
// matching ANY 23505) would mask real bugs; a regression that
// narrows it (e.g. dropping the message fallback) would let the
// 500-on-double-click bug recur on driver builds that strip
// Constraint metadata.
func TestIsParentNameUniqueViolation_PinsTheConstraint(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"plain string error", errors.New("network down"), false},
{
name: "23505 on parent_name_uniq via pq.Error",
err: fakePqUniqueViolation("workspaces_parent_name_uniq"),
want: true,
},
{
name: "23505 on a DIFFERENT unique index — must NOT be auto-suffixed",
err: fakePqUniqueViolation("workspaces_slug_uniq"),
want: false,
},
{
name: "23505 with empty Constraint — fall back to message match",
err: &pq.Error{
Code: "23505",
Message: `duplicate key value violates unique constraint "workspaces_parent_name_uniq"`,
},
want: true,
},
{
name: "non-23505 (e.g. FK violation) on the same index name in message — must NOT match",
err: &pq.Error{
Code: "23503",
Message: `foreign key references workspaces_parent_name_uniq region`,
},
want: false,
},
{
name: "wrapped via fmt.Errorf (errors.As must unwrap)",
err: fmt.Errorf("create workspace: %w", fakePqUniqueViolation("workspaces_parent_name_uniq")),
want: true,
},
{
name: "raw string from a non-pq error mentioning the index — last-resort fallback",
err: errors.New(`pq: duplicate key value violates unique constraint "workspaces_parent_name_uniq"`),
want: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := isParentNameUniqueViolation(tc.err)
if got != tc.want {
t.Fatalf("isParentNameUniqueViolation(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// TestInsertWorkspaceWithNameRetry_FirstAttemptSucceeds confirms
// the helper does NOT modify the name when the first INSERT
// succeeds — a naive implementation that always wraps in a retry
// loop could accidentally add a " (1)" suffix even on the happy
// path.
func TestInsertWorkspaceWithNameRetry_FirstAttemptSucceeds(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace").
WillReturnResult(sqlmock.NewResult(0, 1))
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
name, finalTx, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if err != nil {
t.Fatalf("retry helper: %v", err)
}
if name != "MyWorkspace" {
t.Fatalf("name = %q, want %q (happy path must NOT suffix)", name, "MyWorkspace")
}
if finalTx == nil {
t.Fatalf("finalTx == nil; caller needs a live tx to commit")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestInsertWorkspaceWithNameRetry_SecondAttemptSuffixed confirms
// that on a single collision the helper retries with " (2)" and
// returns that as the persisted name. The dispatched-name suffix
// shape is part of the user-visible contract — if a future
// refactor switches to "-2" / "_2" / "MyWorkspace2", the canvas
// renders the wrong label until the next poll.
func TestInsertWorkspaceWithNameRetry_SecondAttemptSuffixed(t *testing.T) {
mock := setupTestDB(t)
// First begin (caller-owned), then first INSERT fails with the
// partial-unique violation, helper rolls back the tx, opens a
// fresh tx, and the second INSERT (with " (2)") succeeds.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace").
WillReturnError(fakePqUniqueViolation("workspaces_parent_name_uniq"))
mock.ExpectRollback()
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace (2)").
WillReturnResult(sqlmock.NewResult(0, 1))
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
name, finalTx, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if err != nil {
t.Fatalf("retry helper: %v", err)
}
// Exact-equality assertion (per feedback_assert_exact_not_substring):
// substring-match on "MyWorkspace" would also pass for the bug case
// where the helper accidentally returns "MyWorkspace (1)" or
// "MyWorkspace2".
if name != "MyWorkspace (2)" {
t.Fatalf("name = %q, want exactly %q", name, "MyWorkspace (2)")
}
if finalTx == nil {
t.Fatalf("finalTx == nil after successful retry")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestInsertWorkspaceWithNameRetry_NonRetryableErrorPassesThrough
// pins that we do NOT retry on errors we don't recognize. A
// connection drop, an FK violation, a check-constraint failure
// must propagate verbatim — the helper is NOT a generic
// SQL-retry wrapper.
func TestInsertWorkspaceWithNameRetry_NonRetryableErrorPassesThrough(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectBegin()
connErr := errors.New("connection reset by peer")
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("id-1", "MyWorkspace").
WillReturnError(connErr)
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
name, _, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if err == nil {
t.Fatalf("expected error, got nil (name=%q)", name)
}
if !errors.Is(err, connErr) && !strings.Contains(err.Error(), "connection reset") {
t.Fatalf("expected connection-reset to propagate, got %v", err)
}
if name != "" {
t.Fatalf("name = %q, want empty on failure", name)
}
}
// TestInsertWorkspaceWithNameRetry_ExhaustsAfterMaxSuffix pins the
// upper bound: after maxNameSuffix retries the helper returns
// errWorkspaceNameExhausted so the caller maps it to 409 Conflict
// rather than spinning indefinitely.
func TestInsertWorkspaceWithNameRetry_ExhaustsAfterMaxSuffix(t *testing.T) {
mock := setupTestDB(t)
// Every attempt collides. Expect maxNameSuffix+1 INSERTs (the
// initial + maxNameSuffix retries), each followed by a Rollback,
// and a Begin between rollbacks except the final terminal one.
mock.ExpectBegin()
for i := 0; i <= maxNameSuffix; i++ {
mock.ExpectExec("INSERT INTO workspaces").
WillReturnError(fakePqUniqueViolation("workspaces_parent_name_uniq"))
mock.ExpectRollback()
if i < maxNameSuffix {
mock.ExpectBegin()
}
}
tx, err := getDBHandle(t).BeginTx(context.Background(), nil)
if err != nil {
t.Fatalf("begin: %v", err)
}
_, finalTx, err := insertWorkspaceWithNameRetry(
context.Background(),
tx,
func(ctx context.Context) (*sql.Tx, error) {
return getDBHandle(t).BeginTx(ctx, nil)
},
"MyWorkspace",
1,
"INSERT INTO workspaces (id, name) VALUES ($1, $2)",
[]any{"id-1", "MyWorkspace"},
)
if !errors.Is(err, errWorkspaceNameExhausted) {
t.Fatalf("err = %v, want errWorkspaceNameExhausted", err)
}
if finalTx != nil {
t.Fatalf("finalTx must be nil on exhaustion (helper already rolled back); got %v", finalTx)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// getDBHandle exposes the package-level db.DB the test infrastructure
// stashes after setupTestDB. Kept as a helper so the test reads as
// the production code does ("BeginTx on the platform's DB") without
// the cross-package import noise.
func getDBHandle(t *testing.T) *sql.DB {
t.Helper()
// db.DB is the package-level handle; setupTestDB assigns it to
// the sqlmock-backed *sql.DB. Use this helper everywhere instead
// of dereferencing db.DB directly so a future move to a per-test
// container fixture has one rename surface.
return db.DB
}
@@ -1,165 +0,0 @@
package handlers
// workspace_crud_helpers_test.go — tests for pure-logic helpers in workspace_crud.go.
//
// Covered helpers:
// validateWorkspaceDir — bind-mount path safety (CWE-22 defence-in-depth)
import "testing"
// ─────────────────────────────────────────────────────────────────────────────
// validateWorkspaceDir
// ─────────────────────────────────────────────────────────────────────────────
func TestValidateWorkspaceDir_AcceptsValidAbsolutePath(t *testing.T) {
cases := []string{
"/home/ubuntu/workspace",
"/opt/myapp/data",
"/tmp/molecule-workspace",
"/Users/admin/workspace",
"/workspace",
"/mnt/volumes/data",
"/srv/molecule",
"/nix/store",
}
for _, dir := range cases {
err := validateWorkspaceDir(dir)
if err != nil {
t.Errorf("validateWorkspaceDir(%q) returned error: %v; want nil", dir, err)
}
}
}
func TestValidateWorkspaceDir_RejectsRelativePath(t *testing.T) {
cases := []string{
"relative/path",
"./local",
"../sibling",
"workspace",
"",
}
for _, dir := range cases {
err := validateWorkspaceDir(dir)
if err == nil {
t.Errorf("validateWorkspaceDir(%q) = nil; want error (relative path)", dir)
}
}
}
func TestValidateWorkspaceDir_RejectsTraversalSequence(t *testing.T) {
cases := []string{
"/etc/../../../etc/passwd",
"/home/user/../../root",
"/workspace/../../../sibling",
"/foo/bar/..%2f..%2fetc",
"/valid/../etc/passwd",
}
for _, dir := range cases {
err := validateWorkspaceDir(dir)
if err == nil {
t.Errorf("validateWorkspaceDir(%q) = nil; want error (traversal)", dir)
}
}
}
func TestValidateWorkspaceDir_RejectsSystemPaths(t *testing.T) {
// System paths must be rejected outright — a workspace binding /etc or
// /proc would let the agent read host secrets or inspect kernel state.
systemPaths := []string{
"/etc",
"/var",
"/proc",
"/sys",
"/dev",
"/boot",
"/sbin",
"/bin",
"/usr",
}
for _, dir := range systemPaths {
err := validateWorkspaceDir(dir)
if err == nil {
t.Errorf("validateWorkspaceDir(%q) = nil; want error (system path)", dir)
}
}
}
func TestValidateWorkspaceDir_RejectsDescendantsOfSystemPaths(t *testing.T) {
// A descendant of a system path must also be rejected — /etc/shadow,
// /proc/1/cmdline, /dev/null all fall in this category.
descendants := []string{
"/etc/passwd",
"/etc/shadow",
"/etc/ssh/sshd_config",
"/var/log/syslog",
"/proc/self/environ",
"/sys/kernel/version",
"/dev/null",
"/boot/grub/grub.cfg",
"/sbin/init",
"/bin/bash",
"/usr/bin/python3",
}
for _, dir := range descendants {
err := validateWorkspaceDir(dir)
if err == nil {
t.Errorf("validateWorkspaceDir(%q) = nil; want error (descendant of system path)", dir)
}
}
}
func TestValidateWorkspaceDir_AcceptsPathsSimilarToSystemPaths(t *testing.T) {
// Paths that LOOK like system paths but are NOT exact matches or
// descendants should be accepted. These are valid workspace directories.
valid := []string{
"/etcworkspace",
"/varworkspace",
"/procworkspace",
"/sysworkspace",
"/devworkspace",
"/bootworkspace",
"/sbinworkspace",
"/binworkspace",
"/usrworkspace",
"/etx", // typo of /etc but a different path
"/vartmp", // /var/tmp is different from /var
"/usrr", // typo of /usr but a different path
"/workspace/etc",
"/workspace/var",
"/home/user/etc",
"/opt/etc",
}
for _, dir := range valid {
err := validateWorkspaceDir(dir)
if err != nil {
t.Errorf("validateWorkspaceDir(%q) returned error: %v; want nil", dir, err)
}
}
}
func TestValidateWorkspaceDir_ErrorMessages(t *testing.T) {
// Error messages must be descriptive enough for operators to self-diagnose.
relErr := validateWorkspaceDir("relative")
if relErr == nil {
t.Fatal("relative path: want error, got nil")
}
if relErr.Error() == "" {
t.Error("relative path error message is empty")
}
travErr := validateWorkspaceDir("/etc/../../../etc/passwd")
if travErr == nil {
t.Fatal("traversal: want error, got nil")
}
if travErr.Error() == "" {
t.Error("traversal error message is empty")
}
sysErr := validateWorkspaceDir("/etc")
if sysErr == nil {
t.Fatal("system path: want error, got nil")
}
if sysErr.Error() == "" {
t.Error("system path error message is empty")
}
}
@@ -1,263 +0,0 @@
package handlers
import (
"testing"
)
// ── validateWorkspaceID ─────────────────────────────────────────────────────────
func TestValidateWorkspaceID_Valid(t *testing.T) {
cases := []string{
"550e8400-e29b-41d4-a716-446655440000",
"00000000-0000-0000-0000-000000000000",
"ffffffff-ffff-ffff-ffff-ffffffffffff",
}
for _, id := range cases {
t.Run(id, func(t *testing.T) {
if err := validateWorkspaceID(id); err != nil {
t.Errorf("validateWorkspaceID(%q) returned error: %v", id, err)
}
})
}
}
func TestValidateWorkspaceID_Invalid(t *testing.T) {
cases := []struct {
name string
id string
}{
{"empty", ""},
{"not a UUID", "not-a-uuid"},
{"traversal attack", "../../etc/passwd"},
{"SQL injection", "'; DROP TABLE workspaces;--"},
{"UUID too short", "550e8400-e29b-41d4-a716"},
{"UUID with invalid hex chars", "550e8400-e29b-41d4-a716-44665544000g"},
{"UUID all zeros", "00000000000000000000000000000000"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateWorkspaceID(tc.id); err == nil {
t.Errorf("validateWorkspaceID(%q): expected error, got nil", tc.id)
}
})
}
}
// ── validateWorkspaceDir ───────────────────────────────────────────────────────
func TestValidateWorkspaceDir_Valid(t *testing.T) {
cases := []string{
"/opt/molecule/workspaces/dev",
"/home/user/.molecule/workspaces",
"/var/data/workspace-abc-123",
"/opt/services/molecule/tenant-workspaces",
}
for _, dir := range cases {
t.Run(dir, func(t *testing.T) {
if err := validateWorkspaceDir(dir); err != nil {
t.Errorf("validateWorkspaceDir(%q) returned error: %v", dir, err)
}
})
}
}
func TestValidateWorkspaceDir_RelativeRejected(t *testing.T) {
cases := []string{
"relative/path",
"./myworkspace",
"~/workspaces/dev",
}
for _, dir := range cases {
t.Run(dir, func(t *testing.T) {
if err := validateWorkspaceDir(dir); err == nil {
t.Errorf("validateWorkspaceDir(%q): expected error (relative path), got nil", dir)
}
})
}
}
func TestValidateWorkspaceDir_TraversalRejected(t *testing.T) {
cases := []string{
"/opt/molecule/../../../etc",
"/workspaces/dev/../../root",
"/opt/../opt/../etc",
}
for _, dir := range cases {
t.Run(dir, func(t *testing.T) {
if err := validateWorkspaceDir(dir); err == nil {
t.Errorf("validateWorkspaceDir(%q): expected error (traversal), got nil", dir)
}
})
}
}
func TestValidateWorkspaceDir_SystemPathsRejected(t *testing.T) {
cases := []string{
"/etc",
"/etc/molecule",
"/var",
"/var/log",
"/proc",
"/proc/self",
"/sys",
"/sys/kernel",
"/dev",
"/dev/null",
"/boot",
"/sbin",
"/bin",
"/lib",
"/usr",
"/usr/local",
}
for _, dir := range cases {
t.Run(dir, func(t *testing.T) {
if err := validateWorkspaceDir(dir); err == nil {
t.Errorf("validateWorkspaceDir(%q): expected error (system path), got nil", dir)
}
})
}
}
func TestValidateWorkspaceDir_PrefixMatchesBlocked(t *testing.T) {
// The blocklist checks prefix so /etc/foo must also be rejected.
cases := []string{
"/etc/molecule-config",
"/var/log/workspace",
"/usr/local/bin",
"/usr/bin/molecule",
}
for _, dir := range cases {
t.Run(dir, func(t *testing.T) {
if err := validateWorkspaceDir(dir); err == nil {
t.Errorf("validateWorkspaceDir(%q): expected error (prefix of blocked path), got nil", dir)
}
})
}
}
// ── validateWorkspaceFields ────────────────────────────────────────────────────
func TestValidateWorkspaceFields_AllEmpty(t *testing.T) {
// All empty → valid (creation uses defaults; empty is allowed)
if err := validateWorkspaceFields("", "", "", ""); err != nil {
t.Errorf("validateWorkspaceFields with all empty: expected nil, got %v", err)
}
}
func TestValidateWorkspaceFields_Valid(t *testing.T) {
if err := validateWorkspaceFields("My Workspace", "Backend Engineer", "gpt-4o", "langgraph"); err != nil {
t.Errorf("validateWorkspaceFields with valid args: expected nil, got %v", err)
}
}
func TestValidateWorkspaceFields_NameTooLong(t *testing.T) {
longName := make([]byte, 256)
for i := range longName {
longName[i] = 'a'
}
if err := validateWorkspaceFields(string(longName), "", "", ""); err == nil {
t.Error("name > 255 chars: expected error, got nil")
}
// Exactly 255 chars is OK
validName := make([]byte, 255)
for i := range validName {
validName[i] = 'a'
}
if err := validateWorkspaceFields(string(validName), "", "", ""); err != nil {
t.Errorf("name exactly 255 chars: expected nil, got %v", err)
}
}
func TestValidateWorkspaceFields_RoleTooLong(t *testing.T) {
longRole := make([]byte, 1001)
for i := range longRole {
longRole[i] = 'x'
}
if err := validateWorkspaceFields("", string(longRole), "", ""); err == nil {
t.Error("role > 1000 chars: expected error, got nil")
}
}
func TestValidateWorkspaceFields_ModelTooLong(t *testing.T) {
longModel := make([]byte, 101)
for i := range longModel {
longModel[i] = 'x'
}
if err := validateWorkspaceFields("", "", string(longModel), ""); err == nil {
t.Error("model > 100 chars: expected error, got nil")
}
}
func TestValidateWorkspaceFields_RuntimeTooLong(t *testing.T) {
longRuntime := make([]byte, 101)
for i := range longRuntime {
longRuntime[i] = 'x'
}
if err := validateWorkspaceFields("", "", "", string(longRuntime)); err == nil {
t.Error("runtime > 100 chars: expected error, got nil")
}
}
func TestValidateWorkspaceFields_NewlineInName(t *testing.T) {
if err := validateWorkspaceFields("My\nWorkspace", "", "", ""); err == nil {
t.Error("name with \\n: expected error, got nil")
}
}
func TestValidateWorkspaceFields_CRLFInRole(t *testing.T) {
if err := validateWorkspaceFields("", "Backend\r\nEngineer", "", ""); err == nil {
t.Error("role with \\r\\n: expected error, got nil")
}
}
func TestValidateWorkspaceFields_NewlineInModel(t *testing.T) {
if err := validateWorkspaceFields("", "", "gpt-\n4o", ""); err == nil {
t.Error("model with \\n: expected error, got nil")
}
}
func TestValidateWorkspaceFields_NewlineInRuntime(t *testing.T) {
if err := validateWorkspaceFields("", "", "", "lang\rgraph"); err == nil {
t.Error("runtime with \\r: expected error, got nil")
}
}
func TestValidateWorkspaceFields_YAMLSpecialChars(t *testing.T) {
// yamlSpecialChars = "{}[]|>*&!"
// These must be rejected in name and role.
dangerous := []string{
"Workspace{evil}",
"Workspace[evil]",
"Workspace]evil[",
"Workspace|evil",
"Workspace>evil",
"Workspace*evil",
"Workspace&evil",
"Workspace!evil",
"Name{}",
"Role[]",
}
for _, v := range dangerous {
t.Run(v, func(t *testing.T) {
if err := validateWorkspaceFields(v, "", "", ""); err == nil {
t.Errorf("name %q: expected error (YAML special char), got nil", v)
}
})
}
}
func TestValidateWorkspaceFields_YAMLCharsAllowedInModelRuntime(t *testing.T) {
// YAML special chars are only blocked in name/role, not model/runtime.
if err := validateWorkspaceFields("", "", "model{}[]", "runtime*&!"); err != nil {
t.Errorf("model/runtime with YAML chars: expected nil, got %v", err)
}
}
func TestValidateWorkspaceFields_YAMLCharsAllowedInEmptyName(t *testing.T) {
// Empty name is fine; YAML char restriction is only on non-empty values.
if err := validateWorkspaceFields("", "Backend Engineer", "", ""); err != nil {
t.Errorf("empty name with valid role: expected nil, got %v", err)
}
}
@@ -109,14 +109,13 @@ type LocalBuildOptions struct {
// http.DefaultClient with a 30s timeout.
HTTPClient *http.Client
// remoteHeadSha + dockerBuild + gitClone + checkShellDeps are seams for
// tests; if nil, the production implementations are used.
remoteHeadSha func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error)
gitClone func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error
dockerBuild func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error
dockerHasTag func(ctx context.Context, tag string) (bool, error)
dockerTag func(ctx context.Context, src, dst string) error
checkShellDeps func() error // nil = use checkShellDepsProd
// remoteHeadSha + dockerBuild + gitClone are seams for tests; if
// nil, the production implementations are used.
remoteHeadSha func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error)
gitClone func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error
dockerBuild func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error
dockerHasTag func(ctx context.Context, tag string) (bool, error)
dockerTag func(ctx context.Context, src, dst string) error
}
func newDefaultLocalBuildOptions() *LocalBuildOptions {
@@ -188,18 +187,6 @@ func ensureLocalImageWithOpts(ctx context.Context, runtime string, opts *LocalBu
return "", fmt.Errorf("local-build: refusing to build unknown runtime %q (must be one of %v)", runtime, knownRuntimes)
}
// Fail-fast: local-build mode requires docker and git on PATH. The
// error from exec.Command is cryptic ("exec: \"docker\": executable
// file not found in $PATH"); a pre-flight check surfaces the same
// failure with an actionable message and a pointer to the fix.
checkFn := opts.checkShellDeps
if checkFn == nil {
checkFn = checkShellDepsProd
}
if err := checkFn(); err != nil {
return "", err
}
lock := runtimeBuildLock(runtime)
lock.Lock()
defer lock.Unlock()
@@ -418,28 +405,6 @@ func giteaBranchAPIURL(repoPrefix, runtime, branch string) (string, error) {
return apiURL.String(), nil
}
// checkShellDepsProd verifies that both `docker` and `git` binaries are
// reachable via PATH. This runs before any exec.Command call so a missing
// binary surfaces as an actionable error rather than a cryptic exec-not-found
// from deep inside the clone/build pipeline.
func checkShellDepsProd() error {
missing := []string{}
for _, bin := range []string{"docker", "git"} {
if _, err := exec.LookPath(bin); err != nil {
missing = append(missing, bin)
}
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf(
"local-build mode requires `docker` and `git` on PATH in the platform container; "+
"missing: %s. "+
"Fix: either install both, OR set MOLECULE_IMAGE_REGISTRY so local-build is bypassed",
strings.Join(missing, ", "),
)
}
// parseGiteaBranchHeadSha extracts commit.id from the Gitea
// /branches/<name> response. We use a permissive substring scan so a
// missing-key in the JSON gives a clear error rather than the
@@ -14,8 +14,8 @@ import (
)
// makeTestOpts produces a LocalBuildOptions where every external seam
// (Gitea HEAD, git clone, docker build/has/tag, shell-dep pre-flight) is
// replaced by a stub. Tests override the stub for the behavior they want to assert.
// (Gitea HEAD, git clone, docker build/has/tag) is replaced by a stub.
// Tests override the stub for the behavior they want to assert.
func makeTestOpts(t *testing.T) *LocalBuildOptions {
t.Helper()
tmp := t.TempDir()
@@ -24,9 +24,6 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
RepoPrefix: "https://git.test/molecule-ai/molecule-ai-workspace-template-",
Platform: "linux/amd64",
HTTPClient: &http.Client{},
checkShellDeps: func() error {
return nil // tests bypass the real PATH check
},
remoteHeadSha: func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
return "abcdef0123456789abcdef0123456789abcdef01", nil
},
@@ -46,7 +43,6 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
dockerTag: func(ctx context.Context, src, dst string) error {
return nil
},
}
}
@@ -93,49 +89,6 @@ func TestEnsureLocalImage_CacheHit(t *testing.T) {
// TestEnsureLocalImage_UnknownRuntime — the allowlist guard rejects
// arbitrary runtime names before any network or filesystem call.
func TestEnsureLocalImage_MissingShellDeps(t *testing.T) {
opts := makeTestOpts(t)
opts.checkShellDeps = func() error {
return errors.New("local-build mode requires `docker` and `git` on PATH; missing: docker")
}
_, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "missing: docker") {
t.Errorf("error = %v, want one mentioning missing: docker", err)
}
}
// TestCheckShellDepsProd_AllPresent — when both docker and git are on
// PATH the check passes without error.
func TestCheckShellDepsProd_AllPresent(t *testing.T) {
// The test host must have docker+git; skip if not present so this test
// is portable.
t.SkipNow() // implementation: exec.LookPath is not stubbed in production.
_ = checkShellDepsProd // compile-time pin that the symbol exists.
}
// TestCheckShellDepsProd_ErrorMessage_Actionable — the error message must
// name every missing binary and point at the fix (MOLECULE_IMAGE_REGISTRY).
func TestCheckShellDepsProd_ErrorMessage_Actionable(t *testing.T) {
// We can't easily make LookPath fail in the test without patching the
// binary itself, so we test the error string shape directly.
err := fmt.Errorf(
"local-build mode requires `docker` and `git` on PATH in the platform container; "+
"missing: docker. "+
"Fix: either install both, OR set MOLECULE_IMAGE_REGISTRY so local-build is bypassed")
if !strings.Contains(err.Error(), "missing: docker") {
t.Errorf("error = %v, want missing: docker", err)
}
if !strings.Contains(err.Error(), "MOLECULE_IMAGE_REGISTRY") {
t.Errorf("error = %v, want MOLECULE_IMAGE_REGISTRY", err)
}
if !strings.Contains(err.Error(), "Fix: either install both") {
t.Errorf("error = %v, want actionable Fix: line", err)
}
}
func TestEnsureLocalImage_UnknownRuntime(t *testing.T) {
opts := makeTestOpts(t)
for _, bad := range []string{
@@ -674,41 +627,6 @@ func TestProvisionerStartUsesLocalBuild_LocalMode(t *testing.T) {
// caught by this test.
}
// TestEnsureLocalImage_Hooks checkShellDeps — when preflight fails,
func TestEnsureLocalImage_PreflightFailsIfDockerMissing(t *testing.T) {
opts := makeTestOpts(t)
opts.checkShellDeps = func() error {
return fmt.Errorf(
"local-build mode requires `docker` and `git` on PATH in the platform container; " +
"found: docker=<missing>, git=<missing>. " +
"Fix: either install both, OR set MOLECULE_IMAGE_REGISTRY so local-build mode is bypassed")
}
_, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err == nil {
t.Fatalf("expected preflight error, got nil")
}
if !strings.Contains(err.Error(), "local-build mode requires") {
t.Errorf("error = %v, want preflight failure message", err)
}
if !strings.Contains(err.Error(), "MOLECULE_IMAGE_REGISTRY") {
t.Errorf("error = %v, want recovery hint mentioning MOLECULE_IMAGE_REGISTRY", err)
}
}
// TestEnsureLocalImage_PreflightOKPassesThrough — when preflight returns
// nil, execution proceeds normally.
func TestEnsureLocalImage_PreflightOKPassesThrough(t *testing.T) {
opts := makeTestOpts(t)
opts.checkShellDeps = func() error { return nil }
tag, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(tag, "abcdef012345") {
t.Errorf("tag = %q, want sha in it", tag)
}
}
// TestEnsureLocalImageHook_DefaultIsRealFunction — pin that the
// production hook points at EnsureLocalImage. Tests that swap the hook
// must restore it via t.Cleanup; this test catches a leaked override.
+1 -3
View File
@@ -127,9 +127,7 @@ func (h *Hub) Close() {
count := len(h.clients)
for client := range h.clients {
close(client.Send)
if client.Conn != nil {
client.Conn.Close()
}
client.Conn.Close()
delete(h.clients, client)
}
log.Printf("WebSocket hub closed (%d clients disconnected)", count)
-386
View File
@@ -1,386 +0,0 @@
package ws
import (
"sync"
"testing"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
)
// ─── helpers ────────────────────────────────────────────────────────────────
// mockClient returns a Client with a buffered send channel of the given size
// and a nil WebSocket connection. Nil Conn is safe for our tests because we
// never call WritePump (which uses Conn) — we only test the hub's send channel
// and broadcast logic.
func mockClient(workspaceID string, bufSize int) *Client {
return &Client{
WorkspaceID: workspaceID,
Send: make(chan []byte, bufSize),
// Conn is nil — safe: WritePump (which uses Conn) is never called in tests.
}
}
// ─── NewHub ────────────────────────────────────────────────────────────────
func TestNewHub_NilChecker(t *testing.T) {
// nil AccessChecker is accepted (hub allows all workspace→workspace broadcasts
// when canCommunicate is unset — the gating is purely advisory).
h := NewHub(nil)
if h == nil {
t.Fatal("NewHub(nil) returned nil")
}
if h.canCommunicate != nil {
t.Error("canCommunicate should be nil")
}
}
func TestNewHub_AccessCheckerWired(t *testing.T) {
called := false
checker := func(callerID, targetID string) bool {
called = true
return callerID == targetID // only self-communication allowed
}
h := NewHub(checker)
if h.canCommunicate == nil {
t.Fatal("canCommunicate not wired")
}
// Invoke the wired function directly
allowed := h.canCommunicate("ws-1", "ws-1")
if !called {
t.Error("checker was not called")
}
if !allowed {
t.Error("self-communication should be allowed")
}
if h.canCommunicate("ws-1", "ws-2") {
t.Error("cross-workspace communication should be blocked by checker")
}
}
// ─── safeSend ─────────────────────────────────────────────────────────────
func TestSafeSend_OpenChannel_Sends(t *testing.T) {
c := mockClient("ws-1", 10)
data := []byte(`{"type":"ping"}`)
ok := safeSend(c, data)
if !ok {
t.Error("safeSend should return true for open channel")
}
select {
case got := <-c.Send:
if string(got) != string(data) {
t.Errorf("got %q, want %q", got, data)
}
case <-time.After(100 * time.Millisecond):
t.Error("no message received on channel")
}
}
func TestSafeSend_ClosedChannel_ReturnsFalse(t *testing.T) {
c := mockClient("ws-1", 10)
close(c.Send) // close before safeSend
ok := safeSend(c, []byte("data"))
if ok {
t.Error("safeSend should return false for closed channel")
}
}
func TestSafeSend_FullChannel_ReturnsFalse(t *testing.T) {
c := mockClient("ws-1", 1) // buffer size 1
// Fill the channel
c.Send <- []byte("first")
// Channel is now full
ok := safeSend(c, []byte("second"))
if ok {
t.Error("safeSend should return false when channel buffer is full")
}
// Drain to leave clean state
<-c.Send
}
// ─── Broadcast ────────────────────────────────────────────────────────────
func TestBroadcast_CanvasAlwaysReceives(t *testing.T) {
h := NewHub(nil) // nil checker: canvas always gets messages
// Canvas client (no workspaceID) + two workspace clients
canvas := mockClient("", 10)
ws1 := mockClient("ws-1", 10)
ws2 := mockClient("ws-2", 10)
// Manually register clients into hub state
h.mu.Lock()
h.clients[canvas] = true
h.clients[ws1] = true
h.clients[ws2] = true
h.mu.Unlock()
msg := models.WSMessage{Event: "test", Payload: []byte(`"hello"`)}
h.Broadcast(msg)
// Canvas must receive
select {
case got := <-canvas.Send:
t.Logf("canvas received: %s", got)
case <-time.After(100 * time.Millisecond):
t.Error("canvas client did not receive broadcast")
}
}
func TestBroadcast_WorkspaceCanCommunicateGating(t *testing.T) {
// Only ws-1 can receive messages for ws-2
checker := func(callerID, targetID string) bool {
return callerID == targetID
}
h := NewHub(checker)
ws1 := mockClient("ws-1", 10)
ws2 := mockClient("ws-2", 10)
canvas := mockClient("", 10)
h.mu.Lock()
h.clients[ws1] = true
h.clients[ws2] = true
h.clients[canvas] = true
h.mu.Unlock()
// Broadcast addressed to ws-2
msg := models.WSMessage{Event: "test", WorkspaceID: "ws-2"}
h.Broadcast(msg)
// ws-1 should NOT receive (not the target, checker says no)
select {
case <-ws1.Send:
t.Error("ws-1 should not receive broadcast for ws-2")
case <-time.After(50 * time.Millisecond):
t.Log("ws-1 correctly blocked — no message")
}
// ws-2 should receive
select {
case <-ws2.Send:
t.Log("ws-2 correctly received broadcast")
case <-time.After(100 * time.Millisecond):
t.Error("ws-2 did not receive broadcast")
}
// Canvas always receives
select {
case <-canvas.Send:
t.Log("canvas correctly received broadcast")
case <-time.After(100 * time.Millisecond):
t.Error("canvas did not receive broadcast")
}
}
func TestBroadcast_DropsOnClosedChannel(t *testing.T) {
h := NewHub(nil)
c := mockClient("", 10)
close(c.Send) // pre-close so safeSend returns false
h.mu.Lock()
h.clients[c] = true
h.mu.Unlock()
// Broadcast must not panic; closed client should be dropped silently.
msg := models.WSMessage{Event: "ping"}
h.Broadcast(msg) // should not panic
}
func TestBroadcast_DropsOnFullChannel(t *testing.T) {
h := NewHub(nil)
c := mockClient("", 1)
c.Send <- []byte("blocker") // fill buffer
h.mu.Lock()
h.clients[c] = true
h.mu.Unlock()
msg := models.WSMessage{Event: "ping"}
h.Broadcast(msg) // safeSend returns false; no panic
// Drain to leave clean state
<-c.Send
}
func TestBroadcast_EmptyHubNoPanic(t *testing.T) {
h := NewHub(nil)
msg := models.WSMessage{Event: "ping"}
h.Broadcast(msg) // must not panic with no clients
}
func TestBroadcast_MultiClient(t *testing.T) {
h := NewHub(nil)
clients := make([]*Client, 5)
h.mu.Lock()
for i := 0; i < 5; i++ {
clients[i] = mockClient("", 10)
h.clients[clients[i]] = true
}
h.mu.Unlock()
msg := models.WSMessage{Event: "multi", Payload: []byte(`"all receive"`)}
h.Broadcast(msg)
for i, c := range clients {
select {
case <-c.Send:
t.Logf("client %d received", i)
case <-time.After(100 * time.Millisecond):
t.Errorf("client %d did not receive broadcast", i)
}
}
}
func TestBroadcast_CanvasIgnoresChecker(t *testing.T) {
// Strict checker that blocks ALL cross-workspace (never returns true for different IDs)
strictChecker := func(callerID, targetID string) bool {
return callerID == targetID
}
h := NewHub(strictChecker)
canvas := mockClient("", 10)
h.mu.Lock()
h.clients[canvas] = true
h.mu.Unlock()
msg := models.WSMessage{Event: "ping", WorkspaceID: "ws-1"}
h.Broadcast(msg)
select {
case <-canvas.Send:
t.Log("canvas received message even though checker blocks ws-1")
case <-time.After(100 * time.Millisecond):
t.Error("canvas must always receive — checker should be bypassed")
}
}
// ─── Close ────────────────────────────────────────────────────────────────
func TestClose_DisconnectsAllClients(t *testing.T) {
h := NewHub(nil)
clients := make([]*Client, 3)
h.mu.Lock()
for i := 0; i < 3; i++ {
clients[i] = mockClient("", 10)
h.clients[clients[i]] = true
}
h.mu.Unlock()
// Start Run goroutine so Close can drain Unregister channel
go h.Run()
defer h.Close()
// Unregister all clients so the mutex is released before Close() tries to lock it
for _, c := range clients {
h.Unregister <- c
}
time.Sleep(50 * time.Millisecond)
// Now close — mutex is free, Close() should succeed
h.Close()
// All client channels should be closed
for i, c := range clients {
select {
case _, ok := <-c.Send:
if ok {
t.Errorf("client %d channel still open after Close", i)
}
case <-time.After(100 * time.Millisecond):
// Channel drained and closed
}
}
}
func TestClose_Idempotent(t *testing.T) {
h := NewHub(nil)
c := mockClient("", 10)
h.mu.Lock()
h.clients[c] = true
h.mu.Unlock()
// Close twice — must not panic or deadlock
h.Close()
h.Close() // second call also fine
}
func TestClose_ClosesDoneChannel(t *testing.T) {
h := NewHub(nil)
// Start Run goroutine
done := make(chan struct{})
go func() {
h.Run()
close(done)
}()
h.Close()
select {
case <-done:
t.Log("Run exited after Close")
case <-time.After(200 * time.Millisecond):
t.Error("Run did not exit after Close")
}
}
// ─── Run goroutine (Unregister) ──────────────────────────────────────────
func TestRun_UnregisterClosesClientSend(t *testing.T) {
h := NewHub(nil)
c := mockClient("ws-1", 10)
// Start Run() BEFORE sending to Register — Register is unbuffered,
// so Run() must be ready to receive before the send can complete.
go h.Run()
defer h.Close()
// Register the client
h.Register <- c
// Give Run a moment to register the client
time.Sleep(20 * time.Millisecond)
// Unregister client
h.Unregister <- c
select {
case _, ok := <-c.Send:
if ok {
t.Error("client send channel should be closed after Unregister")
}
case <-time.After(500 * time.Millisecond):
t.Error("client send channel not closed within timeout")
}
}
// ─── Concurrent access ────────────────────────────────────────────────────
func TestBroadcast_ConcurrentSafe(t *testing.T) {
h := NewHub(nil)
clients := make([]*Client, 10)
h.mu.Lock()
for i := 0; i < 10; i++ {
clients[i] = mockClient("", 100)
h.clients[clients[i]] = true
}
h.mu.Unlock()
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 20; j++ {
h.Broadcast(models.WSMessage{Event: "ping", Payload: []byte(`"concurrent"`)})
}
}(i)
}
wg.Wait() // should not deadlock or panic
}
+74 -87
View File
@@ -1,112 +1,99 @@
"""Sanitization helpers for A2A delegation results.
"""OFFSEC-003: A2A peer-result sanitization — shared across delegation tools.
OFFSEC-003: Peer text must not be able to escape trust boundaries by
injecting control markers that the caller interprets as structured framing.
This module is intentionally a LEAF (no imports from the molecule-runtime
package) to avoid circular dependency cycles. Both ``a2a_tools_delegation``
and ``a2a_tools`` can import from here without creating import loops.
This module is intentionally isolated from the rest of the molecule-runtime
import graph to avoid circular imports. Callers import only from here when
they need to sanitize a2a result text before returning it to the agent.
Trust-boundary design (OFFSEC-003):
A2A peer responses are untrusted third-party content. Before passing
them to the agent context, they MUST be wrapped in a trust-boundary
marker pair so the calling agent knows the content is external.
Boundary markers:
- _A2A_BOUNDARY_START = "[A2A_RESULT_FROM_PEER]"
- _A2A_BOUNDARY_END = "[/A2A_RESULT_FROM_PEER]"
The boundary is the PRIMARY security control. A peer that sends
"[A2A_RESULT_FROM_PEER]evil[/A2A_RESULT_FROM_PEER]safe" can make "safe"
appear inside the trusted context unless the markers themselves are
escaped before wrapping see _escape_boundary_markers() below.
Defense-in-depth (secondary):
Known prompt-injection control-words are also escaped so that even
if a calling agent ignores the boundary marker, embedded attack
patterns (SYSTEM:, OVERRIDE:, etc.) lose their special meaning.
This is not a complete injection sanitizer do not rely on it as
the primary control.
"""
from __future__ import annotations
import re
# ── Trust-boundary markers ────────────────────────────────────────────────────
# Sentinel strings used by a2a_tools_delegation.py as control prefixes.
_A2A_ERROR_PREFIX = "[A2A_ERROR] "
_A2A_QUEUED_PREFIX = "[A2A_QUEUED] "
_A2A_RESULT_FROM_PEER = "[A2A_RESULT_FROM_PEER]"
_A2A_RESULT_TO_PEER = "[A2A_RESULT_TO_PEER]"
_A2A_BOUNDARY_START = "[A2A_RESULT_FROM_PEER]"
_A2A_BOUNDARY_END = "[/A2A_RESULT_FROM_PEER]"
# Regex patterns for the lookahead. Each is a raw string where \[ = escaped
# '[' and \] = escaped ']'. The full pattern (separator + '[' + rest) is
# matched in two pieces:
# 1. (?=<marker>) — lookahead: matches the ENTIRE marker (including '[')
# at the current position without consuming any chars.
# 2. \[ — consumes the '[' so it gets replaced, not duplicated.
#
# Why the lookahead-first approach? If we match (^|\n)\[ first, the lookahead
# would fire at the *new* position (after the '['), not the original one, and
# would fail. By matching the lookahead first, we assert the marker is present
# at the correct token boundary, then consume the '[' separately.
_BOUNDARY_PATTERNS: list[tuple[str, str]] = [
(_A2A_ERROR_PREFIX, r"\[A2A_ERROR\] "),
(_A2A_QUEUED_PREFIX, r"\[A2A_QUEUED\] "),
(_A2A_RESULT_FROM_PEER, r"\[A2A_RESULT_FROM_PEER\]"),
(_A2A_RESULT_TO_PEER, r"\[A2A_RESULT_TO_PEER\]"),
]
_CONTROL_PATTERNS: list[tuple[str, str]] = [
(r"[SYSTEM]", r"\[SYSTEM\]"),
(r"[OVERRIDE]", r"\[OVERRIDE\]"),
(r"[INSTRUCTIONS]", r"\[INSTRUCTIONS\]"),
(r"[IGNORE ALL]", r"\[IGNORE ALL\]"),
(r"[YOU ARE NOW]", r"\[YOU ARE NOW\]"),
]
# ZERO-WIDTH SPACE (U+200B)
_ZWSP = ""
# ── Boundary-marker escaping ─────────────────────────────────────────────────
# A peer that sends "[/A2A_RESULT_FROM_PEER]evil" can make "evil" appear
# inside the trusted zone. Escape BOTH boundary markers in the raw text
# before wrapping so they can never close the boundary early.
# We use "[/ " as the escape prefix — visually distinct from the real marker.
def _escape_boundary_markers(text: str) -> str:
"""Escape trust-boundary markers embedded in raw peer text.
"""Escape boundary markers inside the raw peer text before wrapping.
Scans ``text`` for any known boundary-control pattern that appears as a
TOP-LEVEL token (start of string or after a newline) and inserts a
ZERO-WIDTH SPACE (U+200B) before the opening '[' so that downstream
parsers that look for the raw '[' no longer match the marker as a prefix.
Replaces any occurrence of the boundary start/end markers with a
visually-similar escaped form so a malicious peer can never close
the boundary early or inject a fake opener.
"""
if not text:
return ""
# Build alternation from the second (regex) element of each tuple.
marker_alts = "|".join(pat for _, pat in _BOUNDARY_PATTERNS + _CONTROL_PATTERNS)
# Pattern: (?=<marker>)\[ — lookahead for the FULL marker, then consume '['.
# This ensures the '[' is consumed so it gets replaced, not duplicated.
# We use regular string concatenation for (^|\n) so \n is 0x0A.
boundary_re = re.compile(
"(^|\n)(?=" + marker_alts + ")\\[",
flags=re.MULTILINE,
return (
text.replace(_A2A_BOUNDARY_START, "[/ A2A_RESULT_FROM_PEER]")
.replace(_A2A_BOUNDARY_END, "[/ /A2A_RESULT_FROM_PEER]")
)
def _replacer(m: re.Match[str]) -> str:
# m.group(1) = '' or '\n'; the '[' is consumed by the match
return m.group(1) + _ZWSP + "["
return boundary_re.sub(_replacer, text)
# ── Defense-in-depth: injection pattern escaping ───────────────────────────────
# These patterns cover common prompt-injection phrasings. They are NOT a
# complete sanitizer — see module docstring. The boundary marker is the
# primary control; these are purely defense-in-depth.
_INJECTION_PATTERNS = [
# Single-word patterns: anchor to word boundary so they don't match
# inside other words (e.g. "SYSTEM" in "mySYSTEMatic").
# Single-word patterns: anchor to word boundary so they don't match
# inside other words (e.g. "SYSTEM" in "mySYSTEMatic").
(re.compile(r"(^|[^\w])SYSTEM\b", re.IGNORECASE), r"\1[ESCAPED_SYSTEM]"),
(re.compile(r"(^|[^\w])OVERRIDE\b", re.IGNORECASE), r"\1[ESCAPED_OVERRIDE]"),
# "INSTRUCTIONS" may appear at the start of a string or after a newline.
(re.compile(r"(^|\n)INSTRUCTIONS?\b", re.IGNORECASE), " [ESCAPED_INSTRUCTIONS]"),
(re.compile(r"(^|[^\w])IGNORE\s+ALL\b", re.IGNORECASE), r"\1[ESCAPED_IGNORE_ALL]"),
(re.compile(r"(^|[^\w])YOU\s+ARE\s+NOW\b", re.IGNORECASE), r"\1[ESCAPED_YOU_ARE_NOW]"),
]
def sanitize_a2a_result(text: str) -> str:
"""Sanitize raw A2A delegation result text before returning to the caller."""
"""Sanitize and wrap untrusted text from an A2A peer (OFFSEC-003).
Order of operations:
1. Escape boundary markers in the raw text (prevents injection).
2. Escape known injection patterns (defense-in-depth).
3. Wrap in trust-boundary markers.
Returns the input unchanged if it is empty/None.
"""
if not text:
return ""
return text
text = _escape_boundary_markers(text)
text = _strip_closed_blocks(text)
return text
# 1. Escape boundary markers so a malicious peer cannot break the
# trust boundary from inside their response.
escaped = _escape_boundary_markers(text)
# 2. Escape known injection control-words (defense-in-depth only).
for pattern, replacement in _INJECTION_PATTERNS:
escaped = pattern.sub(replacement, escaped)
def _strip_closed_blocks(text: str) -> str:
"""Remove content after a closing marker injected by a malicious peer."""
CLOSERS = [
"[/A2A_ERROR]",
"[/A2A_QUEUED]",
"[/A2A_RESULT_FROM_PEER]",
"[/A2A_RESULT_TO_PEER]",
"[/SYSTEM]",
"[/OVERRIDE]",
"[/INSTRUCTIONS]",
"[/IGNORE ALL]",
"[/YOU ARE NOW]",
]
closer_re = "|".join(re.escape(c) for c in CLOSERS)
parts = re.split(
"(?<=\n)(?=" + closer_re + ")|(?=^)(?=" + closer_re + ")",
text, maxsplit=1, flags=re.MULTILINE,
)
# parts[0] may have a trailing \n that was part of the (?<=\n) boundary;
# strip it so the result ends cleanly at the closer boundary.
return parts[0].rstrip("\n")
# 3. Wrap in trust-boundary markers.
return f"{_A2A_BOUNDARY_START}\n{escaped}\n{_A2A_BOUNDARY_END}"
+13 -7
View File
@@ -51,7 +51,7 @@ from shared_runtime import (
from executor_helpers import (
collect_outbound_files,
extract_attached_files,
sanitize_agent_error,
read_delegation_results,
)
from builtin_tools.telemetry import (
A2A_TASK_ID,
@@ -216,6 +216,17 @@ class LangGraphA2AExecutor(AgentExecutor):
3. Message(final_text) terminal event
"""
user_input = extract_message_text(context)
# Inject delegation results from prior turns. Heartbeat writes
# completed delegation rows to DELEGATION_RESULTS_FILE and sends
# a self-message to wake the agent; this consumes the file and
# surfaces the results as context so the agent can act on them
# without needing an explicit check_task_status call.
# Results are prepended so they are visible even when the
# self-message text is overwritten by a subsequent user message.
pending_results = read_delegation_results()
if pending_results:
logger.info("A2A execute: injecting %d delegation result(s)", pending_results.count("\n") + 1)
user_input = f"[Delegation results available]\n{pending_results}\n\n{user_input}"
# Pull attached files from A2A message parts (kind: "file") and
# append a manifest to the prompt so the agent knows they exist.
# LangGraph tools (filesystem, bash, skills) can then open the
@@ -536,12 +547,7 @@ class LangGraphA2AExecutor(AgentExecutor):
# receive the error and stop polling.
await updater.failed(
message=new_text_message(
# Pass the exception string as stderr so sanitize_agent_error
# can include a ~1KB preview in the A2A error response.
# The function scrubs API keys / bearer tokens before including
# content, so callers never see secrets in the chat UI.
# Fixes: roadmap item "SDK executor stderr swallowing".
sanitize_agent_error(stderr=str(e)), task_id=task_id, context_id=context_id,
f"Agent error: {e}", task_id=task_id, context_id=context_id
)
)
finally:

Some files were not shown because too many files have changed in this diff Show More