Compare commits

..

9 Commits

Author SHA1 Message Date
core-uiux 56945ffd49 fix(canvas/mobile): remove ?? [] from Zustand selector to prevent infinite render loop
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
CI / Detect changes (pull_request) Successful in 54s
E2E API Smoke Test / detect-changes (pull_request) Successful in 49s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 39s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 39s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 42s
qa-review / approved (pull_request) Failing after 24s
security-review / approved (pull_request) Failing after 23s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Platform (Go) (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 14s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 12s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 17s
sop-tier-check / tier-check (pull_request) Successful in 19s
gate-check-v3 / gate-check (pull_request) Successful in 33s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10m16s
CI / Canvas (Next.js) (pull_request) Successful in 11m57s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
audit-force-merge / audit (pull_request) Successful in 19s
React error #185 (Maximum update depth exceeded) on mobile chat tab.

Root cause: useCanvasStore((s) => s.agentMessages[agentId] ?? []) used
a `?? []` fallback in the selector. Zustand uses Object.is for selector
equality. When agentMessages[agentId] is undefined (initial state), the
fallback creates a NEW [] reference on every store update. Zustand sees
this as a state change and re-renders the component. The component reads
from the store again, gets another new [] reference, and the cycle
repeats until React hits the depth cap.

Fix: remove `?? []` from the selector (returns undefined when no messages)
and move the fallback to the useState initializer:
  storedMessages = useCanvasStore(selector)     // returns undefined | T[]
  [messages] = useState(() => (storedMessages ?? []).map(...))

The useState initializer only runs once on mount, so the `?? []`
there is safe — it creates the initial state once, then messages are
managed via setMessages.

Fixes issue #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 04:56:49 +00:00
core-qa d23bd286ce Merge pull request 'fix(ci)(interim): re-add continue-on-error to platform-build (mc#664)' (#665) from fix/664-interim-remask-platform-build into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 17s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
CI / Detect changes (push) Successful in 40s
E2E API Smoke Test / detect-changes (push) Successful in 42s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 43s
Handlers Postgres Integration / detect-changes (push) Successful in 41s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 33s
CI / Shellcheck (E2E scripts) (push) Successful in 27s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 16s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 22s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 23s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 15s
CI / Python Lint & Test (push) Successful in 8m3s
CI / Canvas (Next.js) (push) Successful in 15m4s
CI / Platform (Go) (push) Failing after 15m25s
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Successful in 57s
CI / Canvas Deploy Reminder (push) Successful in 7s
CI / all-required (push) Failing after 4s
main-red-watchdog / watchdog (push) Successful in 1m0s
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)
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 2s
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 1m21s
status-reaper / reap (push) Successful in 2m49s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (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-12 04:47:23 +00:00
core-lead 9aa2b13934 fix(ci)(interim): re-add continue-on-error to platform-build (mc#664 fix-forward in flight)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 22s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 22s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Successful in 18s
qa-review / approved (pull_request) Failing after 13s
security-review / approved (pull_request) Failing after 13s
sop-tier-check / tier-check (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 17s
audit-force-merge / audit (pull_request) Successful in 21s
CI / Python Lint & Test (pull_request) Successful in 7m20s
CI / Platform (Go) (pull_request) Failing after 8m35s
CI / Canvas (Next.js) (pull_request) Successful in 10m33s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Failing after 5s
Phase-3-masked test failures in workspace-server/internal/handlers/ surfaced
when #656 (RFC internal#219 Phase 4) flipped platform-build continue-on-error
from true to false on 0e5152c3. The pre-#656 main was masking these:

  4x delegation_test.go (lines 1110/1176/1228/1271):
      TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess
      TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed
      TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed
      TestExecuteDelegation_CleanProxyResponse_Unchanged
    Root cause: expectExecuteDelegationBase/Success/Failed helpers do not
    mock the DB queries production has issued since ~2026-04-21:
      - UPDATE workspaces SET last_outbound_at (commit 2f36bb9a, 2026-04-18,
        async goroutine fired from logA2ASuccess in a2a_proxy_helpers.go)
      - SELECT delivery_mode / SELECT runtime FROM workspaces (lookup* in
        a2a_proxy_helpers.go since file split in 64ccf8e1, 2026-04-21)
      - INSERT INTO activity_logs (a2a_receive) via LogActivity in
        logA2ASuccess/logA2AError (preexisting, not mocked)
      - recordLedgerStatus writes (RFC #2829 #318)
    Symptoms: sqlmock unexpected query → production short-circuits → trailing
    ExpectExec for completed/failed never fires → mock.ExpectationsWereMet()
    reports unmet remaining expectations. 8.11s uniform wall time is the
    delegationRetryDelay × 2 attempts after the first unexpected-query causes
    a transient retry path. Halt cond #3 applies (>7 days masked → broader
    sweep needed; many subsequent commits stacked on top).

  1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
    Commit 7d1a189f (2026-05-10) hardened mcp.go:427 to scrub err.Error()
    from JSON-RPC error.Message (OFFSEC-001 / #259) — returning the constant
    string "tool call failed" instead. The test asserts the message contains
    "GLOBAL". Production-vs-test contract collision; needs a design call
    (revert OFFSEC scrub for this code class, or update the test to assert a
    different oracle e.g. captured logs / specific error code). Halt cond #2
    applies (alternate-class finding, not sqlmock-mismatch).

Time-boxed Option A (90 min sqlmock update) does not fit either failure class
within scope. Choosing Option B per brief: interim re-mask of platform-build
only — the other 4 #656 flips (changes, canvas-build, shellcheck, python-lint)
retain continue-on-error: false. This is a sequenced revert→fix→reflip per
feedback_strict_root_only_after_class_a emergency clause, NOT a permanent
re-mask. mc#664 stays open as the fix-then-reflip tracker.

Process note for charter SOP-N (companion to vendor-truth-review-discipline):
before flipping a job continue-on-error: true → false, do not trust the
combined-status "success" signal alone — pull the actual run log and grep
for --- FAIL / FAIL <package> to confirm the tests really pass. The masked
green on 0e5152c3 came from continue-on-error suppressing the per-job status
to neutral, which the combined-status aggregator counted as not-failure.

Cross-links:
- mc#664 (hongming-pc2 04:35Z Phase-3-masked defect filing)
- mc#656 (the flip that surfaced this; 0e5152c3 first commit to actually run
  the Go tests against internal/handlers/* since the silent stack-up began)
- feedback_strict_root_only_after_class_a (revert→fix→reflip discipline)
- feedback_return_contract_change_audit_caller_tests (mcp case applies)
- feedback_no_such_thing_as_flakes (these are real bugs, not flakes)

Evidence (run 17810 / job 33895 / task 34532 on 0e5152c3):
- 5x --- FAIL lines confirmed in actions_log/molecule-ai/molecule-core/e4/34532.log
- delegation_test.go:1110/1176/1228/1271: "unmet sqlmock expectations"
- mcp_test.go:433: "error message should mention GLOBAL, got: tool call failed"

Gitea 1.22.6 quirk #10 confirmation: per the run, job-level continue-on-error
DID still allow the combined commit-status to show neutral/success when the
job logically failed — so the #656 PR check showed green even with these
underlying failures masked. Reproduced.

Co-Authored-By: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com>
2026-05-12 04:40:32 +00:00
core-lead 0e5152c342 Merge pull request 'fix(ci): RFC internal#219 Phase 4 — all-required enforced, stable jobs hard-fail' (#656) from infra/622-force-merge-protection-fix into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Detect changes (push) Successful in 11s
E2E API Smoke Test / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Platform (Go) (push) Failing after 4m7s
CI / Canvas (Next.js) (push) Successful in 4m28s
CI / Canvas Deploy Reminder (push) Successful in 1s
CI / Python Lint & Test (push) Successful in 6m30s
CI / all-required (push) Failing after 1s
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 8s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (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) Successful in 1m27s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (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-12 04:18:19 +00:00
core-devops 1719534bf3 fix(ci): RFC internal#219 Phase 4 — all-required sentinel enforced, stable jobs hard-fail
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 11s
qa-review / approved (pull_request) Failing after 9s
security-review / approved (pull_request) Failing after 9s
sop-tier-check / tier-check (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
gate-check-v3 / gate-check (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 14s
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 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Platform (Go) (pull_request) Failing after 4m27s
CI / Canvas (Next.js) (pull_request) Successful in 4m41s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m33s
CI / all-required (pull_request) Failing after 1s
audit-force-merge / audit (pull_request) Successful in 3s
Phase 4 of the force-merge protection fix (internal#219 §2).

Changes:
- audit-force-merge.yml REQUIRED_CHECKS: add CI / all-required (pull_request)
  — closes the audit gap; force-merge audit now checks ci/all-required.
- ci.yml: flip continue-on-error: false on stable jobs
  (changes, platform-build, canvas-build, shellcheck, python-lint)
  — confirmed green on main 2026-05-12 combined-status check.
  The all-required sentinel (continue-on-error: true) will be flipped
  once branch protection PATCH lands (Owner-tier, delegated separately).

NOT included in this PR (separate Owner-tier action required):
- Branch protection PATCH: add ci/all-required as required check on main.
  Needed to make the sentinel actually block merges. Delegate to Core
  Platform Lead.

Refs: molecule-core#622, molecule-core#623
2026-05-12 04:09:44 +00:00
claude-ceo-assistant 49355cf971 Merge pull request 'fix(ci): status-reaper rev4 reads per-context "status" key not "state" (compensation was unreachable since rev1)' (#652) from infra/status-reaper-rev4-status-key-fix into main
Block internal-flavored paths / Block forbidden paths (push) Successful in 13s
E2E API Smoke Test / detect-changes (push) Successful in 26s
CI / Detect changes (push) Successful in 28s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 28s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 25s
Handlers Postgres Integration / detect-changes (push) Successful in 26s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
CI / Platform (Go) (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 6s
CI / Canvas (Next.js) (push) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 13s
CI / Python Lint & Test (push) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 9s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 4s
main-red-watchdog / watchdog (push) Successful in 22s
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)
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 2s
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)
status-reaper / reap (push) Successful in 52s
ci-required-drift / drift (push) Successful in 56s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (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-12 03:52:04 +00:00
claude-ceo-assistant f6477f87ff Merge branch 'main' into infra/status-reaper-rev4-status-key-fix
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 15s
CI / Detect changes (pull_request) Successful in 25s
E2E API Smoke Test / detect-changes (pull_request) Successful in 28s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 28s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 29s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 27s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
security-review / approved (pull_request) Failing after 17s
qa-review / approved (pull_request) Failing after 19s
gate-check-v3 / gate-check (pull_request) Successful in 27s
sop-tier-check / tier-check (pull_request) Successful in 18s
CI / Platform (Go) (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 14s
CI / all-required (pull_request) Successful in 7s
audit-force-merge / audit (pull_request) Successful in 17s
2026-05-12 03:46:25 +00:00
core-uiux 0caafb85bc test(canvas): ActivityTab + DetailsTab + DropTargetBadge (65 cases) (#647)
CI / all-required (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
CI / Detect changes (push) Successful in 23s
Harness Replays / detect-changes (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 24s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 23s
Handlers Postgres Integration / detect-changes (push) Successful in 23s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 26s
publish-canvas-image / Build & push canvas image (push) Failing after 1m2s
CI / Platform (Go) (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 6s
Harness Replays / Harness Replays (push) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 11s
CI / Canvas (Next.js) (push) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
status-reaper / reap (push) Successful in 2m40s
Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app>
Co-committed-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app>
2026-05-12 03:45:48 +00:00
core-devops 5674b0e067 fix(ci): status-reaper rev4 reads per-context "status" key not "state" (compensation was unreachable since rev1)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
qa-review / approved (pull_request) Failing after 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 12s
security-review / approved (pull_request) Failing after 11s
sop-tier-check / tier-check (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 14s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
CI / all-required (pull_request) Successful in 2s
Schema asymmetry in Gitea 1.22.6 combined-status response:
  - top-level `combined.state`         → uses key "state"
  - per-entry `combined.statuses[i].*` → uses key "status", NOT "state"

Pre-rev4 the per-entry loop in reap() (and the matching is_red() /
render_body() in main-red-watchdog) read `s.get("state")` only, which
returned None on every real Gitea response → state coerced to "" →
`"" != "failure"` guard preserved every entry → compensation path
unreachable since rev1.

Empirical proof (orchestrator probe 2026-05-12 03:42Z):
  GET /repos/molecule-ai/molecule-core/commits/210da3b1/status
  → 29 per-entry items, ALL have key "status", ZERO have key "state".
  status value distribution: {success:18, failure:8, pending:3}.
  rev3 production run 17516 reported preserved_non_failure=585=30*19.5
  (every context across all 30 SHAs preserved, none compensated)
  despite the same SHAs showing ~25 real failures via direct probe.

Fix is one line per call site:
  s.get("state") → s.get("status") or s.get("state")
The `state` fallback is defensive — keeps rev1-3 fixtures green and
absorbs a hypothetical future Gitea version that emits both keys.

Sibling-script audit:
  - main-red-watchdog.py: same bug at 3 sites (filter in is_red,
    display in render_body, debug dict in run_once). Bundled here
    because the fix is structurally identical and the failure mode
    matches.
  - ci-required-drift.py: no per-entry status iteration. Clean.

Test gap (rev1-3 fixtures mirrored the bug):
  All 42 reaper fixtures + 26 watchdog fixtures used "state" per
  entry — same wrong key. That's why rev1-3 tests stayed green while
  the production code was no-op. Logged under
  `feedback_smoke_test_vendor_truth_not_shape_match`.

New tests (8 total: 4 reaper + 4 watchdog) explicitly use the
vendor-truth `status` per entry. Hostile self-review: temporarily
reverted the reaper fix and re-ran — new tests FAILED at exactly the
predicted assertion `assert counters["compensated"] == 1` → proves
they're load-bearing, not tautological.

Cross-links:
  task #90 (orchestrator), task #46 (hongming-pc2 paired investigation)
  PR #618 (rev1), PR #633 (rev2), PR #650 (rev3 widened window)
2026-05-11 20:44:20 -07:00
12 changed files with 2225 additions and 551 deletions
+20 -3
View File
@@ -222,9 +222,20 @@ def is_red(status: dict) -> tuple[bool, list[dict]]:
combined = status.get("state")
statuses = status.get("statuses") or []
red_states = {"failure", "error"}
# Schema asymmetry: top-level combined uses `state`, but per-entry
# items in `statuses[]` use `status` in Gitea 1.22.6. Prefer
# `status`; fall back to `state` defensively. Verified empirically
# 2026-05-12 03:42Z. Pre-rev4 code only read `state` from per-entry
# items → failed[] always empty → render_body always showed the
# "no per-context entries were in a red state" fallback even when
# the combined-state correctly flagged red. See
# `feedback_smoke_test_vendor_truth_not_shape_match`.
def _entry_state(s: dict) -> str:
return s.get("status") or s.get("state") or ""
failed = [
s for s in statuses
if isinstance(s, dict) and s.get("state") in red_states
if isinstance(s, dict) and _entry_state(s) in red_states
]
return (combined in red_states or bool(failed), failed)
@@ -313,7 +324,9 @@ def render_body(sha: str, failed: list[dict], debug: dict) -> str:
else:
for s in failed:
ctx = s.get("context", "(no context)")
state = s.get("state", "(no state)")
# Per-entry key is `status` in Gitea 1.22.6, not `state`
# (see _entry_state in is_red). Fallback for forward-compat.
state = s.get("status") or s.get("state") or "(no state)"
url = s.get("target_url") or ""
desc = (s.get("description") or "").strip()
entry = f"- **{ctx}** — `{state}`"
@@ -546,7 +559,11 @@ def run_once(*, dry_run: bool = False) -> int:
"combined_state": status.get("state"),
"failed_contexts": [s.get("context") for s in failed],
"all_contexts": [
{"context": s.get("context"), "state": s.get("state")}
# Per-entry key is `status` in Gitea 1.22.6, not `state`.
# Pre-rev4 debug output reported `state: None` for every
# context, making run logs useless for triage.
{"context": s.get("context"),
"state": s.get("status") or s.get("state")}
for s in (status.get("statuses") or [])
if isinstance(s, dict)
],
+12 -1
View File
@@ -452,7 +452,18 @@ def reap(
if not isinstance(s, dict):
continue
context = s.get("context") or ""
state = s.get("state") or ""
# Schema asymmetry: Gitea 1.22.6 returns the TOP-LEVEL combined
# aggregate as `combined.state` but each per-context entry in
# `combined.statuses[]` uses the key `status`, NOT `state`.
# Prefer `status`; fall back to `state` so a future Gitea
# version (or a test fixture written against the wrong key)
# still flows through the compensation path. Verified empirically
# via direct API probe 2026-05-12 03:42Z:
# /repos/.../commits/{sha}/status entries → key is "status".
# Pre-rev4 code read "state" only → returned "" → bypassed the
# `state != "failure"` guard → compensation path unreachable.
# See `feedback_smoke_test_vendor_truth_not_shape_match`.
state = s.get("status") or s.get("state") or ""
# Only `failure` is the bug shape. `error`/`pending`/`success`
# left alone — they have other meanings.
+1
View File
@@ -85,4 +85,5 @@ jobs:
REQUIRED_CHECKS: |
Secret scan / Scan diff for credential-shaped strings (pull_request)
sop-tier-check / tier-check (pull_request)
CI / all-required (pull_request)
run: bash .gitea/scripts/audit-force-merge.sh
+35 -8
View File
@@ -70,10 +70,12 @@ jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
# the PR. Follow-up PR flips this off after the surfaced defects
# (if any) are triaged.
continue-on-error: true
# Phase 4 (RFC #219 §1): all required jobs >=98% green on main.
# Flip confirmed 2026-05-12 via combined-status check of latest main
# commit (all CI jobs green). `all-required` sentinel hard-fails
# when this job fails; no Phase 3 suppression needed.
# revert: add `continue-on-error: true` back if regressions appear.
continue-on-error: false
outputs:
platform: ${{ steps.check.outputs.platform }}
canvas: ${{ steps.check.outputs.canvas }}
@@ -124,7 +126,29 @@ jobs:
name: Platform (Go)
needs: changes
runs-on: ubuntu-latest
continue-on-error: true
# mc#664 (interim): re-mask platform-build pending fix-forward. Phase 4
# (#656) flipped this to continue-on-error: false based on a Phase-3-masked
# "green on main 2026-05-12" — the prior continue-on-error: true had
# been hiding failing tests in workspace-server/internal/handlers/.
# Two distinct failure classes surfaced on 0e5152c3:
# (1) 4x delegation_test.go (lines 1110/1176/1228/1271): helpers
# expectExecuteDelegationBase/Success/Failed are missing sqlmock
# expectations for queries production has issued since ~2026-04-21
# (last_outbound_at UPDATE, lookupDeliveryMode/Runtime SELECTs,
# a2a_receive INSERT activity_logs, recordLedgerStatus writes).
# Halt cond #3 applies (regression > 7 days → broader sweep).
# (2) 1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
# commit 7d1a189f (2026-05-10) hardened mcp.go to scrub err.Error()
# from JSON-RPC responses (OFFSEC-001), but the test asserts the
# error message contains "GLOBAL". Production-vs-test contract
# collision — needs design call, not mock update.
# Time-boxed Option A (90 min) did not fit the cross-cutting scope.
# This is a sequenced revert→fix→reflip per
# feedback_strict_root_only_after_class_a emergency clause — NOT
# a permanent re-mask. Re-flip blocked on mc#664 fix-forward landing.
# Other 4 #656 flips (changes, canvas-build, shellcheck, python-lint)
# retain continue-on-error: false; only platform-build regresses.
continue-on-error: true # mc#664 fix-forward in flight; re-flip when tests pass
defaults:
run:
working-directory: workspace-server
@@ -271,7 +295,8 @@ jobs:
name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest
continue-on-error: true
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
defaults:
run:
working-directory: canvas
@@ -317,7 +342,8 @@ jobs:
name: Shellcheck (E2E scripts)
needs: changes
runs-on: ubuntu-latest
continue-on-error: true
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: needs.changes.outputs.scripts != 'true'
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
@@ -392,7 +418,8 @@ jobs:
name: Python Lint & Test
needs: changes
runs-on: ubuntu-latest
continue-on-error: true
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
env:
WORKSPACE_ID: test
defaults:
@@ -63,6 +63,7 @@ export function DropTargetBadge() {
<>
{ghostVisible && (
<div
data-testid="ghost-slot"
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{
left: slotTL.x,
@@ -73,6 +74,7 @@ export function DropTargetBadge() {
/>
)}
<div
data-testid="drop-badge"
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
@@ -0,0 +1,253 @@
// @vitest-environment jsdom
/**
* Tests for DropTargetBadge — floating drag affordance rendered over the
* ReactFlow canvas while a workspace node is being dragged onto a parent.
*
* Covers:
* - Renders nothing when dragOverNodeId is null
* - Renders nothing when target node not found in store
* - Renders nothing when getInternalNode returns null
* - Renders ghost slot + badge when valid target is found
* - Ghost hidden when slot falls outside parent bounds
* - Badge text includes the target workspace name
* - Badge positioned via screen-space coordinates from flowToScreenPosition
*/
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DropTargetBadge } from "../DropTargetBadge";
// ─── Mutable store state — hoisted so vi.mock factory closures capture the ref ─
let _storeState: {
dragOverNodeId: string | null;
nodes: Array<{
id: string;
data: Record<string, unknown>;
parentId: string | null;
measured?: { width: number; height: number };
}>;
} = {
dragOverNodeId: null,
nodes: [],
};
const _subscribers = new Set<() => void>();
function _notifySubscribers() {
for (const fn of _subscribers) fn();
}
const _mockUseCanvasStore = vi.hoisted(() => {
const impl = (selector: (s: typeof _storeState) => unknown) => selector(_storeState);
return impl;
});
// Module-level mutable impl — setFlowMock() swaps it out per test.
let _flowImpl: (arg: { x: number; y: number }) => { x: number; y: number } =
({ x, y }) => ({ x: x * 2, y: y * 2 });
let _flowToScreenPosition = vi.hoisted(() =>
vi.fn((arg: { x: number; y: number }) => _flowImpl(arg)),
);
let _getInternalNode = vi.hoisted(() =>
vi.fn<(id: string) => {
internals: { positionAbsolute: { x: number; y: number } };
measured?: { width: number; height: number };
} | null>(() => null),
);
const _mockUseReactFlow = vi.hoisted(() =>
vi.fn(() => ({
getInternalNode: _getInternalNode,
flowToScreenPosition: _flowToScreenPosition,
})),
);
// ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("@/store/canvas", () => ({
useCanvasStore: _mockUseCanvasStore,
}));
vi.mock("@xyflow/react", () => ({
useReactFlow: _mockUseReactFlow,
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function setStore(state: Partial<typeof _storeState>) {
_storeState = { ..._storeState, ...state };
_notifySubscribers();
}
// Helper to set per-test flowToScreenPosition mock — replaces _flowImpl.
function setFlowMock(impl: (arg: { x: number; y: number }) => { x: number; y: number }) {
_flowImpl = impl;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("DropTargetBadge — renders nothing when not dragging", () => {
afterEach(() => {
cleanup();
_storeState = { dragOverNodeId: null, nodes: [] };
_getInternalNode.mockReset().mockReturnValue(null);
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
});
it("returns null when dragOverNodeId is null", () => {
setStore({ dragOverNodeId: null });
render(<DropTargetBadge />);
expect(document.body.textContent).toBe("");
});
it("returns null when target node not found in store nodes array", () => {
setStore({ dragOverNodeId: "ws-target", nodes: [] });
render(<DropTargetBadge />);
expect(document.body.textContent).toBe("");
});
});
describe("DropTargetBadge — renders nothing when getInternalNode is null", () => {
afterEach(() => {
cleanup();
_storeState = { dragOverNodeId: null, nodes: [] };
_getInternalNode.mockReset().mockReturnValue(null);
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
});
it("returns null when getInternalNode returns null (node not in RF viewport)", () => {
_getInternalNode.mockReturnValue(null);
setStore({
dragOverNodeId: "ws-target",
nodes: [{ id: "ws-target", data: { name: "Target WS" }, parentId: null }],
});
render(<DropTargetBadge />);
expect(document.body.textContent).toBe("");
});
});
describe("DropTargetBadge — renders ghost slot + badge for valid drag target", () => {
afterEach(() => {
cleanup();
_storeState = { dragOverNodeId: null, nodes: [] };
_getInternalNode.mockReset().mockReturnValue(null);
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
});
it("renders the drop badge with target name", () => {
_getInternalNode.mockReturnValue({
internals: { positionAbsolute: { x: 100, y: 200 } },
measured: { width: 220, height: 120 },
});
_flowToScreenPosition
.mockReturnValueOnce({ x: 500, y: 400 }) // slotTL
.mockReturnValueOnce({ x: 900, y: 600 }) // slotBR
.mockReturnValueOnce({ x: 700, y: 200 }); // badge
setStore({
dragOverNodeId: "ws-target",
nodes: [
{ id: "ws-target", data: { name: "SEO Workspace" }, parentId: null, measured: { width: 220, height: 120 } },
],
});
render(<DropTargetBadge />);
expect(screen.getByText(/Drop into: SEO Workspace/)).toBeTruthy();
});
it("renders the ghost slot div via data-testid", () => {
// measured.height must be large enough that parentBR.y > slotTL.y=330 so
// ghostVisible = (slotTL.y < parentBR.y) is true.
// parentBR.y = abs.y + measured.height = 200 + h > 330 → h > 130
_getInternalNode.mockReturnValue({
internals: { positionAbsolute: { x: 100, y: 200 } },
measured: { width: 220, height: 500 },
});
// Component calls flowToScreenPosition 5 times (confirmed via debug):
// 1) badge {x:210, y:200} -> {x:420, y:400} (badge center)
// 2) slotTL {x:116, y:330} -> {x:232, y:660} (slot origin)
// 3) slotBR {x:356, y:460} -> {x:712, y:920} (ghost uses this)
// 4) parentTL {x:100, y:200} -> {x:200, y:400} (parent origin)
// 5) parentBR {x:320, y:320} -> {x:640, y:640} (parent corner)
setFlowMock(({ x, y }: { x: number; y: number }) => {
if (x === 210 && y === 200) return { x: 420, y: 400 };
if (x === 116 && y === 330) return { x: 232, y: 660 };
if (x === 356 && y === 460) return { x: 712, y: 920 };
if (x === 100 && y === 200) return { x: 200, y: 400 };
// 5th call: parentBR = abs + {w:220, h:500} = {320, 700}
if (x === 320 && y === 700) return { x: 640, y: 1400 };
return { x: x * 2, y: y * 2 };
});
setStore({
dragOverNodeId: "ws-target",
nodes: [
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } },
],
});
render(<DropTargetBadge />);
expect(screen.getByTestId("ghost-slot")).toBeTruthy();
// Ghost uses slotBR from 3rd call: slotBR - slotTL = (712-232, 920-660)
expect(screen.getByTestId("ghost-slot").style.left).toBe("232px");
expect(screen.getByTestId("ghost-slot").style.top).toBe("660px");
expect(screen.getByTestId("ghost-slot").style.width).toBe("480px");
expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
});
it("ghost is hidden when slot falls entirely outside parent bounds", () => {
_getInternalNode.mockReturnValue({
internals: { positionAbsolute: { x: 100, y: 200 } },
measured: { width: 220, height: 120 },
});
// Set slotBR (3rd call) to be inside parent to hide ghost.
// slotBR.x ≤ parentTL.x makes slotBR.x - slotTL.x < 0 → ghostVisible = false.
setFlowMock(({ x, y }: { x: number; y: number }) => {
if (x === 210 && y === 200) return { x: 420, y: 400 }; // badge (1st call)
if (x === 116 && y === 330) return { x: 232, y: 660 }; // slotTL (2nd call)
if (x === 356 && y === 460) return { x: 150, y: 460 }; // slotBR (3rd): slotBR.x=150 < parentTL.x=200 → hidden
if (x === 100 && y === 200) return { x: 200, y: 400 }; // parentTL (4th call)
if (x === 320 && y === 320) return { x: 640, y: 640 }; // parentBR (5th call)
return { x: x * 2, y: y * 2 };
});
setStore({
dragOverNodeId: "ws-target",
nodes: [
{ id: "ws-target", data: { name: "Tiny" }, parentId: null, measured: { width: 220, height: 120 } },
],
});
render(<DropTargetBadge />);
// Badge should still render, ghost should not
expect(screen.getByText(/Drop into: Tiny/)).toBeTruthy();
expect(screen.queryByTestId("ghost-slot")).toBeNull();
});
it("badge is absolutely positioned with left and top from flowToScreenPosition", () => {
_getInternalNode.mockReturnValue({
internals: { positionAbsolute: { x: 100, y: 200 } },
measured: { width: 220, height: 120 },
});
setFlowMock(({ x, y }: { x: number; y: number }) => {
if (x === 210 && y === 200) return { x: 420, y: 400 };
if (x === 116 && y === 330) return { x: 232, y: 660 };
if (x === 356 && y === 460) return { x: 712, y: 920 };
if (x === 100 && y === 200) return { x: 200, y: 400 };
if (x === 320 && y === 320) return { x: 640, y: 640 };
return { x: x * 2, y: y * 2 };
});
setStore({
dragOverNodeId: "ws-target",
nodes: [
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 120 } },
],
});
render(<DropTargetBadge />);
expect(screen.getByTestId("drop-badge")).toBeTruthy();
// Badge uses 1st call: {x:210,y:200} -> {x:420,y:400}, badge.y = 400-6 = 394
expect(screen.getByTestId("drop-badge").style.left).toBe("420px");
expect(screen.getByTestId("drop-badge").style.top).toBe("394px");
expect(screen.getByText(/Drop into: Target/)).toBeTruthy();
});
});
+7 -2
View File
@@ -54,9 +54,14 @@ export function MobileChat({
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId] ?? []);
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
// for selector equality. A fallback `?? []` creates a new [] reference on
// every store update when agentMessages[agentId] is undefined, causing an
// infinite re-render loop (React error #185 / Maximum update depth
// exceeded). The undefined case is handled by the initializer below.
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
storedMessages.map((m) => ({
(storedMessages ?? []).map((m) => ({
id: m.id,
role: "agent",
text: m.content,
@@ -0,0 +1,535 @@
// @vitest-environment jsdom
/**
* Tests for ActivityTab — activity ledger with live updates, filtering,
* expand/collapse, and A2A error hint rendering.
*
* Covers:
* - Loading state
* - Error state (network failure)
* - Empty state (no activities)
* - Activity list rendering (single + multiple)
* - Filter bar: 7 filters, active filter highlighted
* - Each filter updates the rendered list
* - Auto-refresh toggle (Live / Paused)
* - Refresh button calls API
* - Full Trace button opens ConversationTraceModal
* - Duration display in activity rows
* - Expand/collapse row details
* - A2A rows show source → target name flow
* - Error rows styled differently
* - Error detail shown when expanded
* - getSkills exported function (standalone unit)
*/
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 { ActivityTab } from "../ActivityTab";
import type { ActivityEntry } from "@/types/activity";
const mockApiGet = vi.fn();
const mockUseSocketEvent = vi.fn();
const mockUseWorkspaceName = vi.fn<(id: string | null) => string>((_id: string | null) => "Test Workspace");
const mockConversationTraceModal = vi.fn(() => null);
const mockConversationTraceModalRender = vi.fn(
({ open }: { open: boolean }) => (open ? <div data-testid="trace-modal">Trace</div> : null),
);
vi.mock("@/hooks/useSocketEvent", () => ({
useSocketEvent: (...args: unknown[]) => mockUseSocketEvent(...args),
}));
vi.mock("@/hooks/useWorkspaceName", () => ({
useWorkspaceName: () => mockUseWorkspaceName,
}));
vi.mock("@/components/ConversationTraceModal", () => ({
ConversationTraceModal: (props: { open: boolean; onClose: () => void; workspaceId: string }) =>
props.open ? <div data-testid="trace-modal">Trace</div> : null,
}));
vi.mock("@/lib/api", () => ({
api: { get: (...args: unknown[]) => mockApiGet(...args) },
}));
// ─── Fixtures ───────────────────────────────────────────────────────────────
function activity(overrides: Partial<ActivityEntry> = {}): ActivityEntry {
return {
id: "act-1",
workspace_id: "ws-1",
activity_type: "agent_log",
source_id: null,
target_id: null,
method: null,
summary: null,
request_body: null,
response_body: null,
duration_ms: null,
status: "ok",
error_detail: null,
created_at: new Date(Date.now() - 60_000).toISOString(),
...overrides,
};
}
// ─── Helpers ────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Tests ────────────────────────────────────────────────────────────────
describe("ActivityTab — loading / error / empty", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("shows loading state initially", () => {
mockApiGet.mockImplementation(() => new Promise(() => {}));
render(<ActivityTab workspaceId="ws-1" />);
expect(screen.getByText("Loading activity...")).toBeTruthy();
});
it("shows error banner when API fails", async () => {
mockApiGet.mockRejectedValue(new Error("network failure"));
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/network failure/i)).toBeTruthy();
});
it("shows empty state when no activities", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("No activity recorded yet")).toBeTruthy();
});
});
describe("ActivityTab — list rendering", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders a single activity row", async () => {
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("LOG")).toBeTruthy();
});
it("renders multiple activity rows", async () => {
mockApiGet.mockResolvedValue([
activity({ id: "a1", activity_type: "agent_log" }),
activity({ id: "a2", activity_type: "task_update" }),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("LOG")).toBeTruthy();
expect(screen.getByText("TASK")).toBeTruthy();
});
it("shows duration when duration_ms is present", async () => {
mockApiGet.mockResolvedValue([
activity({ id: "a1", duration_ms: 1234, activity_type: "agent_log" }),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("1234ms")).toBeTruthy();
});
it("shows summary text when present", async () => {
mockApiGet.mockResolvedValue([
activity({ id: "a1", summary: "Delegated task to SEO Agent", activity_type: "a2a_send" }),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/Delegated task to SEO Agent/)).toBeTruthy();
});
});
describe("ActivityTab — filter bar", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders all 7 filter buttons", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByRole("button", { name: /all/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /a2a in/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /a2a out/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /tasks/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /skill promo/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /logs/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /errors/i })).toBeTruthy();
});
it("active filter has aria-pressed=true", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const allBtn = screen.getByRole("button", { name: /all/i });
expect(allBtn.getAttribute("aria-pressed")).toBe("true");
});
it("clicking a filter updates aria-pressed and re-fetches", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const errorsBtn = screen.getByRole("button", { name: /errors/i });
await act(async () => { errorsBtn.click(); });
await flush();
expect(errorsBtn.getAttribute("aria-pressed")).toBe("true");
// API was called with ?type=error
expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity?type=error");
});
it("clicking All removes the type query param", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
// First click a specific filter
const errorsBtn = screen.getByRole("button", { name: /errors/i });
await act(async () => { errorsBtn.click(); });
await flush();
// Then click All
const allBtn = screen.getByRole("button", { name: /all/i });
await act(async () => { allBtn.click(); });
await flush();
expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity");
});
});
describe("ActivityTab — auto-refresh toggle", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders Live by default", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("⟳ Live")).toBeTruthy();
});
it("clicking Live toggles to Paused", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const liveBtn = screen.getByText("⟳ Live");
await act(async () => { liveBtn.click(); });
await flush();
expect(screen.getByText("⟳ Paused")).toBeTruthy();
});
it("clicking Paused toggles back to Live", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const liveBtn = screen.getByText("⟳ Live");
await act(async () => { liveBtn.click(); });
await flush();
const pausedBtn = screen.getByText("⟳ Paused");
await act(async () => { pausedBtn.click(); });
await flush();
expect(screen.getByText("⟳ Live")).toBeTruthy();
});
});
describe("ActivityTab — refresh button", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("Refresh calls the API", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
await act(async () => { refreshBtn.click(); });
await flush();
// loadActivities called again (second call)
expect(mockApiGet.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
describe("ActivityTab — Full Trace button", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("Full Trace button opens the trace modal", async () => {
mockApiGet.mockResolvedValue([]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const traceBtn = screen.getByRole("button", { name: /full trace/i });
await act(async () => { traceBtn.click(); });
await flush();
expect(screen.getByTestId("trace-modal")).toBeTruthy();
});
});
describe("ActivityTab — row expand / collapse", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("row is collapsed by default (shows ▶)", async () => {
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("▶")).toBeTruthy();
});
it("clicking a row expands it (shows ▼)", async () => {
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
await act(async () => { rowBtn.click(); });
await flush();
expect(screen.getByText("▼")).toBeTruthy();
});
it("clicking expanded row collapses it", async () => {
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
await act(async () => { rowBtn.click(); }); // expand
await flush();
await act(async () => { rowBtn.click(); }); // collapse
await flush();
expect(screen.getByText("▶")).toBeTruthy();
});
});
describe("ActivityTab — A2A rows with source/target", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
mockUseWorkspaceName.mockImplementation((id: string | null) => {
if (id === "ws-agent-1") return "Alice Agent";
if (id === "ws-agent-2") return "Bob Agent";
return "Unknown";
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("shows source → target for a2a_receive rows", async () => {
mockApiGet.mockResolvedValue([
activity({
id: "a1",
activity_type: "a2a_receive",
source_id: "ws-agent-1",
target_id: "ws-agent-2",
method: "message/send",
}),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("Alice Agent")).toBeTruthy();
expect(screen.getByText("→")).toBeTruthy();
expect(screen.getByText("Bob Agent")).toBeTruthy();
});
it("shows A2A OUT badge for a2a_send rows", async () => {
mockApiGet.mockResolvedValue([
activity({
id: "a1",
activity_type: "a2a_send",
source_id: "ws-agent-1",
target_id: "ws-agent-2",
}),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("A2A OUT")).toBeTruthy();
});
});
describe("ActivityTab — error rows", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("error status row renders with ERROR badge", async () => {
mockApiGet.mockResolvedValue([
activity({ id: "a1", activity_type: "error", status: "error" }),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("ERROR")).toBeTruthy();
});
it("error detail is shown when row is expanded", async () => {
mockApiGet.mockResolvedValue([
activity({
id: "a1",
activity_type: "error",
status: "error",
error_detail: "Connection refused",
duration_ms: null,
}),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const rowBtn = screen.getByText("ERROR").closest("button") as HTMLButtonElement;
await act(async () => { rowBtn.click(); });
await flush();
// Text appears twice: collapsed-row preview + expanded detail section
expect(screen.getAllByText("Connection refused")).toHaveLength(2);
});
});
describe("ActivityTab — type badge rendering", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders correct badge text for each type", async () => {
const types: ActivityEntry["activity_type"][] = [
"a2a_receive", "a2a_send", "task_update", "skill_promotion", "agent_log", "error",
];
const entries = types.map((t, i) =>
activity({ id: `a${i}`, activity_type: t }),
);
mockApiGet.mockResolvedValue(entries);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText("A2A IN")).toBeTruthy();
expect(screen.getByText("A2A OUT")).toBeTruthy();
expect(screen.getByText("TASK")).toBeTruthy();
expect(screen.getByText("PROMO")).toBeTruthy();
expect(screen.getByText("LOG")).toBeTruthy();
expect(screen.getByText("ERROR")).toBeTruthy();
});
});
describe("ActivityTab — count display", () => {
beforeEach(() => {
mockApiGet.mockReset();
mockUseSocketEvent.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("shows count with 'activities' label when filter=all", async () => {
mockApiGet.mockResolvedValue([
activity({ id: "a1" }),
activity({ id: "a2" }),
]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
expect(screen.getByText(/2 activities/)).toBeTruthy();
});
it("shows count with filter label when non-all filter selected", async () => {
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "error" })]);
render(<ActivityTab workspaceId="ws-1" />);
await flush();
const errorsBtn = screen.getByRole("button", { name: /errors/i });
await act(async () => { errorsBtn.click(); });
await flush();
expect(screen.getByText(/1 error entries/)).toBeTruthy();
});
});
describe("getSkills — unit", () => {
it("returns empty array for null card", async () => {
const { getSkills } = await import("../DetailsTab");
expect(getSkills(null)).toEqual([]);
});
it("returns empty array when skills is not an array", async () => {
const { getSkills } = await import("../DetailsTab");
expect(getSkills({ name: "test" } as Record<string, unknown>)).toEqual([]);
});
it("extracts skill ids and descriptions", async () => {
const { getSkills } = await import("../DetailsTab");
const card = {
skills: [
{ id: "web-search", description: "Search the web" },
{ name: "code-interpreter" },
{ id: "analytics" },
],
};
const result = getSkills(card as Record<string, unknown>);
expect(result).toEqual([
{ id: "web-search", description: "Search the web" },
{ id: "code-interpreter" },
{ id: "analytics" },
]);
});
it("filters out skills with no id or name", async () => {
const { getSkills } = await import("../DetailsTab");
const card = { skills: [{ description: "no id" }, { id: "valid" }] };
expect(getSkills(card as Record<string, unknown>)).toEqual([{ id: "valid" }]);
});
});
@@ -0,0 +1,459 @@
// @vitest-environment jsdom
/**
* Tests for DetailsTab — workspace detail panel with editable fields,
* delete/restart workflows, peers list, error display, and section
* composition.
*
* Covers:
* - View mode: all rows rendered (name, role, tier, status, URL, etc.)
* - Edit mode: name/role/tier fields become editable
* - Save workflow: calls PATCH and updates store
* - Cancel: reverts fields to original data
* - Delete: two-step confirm (confirm button shows alertdialog)
* - Delete confirm: calls DELETE and removes node from store
* - Restart button: calls POST /restart for failed/degraded/offline
* - Error section: shown for failed/degraded with lastSampleError
* - Skills section: rendered when agentCard has skills
* - Peers section: loads and displays peer list
* - Peers section: empty state when offline
* - ConsoleModal: opens/closes via button click
*/
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 { DetailsTab } from "../DetailsTab";
import type { WorkspaceNodeData } from "@/store/canvas";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
patch: vi.fn(),
del: vi.fn(),
post: vi.fn(),
}));
const mockUpdateNodeData = vi.hoisted(() => vi.fn());
const mockRemoveSubtree = vi.hoisted(() => vi.fn());
const mockSelectNode = vi.hoisted(() => vi.fn());
const mockUseCanvasStore = vi.hoisted(() => {
const fn = (selector: (s: {
updateNodeData: typeof mockUpdateNodeData;
removeSubtree: typeof mockRemoveSubtree;
selectNode: typeof mockSelectNode;
}) => unknown) =>
selector({
updateNodeData: mockUpdateNodeData,
removeSubtree: mockRemoveSubtree,
selectNode: mockSelectNode,
});
return fn;
});
vi.mock("@/store/canvas", () => ({
useCanvasStore: mockUseCanvasStore,
}));
vi.mock("@/lib/api", () => ({
api: mockApi,
}));
vi.mock("@/components/BudgetSection", () => ({
BudgetSection: () => <div data-testid="budget-section">BudgetSection</div>,
}));
vi.mock("@/components/WorkspaceUsage", () => ({
WorkspaceUsage: () => <div data-testid="workspace-usage">WorkspaceUsage</div>,
}));
vi.mock("@/components/ConsoleModal", () => ({
ConsoleModal: ({ open, onClose }: { open: boolean; onClose: () => void; workspaceId: string; workspaceName: string }) =>
open ? (
<div role="dialog" data-testid="console-modal">
<button onClick={onClose}>Close Console</button>
</div>
) : null,
}));
// ─── Fixtures ───────────────────────────────────────────────────────────────
const baseData: WorkspaceNodeData = {
name: "Test Workspace",
status: "online",
tier: 2,
url: "https://test.molecules.ai",
parentId: null,
activeTasks: 0,
agentCard: null,
} as WorkspaceNodeData;
function data(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNodeData {
return { ...baseData, ...overrides } as WorkspaceNodeData;
}
// ─── Helpers ───────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Tests ────────────────────────────────────────────────────────────────
describe("DetailsTab — view mode", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockUpdateNodeData.mockReset();
mockRemoveSubtree.mockReset();
mockSelectNode.mockReset();
mockApi.get.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders name, role, tier, status, URL, parent rows", () => {
render(<DetailsTab workspaceId="ws-1" data={data({ role: "SEO Specialist", url: "https://example.com" })} />);
expect(screen.getByText("Test Workspace")).toBeTruthy();
expect(screen.getByText("SEO Specialist")).toBeTruthy();
expect(screen.getByText("T2")).toBeTruthy();
expect(screen.getByText("online")).toBeTruthy();
expect(screen.getByText("https://example.com")).toBeTruthy();
expect(screen.getByText("root")).toBeTruthy();
});
it("renders Edit button", () => {
render(<DetailsTab workspaceId="ws-1" data={data()} />);
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
});
it("renders BudgetSection and WorkspaceUsage", () => {
render(<DetailsTab workspaceId="ws-1" data={data()} />);
expect(screen.getByTestId("budget-section")).toBeTruthy();
expect(screen.getByTestId("workspace-usage")).toBeTruthy();
});
it("renders Restart button for failed status", () => {
render(<DetailsTab workspaceId="ws-1" data={data({ status: "failed" })} />);
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
});
it("renders Restart button for offline status", () => {
render(<DetailsTab workspaceId="ws-1" data={data({ status: "offline" })} />);
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
});
it("renders Restart button for degraded status", () => {
render(<DetailsTab workspaceId="ws-1" data={data({ status: "degraded" })} />);
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
});
it("does not render Restart for online status", () => {
render(<DetailsTab workspaceId="ws-1" data={data()} />);
expect(screen.queryByRole("button", { name: /restart|retry/i })).toBeNull();
});
it("renders error section for failed status with lastSampleError", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={data({ status: "failed", lastSampleError: "ModuleNotFoundError: No module named 'requests'" })}
/>,
);
expect(screen.getByTestId("details-error-log")).toBeTruthy();
expect(screen.getByText(/ModuleNotFoundError/)).toBeTruthy();
});
it("renders error rate for degraded status", () => {
render(<DetailsTab workspaceId="ws-1" data={data({ status: "degraded", lastErrorRate: 0.15 })} />);
expect(screen.getByText(/15%/)).toBeTruthy();
});
it("renders Delete Workspace button in Danger Zone", () => {
render(<DetailsTab workspaceId="ws-1" data={data()} />);
expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
});
});
describe("DetailsTab — edit mode", () => {
beforeEach(() => {
mockApi.patch.mockReset();
mockUpdateNodeData.mockReset();
mockApi.get.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("clicking Edit shows form fields", () => {
render(<DetailsTab workspaceId="ws-1" data={data({ role: "Agent" })} />);
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect(screen.getByLabelText(/name/i)).toBeTruthy();
expect(screen.getByLabelText(/role/i)).toBeTruthy();
expect(screen.getByLabelText(/tier/i)).toBeTruthy();
});
it("Edit form pre-fills current values", () => {
render(<DetailsTab workspaceId="ws-1" data={data({ name: "My WS", role: "Coder" })} />);
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
expect((screen.getByLabelText(/name/i) as HTMLInputElement).value).toBe("My WS");
expect((screen.getByLabelText(/role/i) as HTMLInputElement).value).toBe("Coder");
});
it("Save calls PATCH and exits edit mode", async () => {
mockApi.patch.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={data({ name: "WS" })} />);
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "Renamed WS" } });
await flush();
// Use scoped search: BudgetSection also has a Save button
const saveBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
) as HTMLButtonElement;
fireEvent.click(saveBtn);
await flush();
expect(mockApi.patch).toHaveBeenCalledWith(
"/workspaces/ws-1",
expect.objectContaining({ name: "Renamed WS" }),
);
expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", expect.objectContaining({ name: "Renamed WS" }));
// Edit fields should no longer be visible
expect(screen.queryByLabelText(/name/i)).toBeNull();
});
it("Cancel reverts to view mode without saving", async () => {
mockApi.patch.mockResolvedValue({});
render(<DetailsTab workspaceId="ws-1" data={data({ name: "Original" })} />);
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "Changed" } });
await flush();
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Cancel" && !b.getAttribute("data-testid"),
) as HTMLButtonElement;
fireEvent.click(cancelBtn);
await flush();
expect(mockApi.patch).not.toHaveBeenCalled();
expect(screen.getByText("Original")).toBeTruthy();
expect(screen.queryByLabelText(/name/i)).toBeNull();
});
it("Save shows error banner on failure", async () => {
mockApi.patch.mockRejectedValue(new Error("Server error"));
render(<DetailsTab workspaceId="ws-1" data={data()} />);
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
const saveBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
) as HTMLButtonElement;
fireEvent.click(saveBtn);
await flush();
expect(screen.getByText(/server error/i)).toBeTruthy();
});
});
describe("DetailsTab — delete workflow", () => {
beforeEach(() => {
mockApi.del.mockReset();
mockRemoveSubtree.mockReset();
mockSelectNode.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("clicking Delete shows confirm dialog", async () => {
render(<DetailsTab workspaceId="ws-1" data={data()} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
expect(screen.getByRole("alertdialog")).toBeTruthy();
expect(screen.getByText(/confirm deletion/i)).toBeTruthy();
});
it("confirming delete calls DELETE and removes node from store", async () => {
mockApi.del.mockResolvedValue(undefined);
render(<DetailsTab workspaceId="ws-1" data={data()} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
// Radix ConfirmDialog uses dispatchEvent with bubbling click
const confirmBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Confirm Delete",
) as HTMLButtonElement;
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
await flush();
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
expect(mockSelectNode).toHaveBeenCalledWith(null);
});
it("cancelling delete returns to view mode", async () => {
mockApi.del.mockResolvedValue(undefined);
render(<DetailsTab workspaceId="ws-1" data={data()} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
await flush();
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent === "Cancel",
) as HTMLButtonElement;
fireEvent(cancelBtn, new MouseEvent("click", { bubbles: true }));
await flush();
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
});
});
describe("DetailsTab — restart workflow", () => {
beforeEach(() => {
mockApi.post.mockReset();
mockUpdateNodeData.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("Restart button calls POST /restart and sets status to provisioning", async () => {
mockApi.post.mockResolvedValue(undefined);
render(<DetailsTab workspaceId="ws-1" data={data({ status: "failed" })} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await flush();
expect(mockApi.post).toHaveBeenCalledWith("/workspaces/ws-1/restart", {});
expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", { status: "provisioning" });
});
it("Restart shows error on failure", async () => {
mockApi.post.mockRejectedValue(new Error("Restart failed"));
render(<DetailsTab workspaceId="ws-1" data={data({ status: "offline" })} />);
await flush();
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
await flush();
expect(screen.getByText(/restart failed/i)).toBeTruthy();
});
});
describe("DetailsTab — peers section", () => {
beforeEach(() => {
mockApi.get.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("loads peers from API", async () => {
mockApi.get.mockResolvedValue([
{ id: "p1", name: "Alice Agent", role: "seo", status: "online", tier: 2 },
{ id: "p2", name: "Bob Agent", role: null, status: "offline", tier: 3 },
]);
render(<DetailsTab workspaceId="ws-1" data={data()} />);
await flush();
expect(screen.getByText("Alice Agent")).toBeTruthy();
expect(screen.getByText("Bob Agent")).toBeTruthy();
});
it("shows 'No reachable peers' when list is empty", async () => {
mockApi.get.mockResolvedValue([]);
render(<DetailsTab workspaceId="ws-1" data={data()} />);
await flush();
expect(screen.getByText("No reachable peers")).toBeTruthy();
});
it("shows offline message when workspace is not online", async () => {
mockApi.get.mockResolvedValue([]);
render(<DetailsTab workspaceId="ws-1" data={data({ status: "provisioning" })} />);
await flush();
expect(screen.getByText(/only discoverable while the workspace is online/i)).toBeTruthy();
});
it("clicking peer name selects that node", async () => {
mockApi.get.mockResolvedValue([{ id: "p1", name: "Alice Agent", role: null, status: "online", tier: 2 }]);
render(<DetailsTab workspaceId="ws-1" data={data()} />);
await flush();
fireEvent.click(screen.getByText("Alice Agent"));
await flush();
expect(mockSelectNode).toHaveBeenCalledWith("p1");
});
});
describe("DetailsTab — skills section", () => {
beforeEach(() => {
mockApi.get.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders skills from agentCard", () => {
render(
<DetailsTab
workspaceId="ws-1"
data={data({ agentCard: { name: "Test Agent", skills: [
{ id: "web-search", description: "Search the web" },
{ id: "code-interpreter" },
]} as unknown as WorkspaceNodeData["agentCard"] })}
/>,
);
expect(screen.getByText("web-search")).toBeTruthy();
expect(screen.getByText("Search the web")).toBeTruthy();
expect(screen.getByText("code-interpreter")).toBeTruthy();
});
it("does not render Skills section when agentCard is null", () => {
render(<DetailsTab workspaceId="ws-1" data={data()} />);
expect(screen.queryByText("Skills")).toBeNull();
});
});
describe("DetailsTab — ConsoleModal", () => {
beforeEach(() => {
mockApi.get.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("View console output button opens ConsoleModal", async () => {
render(
<DetailsTab
workspaceId="ws-1"
data={data({ status: "failed", lastSampleError: "Traceback..." })}
/>,
);
await flush();
fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
await flush();
expect(screen.getByTestId("console-modal")).toBeTruthy();
});
it("Close button closes ConsoleModal", async () => {
render(
<DetailsTab
workspaceId="ws-1"
data={data({ status: "failed", lastSampleError: "Traceback..." })}
/>,
);
await flush();
fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
await flush();
expect(screen.getByTestId("console-modal")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /close console/i }));
await flush();
expect(screen.queryByTestId("console-modal")).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
+72
View File
@@ -189,6 +189,78 @@ def test_is_red_no_statuses(wd_module):
assert failed == []
# --------------------------------------------------------------------------
# Per-entry vendor-truth key (rev4) — see status-reaper rev4 sibling
#
# Gitea 1.22.6 returns per-entry items in combined.statuses[] with key
# `status`, not `state`. Pre-rev4 code only read `state` → failed[]
# was always empty → render_body always emitted the fallback "no
# per-context entries were in a red state". These tests use the
# canonical Gitea shape to lock the fix in.
# --------------------------------------------------------------------------
def test_is_red_vendor_truth_status_key_under_pending(wd_module):
"""Real Gitea 1.22.6 shape: per-entry uses `status`. A single failed
context counts as red even when combined is `pending`. Pre-rev4
this returned `(False, [])` because `s.get("state")` was None."""
red, failed = wd_module.is_red({
"state": "pending",
"statuses": [
{"context": "ci/lint", "status": "success"},
{"context": "ci/test", "status": "failure"},
{"context": "ci/build", "status": "pending"},
],
})
assert red is True
assert [s["context"] for s in failed] == ["ci/test"]
def test_is_red_status_takes_precedence_over_state(wd_module):
"""If both keys present (defensive), `status` (vendor truth) wins."""
red, failed = wd_module.is_red({
"state": "pending",
"statuses": [
# `status=failure` is truth even though `state=success` is
# stale. Locking in the precedence prevents a hypothetical
# future Gitea release that emits both from re-introducing
# the bug under a different shape.
{"context": "ci/test", "status": "failure", "state": "success"},
],
})
assert red is True
assert len(failed) == 1
def test_is_red_state_only_fallback_still_works(wd_module):
"""Backward-compat: a legacy fixture or future Gitea variant that
only emits `state` still trips the red detection via the fallback
chain. Keeps pre-rev4 fixtures green during the rev4 rollout."""
red, failed = wd_module.is_red({
"state": "pending",
"statuses": [
{"context": "ci/test", "state": "failure"}, # legacy shape
],
})
assert red is True
assert len(failed) == 1
def test_render_body_uses_status_key_for_per_entry_state(wd_module):
"""render_body must surface the per-entry `status` value in the
issue body. Pre-rev4 it read `state` (always None on real Gitea) →
every issue body said `(no state)`, defeating the diagnostic."""
failed = [
{"context": "ci/test", "status": "failure",
"target_url": "https://example.test/run/1",
"description": "broke"},
]
body = wd_module.render_body("deadbeefcafe1234", failed, {})
assert "`failure`" in body, (
"render_body did not surface per-entry status — likely still "
"reading `state` key only (rev1-3 bug)."
)
assert "(no state)" not in body
# --------------------------------------------------------------------------
# Happy path — main is green, no issue created
# --------------------------------------------------------------------------
+150
View File
@@ -544,6 +544,156 @@ def test_reap_unparseable_push_context_preserved(sr_module, monkeypatch):
assert counters["preserved_unparseable"] == 1
# --------------------------------------------------------------------------
# Per-context status-key vendor-truth (rev4)
#
# Gitea 1.22.6 returns commit-status entries with key `status` per entry,
# NOT `state`. The TOP-LEVEL combined aggregate uses `state`. This schema
# asymmetry caused rev1-3 to take the compensation path 0 times despite
# triggering on real failures: `s.get("state")` returned None → state
# evaluated to "" → `"" != "failure"` guard preserved every entry.
#
# These tests explicitly use the vendor-truth shape (`status` per entry),
# proving the rev4 fix routes the failure entry through compensation.
# Fixtures in rev1-3 tests above use `state` (the pre-fix bug shape) —
# we keep them for backward-compat coverage via the fallback in
# `s.get("status") or s.get("state")`, but the canonical Gitea shape
# uses `status`. Logged under
# `feedback_smoke_test_vendor_truth_not_shape_match`.
# --------------------------------------------------------------------------
def test_reap_per_context_uses_status_key_not_state(sr_module, monkeypatch):
"""Empirical Gitea 1.22.6 shape: per-entry uses `status`, top-level
uses `state`. The rev4 fix MUST detect failure via `status`."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"staging-smoke": False} # no push trigger → Class-O
# Real Gitea-shaped response: top-level `state`, per-entry `status`.
# No `state` key on the per-entry item.
combined = {
"state": "failure",
"statuses": [
{
"context": "staging-smoke / smoke (push)",
"status": "failure", # ← vendor-truth key
"target_url": "https://example.test/run/1",
"description": "smoke job failed",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
# The bug-class assertion: pre-rev4 this would have been 0, with
# preserved_non_failure=1. Rev4 reads `status` → routes to compensate.
assert counters["compensated"] == 1, (
"Compensation path unreachable: status-reaper still reads `state` "
"instead of `status` on per-entry combined.statuses[] items "
"(rev1-3 bug)."
)
assert counters["preserved_non_failure"] == 0
assert len(calls) == 1
assert calls[0][0] == "POST"
assert calls[0][1] == f"/repos/owner/repo/statuses/{SHA}"
def test_reap_per_context_status_key_takes_precedence_over_state(
sr_module, monkeypatch
):
"""Defensive: if both `status` and `state` are present (e.g. a
hypothetical Gitea version emits both), `status` (the canonical
Gitea 1.22.6 key) wins. Guards against a future regression where
a fixture or future Gitea release emits stale `state="success"`
while `status="failure"` is the truth."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"staging-smoke": False}
combined = {
"state": "failure",
"statuses": [
{
"context": "staging-smoke / smoke (push)",
# Both keys present — vendor-truth `status` MUST win.
"status": "failure",
"state": "success",
"target_url": "https://example.test/run/2",
"description": "smoke job failed",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 1
assert counters["preserved_non_failure"] == 0
assert len(calls) == 1
def test_reap_per_context_state_only_fallback(sr_module, monkeypatch):
"""Backward-compat: a test fixture or older Gitea variant that emits
only `state` (no `status`) must still flow through compensation.
Belt-and-suspenders against future fixture drift. Keeps rev1-3
`state`-using fixtures green."""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"staging-smoke": False}
combined = {
"state": "failure",
"statuses": [
{
"context": "staging-smoke / smoke (push)",
"state": "failure", # legacy fixture shape only
"target_url": "https://example.test/run/3",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 1
assert len(calls) == 1
def test_reap_per_context_missing_both_keys_preserves(sr_module, monkeypatch):
"""A per-entry item lacking BOTH `status` and `state` must be
preserved (counted under preserved_non_failure). This is the only
correctly-behaving leg of the pre-rev4 bug — exercising it ensures
the fallback chain doesn't accidentally over-compensate on
malformed entries."""
monkeypatch.setattr(
sr_module, "api",
lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("api should not be called")
),
)
workflow_map = {"staging-smoke": False}
combined = {
"state": "failure",
"statuses": [
{
"context": "staging-smoke / smoke (push)",
# No status, no state — neither key present.
"target_url": "https://example.test/run/4",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_non_failure"] == 1
# --------------------------------------------------------------------------
# ApiError propagation
# --------------------------------------------------------------------------