Repository navigation
docs: screenshots of the running SPA in the README #550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| // tools/simulation/ui/playwright.screenshots.config.ts — #549. | ||
| // | ||
| // Captures the README's screenshots from the REAL built SPA over the same | ||
| // `seed --profile simulation` fixture every other spec here uses. Deliberately | ||
| // a THIRD config rather than a spec in `specs/`: this run WRITES FILES into the | ||
| // repo (docs/images/), and a capture that rides along with `npm test` would | ||
| // rewrite committed images on every smoke run — turning an unrelated green run | ||
| // into a dirty working tree. | ||
| // | ||
| // NOT a visual-regression suite. Nothing here asserts on pixels; rendering is | ||
| // not byte-deterministic across fonts and antialiasing, so a byte-diff gate | ||
| // would flake. The images are documentation artefacts with a manual refresh: | ||
| // see specs-screenshots/screenshots.spec.ts for the staleness contract. | ||
|
|
||
| import { defineConfig, devices } from "@playwright/test"; | ||
| import { resolveBrowser } from "./src/browser"; | ||
| import { BASE_URL } from "./src/env"; | ||
|
|
||
| const { executablePath } = resolveBrowser(); | ||
|
|
||
| export default defineConfig({ | ||
| testDir: "./specs-screenshots", | ||
|
|
||
| // Same reasoning as the smoke config: the stack is owned by reset.sh, not by | ||
| // Playwright. No webServer. | ||
| globalSetup: "./src/preflight.ts", | ||
|
|
||
| // One worker: the captures sign three personas in and out of one seeded | ||
| // database, and a second worker would interleave those sessions. | ||
| workers: 1, | ||
| fullyParallel: false, | ||
| retries: 0, | ||
| forbidOnly: true, | ||
|
|
||
| reporter: [["list"]], | ||
|
|
||
| timeout: 60_000, | ||
| expect: { timeout: 15_000 }, | ||
|
|
||
| use: { | ||
| baseURL: BASE_URL, | ||
| trace: "retain-on-failure", | ||
| video: "off", | ||
| ignoreHTTPSErrors: true, | ||
| }, | ||
|
|
||
| projects: [ | ||
| { | ||
| name: "chromium", | ||
| use: { | ||
| ...devices["Desktop Chrome"], | ||
|
|
||
| // AFTER the spread, not before, and not in the top-level `use`: | ||
| // `devices["Desktop Chrome"]` carries its own 1280x720 @1, and a | ||
| // project's `use` beats the top-level one. Setting the frame above the | ||
| // spread silently captures at the device's size instead of this one. | ||
| // | ||
| // A fixed frame means a refreshed image differs from its predecessor | ||
| // only where the app changed. 1280x800 is the widest that still reads | ||
| // at GitHub's rendered README width without shrinking the type to | ||
| // noise. | ||
| // | ||
| // Scale 1, measured rather than assumed: at scale 2 the three images | ||
| // are 910 KB, at scale 1 they are 382 KB, and GitHub renders a README | ||
| // image into roughly 890 CSS pixels — so a 1280-wide capture still has | ||
| // ~1.4x the pixels of its slot and stays sharp on a HiDPI display. | ||
| // Every regeneration adds its bytes to git history permanently, which | ||
| // is what makes the 2.4x worth avoiding. | ||
| viewport: { width: 1280, height: 800 }, | ||
| deviceScaleFactor: 1, | ||
|
|
||
| launchOptions: executablePath ? { executablePath } : {}, | ||
| }, | ||
| }, | ||
| ], | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| // tools/simulation/ui/specs-screenshots/screenshots.spec.ts — #549. | ||
| // | ||
| // Captures the three images the root README embeds, from the REAL built SPA | ||
| // over the #243 simulation fixture. Run it deliberately: | ||
| // | ||
| // npm run screenshots # from tools/simulation/ui, with the sim stack up | ||
| // | ||
| // ================== THE STALENESS CONTRACT ================== | ||
| // | ||
| // These images are DOCUMENTATION ARTEFACTS, not test baselines, and **nothing | ||
| // enforces that they match the current UI**. A change to any of the three | ||
| // screens below silently invalidates them; a reviewer looking at a stale | ||
| // screenshot cannot tell. The honest mitigation is the one this file can | ||
| // actually keep: the capture is scripted, so refreshing is one command rather | ||
| // than a hunt for whoever took the last one. Re-run it when a captured screen | ||
| // changes, and at each release. | ||
| // | ||
| // Deliberately NOT a visual-regression gate. Rendering is not byte-deterministic | ||
| // across fonts, antialiasing and GPU paths, so a byte-diff check in the shape of | ||
| // the #417 schema-docs gate would flake — and a flaky gate gets disabled, which | ||
| // is worse than an honest "no gate". | ||
| // | ||
| // ================== WHY IT ASSERTS BEFORE IT CAPTURES ================== | ||
| // | ||
| // A screenshot of a half-rendered page is worse than no screenshot: it is a | ||
| // published claim that the app looks like that. Every capture below waits for a | ||
| // signal that the screen's DATA has arrived — not merely that the route | ||
| // rendered — because the panels fetch independently and each degrades to its | ||
| // own error text. `page.screenshot` also waits for fonts, so the assertions are | ||
| // the only part that needs saying out loud. | ||
| // | ||
| // The suite's three standing rules (#277/#385) apply here as they do to every | ||
| // other spec: no hardcoded credential (personas come from the git-ignored | ||
| // `.sim-cast.json`), no hardcoded English (selector text resolves through the | ||
| // SPA's own catalogs), and respect the farm clock. | ||
|
|
||
| import { mkdirSync } from "node:fs"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { expect, test, type Page } from "../src/fixtures"; | ||
| import { castMember } from "../src/cast"; | ||
| import { daysBefore, farmToday } from "../src/farm"; | ||
| import { tEn } from "../src/i18n"; | ||
|
|
||
| // tools/simulation/ui/specs-screenshots/ -> repo root -> docs/images/ | ||
| const IMAGE_DIR = fileURLToPath(new URL("../../../../docs/images/", import.meta.url)); | ||
|
|
||
| function imagePath(name: string): string { | ||
| mkdirSync(IMAGE_DIR, { recursive: true }); | ||
| return `${IMAGE_DIR}${name}`; | ||
| } | ||
|
|
||
| // `animations: "disabled"` finishes CSS transitions instead of freezing them | ||
| // mid-flight, so a capture cannot land on a half-faded panel. | ||
| // | ||
| // Blurring first is not cosmetic pedantry: a field this spec typed into keeps | ||
| // the focus ring AND its text selection, so the published image shows a form | ||
| // mid-edit with half a date highlighted — which reads as a screenshot somebody | ||
| // took by accident. | ||
| async function capture(page: Page, name: string): Promise<void> { | ||
| await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); | ||
| await page.screenshot({ path: imagePath(name), animations: "disabled" }); | ||
| } | ||
|
|
||
| test.describe("README screenshots", () => { | ||
| // ================== WHY THERE IS NO DASHBOARD SHOT ================== | ||
| // | ||
| // The obvious hero image is the Dashboard, and it was captured first. Its | ||
| // "Today" panel is EMPTY in every fixture capture — the simulation seeds | ||
| // history up to a few days back, so both flocks read "no entry" and "eggs | ||
| // collected today" is 0. True of the fixture, misleading about the product, | ||
| // and unfixable from here without the capture writing today's entries into | ||
| // the shared database. The three screens below each evidence a specific | ||
| // claim the README makes, with data. | ||
|
|
||
| test("daily entry — a recorded day, by grade", async ({ page, signIn, farm }) => { | ||
| await signIn(castMember("Manager")); | ||
|
|
||
| // YESTERDAY, not today, and this is the whole point of the capture. The | ||
| // fixture seeds history up to the previous farm-local day, so today's form | ||
| // is an empty one — a screenshot of five zeroes that says nothing about | ||
| // what the screen is for. Yesterday has a real submitted entry: counts, | ||
| // grades, and the sellable arithmetic that makes the screen legible. | ||
| // | ||
| // Farm-local, via the farm fixture: `America/Chicago` is behind UTC, so a | ||
| // UTC "yesterday" is sometimes today's date on the farm and the capture | ||
| // would silently be the empty form again. | ||
| const day = daysBefore(farmToday(farm.timeZoneId), 1); | ||
|
|
||
| // #446: the page fires a prefill that RESETS every count when it settles. | ||
| // Capturing before that lands photographs numbers the app is about to throw | ||
| // away. Register the wait BEFORE the navigation that triggers it. | ||
| const prefill = page.waitForResponse((r) => r.url().includes("/daily-entries") && r.ok()); | ||
| await page.goto("/daily-entry"); | ||
| await page.getByLabel(tEn("dailyEntry:dateLabel")).fill(day); | ||
| await prefill; | ||
|
|
||
| // The assertion IS the point of choosing yesterday: a non-zero total proves | ||
| // the seeded day loaded, which is the whole difference between this capture | ||
| // and a screenshot of an empty form. Waiting on the route or on a label | ||
| // would pass just as happily on the empty one. | ||
| // | ||
| // Three other signals were refuted first, and each failure mode is worth | ||
| // knowing: "Sellable" resolves to two elements (totals label and summary | ||
| // line — strict-mode violation); `allGradedShort` is present but CSS-hidden | ||
| // here, so waiting on it times out claiming the data never arrived when it | ||
| // had; and `entryLockedBanner` never renders for this day at all. | ||
| await expect( | ||
| page.getByRole("spinbutton", { name: tEn("dailyEntry:totalEggsLabel") }), | ||
| ).not.toHaveValue("0"); | ||
|
|
||
| // Read-only on purpose: this capture must not submit, adjust or void | ||
| // anything. It shares one seeded database with every other spec here. | ||
| await capture(page, "daily-entry.png"); | ||
| }); | ||
|
|
||
| test("reports — production and money over the seeded period", async ({ page, signIn, farm }) => { | ||
| await signIn(castMember("Owner")); | ||
| await page.goto("/reports"); | ||
|
|
||
| // The default range ends TODAY, and the fixture's most recent days are | ||
| // drafts — which a report does not count. The default view is therefore | ||
| // three trailing rows of zeroes, which is a true statement about the | ||
| // fixture and a misleading one about the product. | ||
| // | ||
| // So the range is set explicitly to a window that is entirely submitted | ||
| // days. The offsets are fixture knowledge, not a general truth: the seeder | ||
| // leaves roughly the last few days as drafts. If that changes, the | ||
| // assertion below is what fails. | ||
| const today = farmToday(farm.timeZoneId); | ||
| // `exact` on both, and the reason is the SECOND one: the sidebar's theme | ||
| // toggle is labelled "Switch to night mode", which contains "To" — so a | ||
| // substring match on the To field is ambiguous with a button that changes | ||
| // the whole page's colours. | ||
| await page.getByLabel(tEn("reports:fromLabel"), { exact: true }).fill(daysBefore(today, 9)); | ||
| await page.getByLabel(tEn("reports:toLabel"), { exact: true }).fill(daysBefore(today, 3)); | ||
|
|
||
| await expect(page.getByRole("heading", { name: tEn("reports:productionHeading") })).toBeVisible(); | ||
| await expect(page.getByRole("heading", { name: tEn("reports:moneyHeading") })).toBeVisible(); | ||
|
|
||
| // Renders only when the period produced something (ReportsPage guards on | ||
| // `gradeTotals.length > 0`), so it proves the window is not empty. | ||
| await expect(page.getByText(tEn("reports:gradeTotalsLabel"))).toBeVisible(); | ||
|
|
||
| // ...but a non-empty PERIOD can still end in zero rows, which is exactly | ||
| // the thing being avoided. The last row's Eggs cell is the specific check: | ||
| // the window's final day must itself have production. | ||
| const lastRow = page.getByRole("table").first().locator("tbody tr").last(); | ||
| await expect(lastRow.locator("td").nth(1)).not.toHaveText("0"); | ||
|
|
||
| await capture(page, "reports.png"); | ||
| }); | ||
|
|
||
| test("sales — orders across their lifecycle", async ({ page, signIn }) => { | ||
| await signIn(castMember("Sales")); | ||
| await page.goto("/sales"); | ||
|
|
||
| await expect(page.getByRole("heading", { name: tEn("sales:ordersHeading") })).toBeVisible(); | ||
|
|
||
| // The heading and the absence of "Loading…" are NOT enough, and the failure | ||
| // they miss is the one that matters: when the initial /sales request fails, | ||
| // `usePagedList` empties the rows and clears its loading flag, so the page | ||
| // settles into an error plus the empty-state message with the heading still | ||
| // there. Both weak signals pass, and the run overwrites the committed image | ||
| // with a picture of an error while reporting success. | ||
| // | ||
| // So: the empty state must be absent, and the table must actually hold | ||
| // orders in the two lifecycle states this capture exists to show. | ||
| // `sales:noOrdersMatch`, not `dashboard:noOrdersMessage` — two different | ||
| // empty states in two namespaces, and this page renders the former. The | ||
| // wrong one is not a silent miss: src/i18n.ts throws on an absent key | ||
| // rather than falling back, which is how this was caught. | ||
| await expect(page.getByText(tEn("sales:noOrdersMatch"))).toHaveCount(0); | ||
| const orders = page.getByRole("table").first(); | ||
| await expect(orders.getByRole("row").filter({ hasText: tEn("enums:status.Confirmed") })) | ||
| .not.toHaveCount(0); | ||
| await expect(orders.getByRole("row").filter({ hasText: tEn("enums:status.Draft") })) | ||
| .not.toHaveCount(0); | ||
|
|
||
|
Comment on lines
+175
to
+178
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Fresh evidence beyond the earlier order-loading fix is that AGENTS.md reference: AGENTS.md:L188-L191 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed and fixed in 151d4ed. Verified in The capture now waits for the New order button, which renders only past the setup gate. Mutation-checked in both directions, and they do not both land on the new line — worth stating rather than claiming the one assertion covers everything:
Both restored; all three captures green. |
||
| // The orders list and the page's SETUP data (customers, products, grades) | ||
| // are two independent loads, and the rows render either way: `customerName` | ||
| // falls back to `id.slice(0, 8)`, so a capture taken between them shows a | ||
| // column of GUID fragments where customer names belong. Worse, a setup | ||
| // failure replaces the whole screen with `setupError` — after these row | ||
| // assertions have already passed. | ||
| // | ||
| // The New order button is the setup-derived signal: it renders only past | ||
| // that gate, so it is absent in both the mid-load and the error state. | ||
| await expect(page.getByRole("button", { name: tEn("sales:newOrder") })).toBeVisible(); | ||
|
|
||
| await capture(page, "sales.png"); | ||
| }); | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new config points at
specs-screenshots, but this project’stsconfig.jsonincludes onlyspecs/**/*.ts,specs-canary/**/*.ts, and the two existing config files. Consequently,npm run typecheckdoes not examine either this config or the new screenshot spec, so TypeScript-only regressions in the documented capture command can land while the advertised validation remains green; add both paths to the TypeScript project.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 25cfb5b.
specs-screenshots/**/*.tsandplaywright.screenshots.config.tsare both in the project now.You were right that the advertised validation was green while proving nothing about the new files — and the fix immediately proved it, twice:
const __mutant: number = "not a number"in the spec now fails the typecheck (TS2322at that line); removing it restores green. Before the tsconfig change the same mutant compiled clean.TS6133: 'prefixOf' is declared but its value is never read— a leftover import from an assertion I refuted while writing the spec. It would have shipped."npm run typecheck clean" in the PR body was therefore a claim about files the compiler never opened. Corrected.