Skip to content

test(e2e): route-affordance inventory and smoke walk for #1949 AC4 - #2673

Merged
Chris0Jeky merged 4 commits into
mainfrom
issue-1949/route-affordances
Sep 5, 2026
Merged

test(e2e): route-affordance inventory and smoke walk for #1949 AC4#2673
Chris0Jeky merged 4 commits into
mainfrom
issue-1949/route-affordances

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

Refs #1949 (AC4). Adds the route-walking affordance smoke pass as a closed, hand-authored inventory plus one Playwright walk plus one vitest coverage guard. There is no crawler and no generated JSON anywhere in the chain.

Three new files, zero production-source changes, zero doc changes.

  • frontend/taskdeck-web/tests/e2e/support/routeAffordanceInventory.ts is data only and imports nothing, because the vitest guard consumes it under tsconfig.vitest.json, whose types are ["vite/client", "vite-plugin-pwa/client"] with no node types. It lists 12 walked /workspace routes with 2 to 5 affordances each (40 rows), a 22-name exclusion map with a stated reason per route, and the unnamed-redirect count.
  • frontend/taskdeck-web/src/tests/guards/routeAffordanceCoverage.spec.ts reads the real router.getRoutes() table and fails until every named route carrying requiresShell is either walked or excluded with a reason, in both directions.
  • frontend/taskdeck-web/tests/e2e/route-affordances.spec.ts walks every row marked activate: true in four test blocks and asserts the consequence the inventory declares.

Nothing irreversible runs. Today's seal confirm is asserted enabled and never clicked. Execute is walked to the apply-to-board dialog, the accept button is asserted enabled, and the dialog is then cancelled, so support/applyConfirm.ts is deliberately not used here because that helper's contract is to accept. The boardless accept-on-board control is asserted disabled, which is the #1944 shape, and never clicked. Openers for board settings, add column, add card, add connector and new view are opened and dismissed, never submitted.

This is Refs, not Closes. AC4 is delivered as a closed inventory; the parser-precision LOWs and the keystroke descoping recorded on the issue stay as they are. Keystroke rows are deliberately absent because shortcut truth belongs to shortcutLedgerTruth.spec.ts and shortcutNotation.spec.ts, and the inventory header says so.

Round 2

One commit, 53417f9, answering the FIX-FIRST verdict. Every item below was verified by re-running the walk.

HIGH — a mount read could satisfy a response row, so a dead control would pass

Correct and now fixed. The four response rows armed page.waitForResponse immediately before the click, but MetricsView auto-selects boards[0] in onMounted and its watcher fetches in the same tick, and NotificationInboxView fetches on mount while Refresh renders on first paint. Either mount request could settle the row's wait.

Three defences, all applied:

  1. Consume the mount read before arming. Both routes now arm a wait before page.goto, await it, and only then let activate register the row's own wait. Playwright cannot match a response that arrived before its waiter existed, so only a genuinely new request can settle the row. This copies the pattern the Home block already used.

  2. Pin the patterns. Exact sources now in the inventory:

    • metrics.board-select/api/metrics/boards/{metricsBoardId}\?from=
    • metrics.range-select/api/metrics/boards/{metricsBoardId}\?from=
    • metrics.export-csv/api/metrics/boards/{metricsBoardId}/export\?
    • notifications.refresh and notifications.unread-only/api/notifications(\?|$) (unchanged; both notification rows rely on defence 1, see below)

    {metricsBoardId} is a new context token bound at runtime to whichever of this block's two seeded boards MetricsView did not auto-select, with an explicit expect(...).not.toBe(preselected) guarding that choice. The mount read's URL therefore cannot match the rows' patterns even if defence 1 were removed. Anchoring on ?from= also stops the old unanchored pattern from matching a bare /metrics/boards/<id> path.

  3. Independent post-conditions. RouteAffordance gains an optional postCondition, asserted after the response, for rows where the surface renders something that changes: #board-select holds {metricsBoardId}, and #range-select holds 90. These are what fail when the control is inert but the network happens to be busy.

Both notifications rows deliberately carry no postCondition, and the inventory says why on the rows: NotificationInboxView.vue:267 renders the same "No notifications found." empty state filtered or not, and no helper can seed a notification to tell the two lists apart. They rest on defence 1 alone. That is a weaker row than the metrics ones and is stated as such rather than papered over.

Red-first proof. #board-select's v-model="selectedBoardId" was changed to :value="selectedBoardId" in the worktree — the exact dead-control shape, where the DOM select changes but the ref never does, so the watcher never fires. The change was not committed and MetricsView.vue was restored afterwards; git status shows only the three test files.

Error: page.waitForResponse: Test timeout of 90000ms exceeded.
  > 321 |     const responsePromise = page.waitForResponse(
    at activate (tests/e2e/route-affordances.spec.ts:321:34)
    at tests/e2e/route-affordances.spec.ts:463:3   <- metrics.board-select

That 90 s failure exposed a second, smaller problem: an unbounded wait spends the whole test timeout before reporting. The row wait is now bounded at 15 s, so a dead control fails about six times faster and costs the shared E2E Smoke budget far less. Re-running after restoring MetricsView.vue is green.

MEDIUM — expectConsequence discarded the scope

Correct. It built node and attribute locators from page and took .first(), so once a seed has two columns or two captures a consequence could be satisfied by a node the walk never opened. It now takes the same scope the affordance was found in and asserts there. url stays page-level, because navigation is a property of the page and not of a scoped locator; that is commented in place.

MEDIUM — two Today rows declared consequences that prove nothing

Correct on both. today.write-note declared a node TodayLineForTomorrow renders unconditionally, so the spec's toBeFocused was carrying the whole proof while the inventory understated it; today.seal-confirm declared the group it lives inside rather than the control. Two consequence kinds were added so the inventory states the truth: focus (line-for-tomorrow-input holds focus, which is what PaperTodayView.vue:113-118 actually does) and enabled (the [data-action="seal-confirm"] control itself). A third kind, value, backs the metrics post-conditions. assertReachableButNotActivated now asserts the row's declared consequence instead of hardcoding an enabled check.

MEDIUM — the automations-queue exclusion reason was false

Correct, and it was my error. AutomationQueueView is the raw queue-request surface (queueStore rows, a request-type and payload composer) and renders no review rail, so the old reason claimed a duplication that does not exist. The reason now states the real barrier: no E2E spec enters the route and no support helper seeds a queue request, so every control sits behind an empty list.

LOWs

  • Guard assertion 4 now pins the four unnamed redirect paths by identity (/workspace, /workspace/activity/user/:userId, /workspace/automations, /workspace/automations/proposals) and keeps the count as an arity check on that list. A count alone was satisfied by any four redirects.
  • not-found now records that it does render two recovery links (NotFoundView.vue:18 and :21) and stays excluded because the route holds no state to walk and both links duplicate shell navigation.
  • The workspace-settings-access reason is corrected: registerUserSession can mint an account, so the barrier is the board-sharing step no helper performs.
  • coveredBy citations now name the click lines: capture-loop.spec.ts:55 (was 52, the testid lookup) and first-run.spec.ts:49 (was 48, the visibility assertion).
  • The spec header now records what completeness does not catch: each block checks only its own share, so a block that never runs — test.skip, a grep filter, maxFailures cutting the run short — drops its rows from the proof silently. An unwalked new row is still always caught, because the plan-versus-inventory half runs in every surviving block; it is a whole missing block that goes unnoticed, and reading the summary for four passing blocks is what closes it. No marker file was added: it would put shared mutable state back into a file that just had it removed for exactly that reason.
  • Block 1's 90 s timeout is kept. Against the contract's 45 s target the measured cost is 5.3 s, so the margin is roughly 8x; the raise exists for cold-CI variance in the seeding and triage reads, not for expected runtime. Same for block 4 at 6.8 s.

Changes

Four commits.

  1. test(e2e): add the closed route-affordance inventory for #1949 AC4
  2. test(guards): pin the route-affordance inventory against the real router
  3. test(e2e): walk the route-affordance inventory in four smoke blocks
  4. test(e2e): close the mount-read hole in the route-affordance response rows — round 2

The compared surface in guard assertion 3 is "named routes whose meta carries requiresShell: true" rather than a /workspace path prefix. That set is the 33 named /workspace routes plus not-found, which is /:pathMatch(.*)* but still renders inside the shell. A separate assertion pins not-found as the only non-/workspace member, so the framing cannot drift silently.

Test plan

Verified (round 2 head, 53417f9)

npx vitest --run --maxWorkers=2 src/tests/guards/routeAffordanceCoverage.spec.ts src/tests/router/workspaceRouteStability.spec.ts2 files, 43 tests passed (7 new guard assertions plus the 36 existing router-stability tests).

npm run typecheck (vue-tsc -b) — clean.

npx eslint tests/e2e/route-affordances.spec.ts tests/e2e/support/routeAffordanceInventory.ts src/tests/guards/routeAffordanceCoverage.spec.ts — exit 0, no warnings. ESLint is the only static gate on the Playwright spec, which is in no tsconfig project.

npx playwright test tests/e2e/route-affordances.spec.ts --project=chromium --workers=1 --reporter=list with the four retired Llm__Gemini__* variables unset — 4 passed in 21.8 s. Per test: board-seeded 5.3 s, home and inbox 1.3 s, today 873 ms, review 6.8 s. An intermediate round-2 run before the wait was bounded measured 30.0 s; the difference is run-to-run variance on this box, not the bound.

git diff --check — exit 0. git status --porcelain — only the three test files; MetricsView.vue restored after the red-first proof.

Round 1 also verified --list compile and discovery (4 tests in 1 file) and a run at the local default of 2 workers (4 passed, 23.8 s), which confirmed the completeness check does not depend on worker layout.

Red-first evidence

Three, each produced by breaking the checked thing, observing the failure, then restoring.

Coverage equality, by deleting the workspace-archive exclusion entry:

AssertionError: expected [Function] to not throw an error but 'Error: route-affordance inventory is …' was thrown
"route-affordance inventory is out of step with the router table. Shell routes not accounted for
 (add to the inventory or the exclusion map): [workspace-archive]. Declared names the router does not
 define (stale entry): []."

The guard's own canary, by changing its synthetic inventory from slice(1) (one route dropped) to slice(0) (complete):

FAIL src/tests/guards/routeAffordanceCoverage.spec.ts > fails when a synthetic inventory drops one walked route
AssertionError: expected [Function] to throw an error

The HIGH, by turning #board-select into a dead control — quoted in the Round 2 section above.

NOT verified

  • No backend test ran. There is no backend change.
  • npm run build and the full vitest suite were not run. Nothing outside the three test files changed.
  • The walk asserts that each affordance is reachable and produces the declared consequence. It does not assert the resulting content is correct, and it makes no claim about any affordance not listed.
  • The 22 excluded routes are not exercised here. Their reasons cite other specs by file:line; those citations were read, not re-run.
  • Three rows are recorded as out-of-slice-1 and are unproven at runtime.
  • The three covered-elsewhere rows are not clicked by this file.
  • The two notifications rows rest on mount-read consumption alone, with no independent post-condition, for the reason recorded on the rows.
  • A block that never runs takes its rows out of the completeness proof; see the LOW above.

Boundaries and risks

Selector substitutions against the ratified contract

Every contract selector was checked against source before use. These moved.

  • PaperBoardColumn.vue is at src/views/paper/PaperBoardColumn.vue, not src/views/paper/board/. The data-action="toggle-add-card" hook is at line 298 as stated.
  • Add-card consequence: the contract named [data-action="add-card-input"] (real, at PaperCardComposer.vue:70). The inventory asserts the composer's stable testid paper-card-composer on the same element tree, because the consequence vocabulary addresses nodes by testid.
  • paper-board-dialog-name lives in PaperBoardSettingsDialog.vue:170, not PaperBoardView.vue. The affordance's source points at the trigger, PaperBoardView.vue:779.
  • expectApplyConfirmDialog (support/applyConfirm.ts:40) clicks apply-confirm-accept, so it could not be used for the Execute row, whose point is to stop before that.
  • covered-elsewhere citations, corrected again in round 2 to the click lines: capture-loop.spec.ts:55, first-run.spec.ts:49. Capture-here review-proposals.spec.ts:75, composer capture :88 and Ask AI :97 were confirmed as stated.
  • Exclusion citations: activity is smoke.spec.ts:293; chat is automation-ops.spec.ts:56; ops cli is validation-ops-logs-health.spec.ts:14 and ops logs :72. workspace-automations-queue, workspace-ops-endpoints and workspace-metrics-cohorts have no E2E coverage at all, so their reasons state the seeding gap.
  • paper-review-clear-scope (PaperReviewView.vue:2922) renders only inside the board-scoped EMPTY branch of the deep pane. It is walked in the board-seeded block, on a board with no proposals, rather than in the review block whose board deliberately has one. This is a deviation from the contract's block split.

No production testid was added, and no production source is changed by this PR.

Rows recorded as out-of-slice-1

  • notifications.mark-all-read — renders only when unreadCount > 0; no helper creates an unread notification.
  • calendar.timeline-mode — found by running, not reading. The empty-state branch at CalendarView.vue:260 (calendarData.totalCards === 0) precedes both view modes in the v-else-if chain, so the timeline list cannot render for a throwaway user and no helper seeds a due-dated card. The button itself is present and clickable; only the declared consequence is unreachable.
  • views-detail.open-result-card — no helper seeds a card matching the Blocked Work filter. This row also supplies the second affordance the 2-to-5 rule requires for workspace-views-detail, which the contract listed with only one.

Completeness check design

The obvious design, accumulating walked ids across the file and checking in afterAll, is unsound here and was observed failing twice: Playwright restarts the worker process after a failing test, which silently resets the accumulator, and fullyParallel: true lets tests from one file land in different workers, which splits it. Completeness is proved instead by a declared WALK_PLAN that must name exactly the inventory's activate: true and guarded-not-activated rows, checked on every test, plus a per-block check that the block walked exactly its share.

E2E Smoke budget

Two independent ceilings, which matters for the #1872 slow-install concern.

  • Job ceiling: timeout-minutes: 35 on the e2e-smoke job (reusable-e2e-smoke.yml:27).
  • Test-step ceiling: timeout-minutes: 12 on the "Run Playwright smoke tests" step (line 77), matched by globalTimeout: 12 * 60_000 in playwright.config.ts:110.
  • install-deps has its own timeout-minutes: 10 (line 63); the browser install timeout-minutes: 5 (line 68).

Measured. Only two ci-required runs on main completed in the last 20; the rest were cancelled by superseding pushes, so three recent completed runs on PR branches are included.

Run Job total install-deps Playwright test step
main 33949129074 7.3 min 0.2 min 6.4 min
main 33945059269 7.2 min 0.2 min 6.4 min
33953238352 7.5 min 0.2 min 6.5 min
33953203877 6.4 min 0.2 min 5.5 min
33953087574 7.2 min 0.2 min 6.3 min

Headroom against the binding ceiling is about 5.5 min of the 12-min test step. This spec measures 21.8 s wall on a warm local box at workers: 1; allowing 2 to 3x for a hosted runner, budget roughly 45 to 70 s, about 12 to 21 percent of that headroom.

A slow install cannot push this spec past the job ceiling, because the two budgets are independent: worst case is 10 + 5 + 12 plus about 1 min of setup and upload, roughly 28 min against the 35-min job ceiling. The failure mode a slow install produces is its own step timeout, which this change does not affect.

If the lane tightens, shed rows to slice 2 in this order by marking them out-of-slice-1 and deleting their WALK_PLAN entries and calls: the three metrics rows first (heaviest per row: two selects and an export round trip), then the two integrations rows. That removes five of the twenty rows in the heaviest block. Do not raise globalTimeout or the step timeout: .github/** is a control-plane change for the maintainer under SC-10 and is out of scope here.

Other risks

  • The walk creates one throwaway user per test, one board per seeded block, and a second board in the board-seeded block via boards.create-submit. No teardown helper exists in this repo's E2E support, which is the posture every other spec here takes.
  • Row response waits are bounded at 15 s. A genuinely slow hosted runner could in principle exceed that on a request the local box answers instantly; the trade is a fast, legible failure against a small flake risk, and 15 s is nearly 2x the config's 8 s expect timeout.

Hand-authored, import-free data module: 12 walked /workspace routes with 2-5 affordances each, the 22-name exclusion map with a stated reason per route, and the unnamed-redirect count. It imports nothing so the vitest coverage guard can consume it under tsconfig.vitest.json, which carries no node types.
Seven assertions read router.getRoutes() directly, with no generated JSON in between: shape, name existence, set equality against the named requiresShell surface in both directions, the unnamed /workspace redirect count, reason length, id uniqueness and source format, plus a canary proving the equality check can fail.
Four test blocks activate every activate:true row and assert the consequence the inventory declares. Nothing irreversible runs: the Today seal confirm is asserted enabled and declined, Execute stops at the apply dialog and cancels, the boardless accept-on-board control is asserted disabled, and openers are dismissed rather than submitted. Two measured corrections to the inventory ride along: the metrics response patterns no longer pin one board id because MetricsView auto-selects boards[0] on mount, and calendar.timeline-mode moves to out-of-slice-1 because the empty-state branch at CalendarView.vue:260 precedes both view modes while totalCards is 0.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review record (alpha product-trust lane, review-and-ship round 1 at head fcfc2a3).

Reviewer: one fresh-context independent reviewer subagent (read-only), input = merge-base..head diff, the worktree at the head and the ratified AC4 contract. Verdict: FIX-FIRST, one HIGH.

  • HIGH, fix-now (round 2): the four response-consequence rows (metrics board-select and range-select; notifications refresh and unread-only) arm the response wait before the click, but the view's own mount request (MetricsView auto-selects the first board and fetches in the same tick; NotificationInboxView fetches on mount) can satisfy the unanchored pattern, so a dead control still passes; round 2 awaits the mount read before arming, pins the patterns (a second seeded board for the board-select row, from= for the range row) and adds post-conditions, with a red-first proof against a control made dead in the worktree.
  • MEDIUM, taken: expectConsequence discarded the activation scope for node and attribute consequences, so a consequence could be satisfied by a node the walk never opened once a seed has two columns or captures.
  • MEDIUM, taken: two inventory rows declared trivially-true consequences (the note textarea renders unconditionally; the seal confirm's own group); the inventory now declares the real consequence.
  • MEDIUM, taken: the automations-queue exclusion reason was false (that view is the raw queue-request surface, not a review rail duplicate).
  • LOWs taken: the guard asserts the four redirect paths; the not-found and settings-access reasons corrected; coveredBy citations moved to the click lines; the spec header states that a skipped block is not detected. LOW recorded: block 1 carries a 90 s timeout against the contract's 45 s target (measured 5.4 s).

Confirmed clean by the reviewer: the router arithmetic (34 named shell routes = 12 inventory + 22 excluded, four unnamed redirects, not-found pinned as the only non-workspace member; the canary is load-bearing); the inventory imports nothing under a vitest tsconfig with no node types; the router is read the way the route-stability spec reads it; 18 selectors spot-checked against source including every substitution; openers whose consequence is an attribute are load-bearing; the state-flip assertions genuinely flip; all eight URL consequences are not pre-satisfied; the destructive-action policy holds everywhere (seal confirm and accept-on-board never clicked; Execute cancelled at the dialog; Approve, Archive, board-create and quick-capture on a throwaway user; no connector writes); every cited covering spec exists at the cited lines; the dev-tools exclusion is exact; no cross-worker hazard in the walk plan; no ESLint disables; the new file is picked up automatically by the E2E Smoke lane.

Merge gate: round-2 push, one verification pass scoped to the fix diff, ci-required green at the round-2 head (including the E2E Smoke lane that executes the walk), aged three minutes, then merge commit.

… rows

HIGH: the four response rows armed page.waitForResponse just before the click, so a request the view already issued on mount could satisfy them and a dead control would still pass. Each route now consumes its mount read before arming (armed before goto, awaited), the metrics rows are pinned to the board the walk selects rather than the one MetricsView auto-selects, and rows carry an independent postCondition wherever the surface renders something that changes. Proven red-first by turning #board-select into a dead control. The row wait is bounded at 15 s so a dead control reports fast instead of burning the 90 s test timeout.

Also: expectConsequence honours the affordance scope instead of taking a page-wide first(); today.write-note and today.seal-confirm declare their real consequences (focus, and the control's own enabled state) rather than nodes that render unconditionally; the automations-queue, settings-access and not-found exclusion reasons are corrected to what the source actually says; the guard pins the four unnamed redirect paths, not just their count; and the two coveredBy citations name the click lines.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round-2 verification record (scoped to the fix diff fcfc2a3..53417f9; read-only pass).

Verdict: SHIP for the fix diff, no CRITICAL or HIGH; the round-1 HIGH is closed for the four rows it named. Confirmed: the metrics mount issues exactly one request matching the from= anchor (the forecast and export URLs differ), so the pre-goto wait consumes it and nothing else is left in flight; the {metricsBoardId} pin is load-bearing (with a dead board select, neither the pinned metrics URL nor the export URL can be requested) and a board change always serialises from=; the range option's DOM value is the string 90; the notifications route issues exactly one mount GET (one caller in the tree, no bell fetch, no poller), the alternation excludes mark-all-read and preferences, and a dead Refresh fails at the 15 s bound; the bound goes through waitForResponse's timeout option; activate passes the same scope object to locate and expectConsequence, and the two scoped rows resolve inside their scopes; the write-note focus consequence genuinely reddens on a dead button; the guard's four redirect paths match the router table in sorted order; the inventory still imports nothing; nothing outside the three files.

Three MEDIUMs tracked as #2678 rather than a third round (the two-round ceiling is reached; none is a merge blocker for a test-only PR): expectConsequence is a non-exhaustive if-chain and status.assertEnabled is enforced nowhere after round 2 (a future guarded row with a response consequence would assert nothing); the spec header claims universal mount consumption but the calendar rows still arm after navigation and are protected only by ad-hoc label assertions; the value post-condition on the two selects cannot fail for a dead select (the pin is the real defence) and the docs credit the wrong half. Two LOWs recorded there: a response promise armed before a blocking act rejects unawaited; 15 s headroom is about 5x the measured latency.

Merge gate unchanged: ci-required green at 53417f9 (including the E2E Smoke lane that executes the walk), aged, merge commit.

@Chris0Jeky
Chris0Jeky merged commit 87d9943 into main Sep 5, 2026
37 checks passed
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 5, 2026
Chris0Jeky added a commit that referenced this pull request Sep 5, 2026
…als, review records)

Answers the docs review of PR #2686: the SC-10 sentence returns to the unenumerated form (the enumeration dropped #2550 and #2610 and disagreed with the thirteenth block's count of ten); the #2675 bullet no longer says #2090 keeps only two residuals; the header names both PRs that carried a scoped verification pass (#2673, #2681) and the #2681 and #2679 bullets carry their review records; #2638 is tracked as both items.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant