e2e/navigation-feedback.spec.ts
Linked repository file from this build.
import { expect, test, type Page } from "@playwright/test";
import { resetDemoFixture } from "./demo-fixture";
import { createDemoSeed } from "../src/programs/snap-negative/seed";
import { demoActor } from "../src/lib/auth/roster";
import { execute } from "../src/features/reviews/commands";
import { pendingCommandSchema } from "../src/features/reviews/command-schema";
async function reviewFixture(page: Page, failSave = false) {
await resetDemoFixture();
const actor = demoActor("examiner");
let stored = {
review: createDemoSeed(),
version: 1,
savedAt: new Date().toISOString(),
requestId: "navigation-fixture",
actor,
};
let release!: () => void;
const saveGate = new Promise<void>((resolve) => {
release = resolve;
});
let saving = false;
await page.route("**/api/reviews/aa1635c0-c99d-5f12-8f26-54d3f23bf13d", async (route) => {
if (route.request().method() === "POST") {
saving = true;
await saveGate;
if (failSave)
return route.fulfill({ status: 503, json: { error: "Save unavailable. Try again." } });
const body = route.request().postDataJSON();
for (const raw of body.commands) {
const entry = pendingCommandSchema.parse(raw);
stored.review = execute(stored.review, entry.command, actor, {
id: entry.id,
at: new Date().toISOString(),
});
}
stored = {
...stored,
version: stored.version + 1,
savedAt: new Date().toISOString(),
requestId: body.requestId,
};
}
await route.fulfill({ json: stored });
});
await page.goto("/reviews/aa1635c0-c99d-5f12-8f26-54d3f23bf13d");
await expect(page.getByRole("tab", { name: "Computation", exact: true })).toBeVisible();
return { release, isSaving: () => saving, savedName: () => stored.review.clientName };
}
test("navigation shows a skeleton immediately while saving, then keeps it through slow Home data", async ({
page,
}) => {
const fixture = await reviewFixture(page);
let releaseHome!: () => void;
const homeGate = new Promise<void>((resolve) => {
releaseHome = resolve;
});
await page.route(/\/dashboard\?_rsc=/, async (route) => {
await homeGate;
await route.continue();
});
await page.locator("#client-name").fill("Saved before navigation");
await page
.getByRole("navigation", { name: "Main navigation" })
.getByRole("link", { name: "Home", exact: true })
.click();
await expect(page.locator(".app-main > .page-transition-loading > .page-skeleton")).toBeVisible({
timeout: 500,
});
await expect.poll(fixture.isSaving).toBe(true);
await expect(page).toHaveURL(/\/reviews\/aa1635c0-c99d-5f12-8f26-54d3f23bf13d$/);
fixture.release();
await expect(page.locator(".page-skeleton:visible").first()).toBeVisible();
releaseHome();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("heading", { name: "Home", exact: true })).toBeVisible();
expect(fixture.savedName()).toBe("Saved before navigation");
await expect(page.locator(".page-skeleton:visible")).toHaveCount(0);
});
test("failed navigation restores the editor and its unsaved computation draft", async ({
page,
}) => {
const fixture = await reviewFixture(page, true);
await page.getByRole("tab", { name: "Computation", exact: true }).click();
const size = page.locator('input[id$="-compI-hh"]');
await size.fill("4");
await size.blur();
await page.locator("#client-name").fill("Keep this edit");
await page
.getByRole("navigation", { name: "Main navigation" })
.getByRole("link", { name: "Home", exact: true })
.click();
await expect(page.locator(".app-main > .page-transition-loading > .page-skeleton")).toBeVisible({
timeout: 500,
});
fixture.release();
await expect(page.getByRole("alert").filter({ hasText: "Save unavailable" })).toBeVisible();
await expect(page).toHaveURL(/\/reviews\/aa1635c0-c99d-5f12-8f26-54d3f23bf13d$/);
await expect(page.locator("#client-name")).toHaveValue("Keep this edit");
await expect(size).toHaveValue("4");
await expect(page.getByRole("button", { name: "Save Comp I column", exact: true })).toBeEnabled();
await expect(page.locator(".app-main > .page-transition-loading > .page-skeleton")).toHaveCount(
0,
);
});
test("review tabs react on the next frame, resize at the selected duration and retain drafts", async ({
page,
}) => {
const fixture = await reviewFixture(page);
fixture.release();
await page.setViewportSize({ width: 1920, height: 1000 });
const workspace = page.locator(".review-workspace");
await expect(workspace).toHaveCSS("transition-duration", "0.15s");
const compact = (await workspace.boundingBox())!.width;
await page.evaluate(() => {
const tab = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(
(element) => element.textContent === "Computation",
)!;
tab.addEventListener(
"mousedown",
() => {
const started = performance.now();
requestAnimationFrame(() => {
(window as Window & { tabFeedback?: unknown }).tabFeedback = {
selected: tab.getAttribute("aria-selected"),
elapsed: performance.now() - started,
};
});
},
{ once: true },
);
});
await page.getByRole("tab", { name: "Computation", exact: true }).click();
const timing = await (
await page.waitForFunction(
() =>
(window as Window & { tabFeedback?: { selected: string; elapsed: number } }).tabFeedback,
)
).jsonValue();
if (!timing) throw new Error("Tab feedback was not recorded.");
expect(timing.selected).toBe("true");
expect(timing.elapsed).toBeLessThan(150);
await expect.poll(async () => (await workspace.boundingBox())!.width).toBeGreaterThan(compact);
const size = page.locator('input[id$="-compI-hh"]');
await size.fill("4");
await size.blur();
for (const tab of ["Household", "Computation", "Activity", "Computation"])
await page.getByRole("tab", { name: tab, exact: true }).click();
await expect(size).toHaveValue("4");
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(workspace).toHaveCSS("transition-duration", "0s");
await page.getByRole("tab", { name: "Household", exact: true }).click();
await expect(page.locator('[data-slot="tabs-content"][data-state="active"]')).toHaveCSS(
"animation-name",
"none",
);
});
async function targetReview(page: Page) {
// The destination is deliberately outside examiner 101's assignment; SSR enforces access
// before browser route mocks run, so use an all-scope actor for the destination request.
await page
.context()
.addCookies([{ name: "qc-test-staff", value: "301", domain: "localhost", path: "/" }]);
const review = createDemoSeed();
review.reviewNumber = "60009";
review.id = "28ff0dd7-7da0-5ebf-a94a-7d65aff71128";
await page.route("**/api/reviews/28ff0dd7-7da0-5ebf-a94a-7d65aff71128", (route) =>
route.fulfill({
json: {
review,
version: 1,
savedAt: new Date().toISOString(),
requestId: "target-fixture",
actor: demoActor("examiner"),
},
}),
);
}
test("notifications show route feedback before their read request completes", async ({ page }) => {
await reviewFixture(page);
await targetReview(page);
await page.route("**/api/inbox/events", (route) =>
route.fulfill({
json: {
unreadCount: 1,
events: [
{
id: "navigation-notification",
kind: "distributed",
reviewNumber: "60009",
reviewKey: "28ff0dd7-7da0-5ebf-a94a-7d65aff71128",
fiscalYear: 2026,
createdAt: new Date().toISOString(),
readAt: null,
},
],
},
}),
);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/api/inbox/events/navigation-notification/read", async (route) => {
await gate;
await route.fulfill({ json: { ok: true } });
});
await page.reload();
await page.getByRole("button", { name: "Notifications, 1 unread", exact: true }).click();
await page
.locator(".header-popover-panel")
.getByRole("button")
.filter({ hasText: "60009" })
.click();
await expect(page.locator(".app-main > .page-transition-loading > .page-skeleton")).toBeVisible({
timeout: 500,
});
await expect(page).toHaveURL(/\/reviews\/aa1635c0-c99d-5f12-8f26-54d3f23bf13d$/);
release();
await expect(page).toHaveURL(/\/reviews\/28ff0dd7-7da0-5ebf-a94a-7d65aff71128$/);
await expect(page.getByRole("heading", { name: "60009", exact: true })).toBeVisible();
});
test("quick search shows route feedback while loading full results", async ({ page }) => {
await reviewFixture(page);
await targetReview(page);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route(
(url) => url.pathname === "/search" && url.searchParams.has("_rsc"),
async (route) => {
await gate;
await route.continue();
},
);
const search = page.getByRole("textbox", { name: "Search reviews", exact: true });
await search.fill("60009");
await search.press("Enter");
await expect(page.locator(".app-main > .page-transition-loading > .page-skeleton")).toBeVisible({
timeout: 500,
});
await expect(page).toHaveURL(/\/reviews\/aa1635c0-c99d-5f12-8f26-54d3f23bf13d$/);
release();
await expect(page).toHaveURL(/\/search\?.*60009/);
await expect(
page.locator('main a[href="/reviews/28ff0dd7-7da0-5ebf-a94a-7d65aff71128"]').first(),
).toBeVisible();
});
test("computation to review responds promptly on a slower CPU, including the first visit", async ({
page,
}) => {
const fixture = await reviewFixture(page);
fixture.release();
await page.setViewportSize({ width: 1920, height: 1000 });
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
for (let visit = 0; visit < 3; visit++) {
await page.getByRole("tab", { name: "Computation", exact: true }).click();
await page.waitForTimeout(150);
await page.evaluate(() => {
const tab = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(
(el) => el.textContent === "Review",
)!;
(window as Window & { reviewTiming?: unknown }).reviewTiming = undefined;
tab.addEventListener(
"mousedown",
() => {
const start = performance.now();
requestAnimationFrame(() => {
(window as Window & { reviewTiming?: unknown }).reviewTiming = {
elapsed: performance.now() - start,
selected: tab.getAttribute("aria-selected"),
};
});
},
{ once: true },
);
});
await page.getByRole("tab", { name: "Review", exact: true }).click();
const timing = await (
await page.waitForFunction(
() =>
(window as Window & { reviewTiming?: { elapsed: number; selected: string } })
.reviewTiming,
)
).jsonValue();
expect(timing!.selected).toBe("true");
expect(timing!.elapsed).toBeLessThan(150);
await expect(page.locator("#telephone")).toBeVisible();
}
});
for (const width of [1280, 1920]) {
test(`server-rendered review geometry stays stable at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 });
let reviewGets = 0;
page.on("request", (request) => {
if (
request.method() === "GET" &&
request.url().endsWith("/api/reviews/aa1635c0-c99d-5f12-8f26-54d3f23bf13d")
)
reviewGets++;
});
await page.goto("/reviews/aa1635c0-c99d-5f12-8f26-54d3f23bf13d");
await expect(page.getByRole("tab", { name: "Household", exact: true })).toBeVisible();
const frame = await page.locator(".review-workspace").boundingBox();
const session = await page.locator(".workspace-session").boundingBox();
const body = await page.locator(".workspace-tab-body").boundingBox();
await page.reload();
await expect(page.getByRole("tab", { name: "Household", exact: true })).toBeVisible();
for (const [selector, before] of [
[".review-workspace", frame],
[".workspace-session", session],
[".workspace-tab-body", body],
] as const) {
const after = await page.locator(selector).boundingBox();
for (const dimension of ["x", "y", "width", "height"] as const)
expect(
Math.abs(after![dimension] - before![dimension]),
`${selector} ${dimension}`,
).toBeLessThanOrEqual(1);
}
expect(reviewGets).toBe(0);
});
}
test("review dropdowns retain selected labels and support keyboard selection across tabs", async ({
page,
}) => {
const fixture = await reviewFixture(page);
fixture.release();
await page.getByRole("tab", { name: "Review", exact: true }).click();
const action = page.locator("#actionType");
await action.focus();
await page.keyboard.press("2");
await expect(action).toContainText("2 —");
await expect(page.getByRole("listbox")).toHaveCount(0);
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("listbox")).toBeVisible();
await page.keyboard.press("Home");
await page.keyboard.press("Enter");
const selection = await action.innerText();
expect(selection).toContain("—");
await page.getByRole("tab", { name: "Computation", exact: true }).click();
await page.getByRole("tab", { name: "Review", exact: true }).click();
await expect(action).toHaveText(selection);
await action.click();
await expect(page.getByRole("option", { selected: true })).toHaveText(selection);
await page.keyboard.press("Escape");
await expect(action).toBeFocused();
});
test("computation stops growing at its minimum table width", async ({ page }) => {
const fixture = await reviewFixture(page);
fixture.release();
await page.setViewportSize({ width: 1920, height: 1000 });
await page.getByRole("tab", { name: "Computation", exact: true }).click();
await page.waitForTimeout(150);
const table = page.getByRole("table", { name: "SNAP computation comparison grid" });
const before = await table.boundingBox();
const workspace = await page.locator(".review-workspace").boundingBox();
expect(before!.width).toBeGreaterThan(0);
expect(before!.width).toBeLessThan(workspace!.width);
expect(
await table.evaluate((el) => el.parentElement!.scrollWidth - el.parentElement!.clientWidth),
).toBeLessThanOrEqual(1);
await page.setViewportSize({ width: 2560, height: 1200 });
await expect.poll(async () => (await table.boundingBox())!.width).toBe(before!.width);
await expect
.poll(async () => (await page.locator(".review-workspace").boundingBox())!.width)
.toBe(workspace!.width);
});
for (const width of [768, 1440]) {
test(`reports loading uses the real layout at ${width}px`, async ({ page }) => {
await page
.context()
.addCookies([{ name: "qc-test-staff", value: "401", domain: "localhost", path: "/" }]);
await page.setViewportSize({ width, height: 1000 });
await page.goto("/reports");
// The page opens on the current FFY, which has no seeded reviews early in the year.
// Empty charts are shorter than the skeleton, so pin a seeded year first.
await page.getByLabel("From sample month", { exact: true }).fill("2025-10");
await page.getByLabel("Through sample month", { exact: true }).fill("2026-09");
await expect(page.locator('.report-view[aria-busy="false"]')).toBeVisible();
await expect(
page.getByRole("region", { name: "Sample month progress", exact: true }).getByRole("button", {
name: /^Filter to/,
}),
).toHaveCount(12);
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/api/reports?*", async (route) => {
await gate;
await route.continue();
});
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expect(page.locator('.report-view[aria-busy="true"]')).toBeVisible();
const selectors = [
'[aria-label="Report filters"]',
'.report-view section:has(h2:text-is("Reviews in scope"))',
'[aria-labelledby="erc-heading"]',
'[aria-labelledby="erc-errors-heading"]',
'[aria-label="Workflow state progress"]',
'[aria-label="County progress"]',
];
const before = await Promise.all(
selectors.map((selector) => page.locator(selector).boundingBox()),
);
release();
await expect(page.locator('.report-view[aria-busy="false"]')).toBeVisible();
for (let index = 0; index < selectors.length; index++) {
const after = await page.locator(selectors[index]).boundingBox();
for (const dimension of ["x", "y", "width", "height"] as const)
expect(
Math.abs(after![dimension] - before[index]![dimension]),
`${selectors[index]} ${dimension}`,
).toBeLessThanOrEqual(1);
}
await expect(page.getByRole("button", { name: "Run report", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Run report", exact: true }).click();
await expect(page.getByRole("table", { name: "Report runs", exact: true })).toBeVisible();
});
}
test("section resets preserve other fields and broad controls stay fixed while scrolling", async ({
page,
}) => {
const fixture = await reviewFixture(page);
fixture.release();
await page.getByRole("tab", { name: "Review", exact: true }).click();
const telephone = page.locator("#telephone");
const originalTelephone = await telephone.inputValue();
await telephone.fill("7175550123");
const action = page.locator("#actionType");
const originalAction = await action.innerText();
await action.focus();
await page.keyboard.press("1");
await expect(action).toContainText("1 —");
await page
.getByRole("button", { name: "Reset identifying information (ii)", exact: true })
.click();
await expect(action).toHaveText(originalAction);
await expect(telephone).toHaveValue("7175550123");
const controls = page.getByRole("button", { name: "Clear all", exact: true }).locator("..");
const before = await controls.boundingBox();
await page.locator("[data-review-sections]").evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
expect(
await page.locator("[data-review-sections]").evaluate((el) => el.scrollTop),
).toBeGreaterThan(0);
expect(await controls.boundingBox()).toEqual(before);
await expect(page.getByRole("button", { name: "Clear all", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Reset case management (i)", exact: true }).click();
await expect(telephone).toHaveValue(originalTelephone);
});
test("Return restores search filters changed on the same page", async ({ page }) => {
await resetDemoFixture();
await page.goto("/search");
const search = page.getByRole("textbox", { name: "Search reviews", exact: true });
await search.fill("60001");
await search.press("Enter");
await expect(page).toHaveURL(/\/search\?.*60001/);
const source = page.url();
await page
.locator('main a[href="/reviews/aa1635c0-c99d-5f12-8f26-54d3f23bf13d"]')
.first()
.click();
await expect(page).toHaveURL(/\/reviews\/aa1635c0-c99d-5f12-8f26-54d3f23bf13d$/);
await page.getByRole("link", { name: "Return", exact: true }).click();
await expect(page).toHaveURL(source);
});