From dbb484c9d9521f0192b516b74347e042b7052a69 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 12:15:14 -0700 Subject: [PATCH 1/8] docs(plan): close the three smoke-coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controlled checklist filtering, group collapse by child identity rather than row counts, and a surface where a server actually applies the query — the last of which has no test today because nothing on the site fetches rows per query. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-14-smoke-coverage-gaps.md | 619 ++++++++++++++++++ 1 file changed, 619 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-14-smoke-coverage-gaps.md diff --git a/docs/superpowers/plans/2026-08-14-smoke-coverage-gaps.md b/docs/superpowers/plans/2026-08-14-smoke-coverage-gaps.md new file mode 100644 index 00000000..3819b127 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-smoke-coverage-gaps.md @@ -0,0 +1,619 @@ +# Smoke Coverage Gaps Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the three gaps a manual Chrome smoke on 2026-08-14 could not verify — controlled filtering through a checklist funnel, group collapse/expand on a controlled grid, and sort/filter/group against row data fetched from a server per query. + +**Architecture:** Two of the three are e2e tests against surfaces that already exist (`/docs/grid/filtering`, `/docs/grid/grouping`). The third has no surface to test: nothing on the site fetches rows in response to `onQueryChange`, so Task 3 builds one — a Next route handler that owns sorting/filtering/grouping, plus a fixture page that renders `PretableSurface` in controlled mode and refetches on every query change. That fixture is the artifact the test drives, and it doubles as the reference for consumers wiring a real backend. + +**Tech Stack:** Next 16 App Router (route handlers, client components), `@pretable/react` controlled-query mode (`query` + `onQueryChange`), Playwright, existing `apps/website/e2e/helpers.ts`. + +--- + +## Background the engineer needs + +- **Two query modes.** Uncontrolled: the grid owns the query and applies it. Controlled (`query` + `onQueryChange` both passed): the consumer owns it, the grid reports intent and does **not** apply the transition itself — the consumer supplies the next `query` (and, in Task 3, the next rows). The internal note explaining this is `packages/react/src/pretable-model.ts` around the `notify-only` comment. +- **Never assert on rendered row counts to prove collapse.** The grid virtualizes: collapsing a 6-row group pulls 5 rows in from below, so `[data-pretable-row]` count moves by 1. That exact mistake produced a false "collapse does nothing" reading during the manual smoke. Assert `aria-expanded` on the group row plus the disappearance of a **named** child (`[data-pretable-row-id="…"]` → `toHaveCount(0)`), which is what `e2e/grouping.spec.ts:270-292` already does. +- **Funnels are opacity-0 until the header row is hovered.** Use the existing `openFilterMenu(page, "Status")` helper (`e2e/helpers.ts:92`); it hovers, clicks, and returns the dialog. +- **Fixture data, exact values:** + - `content/examples/column-filters/data.ts` — 7 orders; `status` ∈ {`open`, `shipped`, `cancelled`}; the example mounts with `status isAnyOf ["open"]` already applied. + - `content/examples/grouping-panel/data.ts` — 12 positions, ids `p1`…`p12`; `desk` ∈ {`Equities`, `Credit`, `Macro`}. +- **Fixture pages** live at `apps/website/app/fixtures//page.tsx` and are plain routes (see `app/fixtures/grouping/page.tsx`). They are not linked from the site. +- **Running the website e2e:** specs need a server. Production: `pnpm --filter @pretable/app-website exec next build` then `next start -p 3100`. For these tests a dev server is fine and faster: `pnpm --filter @pretable/app-website exec next dev -p 3100`. Then `BASE_URL=http://localhost:3100 pnpm --filter @pretable/app-website exec playwright test --project=chromium --workers=1`. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `apps/website/e2e/controlled-query.spec.ts` (create) | Tasks 1–2: checklist filtering and collapse/expand on the controlled docs examples | +| `apps/website/app/api/rows/route.ts` (create) | Task 3: the "server" — owns sort/filter/group, returns rows for a query | +| `apps/website/app/api/rows/dataset.ts` (create) | Task 3: the row set and the query application, importable by both route and test | +| `apps/website/app/fixtures/server-query/page.tsx` (create) | Task 3: fixture route shell | +| `apps/website/app/fixtures/server-query/ServerQueryGrid.tsx` (create) | Task 3: controlled `PretableSurface` that refetches per query change | +| `apps/website/e2e/server-query.spec.ts` (create) | Task 3: drives the fixture, asserts the round-trip and server-applied results | + +--- + +## Task 1: Controlled filtering through a checklist funnel + +The manual smoke opened the first funnel it found, which was a text filter, toggled nothing, and proved nothing. This drives the `Status` funnel specifically — an `enum` column with **no** `options`, so the checklist loads distinct values from the rows, which is the path most likely to break silently. + +**Files:** +- Create: `apps/website/e2e/controlled-query.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { expect, test } from "@playwright/test"; + +import { openFilterMenu, waitForGridReady } from "./helpers"; + +/** + * The controlled-query surfaces on the docs pages: `query` + `onQueryChange`, + * where the consumer owns the query and the grid reports intent rather than + * applying it itself. This is the shape a server integration uses, and a manual + * smoke on 2026-08-14 could not verify either flow here — the filter check + * opened a non-checklist funnel and toggled nothing, and the collapse check + * counted rendered rows, which virtualization makes meaningless. + */ +test("checklist funnel filters a controlled grid, and the page sees the query", async ({ + page, +}) => { + await page.goto("/docs/grid/filtering", { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + + // The example mounts with `status isAnyOf ["open"]` already applied, and + // echoes the live query beneath the grid — so the page itself tells us + // whether `onQueryChange` reached the consumer. + const echo = page.getByText(/Active filters:/); + await expect(echo).toContainText("status"); + const openRows = await page.locator("[data-pretable-row]").count(); + expect(openRows).toBeGreaterThan(0); + + // `status` is an enum column that declares no `options`, so this checklist is + // built from the rows' distinct values. All three must be offered. + const dialog = await openFilterMenu(page, "Status"); + for (const value of ["open", "shipped", "cancelled"]) { + await expect(dialog.getByRole("checkbox", { name: value })).toBeVisible(); + } + + // Add `shipped` to the selection: strictly more rows, and both values present. + await dialog.getByRole("checkbox", { name: "shipped" }).check(); + await page.keyboard.press("Escape"); + + await expect + .poll(() => page.locator("[data-pretable-row]").count()) + .toBeGreaterThan(openRows); + + const statuses = await page.$$eval( + '[data-pretable-row] [data-pretable-column-id="status"]', + (cells) => [...new Set(cells.map((cell) => cell.textContent?.trim()))], + ); + expect([...statuses].sort()).toEqual(["open", "shipped"]); +}); +``` + +- [ ] **Step 2: Run it and watch it fail for the right reason** + +Start a dev server in one terminal: + +```bash +pnpm --filter @pretable/app-website exec next dev -p 3100 +``` + +Then: + +```bash +BASE_URL=http://localhost:3100 pnpm --filter @pretable/app-website exec playwright test e2e/controlled-query.spec.ts --project=chromium --workers=1 +``` + +Expected: **PASS** if the feature works. This is a coverage task, not a bug fix — the test is the deliverable. If it fails, you have found a real defect: capture the failure, stop, and report it rather than weakening the assertion. The two failures to distinguish: +- `Filter Status` button not found → the funnel label differs from the column header; read the header text and fix the selector. +- checklist empty → distinct-value loading for an `options`-less enum is broken; that is a product bug worth its own issue. + +- [ ] **Step 3: Prove the assertion can fail** + +Temporarily change `["open", "shipped"]` to `["open"]` in the last assertion and re-run. Expected: FAIL with a diff showing `shipped` present. Restore it. + +This matters because the whole point is catching a filter that silently does nothing; an assertion that passes either way is worse than none. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/e2e/controlled-query.spec.ts +git commit -m "test(website): pin controlled checklist filtering on the docs grid" +``` + +--- + +## Task 2: Collapse and expand on a controlled grouped grid + +**Files:** +- Modify: `apps/website/e2e/controlled-query.spec.ts` (append) + +- [ ] **Step 1: Write the failing test** + +Append to `apps/website/e2e/controlled-query.spec.ts`: + +```ts +test("collapsing a group hides its children, and expanding brings them back", async ({ + page, +}) => { + await page.goto("/docs/grid/grouping", { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + + const firstGroup = page.locator("[data-pretable-group-row]").first(); + await expect(firstGroup).toBeVisible(); + await expect(firstGroup).toHaveAttribute("aria-expanded", "true"); + + // A NAMED child, not a row count. The grid virtualizes: collapsing a group + // pulls rows in from below, so `[data-pretable-row]` barely moves and a + // count-based assertion reports "collapse does nothing" — which is exactly + // what the 2026-08-14 manual smoke concluded, wrongly. + const childIds = await page.$$eval( + "[data-pretable-row]:not([data-pretable-group-row])", + (rows) => + rows + .map((row) => row.getAttribute("data-pretable-row-id")) + .filter((id): id is string => id !== null), + ); + expect(childIds.length).toBeGreaterThan(0); + const child = page.locator(`[data-pretable-row-id="${childIds[0]}"]`); + await expect(child).toHaveCount(1); + + await firstGroup.getByRole("button", { name: /^Collapse / }).click(); + await expect(firstGroup).toHaveAttribute("aria-expanded", "false"); + await expect(child).toHaveCount(0); + + await firstGroup.getByRole("button", { name: /^Expand / }).click(); + await expect(firstGroup).toHaveAttribute("aria-expanded", "true"); + await expect(child).toHaveCount(1); +}); +``` + +- [ ] **Step 2: Run it** + +```bash +BASE_URL=http://localhost:3100 pnpm --filter @pretable/app-website exec playwright test e2e/controlled-query.spec.ts --project=chromium --workers=1 +``` + +Expected: 2 passed. If the twisty button is not found, read the group row's markup with `await firstGroup.innerHTML()` and fix the selector — `grouping.spec.ts:1213` uses the same `/^Expand /` name and is the reference. + +- [ ] **Step 3: Prove the assertion can fail** + +Temporarily comment out the `Collapse` click. Expected: FAIL at `aria-expanded` still `"true"`. Restore. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/e2e/controlled-query.spec.ts +git commit -m "test(website): pin group collapse/expand by child identity, not row count" +``` + +--- + +## Task 3: A real server-fetching surface, and its test + +Nothing on the site fetches rows in response to a query, so "server-side row data" has never been exercised end to end. This builds the missing surface: the route handler applies sort, filter and grouping; the client sends the query and renders whatever comes back. + +### Task 3a: The dataset and the query application + +**Files:** +- Create: `apps/website/app/api/rows/dataset.ts` + +- [ ] **Step 1: Write the module** + +```ts +export interface ServerRow extends Record { + id: string; + region: string; + rep: string; + amount: number; +} + +/** Deliberately small and low-cardinality: three regions, four reps. */ +export const SERVER_ROWS: ServerRow[] = [ + { id: "s1", region: "East", rep: "Ada", amount: 120 }, + { id: "s2", region: "East", rep: "Brin", amount: 340 }, + { id: "s3", region: "East", rep: "Cyd", amount: 55 }, + { id: "s4", region: "North", rep: "Ada", amount: 900 }, + { id: "s5", region: "North", rep: "Dara", amount: 210 }, + { id: "s6", region: "West", rep: "Brin", amount: 75 }, + { id: "s7", region: "West", rep: "Cyd", amount: 480 }, + { id: "s8", region: "West", rep: "Dara", amount: 260 }, +]; + +export interface ServerQuery { + filters: { columnId: string; operator: string; value: unknown }[]; + sort: { columnId: string; direction: "asc" | "desc" }[]; + rowGroups: { columnId: string }[]; +} + +/** + * The "server". Applies the query itself — the grid is in controlled mode and + * applies nothing, so whatever this returns is what the user sees. That is the + * property the e2e leans on: if sorting silently happened client-side too, this + * function could return garbage and the screen would still look right. + */ +export function applyServerQuery( + rows: readonly ServerRow[], + query: ServerQuery, +): ServerRow[] { + let out = [...rows]; + + for (const filter of query.filters) { + const { columnId, operator, value } = filter; + if (operator === "isAnyOf" && Array.isArray(value)) { + out = out.filter((row) => value.includes(row[columnId])); + } else if (operator === "contains" && typeof value === "string") { + out = out.filter((row) => + String(row[columnId]).toLowerCase().includes(value.toLowerCase()), + ); + } else if (operator === "gte" && typeof value === "number") { + out = out.filter((row) => Number(row[columnId]) >= value); + } + } + + for (const entry of [...query.sort].reverse()) { + const { columnId, direction } = entry; + out.sort((left, right) => { + const a = left[columnId]; + const b = right[columnId]; + const cmp = + typeof a === "number" && typeof b === "number" + ? a - b + : String(a).localeCompare(String(b)); + return direction === "desc" ? -cmp : cmp; + }); + } + + // Grouping is expressed as ordering here: rows arrive already clustered by + // the group key, which is what a server that cannot send tree structure does. + for (const group of [...query.rowGroups].reverse()) { + out.sort((left, right) => + String(left[group.columnId]).localeCompare(String(right[group.columnId])), + ); + } + + return out; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/website/app/api/rows/dataset.ts +git commit -m "feat(website): a server-side dataset and query application for the fixture" +``` + +### Task 3b: The route handler + +**Files:** +- Create: `apps/website/app/api/rows/route.ts` + +- [ ] **Step 1: Write the handler** + +```ts +import { NextResponse } from "next/server"; + +import { applyServerQuery, SERVER_ROWS, type ServerQuery } from "./dataset"; + +/** + * Rows for a query. POST rather than GET so the query travels as JSON instead + * of a hand-rolled encoding, and so responses are never cached — the test + * asserts one request per query change and a cache hit would swallow them. + */ +export async function POST(request: Request): Promise { + const query = (await request.json()) as ServerQuery; + const rows = applyServerQuery(SERVER_ROWS, { + filters: query.filters ?? [], + sort: query.sort ?? [], + rowGroups: query.rowGroups ?? [], + }); + + return NextResponse.json( + { rows, total: rows.length }, + { headers: { "cache-control": "no-store" } }, + ); +} +``` + +- [ ] **Step 2: Verify the endpoint by hand** + +With a dev server running: + +```bash +curl -s -X POST http://localhost:3100/api/rows \ + -H 'content-type: application/json' \ + -d '{"filters":[],"sort":[{"columnId":"amount","direction":"desc"}],"rowGroups":[]}' \ + | head -c 200 +``` + +Expected: JSON whose first row is `s4` (amount 900). + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/app/api/rows/route.ts +git commit -m "feat(website): POST /api/rows returns rows for a query" +``` + +### Task 3c: The fixture page + +**Files:** +- Create: `apps/website/app/fixtures/server-query/ServerQueryGrid.tsx` +- Create: `apps/website/app/fixtures/server-query/page.tsx` + +- [ ] **Step 1: Write the client component** + +```tsx +"use client"; + +import { PretableSurface, type PretableColumn } from "@pretable/react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { ServerQuery, ServerRow } from "../../api/rows/dataset"; + +const columns: PretableColumn[] = [ + { id: "region", header: "Region", type: "enum", widthPx: 120 }, + { id: "rep", header: "Rep", widthPx: 120 }, + { id: "amount", header: "Amount", type: "number", widthPx: 120 }, +]; + +const EMPTY_QUERY: ServerQuery = { filters: [], sort: [], rowGroups: [] }; + +/** + * Controlled mode against a real endpoint: the grid reports the query the user + * asked for, this component fetches rows for it, and the grid renders what + * comes back. Nothing is sorted or filtered on the client — that is the point, + * and `data-fetch-count` is how the test proves the round-trip happened. + */ +export function ServerQueryGrid() { + const [query, setQuery] = useState(EMPTY_QUERY); + const [rows, setRows] = useState([]); + const [fetchCount, setFetchCount] = useState(0); + const generation = useRef(0); + + useEffect(() => { + const mine = ++generation.current; + void (async () => { + const response = await fetch("/api/rows", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(query), + }); + const payload = (await response.json()) as { rows: ServerRow[] }; + // A slower earlier request must not overwrite a newer answer. + if (mine !== generation.current) return; + setRows(payload.rows); + setFetchCount((count) => count + 1); + })(); + }, [query]); + + const handleQueryChange = useCallback((next: ServerQuery) => { + setQuery(next); + }, []); + + return ( +
+ + ariaLabel="Server query grid" + columns={columns} + getRowId={(row) => row.id} + onQueryChange={handleQueryChange as never} + query={query as never} + rows={rows} + viewportHeight={320} + /> +
+ ); +} +``` + +- [ ] **Step 2: Write the page** + +```tsx +import { ServerQueryGrid } from "./ServerQueryGrid"; + +export default function ServerQueryFixturePage() { + return ( +
+

Server query fixture

+ +
+ ); +} +``` + +- [ ] **Step 3: Look at it** + +Open http://localhost:3100/fixtures/server-query. Expected: 8 rows, three columns. Click the `Amount` header: the rows reorder and `data-fetch-count` increments — check in devtools with `document.querySelector('[data-testid=server-query-fixture]').dataset.fetchCount`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/app/fixtures/server-query +git commit -m "feat(website): a fixture that fetches rows per query change" +``` + +### Task 3d: The end-to-end test + +**Files:** +- Create: `apps/website/e2e/server-query.spec.ts` + +- [ ] **Step 1: Write the test** + +```ts +import { expect, test } from "@playwright/test"; + +import { waitForGridReady } from "./helpers"; + +/** + * Sort, filter and group against rows the SERVER produced. + * + * Every other e2e on this site hands the grid its rows and lets the engine + * apply the query. This one never does: the fixture is in controlled mode, so + * the grid applies nothing, and every row on screen came back from + * `POST /api/rows`. If the client quietly sorted too, these assertions would + * still pass — so the test also counts the requests, which is the only evidence + * that the round-trip is real. + */ +const FIXTURE = "/fixtures/server-query"; + +async function fetchCount(page: import("@playwright/test").Page) { + return Number( + await page.getAttribute("[data-testid=server-query-fixture]", "data-fetch-count"), + ); +} + +const amounts = async (page: import("@playwright/test").Page) => + ( + await page.$$eval('[data-pretable-row] [data-pretable-column-id="amount"]', (cells) => + cells.map((cell) => cell.textContent?.trim() ?? ""), + ) + ).map(Number); + +test("the server sorts", async ({ page }) => { + const requests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/api/rows")) requests.push(request.postData() ?? ""); + }); + + await page.goto(FIXTURE, { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + await expect.poll(() => page.locator("[data-pretable-row]").count()).toBe(8); + + const before = await fetchCount(page); + await page.locator('[data-pretable-header-cell][data-pretable-column-id="amount"]').click(); + + await expect.poll(() => fetchCount(page)).toBeGreaterThan(before); + await expect.poll(async () => (await amounts(page))[0]).toBe(55); + expect(requests.at(-1)).toContain('"columnId":"amount"'); + + // Descending on the second click, still server-applied. + await page.locator('[data-pretable-header-cell][data-pretable-column-id="amount"]').click(); + await expect.poll(async () => (await amounts(page))[0]).toBe(900); +}); + +test("the server filters", async ({ page }) => { + await page.goto(FIXTURE, { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + await expect.poll(() => page.locator("[data-pretable-row]").count()).toBe(8); + + await page.locator("[data-pretable-header-row]").first().hover(); + await page.getByRole("button", { name: "Filter Region" }).click(); + const dialog = page.getByRole("dialog", { name: "Filter Region" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("checkbox", { name: "East" }).check(); + await page.keyboard.press("Escape"); + + await expect.poll(() => page.locator("[data-pretable-row]").count()).toBe(3); + const regions = await page.$$eval( + '[data-pretable-row] [data-pretable-column-id="region"]', + (cells) => [...new Set(cells.map((cell) => cell.textContent?.trim()))], + ); + expect(regions).toEqual(["East"]); +}); + +test("the server groups", async ({ page }) => { + await page.goto(FIXTURE, { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + await expect.poll(() => page.locator("[data-pretable-row]").count()).toBe(8); + + const before = await fetchCount(page); + await page.getByRole("button", { name: "Column menu for Region" }).click(); + await page.getByRole("menuitem", { name: "Group by this column" }).click(); + + await expect.poll(() => fetchCount(page)).toBeGreaterThan(before); + // The server returns rows clustered by region; assert the clustering rather + // than group headers, which only exist when the ENGINE groups. + const regions = await page.$$eval( + '[data-pretable-row] [data-pretable-column-id="region"]', + (cells) => cells.map((cell) => cell.textContent?.trim() ?? ""), + ); + expect(regions).toEqual([...regions].sort()); +}); +``` + +- [ ] **Step 2: Run it** + +```bash +BASE_URL=http://localhost:3100 pnpm --filter @pretable/app-website exec playwright test e2e/server-query.spec.ts --project=chromium --workers=1 +``` + +Expected: 3 passed. + +Two failures worth telling apart: +- **Row count stays 8 after filtering** → the grid is applying the query itself despite controlled mode, or `onQueryChange` never fired. Check `data-fetch-count`: unchanged means the callback never fired; incremented means the server did not filter, and the bug is in `applyServerQuery`. +- **`Filter Region` not found** → an `enum` column with no `options` needs distinct values, which the grid derives from `rows`; confirm rows arrived before opening the funnel. + +- [ ] **Step 3: Prove the round-trip assertion can fail** + +In `ServerQueryGrid.tsx`, temporarily change the effect's dependency from `[query]` to `[]` so it fetches once and never again. Re-run. Expected: FAIL — `fetchCount` never increases and the sort assertion times out. Restore `[query]`. + +This is the assertion that separates "the server did it" from "the client did it and we never noticed". + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/e2e/server-query.spec.ts +git commit -m "test(website): sort, filter and group against server-fetched rows" +``` + +--- + +## Task 4: Wire the new specs into the suite and verify nothing else moved + +**Files:** +- Modify: none expected — `playwright.config.ts` has `testDir: "./e2e"`, so new specs are picked up automatically. + +- [ ] **Step 1: Run the whole website suite in both engines** + +Build and start a production server (the suite's normal target): + +```bash +pnpm build +pnpm --filter @pretable/app-website exec next build +pnpm --filter @pretable/app-website exec next start -p 3100 +``` + +Then: + +```bash +BASE_URL=http://localhost:3100 pnpm --filter @pretable/app-website exec playwright test --workers=1 +``` + +Expected: every spec passes, including the three new ones in both chromium and webkit. `--workers=1` matters: this machine saturates and parallel workers produce false flakes. + +- [ ] **Step 2: Run the unit gates** + +```bash +pnpm test && pnpm typecheck && pnpm lint && pnpm format +``` + +Expected: all clean. The new route handler and fixture are compiled by `next build` in Step 1, so a type error there surfaces before this. + +- [ ] **Step 3: Commit any formatting the gates applied** + +```bash +pnpm format:write +git add -A +git commit -m "chore: formatting for the new smoke coverage" +``` + +--- + +## Definition of done + +- Controlled checklist filtering is pinned by a test that fails when the filter stops applying. +- Group collapse/expand is pinned by child identity, not row counts, so virtualization cannot fake a pass. +- A surface exists where sort, filter and grouping are applied by a server, and the test proves the round-trip happened rather than inferring it from the screen. +- The full website suite passes in both engines against a production build. + +## Out of scope + +- Making the server fixture paginate or window rows. Row windowing has its own design in flight (#375); this fixture returns the whole result set on purpose so the tests are about query ownership, not transport. +- Group *headers* from a server. The engine builds group rows; a server that only clusters rows cannot produce them, which is why Task 3d asserts clustering instead. Server-produced tree structure belongs with the remote row model on the roadmap. +- Adding these specs to the CI dev-smoke job. They run against either server; which lane owns them is a CI decision, not a coverage one. From 140627a42274a3f12fe0818bdeb86ee75a468687 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 12:42:30 -0700 Subject: [PATCH 2/8] test(website): pin controlled checklist filtering on the docs grid --- apps/website/e2e/controlled-query.spec.ts | 86 +++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 apps/website/e2e/controlled-query.spec.ts diff --git a/apps/website/e2e/controlled-query.spec.ts b/apps/website/e2e/controlled-query.spec.ts new file mode 100644 index 00000000..bb056f28 --- /dev/null +++ b/apps/website/e2e/controlled-query.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "@playwright/test"; + +import { + openFilterMenu, + waitForGridReady, + waitForStablePosition, +} from "./helpers"; + +/** + * The controlled-query surfaces on the docs pages: `query` + `onQueryChange`, + * where the consumer owns the query and the grid reports intent rather than + * applying it itself. This is the shape a server integration uses, and a manual + * smoke on 2026-08-14 could not verify either flow here — the filter check + * opened a non-checklist funnel and toggled nothing, and the collapse check + * counted rendered rows, which virtualization makes meaningless. + */ +test("checklist funnel filters a controlled grid, and the page sees the query", async ({ + page, +}) => { + await page.goto("/docs/grid/filtering", { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + + // The example mounts with `status isAnyOf ["open"]` already applied and + // echoes it beneath the grid, so this establishes the starting state: the + // query is the consumer's (ColumnFiltersGrid.tsx's `useState`), not the + // grid's, and it is rendered. It says nothing yet about `onQueryChange` — + // at mount the echo only reflects that initial value. What proves the + // round-trip is the row count and the distinct statuses asserted after the + // toggle below, since in controlled mode the grid applies nothing and those + // rows can only have changed by way of the consumer's new query. + // + // Re-asserting the echo after the toggle would NOT strengthen this: it + // prints `${columnId} ${operator}`, and adding `shipped` to the same + // `isAnyOf` filter leaves the string "status isAnyOf" byte-identical. That + // assertion would pass whether or not the filter changed. + // + // Scoped to the Preview pane because `ExampleShell` keeps the Code pane + // mounted too (see its layout comment), and the source it renders is + // `ColumnFiltersGrid.tsx` — which contains the literal "Active filters:" + // that draws the echo. An unscoped match resolves to both. + const preview = page.getByRole("tabpanel", { name: "Preview" }); + const echo = preview.getByText(/Active filters:/); + await expect(echo).toContainText("status"); + const openRows = await page.locator("[data-pretable-row]").count(); + expect(openRows).toBeGreaterThan(0); + + // Scroll the header into place and let the page stop moving BEFORE opening + // anything. Playwright auto-scrolls a target into view before acting on it, + // and the docs routes scroll smoothly (`scroll-behavior: smooth`, + // app/globals.css), so that scroll is still animating when the next action + // fires. The popover closes on any scroll — deliberately, so it never floats + // away from its anchor (`overlay/useHeaderPopover.ts`) — so a click issued + // mid-glide opens the menu and the following animation frame shuts it again. + // Measured in WebKit: dialog present at t+1260ms, gone at t+1288ms, three + // runs out of three. Hovering here is what triggers the scroll; + // `openFilterMenu`'s own hover is then a no-op and its click lands on a still + // page. `smoke.spec.ts` never needed this because the hero grid sits at the + // top of the page and is already in view. + await page.locator("[data-pretable-header-row]").first().hover(); + await waitForStablePosition( + page.locator( + '[data-pretable-filter-funnel][data-pretable-column-id="status"]', + ), + ); + + // `status` is an enum column that declares no `options`, so this checklist is + // built from the rows' distinct values. All three must be offered. + const dialog = await openFilterMenu(page, "Status"); + for (const value of ["open", "shipped", "cancelled"]) { + await expect(dialog.getByRole("checkbox", { name: value })).toBeVisible(); + } + + // Add `shipped` to the selection: strictly more rows, and both values present. + await dialog.getByRole("checkbox", { name: "shipped" }).check(); + await page.keyboard.press("Escape"); + + await expect + .poll(() => page.locator("[data-pretable-row]").count()) + .toBeGreaterThan(openRows); + + const statuses = await page.$$eval( + '[data-pretable-row] [data-pretable-column-id="status"]', + (cells) => [...new Set(cells.map((cell) => cell.textContent?.trim()))], + ); + expect([...statuses].sort()).toEqual(["open", "shipped"]); +}); From ea896be7d7f0ec47e44139e244a16226e64a7ede Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 12:42:30 -0700 Subject: [PATCH 3/8] test(website): pin group collapse/expand by child identity, not row count --- apps/website/e2e/controlled-query.spec.ts | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/apps/website/e2e/controlled-query.spec.ts b/apps/website/e2e/controlled-query.spec.ts index bb056f28..e6166117 100644 --- a/apps/website/e2e/controlled-query.spec.ts +++ b/apps/website/e2e/controlled-query.spec.ts @@ -84,3 +84,61 @@ test("checklist funnel filters a controlled grid, and the page sees the query", ); expect([...statuses].sort()).toEqual(["open", "shipped"]); }); + +test("collapsing a group hides its children, and expanding brings them back", async ({ + page, +}) => { + await page.goto("/docs/grid/grouping", { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + + const firstGroup = page.locator("[data-pretable-group-row]").first(); + await expect(firstGroup).toBeVisible(); + await expect(firstGroup).toHaveAttribute("aria-expanded", "true"); + + // A NAMED child, not a row count. The grid virtualizes: collapsing a group + // pulls rows in from below, so `[data-pretable-row]` barely moves and a + // count-based assertion reports "collapse does nothing" — which is exactly + // what the 2026-08-14 manual smoke concluded, wrongly. + // + // `[data-pretable-row]` alone is already child-rows-only: group rows carry + // `data-pretable-group-row` and `data-pretable-row-id` but never + // `data-pretable-row` (packages/react/src/group-row.tsx vs the data-row + // branch in pretable-surface.tsx), so there is nothing here to exclude. + const childIds = await page.$$eval("[data-pretable-row]", (rows) => + rows + .map((row) => row.getAttribute("data-pretable-row-id")) + .filter((id): id is string => id !== null), + ); + expect(childIds.length).toBeGreaterThan(0); + // Scoped to `[data-pretable-row]` because group rows DO share the row-id + // attribute — the ids happen not to collide here (`p1`… vs `__group__:…`), + // but the locator should not depend on that. + const child = page.locator( + `[data-pretable-row][data-pretable-row-id="${childIds[0]}"]`, + ); + await expect(child).toHaveCount(1); + + // Settle before pressing, for the reason spelled out in the filtering test + // above: Playwright auto-scrolls the twisty into view, the docs routes scroll + // smoothly, and the target is 18px wide, so a press issued mid-glide can miss + // it. Nothing amplifies the miss here the way the filter popover's + // close-on-scroll does, which is what makes it worth guarding — a click that + // misses a twisty is silent, and the failure surfaces two assertions later as + // "collapse did nothing". + // + // Prophylactic, not a diagnosed fix: one WebKit run of this test failed once + // in ~37, on a machine at load average 11, and neither 20 repeat-each runs + // nor 10 further full-file runs reproduced it — its message was never + // captured (`reporter: "list"`, no HTML report). This is the most plausible + // mechanism, not a confirmed one. + const collapse = firstGroup.getByRole("button", { name: /^Collapse / }); + await collapse.hover(); + await waitForStablePosition(collapse); + await collapse.click(); + await expect(firstGroup).toHaveAttribute("aria-expanded", "false"); + await expect(child).toHaveCount(0); + + await firstGroup.getByRole("button", { name: /^Expand / }).click(); + await expect(firstGroup).toHaveAttribute("aria-expanded", "true"); + await expect(child).toHaveCount(1); +}); From a66bb86b1c7781e24363d1cef9bee30ac940c0cd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 12:51:29 -0700 Subject: [PATCH 4/8] feat(website): a server-side dataset and query application for the fixture --- apps/website/app/api/rows/dataset.ts | 93 ++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 apps/website/app/api/rows/dataset.ts diff --git a/apps/website/app/api/rows/dataset.ts b/apps/website/app/api/rows/dataset.ts new file mode 100644 index 00000000..9b24032c --- /dev/null +++ b/apps/website/app/api/rows/dataset.ts @@ -0,0 +1,93 @@ +export interface ServerRow extends Record { + id: string; + region: string; + rep: string; + amount: number; +} + +/** Deliberately small and low-cardinality: three regions, four reps. */ +export const SERVER_ROWS: ServerRow[] = [ + { id: "s1", region: "East", rep: "Ada", amount: 120 }, + { id: "s2", region: "East", rep: "Brin", amount: 340 }, + { id: "s3", region: "East", rep: "Cyd", amount: 55 }, + { id: "s4", region: "North", rep: "Ada", amount: 900 }, + { id: "s5", region: "North", rep: "Dara", amount: 210 }, + { id: "s6", region: "West", rep: "Brin", amount: 75 }, + { id: "s7", region: "West", rep: "Cyd", amount: 480 }, + { id: "s8", region: "West", rep: "Dara", amount: 260 }, +]; + +/** + * The wire shape of a query, as it arrives over JSON. + * + * Structurally looser than `PretableQueryFor`: the arrays are + * readonly and the entries carry only what this handler reads, so the grid's + * own query — which also carries `nulls`, and `direction` on row groups — + * assigns to it without a cast. `operator` and `value` are widened because a + * request body is untrusted input, not a typed value. + */ +export interface ServerQuery { + filters: readonly { columnId: string; operator: string; value?: unknown }[]; + sort: readonly { columnId: string; direction: "asc" | "desc" }[]; + rowGroups: readonly { columnId: string }[]; +} + +/** + * The "server". Applies the query to its own rows and returns the result. + * + * Read this before trusting any screen assertion built on it: the fixture is in + * controlled mode, which means the grid does not apply a query *transition* + * itself — it reports intent and waits for the consumer to hand back the next + * `query`. It does still apply whatever `query` prop it is holding to whatever + * `rows` prop it is holding. So when the fixture feeds it both the new query + * and the server's rows, the engine sorts and filters the server's answer a + * second time, and the two applications agree. + * + * The consequence is sharp: if this function returned rows in a random order, + * the grid would still show them correctly sorted, and a test that only looks + * at the screen would still pass. That is why the fixture publishes the + * server's answer verbatim (`data-server-row-ids`) and the e2e asserts on it + * and on the outgoing request bodies rather than on the rendered order alone. + */ +export function applyServerQuery( + rows: readonly ServerRow[], + query: ServerQuery, +): ServerRow[] { + let out = [...rows]; + + for (const filter of query.filters) { + const { columnId, operator, value } = filter; + if (operator === "isAnyOf" && Array.isArray(value)) { + out = out.filter((row) => value.includes(row[columnId])); + } else if (operator === "contains" && typeof value === "string") { + out = out.filter((row) => + String(row[columnId]).toLowerCase().includes(value.toLowerCase()), + ); + } else if (operator === "gte" && typeof value === "number") { + out = out.filter((row) => Number(row[columnId]) >= value); + } + } + + for (const entry of [...query.sort].reverse()) { + const { columnId, direction } = entry; + out.sort((left, right) => { + const a = left[columnId]; + const b = right[columnId]; + const cmp = + typeof a === "number" && typeof b === "number" + ? a - b + : String(a).localeCompare(String(b)); + return direction === "desc" ? -cmp : cmp; + }); + } + + // Grouping is expressed as ordering here: rows arrive already clustered by + // the group key, which is what a server that cannot send tree structure does. + for (const group of [...query.rowGroups].reverse()) { + out.sort((left, right) => + String(left[group.columnId]).localeCompare(String(right[group.columnId])), + ); + } + + return out; +} From 875d079259422d0f3ffcf5428bafb9d371e434fd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 12:51:48 -0700 Subject: [PATCH 5/8] feat(website): POST /api/rows returns rows for a query --- apps/website/app/api/rows/route.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/website/app/api/rows/route.ts diff --git a/apps/website/app/api/rows/route.ts b/apps/website/app/api/rows/route.ts new file mode 100644 index 00000000..1b1d9172 --- /dev/null +++ b/apps/website/app/api/rows/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +import { applyServerQuery, SERVER_ROWS, type ServerQuery } from "./dataset"; + +/** + * Rows for a query. POST rather than GET so the query travels as JSON instead + * of a hand-rolled encoding, and so responses are never cached — the test + * asserts one request per query change and a cache hit would swallow them. + */ +export async function POST(request: Request): Promise { + const query = (await request.json()) as Partial; + const rows = applyServerQuery(SERVER_ROWS, { + filters: query.filters ?? [], + sort: query.sort ?? [], + rowGroups: query.rowGroups ?? [], + }); + + return NextResponse.json( + { rows, total: rows.length }, + { headers: { "cache-control": "no-store" } }, + ); +} From ad78d10a410ddc4bbdc6d4c2e117fdcea75192e2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 12:53:02 -0700 Subject: [PATCH 6/8] feat(website): a fixture that fetches rows per query change --- .../fixtures/server-query/ServerQueryGrid.tsx | 94 +++++++++++++++++++ .../app/fixtures/server-query/page.tsx | 16 ++++ 2 files changed, 110 insertions(+) create mode 100644 apps/website/app/fixtures/server-query/ServerQueryGrid.tsx create mode 100644 apps/website/app/fixtures/server-query/page.tsx diff --git a/apps/website/app/fixtures/server-query/ServerQueryGrid.tsx b/apps/website/app/fixtures/server-query/ServerQueryGrid.tsx new file mode 100644 index 00000000..fe82dc83 --- /dev/null +++ b/apps/website/app/fixtures/server-query/ServerQueryGrid.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { PretableSurface, type PretableColumn } from "@pretable/react"; +import { + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; + +import type { ServerRow } from "../../api/rows/dataset"; + +const COLUMNS: PretableColumn[] = [ + { id: "region", header: "Region", type: "enum", widthPx: 140 }, + { id: "rep", header: "Rep", widthPx: 140 }, + { id: "amount", header: "Amount", type: "number", widthPx: 140 }, +]; + +type SurfaceQuery = NonNullable< + ComponentProps>["query"] +>; + +const EMPTY_QUERY: SurfaceQuery = { filters: [], sort: [], rowGroups: [] }; + +/** + * Test fixture for `apps/website/e2e/server-query.spec.ts`, and the reference + * for wiring a real backend. + * + * Controlled mode against a real endpoint: the grid reports the query the user + * asked for, this component fetches rows for it, and the grid renders what + * comes back. Nothing here sorts or filters — that is the whole point. + * + * The two data attributes are the test's only honest evidence, and they exist + * because the screen is not evidence. Controlled mode stops the grid applying + * a query *transition* itself, but it still applies the `query` prop it holds + * to the `rows` prop it holds — and this component hands it both. So the + * engine re-sorts and re-filters the server's answer, and a rendered order + * assertion would pass even if `/api/rows` returned rows in a random order. + * + * - `data-fetch-count` proves a round-trip happened at all (freeze the fetch + * and it stops moving, while the screen keeps working). + * - `data-server-row-ids` is the server's answer verbatim, in the order it + * arrived, before the engine touches it. It is the one thing on the page + * that changes if the server stops applying the query. + * + * `groupPanel` is enabled because the column menu — the only way to add a row + * group by pointer — is rendered only when it is (`showColumnMenu` in + * packages/react/src/pretable-surface.tsx). + * + * Deliberately not part of the product surface, and not linked from the site. + */ +export function ServerQueryGrid() { + const columns = useMemo(() => COLUMNS, []); + const [query, setQuery] = useState(EMPTY_QUERY); + const [rows, setRows] = useState([]); + const [fetchCount, setFetchCount] = useState(0); + const generation = useRef(0); + + useEffect(() => { + const mine = ++generation.current; + void (async () => { + const response = await fetch("/api/rows", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(query), + }); + const payload = (await response.json()) as { rows: ServerRow[] }; + // A slower earlier request must not overwrite a newer answer. + if (mine !== generation.current) return; + setRows(payload.rows); + setFetchCount((count) => count + 1); + })(); + }, [query]); + + return ( +
row.id).join(",")} + > + + ariaLabel="Server query grid" + columns={columns} + getRowId={(row) => row.id} + groupPanel={{ enabled: true }} + onQueryChange={setQuery} + query={query} + rows={rows} + viewportHeight={320} + /> +
+ ); +} diff --git a/apps/website/app/fixtures/server-query/page.tsx b/apps/website/app/fixtures/server-query/page.tsx new file mode 100644 index 00000000..bc94faa8 --- /dev/null +++ b/apps/website/app/fixtures/server-query/page.tsx @@ -0,0 +1,16 @@ +import { ServerQueryGrid } from "./ServerQueryGrid"; + +/** + * Kept to a heading and the grid so the header row sits above the fold. The + * filter popover closes on any scroll (`overlay/useHeaderPopover.ts`), and + * Playwright auto-scrolls a target into view before clicking it, so a funnel + * below the fold opens and shuts in the same frame. + */ +export default function ServerQueryFixturePage() { + return ( +
+

Server query fixture

+ +
+ ); +} From 4e3a880914937a113c78b2a35768fa8874dd7e2c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 12:56:36 -0700 Subject: [PATCH 7/8] test(website): sort, filter and group against server-fetched rows --- apps/website/e2e/server-query.spec.ts | 192 ++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 apps/website/e2e/server-query.spec.ts diff --git a/apps/website/e2e/server-query.spec.ts b/apps/website/e2e/server-query.spec.ts new file mode 100644 index 00000000..1f573f45 --- /dev/null +++ b/apps/website/e2e/server-query.spec.ts @@ -0,0 +1,192 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { waitForGridReady } from "./helpers"; + +/** + * Sort, filter and group against rows the SERVER produced. + * + * Every other e2e on this site hands the grid its rows and lets the engine + * apply the query. This one drives `/fixtures/server-query`, where the query + * makes a round trip through `POST /api/rows` and the rows on screen are the + * ones that came back. + * + * READ THIS BEFORE ADDING AN ASSERTION HERE. The rendered grid is not evidence + * that the server did anything. Controlled mode (`query` + `onQueryChange`) + * stops the grid applying a query *transition* itself — it reports intent and + * waits for the consumer — but it still applies the `query` prop it holds to + * the `rows` prop it holds, and the fixture hands it both. So the engine + * re-sorts and re-filters the server's answer, and every assertion about what + * is on screen would pass unchanged if `/api/rows` shuffled its rows and + * ignored the query completely. Confirmed by mutation, not by argument: with + * the response rows reversed by a `page.route` interceptor, the rendered order + * was still correct. + * + * Two things do discriminate, and every test here leans on them: + * + * - the outgoing request body, asserted whole, which proves the query the user + * expressed is the query the server was asked; + * - `data-server-row-ids` on the fixture, which is the server's answer verbatim + * in the order it arrived, published before the engine touches it. + * + * `data-fetch-count` is the third: it proves a round trip happened at all, and + * is what fails first if the fetch stops firing. + */ +const FIXTURE = "/fixtures/server-query"; + +/** The natural order of `SERVER_ROWS`, which is also the empty query's answer. */ +const UNSORTED = "s1,s2,s3,s4,s5,s6,s7,s8"; + +const fixture = (page: Page) => + page.locator("[data-testid=server-query-fixture]"); + +const fetchCount = async (page: Page) => + Number(await fixture(page).getAttribute("data-fetch-count")); + +/** The server's answer, verbatim and in arrival order. */ +const serverRowIds = async (page: Page) => + (await fixture(page).getAttribute("data-server-row-ids")) ?? ""; + +const amounts = async (page: Page) => + ( + await page.$$eval( + '[data-pretable-row] [data-pretable-column-id="amount"]', + (cells) => cells.map((cell) => cell.textContent?.trim() ?? ""), + ) + ).map(Number); + +/** + * Records every `/api/rows` request body, and lands the fixture in its settled + * initial state: 8 rows fetched and drawn, `data-fetch-count` at rest. + * + * The count has to be sampled after that settling rather than at mount, so + * that a later "it went up" reading can only be the interaction's doing. + * `reactStrictMode` is on, so the mount effect runs twice in dev and two + * identical requests go out; the fixture's generation guard drops the first + * answer, so the count lands on 1 either way, but the second request is still + * in flight for a moment. + */ +async function openFixture(page: Page) { + const requests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/api/rows")) + requests.push(request.postData() ?? ""); + }); + + await page.goto(FIXTURE, { waitUntil: "domcontentloaded" }); + await waitForGridReady(page); + await expect.poll(() => page.locator("[data-pretable-row]").count()).toBe(8); + await expect.poll(() => serverRowIds(page)).toBe(UNSORTED); + + return { + requests, + /** The parsed body of the most recent request. */ + lastQuery: () => JSON.parse(requests.at(-1) ?? "null") as unknown, + }; +} + +test("the server sorts", async ({ page }) => { + const { lastQuery } = await openFixture(page); + const before = await fetchCount(page); + + // Header click cycles absent → desc → asc → absent (see the plain-click + // branch in packages/react/src/pretable-surface.tsx), so the first press is + // descending. + const amountHeader = page.locator( + '[data-pretable-header-cell][data-pretable-column-id="amount"]', + ); + await amountHeader.click(); + + await expect.poll(() => fetchCount(page)).toBeGreaterThan(before); + // The server's own answer, exact. This is the assertion that fails if + // `/api/rows` stops sorting; the `amounts` check below would not. + await expect.poll(() => serverRowIds(page)).toBe("s4,s7,s2,s8,s5,s1,s6,s3"); + expect(lastQuery()).toEqual({ + filters: [], + sort: [{ columnId: "amount", direction: "desc" }], + rowGroups: [], + }); + // Corroborating, not probative: the engine would produce this order from the + // query alone whatever the server sent back. + await expect.poll(async () => (await amounts(page))[0]).toBe(900); + + // Second click flips to ascending, and the flip is server-applied too. + await amountHeader.click(); + await expect.poll(() => serverRowIds(page)).toBe("s3,s6,s1,s5,s8,s2,s7,s4"); + expect(lastQuery()).toEqual({ + filters: [], + sort: [{ columnId: "amount", direction: "asc" }], + rowGroups: [], + }); + await expect.poll(async () => (await amounts(page))[0]).toBe(55); +}); + +test("the server filters", async ({ page }) => { + const { lastQuery } = await openFixture(page); + const before = await fetchCount(page); + + // `region` is an enum column with no `options`, so the checklist is built + // from the distinct values of the rows the grid currently holds — which are + // the server's, so this also proves the fetched rows reached the engine. + await page.locator("[data-pretable-header-row]").first().hover(); + await page.getByRole("button", { name: "Filter Region" }).click(); + const dialog = page.getByRole("dialog", { name: "Filter Region" }); + await expect(dialog).toBeVisible(); + for (const value of ["East", "North", "West"]) { + await expect(dialog.getByRole("checkbox", { name: value })).toBeVisible(); + } + await dialog.getByRole("checkbox", { name: "East" }).check(); + await page.keyboard.press("Escape"); + + await expect.poll(() => fetchCount(page)).toBeGreaterThan(before); + // Three ids, not eight: the rows the server withheld never reached the + // client. A grid-side count cannot tell that apart from the engine hiding + // five rows it was given. + await expect.poll(() => serverRowIds(page)).toBe("s1,s2,s3"); + expect(lastQuery()).toEqual({ + filters: [{ columnId: "region", operator: "isAnyOf", value: ["East"] }], + sort: [], + rowGroups: [], + }); + + await expect.poll(() => page.locator("[data-pretable-row]").count()).toBe(3); + const regions = await page.$$eval( + '[data-pretable-row] [data-pretable-column-id="region"]', + (cells) => [...new Set(cells.map((cell) => cell.textContent?.trim()))], + ); + expect(regions).toEqual(["East"]); +}); + +test("the server groups", async ({ page }) => { + const { lastQuery } = await openFixture(page); + + // Sort by amount first, deliberately: it scatters the regions + // (s4,s7,s2,s8,s5,s1,s6,s3), so the clustering asserted after grouping is + // work the server visibly had to do. `SERVER_ROWS` is already stored in + // region order, so grouping an unsorted fetch would be satisfied by the + // server returning its rows untouched — a passing assertion proving nothing. + await page + .locator('[data-pretable-header-cell][data-pretable-column-id="amount"]') + .click(); + await expect.poll(() => serverRowIds(page)).toBe("s4,s7,s2,s8,s5,s1,s6,s3"); + const before = await fetchCount(page); + + await page.locator("[data-pretable-header-row]").first().hover(); + await page.getByRole("button", { name: "Column menu for Region" }).click(); + await page.getByRole("menuitem", { name: "Group by this column" }).click(); + + await expect.poll(() => fetchCount(page)).toBeGreaterThan(before); + // Regions clustered, amount-descending preserved inside each: East + // (340, 120, 55), North (900, 210), West (480, 260, 75). Only a server that + // applied both parts of the query returns this. + await expect.poll(() => serverRowIds(page)).toBe("s2,s1,s3,s4,s5,s7,s8,s6"); + expect(lastQuery()).toEqual({ + filters: [], + sort: [{ columnId: "amount", direction: "desc" }], + rowGroups: [{ columnId: "region" }], + }); + + // The engine still builds the group rows — a server that can only cluster + // rows cannot send tree structure, so the header rows are the client's work + // on top of the server's ordering. One per region. + await expect(page.locator("[data-pretable-group-row]")).toHaveCount(3); +}); From a2afbb483b12ebea8c72b5b83f2cc2c2cbbfc686 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 14 Aug 2026 13:07:27 -0700 Subject: [PATCH 8/8] test(website): label which server-query assertions are probative --- apps/website/e2e/server-query.spec.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/website/e2e/server-query.spec.ts b/apps/website/e2e/server-query.spec.ts index 1f573f45..f96486c9 100644 --- a/apps/website/e2e/server-query.spec.ts +++ b/apps/website/e2e/server-query.spec.ts @@ -117,6 +117,7 @@ test("the server sorts", async ({ page }) => { sort: [{ columnId: "amount", direction: "asc" }], rowGroups: [], }); + // Corroborating, not probative, for the same reason as the check above. await expect.poll(async () => (await amounts(page))[0]).toBe(55); }); @@ -125,8 +126,11 @@ test("the server filters", async ({ page }) => { const before = await fetchCount(page); // `region` is an enum column with no `options`, so the checklist is built - // from the distinct values of the rows the grid currently holds — which are - // the server's, so this also proves the fetched rows reached the engine. + // from the distinct values of the rows the grid currently holds. Three + // regions being offered therefore says the engine holds rows whose regions + // are East/North/West — and no more than that. Nothing here asserts that the + // engine's row set IS `data-server-row-ids`; the two agree structurally + // (the fixture feeds one to the other) but no assertion pins them together. await page.locator("[data-pretable-header-row]").first().hover(); await page.getByRole("button", { name: "Filter Region" }).click(); const dialog = page.getByRole("dialog", { name: "Filter Region" }); @@ -139,8 +143,9 @@ test("the server filters", async ({ page }) => { await expect.poll(() => fetchCount(page)).toBeGreaterThan(before); // Three ids, not eight: the rows the server withheld never reached the - // client. A grid-side count cannot tell that apart from the engine hiding - // five rows it was given. + // client at all. This is the probative assertion of this test — do not trim + // it and keep the row count below, which is a different and much weaker + // claim. await expect.poll(() => serverRowIds(page)).toBe("s1,s2,s3"); expect(lastQuery()).toEqual({ filters: [{ columnId: "region", operator: "isAnyOf", value: ["East"] }], @@ -148,6 +153,10 @@ test("the server filters", async ({ page }) => { rowGroups: [], }); + // Corroborating, not probative: a grid-side count cannot tell "the server + // sent three rows" apart from "the server sent eight and the engine hid + // five", and neither can the distinct regions on screen. Both hold whatever + // `/api/rows` does, so they only confirm the round trip ends up drawn. await expect.poll(() => page.locator("[data-pretable-row]").count()).toBe(3); const regions = await page.$$eval( '[data-pretable-row] [data-pretable-column-id="region"]',