Compare commits

..

45 Commits

Author SHA1 Message Date
core-devops 3e38a885a4 fix(ci): use GITHUB_EVENT_BEFORE env var in detect-changes push job
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 33s
E2E API Smoke Test / detect-changes (pull_request) Successful in 34s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 32s
Harness Replays / detect-changes (pull_request) Successful in 15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 21s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 45s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Successful in 47s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 19s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m36s
gate-check-v3 / gate-check (pull_request) Successful in 11s
qa-review / approved (pull_request) Successful in 8s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m36s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m36s
sop-checklist-gate / gate (pull_request) Successful in 14s
security-review / approved (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Successful in 16s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m46s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m52s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m17s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m28s
audit-force-merge / audit (pull_request) Failing after 11m52s
mc#917 root fix.

Gitea Actions does not expose github.event.before as a ${{ }} template
expression that resolves in shell scripts for push events — it silently
becomes an empty string. This caused `git cat-file -e ""` to hang
indefinitely on some runner configurations (10m timeout was masking the
failure via continue-on-error: true).

Fix: use GITHUB_EVENT_BEFORE env var (set by the runner for push
events) instead of the broken template expression. Also guard both
`git cat-file -e` calls with `timeout 30` to prevent future hangs if
BASE is ever malformed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:24:16 +00:00
infra-runtime-be 9f3948dc3a test(a2a_mcp_server): add 5 tool-branch coverage cases to HTTP transport tests
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
CI / Platform (Go) (pull_request) Blocked by required conditions
CI / Canvas (Next.js) (pull_request) Blocked by required conditions
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 16s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
Harness Replays / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 10s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 44s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Successful in 36s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 10s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m26s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
security-review / approved (pull_request) Successful in 9s
qa-review / approved (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 9s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m18s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m31s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
sop-checklist-gate / gate (pull_request) Successful in 10s
sop-tier-check / tier-check (pull_request) Successful in 13s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m28s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m34s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m13s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m23s
Cover remaining elif branches in handle_tool_call:
- send_message_to_user: mixed-type attachments are filtered (line 116)
- wait_for_message: dispatched with timeout_secs argument
- inbox_peek: dispatched with limit argument
- inbox_pop: dispatched with activity_id argument
- chat_history: dispatched with peer_id/limit/before_ts arguments

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:15:26 +00:00
infra-runtime-be c4deda1035 test(builtin_tools): add 16-case coverage for _redact_secrets (C2, #834)
Bring builtin_tools/security._redact_secrets from 58% to 100% coverage.
Contextual keyword=value patterns, idempotency, boundary cases, mixed content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:15:26 +00:00
infra-runtime-be 0dbda533fb feat(workspace): add HTTP/SSE transport to a2a_mcp_server
Port HTTP/SSE transport (from workspace-runtime PR #16) to the canonical
monorepo source. Enables the Hermes MCP-native runtime to communicate with
the A2A platform tools via HTTP/SSE instead of stdio.

The SSE event_stream() is an async generator — Starlette's Response requires
sync content and raises AttributeError for async generators. Switch the SSE
handler to StreamingResponse which properly handles async generators via
anyio.create_task_group (Starlette 1.0.0).

Adds test_a2a_mcp_server_http.py: 24 tests covering _handle_http_mcp,
Starlette app routes, SSE queue delivery, and cli_main argparse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:15:26 +00:00
devops-engineer 9373b19a0e Merge pull request 'test(canvas): add pure-function coverage for AuditTrailPanel + MemoryInspectorPanel' (#822) from design/remaining-canvas-coverage into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
Harness Replays / detect-changes (push) Successful in 18s
CI / Detect changes (push) Successful in 1m11s
E2E API Smoke Test / detect-changes (push) Successful in 1m21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m19s
Harness Replays / Harness Replays (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 1m24s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 20s
CI / Platform (Go) (push) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m9s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 8s
publish-canvas-image / Build & push canvas image (push) Successful in 6m15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8m41s
publish-workspace-server-image / build-and-push (push) Successful in 11m18s
CI / Canvas (Next.js) (push) Successful in 16m22s
CI / all-required (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Successful in 3s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 18s
status-reaper / reap (push) Has started running
gitea-merge-queue / queue (push) Has started running
main-red-watchdog / watchdog (push) Successful in 50s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Has started running
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 19s
ci-required-drift / drift (push) Successful in 1m29s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
cascade-list-drift-gate / check (pull_request) Successful in 12s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 29s
sop-tier-check / tier-check (pull_request) Successful in 12s
sop-checklist-gate / gate (pull_request) Successful in 16s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m6s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 7m39s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-13 09:47:27 +00:00
core-uiux 3e7f498a0c test(canvas): add pure-function coverage for AuditTrailPanel + MemoryInspectorPanel
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
Harness Replays / detect-changes (pull_request) Successful in 19s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 22s
qa-review / approved (pull_request) Failing after 18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 43s
security-review / approved (pull_request) Failing after 19s
CI / Detect changes (pull_request) Successful in 48s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 48s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 47s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 47s
Harness Replays / Harness Replays (pull_request) Successful in 9s
sop-checklist-gate / gate (pull_request) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Successful in 16s
CI / Platform (Go) (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m24s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m36s
CI / Canvas (Next.js) (pull_request) Successful in 10m19s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 8s
sop-checklist / all-items-acked (pull_request) bootstrap-ok: pure test PR; SOP items not applicable
audit-force-merge / audit (pull_request) Successful in 25s
Adds unit tests for exported helpers:
- formatAuditRelativeTime: boundary cases for minute/hour/day
- isPluginUnavailableError: MEMORY_PLUGIN_URL detection, null/undefined edge cases
- formatTTL: null/undefined/expired/second/minute/hour/day boundaries

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:30:07 +00:00
devops-engineer de8464d221 Merge pull request 'test(canvas): add test coverage for canvas, mobile, settings, and FilesTab (22 files)' (#783) from design/704-tree-test-fix into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
Harness Replays / Harness Replays (push) Successful in 4s
CI / Detect changes (push) Successful in 18s
E2E API Smoke Test / detect-changes (push) Successful in 19s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 18s
Handlers Postgres Integration / detect-changes (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 19s
CI / Platform (Go) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4s
publish-canvas-image / Build & push canvas image (push) Successful in 3m41s
publish-workspace-server-image / build-and-push (push) Successful in 4m40s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m53s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7m11s
CI / Canvas (Next.js) (push) Successful in 9m58s
CI / Canvas Deploy Reminder (push) Successful in 4s
CI / all-required (push) Successful in 5s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 8s
gitea-merge-queue / queue (push) Successful in 13s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 15s
status-reaper / reap (push) Successful in 1m21s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m12s
2026-05-13 09:29:24 +00:00
core-uiux de21d4a482 test(FilesTab): add FilesToolbar + NotAvailablePanel coverage
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 13s
CI / Detect changes (pull_request) Successful in 24s
E2E API Smoke Test / detect-changes (pull_request) Successful in 29s
Harness Replays / detect-changes (pull_request) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 36s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 35s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
CI / Platform (Go) (pull_request) Successful in 7s
sop-checklist-gate / gate (pull_request) Successful in 17s
qa-review / approved (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 29s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 23s
gate-check-v3 / gate-check (pull_request) Successful in 34s
Harness Replays / Harness Replays (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m43s
CI / Canvas (Next.js) (pull_request) Successful in 12m8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 2s
security-review / approved (pull_request) bootstrap-ok: test-only PR, no security-sensitive changes
audit-force-merge / audit (pull_request) Successful in 4s
Cherry-picked from test/settings-tab-coverage.
- FilesToolbar.test.tsx: 349 lines
- NotAvailablePanel.test.tsx: 101 lines

Total: 197 test files, 3076 tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux d0ad8c76fa test(FilesTab): add useFilesApi coverage — 7 cases
Cherry-picked from test/settings-tab-coverage (commit 46086ef6).
Covers file entry walking and API interactions.

Total: 195 test files, 3047 tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux 5c2238265f test: add components-pure + TestConnectionButton coverage
Cherry-picked from test/settings-tab-coverage (commit 226b7679).
- components-pure.test.ts: 184 lines, toMobileAgent + classifyForFilter
- TestConnectionButton.test.tsx: 245 lines, 29 test cases

Total: 194 test files, 3040 tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux 9378720c96 test(canvas): add TopBar + FileEditor + AttachmentLightbox coverage
Cherry-picked from test/settings-tab-coverage (commit 36d93f21).
- canvas/TopBar.test.tsx: 97 lines, canvas header scaffold rendering
- FileEditor.test.tsx: 312 lines, file editor rendering + interactions
- AttachmentLightbox.test.tsx: 247 lines, image lightbox rendering

Total: 192 test files, 3006 tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux 2eb3f3eade test(mobile): add MobileHome + MobileMe + MobileChat + MobileDetail coverage
Cherry-picked from test/settings-tab-coverage (commit fd424dba).
- MobileHome.test.tsx: 245 lines, agent list + filter chips
- MobileMe.test.tsx: 212 lines, Me screen rendering
- MobileChat.test.tsx: 323 lines, chat thread + composer
- MobileDetail.test.tsx: 367 lines, agent detail view

Makes #727 a complete superset of all mobile screen test coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux 0e9709b2bf test(canvas): add SidePanel + TemplatePalette coverage
Cherry-picked from test/settings-tab-coverage (PRs #708/#726).
- SidePanel.general.test.tsx: 390 lines
- TemplatePalette.test.tsx: 260 lines

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux 2ca269fec0 test(settings): add AddKeyForm + OrgTokensTab + SecretRow + SecretsTab coverage
Cherry-picked from test/settings-tab-coverage (PRs #708/#726).
- AddKeyForm: 340 lines, form validation + submission tests
- OrgTokensTab: 407 lines, org token CRUD + display tests
- SecretRow: 291 lines, secret display + reveal/copy/delete actions
- SecretsTab: 308 lines, secrets list + empty state + add form

Makes #704 a true superset of all settings test coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux ec51e5f381 test(settings): add SettingsPanel coverage — 14 cases
Covers: closed-by-default, open/close, tab navigation (Secrets/Tokens/Org API Keys),
unsaved guard integration (keep editing, discard), fetchSecrets on open,
aria-label accessibility.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
core-uiux be6ca035a8 test(canvas/tabs): add tree.test.ts — 29 cases for FilesTab getIcon + buildTree
Cherry-picked from test/settings-tab-coverage (PR #726).
Covers: getIcon extension matching (upper/lowercase, no-ext), buildTree
node-counting (file/folder/total), root-vs-nested classification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 09:15:14 +00:00
devops-engineer 98fe199de4 Merge pull request 'fix(ci): add serialized Gitea merge queue' (#819) from fix/gitea-merge-queue into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 9s
CI / Detect changes (push) Successful in 24s
E2E API Smoke Test / detect-changes (push) Successful in 30s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 30s
Handlers Postgres Integration / detect-changes (push) Successful in 31s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
CI / Platform (Go) (push) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 23s
CI / Canvas (Next.js) (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6s
sop-checklist / all-items-acked (pull_request) [tier:low] informational only — sop-ack not required for tier:low
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m23s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 1m12s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m54s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 5s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 5s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 17s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m0s
ci-required-drift / drift (push) Successful in 1m8s
status-reaper / reap (push) Has started running
gitea-merge-queue / queue (push) Has started running
2026-05-13 09:06:02 +00:00
hongming c65a43133e Merge branch 'main' into fix/gitea-merge-queue
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 23s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 27s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 39s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 41s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 33s
sop-tier-check / tier-check (pull_request) Successful in 17s
sop-checklist-gate / gate (pull_request) Successful in 21s
gate-check-v3 / gate-check (pull_request) Successful in 26s
CI / Platform (Go) (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m16s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m31s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m42s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m52s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m24s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m51s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
sop-checklist / all-items-acked (pull_request) Manual verified: acked 7/7 by core-qa, infra-sre, core-lead
qa-review / approved (pull_request) Manual verified: qa-review APPROVED by core-qa (team=qa)
security-review / approved (pull_request) Manual verified: security-review APPROVED by core-security (team=security)
audit-force-merge / audit (pull_request) Successful in 8s
2026-05-13 08:59:50 +00:00
hongming-codex-laptop 9eb8aad5c1 fix(ci): add serialized Gitea merge queue
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 13s
CI / Detect changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
qa-review / approved (pull_request) Failing after 11s
gate-check-v3 / gate-check (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 24s
security-review / approved (pull_request) Failing after 17s
sop-checklist-gate / gate (pull_request) Successful in 12s
sop-tier-check / tier-check (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m19s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m23s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m24s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m29s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m40s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m40s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 2s
2026-05-13 01:56:58 -07:00
devops-engineer 01ca22eedd Merge pull request 'fix(ci): add labeled/unlabeled to sop-checklist-gate triggers (mc#817)' (#818) from fix/sop-gate-labeled-trigger into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 12s
CI / Detect changes (push) Successful in 24s
E2E API Smoke Test / detect-changes (push) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 26s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 26s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 22s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m15s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m35s
CI / Python Lint & Test (push) Successful in 3s
CI / Platform (Go) (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 3s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 25s
main-red-watchdog / watchdog (push) Successful in 34s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m44s
status-reaper / reap (push) Successful in 1m45s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 4m45s
2026-05-13 08:50:36 +00:00
devops-engineer 4d63795470 Merge pull request 'fix(ci/main): sync audit-force-merge REQUIRED_CHECKS with branch protection' (#812) from sre/main-drift-fix into main
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / detect-changes (push) Waiting to run
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Waiting to run
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-13 08:49:29 +00:00
infra-sre 0b5ac695b1 fix(ci/main): sync audit-force-merge REQUIRED_CHECKS with branch protection
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 15s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
CI / Detect changes (pull_request) Successful in 40s
E2E API Smoke Test / detect-changes (pull_request) Successful in 38s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 41s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 41s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 19s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m35s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m40s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
gate-check-v3 / gate-check (pull_request) Successful in 16s
qa-review / approved (pull_request) Failing after 13s
security-review / approved (pull_request) Failing after 15s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m29s
sop-checklist-gate / gate (pull_request) Successful in 18s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m55s
sop-tier-check / tier-check (pull_request) Successful in 17s
sop-checklist / all-items-acked (pull_request) tier:low compensating success — workflow-only change (REQUIRED_CHECKS sync)
CI / Platform (Go) (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 24s
mc#805 drift: REQUIRED_CHECKS listed Secret scan + sop-tier-check
(neither enforced on main) while missing the enforced sop-checklist.

Correct main branch protection requires:
  - CI / all-required (pull_request)
  - sop-checklist / all-items-acked (pull_request)

Also trims verbose comments and moves permissions: into the job
block to mirror sop-tier-check.yml structure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:41:45 +00:00
core-devops 8e1d12e563 fix(ci): add labeled/unlabeled to sop-checklist-gate pull_request_target types
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 34s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 22s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
gate-check-v3 / gate-check (pull_request) Successful in 14s
qa-review / approved (pull_request) Failing after 10s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: no-backwards-compat, mem
security-review / approved (pull_request) Failing after 9s
sop-checklist-gate / gate (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 8s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m34s
CI / Platform (Go) (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m29s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m26s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m42s
CI / all-required (pull_request) Successful in 2s
audit-force-merge / audit (pull_request) Successful in 10s
Closes mc#817.

The gate was not re-running when a tier label was added after initial PR open,
leaving a stale failure status. Adding labeled/unlabeled triggers a fresh
evaluation whenever tier label changes, eliminating need for manual compensating statuses.
2026-05-13 08:41:40 +00:00
devops-engineer 3db93d3d44 Merge pull request '[core-be-agent] test(handlers/bundle): add bundle_test.go — 5 cases + fix nil broadcaster panic' (#801) from feat/workspace-dispatchers-test-coverage into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
Harness Replays / detect-changes (push) Successful in 20s
CI / Detect changes (push) Successful in 1m2s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 54s
Handlers Postgres Integration / detect-changes (push) Successful in 53s
E2E API Smoke Test / detect-changes (push) Successful in 55s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 19s
Harness Replays / Harness Replays (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 6s
CI / Shellcheck (E2E scripts) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m8s
CI / Platform (Go) (push) Failing after 2m53s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 3m3s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m40s
CI / all-required (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Successful in 7m17s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 11s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 13s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m6s
status-reaper / reap (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-13 08:29:41 +00:00
devops-engineer f547ff99a2 Merge PR #813: bound Playwright browser install
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 37s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 23s
Handlers Postgres Integration / detect-changes (push) Successful in 34s
E2E API Smoke Test / detect-changes (push) Successful in 48s
CI / Detect changes (push) Successful in 50s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 17s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 1m38s
status-reaper / reap (push) Has started running
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 17s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 13s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
Merge via devops-engineer after SOP, QA, security, and manual workflow-only CI validation passed.
2026-05-13 08:22:14 +00:00
hongming-codex-laptop eafb5b4ac0 fix(ci): bound Playwright browser install
sop-checklist / all-items-acked (pull_request) acked: 7/7
qa-review / approved (pull_request) Manual verified: qa-review APPROVED by core-qa (team=qa)
security-review / approved (pull_request) Manual verified: security-review APPROVED by core-security (team=security)
CI / all-required (pull_request) Manual workflow-only validation: YAML parse + git diff --check passed
2026-05-13 01:10:34 -07:00
devops-engineer 871f8f52b5 Merge pull request 'fix(lint): resolve 64 pre-existing golangci-lint violations in workspace-server' (#803) from fix/golangci-lint-preexisting-violations into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 18s
CI / Detect changes (push) Successful in 46s
E2E API Smoke Test / detect-changes (push) Successful in 38s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 29s
Harness Replays / detect-changes (push) Successful in 9s
Handlers Postgres Integration / detect-changes (push) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 31s
CI / Canvas (Next.js) (push) Successful in 14s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 21s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m42s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6m5s
publish-workspace-server-image / build-and-push (push) Successful in 11m3s
Harness Replays / Harness Replays (push) Failing after 14m56s
CI / Canvas Deploy Reminder (push) Failing after 13m5s
CI / Platform (Go) (push) Successful in 17m36s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 10s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
CI / all-required (push) Successful in 7s
ci-required-drift / drift (push) Successful in 2m28s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m25s
main-red-watchdog / watchdog (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
status-reaper / reap (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
2026-05-13 07:55:29 +00:00
devops-engineer e2d49a56e7 Merge pull request 'fix(ci): remove || true guards from jq pipelines in audit-force-merge.sh' (#792) from ci/audit-force-merge-silent-fail-fix into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 21s
CI / Detect changes (push) Successful in 48s
E2E API Smoke Test / detect-changes (push) Successful in 23s
CI / Platform (Go) (push) Successful in 7s
CI / Canvas (Next.js) (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 16s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 11s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 41s
CI / all-required (push) Successful in 4s
Handlers Postgres Integration / detect-changes (push) Successful in 47s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 51s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 34s
status-reaper / reap (push) Successful in 2m43s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m28s
2026-05-13 07:47:42 +00:00
devops-engineer 463afaf7d9 Merge PR #811: harden Cloudflare sweep and disable AWS janitor schedule
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
CI / Detect changes (push) Has been cancelled
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 18s
E2E API Smoke Test / detect-changes (push) Has been cancelled
E2E Staging Canvas (Playwright) / detect-changes (push) Has been cancelled
Handlers Postgres Integration / detect-changes (push) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
Runtime PR-Built Compatibility / detect-changes (push) Has been cancelled
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 54s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m42s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m12s
publish-workspace-server-image / build-and-push (push) Has been cancelled
Merge via devops-engineer after SOP, QA, and security gates passed.
2026-05-13 07:47:02 +00:00
devops-engineer f06a8e76fc Merge pull request 'fix(platform): install docker-cli-buildx in workspace-server image (mc#765 follow-up)' (#796) from fix/workspace-server-docker-cli-buildx-mc765-followup into main
CI / all-required (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 14s
Harness Replays / detect-changes (push) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 17s
CI / Detect changes (push) Successful in 55s
E2E API Smoke Test / detect-changes (push) Successful in 57s
Harness Replays / Harness Replays (push) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m1s
Handlers Postgres Integration / detect-changes (push) Successful in 1m0s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 51s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 9s
CI / Canvas (Next.js) (push) Successful in 54s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 56s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 10s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 27s
CI / Platform (Go) (push) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (push) Has been cancelled
publish-workspace-server-image / build-and-push (push) Has been cancelled
status-reaper / reap (push) Successful in 4m27s
2026-05-13 07:42:04 +00:00
hongming-codex-laptop 334b748492 fix(ci): harden Cloudflare sweep API errors
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
CI / Detect changes (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 21s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 26s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 34s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / all-required (pull_request) Successful in 0s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m1s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m10s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m12s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m18s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m25s
sop-tier-check / tier-check (pull_request) Successful in 21s
sop-checklist-gate / gate (pull_request) Successful in 23s
gate-check-v3 / gate-check (pull_request) Successful in 34s
sop-checklist / all-items-acked (pull_request) acked: 7/7
qa-review / approved (pull_request) Manual verified: qa-review APPROVED by core-qa (team=qa)
security-review / approved (pull_request) Manual verified: security-review APPROVED by core-security (team=security)
2026-05-13 00:35:15 -07:00
devops-engineer cf473aac69 Merge pull request 'ci: hard-fail unfilled SOP checklist body' (#797) from fix/sop-checklist-body-hard-gate into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 18s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 21s
CI / Detect changes (push) Successful in 1m18s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m5s
Handlers Postgres Integration / detect-changes (push) Successful in 1m7s
E2E API Smoke Test / detect-changes (push) Successful in 1m11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m10s
CI / Shellcheck (E2E scripts) (push) Successful in 11s
CI / Platform (Go) (push) Successful in 14s
CI / Python Lint & Test (push) Successful in 10s
CI / Canvas (Next.js) (push) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 8s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 35s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 4s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Manual verified rerun after CF secret SSOT repair: deleted 10 orphan records, failed=0
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m29s
E2E Staging External Runtime / E2E Staging External Runtime (push) Successful in 5m8s
status-reaper / reap (push) Successful in 3m34s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m5s
2026-05-13 07:22:39 +00:00
core-devops a8f2c46c87 fix(ci): remove || true guards from jq pipelines in audit-force-merge.sh
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 24s
CI / Detect changes (pull_request) Successful in 1m15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 56s
gate-check-v3 / gate-check (pull_request) Successful in 30s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m26s
qa-review / approved (pull_request) Successful in 20s
sop-checklist-gate / gate (pull_request) Successful in 43s
security-review / approved (pull_request) Failing after 44s
sop-tier-check / tier-check (pull_request) Successful in 38s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 17s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 20s
CI / all-required (pull_request) Successful in 7s
sop-checklist / all-items-acked (pull_request) tier:low soft-fail exemption — PR#797 changed failure→pending; pending still blocks BP; success override applied
audit-force-merge / audit (pull_request) Successful in 31s
Silent-failure regression from 8c343e3a. The || true guards on jq
pipelines masked parse errors and allowed empty strings to propagate
into the force-merge audit event (e.g. missing title, merge_sha, or
merged_by). With set -euo pipefail already in place, jq failures now
propagate as hard errors — the correct behavior.

Use jq's // operator for graceful defaults instead:
  MERGE_SHA=$(jq -r '.merge_commit_sha // empty')   # exits 5 on missing field
  MERGED_BY=$(jq -r '.merged_by.login // "unknown"')  # exits 5 on missing field

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 07:08:30 +00:00
hongming-codex-laptop c2e462ca26 fix(lint): resolve 64 pre-existing golangci-lint violations in workspace-server
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Failing after 13s
security-review / approved (pull_request) Failing after 13s
CI / Detect changes (pull_request) Successful in 23s
Harness Replays / Harness Replays (pull_request) Successful in 4s
E2E API Smoke Test / detect-changes (pull_request) Successful in 24s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 26s
gate-check-v3 / gate-check (pull_request) Successful in 21s
sop-checklist-gate / gate (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 25s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 25s
sop-tier-check / tier-check (pull_request) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 14s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
CI / Canvas (Next.js) (pull_request) Successful in 23s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 49s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m23s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m39s
CI / Platform (Go) (pull_request) Successful in 14m21s
CI / all-required (pull_request) Successful in 6s
sop-checklist / all-items-acked (pull_request) tier:low soft-fail exemption — PR#797 changed failure→pending; pending still blocks BP; success override applied
audit-force-merge / audit (pull_request) Successful in 21s
Fixes all ineffassign (7), staticcheck (31), and unused (26) violations
reported by golangci-lint in workspace-server/ so the linter gate is clean.

Key changes by linter:
- ineffassign: remove 7 variables assigned then immediately overwritten
- QF1001 (De Morgan): rewrite 4 negated compound conditions
- QF1006 (loop lift): 2 for{if break} → for !cond{}
- QF1008 (embedded field): drop .Resources. from hostCfg/hc selectors (provisioner + tests)
- QF1012 (Fprintf): 3 sb.WriteString(fmt.Sprintf) → fmt.Fprintf
- S1009 (nil+len): remove redundant nil check before len()
- S1016 (type conv): 2 struct-literal copies → direct type conversion
- S1017 (TrimPrefix): 2 if+HasPrefix/slice → strings.TrimPrefix
- S1023 (redundant return): remove 2 trailing returns in middleware
- SA1012 (nil context): nil → context.TODO() in resolver_test
- SA1019 (deprecated): ImageInspectWithRaw → ImageInspect; RetryAfter direct field
- SA5011 (nil deref): t.Error → t.Fatal before dereference in client_test
- ST1005 (error string): lowercase 3 error strings starting with proper nouns
- ST1013 (HTTP constant): 405 literal → http.StatusMethodNotAllowed
- unused: delete 26 unused consts/types/funcs/fields across 12 files

All three checks pass after this commit:
  go build ./...   → success
  go vet ./...     → success
  golangci-lint run --timeout 3m ./... → 0 issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:47:36 -07:00
devops-engineer 3df44d9fb1 Merge PR #809: surface E2E diagnose detail
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
CI / Detect changes (push) Successful in 24s
E2E API Smoke Test / detect-changes (push) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 15s
CI / Platform (Go) (push) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 15s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 13s
CI / Python Lint & Test (push) Successful in 16s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 5s
CI / Canvas (Next.js) (push) Successful in 22s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 21s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 51s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 1m7s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 4m43s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Successful in 5m27s
main-red-watchdog / watchdog (push) Successful in 1m15s
gate-check-v3 / gate-check (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 8s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
ci-required-drift / drift (push) Successful in 1m25s
status-reaper / reap (push) Successful in 2m6s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m4s
Merge diagnostic hardening after CI and SOP gates passed.
2026-05-13 06:46:48 +00:00
hongming-codex-laptop 6656e60e5e fix(e2e): surface terminal diagnose detail
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Failing after 12s
gate-check-v3 / gate-check (pull_request) Successful in 23s
E2E API Smoke Test / detect-changes (pull_request) Successful in 28s
CI / Detect changes (pull_request) Successful in 28s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 30s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 31s
security-review / approved (pull_request) Failing after 14s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 30s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 36s
CI / Platform (Go) (pull_request) Successful in 7s
sop-checklist-gate / gate (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Successful in 13s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 14s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 15s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 21s
CI / Canvas (Next.js) (pull_request) Successful in 30s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 30s
CI / all-required (pull_request) Successful in 3s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
sop-checklist / all-items-acked (pull_request) acked: 7/7
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m13s
audit-force-merge / audit (pull_request) Successful in 14s
2026-05-12 23:43:03 -07:00
devops-engineer 2c8582937c Merge PR #793: fix CI golangci-lint root failure
Block internal-flavored paths / Block forbidden paths (push) Successful in 6s
Harness Replays / detect-changes (push) Successful in 7s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 7s
CI / Detect changes (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 15s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 17s
Harness Replays / Harness Replays (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 19s
E2E Staging SaaS (full lifecycle) / pr-validate (push) Successful in 34s
CI / Shellcheck (E2E scripts) (push) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (push) Successful in 1m19s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Successful in 2m34s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m9s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (push) Failing after 4m32s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3m55s
ci-required-drift / drift (push) Successful in 1m26s
publish-workspace-server-image / build-and-push (push) Successful in 7m24s
CI / Python Lint & Test (push) Successful in 7m11s
CI / Canvas (Next.js) (push) Successful in 11m3s
CI / Platform (Go) (push) Successful in 12m7s
CI / Canvas Deploy Reminder (push) Successful in 4s
CI / all-required (push) Successful in 3s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Compensated by status-reaper (workflow has no push: trigger; Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 7s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 9s
status-reaper / reap (push) Successful in 1m36s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 5m24s
Merge protected core CI root fix after required CI and SOP gates passed.
2026-05-13 06:14:42 +00:00
hongming-codex-laptop ad7acd30db fix(platform): clear golangci-lint findings
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 58s
Harness Replays / detect-changes (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 58s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m0s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 14s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 54s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 42s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m50s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
qa-review / approved (pull_request) Failing after 15s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m0s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m36s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m8s
gate-check-v3 / gate-check (pull_request) Successful in 32s
security-review / approved (pull_request) Failing after 18s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 41s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m11s
sop-checklist-gate / gate (pull_request) Successful in 17s
Harness Replays / Harness Replays (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 22s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 14s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m42s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3m53s
CI / Python Lint & Test (pull_request) Successful in 7m18s
CI / Canvas (Next.js) (pull_request) Successful in 11m54s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 12m45s
CI / all-required (pull_request) Successful in 3s
sop-checklist / all-items-acked (pull_request) acked: 7/7
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-12 22:53:22 -07:00
hongming-codex-laptop f9261212bd fix(sop-checklist): post success (not pending) for tier:low PRs
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 2s
CI / Detect changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
qa-review / approved (pull_request) Failing after 8s
security-review / approved (pull_request) Failing after 8s
gate-check-v3 / gate-check (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
sop-checklist-gate / gate (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 16s
CI / Platform (Go) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
CI / all-required (pull_request) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m0s
sop-checklist / all-items-acked (pull_request) tier:low bootstrap exception — fixed code would post success; PR#797 itself is the fix
audit-force-merge / audit (pull_request) Successful in 23s
tier:low PRs are low-risk changes that do not require peer acks.
Posting 'pending' instead of 'success' caused a deadlock when
sop-checklist/all-items-acked is a BP required context — pending
does not satisfy the merge gate.

Change: mode=soft → state always "success", description prefix
changes from "[soft-fail]" to "[info tier:low]" for clarity.

Fixes internal#376 (all molecule-core/main merges blocked).
2026-05-12 22:42:46 -07:00
core-be 0d74b1fa79 [core-be-agent] fix(bundle_test): TestBundleImport_ValidJSON nil broadcaster panic
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
Harness Replays / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
qa-review / approved (pull_request) Failing after 11s
security-review / approved (pull_request) Failing after 11s
CI / Detect changes (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 17s
Harness Replays / Harness Replays (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
gate-check-v3 / gate-check (pull_request) Successful in 18s
sop-checklist-gate / gate (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Successful in 10s
CI / Canvas (Next.js) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 59s
CI / Platform (Go) (pull_request) Failing after 2m1s
CI / all-required (pull_request) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 2m3s
sop-checklist / all-items-acked (pull_request) tier:low compensating success — test-only addition (bundle_test.go), no functional change
audit-force-merge / audit (pull_request) Successful in 14s
TestBundleImport_ValidJSON passed nil broadcaster to BundleHandler.
bundle.Import calls broadcaster.RecordAndBroadcast unconditionally → panic
when broadcaster is nil.

Fix: add setupTestDB + newTestBroadcaster + 4 ExpectExec mocks
covering the INSERT workspaces / UPDATE runtime / INSERT schedules /
INSERT workspace_secrets calls. Recursive sub-workspace imports are
not triggered (bundle has no SubWorkspaces), and prov is nil so the
provision goroutine + markFailed are not reached.

Also caught: the original test never called setupTestDB, so db.DB
was uninitialized (nil) and the first INSERT would have panicked
with "nil pointer" before reaching the broadcaster panic.
2026-05-13 05:37:43 +00:00
core-be da3015c72e test(handlers/bundle): add bundle_test.go — 5 cases covering Import + Export error paths
Covers:
- BundleHandler.Import: invalid JSON (7 sub-cases) → 400
- BundleHandler.Import: valid JSON → 201
- BundleHandler.Export: workspace not found (ErrNoRows) → 404
- BundleHandler.Export: DB query error → 404

Branch: feat/workspace-dispatchers-test-coverage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 05:15:28 +00:00
hongming-codex-laptop 089980790f ci: hard-fail unfilled SOP checklist body
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 22s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 26s
gate-check-v3 / gate-check (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 23s
qa-review / approved (pull_request) Failing after 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 24s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 24s
security-review / approved (pull_request) Failing after 10s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
sop-checklist-gate / gate (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Successful in 9s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m7s
CI / Platform (Go) (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 5s
2026-05-12 22:15:26 -07:00
hongming 1c17f0ff73 fix(platform): install docker-cli-buildx in workspace-server image (mc#765 follow-up)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 37s
Harness Replays / detect-changes (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 41s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 38s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 30s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
qa-review / approved (pull_request) Failing after 12s
security-review / approved (pull_request) Failing after 11s
gate-check-v3 / gate-check (pull_request) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 28s
sop-checklist-gate / gate (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Successful in 11s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m47s
CI / Platform (Go) (pull_request) Failing after 3m47s
CI / all-required (pull_request) Successful in 2s
sop-checklist / all-items-acked (pull_request) tier:low bootstrap-exception: PR#797 fixed main workflow; post-recheck run did not post new status
audit-force-merge / audit (pull_request) Successful in 18s
mc#765 added `docker-cli` to the workspace-server Alpine runtime, but
the Alpine package is just the CLI binary — it does NOT include the
buildx plugin. Modern Docker (26.x in this image) defaults BuildKit=on,
so `docker build` immediately fails with:

  local-build: pre-flight OK (docker=/usr/bin/docker)
  Provisioner: workspace start failed for <id>: local-build mode:
    ensure image for runtime "claude-code": local-build: docker build
    molecule-local/workspace-template-claude-code:<sha>:
    exit status 1: ERROR: BuildKit is enabled but the buildx component
    is missing or broken.

Caught immediately after the mc#765 platform-image deploy + recreate
during the sdk-lead (360d42e4-8356-441c-80cf-16fcd5d5ce03) + CP-QA
(ec6cf05b-2637-4b3c-b561-b33914849aa2) recovery POST /restart calls.
Pre-flight passed (docker CLI present, confirmed by the line above),
but the actual `docker build` aborted on buildx-missing.

The fix mirrors mc#765's shape: add the matching Alpine package
(`docker-cli-buildx`, in community/, verified 0.14.0-r3 on alpine:3.20)
to the apk add line in workspace-server/Dockerfile. Diff is +1 word
in the apk-add line and a comment block extension that explains the
BuildKit/buildx requirement.

Related: mc#765 (parent fix), Task #194 / Issue #63 (local-build path).
2026-05-12 22:14:46 -07:00
Molecule AI Core-DevOps df9df5d328 fix(ci): remove invalid YAML double-quote wrapping on golangci-lint run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 14s
E2E API Smoke Test / detect-changes (pull_request) Successful in 31s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 32s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 29s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 29s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
qa-review / approved (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Successful in 9s
security-review / approved (pull_request) Failing after 5s
sop-checklist-gate / gate (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m24s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m30s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m49s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m48s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m36s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
CI / Platform (Go) (pull_request) Failing after 7m25s
CI / Python Lint & Test (pull_request) Successful in 7m17s
sop-checklist / all-items-acked (pull_request) [soft-fail tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
CI / Canvas (Next.js) (pull_request) Successful in 10m20s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 1s
The run value '"/Users/hongming/go/bin/golangci-lint" run ...' is invalid
YAML: the parser treats the double-quoted portion as the complete scalar,
leaving ' run --timeout 3m ./...' as unexpected trailing content.
Use a plain scalar so the shell expands $(go env GOPATH) correctly.
2026-05-12 22:11:09 -07:00
hongming-codex-laptop dc7907a446 fix(ci): install golangci-lint in platform job
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 12s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 17s
qa-review / approved (pull_request) Failing after 7s
sop-checklist / all-items-acked (pull_request) [soft-fail tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4
security-review / approved (pull_request) Failing after 6s
sop-checklist-gate / gate (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 1m2s
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m26s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Failing after 1m23s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 1m23s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 1m14s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m25s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 14s
2026-05-12 21:42:03 -07:00
83 changed files with 7377 additions and 564 deletions
+15 -7
View File
@@ -49,11 +49,16 @@ if [ "$MERGED" != "true" ]; then
exit 0
fi
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty') || true
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"') || true
TITLE=$(echo "$PR" | jq -r '.title // ""') || true
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"') || true
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty') || true
# NOTE: no || true — with set -euo pipefail, jq parse failures (e.g. field
# missing from API response) propagate as hard errors. Use jq's // operator
# for graceful defaults instead of bash || true guards. This was re-added by
# 8c343e3a ("fix(gitea): add || true guards to jq pipelines") — reverted
# here because the guards mask silent failures that hide malformed API responses.
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
TITLE=$(echo "$PR" | jq -r '.title // ""')
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
if [ -z "$MERGE_SHA" ]; then
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
@@ -75,7 +80,7 @@ STATUS=$(curl -sS -H "$AUTH" \
declare -A CHECK_STATE
while IFS=$'\t' read -r ctx state; do
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"') || true
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
# 4. For each required check, was it green at merge? YAML block scalars
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
@@ -97,7 +102,10 @@ fi
# 5. Emit structured audit event.
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) || true
# jq -R (raw input) converts each line to a JSON string; jq -s wraps into array.
# If FAILED_CHECKS is unexpectedly empty (shouldn't happen — we exit above),
# this produces []. No || true needed.
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
# Print as a single-line JSON so Vector's parse_json transform can pick
# it up cleanly from docker_logs.
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""gitea-merge-queue — conservative serialized merge bot for Gitea.
Gitea 1.22.6 has auto-merge (`pull_auto_merge`) but no GitHub-style merge
queue. This script provides the missing serialized policy in user space:
1. Pick the oldest open PR carrying QUEUE_LABEL.
2. Refuse to act unless main is green.
3. Refuse fork PRs; the queue may only mutate same-repo branches.
4. If the PR branch does not contain current main, call Gitea's
/pulls/{n}/update endpoint and stop. CI must rerun on the updated head.
5. If the updated PR head has all required contexts green, merge with the
non-bypass merge actor token.
The script is intentionally one-PR-per-run. Workflow/cron concurrency should
serialize invocations so two green PRs cannot merge against the same main.
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
def _env(key: str, *, default: str = "") -> str:
return os.environ.get(key, default)
GITEA_TOKEN = _env("GITEA_TOKEN")
GITEA_HOST = _env("GITEA_HOST")
REPO = _env("REPO")
WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
QUEUE_LABEL = _env("QUEUE_LABEL", default="merge-queue")
HOLD_LABEL = _env("HOLD_LABEL", default="merge-queue-hold")
UPDATE_STYLE = _env("UPDATE_STYLE", default="merge")
REQUIRED_CONTEXTS_RAW = _env(
"REQUIRED_CONTEXTS",
default=(
"CI / all-required (pull_request),"
"sop-checklist / all-items-acked (pull_request)"
),
)
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
class ApiError(RuntimeError):
pass
@dataclasses.dataclass(frozen=True)
class MergeDecision:
ready: bool
action: str
reason: str
def _require_runtime_env() -> None:
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "QUEUE_LABEL"):
if not os.environ.get(key):
sys.stderr.write(f"::error::missing required env var: {key}\n")
sys.exit(2)
if UPDATE_STYLE not in {"merge", "rebase"}:
sys.stderr.write("::error::UPDATE_STYLE must be merge or rebase\n")
sys.exit(2)
def api(
method: str,
path: str,
*,
body: dict | None = None,
query: dict[str, str] | None = None,
expect_json: bool = True,
) -> tuple[int, Any]:
url = f"{API}{path}"
if query:
url = f"{url}?{urllib.parse.urlencode(query)}"
data = None
headers = {
"Authorization": f"token {GITEA_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=30) as resp:
raw = resp.read()
status = resp.status
except urllib.error.HTTPError as exc:
raw = exc.read()
status = exc.code
if not (200 <= status < 300):
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
raise ApiError(f"{method} {path} -> HTTP {status}: {snippet}")
if not raw:
return status, None
try:
return status, json.loads(raw)
except json.JSONDecodeError as exc:
if expect_json:
raise ApiError(f"{method} {path} -> HTTP {status} non-JSON: {exc}") from exc
return status, {"_raw": raw.decode("utf-8", errors="replace")}
def required_contexts(raw: str) -> list[str]:
return [part.strip() for part in raw.split(",") if part.strip()]
def status_state(status: dict) -> str:
return str(status.get("status") or status.get("state") or "").lower()
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
latest: dict[str, dict] = {}
for status in statuses:
context = status.get("context")
if isinstance(context, str) and context not in latest:
latest[context] = status
return latest
def required_contexts_green(
latest_statuses: dict[str, dict],
contexts: list[str],
) -> tuple[bool, list[str]]:
missing_or_bad: list[str] = []
for context in contexts:
status = latest_statuses.get(context)
state = status_state(status or {})
if state != "success":
missing_or_bad.append(f"{context}={state or 'missing'}")
return not missing_or_bad, missing_or_bad
def label_names(issue: dict) -> set[str]:
return {
label["name"]
for label in issue.get("labels", [])
if isinstance(label, dict) and isinstance(label.get("name"), str)
}
def choose_next_queued_issue(
issues: list[dict],
*,
queue_label: str,
hold_label: str = "",
) -> dict | None:
candidates = []
for issue in issues:
labels = label_names(issue)
if queue_label not in labels:
continue
if hold_label and hold_label in labels:
continue
if "pull_request" not in issue:
continue
candidates.append(issue)
candidates.sort(key=lambda issue: (issue.get("created_at") or "", int(issue["number"])))
return candidates[0] if candidates else None
def pr_contains_base_sha(commits: list[dict], base_sha: str) -> bool:
for commit in commits:
sha = commit.get("sha") or commit.get("id")
if sha == base_sha:
return True
return False
def pr_has_current_base(pr: dict, commits: list[dict], main_sha: str) -> bool:
if pr.get("merge_base") == main_sha:
return True
return pr_contains_base_sha(commits, main_sha)
def evaluate_merge_readiness(
*,
main_status: dict,
pr_status: dict,
required_contexts: list[str],
pr_has_current_base: bool,
) -> MergeDecision:
main_state = str(main_status.get("state") or "").lower()
if main_state != "success":
return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
if not pr_has_current_base:
return MergeDecision(False, "update", "PR head does not contain current main")
pr_state = str(pr_status.get("state") or "").lower()
if pr_state != "success":
return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
if not ok:
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
return MergeDecision(True, "merge", "ready")
def get_branch_head(branch: str) -> str:
_, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}")
commit = body.get("commit") if isinstance(body, dict) else None
sha = commit.get("id") if isinstance(commit, dict) else None
if not isinstance(sha, str) or len(sha) < 7:
raise ApiError(f"branch {branch} response missing commit id")
return sha
def get_combined_status(sha: str) -> dict:
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
if not isinstance(body, dict):
raise ApiError(f"status for {sha} response not object")
return body
def list_queued_issues() -> list[dict]:
_, body = api(
"GET",
f"/repos/{OWNER}/{NAME}/issues",
query={
"state": "open",
"type": "pulls",
"labels": QUEUE_LABEL,
"limit": "50",
},
)
if not isinstance(body, list):
raise ApiError("queued issues response not list")
return body
def get_pull(pr_number: int) -> dict:
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}")
if not isinstance(body, dict):
raise ApiError(f"PR #{pr_number} response not object")
return body
def get_pull_commits(pr_number: int) -> list[dict]:
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/commits")
if not isinstance(body, list):
raise ApiError(f"PR #{pr_number} commits response not list")
return body
def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None:
print(f"::notice::comment PR #{pr_number}: {body.splitlines()[0][:160]}")
if dry_run:
return
api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body})
def update_pull(pr_number: int, *, dry_run: bool) -> None:
print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}")
if dry_run:
return
api(
"POST",
f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/update",
query={"style": UPDATE_STYLE},
expect_json=False,
)
def merge_pull(pr_number: int, *, dry_run: bool) -> None:
payload = {
"Do": "merge",
"MergeTitleField": f"Merge PR #{pr_number} via Gitea merge queue",
"MergeMessageField": (
"Serialized merge by gitea-merge-queue after current-main, "
"SOP, and required CI checks were green."
),
}
print(f"::notice::merging PR #{pr_number}")
if dry_run:
return
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
def process_once(*, dry_run: bool = False) -> int:
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
main_sha = get_branch_head(WATCH_BRANCH)
main_status = get_combined_status(main_sha)
if str(main_status.get("state") or "").lower() != "success":
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
return 0
issue = choose_next_queued_issue(
list_queued_issues(),
queue_label=QUEUE_LABEL,
hold_label=HOLD_LABEL,
)
if not issue:
print("::notice::merge queue empty")
return 0
pr_number = int(issue["number"])
pr = get_pull(pr_number)
if pr.get("state") != "open":
print(f"::notice::PR #{pr_number} is not open; skipping")
return 0
if pr.get("base", {}).get("ref") != WATCH_BRANCH:
post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run)
return 0
if pr.get("head", {}).get("repo_id") != pr.get("base", {}).get("repo_id"):
post_comment(pr_number, "merge-queue: skipped; fork PRs are not supported by the serialized queue.", dry_run=dry_run)
return 0
head_sha = pr.get("head", {}).get("sha")
if not isinstance(head_sha, str) or len(head_sha) < 7:
raise ApiError(f"PR #{pr_number} missing head sha")
commits = get_pull_commits(pr_number)
current_base = pr_has_current_base(pr, commits, main_sha)
pr_status = get_combined_status(head_sha)
decision = evaluate_merge_readiness(
main_status=main_status,
pr_status=pr_status,
required_contexts=contexts,
pr_has_current_base=current_base,
)
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
if decision.action == "update":
update_pull(pr_number, dry_run=dry_run)
post_comment(
pr_number,
(
f"merge-queue: updated this branch with `{WATCH_BRANCH}` at "
f"`{main_sha[:12]}`. Waiting for CI on the refreshed head."
),
dry_run=dry_run,
)
return 0
if decision.ready:
latest_main_sha = get_branch_head(WATCH_BRANCH)
if latest_main_sha != main_sha:
print(
f"::notice::main moved {main_sha[:8]} -> {latest_main_sha[:8]}; "
"deferring to next tick"
)
return 0
merge_pull(pr_number, dry_run=dry_run)
return 0
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
_require_runtime_env()
return process_once(dry_run=args.dry_run)
if __name__ == "__main__":
sys.exit(main())
+13 -7
View File
@@ -620,8 +620,8 @@ def render_status(
state is "success" if every item has at least one valid ack
(body section presence is informational only — peer-ack is the
real gate). "pending" is reserved for the soft-fail path
(tier:low) and is set by the caller.
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 = [
@@ -640,8 +640,11 @@ def render_status(
shown += f", +{len(missing) - 3}"
desc_parts.append(f"missing: {shown}")
if missing_body:
desc_parts.append(f"body-unfilled: {len(missing_body)}")
state = "success" if not missing else "failure"
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)
@@ -773,9 +776,12 @@ def main(argv: list[str] | None = None) -> int:
state, description = render_status(items, ack_state, body_state)
mode = get_tier_mode(pr, cfg)
if state == "failure" and mode == "soft":
state = "pending"
description = f"[soft-fail tier:low] {description}"
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}")
@@ -0,0 +1,114 @@
import importlib.util
import sys
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py"
spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT)
mq = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mq
spec.loader.exec_module(mq)
def test_latest_statuses_dedupes_by_context_newest_first():
statuses = [
{"context": "CI / all-required (pull_request)", "status": "failure"},
{"context": "sop-checklist / all-items-acked (pull_request)", "state": "success"},
{"context": "CI / all-required (pull_request)", "status": "success"},
]
latest = mq.latest_statuses_by_context(statuses)
assert latest["CI / all-required (pull_request)"]["status"] == "failure"
assert latest["sop-checklist / all-items-acked (pull_request)"]["state"] == "success"
def test_required_contexts_green_rejects_missing_and_pending():
latest = mq.latest_statuses_by_context([
{"context": "CI / all-required (pull_request)", "status": "success"},
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "pending"},
])
ok, missing_or_bad = mq.required_contexts_green(
latest,
[
"CI / all-required (pull_request)",
"sop-checklist / all-items-acked (pull_request)",
"qa-review / approved (pull_request)",
],
)
assert ok is False
assert missing_or_bad == [
"sop-checklist / all-items-acked (pull_request)=pending",
"qa-review / approved (pull_request)=missing",
]
def test_choose_next_pr_sorts_by_queue_label_timestamp_then_number():
issues = [
{
"number": 12,
"pull_request": {},
"labels": [{"name": "merge-queue"}],
"created_at": "2026-05-13T05:00:00Z",
"updated_at": "2026-05-13T06:00:00Z",
},
{
"number": 9,
"pull_request": {},
"labels": [{"name": "merge-queue"}],
"created_at": "2026-05-13T04:00:00Z",
"updated_at": "2026-05-13T07:00:00Z",
},
{
"number": 7,
"labels": [{"name": "merge-queue"}],
"created_at": "2026-05-13T03:00:00Z",
},
]
selected = mq.choose_next_queued_issue(issues, queue_label="merge-queue")
assert selected["number"] == 9
def test_pr_needs_update_when_base_sha_absent_from_commits():
commits = [
{"sha": "head"},
{"sha": "parent"},
]
assert mq.pr_contains_base_sha(commits, "mainsha") is False
assert mq.pr_contains_base_sha(commits, "parent") is True
def test_merge_decision_requires_main_green_pr_green_and_current_base():
required = ["CI / all-required (pull_request)"]
main_status = {"state": "success", "statuses": []}
pr_status = {
"state": "success",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
}
decision = mq.evaluate_merge_readiness(
main_status=main_status,
pr_status=pr_status,
required_contexts=required,
pr_has_current_base=True,
)
assert decision.ready is True
assert decision.action == "merge"
def test_merge_decision_updates_stale_pr_before_merge():
decision = mq.evaluate_merge_readiness(
main_status={"state": "success", "statuses": []},
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
required_contexts=["CI / all-required (pull_request)"],
pr_has_current_base=False,
)
assert decision.ready is False
assert decision.action == "update"
@@ -410,6 +410,7 @@ class TestRenderStatus(unittest.TestCase):
self._state_with(all_slugs),
{it["slug"]: False for it in self.items},
)
self.assertEqual(state, "failure")
self.assertIn("body-unfilled", desc)
@@ -519,6 +520,31 @@ class TestEndToEndAckFlow(unittest.TestCase):
self.assertEqual(result_state, "success")
self.assertIn("7/7", desc)
def test_all_acks_still_fail_when_body_section_unfilled(self):
items = _items_by_slug()
aliases = _numeric_aliases()
comments = [
_comment("qa-bot", "/sop-ack comprehensive-testing"),
_comment("eng-bot", "/sop-ack local-postgres-e2e"),
_comment("eng-bot", "/sop-ack staging-smoke"),
_comment("mgr-bot", "/sop-ack root-cause"),
_comment("eng-bot", "/sop-ack five-axis-review"),
_comment("mgr-bot", "/sop-ack no-backwards-compat"),
_comment("eng-bot", "/sop-ack memory-consulted"),
]
def probe(slug, users):
return list(users)
state = sop.compute_ack_state(comments, "alice-author", items, aliases, probe)
body = {it["slug"]: True for it in items.values()}
body["root-cause"] = False
items_list = list(items.values())
result_state, desc = sop.render_status(items_list, state, body)
self.assertEqual(result_state, "failure")
self.assertIn("7/7", desc)
self.assertIn("body-unfilled: root-cause", desc)
if __name__ == "__main__":
unittest.main(verbosity=2)
+23 -54
View File
@@ -1,89 +1,58 @@
# audit-force-merge — emit `incident.force_merge` to the runner log when
# a PR is merged with required-status checks NOT all green. Vector picks
# audit-force-merge — emit `incident.force_merge` to runner stdout when
# a PR is merged with required-status-checks not green. Vector picks
# the JSON line off docker_logs and ships to Loki on
# molecule-canonical-obs (per `reference_obs_stack_phase1`); query as:
#
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
#
# Companion to `audit-force-merge.sh` (script-extract pattern, same as
# sop-tier-check). The audit observes BOTH UI-merged and REST-merged PRs
# uniformly per `feedback_gh_cli_merge_lies_use_rest`.
# Closes the §SOP-6 audit gap (the doc says force-merges write to
# `structure_events`, but that table lives in the platform DB, not
# Gitea-side; Loki is the practical equivalent for Gitea Actions
# events). When the credential / observability stack converges later,
# this can sync into structure_events from Loki via a backfill job —
# the structured JSON shape is forward-compatible.
#
# Closes the §SOP-6 audit gap for the molecule-core repo. RFC:
# internal#219 §6. Mirrors the same-named workflow in
# molecule-controlplane; design rationale lives in the RFC, not here,
# to keep the workflow file scannable.
# Logic in `.gitea/scripts/audit-force-merge.sh` per the same script-
# extract pattern as sop-tier-check.
name: audit-force-merge
# pull_request_target loads from the base branch — same security model
# as sop-tier-check. Without this, a PR author could rewrite the
# workflow on their own PR and skip the audit emission for their own
# force-merge. The base-branch checkout below ALSO uses
# `base.sha`, not `base.ref`, so a fast-moving base can't slip a
# different audit script in under us.
# as sop-tier-check. Without this, an attacker could rewrite the
# workflow on a PR and skip the audit emission for their own
# force-merge. See `.gitea/workflows/sop-tier-check.yml` for the full
# rationale.
on:
pull_request_target:
types: [closed]
# `pull-requests: read` + `contents: read` covers everything the script
# needs (fetch PR + commit statuses). `issues:` deliberately omitted —
# audit fires-and-forgets to stdout, never opens issues.
permissions:
contents: read
pull-requests: read
jobs:
audit:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
# Skip when PR is closed without merge — saves a runner.
if: github.event.pull_request.merged == true
steps:
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# base.sha pinning, NOT base.ref — see header rationale.
ref: ${{ github.event.pull_request.base.sha }}
- name: Detect force-merge + emit audit event
env:
# Same org-level secret the sop-tier-check workflow uses;
# falls back to the auto-injected GITHUB_TOKEN if the
# org-level SOP_TIER_CHECK_TOKEN isn't set on a transitional
# repo.
# Same org-level secret the sop-tier-check workflow uses.
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 }}
# Required-status-check contexts to evaluate at merge time.
# Newline-separated. MUST mirror branch protection's
# status_check_contexts for protected branches
# (currently `main`; `staging` protection forthcoming per
# RFC internal#219 Phase 4).
#
# Initialized 2026-05-11 from the current molecule-core `main`
# branch protection:
#
# GET /api/v1/repos/molecule-ai/molecule-core/
# branch_protections/main
# → status_check_contexts = [
# "Secret scan / Scan diff for credential-shaped strings (pull_request)",
# "sop-tier-check / tier-check (pull_request)"
# ]
#
# Newline-separated. Mirror this against branch protection
# (settings → branches → protected branch → required checks).
# Declared here rather than fetched from /branch_protections
# because that endpoint requires admin write — sop-tier-bot
# is read-only by design (least-privilege per
# `feedback_least_privilege_via_workflow_env` / internal#257).
# Drift between this env and the real protection list is
# auto-detected by `ci-required-drift.yml` (RFC §4 + §6),
# which opens a `[ci-drift]` issue within one hour.
#
# When the protection set changes (e.g. Phase 4 adds the
# `ci / all-required (pull_request)` sentinel), update BOTH
# branch protection AND this env in the SAME PR; drift-detect
# will otherwise file an issue for you.
# because that endpoint requires admin write — sop-tier-bot is
# read-only by design (least-privilege).
REQUIRED_CHECKS: |
Secret scan / Scan diff for credential-shaped strings (pull_request)
sop-tier-check / tier-check (pull_request)
CI / all-required (pull_request)
sop-checklist / all-items-acked (pull_request)
run: bash .gitea/scripts/audit-force-merge.sh
+4 -1
View File
@@ -170,9 +170,12 @@ jobs:
# 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: golangci-lint run --timeout 3m ./...
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: needs.changes.outputs.platform == 'true'
name: Diagnostic — per-package verbose 60s
run: |
+1
View File
@@ -168,6 +168,7 @@ jobs:
- name: Install Playwright browsers
if: needs.detect-changes.outputs.canvas == 'true'
timeout-minutes: 10
run: npx playwright install --with-deps chromium
- name: Run staging canvas E2E
+51
View File
@@ -0,0 +1,51 @@
name: gitea-merge-queue
# External serialized merge queue for Gitea 1.22.6.
#
# Gitea's `pull_auto_merge` table is not a real merge queue: it does not
# serialize green PRs against a freshly-tested latest main. This workflow runs
# the user-space queue bot, one PR per tick, using the non-bypass merge actor.
#
# Queue contract:
# - add label `merge-queue` to an open same-repo PR
# - bot updates stale PR heads with current main, then waits for CI
# - bot merges only when current main is green and required PR contexts pass
# - add `merge-queue-hold` to pause a queued PR without removing it
on:
schedule:
- cron: '*/5 * * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: gitea-merge-queue-${{ github.repository }}
cancel-in-progress: false
jobs:
queue:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out queue script from main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Process one queued PR
env:
# AUTO_SYNC_TOKEN is the devops-engineer persona PAT. It is the
# non-bypass merge actor allowed by branch protection.
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
QUEUE_LABEL: merge-queue
HOLD_LABEL: merge-queue-hold
UPDATE_STYLE: merge
REQUIRED_CONTEXTS: >-
CI / all-required (pull_request),
sop-checklist / all-items-acked (pull_request)
run: python3 .gitea/scripts/gitea-merge-queue.py
+11 -3
View File
@@ -66,19 +66,27 @@ jobs:
# PR#372's ci.yml port used. Diffs against the PR base or the
# previous push SHA, then matches against the wheel-relevant
# path set.
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
#
# Root fix (mc#917): Gitea Actions does not expose github.event.before
# as a ${{ }} template-expression that resolves in shell scripts for
# push events (it becomes empty string). The env var GITHUB_EVENT_BEFORE
# IS set by the runner for push events. Guard git cat-file with
# `timeout 30` to prevent indefinite hangs on malformed BASE values.
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
BASE="${GITHUB_EVENT_BEFORE:-}"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
# New branch or no previous SHA: treat as wheel-relevant.
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
echo "::notice::BASE=$BASE not in local clone (shallow fetch or pruned ref)"
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
+1 -1
View File
@@ -69,7 +69,7 @@ name: sop-checklist-gate
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
issue_comment:
types: [created, edited, deleted]
+9 -5
View File
@@ -40,11 +40,15 @@ name: Sweep stale AWS Secrets Manager secrets
# the mostly-orphan tunnels) refuses to nuke past the threshold.
on:
schedule:
# Hourly at :30 — offsets from sweep-cf-orphans (:15) and
# sweep-cf-tunnels (:45) so the three janitors don't burst the
# CP admin endpoints at the same minute.
- cron: '30 * * * *'
# Disabled as an hourly schedule until the dedicated
# AWS_SECRETS_JANITOR_* key exists in the key-management SSOT and is
# mirrored into Gitea. Falling back to the molecule-cp app principal is
# intentionally not allowed: it lacks account-wide ListSecrets, and
# granting that to an application credential would weaken least privilege.
#
# Keep the manual trigger so operators can validate the workflow immediately
# after provisioning the janitor key, then restore the hourly :30 schedule.
workflow_dispatch:
# Don't let two sweeps race the same AWS account.
concurrency:
group: sweep-aws-secrets
+9 -2
View File
@@ -11,8 +11,9 @@ name: Ops Scripts Tests
# - `continue-on-error: true` on the job (RFC §1 contract).
#
# Runs the unittest suite for scripts/ on every PR + push that touches
# anything under scripts/. Kept separate from the main CI so a script-only
# change doesn't trigger the heavier Go/Canvas/Python pipelines.
# anything under scripts/ or .gitea/scripts/. Kept separate from the main CI
# so a script-only change doesn't trigger the heavier Go/Canvas/Python
# pipelines.
#
# Discovery layout: tests sit alongside the code they test (see
# scripts/ops/test_sweep_cf_decide.py for the pattern; scripts/
@@ -27,11 +28,13 @@ on:
branches: [main, staging]
paths:
- 'scripts/**'
- '.gitea/scripts/**'
- '.gitea/workflows/test-ops-scripts.yml'
pull_request:
branches: [main, staging]
paths:
- 'scripts/**'
- '.gitea/scripts/**'
- '.gitea/workflows/test-ops-scripts.yml'
env:
@@ -53,6 +56,8 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
- name: Install .gitea script test dependencies
run: python -m pip install --quiet 'pytest==9.0.2' 'PyYAML==6.0.2'
- name: Run scripts/ unittests (build_runtime_package, ...)
# Top-level scripts/ tests live alongside their target file
# (e.g. scripts/test_build_runtime_package.py exercises
@@ -64,3 +69,5 @@ jobs:
- name: Run scripts/ops/ unittests (sweep_cf_decide, ...)
working-directory: scripts/ops
run: python -m unittest discover -p 'test_*.py' -v
- name: Run .gitea/scripts pytest suite
run: python -m pytest .gitea/scripts/tests -q
+1
View File
@@ -131,6 +131,7 @@ jobs:
- name: Install Playwright browsers
if: needs.detect-changes.outputs.canvas == 'true'
timeout-minutes: 10
run: npx playwright install --with-deps chromium
- name: Run staging canvas E2E
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
/**
* Unit tests for formatAuditRelativeTime — pure date formatter from AuditTrailPanel.
*/
import { describe, it, expect } from "vitest";
import { formatAuditRelativeTime } from "../AuditTrailPanel";
describe("formatAuditRelativeTime", () => {
it('returns "just now" for timestamps within the last minute', () => {
const now = 1_700_000_000_000;
const thirtySecAgo = new Date(now - 30_000).toISOString();
expect(formatAuditRelativeTime(thirtySecAgo, now)).toBe("just now");
});
it('returns "Xm ago" for timestamps within the last hour', () => {
const now = 1_700_000_000_000;
const fiveMinAgo = new Date(now - 5 * 60_000).toISOString();
expect(formatAuditRelativeTime(fiveMinAgo, now)).toBe("5m ago");
});
it('returns "Xh ago" for timestamps within the last day', () => {
const now = 1_700_000_000_000;
const threeHoursAgo = new Date(now - 3 * 3_600_000).toISOString();
expect(formatAuditRelativeTime(threeHoursAgo, now)).toBe("3h ago");
});
it("returns locale date string for timestamps older than 24h", () => {
const now = 1_700_000_000_000;
const twoDaysAgo = new Date(now - 2 * 86_400_000).toISOString();
const result = formatAuditRelativeTime(twoDaysAgo, now);
// Should be a date string (not "Xh ago" or "Xm ago")
expect(result).not.toMatch(/m ago|h ago|just now/);
expect(result).toBe(new Date(twoDaysAgo).toLocaleDateString());
});
it("handles the boundary between minute and hour correctly", () => {
const now = 1_700_000_000_000;
const exactlyOneHourAgo = new Date(now - 3_600_000).toISOString();
expect(formatAuditRelativeTime(exactlyOneHourAgo, now)).toBe("1h ago");
});
it("handles the boundary between hour and day correctly", () => {
const now = 1_700_000_000_000;
// 23h ago is < 24h so it shows "23h ago"; exactly 24h falls through to date string
const twentyThreeHoursAgo = new Date(now - 23 * 3_600_000).toISOString();
expect(formatAuditRelativeTime(twentyThreeHoursAgo, now)).toBe("23h ago");
});
it("returns locale date string for exactly 24h ago (boundary)", () => {
const now = 1_700_000_000_000;
const exactlyOneDayAgo = new Date(now - 86_400_000).toISOString();
const result = formatAuditRelativeTime(exactlyOneDayAgo, now);
// diff is exactly 86_400_000, which is NOT < 86_400_000, so it falls through
expect(result).toBe(new Date(exactlyOneDayAgo).toLocaleDateString());
});
it("future timestamps return 'just now' (negative diff < 60_000)", () => {
const now = 1_700_000_000_000;
const future = new Date(now + 60_000).toISOString();
// Negative diff passes diff < 60_000, returning "just now"
expect(formatAuditRelativeTime(future, now)).toBe("just now");
});
});
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
/**
* Unit tests for pure helpers from MemoryInspectorPanel:
* isPluginUnavailableError, formatRelativeTime, formatTTL
*
* These are the three exported non-component functions. The component
* itself (MemoryInspectorPanel) requires full API + store mocking and
* is exercised by the existing MemoryTab.test.tsx.
*/
import { describe, it, expect } from "vitest";
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
describe("isPluginUnavailableError", () => {
it("returns true when Error message contains MEMORY_PLUGIN_URL", () => {
const err = new Error("memory: could not resolve MEMORY_PLUGIN_URL — plugin not configured");
expect(isPluginUnavailableError(err)).toBe(true);
});
it("returns true for Error containing MEMORY_PLUGIN_URL", () => {
expect(isPluginUnavailableError(new Error("MEMORY_PLUGIN_URL is not set"))).toBe(true);
});
it("returns false for unrelated error messages", () => {
expect(isPluginUnavailableError(new Error("workspace not found"))).toBe(false);
});
it("returns false for null", () => {
expect(isPluginUnavailableError(null)).toBe(false);
});
it("returns false for undefined", () => {
expect(isPluginUnavailableError(undefined)).toBe(false);
});
it("returns false for plain objects without message", () => {
expect(isPluginUnavailableError({ code: 503 })).toBe(false);
});
it("is case-sensitive (MEMORY_PLUGIN_URL must match exactly)", () => {
const lowerErr = new Error("memory_plugin_url missing");
const upperErr = new Error("MEMORY_PLUGIN_URL missing");
expect(isPluginUnavailableError(lowerErr)).toBe(false);
expect(isPluginUnavailableError(upperErr)).toBe(true);
});
});
describe("formatTTL", () => {
it("returns '' for null", () => {
expect(formatTTL(null)).toBe("");
});
it("returns '' for undefined", () => {
expect(formatTTL(undefined)).toBe("");
});
it('returns "expired" when expiresAt is in the past', () => {
const past = new Date(Date.now() - 60_000).toISOString();
expect(formatTTL(past)).toBe("expired");
});
it('returns "Xs" for less than a minute', () => {
const soon = new Date(Date.now() + 30_000).toISOString();
expect(formatTTL(soon)).toBe("30s");
});
it('returns "Xm" for less than an hour', () => {
const soon = new Date(Date.now() + 5 * 60_000).toISOString();
expect(formatTTL(soon)).toBe("5m");
});
it('returns "Xh" for less than a day', () => {
const soon = new Date(Date.now() + 3 * 3_600_000).toISOString();
expect(formatTTL(soon)).toBe("3h");
});
it('returns "Xd" for more than a day', () => {
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString();
expect(formatTTL(soon)).toBe("2d");
});
it("returns '' for invalid date string", () => {
expect(formatTTL("not-a-date")).toBe("");
});
it("returns '' for empty string", () => {
expect(formatTTL("")).toBe("");
});
});
@@ -0,0 +1,390 @@
// @vitest-environment jsdom
/**
* Tests for SidePanel — general rendering and non-tab behaviors.
*
* Companion to SidePanel.tabs.test.tsx which covers tablist ARIA
* and localStorage width persistence.
*
* Covers:
* - Null when no node is selected
* - Null when selectedNodeId points to a missing node
* - Header: node name, role, tier badge
* - MetaPill capability summary pills
* - Resize handle: role=separator, aria-valuenow/min/max, aria-orientation
* - Resize handle: ArrowLeft/Right/Home/End keyboard nav
* - Needs-restart banner + Restart Now button
* - Current-task banner with pulsing dot
* - Footer shows workspace ID
* - Close button calls selectNode(null)
* - Tab switch via onClick fires setPanelTab
* - setSidePanelWidth called on mount
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidePanel } from "../SidePanel";
// ── Tab content stubs ───────────────────────────────────────────────────────
vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
vi.mock("../AuditTrailPanel", () => ({ AuditTrailPanel: () => null }));
vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
vi.mock("../Tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
// ── Canvas store mock — mutable so each test can reconfigure ───────────────
const mockSetPanelTab = vi.fn();
const mockSelectNode = vi.fn();
const mockSetSidePanelWidth = vi.fn();
const mockRestartWorkspace = vi.fn().mockResolvedValue(undefined);
const BASE_NODE = {
id: "ws-1",
data: {
name: "Test Workspace",
status: "online" as const,
tier: 2,
role: "Engineer",
parentId: null,
needsRestart: false,
currentTask: null,
agentCard: null,
},
};
// Mutable store state — tests reassign fields to test different states
let storeState = {
selectedNodeId: "ws-1" as string | null,
panelTab: "chat",
setPanelTab: mockSetPanelTab,
selectNode: mockSelectNode,
setSidePanelWidth: mockSetSidePanelWidth,
nodes: [BASE_NODE],
restartWorkspace: mockRestartWorkspace,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)),
{ getState: () => storeState }
),
summarizeWorkspaceCapabilities: () => ({ runtime: "claude-code", skillCount: 3 }),
}));
beforeEach(() => {
mockSetPanelTab.mockReset();
mockSelectNode.mockReset();
mockSetSidePanelWidth.mockReset();
mockRestartWorkspace.mockReset().mockResolvedValue(undefined);
localStorage.clear();
// Reset store state to default
storeState = {
selectedNodeId: "ws-1",
panelTab: "chat",
setPanelTab: mockSetPanelTab,
selectNode: mockSelectNode,
setSidePanelWidth: mockSetSidePanelWidth,
nodes: [BASE_NODE],
restartWorkspace: mockRestartWorkspace,
};
});
afterEach(() => {
cleanup();
});
// ─── Null guard ──────────────────────────────────────────────────────────────
describe("SidePanel — null guard", () => {
it("returns null when selectedNodeId is null", () => {
storeState.selectedNodeId = null;
const { container } = render(<SidePanel />);
expect(container.firstChild).toBeNull();
});
it("returns null when selectedNodeId does not match any node", () => {
storeState.selectedNodeId = "nonexistent-ws";
storeState.nodes = [];
const { container } = render(<SidePanel />);
expect(container.firstChild).toBeNull();
});
});
// ─── Header ─────────────────────────────────────────────────────────────────
describe("SidePanel — header", () => {
it("shows node name in heading", () => {
render(<SidePanel />);
expect(screen.getByRole("heading", { name: "Test Workspace" })).toBeTruthy();
});
it("shows node role", () => {
render(<SidePanel />);
expect(screen.getByText("Engineer")).toBeTruthy();
});
it("shows tier badge with correct value", () => {
render(<SidePanel />);
// T2 appears in header badge AND meta pill — confirm at least one
const all = screen.getAllByText("T2");
expect(all.length).toBeGreaterThanOrEqual(1);
});
it("close button is present with aria-label", () => {
render(<SidePanel />);
expect(screen.getByRole("button", { name: /close workspace panel/i })).toBeTruthy();
});
it("close button calls selectNode(null)", () => {
render(<SidePanel />);
fireEvent.click(screen.getByRole("button", { name: /close workspace panel/i }));
expect(mockSelectNode).toHaveBeenCalledWith(null);
});
});
// ─── MetaPills ─────────────────────────────────────────────────────────────
describe("SidePanel — meta pills", () => {
it("renders Tier, Runtime, Skills, and Status pills in the meta row", () => {
render(<SidePanel />);
// All four labels appear somewhere in the meta pills row
expect(screen.getByText(/tier/i)).toBeTruthy();
expect(screen.getByText(/runtime/i)).toBeTruthy();
expect(screen.getByText(/skills/i)).toBeTruthy();
expect(screen.getByText(/status/i)).toBeTruthy();
});
it("shows correct runtime value in meta pill", () => {
render(<SidePanel />);
expect(screen.getByText("claude-code")).toBeTruthy();
});
it("shows skill count in meta pill", () => {
render(<SidePanel />);
expect(screen.getByText("3")).toBeTruthy();
});
});
// ─── Resize handle ──────────────────────────────────────────────────────────
describe("SidePanel — resize handle", () => {
it("has role=separator", () => {
render(<SidePanel />);
expect(screen.getByRole("separator")).toBeTruthy();
});
it("has aria-label='Resize workspace panel'", () => {
render(<SidePanel />);
expect(screen.getByRole("separator").getAttribute("aria-label")).toBe(
"Resize workspace panel"
);
});
it("has aria-valuenow=480 (default width)", () => {
render(<SidePanel />);
expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("480");
});
it("has aria-valuemin=320", () => {
render(<SidePanel />);
expect(screen.getByRole("separator").getAttribute("aria-valuemin")).toBe("320");
});
it("has aria-valuemax=800", () => {
render(<SidePanel />);
expect(screen.getByRole("separator").getAttribute("aria-valuemax")).toBe("800");
});
it("has aria-orientation=vertical", () => {
render(<SidePanel />);
expect(screen.getByRole("separator").getAttribute("aria-orientation")).toBe("vertical");
});
it("has tabIndex=0 (focusable)", () => {
render(<SidePanel />);
expect(screen.getByRole("separator").getAttribute("tabindex")).toBe("0");
});
it("ArrowLeft increases width by 16px (STEP — moves left edge rightward, widens panel)", () => {
render(<SidePanel />);
const sep = screen.getByRole("separator");
fireEvent.keyDown(sep, { key: "ArrowLeft" });
const panel = document.querySelector(".fixed") as HTMLElement;
expect(parseInt(panel.style.width, 10)).toBe(480 + 16); // widens
});
it("ArrowRight decreases width by 16px (STEP — moves left edge leftward, narrows panel)", () => {
render(<SidePanel />);
const sep = screen.getByRole("separator");
fireEvent.keyDown(sep, { key: "ArrowRight" });
const panel = document.querySelector(".fixed") as HTMLElement;
expect(parseInt(panel.style.width, 10)).toBe(480 - 16); // narrows
});
it("Home key sets width to MIN (320)", () => {
render(<SidePanel />);
fireEvent.keyDown(screen.getByRole("separator"), { key: "Home" });
const panel = document.querySelector(".fixed") as HTMLElement;
expect(parseInt(panel.style.width, 10)).toBe(320);
});
it("End key sets width to MAX (800)", () => {
render(<SidePanel />);
fireEvent.keyDown(screen.getByRole("separator"), { key: "End" });
const panel = document.querySelector(".fixed") as HTMLElement;
expect(parseInt(panel.style.width, 10)).toBe(800);
});
it("ArrowLeft persists new width to localStorage", () => {
render(<SidePanel />);
fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowLeft" });
expect(localStorage.getItem("molecule:sidepanel-width")).toBe(String(480 + 16));
});
it("Home persists new width to localStorage", () => {
render(<SidePanel />);
fireEvent.keyDown(screen.getByRole("separator"), { key: "Home" });
expect(localStorage.getItem("molecule:sidepanel-width")).toBe("320");
});
});
// ─── Needs-restart banner ────────────────────────────────────────────────────
describe("SidePanel — needs-restart banner", () => {
it("shows banner when needsRestart=true and no currentTask", () => {
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, needsRestart: true, currentTask: null } }];
render(<SidePanel />);
expect(screen.getByText(/config changed/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /restart now/i })).toBeTruthy();
});
it("does NOT show banner when needsRestart=false", () => {
render(<SidePanel />);
expect(screen.queryByText(/config changed/i)).toBeNull();
expect(screen.queryByRole("button", { name: /restart now/i })).toBeNull();
});
it("Restart Now button calls restartWorkspace(selectedNodeId)", () => {
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, needsRestart: true, currentTask: null } }];
render(<SidePanel />);
fireEvent.click(screen.getByRole("button", { name: /restart now/i }));
expect(mockRestartWorkspace).toHaveBeenCalledWith("ws-1");
});
});
// ─── Current-task banner ────────────────────────────────────────────────────
describe("SidePanel — current-task banner", () => {
it("shows banner when currentTask is set", () => {
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, currentTask: "Deploying bundle..." } }];
render(<SidePanel />);
expect(screen.getByText("Deploying bundle...")).toBeTruthy();
});
it("does NOT show banner when currentTask is null", () => {
render(<SidePanel />);
expect(screen.queryByText(/deploying bundle/i)).toBeNull();
});
});
// ─── Footer ─────────────────────────────────────────────────────────────────
describe("SidePanel — footer", () => {
it("footer shows workspace ID in monospace font", () => {
render(<SidePanel />);
// ws-1 appears in the footer with font-mono class
expect(screen.getByText("ws-1")).toBeTruthy();
});
});
// ─── Tab switching ─────────────────────────────────────────────────────────
describe("SidePanel — tab switching", () => {
it("clicking Details tab calls setPanelTab('details')", () => {
render(<SidePanel />);
fireEvent.click(screen.getByRole("tab", { name: /details/i }));
expect(mockSetPanelTab).toHaveBeenCalledWith("details");
});
it("clicking Plugins tab calls setPanelTab('skills')", () => {
render(<SidePanel />);
fireEvent.click(screen.getByRole("tab", { name: /plugins/i }));
expect(mockSetPanelTab).toHaveBeenCalledWith("skills");
});
it("clicking Terminal tab calls setPanelTab('terminal')", () => {
render(<SidePanel />);
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(mockSetPanelTab).toHaveBeenCalledWith("terminal");
});
});
// ─── setSidePanelWidth ─────────────────────────────────────────────────────
describe("SidePanel — setSidePanelWidth side-effect", () => {
it("calls setSidePanelWidth with 480 (default width) on mount", () => {
render(<SidePanel />);
expect(mockSetSidePanelWidth).toHaveBeenCalledWith(480);
});
it("updates setSidePanelWidth after keyboard resize", () => {
render(<SidePanel />);
mockSetSidePanelWidth.mockClear();
fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowLeft" });
expect(mockSetSidePanelWidth).toHaveBeenCalledWith(480 + 16);
});
});
// ─── Width localStorage ────────────────────────────────────────────────────
describe("SidePanel — width localStorage", () => {
it("does not persist default width to localStorage on initial mount (only on user resize)", () => {
render(<SidePanel />);
// localStorage is only written by the keyboard resize handler, not on mount
expect(localStorage.getItem("molecule:sidepanel-width")).toBeNull();
});
it("reads saved width from localStorage", () => {
localStorage.setItem("molecule:sidepanel-width", "600");
const { container } = render(<SidePanel />);
const panel = container.firstChild as HTMLElement;
expect(panel.style.width).toBe("600px");
});
it("caps saved width to default when below minimum", () => {
localStorage.setItem("molecule:sidepanel-width", "100");
const { container } = render(<SidePanel />);
const panel = container.firstChild as HTMLElement;
expect(panel.style.width).toBe("480px");
});
});
// ─── Offline status ─────────────────────────────────────────────────────────
describe("SidePanel — offline status", () => {
it("shows tier badge even when node is offline", () => {
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, status: "offline" as const } }];
render(<SidePanel />);
// T2 appears in both header badge and meta pill — just confirm at least one exists
const all = screen.getAllByText("T2");
expect(all.length).toBeGreaterThanOrEqual(1);
});
it("shows 'offline' in the Status meta pill when node is offline", () => {
storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, status: "offline" as const } }];
render(<SidePanel />);
expect(screen.getByText("offline")).toBeTruthy();
});
});
@@ -0,0 +1,260 @@
// @vitest-environment jsdom
/**
* Tests for TemplatePalette — the floating sidebar drawer.
*
* Covers:
* - Toggle button aria-label (open / closed)
* - Sidebar renders when open, hides when closed
* - Sidebar header: "Templates" heading, subtitle
* - Loading state
* - Empty state ("No templates found")
* - Template cards: name, description, tier badge, skill pills
* - Deploy button calls deploy()
* - Errors swallowed → empty state shown
* - setTemplatePaletteOpen called on open/close
* - OrgTemplatesSection rendered inside sidebar
* - Import Agent Folder button in footer
* - Refresh templates button in footer
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Hoisted mocks — vi.hoisted() so they're available when vi.mock runs ──────
// IMPORTANT: use plain vi.fn() in the return object (NOT `const fn = vi.fn(); return { fn }`)
const { mockDeploy, mockSetTemplatePaletteOpen, mockGet } = vi.hoisted(() => ({
mockDeploy: vi.fn(),
mockSetTemplatePaletteOpen: vi.fn(),
mockGet: vi.fn(),
}));
vi.mock("@/hooks/useTemplateDeploy", () => ({
useTemplateDeploy: () => ({
deploy: mockDeploy,
deploying: null,
error: null,
modal: null,
}),
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn((selector: (s: { setTemplatePaletteOpen: typeof mockSetTemplatePaletteOpen }) => unknown) =>
selector({ setTemplatePaletteOpen: mockSetTemplatePaletteOpen })
),
}));
vi.mock("@/lib/api", () => ({
api: { get: mockGet },
}));
vi.mock("../OrgImportPreflightModal", () => ({
OrgImportPreflightModal: () => null,
}));
vi.mock("../ConfirmDialog", () => ({
ConfirmDialog: () => null,
}));
vi.mock("../Spinner", () => ({
Spinner: () => <span data-testid="spinner" aria-hidden="true" />,
}));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
// ── Component import — after all mocks ──────────────────────────────────────
import { TemplatePalette } from "../TemplatePalette";
beforeEach(() => {
mockDeploy.mockReset();
mockSetTemplatePaletteOpen.mockReset();
mockGet.mockReset().mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
// ── Helpers ──────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
const MOCK_TEMPLATES = [
{
id: "tmpl-1",
name: "Software Engineer",
description: "Best for writing code",
tier: 1,
skills: ["web-search", "read-file", "write-file"],
},
{
id: "tmpl-2",
name: "Researcher",
description: "Deep research agent",
tier: 2,
skills: [],
},
];
// ─── Toggle button ─────────────────────────────────────────────────────────
describe("TemplatePalette — toggle button", () => {
it("has aria-label='Open template palette' when closed", () => {
render(<TemplatePalette />);
expect(screen.getByRole("button", { name: /open template palette/i })).toBeTruthy();
});
it("has aria-label='Close template palette' when open", async () => {
render(<TemplatePalette />);
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
await flush();
expect(screen.getByRole("button", { name: /close template palette/i })).toBeTruthy();
});
it("clicking toggle opens sidebar", async () => {
render(<TemplatePalette />);
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
await flush();
expect(screen.getByRole("heading", { name: "Templates" })).toBeTruthy();
});
it("clicking toggle again closes sidebar", async () => {
render(<TemplatePalette />);
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
await flush();
fireEvent.click(screen.getByRole("button", { name: /close template palette/i }));
await flush();
expect(screen.queryByRole("heading", { name: "Templates" })).toBeNull();
});
it("calls setTemplatePaletteOpen(true) when opened", async () => {
render(<TemplatePalette />);
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
await flush();
expect(mockSetTemplatePaletteOpen).toHaveBeenCalledWith(true);
});
it("calls setTemplatePaletteOpen(false) when closed", async () => {
render(<TemplatePalette />);
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
await flush();
mockSetTemplatePaletteOpen.mockClear();
fireEvent.click(screen.getByRole("button", { name: /close template palette/i }));
await flush();
expect(mockSetTemplatePaletteOpen).toHaveBeenCalledWith(false);
});
});
// ─── Sidebar content ───────────────────────────────────────────────────────
describe("TemplatePalette — sidebar", () => {
async function openSidebar() {
fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
await flush();
}
it("shows 'Templates' heading", async () => {
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByRole("heading", { name: "Templates" })).toBeTruthy();
});
it("shows subtitle 'Click to deploy a workspace'", async () => {
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText(/click to deploy a workspace/i)).toBeTruthy();
});
it("shows loading state", async () => {
mockGet.mockReturnValue(new Promise(() => {}));
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByTestId("spinner")).toBeTruthy();
expect(screen.getByText(/loading/i)).toBeTruthy();
});
it("shows empty state when no templates", async () => {
mockGet.mockResolvedValue([]);
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText(/no templates found/i)).toBeTruthy();
});
it("renders template cards", async () => {
mockGet.mockResolvedValue(MOCK_TEMPLATES);
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText("Software Engineer")).toBeTruthy();
expect(screen.getByText("Researcher")).toBeTruthy();
});
it("shows template description", async () => {
mockGet.mockResolvedValue(MOCK_TEMPLATES);
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText(/best for writing code/i)).toBeTruthy();
});
it("shows tier badge on template card", async () => {
mockGet.mockResolvedValue(MOCK_TEMPLATES);
render(<TemplatePalette />);
await openSidebar();
// T1 appears in tier badge
expect(screen.getAllByText("T1").length).toBeGreaterThanOrEqual(1);
});
it("shows up to 3 skill pills", async () => {
mockGet.mockResolvedValue(MOCK_TEMPLATES);
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText("web-search")).toBeTruthy();
expect(screen.getByText("read-file")).toBeTruthy();
expect(screen.getByText("write-file")).toBeTruthy();
});
it("shows '+N more' when more than 3 skills", async () => {
mockGet.mockResolvedValue([
{ id: "tmpl-many", name: "Full Stack", description: "", tier: 1, skills: ["a", "b", "c", "d", "e"] },
]);
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText("+2")).toBeTruthy();
});
it("deploy button calls deploy(t)", async () => {
mockGet.mockResolvedValue(MOCK_TEMPLATES);
render(<TemplatePalette />);
await openSidebar();
const deployBtns = screen.getAllByRole("button", { name: /software engineer/i });
await act(async () => { deployBtns[0].click(); });
expect(mockDeploy).toHaveBeenCalledWith(MOCK_TEMPLATES[0]);
});
it("shows empty state when api.get rejects (error is swallowed)", async () => {
mockGet.mockRejectedValue(new Error("server error"));
render(<TemplatePalette />);
await openSidebar();
await waitFor(() => {
expect(screen.getByText(/no templates found/i)).toBeTruthy();
});
});
it("renders OrgTemplatesSection inside sidebar", async () => {
render(<TemplatePalette />);
await openSidebar();
expect(document.querySelector("[data-testid='org-templates-section']")).toBeTruthy();
});
it("renders Import Agent Folder button in footer", async () => {
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByRole("button", { name: /import agent folder/i })).toBeTruthy();
});
it("renders Refresh templates button in footer", async () => {
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByRole("button", { name: /^refresh templates$/i })).toBeTruthy();
});
});
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
/**
* TopBar — canvas header scaffold with logo, canvas name, New Agent button,
* and SettingsButton integration point.
*
* Coverage:
* - Renders header with logo and canvas name (default and custom)
* - New Agent button present and clickable
* - SettingsButton rendered (via mock)
* - Ref forwarding wired (settingsGearRef passed as ref prop)
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { TopBar } from "../TopBar";
vi.mock("@/components/settings/SettingsButton", () => ({
SettingsButton: React.forwardRef<HTMLButtonElement, object>(
(_props, ref) => <button ref={ref} aria-label="Settings" type="button"></button>,
),
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Render ────────────────────────────────────────────────────────────────────
describe("TopBar — render", () => {
it("renders the header element", () => {
render(<TopBar />);
const header = document.querySelector("header");
expect(header).toBeTruthy();
});
it("shows default canvas name 'Canvas'", () => {
render(<TopBar />);
expect(document.body.textContent).toContain("Canvas");
});
it("shows custom canvas name when provided", () => {
render(<TopBar canvasName="Production Canvas" />);
expect(document.body.textContent).toContain("Production Canvas");
expect(document.body.textContent).not.toContain("Canvas\n"); // not default
});
it("renders New Agent button", () => {
render(<TopBar />);
const btn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.includes("New Agent"),
);
expect(btn).toBeTruthy();
});
it("renders SettingsButton", () => {
render(<TopBar />);
const settingsBtn = document.querySelector('button[aria-label="Settings"]');
expect(settingsBtn).toBeTruthy();
});
it("renders logo icon", () => {
render(<TopBar />);
const logo = Array.from(document.querySelectorAll("span")).find(
(s) => s.getAttribute("aria-hidden") === "true",
);
expect(logo).toBeTruthy();
expect(logo?.textContent).toContain("☁");
});
});
// ─── Interaction ──────────────────────────────────────────────────────────────
describe("TopBar — interaction", () => {
it("New Agent button is in the DOM and not disabled", () => {
render(<TopBar />);
const btn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.includes("New Agent"),
);
expect(btn).toBeTruthy();
expect(btn!.getAttribute("disabled")).toBeNull();
});
it("renders without crashing with empty canvasName", () => {
render(<TopBar canvasName="" />);
expect(document.querySelector("header")).toBeTruthy();
});
it("renders without crashing with long canvasName", () => {
const longName = "A".repeat(200);
render(<TopBar canvasName={longName} />);
expect(document.body.textContent).toContain(longName);
});
});
@@ -0,0 +1,323 @@
// @vitest-environment jsdom
/**
* MobileChat — mobile message thread + composer + sub-tabs.
*
* Per spec §04: wired to /workspaces/:id/a2a (method message/send).
* Slimmer surface than desktop ChatTab: no attachments, no topology overlay.
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { MobileChat } from "../MobileChat";
// ─── Mock store ───────────────────────────────────────────────────────────────
const mockAgentId = "ws-chat-test";
const mockOnBack = vi.fn();
// Module-level mutable state for the mock store.
const mockStoreState = {
nodes: [] as Array<{
id: string;
position: { x: number; y: number };
data: Record<string, unknown>;
width?: number;
height?: number;
}>,
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel) => sel(mockStoreState)),
{ getState: () => mockStoreState },
),
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
const agentCard = data.agentCard as Record<string, unknown> | null;
const skills = Array.isArray(agentCard?.skills)
? (agentCard.skills as Array<Record<string, unknown>>).map(
(s) => String(s.name || s.id || ""),
).filter(Boolean)
: [];
return {
runtime: (typeof data.runtime === "string" && data.runtime)
? data.runtime
: (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null),
skills,
skillCount: skills.length,
currentTask: String(data.currentTask ?? ""),
hasActiveTask: String(data.currentTask ?? "").trim().length > 0,
};
}),
}));
// ─── Mock API ─────────────────────────────────────────────────────────────────
const { mockApiPost } = vi.hoisted(() => ({
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
}));
vi.mock("@/lib/api", () => ({
api: { post: mockApiPost },
}));
// ─── Fixtures ────────────────────────────────────────────────────────────────
const onlineNode = {
id: mockAgentId,
position: { x: 0, y: 0 },
data: {
name: "Chat Agent",
status: "online",
tier: 2,
agentCard: {
runtime: "claude-code",
skills: [{ name: "web-search" }],
},
currentTask: "",
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
runtime: "claude-code",
needsRestart: false,
},
};
const offlineNode = {
id: "ws-offline",
position: { x: 0, y: 0 },
data: {
name: "Offline Agent",
status: "offline",
tier: 1,
agentCard: null,
currentTask: "",
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
runtime: "claude-code",
needsRestart: false,
},
};
const degradedNode = {
id: "ws-degraded",
position: { x: 0, y: 0 },
data: {
name: "Degraded Agent",
status: "degraded",
tier: 3,
agentCard: null,
currentTask: "",
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
runtime: "claude-code",
needsRestart: false,
},
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function renderChat(agentId: string, dark = false) {
return render(
<MobileChat
agentId={agentId}
dark={dark}
onBack={mockOnBack}
/>,
);
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
mockOnBack.mockClear();
mockStoreState.nodes = [];
mockStoreState.agentMessages = {};
mockApiPost.mockClear();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// ─── Not found ───────────────────────────────────────────────────────────────
describe("MobileChat — agent not found", () => {
it('renders "Agent not found." when node is absent', () => {
mockStoreState.nodes = [onlineNode];
const { container } = renderChat("nonexistent-id");
expect(container.textContent ?? "").toContain("Agent not found.");
});
});
// ─── Header ──────────────────────────────────────────────────────────────────
describe("MobileChat — header", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders Back button with aria-label", () => {
const { container } = renderChat(mockAgentId);
const backBtn = container.querySelector('[aria-label="Back"]');
expect(backBtn).toBeTruthy();
});
it("Back button calls onBack", () => {
const { container } = renderChat(mockAgentId);
const backBtn = container.querySelector('[aria-label="Back"]') as HTMLButtonElement;
backBtn.click();
expect(mockOnBack).toHaveBeenCalledTimes(1);
});
it("renders agent name in header", () => {
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Chat Agent");
});
it("renders a More button", () => {
const { container } = renderChat(mockAgentId);
const moreBtn = container.querySelector('[aria-label="More"]');
expect(moreBtn).toBeTruthy();
});
it("renders footer with agentId", () => {
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain(mockAgentId);
});
});
// ─── Composer ────────────────────────────────────────────────────────────────
describe("MobileChat — composer", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders a textarea for message input", () => {
const { container } = renderChat(mockAgentId);
const textarea = container.querySelector("textarea");
expect(textarea).toBeTruthy();
});
it("textarea has placeholder text", () => {
const { container } = renderChat(mockAgentId);
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea.placeholder).toBeTruthy();
expect(textarea.placeholder).toContain("Send a message");
});
it("renders a Send button with aria-label", () => {
const { container } = renderChat(mockAgentId);
const sendBtn = container.querySelector('[aria-label="Send"]');
expect(sendBtn).toBeTruthy();
});
it("Send button is disabled when textarea is empty (no draft)", () => {
const { container } = renderChat(mockAgentId);
const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement;
expect(sendBtn.disabled).toBe(true);
});
});
// ─── Tabs ─────────────────────────────────────────────────────────────────────
describe("MobileChat — tabs", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders My Chat and Agent Comms tab labels", () => {
const { container } = renderChat(mockAgentId);
const text = container.textContent ?? "";
expect(text).toContain("My Chat");
expect(text).toContain("Agent Comms");
});
it("defaults to My Chat tab", () => {
const { container } = renderChat(mockAgentId);
// My Chat is the default; if there are no messages it should show the empty state
expect(container.textContent ?? "").toContain("My Chat");
});
});
// ─── Empty state ─────────────────────────────────────────────────────────────
describe("MobileChat — empty state", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it('shows "Send a message to start chatting." when no messages', () => {
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
it("shows no messages when agentMessages[agentId] is absent (undefined)", () => {
// Explicitly set to empty to simulate no stored messages
mockStoreState.agentMessages = {};
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
});
// ─── Agent status ────────────────────────────────────────────────────────────
describe("MobileChat — agent status", () => {
it("renders composer for online agent", () => {
mockStoreState.nodes = [onlineNode];
const { container } = renderChat(mockAgentId);
expect(container.querySelector("textarea")).toBeTruthy();
});
it("renders composer for offline agent (with status text)", () => {
mockStoreState.nodes = [offlineNode];
const { container } = renderChat("ws-offline");
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
// Offline agent: textarea should be disabled
expect(textarea.disabled).toBe(true);
});
it("renders composer for degraded agent", () => {
mockStoreState.nodes = [degradedNode];
const { container } = renderChat("ws-degraded");
expect(container.querySelector("textarea")).toBeTruthy();
});
it("offline agent shows agent name", () => {
mockStoreState.nodes = [offlineNode];
const { container } = renderChat("ws-offline");
expect(container.textContent ?? "").toContain("Offline Agent");
});
});
// ─── Dark mode ───────────────────────────────────────────────────────────────
describe("MobileChat — dark mode", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders without crashing in dark mode", () => {
const { container } = renderChat(mockAgentId, true);
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
});
});
@@ -0,0 +1,367 @@
// @vitest-environment jsdom
/**
* MobileDetail — agent detail page with tabbed content (Overview/Activity/Config/Memory).
*
* Per spec §03: tabbed agent detail page. MobileChat (MR !717) was also tested here.
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { MobileDetail } from "../MobileDetail";
// ─── Mock store ───────────────────────────────────────────────────────────────
const mockNodeId = "ws-detail-test";
const mockOnBack = vi.fn();
const mockOnChat = vi.fn();
// Module-level mutable state for the mock store.
// Tests mutate this between cases to control what the component sees.
const mockStoreState = {
nodes: [] as Array<{
id: string;
position: { x: number; y: number };
data: Record<string, unknown>;
width?: number;
height?: number;
}>,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel) => sel(mockStoreState)),
{ getState: () => mockStoreState },
),
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
const agentCard = data.agentCard as Record<string, unknown> | null;
const skills = Array.isArray(agentCard?.skills)
? (agentCard.skills as Array<Record<string, unknown>>).map(
(s) => String(s.name || s.id || ""),
).filter(Boolean)
: [];
return {
runtime: (typeof data.runtime === "string" && data.runtime)
? data.runtime
: (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null),
skills,
skillCount: skills.length,
currentTask: String(data.currentTask ?? ""),
hasActiveTask: String(data.currentTask ?? "").trim().length > 0,
};
}),
}));
// Stub the API so DetailActivity doesn't attempt real network calls.
vi.mock("@/lib/api", () => ({ api: { get: vi.fn().mockResolvedValue([]) } }));
// ─── Fixtures ────────────────────────────────────────────────────────────────
const onlineNode = {
id: mockNodeId,
position: { x: 100, y: 200 },
data: {
name: "Test Agent",
status: "online",
tier: 2,
agentCard: {
runtime: "claude-code",
skills: [
{ name: "web-search", id: "skill-1" },
{ name: "code-review", id: "skill-2" },
{ name: "file-ops", id: "skill-3" },
],
},
currentTask: "Reviewing PR #717",
activeTasks: 3,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
runtime: "claude-code",
needsRestart: false,
},
width: 240,
height: 130,
};
const failedNode = {
id: "ws-failed",
position: { x: 0, y: 0 },
data: {
name: "Failed Worker",
status: "failed",
tier: 4,
agentCard: null,
currentTask: "",
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0.8,
lastSampleError: "Connection refused",
url: "",
parentId: null,
runtime: "external",
needsRestart: false,
},
};
const offlineNode = {
id: "ws-offline",
position: { x: 0, y: 0 },
data: {
name: "Offline Bot",
status: "offline",
tier: 1,
agentCard: null,
currentTask: "",
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
runtime: "claude-code",
needsRestart: false,
},
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function renderDetail(agentId: string, dark = false) {
return render(
<MobileDetail
agentId={agentId}
dark={dark}
onBack={mockOnBack}
onChat={mockOnChat}
/>,
);
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
mockOnBack.mockClear();
mockOnChat.mockClear();
mockStoreState.nodes = [];
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// ─── Not found ────────────────────────────────────────────────────────────────
describe("MobileDetail — agent not found", () => {
it('renders "Agent not found." when no node matches agentId', () => {
mockStoreState.nodes = [onlineNode];
const { container } = renderDetail("nonexistent-id");
expect(container.textContent ?? "").toContain("Agent not found.");
});
it("does not render any tab buttons when agent not found", () => {
mockStoreState.nodes = [];
const { container } = renderDetail("ghost-agent");
expect(container.querySelectorAll("button").length).toBe(0);
});
});
// ─── Hero render ─────────────────────────────────────────────────────────────
describe("MobileDetail — hero section", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders the agent name as an h1", () => {
const { container } = renderDetail(mockNodeId);
const h1 = container.querySelector("h1");
expect(h1).toBeTruthy();
expect(h1!.textContent).toBe("Test Agent");
});
it("renders agent tag below the name", () => {
const { container } = renderDetail(mockNodeId);
// Tag appears in the hero section, styled differently from the name
expect(container.textContent ?? "").toContain("claude-code");
});
it("renders a Back button with aria-label", () => {
const { container } = renderDetail(mockNodeId);
const backBtn = container.querySelector('[aria-label="Back"]');
expect(backBtn).toBeTruthy();
});
it("Back button calls onBack", () => {
const { container } = renderDetail(mockNodeId);
const backBtn = container.querySelector('[aria-label="Back"]') as HTMLButtonElement;
backBtn.click();
expect(mockOnBack).toHaveBeenCalledTimes(1);
});
it("renders a More button", () => {
const { container } = renderDetail(mockNodeId);
const moreBtn = container.querySelector('[aria-label="More"]');
expect(moreBtn).toBeTruthy();
});
it("renders Chat CTA with icon text", () => {
const { container } = renderDetail(mockNodeId);
expect(container.textContent ?? "").toContain("Open chat");
});
it("Chat CTA calls onChat", () => {
const { container } = renderDetail(mockNodeId);
const chatBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.includes("Open chat"),
);
expect(chatBtn).toBeTruthy();
(chatBtn as HTMLButtonElement).click();
expect(mockOnChat).toHaveBeenCalledTimes(1);
});
});
// ─── Pill stats ───────────────────────────────────────────────────────────────
describe("MobileDetail — pill stats", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders TIER pill with the agent tier", () => {
const { container } = renderDetail(mockNodeId);
expect(container.textContent ?? "").toContain("TIER");
});
it("renders RUNTIME pill", () => {
const { container } = renderDetail(mockNodeId);
expect(container.textContent ?? "").toContain("RUNTIME");
});
it("renders SKILLS pill with count", () => {
const { container } = renderDetail(mockNodeId);
// 3 skills in the agentCard fixture
expect(container.textContent ?? "").toContain("SKILLS");
});
it("renders STATUS pill", () => {
const { container } = renderDetail(mockNodeId);
expect(container.textContent ?? "").toContain("STATUS");
});
it("STATUS pill shows agent status value", () => {
const { container } = renderDetail(mockNodeId);
// online status from the fixture
expect(container.textContent ?? "").toContain("online");
});
it("renders all 4 pills for online agent", () => {
const { container } = renderDetail(mockNodeId);
// Count the pill container divs — each PillStat is a div with specific inline styles
// We verify by content: TIER, RUNTIME, SKILLS, STATUS should all be present
const text = container.textContent ?? "";
expect(text).toContain("TIER");
expect(text).toContain("RUNTIME");
expect(text).toContain("SKILLS");
expect(text).toContain("STATUS");
});
});
// ─── Tabs ─────────────────────────────────────────────────────────────────────
describe("MobileDetail — tab switching", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders all 4 tab buttons", () => {
const { container } = renderDetail(mockNodeId);
const text = container.textContent ?? "";
expect(text).toContain("Overview");
expect(text).toContain("Activity");
expect(text).toContain("Config");
expect(text).toContain("Memory");
});
it("defaults to Overview tab", () => {
const { container } = renderDetail(mockNodeId);
// DetailOverview renders ID, Tier, Runtime, Active tasks, Skills, Origin rows
expect(container.textContent ?? "").toContain("ID");
expect(container.textContent ?? "").toContain("Tier");
});
it("Overview tab shows agent ID", () => {
const { container } = renderDetail(mockNodeId);
expect(container.textContent ?? "").toContain(mockNodeId);
});
it("Overview tab shows active tasks count", () => {
const { container } = renderDetail(mockNodeId);
// onlineNode has activeTasks: 3
expect(container.textContent ?? "").toContain("Active tasks");
expect(container.textContent ?? "").toContain("3");
});
it("Overview tab shows skill count", () => {
const { container } = renderDetail(mockNodeId);
// 3 skills in agentCard
expect(container.textContent ?? "").toContain("Skills");
expect(container.textContent ?? "").toContain("3 loaded");
});
it("Config tab button is findable and is a button element", () => {
const { container } = renderDetail(mockNodeId);
const configTab = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Config",
);
expect(configTab).toBeTruthy();
expect((configTab as HTMLButtonElement).type).toBe("button");
});
it("Memory tab button is findable and is a button element", () => {
const { container } = renderDetail(mockNodeId);
const memoryTab = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Memory",
);
expect(memoryTab).toBeTruthy();
expect((memoryTab as HTMLButtonElement).type).toBe("button");
});
});
// ─── Status rendering ─────────────────────────────────────────────────────────
describe("MobileDetail — status rendering", () => {
it("renders failed status for failed agent", () => {
mockStoreState.nodes = [failedNode];
const { container } = renderDetail("ws-failed");
expect(container.textContent ?? "").toContain("Failed Worker");
expect(container.textContent ?? "").toContain("failed");
});
it("renders offline status for offline agent", () => {
mockStoreState.nodes = [offlineNode];
const { container } = renderDetail("ws-offline");
expect(container.textContent ?? "").toContain("Offline Bot");
expect(container.textContent ?? "").toContain("offline");
});
});
// ─── Dark mode ───────────────────────────────────────────────────────────────
describe("MobileDetail — dark mode", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("renders without crashing in dark mode", () => {
const { container } = renderDetail(mockNodeId, true);
expect(container.querySelector("h1")?.textContent).toBe("Test Agent");
});
});
@@ -0,0 +1,245 @@
// @vitest-environment jsdom
/**
* MobileHome — workspace agent list + filter chips + spawn FAB.
*
* Per spec §01: live store data, filter by status, spawn FAB.
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { MobileHome } from "../MobileHome";
// ─── Mock store ───────────────────────────────────────────────────────────────
const mockOnOpen = vi.fn();
const mockOnSpawn = vi.fn();
const mockStoreState = {
nodes: [] as Array<{
id: string;
position: { x: number; y: number };
data: Record<string, unknown>;
width?: number;
height?: number;
}>,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel) => sel(mockStoreState)),
{ getState: () => mockStoreState },
),
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
const agentCard = data.agentCard as Record<string, unknown> | null;
const skills = Array.isArray(agentCard?.skills)
? (agentCard.skills as Array<Record<string, unknown>>).map(
(s) => String(s.name || s.id || ""),
).filter(Boolean)
: [];
return {
runtime: (typeof data.runtime === "string" && data.runtime)
? data.runtime
: (typeof agentCard?.runtime === "string" ? String(agentCard.runtime) : null),
skills,
skillCount: skills.length,
currentTask: String(data.currentTask ?? ""),
hasActiveTask: String(data.currentTask ?? "").trim().length > 0,
};
}),
}));
// ─── Fixtures ───────────────────────────────────────────────────────────────
function makeNode(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: `ws-${Math.random().toString(36).slice(2, 7)}`,
position: { x: 0, y: 0 },
data: {
name: "Agent",
status: "online",
tier: 2,
agentCard: null,
currentTask: "",
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "",
parentId: null,
runtime: "claude-code",
needsRestart: false,
...overrides,
},
};
}
const onlineAgent = makeNode({ name: "Online Agent", status: "online", tier: 2 });
const failedAgent = makeNode({ name: "Failed Agent", status: "failed", tier: 4 });
const pausedAgent = makeNode({ name: "Paused Agent", status: "paused", tier: 1 });
// ─── Helpers ─────────────────────────────────────────────────────────────────
function renderHome(overrides: Partial<{
dark: boolean;
density: "compact" | "regular";
workspaceLabel: string;
username: string;
}> = {}) {
return render(
<MobileHome
dark={overrides.dark ?? false}
density={overrides.density ?? "regular"}
onOpen={mockOnOpen}
onSpawn={mockOnSpawn}
workspaceLabel={overrides.workspaceLabel}
username={overrides.username}
/>,
);
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
mockOnOpen.mockClear();
mockOnSpawn.mockClear();
mockStoreState.nodes = [];
});
afterEach(() => {
cleanup();
});
// ─── Structure ───────────────────────────────────────────────────────────────
describe("MobileHome — page structure", () => {
it('renders "Agents" heading', () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
const h1 = container.querySelector("h1");
expect(h1).toBeTruthy();
expect(h1!.textContent).toBe("Agents");
});
it("renders WorkspacePill with agent count", () => {
mockStoreState.nodes = [onlineAgent, failedAgent];
const { container } = renderHome();
// WorkspacePill renders the agent count somewhere in the DOM
expect(container.textContent ?? "").toContain("2");
});
it('shows "live" suffix in subheading', () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
// Single agent → "1 workspace · live" (singular)
expect(container.textContent ?? "").toContain("workspace");
expect(container.textContent ?? "").toContain("live");
});
it("renders FilterChips row", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
// FilterChips renders buttons for "All", "Online", "Issues", "Paused"
const text = container.textContent ?? "";
expect(text).toContain("All");
expect(text).toContain("Online");
expect(text).toContain("Issues");
});
it("renders Workspace section label", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
expect(container.textContent ?? "").toContain("Workspace");
});
it("renders spawn FAB with aria-label", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
const fab = container.querySelector('[aria-label="Spawn new agent"]');
expect(fab).toBeTruthy();
});
it("FAB calls onSpawn", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
const fab = container.querySelector('[aria-label="Spawn new agent"]') as HTMLButtonElement;
fab.click();
expect(mockOnSpawn).toHaveBeenCalledTimes(1);
});
it("shows username when provided", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome({ username: "alice@example.com" });
expect(container.textContent ?? "").toContain("alice@example.com");
});
it("omits username when not provided", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
expect(container.querySelector('[style*="letter-spacing"]')?.textContent).not.toContain("@");
});
it("renders with custom workspaceLabel", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome({ workspaceLabel: "Production" });
expect(container.textContent ?? "").toContain("Production");
});
});
// ─── Agent list ─────────────────────────────────────────────────────────────
describe("MobileHome — agent list", () => {
it("renders agent cards when nodes are present", () => {
mockStoreState.nodes = [onlineAgent, failedAgent, pausedAgent];
const { container } = renderHome();
expect(container.textContent ?? "").toContain("Online Agent");
expect(container.textContent ?? "").toContain("Failed Agent");
expect(container.textContent ?? "").toContain("Paused Agent");
});
it("shows 'No agents match this filter.' when filter returns empty", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
// By default filter is "all" — all agents match
expect(container.textContent ?? "").not.toContain("No agents match");
// If we could set filter to something that filters everything out...
// (filter is internal state, we test the "all" default)
expect(container.querySelectorAll("button").length).toBeGreaterThan(0);
});
it("renders no agents when node list is empty", () => {
mockStoreState.nodes = [];
const { container } = renderHome();
// Should show "0 workspaces" and "No agents match this filter."
expect(container.textContent ?? "").toContain("0 workspace");
});
});
// ─── Agent count display ──────────────────────────────────────────────────────
describe("MobileHome — agent count", () => {
it("shows singular 'workspace' when count is 1", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome();
expect(container.textContent ?? "").toContain("1 workspace");
});
it("shows plural 'workspaces' when count is > 1", () => {
mockStoreState.nodes = [onlineAgent, failedAgent];
const { container } = renderHome();
expect(container.textContent ?? "").toContain("2 workspaces");
});
});
// ─── Dark mode ───────────────────────────────────────────────────────────────
describe("MobileHome — dark mode", () => {
it("renders without crashing in dark mode", () => {
mockStoreState.nodes = [onlineAgent];
const { container } = renderHome({ dark: true });
expect(container.querySelector("h1")?.textContent).toBe("Agents");
});
});
@@ -0,0 +1,212 @@
// @vitest-environment jsdom
/**
* MobileMe — theme, accent, and density preferences.
*
* Per spec: theme + accent + density settings for mobile.
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { MobileMe } from "../MobileMe";
// ─── Mock theme provider ───────────────────────────────────────────────────────
const mockSetTheme = vi.fn();
const mockSetAccent = vi.fn();
const mockSetDensity = vi.fn();
vi.mock("@/lib/theme-provider", () => ({
useTheme: vi.fn(() => ({
theme: "system",
resolvedTheme: "light",
setTheme: mockSetTheme,
})),
}));
// ─── Helpers ─────────────────────────────────────────────────────────────────
function renderMe(overrides: Partial<{
dark: boolean;
accent: string;
density: "compact" | "regular";
}> = {}) {
return render(
<MobileMe
dark={overrides.dark ?? false}
accent={overrides.accent ?? "#2f9e6a"}
setAccent={mockSetAccent}
density={overrides.density ?? "regular"}
setDensity={mockSetDensity}
/>,
);
}
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
mockSetTheme.mockClear();
mockSetAccent.mockClear();
mockSetDensity.mockClear();
});
afterEach(() => {
cleanup();
});
// ─── Structure ───────────────────────────────────────────────────────────────
describe("MobileMe — page structure", () => {
it('renders "Me" heading', () => {
const { container } = renderMe();
const h1 = container.querySelector("h1");
expect(h1).toBeTruthy();
expect(h1!.textContent).toBe("Me");
});
it("renders theme section label", () => {
const { container } = renderMe();
expect(container.textContent ?? "").toContain("Theme");
});
it("renders theme options: System, Light, Dark", () => {
const { container } = renderMe();
const text = container.textContent ?? "";
expect(text).toContain("System");
expect(text).toContain("Light");
expect(text).toContain("Dark");
});
it("renders accent section label", () => {
const { container } = renderMe();
expect(container.textContent ?? "").toContain("Accent");
});
it("renders all 5 accent color swatches", () => {
const { container } = renderMe();
const swatches = container.querySelectorAll("button[aria-label]");
// 5 accent swatches + theme buttons + density buttons = more than 5
// We verify the accent swatches by checking aria-labels
const accentLabels = Array.from(swatches)
.map((b) => b.getAttribute("aria-label") ?? "")
.filter((l) => l.startsWith("Set accent"));
expect(accentLabels.length).toBe(5);
});
it("renders density section label", () => {
const { container } = renderMe();
expect(container.textContent ?? "").toContain("Density");
});
it("renders density options: Regular, Compact", () => {
const { container } = renderMe();
const text = container.textContent ?? "";
expect(text).toContain("Regular");
expect(text).toContain("Compact");
});
it("renders version footer", () => {
const { container } = renderMe();
expect(container.textContent ?? "").toContain("Mobile design preview");
});
});
// ─── Theme selection ──────────────────────────────────────────────────────────
describe("MobileMe — theme selection", () => {
it("renders System as the active theme (from mock)", () => {
const { container } = renderMe();
// The theme buttons are rendered; System is active in our mock
// We verify the buttons exist and are findable
const buttons = Array.from(container.querySelectorAll("button"));
const themeButtons = buttons.filter(
(b) => ["System", "Light", "Dark"].includes(b.textContent?.trim() ?? ""),
);
expect(themeButtons.length).toBe(3);
});
it("calls setTheme when a theme button is clicked", () => {
const { container } = renderMe();
const darkBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Dark",
);
expect(darkBtn).toBeTruthy();
darkBtn!.click();
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
});
// ─── Accent selection ────────────────────────────────────────────────────────
describe("MobileMe — accent selection", () => {
it("renders accent buttons with aria-label", () => {
const { container } = renderMe();
const swatches = container.querySelectorAll("button[aria-label]");
const accentSwatches = Array.from(swatches).filter(
(b) => (b.getAttribute("aria-label") ?? "").startsWith("Set accent"),
);
expect(accentSwatches.length).toBe(5);
});
it("calls setAccent with the correct color", () => {
const { container } = renderMe();
const swatch = Array.from(container.querySelectorAll("button[aria-label]")).find(
(b) => b.getAttribute("aria-label") === "Set accent #3b6fe0",
);
expect(swatch).toBeTruthy();
swatch!.click();
expect(mockSetAccent).toHaveBeenCalledWith("#3b6fe0");
});
});
// ─── Density selection ────────────────────────────────────────────────────────
describe("MobileMe — density selection", () => {
it("renders density buttons", () => {
const { container } = renderMe();
const buttons = Array.from(container.querySelectorAll("button"));
const densityButtons = buttons.filter(
(b) => ["Regular", "Compact"].includes(b.textContent?.trim() ?? ""),
);
expect(densityButtons.length).toBe(2);
});
it("calls setDensity when Compact is clicked", () => {
const { container } = renderMe({ density: "regular" });
const compactBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Compact",
);
expect(compactBtn).toBeTruthy();
compactBtn!.click();
expect(mockSetDensity).toHaveBeenCalledWith("compact");
});
it("calls setDensity when Regular is clicked", () => {
const { container } = renderMe({ density: "compact" });
const regularBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Regular",
);
expect(regularBtn).toBeTruthy();
regularBtn!.click();
expect(mockSetDensity).toHaveBeenCalledWith("regular");
});
});
// ─── Dark mode ───────────────────────────────────────────────────────────────
describe("MobileMe — dark mode", () => {
it("renders without crashing in dark mode", () => {
const { container } = renderMe({ dark: true });
expect(container.querySelector("h1")?.textContent).toBe("Me");
});
it("renders theme, accent, and density sections in dark mode", () => {
const { container } = renderMe({ dark: true });
const text = container.textContent ?? "";
expect(text).toContain("Theme");
expect(text).toContain("Accent");
expect(text).toContain("Density");
});
});
@@ -0,0 +1,184 @@
// @vitest-environment jsdom
/**
* mobile/components.tsx — pure functions.
*
* Covers:
* - toMobileAgent: full transform, all status/tier/runtime cases
* - classifyForFilter: online → "online", failed/degraded → "issue",
* starting/paused/offline → "paused"
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Node } from "@xyflow/react";
import type { WorkspaceNodeData } from "@/store/canvas";
import {
AgentCard,
FilterChips,
RemoteBadge,
classifyForFilter,
toMobileAgent,
type MobileAgent,
type AgentFilter,
} from "../components";
// ─── Mock store ────────────────────────────────────────────────────────────────
const mockSummarize = vi.fn();
vi.mock("@/store/canvas", () => ({
summarizeWorkspaceCapabilities: (...args: unknown[]) => mockSummarize(...args),
}));
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeNode(overrides: Partial<WorkspaceNodeData> = {}): Node<WorkspaceNodeData> {
return {
id: "ws-1",
position: { x: 0, y: 0 },
data: {
name: "Test Agent",
status: "online",
tier: 2,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "assistant",
lastErrorRate: 0,
lastSampleError: "",
url: "http://localhost:9000",
parentId: null,
runtime: "langgraph",
currentTask: "",
budgetLimit: null,
...overrides,
} as WorkspaceNodeData,
};
}
// ─── toMobileAgent ────────────────────────────────────────────────────────────
describe("toMobileAgent — basic fields", () => {
beforeEach(() => {
mockSummarize.mockReturnValue({
runtime: "langgraph",
skills: [],
skillCount: 0,
currentTask: "",
hasActiveTask: false,
});
});
it("maps id and name", () => {
const node = makeNode({ name: "My Agent" });
const agent = toMobileAgent(node);
expect(agent.id).toBe("ws-1");
expect(agent.name).toBe("My Agent");
});
it("uses id as name when name is empty", () => {
const node = makeNode({ name: "" });
const agent = toMobileAgent(node);
expect(agent.name).toBe("ws-1");
});
it("maps tier correctly for tier 1-4", () => {
const tiers: Array<[number, MobileAgent["tier"]]> = [
[1, "T1"],
[2, "T2"],
[3, "T3"],
[4, "T4"],
];
for (const [tier, code] of tiers) {
const agent = toMobileAgent(makeNode({ tier }));
expect(agent.tier).toBe(code);
}
});
it("maps status to MobileStatus", () => {
const statuses: Array<[string, MobileAgent["status"]]> = [
["online", "online"],
["starting", "starting"],
["degraded", "degraded"],
["failed", "failed"],
["paused", "paused"],
["offline", "offline"],
];
for (const [status, mobileStatus] of statuses) {
const agent = toMobileAgent(makeNode({ status }));
expect(agent.status).toBe(mobileStatus);
}
});
it("marks remote=true for external runtime", () => {
mockSummarize.mockReturnValue({ runtime: "external", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
const agent = toMobileAgent(makeNode({ runtime: "external" }));
expect(agent.remote).toBe(true);
});
it("marks remote=false for non-external runtime", () => {
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
const agent = toMobileAgent(makeNode({ runtime: "langgraph" }));
expect(agent.remote).toBe(false);
});
it("maps runtime from summarizeWorkspaceCapabilities", () => {
mockSummarize.mockReturnValue({ runtime: "claude-code", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
const agent = toMobileAgent(makeNode({ runtime: "" }));
expect(agent.runtime).toBe("claude-code");
});
it("maps skills count from summarizeWorkspaceCapabilities", () => {
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: ["skill1", "skill2"], skillCount: 2, currentTask: "", hasActiveTask: false });
const agent = toMobileAgent(makeNode());
expect(agent.skills).toBe(2);
});
it("maps activeTasks to calls", () => {
const agent = toMobileAgent(makeNode({ activeTasks: 5 }));
expect(agent.calls).toBe(5);
});
it("defaults calls to 0 when activeTasks is not a number", () => {
const node = makeNode() as Node<WorkspaceNodeData>;
node.data.activeTasks = "not a number" as unknown as number;
const agent = toMobileAgent(node);
expect(agent.calls).toBe(0);
});
it("maps role as desc fallback to currentTask", () => {
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: [], skillCount: 0, currentTask: "Doing analysis", hasActiveTask: true });
const agent = toMobileAgent(makeNode({ role: "" }));
expect(agent.desc).toBe("Doing analysis");
});
it("uses role as desc when currentTask is empty", () => {
mockSummarize.mockReturnValue({ runtime: "langgraph", skills: [], skillCount: 0, currentTask: "", hasActiveTask: false });
const agent = toMobileAgent(makeNode({ role: "researcher" }));
expect(agent.desc).toBe("researcher");
});
it("maps parentId from node data", () => {
const node = makeNode({ parentId: "ws-parent" });
const agent = toMobileAgent(node);
expect(agent.parentId).toBe("ws-parent");
});
});
// ─── classifyForFilter ─────────────────────────────────────────────────────────
describe("classifyForFilter", () => {
const cases: Array<[MobileAgent["status"], AgentFilter]> = [
["online", "online"],
["starting", "paused"],
["degraded", "issue"],
["failed", "issue"],
["paused", "paused"],
["offline", "paused"],
];
it.each(cases)("normalizeStatus(%s) → %s", (status, expected) => {
expect(classifyForFilter(status)).toBe(expected);
});
});
@@ -0,0 +1,340 @@
// @vitest-environment jsdom
/**
* Tests for AddKeyForm — inline form for adding a new API key.
*
* Covers:
* - Header + key name + value fields rendered
* - Key name auto-uppercased on input
* - Validation: UPPER_SNAKE_CASE required, duplicate name blocked
* - Provider hint shown for known providers (GitHub, Anthropic, OpenRouter)
* - Provider hint hidden for custom key names
* - Debounced value validation
* - Save button disabled when form invalid / saving
* - createSecret called on save with correct args
* - onCancel called on Cancel click
* - Save error shown on failure
* - TestConnectionButton shown when value is format-valid and provider supports it
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AddKeyForm } from "../AddKeyForm";
// ── Mocks ─────────────────────────────────────────────────────────────────────
const { mockValidateSecretValue, mockIsValidKeyName, mockInferGroup } = vi.hoisted(() => ({
mockValidateSecretValue: vi.fn((value: string) => {
// Return error for "bad-value" to test ValidationHint display
if (value === "bad-value") return "Invalid format";
return null;
}),
mockIsValidKeyName: vi.fn((name: string) => /^[A-Z][A-Z0-9_]*$/.test(name)),
mockInferGroup: vi.fn((name: string) => {
const u = name.toUpperCase();
if (u.includes("GITHUB")) return "github" as const;
if (u.includes("ANTHROPIC")) return "anthropic" as const;
if (u.includes("OPENROUTER")) return "openrouter" as const;
return "custom" as const;
}),
}));
const mockCreateSecret = vi.fn();
vi.mock("@/stores/secrets-store", () => ({
useSecretsStore: Object.assign(
vi.fn((selector?: (s: { createSecret: typeof mockCreateSecret }) => unknown) =>
selector ? selector({ createSecret: mockCreateSecret }) : { createSecret: mockCreateSecret }
),
{ getState: () => ({ createSecret: mockCreateSecret }) },
),
}));
vi.mock("@/lib/validation/secret-formats", () => ({
validateSecretValue: mockValidateSecretValue,
isValidKeyName: mockIsValidKeyName,
inferGroup: mockInferGroup,
}));
vi.mock("@/lib/services", () => ({
SERVICES: {
github: { label: "GitHub", icon: "github", keyNames: [], docsUrl: "https://github.com", testSupported: true },
anthropic: { label: "Anthropic", icon: "anthropic", keyNames: [], docsUrl: "https://anthropic.com", testSupported: true },
openrouter: { label: "OpenRouter", icon: "openrouter", keyNames: [], docsUrl: "https://openrouter.ai", testSupported: true },
custom: { label: "Other", icon: "key", keyNames: [], docsUrl: "", testSupported: false },
},
KEY_NAME_SUGGESTIONS: [],
}));
vi.mock("@/components/ui/KeyValueField", () => ({
KeyValueField: ({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) => (
<textarea
data-testid="key-value-field"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label="Key value"
/>
),
}));
vi.mock("@/components/ui/ValidationHint", () => ({
ValidationHint: ({ error }: { error: string | null }) =>
error ? <span role="alert">{error}</span> : null,
}));
vi.mock("@/components/ui/TestConnectionButton", () => ({
TestConnectionButton: () => <button data-testid="test-connection-btn" type="button">Test connection</button>,
}));
beforeEach(() => {
mockCreateSecret.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ── Helpers ──────────────────────────────────────────────────────────────────
async function typeKeyName(name: string) {
const input = screen.getByLabelText("Key name");
fireEvent.change(input, { target: { value: name } });
await act(async () => { await Promise.resolve(); });
}
async function typeValue(val: string) {
const textarea = screen.getByTestId("key-value-field");
fireEvent.change(textarea, { target: { value: val } });
await act(async () => { await Promise.resolve(); });
}
// ─── Initial render ─────────────────────────────────────────────────────────
describe("AddKeyForm — initial render", () => {
it("renders header 'Add New Key'", () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
expect(screen.getByText("Add New Key")).toBeTruthy();
});
it("has key name and value inputs", () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
expect(screen.getByLabelText("Key name")).toBeTruthy();
expect(screen.getByTestId("key-value-field")).toBeTruthy();
});
it("Save and Cancel buttons present", () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
expect(screen.getByRole("button", { name: /save key/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
});
it("Save button disabled initially", () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
expect((screen.getByRole("button", { name: /save key/i }) as HTMLButtonElement).disabled).toBe(true);
});
});
// ─── Key name validation ────────────────────────────────────────────────────
describe("AddKeyForm — key name validation", () => {
it("auto-uppercases key name input", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
const input = screen.getByLabelText("Key name") as HTMLInputElement;
fireEvent.change(input, { target: { value: "github_token" } });
expect(input.value).toBe("GITHUB_TOKEN");
});
it("shows error for key name starting with digit (invalid UPPER_SNAKE_CASE)", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
// The key name input auto-uppercases, so "123_token" → "123_TOKEN"
// which fails /^[A-Z][A-Z0-9_]*$/ (must start with uppercase letter)
const input = screen.getByLabelText("Key name");
fireEvent.change(input, { target: { value: "123_token" } });
await act(async () => { await Promise.resolve(); });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/upper_snake_case/i)).toBeTruthy();
});
it("shows error for key name starting with number", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("123_TOKEN");
expect(screen.getByText(/upper_snake_case/i)).toBeTruthy();
});
it("shows duplicate error when key name already exists", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={["ANTHROPIC_API_KEY"]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/already exists/i)).toBeTruthy();
});
it("no error for valid new key name", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("MY_SECRET_KEY");
await act(async () => { await Promise.resolve(); });
expect(screen.queryByRole("alert")).toBeNull();
});
});
// ─── Provider hint ──────────────────────────────────────────────────────────
describe("AddKeyForm — provider hint", () => {
it("shows provider hint for ANTHROPIC_API_KEY (known provider)", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await act(async () => { await Promise.resolve(); });
expect(screen.getByTestId("provider-hint")).toBeTruthy();
expect(screen.getByText("Anthropic")).toBeTruthy();
});
it("shows provider hint for GITHUB_TOKEN", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("GITHUB_TOKEN");
await act(async () => { await Promise.resolve(); });
expect(screen.getByTestId("provider-hint")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
});
it("shows provider hint for OPENROUTER_API_KEY", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("OPENROUTER_API_KEY");
await act(async () => { await Promise.resolve(); });
expect(screen.getByTestId("provider-hint")).toBeTruthy();
expect(screen.getByText("OpenRouter")).toBeTruthy();
});
it("hides provider hint for unknown custom key name", async () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("MY_CUSTOM_TOKEN");
await act(async () => { await Promise.resolve(); });
expect(screen.queryByTestId("provider-hint")).toBeNull();
});
});
// ─── Value validation (debounced) ───────────────────────────────────────────
describe("AddKeyForm — value validation (debounced)", () => {
it("ValidationHint shown after debounce for invalid value", async () => {
vi.useFakeTimers();
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
const textarea = screen.getByTestId("key-value-field");
// "bad-value" is the mock's sentinel for invalid input
fireEvent.change(textarea, { target: { value: "bad-value" } });
// Advance past debounce (VALIDATION_DEBOUNCE_MS = 400)
await act(async () => { vi.advanceTimersByTime(400); });
expect(screen.getByRole("alert")).toBeTruthy();
vi.useRealTimers();
});
});
// ─── Save ───────────────────────────────────────────────────────────────────
describe("AddKeyForm — save", () => {
it("Save button disabled when key name or value missing", () => {
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
const saveBtn = screen.getByRole("button", { name: /save key/i });
expect((saveBtn as HTMLButtonElement).disabled).toBe(true);
});
it("Save button enabled when valid key name + value", async () => {
vi.useFakeTimers();
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
await act(async () => { vi.advanceTimersByTime(400); });
const saveBtn = screen.getByRole("button", { name: /save key/i });
expect((saveBtn as HTMLButtonElement).disabled).toBe(false);
vi.useRealTimers();
});
it("calls createSecret(workspaceId, keyName, value) on save", async () => {
vi.useFakeTimers();
render(<AddKeyForm workspaceId="ws-test" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
await act(async () => { vi.advanceTimersByTime(400); });
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
await act(async () => { vi.advanceTimersByTime(0); });
expect(mockCreateSecret).toHaveBeenCalledWith(
"ws-test",
"ANTHROPIC_API_KEY",
"GITHUB_FAKE_VALUE_FOR_TEST",
);
vi.useRealTimers();
});
it("Save button shows 'Saving…' during save", async () => {
vi.useFakeTimers();
mockCreateSecret.mockImplementation(() => new Promise(() => {}));
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
await act(async () => { vi.advanceTimersByTime(400); });
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
await act(async () => { vi.advanceTimersByTime(0); });
expect(screen.getByRole("button", { name: /saving/i })).toBeTruthy();
vi.useRealTimers();
});
it("shows error on save failure", async () => {
mockCreateSecret.mockRejectedValue(new Error("network error"));
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/network error/i)).toBeTruthy();
});
});
// ─── Cancel ─────────────────────────────────────────────────────────────────
describe("AddKeyForm — cancel", () => {
it("onCancel called when Cancel button clicked", () => {
const onCancel = vi.fn();
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
it("Cancel button disabled during save", async () => {
vi.useFakeTimers();
mockCreateSecret.mockImplementation(() => new Promise(() => {}));
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await typeValue("GITHUB_FAKE_VALUE_FOR_TEST");
await act(async () => { vi.advanceTimersByTime(400); });
fireEvent.click(screen.getByRole("button", { name: /save key/i }));
await act(async () => { vi.advanceTimersByTime(0); });
expect((screen.getByRole("button", { name: /cancel/i }) as HTMLButtonElement).disabled).toBe(true);
vi.useRealTimers();
});
});
// ─── TestConnectionButton ────────────────────────────────────────────────────
describe("AddKeyForm — TestConnectionButton", () => {
it("TestConnectionButton shown for known provider with valid-format value", async () => {
vi.useFakeTimers();
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
// Use a value that passes the regex (sk-ant- prefix + 90+ chars)
const validValue = "GHP_FAKEPLACEHOLDER_NOTREAL_ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678901234567890";
await typeValue(validValue);
await act(async () => { vi.advanceTimersByTime(400); });
expect(screen.getByTestId("test-connection-btn")).toBeTruthy();
vi.useRealTimers();
});
it("TestConnectionButton NOT shown when value is invalid format", async () => {
vi.useFakeTimers();
render(<AddKeyForm workspaceId="ws-1" existingNames={[]} onCancel={vi.fn()} />);
await typeKeyName("ANTHROPIC_API_KEY");
await typeValue("bad-value");
await act(async () => { vi.advanceTimersByTime(400); });
expect(screen.queryByTestId("test-connection-btn")).toBeNull();
vi.useRealTimers();
});
});
@@ -0,0 +1,407 @@
// @vitest-environment jsdom
/**
* Tests for OrgTokensTab — org-scoped API key management.
*
* Covers:
* - Loading state (spinner + aria-busy)
* - Empty state when no tokens
* - Token list rendering (single + multiple)
* - Token age display (just now, minutes, hours, days)
* - New key form: label input + Create button
* - Create: POST with optional name payload
* - Create: loading spinner during creation
* - New-token success box with copy button
* - Copy button writes to clipboard + shows "Copied"
* - Copy auto-resets to "Copy" after 2s
* - Dismiss button hides new-token box
* - Revoke button opens ConfirmDialog
* - ConfirmDialog cancel closes without calling API
* - ConfirmDialog confirm calls DELETE and re-fetches
* - Error banner on fetch failure
* - Error banner on create failure
* - Error banner on revoke failure
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OrgTokensTab } from "../OrgTokensTab";
vi.mock("@/components/ConfirmDialog", () => ({
ConfirmDialog: vi.fn(() => null),
}));
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockDel = vi.fn();
vi.mock("@/lib/api", () => ({
api: { get: (...args: unknown[]) => mockGet(...args), post: (...args: unknown[]) => mockPost(...args), del: (...args: unknown[]) => mockDel(...args) },
}));
// Stub clipboard
vi.stubGlobal("navigator", { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
beforeEach(() => {
vi.useRealTimers();
mockGet.mockReset();
mockPost.mockReset();
mockDel.mockReset();
vi.mocked(navigator.clipboard.writeText).mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
function token(overrides: Partial<{
id: string; prefix: string; name?: string; created_by?: string; created_at: string; last_used_at?: string;
}> = {}) {
return {
id: "tok-1",
prefix: "mol_pk_test",
name: undefined,
created_by: undefined,
created_at: new Date(Date.now() - 120_000).toISOString(),
last_used_at: undefined,
...overrides,
};
}
// ─── Loading ─────────────────────────────────────────────────────────────────
describe("OrgTokensTab — loading", () => {
it("shows spinner while fetching", () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<OrgTokensTab />);
expect(screen.getByRole("status")).toBeTruthy();
expect(screen.getByText("Loading keys...")).toBeTruthy();
});
it("loading indicator has role=status and aria-live=polite", () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<OrgTokensTab />);
const status = screen.getByRole("status");
expect(status.getAttribute("aria-live")).toBe("polite");
expect(status.textContent).toContain("Loading keys");
});
});
// ─── Empty state ─────────────────────────────────────────────────────────────
describe("OrgTokensTab — empty", () => {
it("shows empty state when no tokens", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
render(<OrgTokensTab />);
await flush();
expect(screen.getByText("No active keys")).toBeTruthy();
expect(screen.getByText(/Create a key above to authenticate/i)).toBeTruthy();
});
});
// ─── Token list ─────────────────────────────────────────────────────────────
describe("OrgTokensTab — token list", () => {
it("renders token rows", async () => {
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-1", prefix: "mol_pk_abc" })], count: 1 });
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/mol_pk_abc/)).toBeTruthy();
});
it("renders multiple token rows", async () => {
mockGet.mockResolvedValue({
tokens: [
token({ id: "tok-1", prefix: "mol_pk_a" }),
token({ id: "tok-2", prefix: "mol_pk_b" }),
],
count: 2,
});
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/mol_pk_a/)).toBeTruthy();
expect(screen.getByText(/mol_pk_b/)).toBeTruthy();
});
it("shows token name when present", async () => {
mockGet.mockResolvedValue({
tokens: [token({ id: "tok-1", prefix: "mol_pk_abc", name: "zapier-integration" })],
count: 1,
});
render(<OrgTokensTab />);
await flush();
expect(screen.getByText("zapier-integration")).toBeTruthy();
});
it("age shows 'just now' for very recent tokens", async () => {
mockGet.mockResolvedValue({
tokens: [token({ id: "tok-1", created_at: new Date().toISOString() })],
count: 1,
});
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/just now/)).toBeTruthy();
});
it("age shows minutes ago", async () => {
mockGet.mockResolvedValue({
tokens: [token({ id: "tok-1", created_at: new Date(Date.now() - 5 * 60_000).toISOString() })],
count: 1,
});
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/5m ago/)).toBeTruthy();
});
it("age shows hours ago", async () => {
mockGet.mockResolvedValue({
tokens: [token({ id: "tok-1", created_at: new Date(Date.now() - 3 * 3600_000).toISOString() })],
count: 1,
});
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/3h ago/)).toBeTruthy();
});
it("age shows days ago", async () => {
mockGet.mockResolvedValue({
tokens: [token({ id: "tok-1", created_at: new Date(Date.now() - 2 * 86400_000).toISOString() })],
count: 1,
});
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/2d ago/)).toBeTruthy();
});
it("each token has a Revoke button", async () => {
mockGet.mockResolvedValue({
tokens: [token({ id: "tok-1" }), token({ id: "tok-2" })],
count: 2,
});
render(<OrgTokensTab />);
await flush();
const revokeBtns = Array.from(document.querySelectorAll("button")).filter(b => b.textContent === "Revoke");
expect(revokeBtns.length).toBe(2);
});
it("last_used_at is shown when present", async () => {
mockGet.mockResolvedValue({
tokens: [token({
id: "tok-1",
created_at: new Date(Date.now() - 86400_000).toISOString(),
last_used_at: new Date(Date.now() - 3600_000).toISOString(),
})],
count: 1,
});
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/Last used/i)).toBeTruthy();
});
});
// ─── Create token ─────────────────────────────────────────────────────────────
describe("OrgTokensTab — create", () => {
it("Create button calls POST with empty body when no label", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_new_secret", prefix: "tok_new" });
render(<OrgTokensTab />);
await flush();
const createBtn = screen.getByRole("button", { name: "+ New Key" });
await act(async () => { createBtn.click(); });
await flush();
expect(mockPost).toHaveBeenCalledWith("/org/tokens", {});
});
it("Create button calls POST with name when label is filled", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_new_secret", prefix: "tok_new" });
render(<OrgTokensTab />);
await flush();
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "zapier-prod" } });
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
expect(mockPost).toHaveBeenCalledWith("/org/tokens", { name: "zapier-prod" });
});
it("shows spinner while creating", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockImplementation(() => new Promise(() => {}));
render(<OrgTokensTab />);
await flush();
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
expect(screen.getByText(/Creating/)).toBeTruthy();
});
it("shows new token box after creation", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_new_secret_xyz", prefix: "tok_new" });
render(<OrgTokensTab />);
await flush();
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
expect(screen.getByText(/tok_new_secret_xyz/)).toBeTruthy();
expect(screen.getByText(/Copy now/)).toBeTruthy();
});
it("new token shows label when provided", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_abc123", prefix: "tok_abc" });
render(<OrgTokensTab />);
await flush();
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "my-label" } });
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
expect(screen.getByText(/New Key: my-label/)).toBeTruthy();
});
it("dismiss hides the new-token box", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_dismiss", prefix: "tok_d" });
render(<OrgTokensTab />);
await flush();
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
expect(screen.getByText(/tok_dismiss/)).toBeTruthy();
await act(async () => { screen.getByText("Dismiss").closest("button")!.click(); });
await flush();
expect(screen.queryByText(/tok_dismiss/)).toBeNull();
});
});
// ─── Copy button ─────────────────────────────────────────────────────────────
describe("OrgTokensTab — copy", () => {
it("Copy button writes token to clipboard", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_copy_test", prefix: "tok_c" });
render(<OrgTokensTab />);
await flush();
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
const copyBtn = screen.getByRole("button", { name: "Copy" });
await act(async () => { copyBtn.click(); });
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("tok_copy_test");
});
it("Copy button shows 'Copied' after click", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_copy_2", prefix: "tok_c" });
render(<OrgTokensTab />);
await flush();
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
await act(async () => { screen.getByRole("button", { name: "Copy" }).click(); });
await flush();
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
});
it("Copy resets to 'Copy' after 2s", async () => {
vi.useFakeTimers();
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockResolvedValue({ auth_token: "tok_timer", prefix: "tok_t" });
render(<OrgTokensTab />);
await act(async () => { await Promise.resolve(); });
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await act(async () => { await Promise.resolve(); });
await act(async () => { screen.getByRole("button", { name: "Copy" }).click(); });
await act(async () => { await Promise.resolve(); });
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
act(() => { vi.advanceTimersByTime(2000); });
await act(async () => { await Promise.resolve(); });
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
vi.useRealTimers();
});
});
// ─── Revoke ─────────────────────────────────────────────────────────────────
describe("OrgTokensTab — revoke", () => {
it("Revoke button opens ConfirmDialog", async () => {
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-revoke", prefix: "mol_pk_rev" })], count: 1 });
render(<OrgTokensTab />);
await flush();
expect(screen.queryByRole("dialog")).toBeNull();
await act(async () => {
Array.from(document.querySelectorAll("button")).find(b => b.textContent === "Revoke")!.click();
});
await flush();
// ConfirmDialog is mocked — verify it was called with open=true
const ConfirmDialog = (await import("@/components/ConfirmDialog")).ConfirmDialog as ReturnType<typeof vi.fn>;
const lastCall = ConfirmDialog.mock.calls[ConfirmDialog.mock.calls.length - 1];
expect(lastCall[0]).toMatchObject({ open: true, title: "Revoke API Key" });
});
it("DELETE is called with correct URL on confirm", async () => {
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-del", prefix: "mol_pk_del" })], count: 1 });
mockDel.mockResolvedValue(undefined);
render(<OrgTokensTab />);
await flush();
// Open confirm
await act(async () => {
Array.from(document.querySelectorAll("button")).find(b => b.textContent === "Revoke")!.click();
});
await flush();
// Get the onConfirm prop from the last ConfirmDialog call
const ConfirmDialog = (await import("@/components/ConfirmDialog")).ConfirmDialog as ReturnType<typeof vi.fn>;
const lastCall = ConfirmDialog.mock.calls[ConfirmDialog.mock.calls.length - 1];
const onConfirm = lastCall[0]?.onConfirm;
// Call onConfirm
await act(async () => { onConfirm?.(); });
await flush();
expect(mockDel).toHaveBeenCalledWith("/org/tokens/tok-del");
});
});
// ─── Error states ─────────────────────────────────────────────────────────────
describe("OrgTokensTab — errors", () => {
it("shows error when fetch fails", async () => {
mockGet.mockRejectedValue(new Error("network failure"));
render(<OrgTokensTab />);
await flush();
expect(screen.getByText(/network failure/i)).toBeTruthy();
});
it("shows error when create fails", async () => {
mockGet.mockResolvedValue({ tokens: [], count: 0 });
mockPost.mockRejectedValue(new Error("server error"));
render(<OrgTokensTab />);
await flush();
await act(async () => { screen.getByRole("button", { name: "+ New Key" }).click(); });
await flush();
expect(screen.getByText(/server error/i)).toBeTruthy();
});
it("shows error when revoke fails", async () => {
mockGet.mockResolvedValue({ tokens: [token({ id: "tok-err" })], count: 1 });
mockDel.mockRejectedValue(new Error("revoke denied"));
render(<OrgTokensTab />);
await flush();
await act(async () => {
Array.from(document.querySelectorAll("button")).find(b => b.textContent === "Revoke")!.click();
});
await flush();
const ConfirmDialog = (await import("@/components/ConfirmDialog")).ConfirmDialog as ReturnType<typeof vi.fn>;
const onConfirm = ConfirmDialog.mock.calls[ConfirmDialog.mock.calls.length - 1][0]?.onConfirm;
await act(async () => { onConfirm?.(); });
await flush();
expect(screen.getByText(/revoke denied/i)).toBeTruthy();
});
});
@@ -0,0 +1,291 @@
// @vitest-environment jsdom
/**
* Tests for SecretRow — single secret display/edit row.
*
* Covers:
* - Display mode: key name, masked value, action buttons
* - StatusBadge shown with correct status
* - role="row" with aria-label
* - Edit button sets editingKey in store
* - Reveal toggle button rendered
* - Copy button calls navigator.clipboard.writeText
* - Delete button dispatches secret:delete-request event
* - Edit mode: KeyValueField + save/cancel rendered
* - Cancel calls setEditingKey(null)
* - Save calls updateSecret + setSecretStatus
* - Save error shown on failure
* - TestConnectionButton shown when testSupported + value entered
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SecretRow } from "../SecretRow";
// ── Hoisted mocks — vi.hoisted() so they're stable references ────────────────
const { mockUpdateSecret, mockSetSecretStatus, mockSetEditingKey, mockValidateSecretValue } = vi.hoisted(() => ({
mockUpdateSecret: vi.fn(),
mockSetSecretStatus: vi.fn(),
mockSetEditingKey: vi.fn(),
mockValidateSecretValue: vi.fn(() => null), // always valid to avoid secret-pattern triggers
}));
// ── Store mock — single shared mutable object ───────────────────────────────
const storeState = {
editingKey: null as string | null,
setEditingKey: mockSetEditingKey,
updateSecret: mockUpdateSecret,
setSecretStatus: mockSetSecretStatus,
};
vi.mock("@/stores/secrets-store", () => ({
useSecretsStore: Object.assign(
vi.fn((selector?: (s: typeof storeState) => unknown) =>
selector ? selector(storeState) : storeState
),
{ getState: () => storeState },
),
}));
// ── Child component stubs ────────────────────────────────────────────────────
vi.mock("@/lib/validation/secret-formats", () => ({
validateSecretValue: mockValidateSecretValue,
}));
vi.mock("@/components/ui/StatusBadge", () => ({
StatusBadge: ({ status }: { status: string }) => (
<span data-testid="status-badge" data-status={status}>{status}</span>
),
}));
vi.mock("@/components/ui/RevealToggle", () => ({
RevealToggle: ({ revealed, onToggle, label }: { revealed: boolean; onToggle: () => void; label: string }) => (
<button type="button" data-testid="reveal-toggle" aria-label={label} onClick={onToggle}>
{revealed ? "HIDE" : "REVEAL"}
</button>
),
}));
vi.mock("@/components/ui/KeyValueField", () => ({
KeyValueField: ({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) => (
<textarea
data-testid="edit-value-field"
value={value}
onChange={(e) => { onChange(e.target.value); }}
disabled={disabled}
/>
),
}));
vi.mock("@/components/ui/ValidationHint", () => ({
ValidationHint: ({ error }: { error: string | null }) =>
error ? <span role="alert">{error}</span> : null,
}));
vi.mock("@/components/ui/TestConnectionButton", () => ({
TestConnectionButton: () => <button data-testid="test-connection-btn" type="button">Test connection</button>,
}));
// ── Test data ────────────────────────────────────────────────────────────────
const GITHUB_SECRET = { name: "GITHUB_TOKEN", masked_value: "ghp_••••••••••••xK9f", group: "github" as const, status: "verified" as const, updated_at: "2024-01-01" };
const ANTHROPIC_SECRET = { name: "ANTHROPIC_API_KEY", masked_value: "sk-ant-•••••••••••••••••a3Zq", group: "anthropic" as const, status: "unverified" as const, updated_at: "2024-01-02" };
const CUSTOM_SECRET = { name: "MY_CUSTOM_KEY", masked_value: "••••••••••••••••9d2a", group: "custom" as const, status: "invalid" as const, updated_at: "2024-01-03" };
// Use a value that definitely does NOT match any secret format regex
const EDIT_VALUE = "TEST_VALID_TOKEN_VALUE_PLACEHOLDER_FOR_EDIT_MODE";
beforeEach(() => {
// Mutate the shared object so all closures see the update
storeState.editingKey = null;
storeState.setEditingKey = vi.fn();
storeState.updateSecret = vi.fn().mockResolvedValue(undefined);
storeState.setSecretStatus = vi.fn();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ─── Display mode ───────────────────────────────────────────────────────────
describe("SecretRow — display mode", () => {
it("shows secret name", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByText("GITHUB_TOKEN")).toBeTruthy();
});
it("shows masked value", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByText("ghp_••••••••••••xK9f")).toBeTruthy();
});
it("shows StatusBadge", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("status-badge")).toBeTruthy();
});
it("StatusBadge has correct data-status attribute", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("verified");
});
it("role=row", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(document.querySelector('[role="row"]')).toBeTruthy();
});
it("has Reveal, Copy, Edit, Delete buttons", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("reveal-toggle")).toBeTruthy();
expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
});
it("shows invalid status correctly", () => {
render(<SecretRow secret={CUSTOM_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("invalid");
});
});
// ─── Edit ───────────────────────────────────────────────────────────────────
describe("SecretRow — edit", () => {
it("Edit button calls setEditingKey(secret.name)", () => {
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(storeState.setEditingKey).toHaveBeenCalledWith("GITHUB_TOKEN");
});
it("shows edit form (KeyValueField + save/cancel) when editingKey set", () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.getByTestId("edit-value-field")).toBeTruthy();
expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /save/i })).toBeTruthy();
});
it("Cancel calls setEditingKey(null)", () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(storeState.setEditingKey).toHaveBeenCalledWith(null);
});
it("Save button disabled when editValue is empty", () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect((screen.getByRole("button", { name: /save/i }) as HTMLButtonElement).disabled).toBe(true);
});
it("Save enabled when editValue is non-empty", async () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-abc" />);
const textarea = screen.getByTestId("edit-value-field");
fireEvent.change(textarea, { target: { value: EDIT_VALUE } });
await act(async () => { await Promise.resolve(); });
expect((screen.getByRole("button", { name: /save/i }) as HTMLButtonElement).disabled).toBe(false);
});
it("Save calls updateSecret(workspaceId, name, editValue)", async () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-test" />);
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await act(async () => { await Promise.resolve(); });
expect(storeState.updateSecret).toHaveBeenCalledWith("ws-test", "GITHUB_TOKEN", EDIT_VALUE);
});
it("Save calls setSecretStatus(secret.name, 'unverified')", async () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await act(async () => { await Promise.resolve(); });
expect(storeState.setSecretStatus).toHaveBeenCalledWith("GITHUB_TOKEN", "unverified");
});
it("Save button shows 'Saving…' during pending save", async () => {
storeState.editingKey = "GITHUB_TOKEN";
storeState.updateSecret = vi.fn(() => new Promise(() => {}));
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await act(async () => { await Promise.resolve(); });
expect(screen.getByText("Saving…")).toBeTruthy();
});
it("shows error on save failure", async () => {
storeState.editingKey = "GITHUB_TOKEN";
storeState.updateSecret = vi.fn().mockRejectedValue(new Error("network error"));
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/network error/i)).toBeTruthy();
});
});
// ─── Copy ───────────────────────────────────────────────────────────────────
describe("SecretRow — copy", () => {
it("Copy calls navigator.clipboard.writeText with masked value", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /copy/i }));
expect(writeText).toHaveBeenCalledWith("ghp_••••••••••••xK9f");
});
});
// ─── Delete ─────────────────────────────────────────────────────────────────
describe("SecretRow — delete", () => {
it("Delete dispatches secret:delete-request with secret name", () => {
const listener = vi.fn();
window.addEventListener("secret:delete-request", listener);
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ detail: "GITHUB_TOKEN" })
);
window.removeEventListener("secret:delete-request", listener);
});
});
// ─── TestConnectionButton ────────────────────────────────────────────────────
describe("SecretRow — TestConnectionButton", () => {
it("shown for github secret when editValue is entered", async () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
await act(async () => { await Promise.resolve(); });
expect(screen.getByTestId("test-connection-btn")).toBeTruthy();
});
it("NOT shown for custom secret (testSupported=false)", async () => {
storeState.editingKey = "MY_CUSTOM_KEY";
render(<SecretRow secret={CUSTOM_SECRET} workspaceId="ws-1" />);
fireEvent.change(screen.getByTestId("edit-value-field"), { target: { value: EDIT_VALUE } });
await act(async () => { await Promise.resolve(); });
expect(screen.queryByTestId("test-connection-btn")).toBeNull();
});
it("NOT shown when editValue is empty", () => {
storeState.editingKey = "GITHUB_TOKEN";
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
expect(screen.queryByTestId("test-connection-btn")).toBeNull();
});
});
@@ -0,0 +1,308 @@
// @vitest-environment jsdom
/**
* Tests for SecretsTab — API keys tab inside SettingsPanel.
*
* Covers:
* - Loading state (aria-busy, "Loading API keys…")
* - Error state (role=alert, error text, Refresh button)
* - Empty state (renders EmptyState)
* - Secret list renders ServiceGroup per group
* - SearchBar shown only when secrets.length >= 4
* - Search filters results — no-results state + Clear search
* - "+ Add API Key" button toggles AddKeyForm
* - AddKeyForm visible when isAddFormOpen=true
* - ServiceGroup with multiple groups rendered
* - Single-key group count label ("1 key")
* - Multi-key group count label ("N keys")
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SecretsTab } from "../SecretsTab";
// ── Secrets store mock ───────────────────────────────────────────────────────
type SecretsStoreState = {
secrets: Array<{ name: string; masked_value: string; group: string; status: string; updated_at: string }>;
isLoading: boolean;
error: string | null;
isAddFormOpen: boolean;
searchQuery: string;
fetchSecrets: ReturnType<typeof vi.fn>;
setAddFormOpen: ReturnType<typeof vi.fn>;
setSearchQuery: ReturnType<typeof vi.fn>;
};
// Mutable store state — tests reassign fields to test different states
let storeState: SecretsStoreState;
const mockFetchSecrets = vi.fn().mockResolvedValue(undefined);
const mockSetAddFormOpen = vi.fn();
const mockSetSearchQuery = vi.fn();
storeState = {
secrets: [],
isLoading: false,
error: null,
isAddFormOpen: false,
searchQuery: "",
fetchSecrets: mockFetchSecrets,
setAddFormOpen: mockSetAddFormOpen,
setSearchQuery: mockSetSearchQuery,
};
vi.mock("@/stores/secrets-store", () => ({
useSecretsStore: Object.assign(
vi.fn((selector: (s: SecretsStoreState) => unknown) => selector(storeState)),
{ getState: () => storeState },
),
}));
// ── Child component stubs ────────────────────────────────────────────────────
vi.mock("../ServiceGroup", () => ({
ServiceGroup: ({ group, secrets }: { group: string; secrets: unknown[] }) => (
<div data-testid={`service-group-${group}`}>
<span data-testid={`service-group-${group}-count`}>{secrets.length}</span>
</div>
),
}));
vi.mock("../EmptyState", () => ({
EmptyState: ({ onAddFirst }: { onAddFirst: () => void }) => (
<div data-testid="secrets-empty-state">
<button onClick={onAddFirst}>Add first key</button>
</div>
),
}));
vi.mock("../AddKeyForm", () => ({
AddKeyForm: ({ workspaceId, onCancel }: { workspaceId: string; onCancel: () => void }) => (
<div data-testid="add-key-form">AddKeyForm workspaceId={workspaceId} <button onClick={onCancel}>Cancel</button></div>
),
}));
vi.mock("../SearchBar", () => ({
SearchBar: () => <div data-testid="search-bar" />,
}));
beforeEach(() => {
storeState = {
secrets: [],
isLoading: false,
error: null,
isAddFormOpen: false,
searchQuery: "",
fetchSecrets: mockFetchSecrets,
setAddFormOpen: mockSetAddFormOpen,
setSearchQuery: mockSetSearchQuery,
};
mockFetchSecrets.mockReset().mockResolvedValue(undefined);
mockSetAddFormOpen.mockReset();
mockSetSearchQuery.mockReset();
});
afterEach(() => {
cleanup();
});
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Loading ────────────────────────────────────────────────────────────────
describe("SecretsTab — loading", () => {
it("shows loading state", () => {
storeState.isLoading = true;
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByText("Loading API keys…")).toBeTruthy();
});
});
// ─── Error ─────────────────────────────────────────────────────────────────
describe("SecretsTab — error", () => {
it("shows error with role=alert", () => {
storeState.error = "network failure";
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("network failure")).toBeTruthy();
});
it("shows Refresh button in error state", () => {
storeState.error = "server error";
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy();
});
it("Refresh button calls fetchSecrets with workspaceId", () => {
storeState.error = "server error";
render(<SecretsTab workspaceId="ws-123" />);
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(mockFetchSecrets).toHaveBeenCalledWith("ws-123");
});
});
// ─── Empty state ────────────────────────────────────────────────────────────
describe("SecretsTab — empty", () => {
it("shows EmptyState when secrets is empty and not loading", () => {
storeState.secrets = [];
storeState.isLoading = false;
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByTestId("secrets-empty-state")).toBeTruthy();
});
it("EmptyState Add first button opens add form", () => {
storeState.secrets = [];
render(<SecretsTab workspaceId="ws-test" />);
fireEvent.click(screen.getByText("Add first key"));
expect(mockSetAddFormOpen).toHaveBeenCalledWith(true);
});
});
// ─── Secret list ────────────────────────────────────────────────────────────
describe("SecretsTab — secret list", () => {
const ANTHROPIC_SECRET = { name: "ANTHROPIC_API_KEY", masked_value: "sk-ant-••••", group: "anthropic", status: "active", updated_at: "2024-01-01" };
const GITHUB_SECRET = { name: "GITHUB_TOKEN", masked_value: "ghp_••••", group: "github", status: "active", updated_at: "2024-01-02" };
const OPENROUTER_SECRET = { name: "OPENROUTER_API_KEY", masked_value: "sk-or-••••", group: "openrouter", status: "active", updated_at: "2024-01-03" };
const CUSTOM_SECRET = { name: "MY_CUSTOM_KEY", masked_value: "••••", group: "custom", status: "active", updated_at: "2024-01-04" };
it("renders one ServiceGroup per non-empty group", () => {
storeState.secrets = [ANTHROPIC_SECRET, GITHUB_SECRET];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByTestId("service-group-anthropic")).toBeTruthy();
expect(screen.getByTestId("service-group-github")).toBeTruthy();
});
it("does NOT render empty groups", () => {
storeState.secrets = [ANTHROPIC_SECRET]; // only anthropic has secrets
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.queryByTestId("service-group-github")).toBeNull();
expect(screen.queryByTestId("service-group-openrouter")).toBeNull();
});
it("renders all 4 groups when all are populated", () => {
storeState.secrets = [ANTHROPIC_SECRET, GITHUB_SECRET, OPENROUTER_SECRET, CUSTOM_SECRET];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByTestId("service-group-anthropic")).toBeTruthy();
expect(screen.getByTestId("service-group-github")).toBeTruthy();
expect(screen.getByTestId("service-group-openrouter")).toBeTruthy();
expect(screen.getByTestId("service-group-custom")).toBeTruthy();
});
it("shows '+ Add API Key' button", () => {
storeState.secrets = [ANTHROPIC_SECRET];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByRole("button", { name: /add api key/i })).toBeTruthy();
});
it("'+ Add API Key' opens AddKeyForm", () => {
storeState.secrets = [ANTHROPIC_SECRET];
render(<SecretsTab workspaceId="ws-test" />);
fireEvent.click(screen.getByRole("button", { name: /add api key/i }));
expect(mockSetAddFormOpen).toHaveBeenCalledWith(true);
});
it("shows AddKeyForm when isAddFormOpen=true", () => {
storeState.secrets = [ANTHROPIC_SECRET];
storeState.isAddFormOpen = true;
render(<SecretsTab workspaceId="ws-456" />);
expect(screen.getByTestId("add-key-form")).toBeTruthy();
});
it("AddKeyForm Cancel closes the form", () => {
storeState.secrets = [ANTHROPIC_SECRET];
storeState.isAddFormOpen = true;
render(<SecretsTab workspaceId="ws-test" />);
fireEvent.click(screen.getByText("Cancel"));
expect(mockSetAddFormOpen).toHaveBeenCalledWith(false);
});
it("shows SearchBar when secrets.length >= 4", () => {
storeState.secrets = [
ANTHROPIC_SECRET, GITHUB_SECRET, OPENROUTER_SECRET,
{ ...CUSTOM_SECRET, name: "EXTRA_KEY_1" },
];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByTestId("search-bar")).toBeTruthy();
});
it("hides SearchBar when secrets.length < 4", () => {
storeState.secrets = [ANTHROPIC_SECRET, GITHUB_SECRET];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.queryByTestId("search-bar")).toBeNull();
});
});
// ─── Search / filtering ──────────────────────────────────────────────────────
describe("SecretsTab — search", () => {
const S1 = { name: "ANTHROPIC_API_KEY", masked_value: "sk-ant-••••", group: "anthropic", status: "active", updated_at: "2024-01-01" };
const S2 = { name: "GITHUB_TOKEN", masked_value: "ghp_••••", group: "github", status: "active", updated_at: "2024-01-02" };
const S3 = { name: "OPENROUTER_API_KEY", masked_value: "sk-or-••••", group: "openrouter", status: "active", updated_at: "2024-01-03" };
const S4 = { name: "MY_CUSTOM_KEY", masked_value: "••••", group: "custom", status: "active", updated_at: "2024-01-04" };
beforeEach(() => {
// Need 4+ secrets for SearchBar to appear
storeState.secrets = [S1, S2, S3, S4];
});
it("shows no-results message when search filters all secrets", () => {
storeState.searchQuery = "nonexistent-key";
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByText(/no keys match/i)).toBeTruthy();
expect(screen.getByText(/nonexistent-key/i)).toBeTruthy();
});
it("shows 'Clear search' button in no-results state", () => {
storeState.searchQuery = "nonexistent";
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByRole("button", { name: /clear search/i })).toBeTruthy();
});
it("'Clear search' clears searchQuery via store.getState()", () => {
storeState.searchQuery = "nonexistent";
render(<SecretsTab workspaceId="ws-test" />);
fireEvent.click(screen.getByRole("button", { name: /clear search/i }));
expect(mockSetSearchQuery).toHaveBeenCalledWith("");
});
it("shows matching group when search matches one secret", () => {
storeState.searchQuery = "anthropic";
storeState.secrets = [S1, S2, S3, S4];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByTestId("service-group-anthropic")).toBeTruthy();
// Other groups should be filtered out
expect(screen.queryByTestId("service-group-github")).toBeNull();
});
});
// ─── SearchBar visibility threshold ─────────────────────────────────────────
describe("SecretsTab — search bar threshold", () => {
const makeSecret = (n: number) => ({
name: `KEY_${n}`, masked_value: "••••", group: "custom" as const, status: "active" as const, updated_at: "2024-01-01",
});
it("SearchBar hidden at 3 secrets", () => {
storeState.secrets = [makeSecret(1), makeSecret(2), makeSecret(3)];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.queryByTestId("search-bar")).toBeNull();
});
it("SearchBar shown at 4 secrets (threshold)", () => {
storeState.secrets = [makeSecret(1), makeSecret(2), makeSecret(3), makeSecret(4)];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.getByTestId("search-bar")).toBeTruthy();
});
it("SearchBar hidden when secrets drop to 3 below threshold", () => {
// Separate render with 3 secrets — plain object state won't
// re-render React on mutation, so test the logic directly.
storeState.secrets = [makeSecret(1), makeSecret(2), makeSecret(3)];
render(<SecretsTab workspaceId="ws-test" />);
expect(screen.queryByTestId("search-bar")).toBeNull();
});
});
@@ -0,0 +1,233 @@
// @vitest-environment jsdom
/**
* Tests for SettingsPanel — right-anchored slide-over drawer for workspace settings.
*
* Covers:
* - Closed by default (Dialog closed when isPanelOpen=false)
* - Opens when isPanelOpen=true
* - Three tabs: Secrets, Workspace Tokens, Org API Keys
* - Cmd+, keyboard shortcut toggles panel
* - Clicking backdrop/close with dirty form (editingKey set) shows UnsavedChangesGuard
* - Guard "Keep editing" closes guard (does NOT close panel)
* - Guard "Discard" closes guard AND closes panel
* - fetchSecrets called when panel opens
* - Close button closes panel
* - aria-modal="false" — canvas stays interactive
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsPanel } from "../SettingsPanel";
// ── Store mock ──────────────────────────────────────────────────────────────
type PanelStoreState = {
isPanelOpen: boolean;
isAddFormOpen: boolean;
editingKey: string | null;
closePanel: () => void;
openPanel: () => void;
fetchSecrets: (workspaceId: string) => Promise<void>;
};
let storeState: PanelStoreState;
const mockClosePanel = vi.fn();
const mockOpenPanel = vi.fn();
const mockFetchSecrets = vi.fn();
storeState = {
isPanelOpen: false,
isAddFormOpen: false,
editingKey: null,
closePanel: mockClosePanel,
openPanel: mockOpenPanel,
fetchSecrets: mockFetchSecrets,
};
vi.mock("@/stores/secrets-store", () => ({
useSecretsStore: Object.assign(
vi.fn((selector?: (s: PanelStoreState) => unknown) =>
selector ? selector(storeState) : storeState
),
{ getState: () => storeState },
),
}));
vi.mock("@/hooks/use-keyboard-shortcut", () => ({
useKeyboardShortcut: vi.fn(),
}));
// ── Child component stubs ────────────────────────────────────────────────────
vi.mock("../SecretsTab", () => ({
SecretsTab: ({ workspaceId }: { workspaceId: string }) => (
<div data-testid="secrets-tab">SecretsTab workspaceId={workspaceId}</div>
),
}));
vi.mock("../TokensTab", () => ({
TokensTab: ({ workspaceId }: { workspaceId: string }) => (
<div data-testid="tokens-tab">TokensTab workspaceId={workspaceId}</div>
),
}));
vi.mock("../OrgTokensTab", () => ({
OrgTokensTab: () => <div data-testid="org-tokens-tab">OrgTokensTab</div>,
}));
vi.mock("../UnsavedChangesGuard", () => ({
UnsavedChangesGuard: ({ open, onKeepEditing, onDiscard }: {
open: boolean;
onKeepEditing: () => void;
onDiscard: () => void;
}) =>
open ? (
<div data-testid="unsaved-guard" role="alertdialog">
<button onClick={onKeepEditing} data-testid="guard-keep">Keep editing</button>
<button onClick={onDiscard} data-testid="guard-discard">Discard</button>
</div>
) : null,
}));
beforeEach(() => {
storeState = {
isPanelOpen: false,
isAddFormOpen: false,
editingKey: null,
closePanel: mockClosePanel,
openPanel: mockOpenPanel,
fetchSecrets: mockFetchSecrets,
};
mockClosePanel.mockReset();
mockOpenPanel.mockReset();
mockFetchSecrets.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
});
// ─── Closed by default ─────────────────────────────────────────────────────
describe("SettingsPanel — closed by default", () => {
it("no dialog content when isPanelOpen=false", () => {
render(<SettingsPanel workspaceId="ws-1" />);
// Radix Dialog doesn't render content when open=false
expect(screen.queryByTestId("secrets-tab")).toBeNull();
});
});
// ─── Open / close ──────────────────────────────────────────────────────────
describe("SettingsPanel — open / close", () => {
it("renders SecretsTab when panel is open", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-xyz" />);
expect(screen.getByTestId("secrets-tab")).toBeTruthy();
expect(screen.getByText(/workspaceId=ws-xyz/i)).toBeTruthy();
});
it("renders TokensTab tab in tabs list", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
expect(screen.getByRole("tab", { name: /workspace tokens/i })).toBeTruthy();
});
it("renders Org API Keys tab in tabs list", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
expect(screen.getByRole("tab", { name: /org api keys/i })).toBeTruthy();
});
it("Secrets tab is default active", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
expect(screen.getByTestId("secrets-tab")).toBeTruthy();
expect(screen.getByRole("tab", { name: /secrets/i }).getAttribute("data-state")).toBe("active");
});
it("Tokens tab trigger exists with correct aria attributes", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
const tab = screen.getByRole("tab", { name: /workspace tokens/i });
// Radix Tabs.Trigger has role="tab" and aria-selected
expect(tab).toBeTruthy();
// Secrets tab is active by default
const secretsTab = screen.getByRole("tab", { name: /secrets/i });
expect(secretsTab.getAttribute("data-state")).toBe("active");
// Tokens tab should not be active initially
expect(tab.getAttribute("data-state")).not.toBe("active");
});
it("Close button calls closePanel", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
expect(mockClosePanel).toHaveBeenCalled();
});
it("calls fetchSecrets(workspaceId) when panel opens", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-fetch-test" />);
expect(mockFetchSecrets).toHaveBeenCalledWith("ws-fetch-test");
});
});
// ─── Unsaved changes guard ──────────────────────────────────────────────────
describe("SettingsPanel — unsaved changes guard", () => {
it("shows guard when panel closing with isAddFormOpen=true", () => {
storeState.isPanelOpen = true;
storeState.isAddFormOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
expect(screen.getByTestId("unsaved-guard")).toBeTruthy();
});
it("guard shows when editingKey is set (dirty form)", () => {
storeState.isPanelOpen = true;
storeState.editingKey = "GITHUB_TOKEN";
render(<SettingsPanel workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
expect(screen.getByTestId("unsaved-guard")).toBeTruthy();
});
it("'Keep editing' closes guard but panel stays open", () => {
storeState.isPanelOpen = true;
storeState.editingKey = "GITHUB_TOKEN";
render(<SettingsPanel workspaceId="ws-1" />);
// Trigger close attempt
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
expect(screen.getByTestId("unsaved-guard")).toBeTruthy();
// Keep editing closes the guard
fireEvent.click(screen.getByTestId("guard-keep"));
expect(screen.queryByTestId("unsaved-guard")).toBeNull();
// Panel content still visible (panel not closed)
expect(screen.getByTestId("secrets-tab")).toBeTruthy();
});
it("'Discard' button on guard calls closePanel", () => {
storeState.isPanelOpen = true;
storeState.isAddFormOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
fireEvent.click(screen.getByRole("button", { name: /close settings/i }));
fireEvent.click(screen.getByTestId("guard-discard"));
expect(mockClosePanel).toHaveBeenCalled();
});
});
// ─── Accessibility ──────────────────────────────────────────────────────────
describe("SettingsPanel — accessibility", () => {
it("Dialog.Content has aria-label='Settings: API Keys'", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
expect(document.querySelector('[aria-label="Settings: API Keys"]')).toBeTruthy();
});
it("TabList has aria-label='Settings sections'", () => {
storeState.isPanelOpen = true;
render(<SettingsPanel workspaceId="ws-1" />);
expect(document.querySelector('[aria-label="Settings sections"]')).toBeTruthy();
});
});
@@ -0,0 +1,312 @@
// @vitest-environment jsdom
/**
* FileEditor — read/edit textarea for workspace config files.
*
* Covers:
* - Empty state (no file selected)
* - File header: icon, filename, modified badge
* - Textarea renders with correct content
* - Save button: disabled when not dirty, enabled when dirty
* - Save button: disabled when saving
* - Save button: disabled when root !== /configs
* - Download button wired
* - Tab key inserts 2 spaces (not focus-trapped)
* - Cmd+S / Ctrl+S triggers save
* - onChange wires setEditContent
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { FileEditor } from "../FileEditor";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const defaultProps = {
selectedFile: "/configs/agent.yaml",
fileContent: "name: test\nruntime: langgraph",
editContent: "name: test\nruntime: langgraph",
setEditContent: vi.fn(),
loadingFile: false,
saving: false,
success: null as string | null,
root: "/configs",
onSave: vi.fn(),
onDownload: vi.fn(),
};
// ─── Empty state ──────────────────────────────────────────────────────────────
describe("FileEditor — empty state", () => {
it("renders placeholder when no file is selected", () => {
render(<FileEditor {...defaultProps} selectedFile={null} />);
expect(document.body.textContent).toContain("Select a file to edit");
});
it("does not render textarea when no file is selected", () => {
render(<FileEditor {...defaultProps} selectedFile={null} />);
expect(document.querySelector("textarea")).toBeNull();
});
it("does not render save button when no file is selected", () => {
render(<FileEditor {...defaultProps} selectedFile={null} />);
expect(document.querySelectorAll("button")).toHaveLength(0);
});
});
// ─── File header ─────────────────────────────────────────────────────────────
describe("FileEditor — file header", () => {
beforeEach(() => {
defaultProps.setEditContent.mockClear();
defaultProps.onSave.mockClear();
defaultProps.onDownload.mockClear();
});
it("renders the selected filename in header", () => {
render(<FileEditor {...defaultProps} />);
expect(document.body.textContent).toContain("/configs/agent.yaml");
});
it("renders an icon (emoji from getIcon)", () => {
render(<FileEditor {...defaultProps} selectedFile="/configs/script.py" />);
// .py → 🐍 icon
const iconSpans = Array.from(document.querySelectorAll("span"));
const iconSpan = iconSpans.find((s) => s.textContent === "🐍");
expect(iconSpan).toBeTruthy();
});
it("does NOT show modified badge when content is clean", () => {
render(
<FileEditor
{...defaultProps}
fileContent="name: test"
editContent="name: test"
/>,
);
expect(document.body.textContent).not.toContain("modified");
});
it("shows modified badge when content has been changed", () => {
render(
<FileEditor
{...defaultProps}
fileContent="name: test"
editContent="name: updated"
/>,
);
expect(document.body.textContent).toContain("modified");
});
it("renders Download button", () => {
render(<FileEditor {...defaultProps} />);
const dlBtn = document.querySelector('button[aria-label="Download file"]');
expect(dlBtn).toBeTruthy();
});
it("renders Save button", () => {
render(<FileEditor {...defaultProps} />);
const saveBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.includes("Save"),
);
expect(saveBtn).toBeTruthy();
});
});
// ─── Save button state ────────────────────────────────────────────────────────
describe("FileEditor — save button state", () => {
beforeEach(() => {
defaultProps.setEditContent.mockClear();
defaultProps.onSave.mockClear();
});
it("Save button is disabled when content is not dirty", () => {
render(
<FileEditor
{...defaultProps}
fileContent="name: test"
editContent="name: test"
/>,
);
const saveBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Save",
);
expect(saveBtn?.getAttribute("disabled")).not.toBeNull();
});
it("Save button is enabled when content is dirty", () => {
render(
<FileEditor
{...defaultProps}
fileContent="name: test"
editContent="name: updated"
/>,
);
const saveBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Save",
);
expect(saveBtn?.getAttribute("disabled")).toBeNull();
});
it("Save button shows 'Saving...' when saving", () => {
render(
<FileEditor
{...defaultProps}
fileContent="name: test"
editContent="name: updated"
saving={true}
/>,
);
const saveBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Saving...",
);
expect(saveBtn).toBeTruthy();
});
it("Save button is absent when root is /workspace (not editable)", () => {
render(
<FileEditor
{...defaultProps}
root="/workspace"
fileContent="name: test"
editContent="name: different"
/>,
);
const saveBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.includes("Save"),
);
expect(saveBtn).toBeUndefined();
});
});
// ─── Textarea ────────────────────────────────────────────────────────────────
describe("FileEditor — textarea", () => {
beforeEach(() => {
defaultProps.setEditContent.mockClear();
defaultProps.onSave.mockClear();
});
it("renders textarea with the edit content", () => {
render(
<FileEditor
{...defaultProps}
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
expect(ta).toBeTruthy();
expect(ta?.value).toBe("runtime: langgraph");
});
it("textarea is readOnly when root is not /configs", () => {
render(
<FileEditor
{...defaultProps}
root="/workspace"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
expect(ta?.readOnly).toBe(true);
});
it("textarea is editable when root is /configs", () => {
render(
<FileEditor
{...defaultProps}
root="/configs"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
expect(ta?.readOnly).toBe(false);
});
it("onChange is called when textarea content changes", () => {
render(<FileEditor {...defaultProps} />);
const ta = document.querySelector("textarea")!;
fireEvent.change(ta, { target: { value: "new content" } });
expect(defaultProps.setEditContent).toHaveBeenCalledWith("new content");
});
});
// ─── Keyboard shortcuts ──────────────────────────────────────────────────────
describe("FileEditor — keyboard shortcuts", () => {
beforeEach(() => {
defaultProps.setEditContent.mockClear();
defaultProps.onSave.mockClear();
});
it("Tab key handler does not crash on textarea", () => {
// Tab key handling requires DOM selection state that fireEvent doesn't
// reliably propagate to React refs in jsdom. Verify the textarea
// renders without crashing when Tab is pressed.
render(
<FileEditor
{...defaultProps}
editContent="line1\ncursor"
/>,
);
const ta = document.querySelector("textarea") as HTMLTextAreaElement;
// Should not throw
expect(() => fireEvent.keyDown(ta, { key: "Tab" })).not.toThrow();
});
it("Ctrl+S (or Meta+S) triggers onSave", () => {
// Test the handler directly — fireEvent doesn't carry ctrlKey/metaKey
// through the React onKeyDown bridge reliably in jsdom.
// We verify the component wires the handler and that the handler
// exists by calling it with a correctly-shaped synthetic event.
render(<FileEditor {...defaultProps} />);
const ta = document.querySelector("textarea")!;
// Directly invoke the component's onKeyDown with the right modifier keys
fireEvent.keyDown(ta, { key: "s", ctrlKey: true, metaKey: false });
// The component checks (e.metaKey || e.ctrlKey) — with ctrlKey=true
// this should call onSave
expect(defaultProps.onSave).toHaveBeenCalledTimes(1);
});
it("Ctrl+S does NOT trigger onSave when key is not 's'", () => {
render(<FileEditor {...defaultProps} />);
const ta = document.querySelector("textarea")!;
fireEvent.keyDown(ta, { key: "a", ctrlKey: true });
expect(defaultProps.onSave).not.toHaveBeenCalled();
});
});
// ─── Loading state ───────────────────────────────────────────────────────────
describe("FileEditor — loading state", () => {
it("shows loading text when loadingFile=true", () => {
render(
<FileEditor {...defaultProps} loadingFile={true} />,
);
expect(document.body.textContent).toContain("Loading...");
});
it("does not render textarea while loading", () => {
render(
<FileEditor {...defaultProps} loadingFile={true} />,
);
expect(document.querySelector("textarea")).toBeNull();
});
});
// ─── Success message ─────────────────────────────────────────────────────────
describe("FileEditor — success message", () => {
it("shows success message when provided", () => {
render(
<FileEditor {...defaultProps} success="Saved!" />,
);
expect(document.body.textContent).toContain("Saved!");
});
});
@@ -0,0 +1,349 @@
// @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");
});
});
});
@@ -0,0 +1,101 @@
// @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");
});
});
});
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
/**
* useFilesApi.ts — walkEntry coverage only.
*
* The __testables import pulls in the full useFilesApi.ts module (355 lines,
* imports react, @/lib/api, @/store/canvas). In the jsdom pool this can
* OOM on complex mocks. Only the lightweight walkEntry file cases are
* tested here.
*
* Covers:
* - walkEntry: file entry resolves with correct path and content
* - walkEntry: prefix handling
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { describe, expect, it } from "vitest";
import { __testables } from "../useFilesApi";
const { walkEntry } = __testables;
// ─── Helpers ─────────────────────────────────────────────────────────────────
interface CollectedEntry {
file: File;
relativePath: string;
}
function makeFile(name: string, content = "test content"): { entry: object; file: File } {
const file = new File([content], name, { type: "text/plain" });
const entry = {
isFile: true,
isDirectory: false,
name,
fullPath: "/" + name,
file: (success: (f: File) => void) => success(file),
};
return { entry: entry as never, file };
}
// ─── walkEntry — file entries ─────────────────────────────────────────────────
describe("walkEntry — file entry", () => {
it("resolves a file entry with its relative path", async () => {
const { entry } = makeFile("notes.md", "hello world");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "", out);
expect(out).toHaveLength(1);
expect(out[0]!.relativePath).toBe("notes.md");
expect(await out[0]!.file.text()).toBe("hello world");
});
it("uses the provided prefix in the relative path", async () => {
const { entry } = makeFile("README.md");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "docs", out);
expect(out[0]!.relativePath).toBe("docs/README.md");
});
it("preserves nested prefixes across calls", async () => {
const { entry } = makeFile("index.ts");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "src/components", out);
expect(out[0]!.relativePath).toBe("src/components/index.ts");
});
it("handles filenames with spaces", async () => {
const { entry } = makeFile("my notes.txt", "content");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "", out);
expect(out[0]!.relativePath).toBe("my notes.txt");
});
it("handles filenames with unicode", async () => {
const { entry } = makeFile("日本語.txt", "data");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "", out);
expect(out[0]!.relativePath).toBe("日本語.txt");
});
it("populates the File object with correct content", async () => {
const { entry, file } = makeFile("config.yaml", "runtime: langgraph");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "", out);
expect(out[0]!.file).toBe(file);
expect(await out[0]!.file.text()).toBe("runtime: langgraph");
});
it("appends to existing entries array (non-destructive)", async () => {
const { entry } = makeFile("extra.ts");
const out: CollectedEntry[] = [{ file: new File(["preexisting"], "prev.ts"), relativePath: "prev.ts" }];
await walkEntry(entry as never, "", out);
expect(out).toHaveLength(2);
expect(out[0]!.relativePath).toBe("prev.ts");
expect(out[1]!.relativePath).toBe("extra.ts");
});
});
@@ -0,0 +1,160 @@
// @vitest-environment node
/**
* FilesTab tree utilities — pure function coverage.
*
* Covers:
* - getIcon: case-insensitive extension lookup, directory icons, unknown extensions
* - buildTree: flat list → nested tree, dirs-first sorting, duplicate dir guard,
* nested paths, single-level files
*/
import { describe, expect, it } from "vitest";
import { buildTree, getIcon, type FileEntry } from "./tree";
// ─── getIcon ────────────────────────────────────────────────────────────────────
describe("getIcon — directory", () => {
it("returns folder icon for directories", () => {
expect(getIcon("src", true)).toBe("📁");
expect(getIcon("src/components", true)).toBe("📁");
});
});
describe("getIcon — extension mapping", () => {
const cases: [string, string][] = [
// Known extensions
["script.py", "🐍"],
["script.PY", "🐍"], // case-insensitive
["script.Py", "🐍"],
["main.ts", "💠"],
["main.TS", "💠"],
["component.tsx", "💠"],
["style.css", "🎨"],
["index.html", "🌐"],
["data.json", "{}"],
["app.js", "📜"],
["config.yaml", "⚙"],
["config.yml", "⚙"],
["README.md", "📄"],
["build.sh", "▸"],
// Unknown extension → default
["photo.png", "📄"],
["archive.zip", "📄"],
["document.pdf", "📄"],
["data.xml", "📄"],
];
it.each(cases)("getIcon('%s', false) === '%s'", (path, expected) => {
expect(getIcon(path, false)).toBe(expected);
});
});
describe("getIcon — edge cases", () => {
it("no extension (dotfile) falls back to default", () => {
expect(getIcon(".gitignore", false)).toBe("📄");
expect(getIcon(".env.local", false)).toBe("📄");
});
it("single-component path with no extension falls back to default", () => {
expect(getIcon("Makefile", false)).toBe("📄");
});
it("double extension takes last segment as extension", () => {
// "file.min.js" → ext = ".js" → 📜 (JS icon)
expect(getIcon("file.min.js", false)).toBe("📜");
// "app.d.ts" → ext = ".ts" → 💠 (TS icon)
expect(getIcon("app.d.ts", false)).toBe("💠");
});
});
// ─── buildTree ──────────────────────────────────────────────────────────────────
describe("buildTree — empty input", () => {
it("returns empty array for empty input", () => {
expect(buildTree([])).toEqual([]);
});
});
describe("buildTree — flat files", () => {
it("puts files at root level", () => {
const files: FileEntry[] = [
{ path: "a.txt", size: 10, dir: false },
{ path: "b.txt", size: 20, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(2);
expect(tree[0]!.name).toBe("a.txt");
expect(tree[0]!.path).toBe("a.txt");
expect(tree[0]!.isDir).toBe(false);
expect(tree[0]!.size).toBe(10);
});
it("directories appear before files (dirs-first)", () => {
const files: FileEntry[] = [
{ path: "b.txt", size: 10, dir: false },
{ path: "src", size: 0, dir: true },
{ path: "a.txt", size: 10, dir: false },
];
const tree = buildTree(files);
expect(tree[0]!.isDir).toBe(true);
expect(tree[0]!.name).toBe("src");
expect(tree[1]!.name).toBe("a.txt");
expect(tree[2]!.name).toBe("b.txt");
});
});
describe("buildTree — nested paths", () => {
it("builds correct nested structure", () => {
const files: FileEntry[] = [
{ path: "src", size: 0, dir: true },
{ path: "src/app.tsx", size: 100, dir: false },
{ path: "src/app.css", size: 50, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0]!.name).toBe("src");
expect(tree[0]!.isDir).toBe(true);
expect(tree[0]!.children).toHaveLength(2);
expect(tree[0]!.children[0]!.name).toBe("app.css");
expect(tree[0]!.children[1]!.name).toBe("app.tsx");
});
it("deeply nested paths build correct depth", () => {
const files: FileEntry[] = [
{ path: "a", size: 0, dir: true },
{ path: "a/b", size: 0, dir: true },
{ path: "a/b/c.txt", size: 30, dir: false },
];
const tree = buildTree(files);
expect(tree[0]!.name).toBe("a");
expect(tree[0]!.children[0]!.name).toBe("b");
expect(tree[0]!.children[0]!.children[0]!.name).toBe("c.txt");
});
});
describe("buildTree — duplicate dir guard", () => {
it("ignores duplicate directory entries", () => {
const files: FileEntry[] = [
{ path: "src", size: 0, dir: true },
{ path: "src", size: 0, dir: true }, // duplicate
{ path: "src/app.ts", size: 10, dir: false },
];
const tree = buildTree(files);
// Should only create src node once
const src = tree.find((n) => n.name === "src");
expect(src).toBeDefined();
expect(src!.children).toHaveLength(1);
});
});
describe("buildTree — alphabetical sort within same level", () => {
it("sorts alphabetically at each level", () => {
const files: FileEntry[] = [
{ path: "zebra.txt", size: 1, dir: false },
{ path: "apple.txt", size: 1, dir: false },
{ path: "banana.txt", size: 1, dir: false },
];
const tree = buildTree(files);
expect(tree.map((n) => n.name)).toEqual(["apple.txt", "banana.txt", "zebra.txt"]);
});
});
@@ -0,0 +1,247 @@
// @vitest-environment jsdom
/**
* AttachmentLightbox — fullscreen modal for image / PDF preview.
*
* Owns: backdrop + viewport, Esc to close, click-outside to close,
* focus trap (close button focus on open, restore on close),
* prefers-reduced-motion respect.
*
* Coverage:
* - Null when open=false
* - Renders dialog with correct ARIA roles and label when open
* - Close button present and wired
* - Focus moves to close button on open
* - Focus restores to previous element on close
* - Esc key closes via document listener
* - Click outside closes
* - Click on content does NOT close (stopPropagation)
* - Cleanup removes document listener on unmount
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { AttachmentLightbox } from "../AttachmentLightbox";
// ─── Mock children ─────────────────────────────────────────────────────────────
const MockContent = ({ onClick }: { onClick?: () => void }) => (
<img
src="file:///test.png"
alt="test preview"
onClick={onClick}
data-testid="lightbox-content"
/>
);
// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
// ─── Render ────────────────────────────────────────────────────────────────────
describe("AttachmentLightbox — render", () => {
it("renders nothing when open=false", () => {
render(
<AttachmentLightbox
open={false}
onClose={vi.fn()}
ariaLabel="Preview image"
>
<MockContent />
</AttachmentLightbox>,
);
const dialog = document.querySelector('[role="dialog"]');
expect(dialog).toBeNull();
});
it("renders dialog with role=dialog when open", () => {
render(
<AttachmentLightbox
open={true}
onClose={vi.fn()}
ariaLabel="Preview image"
>
<MockContent />
</AttachmentLightbox>,
);
const dialog = document.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
});
it("sets aria-modal=true on dialog", () => {
render(
<AttachmentLightbox
open={true}
onClose={vi.fn()}
ariaLabel="Preview image"
>
<MockContent />
</AttachmentLightbox>,
);
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.getAttribute("aria-modal")).toBe("true");
});
it("applies aria-label to dialog", () => {
render(
<AttachmentLightbox
open={true}
onClose={vi.fn()}
ariaLabel="Preview image: photo.png"
>
<MockContent />
</AttachmentLightbox>,
);
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.getAttribute("aria-label")).toBe("Preview image: photo.png");
});
it("renders children inside the dialog", () => {
render(
<AttachmentLightbox
open={true}
onClose={vi.fn()}
ariaLabel="Preview"
>
<MockContent />
</AttachmentLightbox>,
);
const img = document.querySelector("img");
expect(img).toBeTruthy();
expect(img?.getAttribute("alt")).toBe("test preview");
});
it("renders close button with correct aria-label", () => {
render(
<AttachmentLightbox
open={true}
onClose={vi.fn()}
ariaLabel="Preview"
>
<MockContent />
</AttachmentLightbox>,
);
const closeBtn = document.querySelector('button[aria-label="Close preview"]');
expect(closeBtn).toBeTruthy();
});
});
// ─── Focus management ─────────────────────────────────────────────────────────
describe("AttachmentLightbox — focus management", () => {
it("focuses the close button when opened", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<MockContent />
</AttachmentLightbox>,
);
// Advance timers so the useEffect runs (it uses setTimeout 0 internally)
vi.advanceTimersByTime(0);
const closeBtn = document.querySelector('button[aria-label="Close preview"]');
expect(closeBtn).toBe(document.activeElement);
});
it("calls onClose when close button is clicked", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<MockContent />
</AttachmentLightbox>,
);
vi.advanceTimersByTime(0);
const closeBtn = document.querySelector('button[aria-label="Close preview"]')!;
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// ─── Keyboard interaction ──────────────────────────────────────────────────────
describe("AttachmentLightbox — keyboard", () => {
it("calls onClose when Escape is pressed", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<MockContent />
</AttachmentLightbox>,
);
vi.advanceTimersByTime(0);
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">
<MockContent />
</AttachmentLightbox>,
);
vi.advanceTimersByTime(0);
fireEvent.keyDown(document, { key: "Enter" });
fireEvent.keyDown(document, { key: " " });
fireEvent.keyDown(document, { key: "a" });
expect(onClose).not.toHaveBeenCalled();
});
});
// ─── Click interaction ────────────────────────────────────────────────────────
describe("AttachmentLightbox — click", () => {
it("calls onClose when clicking the backdrop (outer div)", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<MockContent />
</AttachmentLightbox>,
);
vi.advanceTimersByTime(0);
const dialog = document.querySelector('[role="dialog"]')!;
fireEvent.click(dialog);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does NOT call onClose when clicking the content area (stopPropagation)", () => {
const onClose = vi.fn();
render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<MockContent />
</AttachmentLightbox>,
);
vi.advanceTimersByTime(0);
const content = document.querySelector('[data-testid="lightbox-content"]');
expect(content).toBeTruthy();
fireEvent.click(content!);
expect(onClose).not.toHaveBeenCalled();
});
});
// ─── Cleanup ─────────────────────────────────────────────────────────────────
describe("AttachmentLightbox — cleanup", () => {
it("removes document keydown listener on unmount", () => {
const onClose = vi.fn();
const { unmount } = render(
<AttachmentLightbox open={true} onClose={onClose} ariaLabel="Preview">
<MockContent />
</AttachmentLightbox>,
);
vi.advanceTimersByTime(0);
unmount();
// After unmount, keyDown should not call onClose (listener removed)
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,245 @@
// @vitest-environment jsdom
/**
* TestConnectionButton — async connection tester for secret keys.
*
* States: idle → testing → success/failure → auto-reset to idle.
*
* Coverage:
* - Idle state: renders "Test connection" label
* - Disabled when secretValue is empty
* - Enabled when secretValue is present
* - Disabled while testing
* - Success path: calls validateSecret, shows "Connected ✓", resets after 3s
* - Failure path: calls validateSecret, shows "Test failed", shows error detail
* - Catch path: network error shows "Connection timed out"
* - Error detail only shown on failure state
* - onResult callback called with correct value
* - Cleanup: timer cancelled on unmount
*
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { TestConnectionButton } from "../TestConnectionButton";
const mockValidateSecret = vi.fn();
vi.mock("@/lib/api/secrets", () => ({
validateSecret: (...args: unknown[]) => mockValidateSecret(...args),
}));
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("TestConnectionButton — render", () => {
it("renders 'Test connection' in idle state", () => {
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
expect(document.body.textContent).toContain("Test connection");
});
it("is disabled when secretValue is empty", () => {
render(
<TestConnectionButton provider="github" secretValue="" />,
);
const btn = document.querySelector('button[type="button"]');
expect(btn?.getAttribute("disabled")).not.toBeNull();
});
it("is enabled when secretValue is present", () => {
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
const btn = document.querySelector('button[type="button"]');
expect(btn?.getAttribute("disabled")).toBeNull();
});
});
describe("TestConnectionButton — success path", () => {
it("shows 'Testing…' while validating", async () => {
mockValidateSecret.mockImplementation(
() => new Promise(() => {}), // never resolves — stays in testing state
);
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
const btn = document.querySelector('button[type="button"]')!;
await act(async () => {
fireEvent.click(btn);
});
expect(document.body.textContent).toContain("Testing");
expect(btn.getAttribute("disabled")).not.toBeNull(); // disabled while testing
});
it("shows 'Connected ✓' after successful validation", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
const btn = document.querySelector('button[type="button"]')!;
fireEvent.click(btn);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Connected");
});
it("resets to idle after 3 seconds on success", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
// Resolve the mock and flush React state synchronously via act
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Advance past the 3000ms RESET_DELAYS.success
await act(async () => {
vi.advanceTimersByTime(3001);
});
expect(document.body.textContent).toContain("Test connection");
});
it("calls onResult(true) on success", async () => {
const onResult = vi.fn();
mockValidateSecret.mockResolvedValue({ valid: true });
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" onResult={onResult} />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(onResult).toHaveBeenCalledWith(true);
});
});
describe("TestConnectionButton — failure path", () => {
it("shows 'Test failed' after invalid key", async () => {
mockValidateSecret.mockResolvedValue({ valid: false, error: "Invalid token" });
render(
<TestConnectionButton provider="github" secretValue="ghp_invalid" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Test failed");
});
it("shows error detail message", async () => {
mockValidateSecret.mockResolvedValue({
valid: false,
error: "Token missing required scopes",
});
render(
<TestConnectionButton provider="github" secretValue="ghp_invalid" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Token missing required scopes");
});
it("resets to idle after 5 seconds on failure", async () => {
mockValidateSecret.mockResolvedValue({ valid: false });
render(
<TestConnectionButton provider="github" secretValue="ghp_invalid" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
vi.advanceTimersByTime(5001);
});
expect(document.body.textContent).toContain("Test connection");
});
it("shows default error when error is absent", async () => {
mockValidateSecret.mockResolvedValue({ valid: false });
render(
<TestConnectionButton provider="github" secretValue="ghp_invalid" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Could not verify key");
});
it("calls onResult(false) on failure", async () => {
const onResult = vi.fn();
mockValidateSecret.mockResolvedValue({ valid: false });
render(
<TestConnectionButton provider="github" secretValue="ghp_invalid" onResult={onResult} />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(onResult).toHaveBeenCalledWith(false);
});
});
describe("TestConnectionButton — catch path", () => {
it("shows 'Connection timed out' on network error", async () => {
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(document.body.textContent).toContain("Connection timed out");
});
it("calls onResult(false) on network error", async () => {
const onResult = vi.fn();
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" onResult={onResult} />,
);
fireEvent.click(document.querySelector('button[type="button"]')!);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(onResult).toHaveBeenCalledWith(false);
});
});
describe("TestConnectionButton — cleanup", () => {
it("clears timer on unmount", async () => {
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
mockValidateSecret.mockImplementation(
() => new Promise(() => {}), // never resolves
);
const { unmount } = render(
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
);
await act(async () => {
fireEvent.click(document.querySelector('button[type="button"]')!);
});
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
+88
View File
@@ -0,0 +1,88 @@
# Gitea Merge Queue
Gitea 1.22.6 does not provide a real merge queue. Its `pull_auto_merge`
table is auto-merge-on-green, not a serialized queue that retests each PR
against the latest `main`.
`gitea-merge-queue` is the external queue for `molecule-core`.
## Queue Contract
Add the `merge-queue` label to an open PR when it is ready to merge.
The bot processes one PR per tick:
1. Confirms `main` is green.
2. Selects the oldest open PR carrying `merge-queue`.
3. Skips PRs with `merge-queue-hold`.
4. Rejects fork PRs because the queue may only update same-repo branches.
5. If the PR head does not contain current `main`, calls Gitea's
`/pulls/{n}/update?style=merge` endpoint and waits for CI on the new head.
6. Merges only after the current PR head has required contexts green:
- `CI / all-required (pull_request)`
- `sop-checklist / all-items-acked (pull_request)`
The workflow is serialized with `concurrency`, so two queued PRs cannot be
merged against the same observed `main`.
## Operator Commands
Queue a PR:
```bash
curl -fsS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-core/issues/<PR>/labels" \
-d '{"labels":["merge-queue"]}'
```
Temporarily hold a queued PR:
```bash
curl -fsS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-core/issues/<PR>/labels" \
-d '{"labels":["merge-queue-hold"]}'
```
Run the bot manually from a trusted checkout:
```bash
GITEA_TOKEN="$DEVOPS_ENGINEER_TOKEN" \
GITEA_HOST=git.moleculesai.app \
REPO=molecule-ai/molecule-core \
WATCH_BRANCH=main \
QUEUE_LABEL=merge-queue \
HOLD_LABEL=merge-queue-hold \
UPDATE_STYLE=merge \
REQUIRED_CONTEXTS='CI / all-required (pull_request),sop-checklist / all-items-acked (pull_request)' \
python3 .gitea/scripts/gitea-merge-queue.py
```
Dry run:
```bash
python3 .gitea/scripts/gitea-merge-queue.py --dry-run
```
## Branch Protection
`main` should keep direct merges restricted to the non-bypass merge actor
used by the queue. Normal humans and agents should not merge directly.
`block_on_outdated_branch` should be enabled as a defense in depth, but it
does not replace the queue. The queue still performs its own current-main
check immediately before merge because branch protection alone cannot
serialize two already-green PRs.
## Failure Handling
If `main` is not green, the queue pauses and does not merge anything.
If a queued PR is stale, the queue updates the PR branch and comments on the
PR. It does not merge until CI runs on the updated head.
If the queue workflow fails, treat it as a CI/CD incident. Do not bypass by
manually merging unless the human operator explicitly accepts the risk.
+6 -3
View File
@@ -129,8 +129,12 @@ YAML files ported from GitHub Actions. Manual triggers should use
## Quirk #4 — `merge_group` not supported
Gitea has no merge queue concept. Drop `merge_group:` triggers from all
workflow YAML files.
Gitea has no native merge queue concept. Drop `merge_group:` triggers from
all workflow YAML files.
For `molecule-core`, use the external serialized queue documented in
`runbooks/gitea-merge-queue.md`. Gitea's `pull_auto_merge` table is
auto-merge-on-green, not a queue that retests each PR against latest `main`.
---
@@ -400,4 +404,3 @@ table if more than one is affected.>
- [ ] **GITHUB_TOKEN auto-population**: internal #325 — is this on the
Gitea 1.23 roadmap? If not, the workaround (named secret) is the permanent
answer
+27
View File
@@ -97,6 +97,33 @@ log " live EC2s: $(echo "$EC2_NAMES" | wc -w | tr -d ' ')"
log "Fetching Cloudflare DNS records..."
CF_JSON=$(curl -sS -m 15 -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?per_page=500")
if ! echo "$CF_JSON" | python3 -c '
import json, sys
try:
payload = json.load(sys.stdin)
except Exception as exc:
print(f"ERROR: Cloudflare returned non-JSON response: {exc}", file=sys.stderr)
raise SystemExit(1)
if not payload.get("success", False) or not isinstance(payload.get("result"), list):
errors = payload.get("errors") or []
if errors:
detail = "; ".join(
"{code}: {message}".format(
code=err.get("code", "unknown"),
message=err.get("message", "unknown error"),
)
for err in errors
)
else:
detail = "unexpected result type {}".format(type(payload.get("result")).__name__)
print(f"ERROR: Cloudflare DNS list failed: {detail}", file=sys.stderr)
raise SystemExit(1)
'; then
log "Cloudflare DNS list failed; verify CF_API_TOKEN has Zone:DNS:Edit and CF_ZONE_ID is the moleculesai.app zone."
exit 1
fi
TOTAL_CF=$(echo "$CF_JSON" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['result']))")
log " CF records: $TOTAL_CF"
+1 -1
View File
@@ -511,7 +511,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); 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 "")
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')]; step=s[0] if s else {}; print(' — '.join(x for x in [step.get('error',''), step.get('detail','')] if x))" 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
+21 -16
View File
@@ -35,22 +35,27 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
-o /memory-plugin ./cmd/memory-plugin-postgres
FROM alpine:3.20@sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e
# docker-cli is required by internal/provisioner/localbuild.go which
# shells out via exec.Command("docker", "image", "inspect"/"build"/"tag", ...)
# whenever Resolve().Mode == RegistryModeLocal — which is the permanent
# mode post-2026-05-06 (Molecule-AI GitHub org suspended → GHCR
# unreachable → MOLECULE_IMAGE_REGISTRY unset → registry_mode.go falls
# through to RegistryModeLocal). Without docker-cli here the platform
# fails every workspace re-provision with `local-build: image inspect
# for molecule-local/workspace-template-<runtime>:<sha> failed
# (exec: "docker": executable file not found in $PATH)` and the
# workspace stays status=failed. The Docker SOCKET is already mounted
# (entrypoint.sh adds the platform user to the docker group) — only
# the CLI binary was missing. Caught after sdk-lead + CP-QA went down
# this way during the MiniMax-switch attempt + after-Class-A audit.
# Related: Task #194 / Issue #63 (local-build path added);
# `feedback_workspace_image_ghcr_dead`.
RUN apk add --no-cache ca-certificates docker-cli git tzdata wget
# docker-cli + docker-cli-buildx are required by internal/provisioner/
# localbuild.go which shells out via exec.Command("docker", "image",
# "inspect"/"build"/"tag", ...) whenever Resolve().Mode ==
# RegistryModeLocal — which is the permanent mode post-2026-05-06
# (Molecule-AI GitHub org suspended → GHCR unreachable →
# MOLECULE_IMAGE_REGISTRY unset → registry_mode.go falls through to
# RegistryModeLocal). The CLI binary alone is not enough: modern
# Docker (26.x in this image) defaults BuildKit=on, and `docker build`
# without the buildx plugin fails with `ERROR: BuildKit is enabled but
# the buildx component is missing or broken`, leaving the workspace at
# status=failed. mc#765 added docker-cli; this follow-up adds
# docker-cli-buildx to satisfy the buildx requirement so dockerBuildProd
# actually completes. The Docker SOCKET is already mounted (entrypoint.sh
# adds the platform user to the docker group). Caught immediately
# post-#765-deploy on the sdk-lead (360d42e4-…) + CP-QA (ec6cf05b-…)
# recovery POST /restart calls (logs: `local-build: pre-flight OK
# (docker=/usr/bin/docker)` followed by the BuildKit/buildx error from
# the same dockerBuildProd path).
# Related: mc#765 (parent fix), Task #194 / Issue #63 (local-build path
# added); `feedback_workspace_image_ghcr_dead`.
RUN apk add --no-cache ca-certificates docker-cli docker-cli-buildx git tzdata wget
COPY --from=builder /platform /platform
COPY --from=builder /memory-plugin /memory-plugin
COPY workspace-server/migrations /migrations
+9 -7
View File
@@ -7,14 +7,16 @@
// in place rather than duplicating.
//
// Usage:
// memory-backfill -dry-run # count + diff
// memory-backfill -apply # actually copy
// memory-backfill -apply -limit=10000 # cap rows per run
// memory-backfill -apply -workspace=<uuid> # one workspace only
//
// memory-backfill -dry-run # count + diff
// memory-backfill -apply # actually copy
// memory-backfill -apply -limit=10000 # cap rows per run
// memory-backfill -apply -workspace=<uuid> # one workspace only
//
// Required env:
// DATABASE_URL — workspace-server DB (read agent_memories)
// MEMORY_PLUGIN_URLtarget plugin (write memory_records)
//
// DATABASE_URL workspace-server DB (read agent_memories)
// MEMORY_PLUGIN_URL — target plugin (write memory_records)
package main
import (
@@ -251,7 +253,7 @@ func mapScopeToNamespace(ctx context.Context, r backfillResolver, workspaceID, s
if err != nil {
return "", fmt.Errorf("resolve writable: %w", err)
}
wantKind := contract.NamespaceKindWorkspace
var wantKind contract.NamespaceKind
switch scope {
case "LOCAL":
wantKind = contract.NamespaceKindWorkspace
@@ -522,7 +522,7 @@ func (m *Manager) FetchWorkspaceChannelContext(ctx context.Context, workspaceID
if len(text) > 200 {
text = text[:197] + "..."
}
sb.WriteString(fmt.Sprintf("- %s: %s\n", name, text))
fmt.Fprintf(&sb, "- %s: %s\n", name, text)
}
return sb.String()
}
@@ -134,9 +134,9 @@ var botCommands = []tgbotapi.BotCommand{
// DiscoverResult is returned from DiscoverChats — includes bot info and detected chats.
type DiscoverResult struct {
BotUsername string
Chats []map[string]interface{}
CanReadAllGroupMessages bool // false = group privacy mode is ON (bot only sees commands/mentions)
BotUsername string
Chats []map[string]interface{}
CanReadAllGroupMessages bool // false = group privacy mode is ON (bot only sees commands/mentions)
}
// DiscoverChats calls Telegram getUpdates to find groups/chats the bot has been added to.
@@ -231,7 +231,6 @@ func (t *TelegramAdapter) DiscoverChats(ctx context.Context, botToken string) (*
addChat(msg.Chat)
}
return &DiscoverResult{
BotUsername: bot.Self.UserName,
Chats: chats,
@@ -346,7 +345,7 @@ func (t *TelegramAdapter) SendMessage(ctx context.Context, config map[string]int
case 403:
return fmt.Errorf("forbidden: bot was blocked or kicked from chat %s", chatID)
case 429:
retryAfter := time.Duration(apiErr.ResponseParameters.RetryAfter) * time.Second
retryAfter := time.Duration(apiErr.RetryAfter) * time.Second
log.Printf("Channels: Telegram rate-limited, retry after %s", retryAfter)
time.Sleep(retryAfter)
if _, retryErr := bot.Send(msg); retryErr != nil {
@@ -481,7 +480,7 @@ func (t *TelegramAdapter) StartPolling(ctx context.Context, config map[string]in
var apiErr *tgbotapi.Error
if errors.As(err, &apiErr) {
if apiErr.Code == 429 {
retryAfter := time.Duration(apiErr.ResponseParameters.RetryAfter) * time.Second
retryAfter := time.Duration(apiErr.RetryAfter) * time.Second
log.Printf("Channels: Telegram poll rate-limited, sleeping %s", retryAfter)
select {
case <-ctx.Done():
@@ -108,7 +108,7 @@ func TestEventType_AllUppercaseSnakeCase(t *testing.T) {
t.Errorf("EventType %q has consecutive underscores — disallowed", s)
}
for _, r := range s {
if !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
if (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' {
t.Errorf("EventType %q contains disallowed char %q", s, r)
break
}
@@ -42,7 +42,7 @@ func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
// ──────────────────────────────────────────────────────────────────────────────
func TestPriorityConstants(t *testing.T) {
if !(PriorityCritical > PriorityTask && PriorityTask > PriorityInfo) {
if PriorityCritical <= PriorityTask || PriorityTask <= PriorityInfo {
t.Errorf("priority ordering broken: critical=%d task=%d info=%d",
PriorityCritical, PriorityTask, PriorityInfo)
}
@@ -148,7 +148,9 @@ func drainSetup(t *testing.T, workspaceID string) (sqlmock.Sqlmock, *WorkspaceHa
}
// expectQueueBudgetCheck registers the mock for checkWorkspaceBudget's query:
// SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1
//
// SELECT budget_limit, COALESCE(monthly_spend, 0) FROM workspaces WHERE id = $1
//
// Must be called AFTER expectDequeueNextOk — DequeueNext (BEGIN→SELECT→UPDATE→COMMIT)
// runs before proxyA2ARequest which calls checkWorkspaceBudget.
// Named distinctly from handlers_test.go's expectBudgetCheck (which uses MatchPsql
@@ -185,7 +187,9 @@ func drainItem(wsID string) *QueuedItem {
}
// expectDequeueNextOk sets up sqlmock for DequeueNext's transaction:
// BEGIN → SELECT FOR UPDATE SKIP LOCKED → UPDATE status='dispatched', attempts=attempts+1 → COMMIT
//
// BEGIN → SELECT FOR UPDATE SKIP LOCKED → UPDATE status='dispatched', attempts=attempts+1 → COMMIT
//
// SQL strings are EXACT matches to the handler code — QueryMatcherEqual verifies verbatim.
func expectDequeueNextOk(mock sqlmock.Sqlmock, item *QueuedItem) {
mock.ExpectBegin()
@@ -474,12 +474,7 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
// Lark) hook in here too.
attachments := make([]AgentMessageAttachment, 0, len(body.Attachments))
for _, a := range body.Attachments {
attachments = append(attachments, AgentMessageAttachment{
URI: a.URI,
Name: a.Name,
MimeType: a.MimeType,
Size: a.Size,
})
attachments = append(attachments, AgentMessageAttachment(a))
}
writer := NewAgentMessageWriter(db.DB, h.broadcaster)
if err := writer.Send(c.Request.Context(), workspaceID, body.Message, attachments); err != nil {
@@ -18,9 +18,6 @@ import (
// make_interval(secs => $N)` clause, cap at 30 days, reject invalid input
// with 400.
const activityCols = `id, workspace_id, activity_type, source_id, target_id, method, ` +
`summary, request_body, response_body, tool_trace, duration_ms, status, error_detail, created_at`
func newActivityRows() *sqlmock.Rows {
cols := []string{
"id", "workspace_id", "activity_type", "source_id", "target_id", "method",
@@ -262,16 +262,16 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
// because workspaces sharing a team/org root see identical namespaces.
//
// New strategy:
// 1. Single SQL pass walks parent_id chains, returning each
// workspace's root_id alongside its name.
// 2. Group workspaces by root → unique tree count is typically <<
// workspace count.
// 3. Resolve namespaces ONCE per root (any workspace under that
// root produces the same readable list).
// 4. Build a UNION of namespaces across all roots; single plugin
// search call.
// 5. Map each memory back to a workspace_name via a namespace→ws
// lookup table built up from step 3.
// 1. Single SQL pass walks parent_id chains, returning each
// workspace's root_id alongside its name.
// 2. Group workspaces by root → unique tree count is typically <<
// workspace count.
// 3. Resolve namespaces ONCE per root (any workspace under that
// root produces the same readable list).
// 4. Build a UNION of namespaces across all roots; single plugin
// search call.
// 5. Map each memory back to a workspace_name via a namespace→ws
// lookup table built up from step 3.
//
// Net cost: 1 SQL + N_roots resolver calls + 1 plugin call (vs
// N_workspaces resolver + N_workspaces plugin in the old code).
@@ -502,7 +502,7 @@ func (h *AdminMemoriesHandler) scopeToWritableNamespaceForImport(ctx context.Con
if err != nil {
return "", err
}
wantKind := contract.NamespaceKindWorkspace
var wantKind contract.NamespaceKind
switch strings.ToUpper(scope) {
case "", "LOCAL":
wantKind = contract.NamespaceKindWorkspace
@@ -557,4 +557,3 @@ func namespaceKindFromLegacyScope(scope string) contract.NamespaceKind {
return contract.NamespaceKindWorkspace
}
}
@@ -131,10 +131,9 @@ func TestCutoverActive(t *testing.T) {
func TestWithMemoryV2_AttachesDeps(t *testing.T) {
h := NewAdminMemoriesHandler().WithMemoryV2(nil, nil)
// Both nil pointers — wiring still attaches them; cutoverActive
// reports false because the interface values are nil.
if h.plugin == nil && h.resolver == nil {
// expected
// Both nil pointers still return the handler for chained construction.
if h == nil {
t.Fatal("WithMemoryV2(nil, nil) returned nil handler")
}
}
@@ -596,7 +595,7 @@ func (r perWorkspaceResolver) ReadableNamespaces(_ context.Context, ws string) (
return v, nil
}
func (r perWorkspaceResolver) WritableNamespaces(_ context.Context, ws string) ([]namespace.Namespace, error) {
return r.ReadableNamespaces(nil, ws)
return r.ReadableNamespaces(context.TODO(), ws)
}
// TestExport_IncludesEveryMembersPrivateNamespace pins the I3 follow-up
@@ -71,13 +71,6 @@ func (h *BudgetHandler) GetBudget(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// patchBudgetRequest is the expected JSON body for PATCH /workspaces/:id/budget.
// budget_limit=null removes the ceiling; a positive integer sets it (USD cents).
type patchBudgetRequest struct {
// BudgetLimit pointer so JSON null → nil, absent → parse error (required field).
BudgetLimit *int64 `json:"budget_limit"`
}
// PatchBudget handles PATCH /workspaces/:id/budget.
// Accepts {"budget_limit": <int64>} to set a new ceiling, or
// {"budget_limit": null} to remove an existing ceiling.
@@ -0,0 +1,145 @@
package handlers
import (
"bytes"
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// ─────────────────────────────────────────────────────────────────────────────
// BundleHandler Import — JSON binding error cases
// ─────────────────────────────────────────────────────────────────────────────
func TestBundleImport_InvalidJSON(t *testing.T) {
h := NewBundleHandler(nil, nil, "http://localhost:8080", t.TempDir(), nil)
tests := []struct {
name string
body string
}{
{"not JSON", `not json at all`},
{"truncated JSON", `{"name": "test",`},
{"null", `null`},
{"array", `[]`},
{"number", `42`},
{"boolean", `true`},
{"string", `"just a string"`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
h.Import(c)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid JSON %q: expected status %d, got %d", tc.body, http.StatusBadRequest, w.Code)
}
})
}
}
// ─────────────────────────────────────────────────────────────────────────────
// BundleHandler Import — valid JSON routes to bundle.Import and returns 201
// ─────────────────────────────────────────────────────────────────────────────
func TestBundleImport_ValidJSON(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
// bundle.Import does: INSERT workspaces, UPDATE runtime, INSERT schedules, INSERT secrets.
// bundle.Import recurses into SubWorkspaces (empty in this test bundle → no recursive INSERTs).
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE workspaces SET runtime").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_schedules").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_secrets").
WillReturnResult(sqlmock.NewResult(0, 1))
body := `{"name": "test-workspace", "schema": "1.0", "tier": 3}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/bundles/import", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Import(c)
if w.Code != http.StatusCreated {
t.Errorf("valid JSON: expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// BundleHandler Export — workspace not found (ErrNoRows → 404)
// ─────────────────────────────────────────────────────────────────────────────
func TestBundleExport_NotFound(t *testing.T) {
mock := setupTestDB(t)
_ = setupTestRedis(t)
broadcaster := newTestBroadcaster()
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
// bundle.Export queries the workspace row — return ErrNoRows for missing workspace.
mock.ExpectQuery(`SELECT name, COALESCE\(role`).
WithArgs("ws-nonexistent").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-nonexistent"}}
c.Request = httptest.NewRequest("GET", "/bundles/export/ws-nonexistent", nil)
h.Export(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d: %s", http.StatusNotFound, w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// BundleHandler Export — query error (DB error → 404, per bundle.Export semantics)
// ─────────────────────────────────────────────────────────────────────────────
func TestBundleExport_QueryError(t *testing.T) {
mock := setupTestDB(t)
_ = setupTestRedis(t)
broadcaster := newTestBroadcaster()
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
// Simulate a non-ErrNoRows DB error.
mock.ExpectQuery(`SELECT name, COALESCE\(role`).
WithArgs("ws-error").
WillReturnError(sql.ErrConnDone)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-error"}}
c.Request = httptest.NewRequest("GET", "/bundles/export/ws-error", nil)
h.Export(c)
// bundle.Export wraps DB errors as "failed to fetch workspace" which is not
// "workspace not found", but the handler maps any error → 404 for Export.
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d for DB error, got %d: %s", http.StatusNotFound, w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -112,14 +112,6 @@ func (h *ChatFilesHandler) WithPendingUploads(storage pendinguploads.Storage, br
// network boundary before forwarding.
const chatUploadMaxBytes = 50 * 1024 * 1024
// chatUploadDir is the in-container path where user-uploaded chat
// attachments land. Kept here for documentation parity with the
// workspace-side handler — the platform no longer writes files
// directly, but the URI scheme returned in responses still uses this
// path, so any consumer parsing those URIs has the constant to
// reference.
const chatUploadDir = "/workspace/.molecule/chat-uploads"
// resolveWorkspaceForwardCreds resolves the workspace's URL +
// platform_inbound_secret for an /internal/* forward, applying
// lazy-heal on a missing inbound secret (RFC #2312 backfill — the
@@ -460,7 +452,6 @@ func (h *ChatFilesHandler) streamWorkspaceResponse(
}
}
// lookupUploadDeliveryMode returns the workspace's delivery_mode
// for the chat upload branch. Returns ("", false) and writes the
// HTTP error response on lookup failure (caller stops). NULL or
@@ -153,7 +153,7 @@ func TestMergeSystemMessages_EmptySlice(t *testing.T) {
func TestMergeSystemMessages_NilSlice(t *testing.T) {
var input []map[string]interface{}
got := mergeSystemMessages(input)
if got != nil && len(got) != 0 {
if len(got) != 0 {
t.Errorf("nil: got %v, want nil/empty", got)
}
}
+21 -34
View File
@@ -47,13 +47,13 @@ const defaultProvisionConcurrency = 3
//
// - unset / empty / non-numeric → defaultProvisionConcurrency (3)
// - "0" → unlimited (a very large cap;
// practically no semaphore — used on
// SaaS where AWS RunInstances is the
// rate-limiter, not us)
// practically no semaphore — used on
// SaaS where AWS RunInstances is the
// rate-limiter, not us)
// - any positive integer N → N
// - negative integer → defaultProvisionConcurrency (3),
// log warning so operator notices
// the misconfiguration
// log warning so operator notices
// the misconfiguration
//
// The "0 = unlimited" mapping was a deliberate choice: an env var of "0"
// is the natural shorthand for "no cap" without forcing operators to
@@ -102,18 +102,6 @@ const (
childGridColumnCount = 2
)
// childSlot computes the child-relative position for the N-th sibling in
// a parent's 2-column grid. Matches defaultChildSlot in
// canvas-topology.ts exactly — change them together. Leaf-sized slots
// only; for variable-size siblings use childSlotInGrid below.
func childSlot(index int) (x, y float64) {
col := index % childGridColumnCount
row := index / childGridColumnCount
x = parentSidePadding + float64(col)*(childDefaultWidth+childGutter)
y = parentHeaderPadding + float64(row)*(childDefaultHeight+childGutter)
return
}
type nodeSize struct {
width, height float64
}
@@ -342,10 +330,10 @@ func (e *EnvRequirement) UnmarshalJSON(data []byte) error {
// OrgTemplate is the YAML structure for an org hierarchy.
type OrgTemplate struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Defaults OrgDefaults `yaml:"defaults" json:"defaults"`
Workspaces []OrgWorkspace `yaml:"workspaces" json:"workspaces"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Defaults OrgDefaults `yaml:"defaults" json:"defaults"`
Workspaces []OrgWorkspace `yaml:"workspaces" json:"workspaces"`
// GlobalMemories is a list of org-wide memories seeded as GLOBAL scope
// on the first root workspace (PM) during org import. Issue #1050.
GlobalMemories []models.MemorySeed `yaml:"global_memories" json:"global_memories"`
@@ -381,9 +369,9 @@ type OrgDefaults struct {
// declare them — causing live configs to boot without idle_prompts
// even when org.yaml had them. Phase 1 scalability work adds both
// inline + file-ref forms.
IdlePrompt string `yaml:"idle_prompt" json:"idle_prompt"`
IdlePromptFile string `yaml:"idle_prompt_file" json:"idle_prompt_file"`
IdleIntervalSeconds int `yaml:"idle_interval_seconds" json:"idle_interval_seconds"`
IdlePrompt string `yaml:"idle_prompt" json:"idle_prompt"`
IdlePromptFile string `yaml:"idle_prompt_file" json:"idle_prompt_file"`
IdleIntervalSeconds int `yaml:"idle_interval_seconds" json:"idle_interval_seconds"`
// CategoryRouting maps issue/audit category → list of target roles.
// Per-workspace blocks UNION + override per-key with these defaults.
// Rendered into each workspace's config.yaml so agent prompts can read it
@@ -470,12 +458,12 @@ type OrgWorkspace struct {
// time. If empty, defaults.initial_memories are used. Issue #1050.
InitialMemories []models.MemorySeed `yaml:"initial_memories" json:"initial_memories"`
// MaxConcurrentTasks: see models.CreateWorkspacePayload.
MaxConcurrentTasks int `yaml:"max_concurrent_tasks" json:"max_concurrent_tasks"`
Schedules []OrgSchedule `yaml:"schedules" json:"schedules"`
Channels []OrgChannel `yaml:"channels" json:"channels"`
External bool `yaml:"external" json:"external"`
URL string `yaml:"url" json:"url"`
Canvas struct {
MaxConcurrentTasks int `yaml:"max_concurrent_tasks" json:"max_concurrent_tasks"`
Schedules []OrgSchedule `yaml:"schedules" json:"schedules"`
Channels []OrgChannel `yaml:"channels" json:"channels"`
External bool `yaml:"external" json:"external"`
URL string `yaml:"url" json:"url"`
Canvas struct {
X float64 `yaml:"x" json:"x"`
Y float64 `yaml:"y" json:"y"`
} `yaml:"canvas" json:"canvas"`
@@ -714,10 +702,10 @@ func (h *OrgHandler) Import(c *gin.Context) {
wsMissing := collectPerWorkspaceUnsatisfied(tmpl.Workspaces, orgBaseDir, configured)
if len(wsMissing) > 0 {
c.JSON(http.StatusPreconditionFailed, gin.H{
"error": "missing per-workspace required environment variables",
"error": "missing per-workspace required environment variables",
"missing_workspace_env": wsMissing,
"template": tmpl.Name,
"suggestion": "add these keys to the workspace's .env file or set them as global secrets before importing",
"template": tmpl.Name,
"suggestion": "add these keys to the workspace's .env file or set them as global secrets before importing",
})
return
}
@@ -952,4 +940,3 @@ func errString(err error) string {
}
return err.Error()
}
@@ -196,7 +196,7 @@ func TestSanitizeEnvMembers_MaxLength(t *testing.T) {
}
// 129 chars: invalid (exceeds {0,127} suffix in regex)
tooLong := "A" + strings.Repeat("B", 128)
got, ok = sanitizeEnvMembers([]string{tooLong}, "test")
_, ok = sanitizeEnvMembers([]string{tooLong}, "test")
if ok {
t.Error("129 char invalid: ok should be false")
}
@@ -230,7 +230,7 @@ func TestFlattenAndSortRequirements_Empty(t *testing.T) {
func TestFlattenAndSortRequirements_SingleFirst(t *testing.T) {
// Singles come before groups; within singles, alphabetical
reqs := map[string]EnvRequirement{
envRequirementKey([]string{"ZETA"}): {Name: "ZETA"},
envRequirementKey([]string{"ZETA"}): {Name: "ZETA"},
envRequirementKey([]string{"ALPHA"}): {Name: "ALPHA"},
}
got := flattenAndSortRequirements(reqs)
@@ -247,7 +247,7 @@ func TestFlattenAndSortRequirements_SingleFirst(t *testing.T) {
func TestFlattenAndSortRequirements_GroupsAfterSingles(t *testing.T) {
reqs := map[string]EnvRequirement{
envRequirementKey([]string{"X"}): {Name: "X"}, // single
envRequirementKey([]string{"X"}): {Name: "X"}, // single
envRequirementKey([]string{"A", "B"}): {AnyOf: []string{"A", "B"}}, // group
}
got := flattenAndSortRequirements(reqs)
@@ -429,8 +429,8 @@ func TestCollectOrgEnv_WorkspaceLevel(t *testing.T) {
tmpl := &OrgTemplate{
Workspaces: []OrgWorkspace{
{
Name: "Dev",
RequiredEnv: []EnvRequirement{{Name: "DEV_KEY"}},
Name: "Dev",
RequiredEnv: []EnvRequirement{{Name: "DEV_KEY"}},
RecommendedEnv: []EnvRequirement{{Name: "DEV_TOOL"}},
},
},
@@ -456,12 +456,12 @@ func TestCollectOrgEnv_DeepNesting(t *testing.T) {
RequiredEnv: []EnvRequirement{{Name: "ORG_LEVEL"}},
Workspaces: []OrgWorkspace{
{
Name: "Root",
RequiredEnv: []EnvRequirement{{Name: "ROOT_LEVEL"}},
Name: "Root",
RequiredEnv: []EnvRequirement{{Name: "ROOT_LEVEL"}},
Children: []OrgWorkspace{
{
Name: "Child",
RequiredEnv: []EnvRequirement{{Name: "CHILD_LEVEL"}},
Name: "Child",
RequiredEnv: []EnvRequirement{{Name: "CHILD_LEVEL"}},
Children: []OrgWorkspace{
{Name: "GrandChild", RecommendedEnv: []EnvRequirement{{Name: "GRANDCHILD_TOOL"}}},
},
@@ -536,4 +536,3 @@ func TestCollectOrgEnv_MixedCasePreservesSort(t *testing.T) {
t.Errorf("A,B group should come first: got %+v", req[2])
}
}
@@ -33,11 +33,11 @@ GITEA_SSH_KEY_PATH=/etc/molecule-bootstrap/personas/dev-lead/ssh_priv
loadPersonaEnvFile("dev-lead", out)
want := map[string]string{
"GITEA_USER": "dev-lead",
"GITEA_USER_EMAIL": "dev-lead@agents.moleculesai.app",
"GITEA_TOKEN": "abc123",
"GITEA_TOKEN_SCOPES": "write:repository,write:issue,read:user",
"GITEA_SSH_KEY_PATH": "/etc/molecule-bootstrap/personas/dev-lead/ssh_priv",
"GITEA_USER": "dev-lead",
"GITEA_USER_EMAIL": "dev-lead@agents.moleculesai.app",
"GITEA_TOKEN": "abc123",
"GITEA_TOKEN_SCOPES": "write:repository,write:issue,read:user",
"GITEA_SSH_KEY_PATH": "/etc/molecule-bootstrap/personas/dev-lead/ssh_priv",
}
if len(out) != len(want) {
t.Fatalf("got %d keys, want %d: %#v", len(out), len(want), out)
@@ -152,13 +152,8 @@ func TestIsSafeRoleName_Acceptance(t *testing.T) {
t.Errorf("isSafeRoleName(%q) = false; want true", s)
}
}
// trailing-hyphen IS allowed; only include actually-bad names:
bad := []string{
"", ".", "..", "with/slash", "/abs", "dot.in.middle",
"with space", "back\\slash", "trailing-", // trailing-hyphen is fine actually
"with$dollar", "with?question", "newline\nsplit",
}
// trailing-hyphen IS allowed; remove from "bad" list:
bad = []string{
"", ".", "..", "with/slash", "/abs", "dot.in.middle",
"with space", "back\\slash", "with$dollar", "with?question",
"newline\nsplit",
@@ -2,7 +2,6 @@ package handlers
import (
"archive/tar"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -19,7 +18,6 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/envx"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
"github.com/docker/docker/api/types/container"
"github.com/gin-gonic/gin"
)
@@ -436,53 +434,6 @@ func regexpEscapeForAwk(s string) string {
return b.String()
}
// copyPluginToContainer creates a tar from a host directory and copies it into /configs/plugins/<name>/.
// The tar entries are prefixed with plugins/<name>/ so Docker creates the directory structure.
func (h *PluginsHandler) copyPluginToContainer(ctx context.Context, containerName, hostDir, pluginName string) error {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
err := filepath.Walk(hostDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(hostDir, path)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
// Prefix: plugins/<pluginName>/<rel> → extracts under /configs/
header.Name = filepath.Join("plugins", pluginName, rel)
if err := tw.WriteHeader(header); err != nil {
return err
}
if !info.IsDir() {
data, err := os.ReadFile(path)
if err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
}
return nil
})
if err != nil {
return fmt.Errorf("failed to create tar from %s: %w", hostDir, err)
}
if err := tw.Close(); err != nil {
return fmt.Errorf("failed to close tar: %w", err)
}
// Copy to /configs — the tar's plugins/<name>/ prefix creates the directory
return h.docker.CopyToContainer(ctx, containerName, "/configs", &buf, container.CopyToContainerOptions{})
}
// streamDirAsTar writes every regular file + dir under `root` to the tar
// writer, using paths relative to root so the caller's unpack produces
// `<name>/<original-layout>` without any leading tempdir components.
@@ -119,7 +119,7 @@ func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
// returned and propagated when neither Redis cache nor DB lookup succeeds.
func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
_ = setupTestRedis(t) // empty → cache miss
_ = setupTestRedis(t) // empty → cache miss
h := newHandlerWithTestDeps(t)
@@ -209,10 +209,10 @@ func TestGracefulPreRestart_Success(t *testing.T) {
// Pre-populate Redis cache with the test server URL
_ = setupTestRedisWithURL(t, srv.URL)
// Use an embedded struct to override resolveAgentURLForRestartSignal.
// Use a wrapper so gracefulPreRestart runs through the embedded handler.
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: srv.URL + "/agent",
testURL: srv.URL + "/agent",
}
// gracefulPreRestart runs in a goroutine with its own timeout.
@@ -235,7 +235,7 @@ func TestGracefulPreRestart_NotImplemented(t *testing.T) {
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: srv.URL + "/agent",
testURL: srv.URL + "/agent",
}
hWrapper.gracefulPreRestart(context.Background(), "ws-noimpl-999")
@@ -253,7 +253,7 @@ func TestGracefulPreRestart_ConnectionRefused(t *testing.T) {
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: "http://localhost:19999/agent",
testURL: "http://localhost:19999/agent",
}
hWrapper.gracefulPreRestart(context.Background(), "ws-unreachable-000")
@@ -269,7 +269,7 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
errToReturn: context.DeadlineExceeded,
errToReturn: context.DeadlineExceeded,
}
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
@@ -279,21 +279,14 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
// ─── helpers ─────────────────────────────────────────────────────────────────
// resolveURLTestWrapper embeds *WorkspaceHandler and overrides
// resolveAgentURLForRestartSignal so tests can inject a fixed URL or error.
// resolveURLTestWrapper embeds *WorkspaceHandler for tests that exercise
// gracefulPreRestart through a wrapper value.
type resolveURLTestWrapper struct {
*WorkspaceHandler
testURL string
errToReturn error
}
func (w *resolveURLTestWrapper) resolveAgentURLForRestartSignal(ctx context.Context, workspaceID string) (string, error) {
if w.errToReturn != nil {
return "", w.errToReturn
}
return w.testURL, nil
}
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
@@ -313,4 +306,4 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
}
t.Cleanup(func() { mr.Close() })
return mr
}
}
@@ -61,7 +61,6 @@ func resolveRestartTemplate(configsDir, wsName, dbRuntime string, body restartTe
candidatePath, resolveErr := resolveInsideRoot(configsDir, template)
if resolveErr != nil {
log.Printf("Restart: invalid template %q: %v — proceeding without it", template, resolveErr)
template = ""
} else if _, err := os.Stat(candidatePath); err == nil {
return candidatePath, template
} else {
@@ -3,8 +3,6 @@ package handlers
import (
"strings"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
)
// Tests for the SaaS-aware default-tier resolution introduced in #2901
@@ -21,19 +19,6 @@ import (
// was hardcoded to 3 and silently disagreed with the create-
// handler default on SaaS.
// stubCPProv is a minimal stand-in for the CP provisioner — only
// exercises the IsSaaS / HasProvisioner contract, never invoked in
// these tests.
type stubCPProv struct{}
func (stubCPProv) Start(_ interface{}, _ provisioner.WorkspaceConfig) (string, error) {
return "", nil
}
func (stubCPProv) Stop(_ interface{}, _ string) error { return nil }
func (stubCPProv) Restart(_ interface{}, _ provisioner.WorkspaceConfig) (string, error) {
return "", nil
}
func TestIsSaaS_TrueWhenCPProvWired(t *testing.T) {
h := &WorkspaceHandler{cpProv: &trackingCPProv{}}
if !h.IsSaaS() {
@@ -117,14 +117,6 @@ func resolveWorkspaceRootPath(runtime, root string) string {
// EIC misconfiguration.
const eicFileOpTimeout = 30 * time.Second
// eicFileOpTimeout was historically named eicFileWriteTimeout when the
// only EIC op was writeFile. Keep an alias so any external test that
// pinned the old name still compiles; rename can land as a follow-up
// once we've gone a release without the alias being touched.
//
//nolint:revive // intentional alias for back-compat with prior tests.
const eicFileWriteTimeout = eicFileOpTimeout
// eicSSHSession describes an open EIC tunnel ready for an ssh subprocess.
// Only valid inside the closure passed to withEICTunnel — the underlying
// keypair + tunnel are torn down when the closure returns.
@@ -88,7 +88,7 @@ func generateDefaultConfig(name string, files map[string]string, tier int) strin
tier = 3
}
cfg.WriteString("version: 1.0.0\n")
cfg.WriteString(fmt.Sprintf("tier: %d\n", tier))
fmt.Fprintf(&cfg, "tier: %d\n", tier)
cfg.WriteString("model: anthropic:claude-haiku-4-5-20251001\n")
cfg.WriteString("\nprompt_files:\n")
if len(promptFiles) > 0 {
@@ -275,10 +275,10 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
return
}
// Translate to the handler's wire shape (the field names match
// 1:1, but Go can't implicit-convert named struct types).
// 1:1, so we can use a direct type conversion).
out := make([]fileEntry, 0, len(entries))
for _, e := range entries {
out = append(out, fileEntry{Path: e.Path, Size: e.Size, Dir: e.Dir})
out = append(out, fileEntry(e))
}
c.JSON(http.StatusOK, out)
return
@@ -373,9 +373,7 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
func (h *TemplatesHandler) ReadFile(c *gin.Context) {
workspaceID := c.Param("id")
filePath := c.Param("path")
if strings.HasPrefix(filePath, "/") {
filePath = filePath[1:]
}
filePath = strings.TrimPrefix(filePath, "/")
if err := validateRelPath(filePath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
@@ -480,9 +478,7 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
func (h *TemplatesHandler) WriteFile(c *gin.Context) {
workspaceID := c.Param("id")
filePath := c.Param("path")
if strings.HasPrefix(filePath, "/") {
filePath = filePath[1:]
}
filePath = strings.TrimPrefix(filePath, "/")
if err := validateRelPath(filePath); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
@@ -636,4 +632,3 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
go h.wh.RestartByID(workspaceID)
}
}
@@ -63,13 +63,6 @@ const workspacesUniqueIndexName = "workspaces_parent_name_uniq"
// 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
@@ -109,21 +109,6 @@ func (h *WorkspaceHandler) State(c *gin.Context) {
})
}
// sensitiveUpdateFields documents fields that carry elevated risk — kept as
// an explicit list for code readability and future audits. Auth is now fully
// enforced at the router layer (WorkspaceAuth middleware, #680 IDOR fix);
// this map is no longer used for in-handler gate logic but is preserved to
// surface the risk classification clearly.
//
// budget_limit is intentionally NOT here — the dedicated PATCH
// /workspaces/:id/budget (AdminAuth) is the only write path (#611).
var sensitiveUpdateFields = map[string]struct{}{
"tier": {},
"parent_id": {},
"runtime": {},
"workspace_dir": {},
}
// Update handles PATCH /workspaces/:id
func (h *WorkspaceHandler) Update(c *gin.Context) {
id := c.Param("id")
@@ -160,9 +145,7 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
// Auth is fully enforced at the router layer (WorkspaceAuth middleware, #680).
// WorkspaceAuth validates that the caller holds a valid bearer token for this
// specific workspace — no additional auth gate is needed here. The
// sensitiveUpdateFields map above documents the risk classification for
// auditors but is no longer used as a runtime gate.
// specific workspace — no additional auth gate is needed here.
// #120: guard — return 404 for nonexistent workspace IDs instead of
// silently applying zero-row UPDATEs and returning 200.
@@ -156,10 +156,7 @@ func TestProvisionWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
// Wait for the goroutine to land in cpProv.Start (or give up).
deadline := time.Now().Add(2 * time.Second)
for {
if len(rec.startedSnapshot()) > 0 {
break
}
for len(rec.startedSnapshot()) == 0 {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for cpProv.Start; recorded=%v", rec.startedSnapshot())
}
@@ -626,10 +623,7 @@ func TestRestartWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
// the tracking stub, so we expect at least one Stop and (eventually)
// at least one Start.
deadline := time.Now().Add(2 * time.Second)
for {
if len(rec.stoppedSnapshot()) > 0 && len(rec.startedSnapshot()) > 0 {
break
}
for len(rec.stoppedSnapshot()) == 0 || len(rec.startedSnapshot()) == 0 {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for cpProv.Stop + cpProv.Start; stopped=%v started=%v",
rec.stoppedSnapshot(), rec.startedSnapshot())
@@ -907,7 +901,7 @@ func stripGoComments(src []byte) []byte {
// Block comment
if i+1 < len(src) && src[i] == '/' && src[i+1] == '*' {
i += 2
for i+1 < len(src) && !(src[i] == '*' && src[i+1] == '/') {
for i+1 < len(src) && (src[i] != '*' || src[i+1] != '/') {
i++
}
i++ // skip closing /
@@ -13,7 +13,6 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
"gopkg.in/yaml.v3"
)
@@ -49,7 +48,7 @@ func TestConfigDirName(t *testing.T) {
{"abc-def-ghi", "ws-abc-def-ghi"},
{"abcdefghijklmnop", "ws-abcdefghijkl"}, // truncated at 12
{"short", "ws-short"},
{"123456789012", "ws-123456789012"}, // exactly 12
{"123456789012", "ws-123456789012"}, // exactly 12
{"1234567890123", "ws-123456789012"}, // 13 chars, truncated
}
@@ -483,11 +482,11 @@ func TestSanitizeRuntime_Allowlist(t *testing.T) {
{"openclaw", "openclaw"},
{"hermes", "hermes"},
{"codex", "codex"},
{"langgraph", "claude-code"}, // deprecated → default
{"deepagents", "claude-code"}, // deprecated → default
{"crewai", "claude-code"}, // deprecated → default
{"autogen", "claude-code"}, // deprecated → default
{"not-a-runtime", "claude-code"}, // unknown → default
{"langgraph", "claude-code"}, // deprecated → default
{"deepagents", "claude-code"}, // deprecated → default
{"crewai", "claude-code"}, // deprecated → default
{"autogen", "claude-code"}, // deprecated → default
{"not-a-runtime", "claude-code"}, // unknown → default
{"../../sensitive", "claude-code"}, // path traversal probe → default
{"langgraph\nevil", "claude-code"}, // newline injection → default (not in allowlist)
}
@@ -533,7 +532,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) {
},
{
name: "well under limit — passes through unchanged",
contentLen: 50_000,
contentLen: 50_000,
expectInsert: true,
},
}
@@ -1008,13 +1007,6 @@ func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
// Each test injects a known-internal error and verifies the response body
// or broadcast payload contains ONLY the generic prod-safe message.
// errInternalDB is a pkg-level error whose .Error() output matches a real
// postgres driver error shape — used to simulate DB failure without a live DB.
var errInternalDB = fmt.Errorf("pq: connection refused")
// errInternalOS simulates an OS-level error.
var errInternalOS = fmt.Errorf("operation failed: no such file or directory")
// captureBroadcaster is a test broadcaster that captures the last data
// payload passed to RecordAndBroadcast so tests can inspect it. Now
// satisfies events.EventEmitter (#1814) directly — RecordAndBroadcast
@@ -1022,7 +1014,6 @@ var errInternalOS = fmt.Errorf("operation failed: no such file or directory")
// WorkspaceHandler paths under test call it.
type captureBroadcaster struct {
lastData map[string]interface{}
lastErr error
}
// BroadcastOnly is required to satisfy events.EventEmitter. None of the
@@ -1042,46 +1033,6 @@ func (c *captureBroadcaster) RecordAndBroadcast(_ context.Context, _, _ string,
return nil
}
// unsafeErrorStrings lists substrings that must NEVER appear in external-facing
// error responses. Covers DB driver errors, OS errors, and internal paths.
var unsafeErrorStrings = []string{
"pq:",
"pq ",
"connection refused",
"deadlock",
"no such file",
"/var/",
"/tmp/",
"postgres",
"PostgreSQL",
"sql: ",
":8080",
"127.0.0.1",
"localhost",
"secret",
"token",
}
// containsUnsafeString checks whether any prohibited substring appears in
// a string value recursively (handles nested maps for safety).
func containsUnsafeString(v interface{}) bool {
switch v := v.(type) {
case string:
for _, unsafe := range unsafeErrorStrings {
if strings.Contains(v, unsafe) {
return true
}
}
case map[string]interface{}:
for _, val := range v {
if containsUnsafeString(val) {
return true
}
}
}
return false
}
// TestProvisionWorkspace_NoInternalErrorsInBroadcast asserts that provisionWorkspace
// never leaks internal error details in WORKSPACE_PROVISION_FAILED broadcasts.
// Regression test for issue #1206 — drives the global-secrets decrypt-fail
@@ -1251,12 +1202,12 @@ func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
continue
}
for _, leakMarker := range []string{
"t3.large", // machine type
"ami-0abcd1234efgh5678", // AMI id
"vpc-deadbeef", // VPC id
"subnet-cafef00d", // subnet id
"InvalidSubnet.Conflict", // raw upstream HTTP body
"CP API rejected", // raw error string head
"t3.large", // machine type
"ami-0abcd1234efgh5678", // AMI id
"vpc-deadbeef", // VPC id
"subnet-cafef00d", // subnet id
"InvalidSubnet.Conflict", // raw upstream HTTP body
"CP API rejected", // raw error string head
} {
if strings.Contains(s, leakMarker) {
t.Errorf("broadcast leaked %q in payload value %q", leakMarker, s)
@@ -1268,17 +1219,6 @@ func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
}
}
// mockEnvMutator is a provisionhook.Registry stub that always returns a fixed error.
type mockEnvMutator struct {
returnErr error
}
func (m *mockEnvMutator) Run(_ context.Context, _ string, _ map[string]string) error {
return m.returnErr
}
func (m *mockEnvMutator) Register(_ provisionhook.EnvMutator) {}
// TestResolveAndStage_NoInternalErrorsInHTTPErr asserts that
// resolveAndStage never puts internal error detail (resolver error
// strings, file-system paths, upstream rate-limit text, auth tokens
@@ -793,7 +793,7 @@ func TestDoJSON_204OnEndpointExpectingBody(t *testing.T) {
t.Fatalf("Search: %v", err)
}
if got == nil {
t.Error("got nil SearchResponse, want zero value")
t.Fatal("got nil SearchResponse, want zero value")
}
if len(got.Memories) != 0 {
t.Errorf("memories = %v, want empty", got.Memories)
@@ -109,7 +109,7 @@ func (p *flatPlugin) handleNamespace(w http.ResponseWriter, r *http.Request) {
p.mu.Unlock()
w.WriteHeader(204)
default:
http.Error(w, "method not allowed", 405)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
@@ -22,14 +22,7 @@ const chainQuerySnippet = "WITH RECURSIVE chain"
// Helper makes per-test mock setup terser.
func setupMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock new: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// We use QueryMatcherEqual but with regex-based ExpectQuery elsewhere
// for flexibility. Actually swap to regex for the recursive query:
db, mock, err = sqlmock.New() // default = regex
db, mock, err := sqlmock.New() // default = regex
if err != nil {
t.Fatalf("sqlmock new: %v", err)
}
@@ -186,8 +179,8 @@ func TestWalkChain_RowsErr(t *testing.T) {
func TestDerive(t *testing.T) {
cases := []struct {
name string
chain []chainNode
name string
chain []chainNode
wantWS, wantTeam, wantOrg string
}{
{
@@ -80,7 +80,6 @@ func (s *Store) PatchNamespace(ctx context.Context, name string, body contract.N
}
parts = append(parts, fmt.Sprintf("metadata = $%d", idx))
args = append(args, metadata)
idx++
}
query := fmt.Sprintf(`
UPDATE memory_namespaces SET %s
@@ -294,7 +293,9 @@ func (s *Store) Search(ctx context.Context, body contract.SearchRequest) (*contr
// --- Helpers ---
func scanNamespace(row interface{ Scan(dest ...interface{}) error }) (*contract.Namespace, error) {
func scanNamespace(row interface {
Scan(dest ...interface{}) error
}) (*contract.Namespace, error) {
var ns contract.Namespace
var kindStr string
var expires sql.NullTime
@@ -315,7 +316,9 @@ func scanNamespace(row interface{ Scan(dest ...interface{}) error }) (*contract.
return &ns, nil
}
func scanMemory(row interface{ Scan(dest ...interface{}) error }) (*contract.Memory, error) {
func scanMemory(row interface {
Scan(dest ...interface{}) error
}) (*contract.Memory, error) {
var m contract.Memory
var kindStr, sourceStr string
var expires sql.NullTime
@@ -375,7 +378,7 @@ func vectorString(v []float32) string {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(fmt.Sprintf("%g", x))
fmt.Fprintf(&b, "%g", x)
}
b.WriteByte(']')
return b.String()
@@ -120,7 +120,6 @@ func WorkspaceAuth(database *sql.DB) gin.HandlerFunc {
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
return
}
}
@@ -325,7 +324,6 @@ func CanvasOrBearer(database *sql.DB) gin.HandlerFunc {
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
return
}
}
@@ -37,16 +37,6 @@ const validateAnyTokenSelectQuery = "SELECT t\\.id, t\\.workspace_id.*FROM works
// validateTokenUpdateQuery is matched for the best-effort last_used_at UPDATE.
const validateTokenUpdateQuery = "UPDATE workspace_auth_tokens SET last_used_at"
// newWorkspaceAuthRouter builds a minimal gin router that applies WorkspaceAuth
// to a single GET /workspaces/:id/test route, returning 200 on success.
func newWorkspaceAuthRouter(db sqlmock.Sqlmock, realDB interface{ Close() error }) *gin.Engine {
_ = db // unused directly; sqlmock intercepts calls via the *sql.DB pointer
r := gin.New()
// We need the *sql.DB, not the mock. The caller passes mockDB via the
// test-local var — this helper is only used to build the router topology.
return r
}
// TestWorkspaceAuth_351_NoBearer_Returns401 — strict contract: every request
// under /workspaces/:id/* must carry a valid bearer, period. No fail-open,
// no grace period, no existence check. The middleware goes straight to
@@ -483,10 +473,6 @@ func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
// (no ::text cast — sql.NullString handles the NULL scan natively).
const orgTokenValidateQueryV1 = "SELECT id, prefix, org_id FROM org_api_tokens"
// orgTokenOrgIDQuery is deprecated — org_id is now returned by the primary Validate query.
// Kept here to avoid breaking other test files that may reference it.
const orgTokenOrgIDQuery = "SELECT org_id::text FROM org_api_tokens"
// orgTokenLastUsedQuery is matched for the best-effort last_used_at UPDATE.
const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"
@@ -495,10 +481,10 @@ const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"
// and orgCallerID can look it up downstream.
func TestAdminAuth_OrgToken_SetsOrgID(t *testing.T) {
tests := []struct {
name string
orgIDFromDB interface{} // sqlmock row value: nil, "", or "ws-org-1"
wantOrgIDCtx bool // expect c.Get("org_id") to be set
wantOrgIDVal string // if set, expected value
name string
orgIDFromDB interface{} // sqlmock row value: nil, "", or "ws-org-1"
wantOrgIDCtx bool // expect c.Get("org_id") to be set
wantOrgIDVal string // if set, expected value
}{
{
name: "post-fix token has org_id set in context",
@@ -3,6 +3,8 @@ package plugins
import (
"context"
"errors"
"os"
"os/exec"
"testing"
)
@@ -64,31 +66,6 @@ func TestResolveRef_MapsNotFoundToErrPluginNotFound(t *testing.T) {
}
}
// stubGitForResolveRef creates a stub that handles fetch + rev-parse for ResolveRef.
func stubGitForResolveRef(t *testing.T, sha string) func(ctx context.Context, dir string, args ...string) error {
return func(ctx context.Context, dir string, args ...string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if len(args) < 1 {
return errors.New("no args")
}
switch args[0] {
case "fetch":
// mkdir for clone target
_ = dir
return nil
case "rev-parse":
// rev-parse success — write SHA to a file so rev-parse can "read" it
return nil
case "describe":
// git describe for latest tag
return nil
}
return errors.New("unexpected git command: " + args[0])
}
}
func TestResolveRef_SucceedsForTagRef(t *testing.T) {
// This test verifies the happy path: fetch + rev-parse succeed.
// We stub all git commands to succeed, then verify LastFetchSHA is populated.
@@ -99,18 +76,43 @@ func TestResolveRef_SucceedsForTagRef(t *testing.T) {
return ctx.Err()
}
calls[args[0]] = true
if args[0] == "fetch" {
run := func(name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test",
"GIT_AUTHOR_EMAIL=test@example.invalid",
"GIT_COMMITTER_NAME=test",
"GIT_COMMITTER_EMAIL=test@example.invalid",
)
return cmd.Run()
}
if err := run("git", "init"); err != nil {
return err
}
if err := os.WriteFile(dir+"/README.md", []byte("test\n"), 0o644); err != nil {
return err
}
if err := run("git", "add", "README.md"); err != nil {
return err
}
if err := run("git", "commit", "-m", "test"); err != nil {
return err
}
if err := run("git", "tag", "v1.0.0"); err != nil {
return err
}
}
return nil
},
}
_, err := r.ResolveRef(context.Background(), "org/repo#tag:v1.0.0")
// Without a real git binary, we can't fully test success — but we can
// verify the argument routing doesn't panic and returns expected errors.
if err != nil && !errors.Is(err, ErrPluginNotFound) {
// Expect ErrPluginNotFound when git is not available (no real git binary)
// The important thing is it doesn't panic.
if err != nil {
t.Fatalf("ResolveRef returned unexpected error: %v", err)
}
if !calls["fetch"] && !calls["rev-parse"] {
// At least one git command should have been called
t.Fatal("expected at least one git command")
}
}
@@ -149,7 +151,7 @@ func TestPluginUpdateQueueRow_Struct(t *testing.T) {
WorkspaceID: "test-workspace",
PluginName: "test-plugin",
TrackedRef: "tag:v1.0.0",
CurrentSHA: "abc123",
CurrentSHA: "abc123",
LatestSHA: "def456",
Status: "pending",
}
+2 -2
View File
@@ -57,11 +57,11 @@ func (r *GithubResolver) Scheme() string { return "github" }
// - Owner / repo: must start with alphanumeric, then 099 chars from
// [a-zA-Z0-9_.-]. Matches GitHub's validation.
// - Ref: must NOT start with `-` (prevents ref-as-flag injection like
// "-exec=/evil"). Then 0254 chars from [a-zA-Z0-9_./-]. Disallows
// "-exec=/evil"). Then 0254 chars from [a-zA-Z0-9_./:-]. Disallows
// whitespace and shell metacharacters. The handler additionally
// passes `--` before the URL when invoking git, for defense in depth.
var repoRE = regexp.MustCompile(
`^([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})/([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})(?:#([a-zA-Z0-9_.][a-zA-Z0-9_./\-]{0,254}))?$`,
`^([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})/([a-zA-Z0-9][a-zA-Z0-9_.\-]{0,99})(?:#([a-zA-Z0-9_.][a-zA-Z0-9_./:\-]{0,254}))?$`,
)
// Fetch clones the repository and copies its contents (minus .git) into dst.
@@ -31,7 +31,6 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
@@ -104,8 +103,8 @@ func writeManifestJSON(t *testing.T, dir, digest string) {
func writeStagedPlugin(t *testing.T, dir string) {
t.Helper()
files := map[string]string{
"plugin.yaml": "name: test-plugin\nversion: 1.0.0\ndescription: supply chain test\n",
"rules/guidelines.md": "# Plugin Guidelines\nFollow the rules.\n",
"plugin.yaml": "name: test-plugin\nversion: 1.0.0\ndescription: supply chain test\n",
"rules/guidelines.md": "# Plugin Guidelines\nFollow the rules.\n",
"skills/helper/SKILL.md": "---\nid: helper\nname: Helper\ndescription: does stuff\n---\n",
}
for relPath, content := range files {
@@ -119,19 +118,6 @@ func writeStagedPlugin(t *testing.T, dir string) {
}
}
// stubGitSuccess returns a GitRunner that creates the target directory and
// returns nil (simulating a successful shallow clone). Does NOT write any
// repo content — tests that need files should write them into dst separately.
func stubGitSuccess() func(ctx context.Context, dir string, args ...string) error {
return func(ctx context.Context, dir string, args ...string) error {
if len(args) == 0 {
return fmt.Errorf("stubGitSuccess: no args")
}
target := args[len(args)-1]
return os.MkdirAll(target, 0o755)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// SHA256 content-integrity tests (#768 Control 1)
//
@@ -445,16 +445,16 @@ func parseGiteaBranchHeadSha(body []byte) (string, error) {
// Look for `"id":"<40-hex>"` inside the commit object.
idx := strings.Index(string(body), `"id":"`)
if idx < 0 {
return "", errors.New("Gitea branch response missing commit.id field")
return "", errors.New("gitea branch response missing commit.id field")
}
rest := string(body[idx+len(`"id":"`):])
end := strings.IndexByte(rest, '"')
if end < 0 {
return "", errors.New("Gitea branch response has malformed commit.id (no closing quote)")
return "", errors.New("gitea branch response has malformed commit.id (no closing quote)")
}
sha := rest[:end]
if len(sha) < 7 {
return "", fmt.Errorf("Gitea returned suspiciously short sha %q", sha)
return "", fmt.Errorf("gitea returned suspiciously short sha %q", sha)
}
return sha, nil
}
@@ -442,7 +442,7 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
// contents are by definition immutable.
// The pull is best-effort: if it fails (network, auth, rate limit) the
// subsequent ContainerCreate still surfaces the actionable error below.
imgInspect, _, imgErr := p.cli.ImageInspectWithRaw(ctx, image)
imgInspect, imgErr := p.cli.ImageInspect(ctx, image)
moving := imageTagIsMoving(image)
switch {
case imgErr != nil:
@@ -541,12 +541,12 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
//
// Selection matrix:
//
// cfg.WorkspacePath | cfg.WorkspaceAccess | mount
// ------------------+-------------------------+--------------------------------
// "" | "" / "none" | <named-volume>:/workspace (isolated, current default)
// "<host-dir>" | "" / "read_write" | <host-dir>:/workspace (current PM behaviour)
// "<host-dir>" | "read_only" | <host-dir>:/workspace:ro (research agents get read access without write risk)
// "" | "read_only"/"read_write"| <named-volume>:/workspace (degraded — access requires a mount; validated at handler layer)
// cfg.WorkspacePath | cfg.WorkspaceAccess | mount
// ------------------+-------------------------+--------------------------------
// "" | "" / "none" | <named-volume>:/workspace (isolated, current default)
// "<host-dir>" | "" / "read_write" | <host-dir>:/workspace (current PM behaviour)
// "<host-dir>" | "read_only" | <host-dir>:/workspace:ro (research agents get read access without write risk)
// "" | "read_only"/"read_write"| <named-volume>:/workspace (degraded — access requires a mount; validated at handler layer)
//
// Kept pure + side-effect-free so it's unit-testable.
func buildWorkspaceMount(cfg WorkspaceConfig) string {
@@ -700,11 +700,11 @@ func applyTierResources(hostCfg *container.HostConfig, tier int) (memMB, cpuShar
memMB = getTierMemoryMB(tier)
cpuShares = getTierCPUShares(tier)
if memMB > 0 {
hostCfg.Resources.Memory = memMB * 1024 * 1024
hostCfg.Memory = memMB * 1024 * 1024
}
if cpuShares > 0 {
// shares -> NanoCPUs: 1024 shares == 1 CPU == 1e9 NanoCPUs
hostCfg.Resources.NanoCPUs = (cpuShares * 1_000_000_000) / 1024
hostCfg.NanoCPUs = (cpuShares * 1_000_000_000) / 1024
}
return memMB, cpuShares
}
@@ -1000,20 +1000,6 @@ func (p *Provisioner) WriteAuthTokenToVolume(ctx context.Context, workspaceID, t
return nil
}
// execInContainer runs a command inside a running container as root.
// Best-effort: logs errors but does not fail the caller.
func (p *Provisioner) execInContainer(ctx context.Context, containerID string, cmd []string) {
execCfg := container.ExecOptions{Cmd: cmd, User: "root"}
execID, err := p.cli.ContainerExecCreate(ctx, containerID, execCfg)
if err != nil {
log.Printf("Provisioner: exec create failed: %v", err)
return
}
if err := p.cli.ContainerExecStart(ctx, execID.ID, container.ExecStartOptions{}); err != nil {
log.Printf("Provisioner: exec start failed: %v", err)
}
}
// RemoveVolume removes the config volume for a workspace.
// Also removes the claude-sessions volume (best-effort, may not exist
// for non claude-code runtimes). Issue #12.
@@ -1127,12 +1113,12 @@ func (p *Provisioner) IsRunning(ctx context.Context, workspaceID string) (bool,
//
// - ("ws-<id>", nil): container is running. Caller can exec into it.
// - ("", nil): container does not exist OR exists but is stopped
// (NotFound, Exited, Created, Restarting…). Caller
// should treat as a definitive "not running."
// (NotFound, Exited, Created, Restarting…). Caller
// should treat as a definitive "not running."
// - ("", err): transient daemon error (timeout, socket EOF, ctx
// cancel). Caller should NOT infer "not running" —
// this could be a flaky daemon under load. Decide
// per-callsite whether to fail soft or hard.
// cancel). Caller should NOT infer "not running" —
// this could be a flaky daemon under load. Decide
// per-callsite whether to fail soft or hard.
//
// Background — molecule-core#10: the plugins handler used to carry its own
// copy of this inspect logic (`findRunningContainer`) which collapsed
@@ -155,14 +155,14 @@ func TestApplyTierConfig_Tier2_Standard(t *testing.T) {
// Memory limit: 512 MiB
expectedMemory := int64(512 * 1024 * 1024)
if hc.Resources.Memory != expectedMemory {
t.Errorf("T2: expected Memory=%d (512m), got %d", expectedMemory, hc.Resources.Memory)
if hc.Memory != expectedMemory {
t.Errorf("T2: expected Memory=%d (512m), got %d", expectedMemory, hc.Memory)
}
// CPU limit: 1.0 CPU (1e9 NanoCPUs)
expectedCPU := int64(1_000_000_000)
if hc.Resources.NanoCPUs != expectedCPU {
t.Errorf("T2: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.Resources.NanoCPUs)
if hc.NanoCPUs != expectedCPU {
t.Errorf("T2: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.NanoCPUs)
}
// Must NOT be privileged
@@ -270,13 +270,13 @@ func TestApplyTierConfig_UnknownTier_DefaultsToT2(t *testing.T) {
// Unknown tiers should get T2 resource limits as a safe default
expectedMemory := int64(512 * 1024 * 1024)
if hc.Resources.Memory != expectedMemory {
t.Errorf("Unknown tier: expected Memory=%d (512m), got %d", expectedMemory, hc.Resources.Memory)
if hc.Memory != expectedMemory {
t.Errorf("Unknown tier: expected Memory=%d (512m), got %d", expectedMemory, hc.Memory)
}
expectedCPU := int64(1_000_000_000)
if hc.Resources.NanoCPUs != expectedCPU {
t.Errorf("Unknown tier: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.Resources.NanoCPUs)
if hc.NanoCPUs != expectedCPU {
t.Errorf("Unknown tier: expected NanoCPUs=%d (1.0 CPU), got %d", expectedCPU, hc.NanoCPUs)
}
// Must NOT be privileged
@@ -298,8 +298,8 @@ func TestApplyTierConfig_ZeroTier_DefaultsToT2(t *testing.T) {
// Zero tier (default int value) should also get T2 resource limits
expectedMemory := int64(512 * 1024 * 1024)
if hc.Resources.Memory != expectedMemory {
t.Errorf("Tier 0: expected Memory=%d, got %d", expectedMemory, hc.Resources.Memory)
if hc.Memory != expectedMemory {
t.Errorf("Tier 0: expected Memory=%d, got %d", expectedMemory, hc.Memory)
}
if hc.Privileged {
t.Error("Tier 0: must not be privileged")
@@ -944,12 +944,12 @@ func TestApplyTierConfig_T3_UsesEnvOverride(t *testing.T) {
ApplyTierConfig(hc, cfg, "ws-abc123-configs:/configs", "ws-abc123")
wantMem := int64(8192) * 1024 * 1024
if hc.Resources.Memory != wantMem {
t.Errorf("T3 memory override: got %d, want %d", hc.Resources.Memory, wantMem)
if hc.Memory != wantMem {
t.Errorf("T3 memory override: got %d, want %d", hc.Memory, wantMem)
}
wantCPU := int64(4_000_000_000)
if hc.Resources.NanoCPUs != wantCPU {
t.Errorf("T3 CPU override: got %d NanoCPUs, want %d", hc.Resources.NanoCPUs, wantCPU)
if hc.NanoCPUs != wantCPU {
t.Errorf("T3 CPU override: got %d NanoCPUs, want %d", hc.NanoCPUs, wantCPU)
}
if !hc.Privileged || hc.PidMode != "host" {
t.Errorf("T3 override should preserve privileged/pid-host flags, got Privileged=%v PidMode=%q",
@@ -968,11 +968,11 @@ func TestApplyTierConfig_T3_DefaultCap(t *testing.T) {
ApplyTierConfig(hc, cfg, "ws-abc123-configs:/configs", "ws-abc123")
wantMem := int64(defaultTier3MemoryMB) * 1024 * 1024
if hc.Resources.Memory != wantMem {
t.Errorf("T3 default memory: got %d, want %d", hc.Resources.Memory, wantMem)
if hc.Memory != wantMem {
t.Errorf("T3 default memory: got %d, want %d", hc.Memory, wantMem)
}
wantCPU := int64(defaultTier3CPUShares) * 1_000_000_000 / 1024
if hc.Resources.NanoCPUs != wantCPU {
t.Errorf("T3 default NanoCPUs: got %d, want %d", hc.Resources.NanoCPUs, wantCPU)
if hc.NanoCPUs != wantCPU {
t.Errorf("T3 default NanoCPUs: got %d, want %d", hc.NanoCPUs, wantCPU)
}
}
+148 -7
View File
@@ -12,12 +12,14 @@ Environment variables (set by the workspace container):
PLATFORM_URL — platform API base URL (e.g. http://platform:8080)
"""
import argparse
import asyncio
import json
import logging
import os
import stat
import sys
import uuid
from typing import Callable
# Top-level (not inside main()) so the wheel rewriter expands this to
@@ -765,24 +767,163 @@ async def main(): # pragma: no cover
break
def cli_main() -> None: # pragma: no cover
"""Synchronous wrapper around the async MCP stdio loop.
# --- HTTP/SSE Transport (for Hermes runtime) ---
# Per-connection pending request queue.
# Maps connection-id → asyncio.Queue of JSON-RPC responses.
_http_connection_queues: dict[str, asyncio.Queue] = {}
_http_connection_lock = asyncio.Lock()
async def _handle_http_mcp(request) -> dict | None:
"""Handle an incoming JSON-RPC request over HTTP. Returns the JSON-RPC response dict, or None for notifications."""
try:
body = await request.json()
except Exception:
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
req_id = body.get("id")
method = body.get("method", "")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": _build_initialize_result(),
}
elif method == "notifications/initialized":
return None # No response needed
elif method == "tools/list":
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
elif method == "tools/call":
params = body.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {})
result_text = await handle_tool_call(tool_name, tool_args)
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": result_text}]},
}
else:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Method not found: {method}"}}
async def _run_http_server(port: int) -> None:
"""Run MCP server over HTTP/SSE — compatible with Hermes MCP-native agents."""
try:
from starlette.applications import Starlette # noqa: F401
from starlette.routing import Route # noqa: F401
from starlette.responses import JSONResponse, Response, StreamingResponse # noqa: F401
except ImportError:
logger.error("HTTP transport requires starlette — install with: pip install starlette uvicorn")
return
# Import uvicorn here so the stdio path (the common case) doesn't pay
# the import cost if starlette/uvicorn aren't installed.
import uvicorn # noqa: F401
_http_connection_queues.clear()
async def mcp_handler(request):
"""POST /mcp — receive and process JSON-RPC requests."""
conn_id = request.headers.get("x-mcp-conn-id", "default")
response = await _handle_http_mcp(request)
if response is None:
return Response(status_code=202)
async with _http_connection_lock:
queue = _http_connection_queues.get(conn_id)
if queue is not None and not queue.full():
await queue.put(response)
return Response(status_code=202)
# No SSE subscriber — return JSON directly
return JSONResponse(response)
async def sse_handler(request):
"""GET /mcp/stream — SSE stream for push-based responses."""
conn_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
async with _http_connection_lock:
_http_connection_queues[conn_id] = queue
async def event_stream():
yield f"event: connected\ndata: {json.dumps({'conn_id': conn_id})}\n\n"
try:
while True:
response = await asyncio.wait_for(queue.get(), timeout=300)
yield f"event: message\ndata: {json.dumps(response)}\n\n"
if queue.empty():
yield "event: heartbeat\ndata: null\n\n"
except asyncio.TimeoutError:
pass
finally:
async with _http_connection_lock:
_http_connection_queues.pop(conn_id, None)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_handler(_request):
return JSONResponse({"ok": True, "transport": "http+sse", "port": port})
app = Starlette(
routes=[
Route("/mcp", mcp_handler, methods=["POST"]),
Route("/mcp/stream", sse_handler, methods=["GET"]),
Route("/health", health_handler),
]
)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
logger.info(f"A2A MCP HTTP server listening on http://127.0.0.1:{port}/mcp")
await server.serve()
def cli_main(transport: str = "stdio", port: int = 9100) -> None: # pragma: no cover
"""Synchronous wrapper — selects stdio or HTTP transport.
Called by ``mcp_cli.main`` (the ``molecule-mcp`` console-script
entry point in scripts/build_runtime_package.py) AFTER env
validation and the standalone register + heartbeat thread setup.
Direct callers (in-container code that already validated env and
runs heartbeat.py separately) can also invoke this — it's the
smallest possible "run the MCP stdio JSON-RPC loop" surface.
runs heartbeat.py separately) can also invoke this.
Wheel-smoke gates in scripts/wheel_smoke.py pin the importability
of this name (alongside ``mcp_cli.main``) so a silent rename can't
break every external-runtime operator's MCP install — the 0.1.16
``main_sync`` rename incident is the cautionary precedent.
Args:
transport: "stdio" (default) or "http" (HTTP+SSE for Hermes).
port: TCP port for HTTP transport (default 9100).
"""
_assert_stdio_is_pipe_compatible()
asyncio.run(main())
if transport == "http":
asyncio.run(_run_http_server(port))
else:
_assert_stdio_is_pipe_compatible()
asyncio.run(main())
if __name__ == "__main__": # pragma: no cover
cli_main()
parser = argparse.ArgumentParser(description="A2A MCP Server")
parser.add_argument(
"--transport",
default="stdio",
choices=["stdio", "http"],
help="Transport mode: stdio (default) or http (HTTP+SSE for Hermes)",
)
parser.add_argument(
"--port",
type=int,
default=9100,
help="TCP port for HTTP transport (default 9100)",
)
args = parser.parse_args()
cli_main(transport=args.transport, port=args.port)
+671
View File
@@ -0,0 +1,671 @@
"""Tests for the HTTP/SSE transport of a2a_mcp_server.
Covers:
- _handle_http_mcp: JSON-RPC request parsing and routing
- Starlette app routes: POST /mcp, GET /mcp/stream, GET /health
- cli_main argparse: --transport and --port flags
"""
from __future__ import annotations
import asyncio
import json
import sys
import types
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _DummyRequest:
"""Minimal request duck-type for _handle_http_mcp."""
def __init__(self, body_json: dict, headers: dict | None = None):
self._body = body_json
self.headers = headers or {}
async def json(self) -> dict:
return self._body
# ---------------------------------------------------------------------------
# _handle_http_mcp — unit tests (no I/O)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio()
async def test_handle_http_mcp_initialize():
"""initialize method returns protocol version, capabilities, and server info."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "id": 42, "method": "initialize", "params": {}})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 42
assert "protocolVersion" in resp["result"]
assert "capabilities" in resp["result"]
assert resp["result"]["serverInfo"]["name"] == "molecule"
@pytest.mark.asyncio()
async def test_handle_http_mcp_notifications_initialized_returns_none():
"""notifications/initialized is a notification (no response needed)."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "method": "notifications/initialized"})
resp = await _handle_http_mcp(req)
assert resp is None
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_list():
"""tools/list returns the TOOLS schema."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "id": 7, "method": "tools/list"})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 7
assert "tools" in resp["result"]
assert isinstance(resp["result"]["tools"], list)
@pytest.mark.asyncio()
async def test_handle_http_mcp_unknown_method_returns_error():
"""Unknown method returns -32601 Method not found."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({"jsonrpc": "2.0", "id": 3, "method": "foobar", "params": {}})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 3
assert resp["error"]["code"] == -32601
assert "Method not found" in resp["error"]["message"]
@pytest.mark.asyncio()
async def test_handle_http_mcp_malformed_json_returns_parse_error():
"""Request with bad JSON returns -32700 parse error."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest.__new__(_DummyRequest)
req.headers = {}
req.json = AsyncMock(side_effect=ValueError("bad json"))
resp = await _handle_http_mcp(req)
assert resp["error"]["code"] == -32700
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_with_get_workspace_info():
"""tools/call for get_workspace_info returns workspace info (mocked platform call)."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_get_workspace_info", AsyncMock(return_value="mocked info")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {"name": "get_workspace_info", "arguments": {}},
})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 9
assert resp["result"]["content"][0]["text"] == "mocked info"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_unknown_tool():
"""tools/call for an unknown tool returns the handle_tool_call error text."""
from a2a_mcp_server import _handle_http_mcp
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {"name": "not_a_real_tool", "arguments": {}},
})
resp = await _handle_http_mcp(req)
assert resp["jsonrpc"] == "2.0"
assert resp["id"] == 11
assert "Unknown tool" in resp["result"]["content"][0]["text"]
# ---------------------------------------------------------------------------
# Starlette app — integration tests with TestClient
# ---------------------------------------------------------------------------
@pytest.fixture()
def _clear_http_globals():
"""Reset module-level HTTP state before and after each test."""
import a2a_mcp_server
# Save and restore globals
saved_queues = a2a_mcp_server._http_connection_queues.copy()
saved_lock = a2a_mcp_server._http_connection_lock
a2a_mcp_server._http_connection_queues.clear()
yield
# Restore
a2a_mcp_server._http_connection_queues = saved_queues
def _register_sse_queue():
"""Register a queue for SSE push delivery (synchronous — callable from tests)."""
conn_id = str(uuid.uuid4())
queue = asyncio.Queue(maxsize=100)
import a2a_mcp_server
a2a_mcp_server._http_connection_queues[conn_id] = queue
return conn_id, queue
def _build_test_app(port: int = 9100):
"""Build the Starlette app for testing without starting a real server.
Mirrors the app construction inside _run_http_server, but returns
the app directly so TestClient can drive it without binding a port.
"""
from starlette.applications import Starlette
from starlette.routing import Route
import a2a_mcp_server
async def mcp_handler(request):
conn_id = request.headers.get("x-mcp-conn-id", "default")
response = await a2a_mcp_server._handle_http_mcp(request)
if response is None:
from starlette.responses import Response
return Response(status_code=202)
async with a2a_mcp_server._http_connection_lock:
queue = a2a_mcp_server._http_connection_queues.get(conn_id)
if queue is not None and not queue.full():
await queue.put(response)
from starlette.responses import Response
return Response(status_code=202)
from starlette.responses import JSONResponse
return JSONResponse(response)
async def sse_handler(request):
conn_id, queue = _register_sse_queue()
import asyncio as _asyncio
async def event_stream():
import json as _json
yield f"event: connected\ndata: {_json.dumps({'conn_id': conn_id})}\n\n"
try:
while True:
response = await _asyncio.wait_for(queue.get(), timeout=300)
import json as _json
yield f"event: message\ndata: {_json.dumps(response)}\n\n"
if queue.empty():
yield "event: heartbeat\ndata: null\n\n"
except _asyncio.TimeoutError:
pass
finally:
async with a2a_mcp_server._http_connection_lock:
a2a_mcp_server._http_connection_queues.pop(conn_id, None)
from starlette.responses import StreamingResponse
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def health_handler(_request):
from starlette.responses import JSONResponse
return JSONResponse({"ok": True, "transport": "http+sse", "port": port})
return Starlette(
routes=[
Route("/mcp", mcp_handler, methods=["POST"]),
Route("/mcp/stream", sse_handler, methods=["GET"]),
Route("/health", health_handler),
]
)
class TestHTTPAppRoutes:
"""Integration tests using Starlette TestClient against the HTTP app.
Starlette TestClient uses the ASGI interface directly (no real HTTP server
or uvicorn needed), so no uvicorn mock is required.
"""
def test_health_returns_ok_and_transport(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app(port=9100)
with TestClient(app) as client:
resp = client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["transport"] == "http+sse"
assert data["port"] == 9100
def test_health_accepts_different_port(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app(port=9999)
with TestClient(app) as client:
resp = client.get("/health")
assert resp.json()["port"] == 9999
def test_mcp_post_initialize(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {},
})
assert resp.status_code == 200
data = resp.json()
assert data["id"] == 1
assert "protocolVersion" in data["result"]
def test_mcp_post_tools_list(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {},
})
assert resp.status_code == 200
data = resp.json()
assert "tools" in data["result"]
assert len(data["result"]["tools"]) > 0
def test_mcp_post_notifications_initialized_returns_202(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"method": "notifications/initialized",
})
# Notifications return 202 with no body
assert resp.status_code == 202
def test_mcp_post_unknown_method_returns_200_with_error(self, _clear_http_globals):
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app) as client:
resp = client.post("/mcp", json={
"jsonrpc": "2.0",
"id": 5,
"method": "no_such_method",
"params": {},
})
assert resp.status_code == 200
data = resp.json()
assert data["error"]["code"] == -32601
def test_mcp_post_malformed_json_returns_error(self, _clear_http_globals):
"""Malformed JSON body returns a JSON-RPC parse-error response (HTTP 200)."""
from starlette.testclient import TestClient
app = _build_test_app()
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.post(
"/mcp",
content=b"not json at all",
headers={"Content-Type": "application/json"},
)
# _handle_http_mcp catches ValueError from request.json() and returns
# a JSON-RPC parse-error response with HTTP 200.
assert resp.status_code == 200
assert resp.json()["error"]["code"] == -32700
assert "Parse error" in resp.json()["error"]["message"]
@pytest.mark.asyncio()
async def test_sse_stream_populates_queue(self, _clear_http_globals):
"""_register_sse_queue adds a queue to _http_connection_queues before any async work."""
import a2a_mcp_server
conn_id, queue = _register_sse_queue()
# The queue is registered synchronously — no await needed, no cleanup ran yet.
assert conn_id in a2a_mcp_server._http_connection_queues
assert len(conn_id) == 36 # valid UUID format
assert not queue.full()
@pytest.mark.asyncio()
async def test_sse_queue_delivers_response(self, _clear_http_globals):
"""POST /mcp with x-mcp-conn-id routes response into the SSE queue."""
import uuid
import a2a_mcp_server
from starlette.testclient import TestClient
# Pre-register an SSE queue to simulate an active SSE subscriber
conn_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
async with a2a_mcp_server._http_connection_lock:
a2a_mcp_server._http_connection_queues[conn_id] = queue
# POST a tools/call with the conn_id header
with TestClient(_build_test_app()) as client:
with patch("a2a_mcp_server.tool_get_workspace_info", AsyncMock(return_value="test-ws-info")):
resp = client.post(
"/mcp",
headers={"x-mcp-conn-id": conn_id},
json={
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {"name": "get_workspace_info", "arguments": {}},
},
)
# The handler returns 202 because the response was queued for SSE delivery
assert resp.status_code == 202
# Verify the response was placed in the SSE queue
result = await asyncio.wait_for(queue.get(), timeout=2.0)
assert result["id"] == 99
assert result["result"]["content"][0]["text"] == "test-ws-info"
# ---------------------------------------------------------------------------
# handle_tool_call — remaining tool branches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_send_message_to_user_with_mixed_attachments():
"""attachments with non-string elements are filtered; the list branch is exercised."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_send_message_to_user", AsyncMock(return_value="sent ok")) as mock_fn:
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 21,
"method": "tools/call",
"params": {
"name": "send_message_to_user",
"arguments": {
"message": "hello",
# Mixed types: list contains a dict (non-string) and an empty string
"attachments": [{"url": "http://x"}, "", "valid.zip", None],
},
},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "sent ok"
# Only string, non-empty values passed through
mock_fn.assert_called_once()
_, kwargs = mock_fn.call_args
assert kwargs["attachments"] == ["valid.zip"]
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_wait_for_message():
"""wait_for_message is dispatched and returns the wrapped result."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_wait_for_message", AsyncMock(return_value="no messages")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 22,
"method": "tools/call",
"params": {"name": "wait_for_message", "arguments": {"timeout_secs": 5.0}},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "no messages"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_inbox_peek():
"""inbox_peek is dispatched with the limit argument."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_inbox_peek", AsyncMock(return_value="2 items")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 23,
"method": "tools/call",
"params": {"name": "inbox_peek", "arguments": {"limit": 5}},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "2 items"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_inbox_pop():
"""inbox_pop is dispatched with the activity_id argument."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_inbox_pop", AsyncMock(return_value="acked")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 24,
"method": "tools/call",
"params": {"name": "inbox_pop", "arguments": {"activity_id": "abc-123"}},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "acked"
@pytest.mark.asyncio()
async def test_handle_http_mcp_tools_call_chat_history():
"""chat_history is dispatched with peer_id, limit, and before_ts arguments."""
from a2a_mcp_server import _handle_http_mcp
with patch("a2a_mcp_server.tool_chat_history", AsyncMock(return_value="history")):
req = _DummyRequest({
"jsonrpc": "2.0",
"id": 25,
"method": "tools/call",
"params": {
"name": "chat_history",
"arguments": {"peer_id": "ws-peer-1", "limit": 10, "before_ts": ""},
},
})
resp = await _handle_http_mcp(req)
assert resp["result"]["content"][0]["text"] == "history"
# ---------------------------------------------------------------------------
# cli_main argparse — unit tests
# ---------------------------------------------------------------------------
def test_mcp_post_falls_back_to_json_when_sse_queue_is_full(_clear_http_globals):
"""When the SSE queue is full (>100 pending), the handler returns JSON directly."""
import a2a_mcp_server
from starlette.testclient import TestClient
# Pre-register a queue and fill it to capacity
conn_id = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue(maxsize=2) # small queue for testing
async def _setup():
async with a2a_mcp_server._http_connection_lock:
a2a_mcp_server._http_connection_queues[conn_id] = queue
queue.put_nowait({"id": 1})
queue.put_nowait({"id": 2})
_sync_run(_setup())
assert queue.full()
app = _build_test_app()
with TestClient(app) as client:
resp = client.post(
"/mcp",
headers={"x-mcp-conn-id": conn_id},
json={"jsonrpc": "2.0", "id": 99, "method": "initialize", "params": {}},
)
# With a full queue, the handler returns the response as JSON (not 202)
assert resp.status_code == 200
assert resp.json()["id"] == 99
assert "result" in resp.json()
def _sync_run(coro):
"""Run a coroutine synchronously for test isolation (no real event loop needed)."""
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coro)
finally:
loop.close()
except Exception:
raise
def test_cli_main_transport_stdio_calls_main(monkeypatch):
"""cli_main(transport='stdio') calls asyncio.run(main) without HTTP."""
import a2a_mcp_server
run_calls: list = []
async def fake_main():
run_calls.append("called")
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
a2a_mcp_server.cli_main(transport="stdio", port=9100)
assert "called" in run_calls
def test_cli_main_transport_http_calls_run_http_server(monkeypatch):
"""cli_main(transport='http') calls _run_http_server without stdio."""
import a2a_mcp_server
run_http_calls = []
async def fake_run_http(port):
run_http_calls.append(port)
# asyncio.run must execute the coroutine for _run_http_server to be called
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_run_http_server", fake_run_http)
# stdio path must not be entered
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
a2a_mcp_server.cli_main(transport="http", port=9102)
assert run_http_calls == [9102]
def test_cli_main_http_skips_stdio_check(monkeypatch):
"""When transport=http, _assert_stdio_is_pipe_compatible must NOT be called."""
import a2a_mcp_server
called = []
def fake_assert():
called.append("assert_called")
# Patch on the module object directly
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", fake_assert)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", lambda fn: None)
a2a_mcp_server.cli_main(transport="http", port=9100)
assert "assert_called" not in called
def test_cli_main_default_transport_is_stdio(monkeypatch):
"""cli_main() with no args defaults to stdio transport."""
import a2a_mcp_server
called_as: list = []
async def fake_main():
called_as.append("called")
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
a2a_mcp_server.cli_main() # No args — defaults to stdio
assert "called" in called_as
def test_cli_main_main_raises_propagates(monkeypatch):
"""If main() raises, cli_main() re-raises (doesn't swallow)."""
import a2a_mcp_server
async def fake_main():
raise RuntimeError("boom")
monkeypatch.setattr(a2a_mcp_server, "main", fake_main)
monkeypatch.setattr(a2a_mcp_server.asyncio, "run", _sync_run)
monkeypatch.setattr(a2a_mcp_server, "_assert_stdio_is_pipe_compatible", lambda: None)
with pytest.raises(RuntimeError, match="boom"):
a2a_mcp_server.cli_main(transport="stdio")
# ---------------------------------------------------------------------------
# uvicorn/starlette lazy-import
# ---------------------------------------------------------------------------
def test_run_http_server_is_coroutine_function():
"""_run_http_server is a coroutine function accepting a port argument."""
import inspect
from a2a_mcp_server import _run_http_server
assert inspect.iscoroutinefunction(_run_http_server)
def test_run_http_server_signature_port_int():
"""_run_http_server accepts port as int."""
import inspect
from a2a_mcp_server import _run_http_server
sig = inspect.signature(_run_http_server)
assert "port" in sig.parameters
assert sig.parameters["port"].annotation == int
+107
View File
@@ -0,0 +1,107 @@
"""Test coverage for builtin_tools.security._redact_secrets().
Issue #834 (C2): commit_memory must not persist API keys verbatim.
Pre-commit hook blocks bare secret-like strings (ghp_, sk-ant-, etc.) to prevent
accidental commits of real credentials. These tests focus on the functional
behaviour of the redaction logic: idempotency, contextual keyword=value patterns,
boundary cases, and mixed content — without triggering the hook's length thresholds.
The pre-commit hook itself is the primary guard for bare-pattern detection.
"""
from __future__ import annotations
from builtin_tools.security import REDACTED, _redact_secrets
class TestRedactContextual:
"""Keyword=value patterns with high-entropy values (under pre-commit threshold)."""
def test_api_key_contextual(self):
"""api_key=X where X ≥ 40 base64 chars → value replaced, keyword preserved."""
value = "A" * 40
assert _redact_secrets(f"api_key={value}") == f"api_key={REDACTED}"
def test_keyword_contextual(self):
"""Generic 'key=' also matches."""
value = "B" * 45
assert _redact_secrets(f"key={value}") == f"key={REDACTED}"
def test_secret_contextual(self):
value = "C" * 50
assert _redact_secrets(f"secret= {value}") == f"secret= {REDACTED}"
def test_token_contextual(self):
value = "D" * 40
assert _redact_secrets(f"token={value}") == f"token={REDACTED}"
def test_password_contextual(self):
value = "E" * 50
assert _redact_secrets(f"password={value}") == f"password={REDACTED}"
def test_keyword_spacing_tolerated(self):
"""Spaces around = are tolerated by the pattern."""
value = "F" * 40
assert _redact_secrets(f"key = {value}") == f"key = {REDACTED}"
def test_contextual_too_short_not_redacted(self):
"""Value shorter than 40 chars is not redacted."""
short = "A" * 39
assert _redact_secrets(f"api_key={short}") == f"api_key={short}"
def test_case_insensitive_keyword(self):
"""Keyword matching is case-insensitive."""
value = "G" * 40
assert _redact_secrets(f"API_KEY={value}") == f"API_KEY={REDACTED}"
assert _redact_secrets(f"Token={value}") == f"Token={REDACTED}"
assert _redact_secrets(f"SECRET={value}") == f"SECRET={REDACTED}"
def test_boundary_preserved(self):
"""Contextual pattern preserves the keyword; only value is replaced."""
value = "H" * 40
result = _redact_secrets(f"api_key={value}")
assert result.startswith("api_key=")
assert result.endswith(REDACTED)
assert result == f"api_key={REDACTED}"
def test_base64_chars_in_value(self):
"""Base64 alphabet chars (/ +) in value are covered by the charset."""
# 40-char string with base64 chars
value = "A" * 20 + "/+" + "A" * 18
result = _redact_secrets(f"api_key={value}")
assert result == f"api_key={REDACTED}"
class TestRedactEdgeCases:
"""Non-secret strings, idempotency, and boundary conditions."""
def test_idempotent(self):
"""Calling redaction twice produces the same result."""
text = f"token={'A' * 40}"
first = _redact_secrets(text)
second = _redact_secrets(first)
assert second == first
assert REDACTED in first
def test_already_redacted_string(self):
"""The [REDACTED] sentinel itself is not matched by any pattern."""
assert _redact_secrets(f"see {REDACTED} here") == f"see {REDACTED} here"
def test_no_match_passthrough(self):
"""Normal prose passes through unchanged."""
assert _redact_secrets("The answer is 42.") == "The answer is 42."
assert _redact_secrets("Hello, world!") == "Hello, world!"
assert _redact_secrets("api_key short") == "api_key short"
assert _redact_secrets("") == ""
def test_empty_string(self):
assert _redact_secrets("") == ""
def test_short_value_not_secret(self):
"""A short string after a keyword= prefix is not a secret."""
assert _redact_secrets("token=short") == "token=short"
def test_mixed_content(self):
"""Real text with a secret-like prefix → only the secret is redacted."""
value = "A" * 40
result = _redact_secrets(f"found secret: api_key={value} in config")
assert result == f"found secret: api_key={REDACTED} in config"