Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ function assertGuardedRowsAreAssertableWithoutActivation(inventory: RouteEntry[]
)
}

// DEAD AT RUNTIME, AND THE TYPE IS WHY. `AffordanceStatus`'s
// `guarded-not-activated` variant declares `assertEnabled: true` as a
// REQUIRED LITERAL, so a row that reaches this line has already been
// proved to satisfy it by `npm run typecheck`; this `!== true` can never
// be true for a real inventory row. That is the difference from the
// consequence check above, whose bad shape the type DOES permit (a
// `response` consequence on a guarded row type-checks — assertion 9
// builds exactly that row with no cast) and which therefore earns a
// canary. This branch has none because there is no cast-free way to
// reach it. It is kept as belt-and-braces: it is the check that starts
// doing work the moment the field is widened to `boolean`.
if (status.assertEnabled !== true) {
throw new Error(
`guarded row '${affordance.id}' (${entry.routeName}) must set assertEnabled: true. `
Expand Down Expand Up @@ -274,9 +285,30 @@ describe('route affordance inventory coverage (GH-1949 AC4)', () => {
* The walk asserts `guarded-not-activated` rows and never clicks them, so a
* `response` consequence copied onto one from an activated sibling would
* assert NOTHING and still count as walked — the row would be reported as
* covered while proving only that the control rendered. `assertEnabled` is
* likewise load-bearing: the walk reads it to decide whether the declined
* control has to be a live choice.
* covered while proving only that the control rendered.
*
* THE TWO HALVES ARE NOT ENFORCED THE SAME WAY. The consequence half is a
* live check: a `response` consequence on a guarded row type-checks, so the
* shape it rejects is one someone can actually write, which is why assertion
* 9 below carries a canary for it. The `assertEnabled` half is NOT: the
* guarded status variant declares `assertEnabled: true` as a required
* literal, so `npm run typecheck` has already rejected every row this check
* could catch, and its `!== true` throw cannot fire for a real row. Both the
* checker here and the walk's `if (affordance.status.assertEnabled)` in
* `tests/e2e/route-affordances.spec.ts` are kept as belt-and-braces, and
* each says so at its own site. Neither describes a per-row choice — the
* type forecloses the choice. Widening the field to `boolean` is what would
* make them live, and would then owe this assertion a canary of its own.
*
* ELEMENT CONSEQUENCES ARE SAFE TO OFFER A GUARDED ROW, INCLUDING THE
* NEGATED ONE. The permitted list below includes `textChangedFrom`, whose
* assertion is `not.toHaveText`. A negated matcher would be worthless here
* if it passed on a selector that matched nothing, but Playwright fails a
* zero-element `not.toHaveText` with `element(s) not found` rather than
* passing it — measured on 1.62.1 and recorded in full on the
* `textChangedFrom` kind in `tests/e2e/support/routeAffordanceInventory.ts`.
* So every kind classified `element` really does assert something about a
* node that exists.
*/
it('gives every guarded-not-activated row an element consequence and assertEnabled', () => {
const guardedRows = ROUTE_AFFORDANCE_INVENTORY.flatMap((entry) =>
Expand Down
117 changes: 104 additions & 13 deletions frontend/taskdeck-web/tests/e2e/route-affordances.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@
* A `response` row's wait is armed BEFORE the act and its rejection is parked
* immediately (see `activate`), so a control that never becomes actionable
* reports its own actionability failure rather than an unhandled rejection.
* The mount reads those routes consume first are armed before `page.goto` for
* the same reason and parked the same way (see `readOnMount`), so a `goto` that
* throws reports its own failure instead of trailing a `waitForResponse`
* settlement with no test left to own it. Note the asymmetry in what BOUNDS the
* two: `activate` passes `{ timeout: 15_000 }`, while the mount reads pass no
* timeout and this config gives them none, so they are bounded only by the test
* timeout. `readOnMount` explains that; bounding them is tracked on #2682.
*
* COMPLETENESS. Every block ends with `assertBlockCompleted`, which proves the
* declared `WALK_PLAN` still names exactly the inventory's `activate: true` and
Expand All @@ -69,7 +76,7 @@
* run summary for four passing blocks is what closes that.
*/

import { expect, test, type Locator, type Page } from '@playwright/test'
import { expect, test, type Locator, type Page, type Response } from '@playwright/test'
import { registerAndAttachSession, type AuthResult } from './support/authSession'
import { createBoardWithColumn } from './support/boardHelpers'
import {
Expand Down Expand Up @@ -345,6 +352,18 @@ async function expectConsequence(
return

case 'textChangedFrom':
// THE ONLY NEGATED MATCHER IN THIS SWITCH, AND IT IS NOT VACUOUS ON A
// MISSING NODE. `expect(locator).not.toHaveText(x)` FAILS when the
// locator resolves to zero elements — it does not quietly pass the way
// `not.toBeVisible()` does. Playwright special-cases only
// visible/hidden/attached/detached/in-viewport and the array expressions
// for a missing node; `to.have.text` falls through to "not satisfied
// yet", polls to the timeout and reports `element(s) not found`.
// Confirmed against Playwright 1.62.1; the code references and the
// chromium probe are recorded on the `textChangedFrom` kind in
// `support/routeAffordanceInventory.ts`. So this needs no `toHaveCount`
// companion, and `calendar.previous-month` proves its own move rather
// than leaning on `calendar.next-month` one row later.
await expect(
scope.locator(consequence.selector).first(),
`${id} must move ${consequence.selector} off '${consequence.from}'`,
Expand Down Expand Up @@ -460,6 +479,16 @@ async function assertReachableButNotActivated(
const target = locate(page, affordance.selector, context)
await expect(target, `${id} (${affordance.source}) must be present`).toBeVisible()
await expectConsequence(page, page, id, affordance.consequence, context)
// THE TYPE IS THE ENFORCEMENT; THIS READ CANNOT TAKE ITS FALSE BRANCH.
// `AffordanceStatus`'s guarded variant declares `assertEnabled: true` as a
// REQUIRED LITERAL, so no row reaching here can have it false or absent and
// the `else` is unreachable at runtime. It is kept as belt-and-braces: it
// documents at the point of use that the enabled check is conditional on the
// flag, and it is what starts working if the field is ever widened to
// `boolean`. Read it as "the type already guaranteed this", not as a choice
// rows make. The guard's matching read (assertion 8 in
// `src/tests/guards/routeAffordanceCoverage.spec.ts`) is dead for the same
// reason and says so there.
if (affordance.status.assertEnabled) {
await expect(
target,
Expand All @@ -469,6 +498,64 @@ async function assertReachableButNotActivated(
guardedIds.add(id)
}

/**
* Navigate to a route and consume the read it issues on MOUNT.
*
* WHY THE MOUNT READ IS CONSUMED AT ALL. Four routes here (calendar, metrics,
* notifications, home) fetch on mount, and three of them own `response` rows
* waiting on the SAME endpoint. A wait armed after navigation could be settled
* by the mount's own request, so a dead control would still pass. Awaiting the
* mount read here takes it off the wire before any row arms its own wait.
*
* WHY THE OUTCOME IS PARKED. The wait must be armed BEFORE `page.goto` — that
* is the whole point, the response can arrive during the navigation — but a
* `goto` that throws would then leave the `waitForResponse` promise with
* nothing awaiting it. Parking the outcome the moment it is armed, exactly as
* `activate` does for row waits, keeps the navigation's own error first while
* still failing on a mount read that never arrives. The success path is
* unchanged: armed before, awaited after, asserted 2xx.
*
* WHAT AN UNPARKED WAIT WOULD ACTUALLY DO HERE — NOT TIME OUT. These calls pass
* no `timeout`, and under this repo's config nothing supplies a default one, so
* the wait has NO event timeout of its own. The chain, read in the installed
* tree: `playwright/lib/index.js:259` defaults the `actionTimeout` fixture to
* `0` and `playwright.config.ts` never sets it (it sets only `timeout: 45_000`
* and `expect.timeout: 8_000`, and nothing under `tests/` calls
* `setDefaultTimeout`); `playwright/lib/index.js:349` then assigns
* `_defaultContextTimeout = actionTimeout || 0`; `coreBundle.js:57160-57169`
* returns that `0` rather than falling through to Playwright's own default;
* and `coreBundle.js:58412` arms a timer only `if (timeout)`, which `0` is not.
* So a mount read whose response never arrives does not reject after 30 s — it
* HANGS until the test timeout (90 s in the board-seeded walk, 45 s in the
* Home/Inbox block) 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` and `:61265-61267`), and by
* then the test that would have owned it is over.
*
* NOT BOUNDED LIKE `activate`. `activate` caps its row waits at
* `{ timeout: 15_000 }` precisely so a dead control does not burn the shared
* E2E Smoke budget; these mount reads take no such cap and are left to the test
* timeout. That asymmetry is deliberate for now, not an oversight — giving them
* their own bound is a behavioural change and is tracked on #2682.
*/
async function readOnMount(
page: Page,
label: string,
matches: (response: Response) => boolean,
navigate: () => Promise<unknown>,
): Promise<void> {
const settledRead = page.waitForResponse(matches).then(
(response) => ({ settled: 'fulfilled' as const, response }),
(reason: unknown) => ({ settled: 'rejected' as const, reason }),
)
await navigate()
const outcome = await settledRead
if (outcome.settled === 'rejected') {
throw outcome.reason instanceof Error ? outcome.reason : new Error(String(outcome.reason))
}
await assertOk(outcome.response, label)
}

function uniqueSeed(): string {
return `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`
}
Expand Down Expand Up @@ -539,12 +626,13 @@ test('walks the board-seeded route affordances', async ({ page, request }) => {
// treatment as metrics and notifications: consume the mount read first. The
// month label is then the independent half, declared as a postCondition on
// both rows rather than asserted ad hoc here.
const mountCalendarRead = page.waitForResponse(
await readOnMount(
page,
'calendar read issued on mount',
(response) =>
response.request().method() === 'GET' && /\/api\/workspace\/calendar/.test(response.url()),
() => page.goto('/workspace/calendar'),
)
await page.goto('/workspace/calendar')
await assertOk(await mountCalendarRead, 'calendar read issued on mount')

const monthLabel = page.locator('.paper-calendar__month-label')
await expect(monthLabel).toBeVisible()
Expand All @@ -563,13 +651,14 @@ test('walks the board-seeded route affordances', async ({ page, request }) => {
// mount read and a dead select would still pass. Two defences: consume the
// mount read here, and pin the rows to the OTHER of the two boards this block
// seeded, so the mount's URL can never match them.
const mountMetricsRead = page.waitForResponse(
await readOnMount(
page,
'metrics read issued on mount',
(response) =>
response.request().method() === 'GET'
&& /\/api\/metrics\/boards\/[a-f0-9-]+\?from=/.test(response.url()),
() => page.goto('/workspace/metrics'),
)
await page.goto('/workspace/metrics')
await assertOk(await mountMetricsRead, 'metrics read issued on mount')

const boardSelect = page.locator('#board-select')
await expect(boardSelect, 'the board select must settle on its auto-selection first')
Expand Down Expand Up @@ -606,12 +695,13 @@ test('walks the board-seeded route affordances', async ({ page, request }) => {
// it first: only a genuinely new request can settle the rows below. Neither
// row can carry a post-condition, because the filtered and unfiltered empty
// states render identical copy for a user with no notifications.
const mountNotificationsRead = page.waitForResponse(
await readOnMount(
page,
'notifications read issued on mount',
(response) =>
response.request().method() === 'GET' && /\/api\/notifications(\?|$)/.test(response.url()),
() => page.goto('/workspace/notifications'),
)
await page.goto('/workspace/notifications')
await assertOk(await mountNotificationsRead, 'notifications read issued on mount')

await expect(page.getByRole('button', { name: 'Refresh' })).toBeVisible()
await activate(page, 'notifications.refresh', context)
Expand All @@ -636,12 +726,13 @@ test('walks the Home quick capture and the Inbox triage affordances', async ({ p
const captureText = `Route walk boardless capture ${seed}`

// ── /workspace/home — await the summary read rather than sleeping ────────
const homeSummary = page.waitForResponse(
await readOnMount(
page,
'Home summary read on first paint',
(response) =>
response.request().method() === 'GET' && /\/api\/workspace\/home$/.test(response.url()),
() => page.goto('/workspace/home'),
)
await page.goto('/workspace/home')
await assertOk(await homeSummary, 'Home summary read on first paint')
await expect(page.getByTestId('paper-home')).toBeVisible()

// The Home quick capture posts `boardId: null`, so it seeds the boardless
Expand Down
Loading
Loading