test(composer): agent-mode DOM + accessibility coverage for the ledger-v2 decision controls - #35
Merged
Merged
Conversation
Closes the verification gap recorded as #32: every ledger-v2 transition was pinned in composer-core and the agent routes, but nothing proved a PERSON could reach them. The deployed-composer smoke is agent-free and ships a v1 demo, so the decision controls never rendered under test. Harness (a third Playwright config, playwright.composer-agent.config.ts): the real static export of apps/composer/out + the real local agent + a per-spec temp copy of the shipped demo project on disk. No mocked transport; the only route interception adds latency to a real save so the transient busy state is observable. One worker, no retries — a retry would replay a decision against already-decided state. Coverage (21 specs): v1 stays section-level; migration surfaces every decision family without deciding any; Restore; Never rediscover; tombstone removal; Keep nested; Restore top-level; unresolved-conflict persistence; freshDelta Accept for both ratified shapes (scalar leaf and append-only enum); reload+reconnect persistence; a refused action surfaced verbatim with the ledger untouched; double activation applying exactly once; keyboard operation and focus; accessible names; live-region announcements; labelled decision groups; axe over the report, ownership panel, and inventory. Product defects the rendered tests uncovered, and fixed: - AGENT MODE WAS UNREACHABLE in the built app: the mount effect tracked loadDemo's identity, which changes when connect() sets extraSurfaces, so connecting to a real project snapped straight back to the demo. Now a one-shot bootstrap ref. - decisions never set `busy`, so no control disabled while one ran (the ref lock prevented double-apply, but nothing showed it) - the notice was not a live region: outcomes were painted, not announced - decision buttons carried ambiguous repeated names ("Restore", "Restore"); each now names its component, and the ownership panel's twins name their surface - decision groups were unlabelled in the accessibility tree - WCAG AA contrast failures (3.25:1) on the tombstoned entry state, the suppressed report line, and the inventory "bare" chip — --fg-faint on 11px text; now --fg-dim (5.40:1) - deletion-group copy asserted "these were deleted", which is false for ids a v1 migration could not attribute; reworded to cover both origins Fail-first: the suite failed 10/21 against the pre-fix UI, each failure naming a required behavior (names, live region, groups, axe, busy, focus); an earlier pass failed 20/20 at connect, which is how the demo-revert defect surfaced. Wired into CI after the exhibit suite. Workspace: composer-core 23/23, agent 31/31, all packages green, typecheck clean, exhibit Playwright 106 passed/4 pre-existing skips, deployed-composer smoke 12/12 unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an agent-mode, DOM-level Playwright harness to exercise the composer’s ledger-v2 decision controls (including keyboard + axe coverage) against the real static export, the real local agent, and real on-disk project files—closing the existing gap where these controls were pinned only at unit/route layers.
Changes:
- Introduces a dedicated Playwright config + webServer setup for “composer agent-mode” tests, and wires it into CI.
- Adds an agent-mode test project helper that clones the shipped demo project into a per-spec temp workspace and drives connect/rediscover flows.
- Updates composer UI state and views to support accessible, testable decision controls (busy/disabled state, live-region notice, labelled decision groups, clearer button names, contrast/copy tweaks).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| playwright.config.ts | Excludes the new agent-mode specs from the default Playwright run. |
| playwright.composer-agent.config.ts | New Playwright config to run agent-mode composer suites with two web servers (static export + agent). |
| e2e/support/agent-project.ts | Helper to provision per-test temp projects and drive connect/rediscover flows in the UI. |
| e2e/serve-composer.mjs | New zero-dependency static server for apps/composer/out used by the agent-mode suite. |
| e2e/composer-agent.spec.ts | New end-to-end decision-control interaction coverage (restore/tombstone/conflicts/fresh facts/persistence/double-activation/focus). |
| e2e/composer-agent-a11y.spec.ts | New accessibility coverage (names, live region, labelled groups, axe checks, busy/disabled semantics). |
| apps/composer/app/views/project-view.tsx | Renders decision controls with labelled groups + aria-labels; focus-management after decisions; contrast/copy updates. |
| apps/composer/app/views/inventory-view.tsx | Contrast fix for the “bare” chip state. |
| apps/composer/app/state.tsx | Fixes demo bootstrap re-run; introduces busy state during decisions for UI lockout. |
| apps/composer/app/composer.tsx | Makes notice a live region (role=status, aria-live=polite). |
| .github/workflows/ci.yml | Builds composer static export and runs the new agent-mode Playwright config in CI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+31
to
+40
| let path = normalize(decodeURIComponent((req.url ?? "/").split("?")[0])).replace(/^([/\\])+/, ""); | ||
| if (path === "" || path.endsWith("/")) path += "index.html"; | ||
| let file; | ||
| try { | ||
| file = await readFile(join(root, path)); | ||
| } catch { | ||
| file = await readFile(join(root, `${path}.html`)).catch(() => readFile(join(root, "index.html"))); | ||
| } | ||
| res.writeHead(200, { "content-type": MIME[extname(path)] ?? "application/octet-stream" }); | ||
| res.end(file); |
Comment on lines
+56
to
+61
| export function demoProject(options: DemoOptions = {}): DemoProject { | ||
| const root = mkdtempSync(join(tmpdir(), "composer-agent-")); | ||
| cpSync(DEMO, root, { recursive: true }); | ||
| const contractPath = join(root, "acme-ui.dspack.json"); | ||
| const read = () => JSON.parse(readFileSync(contractPath, "utf8")) as Record<string, any>; | ||
| const write = (doc: Record<string, any>) => writeFileSync(contractPath, `${JSON.stringify(doc, null, 2)}\n`); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #32.
Every ledger-v2 ownership transition was already pinned in
composer-coreand the agent routes. Nothing proved a person could reach them: the deployed-composer smoke is agent-free by construction and ships a ledger-v1 demo, so the decision controls never rendered under test. This adds the missing DOM layer above the existing tests — it replaces nothing.Harness
A third Playwright config,
playwright.composer-agent.config.ts:apps/composer/out, the same artifact that deploys) served by a zero-dependency file server;pnpm --filter agent dev) — the actual/project/*routes;No mocked transport and no hand-written ledgers. The only route interception adds latency to a real
/project/save(the request still reaches the agent) so the transient busy state is observable instead of raced. One worker, no retries — a retry would replay a decision against already-decided state, which is precisely the flakiness these tests exist to catch.The premise needs no artificial fixtures: one rediscovery of the demo project produces all three decision families from real source — restructure conflicts (
info-card-*, issue #13's exact shape), an id awaiting a decision (a component added to source that a human-owned v1 section cannot attribute), and fresh facts on entries the author owns (a changed doc comment, a new cva variant).Coverage — 21 specs
Interactions: v1 projects render only the section-level experience; migration surfaces every decision family without deciding any; Restore; Never rediscover; tombstone removal; Keep nested; Restore top-level (nested representation preserved byte-identically); unresolved-conflict persistence across rediscoveries; freshDelta Accept for both ratified shapes (scalar leaf, append-only enum keeping authored order); reload + reconnect persistence; a refused action surfaced verbatim with the ledger byte-unchanged; double activation applying exactly once.
Accessibility: accessible names that identify the component acted on;
role="status"live-region announcements; labelled decision groups; keyboard operation and focus never dropping to the body; axe (wcag2a,wcag2aa) over the pending-decision surface, the ownership panel after a decision, and the inventory view.Product defects the rendered tests uncovered — and fixed
loadDemo's identity, which changes whenconnect()setsextraSurfaces— so connecting to a real project snapped straight back to the demo project. Nothing below the DOM could see this; it is why the first run failed 20/20 atconnect. Now a one-shot bootstrap ref.busy, locking the whole decision surface.--fg-fainton 11px text; now--fg-dim(5.40:1).Fail-first evidence
The suite failed 10/21 against the pre-fix UI, each failure naming a required behavior (names, live region, groups, axe, busy/disabled, focus). An earlier pass failed 20/20 at
connect— which is how defect 1 surfaced. After the fixes: 21/21 green (~29s).Results
composer-core 23/23 · agent 31/31 · all workspace packages green · typecheck clean · exhibit Playwright 106 passed / 4 pre-existing skips · deployed-composer production smoke 12/12 (unaffected — this branch changes no deployed behavior for the hosted v1 demo beyond the contrast and copy fixes). Wired into CI after the exhibit suite.
Out of scope, per the issue: ledger semantics, casualty acknowledgement (#30), Phase 3 AI, hosted bindings, visual redesign, unrelated test cleanup.
🤖 Generated with Claude Code