test(e2e): route-affordance walk doc precision, negated-matcher findings and parked mount reads (#2682 follow-ups) - #2706
Conversation
Follow-ups 1, 2 and the inventory half of 4 from the #2679 review. The walk's header says which defence covers each `response` row is "stated per row in the inventory". Four rows had no such note, so add them: `home.quick-capture-submit`, `inbox.archive` and `review.approve` name the POST that no mount can raise (each anchored to the view's own `onMounted`), and `calendar.next-month` gains the spent-mount-read half its comment was missing beside its exact-string post-condition. The token docblock claimed substitution happens in `url` and `response` pattern strings and that `{openingMonthLabel}` is among them. Neither is the whole truth: `fillTokens` is applied at five consequence sites plus a `role` selector name, and `{openingMonthLabel}` appears in no pattern at all. State the real surface, and say what is used verbatim so a token written into an `attribute` selector or a test id is understood to fail silently. `entryPath` is documented as the read-nothing field it is. Record on the `textChangedFrom` kind that its negated matcher is not vacuous on a missing node: Playwright fails a zero-element `not.toHaveText` rather than passing it. Refs #2682
Follow-up 3 from the #2679 review, guard half. `AffordanceStatus`'s guarded variant declares `assertEnabled: true` as a required literal, so the checker's `!== true` throw cannot fire for a real inventory row and assertion 8's docblock was describing a per-row decision the type forecloses. Keep the read as belt-and-braces and say so at the site: the type is the enforcement, and widening the field to `boolean` is what would make the check live and owe it a canary. That also answers why this branch has no canary while the consequence branch does. A `response` consequence on a guarded row type-checks -- assertion 9 builds exactly that row with no cast -- so the shape it rejects is writable. The `assertEnabled` shape is not reachable without a cast, so a canary would prove the checker against something the type already makes unconstructible. Also note that offering `textChangedFrom` to guarded rows is safe: Playwright fails a zero-element `not.toHaveText` with `element(s) not found`, so every kind classified `element` asserts something about a node that exists. Refs #2682
Follow-up 5 from the #2679 review, plus the walk-side halves of 3 and 4. The calendar, metrics, notifications and home mount reads all armed a `page.waitForResponse` before `page.goto` and awaited it only on the next line. If the `goto` threw, nothing awaited the wait and its 30 s rejection landed on the worker with no test to own it. Extract `readOnMount`, which parks the outcome the moment the wait is armed -- the pattern `activate` already uses for row waits -- and route all four through it. The success path is byte-for-byte the same semantics: armed before, awaited after, asserted 2xx. Measured before the change, against a `goto` to a refusing port: the shipped shape leaked exactly one unhandled rejection ("page.waitForResponse: Timeout exceeded"), the parked shape leaked none, and both reported the same navigation error. Also record at their own sites that `if (affordance.status.assertEnabled)` is dead by construction, and that `textChangedFrom`'s `not.toHaveText` fails rather than passes on a zero-element locator -- so it needs no `toHaveCount` companion and `calendar.previous-month` proves its own move. Refs #2682
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Review record (alpha product-trust lane; test-only, one fresh-context read-only reviewer subagent since Codex credits are exhausted, SC-9; round 1 at head Verdict at round 1: SHIP on the merge-blocking bar. Because this PR's purpose is doc precision, its three findings, all sentences in the new text, get one wording-only commit in round 2 rather than a tracked issue:
Clean lenses corroborated by the reviewer against the tree at the head: Unverified by the read-only reviewer, run by the worker at the head: guard spec 9/9 (Test Files 1), typecheck, eslint, Round count: 2 (wording only, so no further review pass is owed). Merge gate remaining: the fix head's |
… token census Round 2 wording-only fixes on the #2682 follow-ups. No executable change; no bound added. 1. The header and `readOnMount` both claimed an unparked mount read would reject after "30 s". False under this config: the calls pass no `timeout` and nothing supplies a default, so the wait has no event timeout at all. Chain read in the installed tree -- `playwright/lib/index.js:259` defaults the `actionTimeout` fixture to 0 and `playwright.config.ts` never sets it (only `timeout: 45_000` and `expect.timeout: 8_000`, and nothing under `tests/` calls `setDefaultTimeout`); `:349` assigns `_defaultContextTimeout = actionTimeout || 0`; `coreBundle.js:57160-57169` returns that 0 rather than falling through to the library default; and `:58412` arms a timer only `if (timeout)`, which 0 is not. So such a read HANGS to the test timeout (90 s board-seeded, 45 s Home/Inbox) and reports "Test timeout exceeded". Parking still earns its place: an unawaited wait is rejected at page close with a `TargetClosedError` (`coreBundle.js:61278-61279`, `:61265-61267`), with no test left to own it. Also record that `activate` caps its row waits at 15 s while these do not, and that bounding them is tracked on #2682. 2. "`{boardId}` ... nowhere else" was false: it also appears in the `workspace-board` entry's `entryPath`, which this same PR documents as never substituted. Name that occurrence. 3. The verbatim-usage list omitted `value.selector`, `text.selector` and `textChangedFrom.selector`. Added, so the list is complete rather than illustrative. Refs #2682
|
Round 2 at |
Test-only. The five follow-ups from the fresh-context review of PR #2679, over the three files PR #2673 created. No product code touched.
Refs #2682
Summary
1. Header pointer made true, not dropped.
route-affordances.spec.tssays which defence covers each walkedresponserow is "stated per row in the inventory". Four rows carried no such note, so they have one now:home.quick-capture-submit—PaperHomeView'sonMountedfetches the home summary and nothing else (PaperHomeView.vue:381-384), so only this submit can raise the POST.inbox.archive—PaperInboxView'sonMounted(PaperInboxView.vue:504-508) issues no request at all, and the pattern is anchored to a POST on/archive.review.approve—PaperReviewView'sonMounted(PaperReviewView.vue:2546-2558) loads board options and proposals and starts a queue poll, all GETs.calendar.next-month— gains the spent-mount-read half beside the exact-string post-condition its comment already explained.2. Token-substitution doc now states the real surface. The old text said tokens are substituted in
urlandresponsepattern strings and listed{openingMonthLabel}among them. Read fromfillTokens's call sites rather than from the issue: it is applied at five consequence sites —url.pathPattern,response.urlPattern,value.value,text.text,textChangedFrom.from— plus aroleselector'sname. It is not applied toattribute'sselector/attribute/value, theenabledselector,node/focustest ids, or anycss/testIdselector value, so a token written into one of those is matched literally and the row silently fails to find its node.{openingMonthLabel}appears in exactly two places (calendar.previous-month'stextChangedFrom.fromandcalendar.next-month'stext.text) and in no pattern at all.fillTokensis unchanged.While there:
RouteEntry.entryPathclaimed{boardId}"is substituted". Nothing reads that field — the walk navigates with literalpage.gotocalls and the guard compares route names — so it is now documented as the read-nothing field it is.3.
assertEnabledreads documented as dead, both kept. The guarded status variant declaresassertEnabled: trueas a required literal, so the guard's!== truethrow and the walk'sif (affordance.status.assertEnabled)cannot take their false branch. Both reads stay as belt-and-braces and now say so at their own sites and in the assertion-8 docblock: the type is the enforcement, and widening the field tobooleanis what would make them live.That also answers the review's "unlike the
responsebranch, this has no canary". The asymmetry is the point: aresponseconsequence on a guarded row type-checks — assertion 9 builds exactly that row with no cast — so its canary tests a shape someone can write. TheassertEnabledshape is not reachable without a cast, so a canary there would only prove the checker against something the type already makes unconstructible. Considered and declined; recorded in the docblock rather than left implicit.4.
textChangedFrom— hypothesis refuted, so nothing changed. The review expectedexpect(locator).not.toHaveText(x)to pass on a zero-element locator, which would have made the kind vacuous and required atoHaveCount(1)companion. It does not pass. Confirmed two ways on Playwright 1.62.1 (@playwright/testandplaywright-coreboth 1.62.1):node_modules/playwright/lib/matchers/expect.js—toHaveTextwith a string sends theto.have.textexpression (not.array).node_modules/playwright-core/lib/coreBundle.js— inFrame._expectInternal, a selector resolving to zero elements special-cases onlyto.be.visible,to.be.hidden,to.be.attached,to.be.detached,to.be.in.viewportand the.array/to.have.countexpressions; everything else falls through tomatches = options.isNot; missingReceived = true.Frame.expectthen treatsmatches === options.isNotas "keep polling", so the assertion runs to its timeout.FAILED after 3019ms — Error: element(s) not found. The control cases behaved as expected (present node with differing text passed in 12 ms; present node with matching text failed).So no
toHaveCountpairing was added,expectConsequenceis unchanged apart from the comment, andcalendar.previous-monthproves its own move rather than leaning oncalendar.next-monthone row later. The finding is recorded on thetextChangedFromkind, at theexpectConsequencecase, and in the assertion-8 docblock.5. All four mount reads parked. The calendar, metrics, notifications and home mount reads each armed a
page.waitForResponsebeforepage.gotoand awaited it only on the following line, so agotothat threw left the wait unawaited and its 30 s rejection landed on the worker with no test to own it. NewreadOnMounthelper parks the outcome the moment the wait is armed — the patternactivatealready uses for row waits — and all four go through it. Success-path semantics are identical: armed before, awaited after, asserted 2xx.Test plan
All commands from
frontend/taskdeck-webunless noted.npx vitest --run --maxWorkers=2 src/tests/guards/routeAffordanceCoverage.spec.tsnpm run typecheck(vue-tsc -b)npx eslinton the three changed filesgit diff --checknpx playwright test tests/e2e/route-affordances.spec.ts --reporter=linewebServer; both ports confirmed free first). Run with the fourLlm__Gemini__*vars unset, which this repo rejects by design.The E2E spec is covered by no tsconfig project —
tsconfig.app.jsonandtsconfig.vitest.jsonboth scope tosrc/, which is what its own header says and why itsexpectConsequenceswitch throws ondefaultrather than relying onnever. Since this PR changes executable code there (readOnMount, thetype Responseimport, four call sites), it was additionally checked out-of-band withnpx tsc --noEmit --ignoreConfig --strict --skipLibCheck --target ES2022 --module esnext --moduleResolution bundler --types node tests/e2e/route-affordances.spec.ts— clean. That is a best-effort check, not a repo gate.The inventory module is type-checked, as a dependency of the guard spec under
tsconfig.vitest.json.Red-first
Item 5 is the only behavioural change, so it was proved defective before it was fixed. A scratch probe (kept outside the tree) drove both shapes against
page.gotoon a refusing port, withprocess.on('unhandledRejection')recording leaks and a 4 s tail so a leaked rejection had time to surface:Both shapes report the same navigation error; only the shipped one trails an orphan rejection.
Item 4 needed no red-first: the hypothesis was refuted, so no code changed.
Not verified
resolvePlaywrightBackendLlmEnvforcesLlm__Provider=Mockunless a demo/live-LLM flag is set; none was) on chromium at 2 local workers, not CI's single-worker shared-budget lane.onMounted, not observed on the wire. They restate at row level what the spec header already asserted for those rows; a request raised from somewhere other thanonMounted(a watcher firing on first paint, say) would not be caught by that reading. The rows' own E2E assertions are what would fail if the claim were wrong.assertEnabledbranch, so the throw remains unexercised at runtime. That is the documented, deliberate outcome of item 3, not an oversight — see the Summary.Boundaries
Only the three files PR #2673 created are touched:
frontend/taskdeck-web/tests/e2e/route-affordances.spec.tsfrontend/taskdeck-web/tests/e2e/support/routeAffordanceInventory.tsfrontend/taskdeck-web/src/tests/guards/routeAffordanceCoverage.spec.tsNot touched: any product code under
src/(a doc-precision PR must not change behaviour), the other E2E specs,playwright.config.ts, anddocs/**.docs/STATUS.mdandOUTSTANDING_TASKS.mdare deliberately untouched — the coordinator records this PR.The issue's informational note stays as recorded and was not acted on: a
postCondition: { kind: 'response' }on any row passes typecheck and the vitest guard and throws only inside E2E Smoke, viaexpectConsequence'sresponsecase. That is the documented design and the written-down limit of the cheap gate.No scratch artifacts were left in the tree; the two probes live under the session scratchpad (
.../scratchpad/worker-2682/) and are not needed to reproduce anything — the code references in item 4 are enough.Round 2 (head
4b1a20003)Review verdict was SHIP with three findings against the new prose — in a doc-precision PR that is the defect class, so all three are fixed in one wording-only commit. No executable change and no timeout bound added.
The "30 s" claim was false, in both the header and
readOnMount. These calls pass notimeoutand this config supplies no default, so the wait has no event timeout of its own. Verified in the installed tree:playwright/lib/index.js:259defaults theactionTimeoutfixture to0;playwright.config.tsnever setsactionTimeout/navigationTimeout(onlytimeout: 45_000at :111 andexpect.timeout: 8_000at :113) and nothing undertests/callssetDefaultTimeout;playwright/lib/index.js:349assigns_defaultContextTimeout = actionTimeout || 0;coreBundle.js:57160-57169returns that0rather than falling through to the library default (0 !== void 0, so the chain stops); andcoreBundle.js:58412arms a timer onlyif (timeout), which0is not.Page.waitForResponsereaches that path viacoreBundle.js:61252-61260→_waitForEventat:61268-61275. So a mount read whose response never arrives hangs to the test timeout — 90 s in the board-seeded walk, 45 s in the Home/Inbox block — and reports "Test timeout exceeded". Both sentences now say that. Parking still earns its place for a different reason, also now stated: an unawaited wait is rejected at page close with aTargetClosedError(coreBundle.js:61278-61279and:61265-61267), by which time no test is left to own it. The text also records thatactivatecaps its row waits at{ timeout: 15_000 }while these mount reads take no cap, and that bounding them is tracked on [Testing][E2E] Route-affordance walk: doc precision and dead-branch follow-ups from the #2679 review #2682.Note on the round-1 red-first probe above: it passed its own explicit
{ timeout: 2000 }to make the leak observable in seconds. That is what produced theTimeout 2000ms exceededrejection in the transcript. It demonstrates the parking mechanism correctly, but it is not the spec's own bound — under this config the unparked failure mode is hang-to-test-timeout, orTargetClosedErrorat page close."
{boardId}… and nowhere else" was false. It also appears in theworkspace-boardentry'sentryPath, which this same PR documents as never substituted. The census now names that occurrence and points at theentryPathdocblock.The verbatim-usage list was incomplete. It omitted
value.selector,text.selectorandtextChangedFrom.selector— all used verbatim byexpectConsequence. Added, and the list is now labelled complete rather than illustrative, with the distinction made explicit: those kinds fill theirvalue/text/frompayload but not the node they read it from.Proof at
4b1a20003:npx vitest --run --maxWorkers=2 src/tests/guards/routeAffordanceCoverage.spec.ts→ Test Files 1 passed (1), Tests 9 passed (9);npm run typecheckclean;npx eslinton the changed files clean (exit 0);git diff --checkclean.No E2E rerun is owed for this round — the commit changes only comment text, so the 4-passing-blocks run recorded above still stands at this head.