diff --git a/.agents/skills/verify b/.agents/skills/verify new file mode 120000 index 000000000..1ea967e2d --- /dev/null +++ b/.agents/skills/verify @@ -0,0 +1 @@ +../../tools/verify \ No newline at end of file diff --git a/.claude/skills/verify b/.claude/skills/verify new file mode 120000 index 000000000..1ea967e2d --- /dev/null +++ b/.claude/skills/verify @@ -0,0 +1 @@ +../../tools/verify \ No newline at end of file diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md deleted file mode 100644 index 2e908c054..000000000 --- a/.claude/skills/verify/SKILL.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -name: verify -description: Build, launch, and drive the Cluckwork app (API + SPA) locally to verify a change end-to-end in a real browser. ---- - -# Verifying Cluckwork changes at runtime - -## Launch the stack - -**First: is an Aspire AppHost already running?** Ask the AppHost, not the container names: - -```sh -pgrep -af 'Cluckwork\.AppHost' # the AppHost process itself -ss -ltn | grep -E ':18888\b' # its pinned dashboard (#565) -aspire describe --apphost src/Cluckwork.AppHost/Cluckwork.AppHost.csproj \ - --format Json --non-interactive # the endpoints THIS run actually holds -``` - -Do **not** detect it with `docker ps | grep -E 'postgres-|redis-'` — that matches -`deploy-redis-1` and `cluckwork-sim-redis-1` too, so Compose or the sim stack reads as Aspire. -And do not treat `5433`/`6380`/`8080`/`5173` as more than **committed defaults**: `LocalPorts:*` -can be overridden per machine or set empty for a random port, so a running AppHost can be sitting -on none of them. `aspire describe` is the authority for a given run; the dashboard port is pinned -and so is a reliable presence check. - -If one is running it already owns the API and SPA ports, so the manual launch below collides with -it, and Aspire's Postgres is a **different database** with a generated credential (#565). Either -drive the running Aspire stack at its advertised web endpoint and skip steps 1-3 entirely, or stop -the AppHost first. Do not run both. - - -```sh -# 1. Dev Postgres (loopback 5432, creds cluckwork/cluckwork/cluckwork) -docker compose -f deploy/docker-compose.dev.yml up -d --wait - -# 2. API — MUST set ASPNETCORE_ENVIRONMENT=Development or user-secrets -# (connection string, Seed:*, JWT keys) don't load and startup crashes -# with "ConnectionString property has not been initialized". -ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://127.0.0.1:8080 \ - dotnet run --project src/Cluckwork.Api --no-launch-profile - -# 3. SPA dev server (proxies /api -> 8080) -cd web && npm run dev # http://localhost:5173 -``` - -A freshly reset database has no admin at all — provision one with -[first-admin provisioning](../../../docs/runbooks/first-admin-provisioning.md) (form 4 if the -database is Aspire's), then use that generated password rather than `Seed:*`. - -Login credentials depend on which stack you are driving, and the two are not interchangeable: - -- **Compose dev database, seeded** — `dotnet user-secrets list --project src/Cluckwork.Api | grep '^Seed:'` - (email `admin@cluckwork.local`). These are the **Compose** credentials only; they do not exist in - Aspire's database. -- **Aspire's database, or any freshly reset one** — there is no `Seed:*` answer. The sign-in password - is the one `bootstrap-admin` printed **to stdout on the run that created the Owner** (form 4), and - it is not recoverable afterwards: it is stored hashed and never logged. If it was not captured, mint - a new one with `recover-admin` rather than hunting for it. The AppHost user-secret - `Parameters:postgres-password` is the **database** password — it is what form 4 passes in - `ConnectionStrings__Default`, and it is never a login credential. - -Don't print either password into reports. - -## Drive the browser - -Playwright MCP fails here (wants Google Chrome at /opt/google/chrome; machine has chromium). Instead script `playwright-core` against system chromium: - -```sh -cd && npm i playwright-core -``` - -```js -import { chromium } from "playwright-core"; -const browser = await chromium.launch({ executablePath: "/usr/bin/chromium" }); -``` - -Login flow: fill `getByLabel(/email/i)` + `getByLabel(/password/i)`, click `getByRole("button", { name: /sign in/i })`, then wait for the target heading. Collect `page.on("pageerror")` + console errors; screenshot for evidence. - -## API-level seeding - -Get a token once, then curl with `Authorization: Bearer` + `Idempotency-Key: $(uuidgen)` on every POST/PUT/DELETE (middleware rejects writes without it). Useful for setting up scenario data (flocks, entries, orders) faster than clicking. - -## Gotchas - -- Dev DB persists between sessions — expect residual data; create what the scenario needs rather than assuming empty state. -- First cold page load may log one or two transient 404 console errors (vite dev asset probing); ignore unless reproducible on reload. -- Teardown: kill dotnet/vite background tasks, `docker compose -f deploy/docker-compose.dev.yml down`. diff --git a/.codex/skills/verify b/.codex/skills/verify new file mode 120000 index 000000000..1ea967e2d --- /dev/null +++ b/.codex/skills/verify @@ -0,0 +1 @@ +../../tools/verify \ No newline at end of file diff --git a/.gitignore b/.gitignore index 62a38edaf..1612232b3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,14 +49,17 @@ TestResults/ # here; nothing in the repo consumes it and it is regenerated on every run. coverage-out/ -.agents/skills -.claude/skills -# verify/ is Cluckwork's own skill, not an installed one: it knows this repo's -# stacks and ports, it is tracked (since #43), and gitignore does not apply to -# tracked files anyway - so the carve-out states the intent instead of leaving -# the file looking like an accident in every diff. -!.claude/skills/verify/ -.codex/skills +.agents/skills/* +.claude/skills/* +.codex/skills/* +# verify is Cluckwork's own skill, not an installed one: it knows this repo's +# stacks and ports. Its tree lives at tools/verify/ (agent-agnostic) and each +# agent's skill directory carries a symlink to it, tracked here so every +# runtime discovers the same SKILL.md. The parent rules end in /* on purpose: +# git cannot re-include a file under an excluded directory. +!.agents/skills/verify +!.claude/skills/verify +!.codex/skills/verify skills-lock.json scratchpad/ diff --git a/AGENTS.md b/AGENTS.md index d13509f17..ff7f482e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,7 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati - **A write-contract change must update its non-CI callers (#394).** Coverage is not uniform, and it is not what it was when this rule was written. The seeders are covered only to the handler layer, so a **validator-only** tightening is invisible to every seeder test. `tools/simulation/k6/` is **genuinely uncovered** — `k6-baseline.yml` is `workflow_dispatch` only, and a tolerated-status list means a green run does not mean a landing write. The **Playwright specs are no longer uncovered**: since 2026-08-08 `e2e-smoke.yml` runs the quick suite on every `pull_request` touching `src/**`, `web/**`, `tools/simulation/**` or `deploy/**` — which is every write-contract change by definition — so CI does catch a broken spec there. A docs-only PR still skips it, and `slow`/`canary` stay dispatch-only. **Verify the request/status contract by reading the callers, not by running** — the reading is what still finds the k6 case and what tells you whether a caller's *intent* changed. → [`394-write-contract-callers.md`](docs/decisions/394-write-contract-callers.md) - **SPA E2E lives in `tools/simulation/ui/` (#277/#385).** Playwright drives the real built SPA over the same `seed --profile simulation` fixture k6 uses; `web/` stays Vitest. Three enforced rules, each of which has caught something: never hardcode a credential, never hardcode English, respect the farm clock. Anything reasoning about `inert` or the accessibility tree must go through CDP (`src/ax.ts`) — Playwright's own APIs do not model `inert` (#501). The suite runs in two projects partitioned by the `@phone` tag (#814) — `chromium` at 1280 and `chromium-phone` at 390, where the sidebar is `display: none`, so the `nav` fixture throws and a phone test uses `phone` instead. → [`277-spa-e2e.md`](docs/decisions/277-spa-e2e.md) - **Any PR that changes what a user sees attaches screenshots, captured from a stack rebuilt at the head under review (#662).** Not only a PR whose *purpose* is visual: a new dialog, a new field, a changed label or an altered state all qualify, because the trigger is what the reader can no longer check from the diff. **Before and after wherever a before exists**, the same viewport and the same scenario in both; after-only where the UI is net-new and there is nothing to compare against. It is the only check in this repo that reads the *rendered* result: on #661 it found the one product defect that four review seats, three CI Playwright runs and the driver's own verification all passed. Three mechanics matter — capture at 1:1, because downscaling destroys exactly the 1px hairlines and 4–5% alpha shadows a styling change is about; rebuild first, because a long-running sim stack serves the bytes it was built from, not the branch under review; and attach the images to the PR with `gh pr comment --attach` (or `gh pr create --attach`), **never** by committing them to a branch — a branch is deletable, so routine cleanup silently breaks the images on a merged PR while the prose around them still claims they exist. +- **Runtime verification uses the repo's `verify` skill at [`tools/verify/`](tools/verify/SKILL.md), and the tree is agent-agnostic.** It covers launching and doctoring the `cluckwork-sim` stack, driving it through the Playwright harness's fixtures and catalog labels, the proof standards, `capture.sh` for the four 1:1 frames the rule above asks for, and a per-feature map under `tools/verify/features/`. `.claude/skills/verify`, `.codex/skills/verify` and `.agents/skills/verify` are tracked symlinks to it, so every runtime that reads the Agent Skills layout loads the same file; edit the tree. Two rules it enforces: there is one sim stack (`reset.sh` runs `down -v` on the one compose project), so never reset it while another agent is driving it; and a running stack serves the bytes it was built from, so the doctor checks that the image came from the checkout under review. Run `/maintain-verification-skill` after a screen slice lands. - **Production logs: compact JSON on stdout; base layer argument-free (#404).** The base `appsettings.json` Console entry carries `Name` only — a template left beside the formatter is **silently** bound instead of it. Trace fields differ by format (`@tr`/`@sp` in compact JSON, `{TraceId}`/`{SpanId}` in the Dev template), and compact JSON serializes **every** property, so anything pushed via `BeginScope`/`LogContext` reaches the collector. → [`404-production-logs.md`](docs/decisions/404-production-logs.md) ## Secrets — never commit diff --git a/tools/verify/SKILL.md b/tools/verify/SKILL.md new file mode 100644 index 000000000..83dc9e693 --- /dev/null +++ b/tools/verify/SKILL.md @@ -0,0 +1,168 @@ +--- +name: verify +description: Launch, doctor, drive and capture evidence from the real Cluckwork app (the .NET API serving the built React SPA) to verify a change end-to-end in a real browser. Reach for it when a PR changes what a user sees or does, before claiming a UI change works, and for the before/after screenshots AGENTS.md requires. +--- + +# Verifying Cluckwork at runtime + +Cluckwork's user surface is the web SPA (`web/`) served by the API. The primary way to +verify it is the **simulation stack**: a Production-config app plus Postgres and Redis under +the `cluckwork-sim` compose project, seeded with a deterministic fixture and driven by the +Playwright suite in `tools/simulation/ui/`. Every revamp PR in September 2026 was verified +this way. The dev-server path (Compose Postgres, `dotnet run`, Vite) still exists for +debugging and is described at the end; it is not the evidence path. + +Read `features/README.md` for the per-feature map before driving anything: a proof that +takes one convenient entry point is incomplete when the map lists others. + +## Launch + +Run these in the repo root or in a worktree of the branch under review (the stack is built from the +checkout you run this in, so a worktree at the PR head captures the PR head): + +```sh +bash tools/simulation/bootstrap.sh # once per checkout: writes .env.sim + .sim-cast.json (git-ignored) +sg docker -c 'bash tools/simulation/reset.sh' # wipe, rebuild the image, migrate, seed both farms, self-check (about 4-6 min) +``` + +`sg docker` is required on this machine: the shell's group list predates the docker group +(see the global CLAUDE.md note). Ready is the last three lines of `reset.sh`'s output: + +``` +Manifest: .../tools/simulation/out/manifest.json +Cast: .../tools/simulation/.sim-cast.json +Farms: default-farm (simulation fixture), readme-farm (demo, README capture) +``` + +Run it in the background and poll for the `Farms:` line with an `until` loop in a +foreground call; never end a turn to wait on it. + +**Two farms come up.** `default-farm` is the simulation fixture: about 100 catalog flocks +that never file, so the Dashboard's Today list is capped at twelve unrecorded rows and the +fortnight strip has no complete day. `readme-farm` ("Meadowlark") is the demo profile: two +houses, one seeded as today's draft, real orders. **Capture screenshots on `readme-farm`**; +drive write flows on `default-farm`, which the smoke specs already do. + +**Isolation: there is exactly one stack.** Both farms live in one compose project on +`127.0.0.1:8081`, and `reset.sh` runs `down -v` on it. Never run `reset.sh` while another +agent is driving or capturing; check with the doctor first and coordinate through the +coordinator. Reading the running stack (a capture, a smoke run) is safe to share; a reset +is not. + +## Doctor + +Run this before driving whenever anything looks off. All four answers must be yes. + +```sh +sg docker -c 'docker ps --filter name=cluckwork-sim --format "{{.Names}} {{.Status}}"' # app, db, redis healthy +curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8081/health/ready # 200 +ls tools/simulation/.sim-cast.json tools/simulation/.env.sim # both present in THIS checkout +git rev-parse --short HEAD # the commit the image was built from is this checkout's HEAD +``` + +The fourth line is the one people skip: a long-running stack serves the bytes it was built +from, not the branch you are looking at. If the stack predates your head, reset. + +## Drive + +Use the harness, not a scratch `playwright-core` script: its fixtures sign in through the +real login form with credentials from `.sim-cast.json`, respect the farm clock, and run in +two projects (`chromium` at 1280x720, `chromium-phone` at 390x844, partitioned by the +`@phone` tag). + +```sh +cd tools/simulation/ui +npm test # the whole smoke suite, about 30 s +npx playwright test specs/owner.spec.ts --project=chromium # one spec +npx playwright test specs/phone.spec.ts --project=chromium-phone # the phone project +``` + +Inside a spec, the handles that matter: + +- `import { test, expect } from "../src/fixtures"` gives `page`, `signIn(member)`, `farm` + (timezone), `nav` (desktop shell), `phone` (tab bar and More sheet at 390). +- Personas from `../src/cast`: `owner()`, `castMember("Manager")`, + `unrestrictedWorker()`, `restrictedWorker()`, `readmeFarmOwner()` (the demo farm; its + `farmCode` is `readme-farm`). Never type a credential; never print one. +- Labels through `tEn(":")` from `../src/i18n`, never hardcoded English: + `tEn("dailyEntry:flockLabel")`, `tEn("sales:newOrder")`, `tEn("stock:lotsHeading")`. +- `commitNamedPicker(page, tEn("dailyEntry:flockLabel"), "Sim House A")` from `../src/dom` + drives the searchable picker; `selectOptionContaining(select, needle)` for plain selects. +- Anything about `inert` or the accessibility tree goes through CDP in `../src/ax.ts`; + Playwright's own APIs do not model `inert` (#501). +- Rows on the new Dashboard are `getByRole("group", { name: })`; the sales + list is `getByRole("list", { name: tEn("dashboard:salesPanelTitle") })`; footer actions + are buttons by their catalog label. + +A throwaway spec for a one-off proof goes under `specs-screenshots/` with a name ending in +`-screenshots.spec.ts` (that config's `testMatch`) and is deleted before you commit; the +`capture.sh` helper below writes and removes one for you. + +## Evidence + +Proof standards, in order of how often they are skipped: + +1. **Exercise the real user path.** Sign in through the form, navigate by the nav or the + URL, click the control the user clicks. No test-only endpoints, no internal setters. +2. **Capture the action and the resulting state**, not only the final screen: a Draft row + after saving, the stock lot after a write-off, the order after a payment. +3. **Verify side effects** alongside the pixels: the API's row through + `GET /api/v1/...` with the persona's token, or the next screen that reads it (History for + an adjusted entry, Stock for an allocated order). +4. **Screenshots are 1:1**: viewport only, device scale factor 1, no full-page smears of + sticky bars, 1280x800 and 390x844, light and dark, from a stack rebuilt at the head under + review. Before frames come from `origin/main` rebuilt the same way. +5. **Where it goes**: `/tmp//{before,after}--.png`. Attach to the PR with + `gh pr comment -R mforce/cluckwork --attach './after-1280-light.png#After, 1280 light'`; + never commit images to a branch (a branch is deletable and takes the evidence with it). + Also record console errors (`page.on("pageerror")`, CSP refusals) in the PR comment when + they appear; `csp-nonce.spec.ts` shows the assertion. + +`capture.sh` does 4 and writes the files for 5: + +```sh +tools/verify/capture.sh [route] [farm] [prefix] +# examples +tools/verify/capture.sh 883-after / readme # Dashboard, four frames +tools/verify/capture.sh 888-after /daily-entry readme +tools/verify/capture.sh 888 /daily-entry readme before # on a stack built from main +``` + +It signs in as the named farm's owner, sets the theme through `data-theme`, captures the +four frames into `/tmp//`, prints any console errors it saw, and removes its +throwaway spec. It reads the running stack; it never resets it. One `401 (Unauthorized)` +resource error per run is the sign-in page's session probe on a fresh browser context, +observed on every proof run against a healthy stack; anything else in that line is a +finding to report. + +## Cleanup + +- Remove any throwaway spec you wrote under `specs-screenshots/` and any scratch files in + the worktree (`git status --short` must be empty of your leftovers before the commit). +- Leave the stack up unless you started it for yourself and nobody else is driving it. To + take it down: `sg docker -c 'docker compose -p cluckwork-sim down -v'` from the checkout + that started it. Never kill by process name. +- Evidence survives cleanup: `/tmp//` stays, and the attached PR comment is the + durable copy. + +## The dev-server path (debugging, not evidence) + +Useful when you need hot reload or a debugger, not for proofs. First check that no Aspire +AppHost owns the ports (`pgrep -af 'Cluckwork\.AppHost'`, `ss -ltn | grep ':18888\b'`, or +`aspire describe --apphost src/Cluckwork.AppHost/Cluckwork.AppHost.csproj --format Json --non-interactive`); +Aspire's Postgres is a different database with a generated credential (#565), so do not +run both. + +```sh +docker compose -f deploy/docker-compose.dev.yml up -d --wait # Postgres 5432 +ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://127.0.0.1:8080 \ + dotnet run --project src/Cluckwork.Api --no-launch-profile # API; the env var is load-bearing +cd web && npm run dev # Vite on 5173, proxies /api +``` + +A fresh database has no Owner: provision one per +`docs/runbooks/first-admin-provisioning.md` and use that printed password. Seeded Compose +credentials: `dotnet user-secrets list --project src/Cluckwork.Api | grep '^Seed:'`. API +writes need `Authorization: Bearer` and an `Idempotency-Key: $(uuidgen)` header. +Teardown: stop the two processes you started, then +`docker compose -f deploy/docker-compose.dev.yml down`. diff --git a/tools/verify/capture.sh b/tools/verify/capture.sh new file mode 100755 index 000000000..1185d80f7 --- /dev/null +++ b/tools/verify/capture.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Capture four 1:1 frames of one route from the RUNNING simulation stack: +# 1280x800 and 390x844, light and dark, into /tmp//after--.png. +# Reads the stack; never resets it. Signs in as the farm's owner from .sim-cast.json. +# +# tools/verify/capture.sh [route=/] [farm=readme|default] [prefix=after] +set -euo pipefail + +slug="${1:?slug, e.g. 883-after}" +route="${2:-/}" +farm="${3:-readme}" +prefix="${4:-after}" + +# The four arguments are interpolated into a generated spec and a /tmp path, so +# each is held to a literal alphabet: no quotes, no braces, no path separators +# in the names, and the route is an absolute app path with a query at most. +name_re='^[A-Za-z0-9][A-Za-z0-9._-]*$' # must start alphanumeric: rules out . and .. +route_re='^/[A-Za-z0-9._/?=&-]*$' +[[ "$slug" =~ $name_re ]] || { echo "slug must match $name_re" >&2; exit 2; } +[[ "$prefix" =~ $name_re ]] || { echo "prefix must match $name_re" >&2; exit 2; } +[[ "$route" =~ $route_re ]] || { echo "route must be an absolute app path like /daily-entry" >&2; exit 2; } + +root="$(git rev-parse --show-toplevel)" +ui="$root/tools/simulation/ui" +out="/tmp/$slug" +spec="$ui/specs-screenshots/verify-capture-$slug-screenshots.spec.ts" + +case "$farm" in + readme) persona="readmeFarmOwner()" ;; + default) persona="owner()" ;; + *) echo "farm must be readme or default" >&2; exit 2 ;; +esac + +[ -f "$root/tools/simulation/.sim-cast.json" ] || { echo "no .sim-cast.json in $root: run tools/simulation/bootstrap.sh" >&2; exit 1; } +curl -fsS -o /dev/null http://127.0.0.1:8081/health/ready || { echo "stack not ready on 127.0.0.1:8081" >&2; exit 1; } +[ -d "$ui/node_modules" ] || (cd "$ui" && npm ci --silent) + +mkdir -p "$out" +cleanup() { rm -f "$spec"; } +trap cleanup EXIT + +cat > "$spec" < = [ + [1280, 800, "light"], [1280, 800, "dark"], [390, 844, "light"], [390, 844, "dark"], +]; + +test.describe("verify capture $slug", () => { + for (const [w, h, theme] of SHOTS) { + test(\`verify-capture \${w} \${theme}\`, async ({ page, signIn }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(String(e))); + page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); + await page.setViewportSize({ width: w, height: h }); + await page.emulateMedia({ colorScheme: theme }); + await signIn($persona); + await page.goto(ROUTE); + await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, theme); + await page.waitForTimeout(800); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await page.screenshot({ path: \`\${OUT}$prefix-\${w}-\${theme}.png\`, animations: "disabled" }); + if (errors.length) console.log(\`[console errors at \${w} \${theme}] \` + errors.join(" | ")); + }); + } +}); +EOF + +set +e +(cd "$ui" && npx playwright test --config playwright.screenshots.config.ts --grep "verify-capture" --project=chromium 2>&1 | grep -E "passed|failed|console errors|Error") +status=${PIPESTATUS[0]} +set -e +for f in "$out/$prefix-1280-light.png" "$out/$prefix-1280-dark.png" "$out/$prefix-390-light.png" "$out/$prefix-390-dark.png"; do + [ -s "$f" ] || { echo "missing evidence: $f" >&2; exit 1; } +done +[ "$status" -eq 0 ] || { echo "playwright exited $status" >&2; exit "$status"; } +ls -1 "$out" diff --git a/tools/verify/features/README.md b/tools/verify/features/README.md new file mode 100644 index 000000000..35a66828d --- /dev/null +++ b/tools/verify/features/README.md @@ -0,0 +1,23 @@ +# Feature map + +One file per user-facing feature. Each answers, from the user's side: what it is, how to +reach it, how to drive it with the harness in `tools/simulation/ui/`, and what end state +proves it works. A proof that drives one entry point is incomplete when the file lists +others. Keep this map honest with `/maintain-verification-skill` when the app changes. + +| Feature | Route | Personas | Existing specs | +| --- | --- | --- | --- | +| [Dashboard](dashboard.md) | `/` | every role | `owner.spec.ts`, `phone.spec.ts`, `readonly.spec.ts` | +| [Daily entry](daily-entry.md) | `/daily-entry` | Worker, Manager, Owner | `worker.spec.ts`, `manager.spec.ts`, `phone.spec.ts` | +| [Sales](sales.md) | `/sales`, `/customers` | Sales, Manager, Owner | `sales.spec.ts`, `worker-sale-allocation.spec.ts` | +| [Stock](stock.md) | `/stock`, `/inventory` | every role reads; Manager, Owner write | `manager.spec.ts`, `readonly.spec.ts`, `pagination.spec.ts` | +| [Flocks](flocks.md) | `/flocks` | Manager, Owner | `manager.spec.ts`, `named-entity-picker.spec.ts` | + +Not yet mapped (routes exist, add a file when you first verify one): `/feed`, `/water`, +`/history`, `/reports` (`reports-range.spec.ts`), `/expenses`, `/settings` (Owner only, +#729), `/grades`, `/products`, `/users`, `/audit`, `/export`, `/account`, `/help`. + +Cross-cutting guarantees with their own specs, not features: session refresh and races +(`session-*.spec.ts`), i18n across es and tl (`i18n.spec.ts`), the PWA shell offline +(`pwa.spec.ts`), the CSP nonce (`csp-nonce.spec.ts`), live regions under a modal +(`a11y-live-regions.spec.ts`). diff --git a/tools/verify/features/daily-entry.md b/tools/verify/features/daily-entry.md new file mode 100644 index 000000000..ceac15012 --- /dev/null +++ b/tools/verify/features/daily-entry.md @@ -0,0 +1,52 @@ +# Daily entry + +The screen a worker uses standing in the shed: pick the house and the date, count eggs +and losses, grade them, save a draft or submit the day. Submitting creates egg lots. +Field-first on the phone per #830. + +## Sub-features + +- Context: flock picker (searchable, `commitNamedPicker`), date, "+ new flock". +- Egg counts pane: total eggs, cracked, dirty, discarded, mortality, each a stepper row. +- Grading pane: one stepper row per sellable grade, "Counted N" in the head, the sum row + that says whether the day adds up. +- Footer: Save draft and Submit day side by side (the #740 exemption; both 48px). +- Draft state line under the title ("Draft, saved HH:MM" in the farm clock). +- Adjust and void after submit happen on History, not here (`manager.spec.ts`). + +## How to get to it (user POV) + +Nav: Production, Daily entry, or the Daily entry tab on the phone. Route `/daily-entry`. +A Worker sees only assigned flocks in the picker (#388). + +## Driving it with Playwright + +```ts +import { test, expect } from "../src/fixtures"; +import { unrestrictedWorker } from "../src/cast"; +import { commitNamedPicker } from "../src/dom"; +import { farmToday } from "../src/farm"; +import { tEn } from "../src/i18n"; + +await signIn(unrestrictedWorker()); +await page.goto("/daily-entry"); +await page.getByLabel(tEn("dailyEntry:dateLabel")).fill(farmToday(farm.timeZoneId)); +await commitNamedPicker(page, tEn("dailyEntry:flockLabel"), "Sim House A"); +await page.getByLabel(tEn("dailyEntry:totalEggsLabel"), { exact: true }).fill("40"); +await page.getByRole("button", { name: tEn("dailyEntry:saveDraftButton") }).click(); +await expect(page.getByText(tEn("dailyEntry:draftSavedMessage"))).toBeVisible(); +``` + +Proof: after Submit day, `/stock` shows new lots for that flock and date, and the +Dashboard's Today row for the house reads recorded with the time. `worker.spec.ts` records +by grade; `manager.spec.ts` submits then adjusts and voids on History. + +## Gotchas + +- Respect the farm clock: fill dates with `farmToday(farm.timeZoneId)`, never the + machine's date. +- A restricted Worker is refused on an unassigned flock (#388); use + `unrestrictedWorker()` for write proofs. +- The number field keeps its own stepper logic (#828 owns its conversion); the row grid is + the screen's. Hold-to-repeat is real, so do not hold a button while asserting. +- The phone footer must stay clear of the tab bar (`phone.spec.ts` pins the margin). diff --git a/tools/verify/features/dashboard.md b/tools/verify/features/dashboard.md new file mode 100644 index 000000000..1ba89563d --- /dev/null +++ b/tools/verify/features/dashboard.md @@ -0,0 +1,52 @@ +# Dashboard + +The morning screen: which houses have filed today, eggs on hand by grade, the last +fortnight's production, recent orders. Since #883 it is the ruled ledger from +`docs/designs/864-visual-language/` at 1280 and a ruled house list at 390. + +## Sub-features + +- Attention line under the title: missing houses first, folding into "+N more" (two items + at 1280, one at 390). +- Today: one ruled row per active house with entry state and time, a Record button (the + single filled button on the page) on an unrecorded house, Continue as ruled text on a + draft, a count on the right, "Today so far" with "Yesterday by close: N" under it. Capped + at twelve rows. +- Recent sales: customer with the order number under it, amount, status as dot plus word, + "Review to confirm" on a draft. No eggs-and-grade cell yet (#887). +- Stock: eggs available, the stacked grade bar, the grade table. +- Last 14 days: the day strip with the hen-day figure and delta. + +## How to get to it (user POV) + +Sign in; it is the landing route `/`. On the phone it lives under More in the tab bar +(Dashboard is not one of the four tabs). + +## Driving it with Playwright + +```ts +import { test, expect } from "../src/fixtures"; +import { owner, readmeFarmOwner } from "../src/cast"; +import { tEn } from "../src/i18n"; + +await signIn(readmeFarmOwner()); // demo farm: two houses, one draft, real orders +await page.goto("/"); +await expect(page.getByRole("heading", { name: tEn("dashboard:title") })).toBeVisible(); +const row = page.getByRole("group", { name: "House 2 layers" }); // one Today row +await expect(row.getByRole("link", { name: /Record/ })).toBeVisible(); // the filled action +const sales = page.getByRole("list", { name: tEn("dashboard:salesPanelTitle") }); +await expect(sales.getByRole("listitem").first()).toBeVisible(); +``` + +Proof: the row's count changes after a daily entry is submitted for that house (drive +`/daily-entry`, come back, read the number); a draft order shows "Review to confirm" and +the link lands on `/sales` filtered to that customer. + +## Gotchas + +- On `default-farm` every catalog flock is unrecorded, so the Today list is twelve amber + bands and a draft you create never reaches it; use `readme-farm` for anything that needs + a recorded or draft row. +- The fortnight strip hatches days where any active house did not file; on the demo farm + the first seven days are hatched by the seed (#886). +- Section headings that are links are underlined by design (#884). diff --git a/tools/verify/features/flocks.md b/tools/verify/features/flocks.md new file mode 100644 index 000000000..34e044dd4 --- /dev/null +++ b/tools/verify/features/flocks.md @@ -0,0 +1,45 @@ +# Flocks + +The houses: create a flock (name, breed, placed date, bird count), see its status and bird +movements, deplete it at end of lay. + +## Sub-features + +- Flock list with status and current bird count. +- New flock dialog (`tEn("flocks:newFlockButton")`, `newFlockDialogTitle`, `nameLabel`, + `breedLabel`, `placedLabel`, `birdsLabel`, `addFlockButton`). +- Bird movement ledger per flock, paged (`pagination.spec.ts`, "Sim House A's + bird-movement ledger reveals its page-two sentinel"). +- Flock scoping: a Worker's assignments decide which flocks appear in pickers (#388, + #613). + +## How to get to it (user POV) + +Nav: Production, Flocks. Route `/flocks`. Also reachable from Daily entry's "+ new flock". + +## Driving it with Playwright + +```ts +await signIn(castMember("Manager")); +await page.goto("/flocks"); +await page.getByRole("button", { name: tEn("flocks:newFlockButton") }).click(); +const dialog = page.getByRole("dialog", { name: tEn("flocks:newFlockDialogTitle") }); +await dialog.getByLabel(tEn("flocks:nameLabel")).fill(`E2E Flock ${Date.now()}`); +await dialog.getByLabel(tEn("flocks:breedLabel")).fill("E2E Leghorn"); +await dialog.getByLabel(tEn("flocks:placedLabel")).fill(farmToday(farm.timeZoneId)); +await dialog.getByLabel(tEn("flocks:birdsLabel"), { exact: true }).fill("50"); +const created = page.waitForResponse((r) => r.url().includes("/api/v1/flocks") && r.request().method() === "POST" && r.ok()); +await dialog.getByRole("button", { name: tEn("flocks:addFlockButton") }).click(); +const { id } = await (await created).json(); +``` + +Proof: the new flock appears in the list and in the Daily entry picker (`commitNamedPicker` +resolves the same id); `manager.spec.ts` continues into an entry, an adjustment and a +void. + +## Gotchas + +- Names starting with `E2E` sort ahead of the seeded `Sim` ones and persist until the next + reset; a capture taken after a smoke run photographs test data. +- The Dashboard caps Today at twelve rows, so a new flock on `default-farm` is not visible + there; prove creation on `/flocks` or in the picker. diff --git a/tools/verify/features/sales.md b/tools/verify/features/sales.md new file mode 100644 index 000000000..9d3aec071 --- /dev/null +++ b/tools/verify/features/sales.md @@ -0,0 +1,49 @@ +# Sales + +Customers and orders: take an order, confirm it (which allocates stock FIFO from egg lots), +record payment. The Dashboard's Recent sales list reads the same orders. + +## Sub-features + +- Customers list and "New customer" (`/customers`). +- New order: customer picker, date, lines (product, unit egg, quantity), "Add line". +- Draft order, then confirm: allocation decrements stock; an under-stocked confirm is + refused with the shortfall. +- Record payment: method, amount; "Unpaid only" filter. +- Status vocabulary: Draft, Confirmed, Allocated, Paid, as dot plus word. + +## How to get to it (user POV) + +Nav: Sales & stock, Sales; the Sales tab on the phone. Route `/sales`; customers at +`/customers`. The Dashboard's "Review to confirm" link lands here filtered by customer. + +## Driving it with Playwright + +`sales.spec.ts` drives the whole path "takes an order from new customer through to a +recorded payment". The handles: + +```ts +await page.goto("/customers"); +await page.getByRole("button", { name: tEn("customers:newCustomerButton") }).click(); +// fill the dialog, save +await page.goto("/sales"); +await page.getByRole("button", { name: tEn("sales:newOrder") }).click(); +await commitNamedPicker(page, tEn("sales:customer"), customerName); +await page.getByRole("button", { name: tEn("sales:addLine") }).click(); +await commitNamedPicker(page, tEn("sales:product"), "Large"); +// quantity, unit tEn("sales:unitEgg"), save the draft, confirm, then +await page.getByRole("button", { name: tEn("sales:recordPayment") }).click(); +``` + +Proof: the order's status walks Draft, Confirmed, Paid on the list; `/stock` shows the +allocated lots decremented; `worker-sale-allocation.spec.ts` proves a Worker cannot +allocate. + +## Gotchas + +- Confirming needs stock: on a fresh reset the fixture has lots; after many smoke runs the + deep grades may be short, and the refusal is a legitimate outcome to assert, not a flake. +- The searchable pickers are paged: `named-entity-picker.spec.ts` shows how a customer on + page two is reached through search. +- Money is displayed in the farm's currency and locale; assert on the catalog string, not + on a formatted number typed by hand. diff --git a/tools/verify/features/stock.md b/tools/verify/features/stock.md new file mode 100644 index 000000000..0ff51c6bb --- /dev/null +++ b/tools/verify/features/stock.md @@ -0,0 +1,42 @@ +# Stock + +Eggs on hand by grade, the lots behind each grade, and the movement ledger: allocations +to orders, write-offs, adjustments. + +## Sub-features + +- Stock by grade with the stacked bar (`StockBar`, also on the Dashboard). +- Lots per grade ("Lots" button, `tEn("stock:lotsHeading")`), paged with "Load more" + (#465). +- Write-off from a lot (`tEn("stock:writeOffButton")`, then the submit button). +- Movement ledger with a date range (`tEn("stock:fromLabel")`, `tEn("stock:toLabel")`). +- General inventory (feed and supplies) is a separate screen at `/inventory`. + +## How to get to it (user POV) + +Nav: Sales & stock, Stock; the Stock tab on the phone. Route `/stock`. + +## Driving it with Playwright + +```ts +await signIn(castMember("Manager")); +await page.goto("/stock"); +await page.getByRole("button", { name: tEn("stock:lotsButton") }).first().click(); +await expect(page.getByRole("heading", { name: tEn("stock:lotsHeading") })).toBeVisible(); +await page.getByRole("button", { name: tEn("stock:writeOffButton") }).first().click(); +// fill the quantity and reason, then +await page.getByRole("button", { name: tEn("stock:writeOffSubmitButton") }).click(); +``` + +Proof: the grade's available count drops by the written-off quantity, the ledger shows the +movement with the actor, and the daily entry that created the lot is untouched +(`manager.spec.ts` "writes off lost stock from its own lot without touching the entry"). +`readonly.spec.ts` proves a ReadOnly user pages lots with Load more and cannot write. + +## Gotchas + +- A deep grade has more lots than one page; assert after "Load more", not before. +- Write-offs need a lot with quantity; pick the first lot of the largest grade on a fresh + reset. +- The bar's grade hues are the only place grade colour appears (direction); do not assert + on colour elsewhere.