Nanook App Runner — Table-Driven Test Execution for an Application
This skill describes the execution side of table-driven testing: how the test cases
that Nanook tables generate become a running Playwright (or API) test suite for an
application — without hand-written spec files per case.
| Question |
Skill |
| How do I write the tables (EqClasses, CASCADE, 100%, references)? |
create-equivalence-class-table |
| How do the tables become a running test execution for an app? |
this one |
The Architecture in One Picture
Table (.xlsx, source of truth)
│ build step (nanook-table + a suite writer)
▼
suites/*.json ← NO Playwright vocabulary (deliberately)
│ case = base state + actions + expectation (ui/api)
├────────────► API runner checks the API boundary
▼
Browser runner (Playwright) checks the UI boundary
│ calls per app only a small "connection" object
▼
<app>/tests/suites.spec.ts thin layer: ONE call into the shared runner
The division of labor — each row is a hard boundary:
|
who |
why |
| Which cases exist |
table |
the domain opinion, editable without TypeScript |
| What the page can do and shows |
page object |
abstracts the UI, never judges |
| Whether it is correct |
shared runner |
the expectation stays in ONE place |
| What the app does differently |
app connection |
base state + entry point — and ONLY those |
| Base state (data) |
API |
~50 ms instead of 10–20 s per case |
| Path to the page |
clicked |
only then is "arrived" a statement |
Why the runner exists ONCE. Before consolidation there were eleven suite-reading
specs (89–400 lines, four fifths identical scaffolding), later two per-app copies — and
every copy accumulated its own subtleties, of which nobody could say whether they were
intent or oversight. Copying the runner for a new app instead of connecting to it
rebuilds exactly that.
Why no Playwright terms in the suite JSON. The same file drives both the API runner
and the browser runner — the same cases hit two independently maintained rule sets. One
browser term in the data destroys that lever.
Connecting a New Application — Checklist
Register the app with the tooling (an apps registry the build step and all
guards iterate). An app that is not registered has suites nobody ever checks — the
guard should throw on a registered-but-unwired app rather than skip it silently.
Own port stack per app so two sessions never share ports — shared ports are the
most common source of misattributed measurements.
App folder skeleton:
<app>/
├── app.ts app constants (URLs, run markers)
├── base-state.ts API seeders: createUser, transferSession, create<Entity>…
├── browser.ts cookie banner / consent handling (stays per app)
├── pages/
│ ├── register.ts suite pattern → page object (the registry)
│ └── <page>.ts one module per form/list, under the page contract
├── suites/*.json generated — NEVER written or amended by hand
├── tests/suites.spec.ts thin layer: one call into the shared runner
└── playwright.config.ts workers: 1 until concurrency is MEASURED safe
The app connection object — deliberately small; every extra field is a place
where apps may drift apart again:
| Field |
What |
suitesDir |
the app's suites/ folder |
register |
suite pattern → page object. A suite without a registry entry is reported, never skipped — otherwise a new table counts as "tested" with zero cases |
countryFrom |
locale/country derived from the suite name |
cookieBanner |
consent handling, passed in per app |
baseState |
establishes the starting point, returns the session cookie (lists seed their inventory with it — a second login would be a second user) |
entry |
entry page + proof of login; differs per app and should be measured, not assumed (some apps redirect / to /login despite a valid session) |
runtimeMarkers? |
markers whose value only this run knows (marker::currentUserEmail). Omitting is a statement ("my pages resolve their own"), not an oversight |
Page Objects — the Page Contract
The contract lives once, shared by all apps. Never copy it per app: two classes of
the same name are not the same class, and the shared runner's instanceof will report
correct app behavior as a test failure.
The four ground rules
- No
expect inside a page object. The page observes and computes; the runner
judges. A module with its own assertion moves the expectation to where the domain
owner no longer sees it (it belongs in the table).
Page only as import type. The module delivers data; it does not own a browser.
- Signatures come from the declared contract, not from the neighboring module.
Agreement that exists only because everyone copied the same example is a habit, not an
interface — a missing return value silently becomes
undefined; only a declared type
turns the deviation into a compiler error.
- Three outcomes, not two: accepted / rejected AND
NotEnterable — "the UI cannot
even be asked this question" (a filtered dropdown cannot take an invalid value). The
application behaves correctly; without the distinction that correctness lands in the
defect list.
The two page kinds
Create page (kind: 'create'): goThere(page, ctx) → operate(page, props) →
reaction(page) → optional readBack(page, wasSet).
operate returns the prop names this form does not have — don't throw, don't
swallow: a field the table knows and the form lacks is a report finding, not a case
abort.
- The reaction carries a
source (form | browser | server) — "the form catches it"
versus "only the server does" is a statement. The third state silent (form still
there, nothing reported) is the worst one and needs its own word.
readBack receives the values that were set and returns what was read plus
deviations — whether a deviation is bad is the runner's call.
List page (kind: 'list'): readState → inventory(authCookie) →
instructions(tc) → check(state, inventory, instructions).
inventory() RETURNS the seeded records — the return value is the point. What the
list should show is computed from it, never asserted next to it. A seeding function
without a return value lets expectation and inventory drift apart.
- Filter/sort/search knowledge lives in the page object (
instructions), not in the
runner — every list has different columns, states, and quirks; that is exactly why.
check returns findings, not an abort: a list with wrong metric tiles AND wrong
sort order should say both.
- Read row fields through a guarded accessor that throws on a missing key, never via
direct property access: a dead reader makes a sort check not red but empty
(
[undefined, …] is "sorted"), and empty looks exactly like correct.
- No
? on fields that a check reads: the ? switches the compiler off as a witness —
a module can then read a field it never sets, and the check passes emptily.
basis — what the page presupposes
Each page declares the base state it needs as one word (e.g. nothing | user |
signedIn | tenant), not as a function: what gets built is the app's business (it has
the context, page, and credentials). Notes:
user is not the weaker form of signedIn but the opposite statement: a user exists
and the browser is signed out — needed only when the login form itself is the test
subject.
- The type is the union over all apps; enforcement is a runtime statement: a
base-state builder receiving a value its app doesn't know throws ("this app has no
tenant concept").
Base States — the Login Optimization
Data via API, path via clicks. Setup through the UI costs 10–20 s per case, via
API ~50 ms — factor 200–400. The path to the page under test is still clicked: only
then is "arrived" a statement.
The pattern (with email verification enabled):
1) POST /api/auth/sign-up create the user (unconfirmed)
2) confirmation link from the mail mock — via the FRONTEND URL, like a real user
3) POST /api/auth/sign-in → set-cookie: session token
4) context.addCookies(...) → the browser is signed in
Four traps, all real:
- The
Origin header is mandatory for auth frameworks like better-auth when
calling from Node — without it: 403 Missing or null Origin.
- The mail-mock port is a parameter, not an assumption — a QA run may start the
mock on a free port; the mail is then there and nowhere else.
- Mock mail lists are paginated — without searching by address and a sufficient
limit, your own mail hides behind everyone else's.
- A registration is unique per database. Suites carry the addresses rolled at
generation time; from the second run on they are taken — and "user already exists" is
a rejection that turns a case green which expected a DIFFERENT rejection. Put a
per-run marker into every address; for over-length addresses insert it
length-preserving so the boundary case stays a boundary case.
The table's secondary data is the pre-state — the base-state builder reads it and
establishes it. Verify every key you read against a real suite file: a reader
comparing against a key no suite ever writes is wrong in every run, silently, and the
run stays green while the domain case never executes.
Resource Pooling by Effect Profile
The pool key is the effect profile, not the test case. 15 permission profiles
across 400 test cases means ~15 users, not 400 — registration + confirmation + login
is the most expensive setup, and it differs only per profile.
This is the same dedup rule as for base states in the tables ("a base state may only
exist if at least one assertion differs because of it"), applied to runtime:
- In the table the permission profile is a secondary-data class (or a base state
with
Execute = F): permissions: read_only, permissions: accountant, …
Deduplicate profiles BEFORE building — two profiles with identical effect profiles
are one duplicate that costs runtime forever.
- In the base-state builder lives the pool: a map profile → credentials, filled
lazily — the first case of a profile creates the user, all later ones reuse it.
- Group execution by base state so setup runs once per group (suites cut per table
already do this; for cross-cutting pools use a worker-scoped fixture).
The counter-rule, without which the saving gets expensive — share only what the test
does not mutate:
| Case |
User |
| Test checks what a profile may see/do (permission checks, lists, reads) |
shared, from the pool |
| Test mutates user-owned state (password, profile, settings, inbox) |
own user — or the pool user after the case is not the one before |
| Test mutates tenant-owned state |
own tenant, OR serialize those cases — a shared user is a collision domain |
Parallelism stays a measurement: a pool additionally couples cases of the same profile.
Whoever raises workers first measures that pool users are collision-free.
Runner Invariants You Must Not Break When Connecting
- Suites are never written or amended by hand — changes go table → build → new
JSON. A test case without a table row is an error.
- The suite format knows no framework vocabulary — one file, two runners, two
independent rule sets checking the same case.
- Directives and markers are resolved by the RUNNER, never by the data or the page.
Measured failure mode: a runner sent
gen::blank as a literal string — the "field
empty" cases were green because the server rejected the eleven characters as an
invalid address, and the actual question was never asked. An unknown directive prefix
throws.
- Gaps are a third outcome, never a silent skip: a case without a fixture is
counted and named (a
gap outcome in the API runner, test.skip with a reason in
the browser) — neither success nor failure.
Verification After Connecting
- Get a base-state-only spec green first (setup isolated, no test subject).
- Run one suite, then the full run.
- Read the runner's report list: "suite without a page" is a finding, not noise — every
reported suite needs a registry entry or a deliberate
apiOnly marking.
1---2name: nanook-app-runner3description: Connects an application to a table-driven Playwright test execution: one shared runner over generated suite files, page objects under a common contract, base states created via API (login optimization), and resource pooling by effect profile. Execution side — table authoring is create-equivalence-class-table. Trigger: "connect app to nanook", "table-driven test execution", "page object contract", "base state setup", "suite runner", "user pooling", "nanook app runner"4---56# Nanook App Runner — Table-Driven Test Execution for an Application78This skill describes the **execution side** of table-driven testing: how the test cases9that Nanook tables generate become a running Playwright (or API) test suite for an10application — without hand-written spec files per case.1112| Question | Skill |13|---|---|14| How do I write the tables (EqClasses, CASCADE, 100%, references)? | `create-equivalence-class-table` |15| How do the tables become a running test execution for an app? | **this one** |1617## The Architecture in One Picture1819```20Table (.xlsx, source of truth)21 │ build step (nanook-table + a suite writer)22 ▼23suites/*.json ← NO Playwright vocabulary (deliberately)24 │ case = base state + actions + expectation (ui/api)25 ├────────────► API runner checks the API boundary26 ▼27Browser runner (Playwright) checks the UI boundary28 │ calls per app only a small "connection" object29 ▼30<app>/tests/suites.spec.ts thin layer: ONE call into the shared runner31```3233The division of labor — each row is a hard boundary:3435| | who | why |36|---|---|---|37| Which cases exist | **table** | the domain opinion, editable without TypeScript |38| What the page can do and shows | **page object** | abstracts the UI, **never judges** |39| Whether it is correct | **shared runner** | the expectation stays in ONE place |40| What the app does differently | **app connection** | base state + entry point — and ONLY those |41| Base state (data) | **API** | ~50 ms instead of 10–20 s per case |42| Path to the page | **clicked** | only then is "arrived" a statement |4344**Why the runner exists ONCE.** Before consolidation there were eleven suite-reading45specs (89–400 lines, four fifths identical scaffolding), later two per-app copies — and46every copy accumulated its own subtleties, of which nobody could say whether they were47intent or oversight. Copying the runner for a new app instead of connecting to it48rebuilds exactly that.4950**Why no Playwright terms in the suite JSON.** The same file drives both the API runner51and the browser runner — the same cases hit two independently maintained rule sets. One52browser term in the data destroys that lever.5354## Connecting a New Application — Checklist55561. **Register the app with the tooling** (an `apps` registry the build step and all57 guards iterate). An app that is not registered has suites nobody ever checks — the58 guard should **throw** on a registered-but-unwired app rather than skip it silently.592. **Own port stack per app** so two sessions never share ports — shared ports are the60 most common source of misattributed measurements.613. **App folder skeleton:**6263 ```64 <app>/65 ├── app.ts app constants (URLs, run markers)66 ├── base-state.ts API seeders: createUser, transferSession, create<Entity>…67 ├── browser.ts cookie banner / consent handling (stays per app)68 ├── pages/69 │ ├── register.ts suite pattern → page object (the registry)70 │ └── <page>.ts one module per form/list, under the page contract71 ├── suites/*.json generated — NEVER written or amended by hand72 ├── tests/suites.spec.ts thin layer: one call into the shared runner73 └── playwright.config.ts workers: 1 until concurrency is MEASURED safe74 ```75764. **The app connection object** — deliberately small; every extra field is a place77 where apps may drift apart again:7879 | Field | What |80 |---|---|81 | `suitesDir` | the app's `suites/` folder |82 | `register` | suite pattern → page object. A suite without a registry entry is **reported**, never skipped — otherwise a new table counts as "tested" with zero cases |83 | `countryFrom` | locale/country derived from the suite name |84 | `cookieBanner` | consent handling, passed in per app |85 | `baseState` | establishes the starting point, returns the session cookie (lists seed their inventory with it — a second login would be a second user) |86 | `entry` | entry page + proof of login; differs per app and should be **measured**, not assumed (some apps redirect `/` to `/login` despite a valid session) |87 | `runtimeMarkers?` | markers whose value only this run knows (`marker::currentUserEmail`). Omitting is a **statement** ("my pages resolve their own"), not an oversight |8889## Page Objects — the Page Contract9091The contract lives **once**, shared by all apps. Never copy it per app: two classes of92the same name are not the same class, and the shared runner's `instanceof` will report93correct app behavior as a test failure.9495### The four ground rules96971. **No `expect` inside a page object.** The page **observes and computes**; the runner98 judges. A module with its own assertion moves the expectation to where the domain99 owner no longer sees it (it belongs in the table).1002. **`Page` only as `import type`.** The module delivers data; it does not own a browser.1013. **Signatures come from the declared contract, not from the neighboring module.**102 Agreement that exists only because everyone copied the same example is a habit, not an103 interface — a missing return value silently becomes `undefined`; only a declared type104 turns the deviation into a compiler error.1054. **Three outcomes, not two**: accepted / rejected AND `NotEnterable` — "the UI cannot106 even be asked this question" (a filtered dropdown cannot take an invalid value). The107 application behaves correctly; without the distinction that correctness lands in the108 defect list.109110### The two page kinds111112**Create page** (`kind: 'create'`): `goThere(page, ctx)` → `operate(page, props)` →113`reaction(page)` → optional `readBack(page, wasSet)`.114115- `operate` returns the prop names **this form does not have** — don't throw, don't116 swallow: a field the table knows and the form lacks is a report finding, not a case117 abort.118- The reaction carries a `source` (`form` | `browser` | `server`) — "the form catches it"119 versus "only the server does" is a statement. The third state `silent` (form still120 there, nothing reported) is the worst one and needs its own word.121- `readBack` receives the values that were set and returns what was read plus122 deviations — whether a deviation is bad is the runner's call.123124**List page** (`kind: 'list'`): `readState` → `inventory(authCookie)` →125`instructions(tc)` → `check(state, inventory, instructions)`.126127- **`inventory()` RETURNS the seeded records — the return value is the point.** What the128 list should show is **computed** from it, never asserted next to it. A seeding function129 without a return value lets expectation and inventory drift apart.130- Filter/sort/search knowledge lives in the page object (`instructions`), not in the131 runner — every list has different columns, states, and quirks; that is exactly why.132- `check` returns **findings**, not an abort: a list with wrong metric tiles AND wrong133 sort order should say both.134- Read row fields through a guarded accessor that **throws on a missing key**, never via135 direct property access: a dead reader makes a sort check not red but **empty**136 (`[undefined, …]` is "sorted"), and empty looks exactly like correct.137- No `?` on fields that a check reads: the `?` switches the compiler off as a witness —138 a module can then read a field it never sets, and the check passes emptily.139140### `basis` — what the page presupposes141142Each page declares the base state it needs as **one word** (e.g. `nothing` | `user` |143`signedIn` | `tenant`), not as a function: what gets built is the app's business (it has144the context, page, and credentials). Notes:145146- `user` is not the weaker form of `signedIn` but the opposite statement: *a user exists147 and the browser is signed out* — needed only when the login form itself is the test148 subject.149- The type is the **union over all apps**; enforcement is a **runtime** statement: a150 base-state builder receiving a value its app doesn't know **throws** ("this app has no151 tenant concept").152153## Base States — the Login Optimization154155> **Data via API, path via clicks.** Setup through the UI costs 10–20 s per case, via156> API ~50 ms — factor 200–400. The path to the page under test is still clicked: only157> then is "arrived" a statement.158159The pattern (with email verification enabled):160161```1621) POST /api/auth/sign-up create the user (unconfirmed)1632) confirmation link from the mail mock — via the FRONTEND URL, like a real user1643) POST /api/auth/sign-in → set-cookie: session token1654) context.addCookies(...) → the browser is signed in166```167168Four traps, all real:1691701. **The `Origin` header is mandatory** for auth frameworks like better-auth when171 calling from Node — without it: `403 Missing or null Origin`.1722. **The mail-mock port is a parameter, not an assumption** — a QA run may start the173 mock on a free port; the mail is then there and nowhere else.1743. **Mock mail lists are paginated** — without searching by address and a sufficient175 `limit`, your own mail hides behind everyone else's.1764. **A registration is unique per database.** Suites carry the addresses rolled at177 generation time; from the second run on they are taken — and "user already exists" is178 a rejection that turns a case green which expected a DIFFERENT rejection. Put a179 per-run marker into every address; for over-length addresses insert it180 **length-preserving** so the boundary case stays a boundary case.181182**The table's secondary data is the pre-state** — the base-state builder reads it and183establishes it. Verify **every key you read against a real suite file**: a reader184comparing against a key no suite ever writes is wrong in every run, silently, and the185run stays green while the domain case never executes.186187## Resource Pooling by Effect Profile188189> **The pool key is the effect profile, not the test case.** 15 permission profiles190> across 400 test cases means ~15 users, not 400 — registration + confirmation + login191> is the most expensive setup, and it differs only per profile.192193This is the same dedup rule as for base states in the tables ("a base state may only194exist if at least one assertion differs because of it"), applied to runtime:1951961. **In the table** the permission profile is a secondary-data class (or a base state197 with `Execute = F`): `permissions: read_only`, `permissions: accountant`, …198 Deduplicate profiles BEFORE building — two profiles with identical effect profiles199 are one duplicate that costs runtime forever.2002. **In the base-state builder** lives the pool: a map profile → credentials, filled201 lazily — the first case of a profile creates the user, all later ones reuse it.2023. **Group execution by base state** so setup runs once per group (suites cut per table203 already do this; for cross-cutting pools use a worker-scoped fixture).204205**The counter-rule, without which the saving gets expensive — share only what the test206does not mutate:**207208| Case | User |209|---|---|210| Test checks what a profile may **see/do** (permission checks, lists, reads) | shared, from the pool |211| Test **mutates user-owned state** (password, profile, settings, inbox) | own user — or the pool user after the case is not the one before |212| Test mutates **tenant-owned state** | own tenant, OR serialize those cases — a shared user is a collision domain |213214Parallelism stays a measurement: a pool additionally couples cases of the same profile.215Whoever raises `workers` first measures that pool users are collision-free.216217## Runner Invariants You Must Not Break When Connecting2182191. **Suites are never written or amended by hand** — changes go table → build → new220 JSON. A test case without a table row is an error.2212. **The suite format knows no framework vocabulary** — one file, two runners, two222 independent rule sets checking the same case.2233. **Directives and markers are resolved by the RUNNER, never by the data or the page.**224 Measured failure mode: a runner sent `gen::blank` as a literal string — the "field225 empty" cases were green because the server rejected the eleven characters as an226 invalid address, and the actual question was never asked. An unknown directive prefix227 **throws**.2284. **Gaps are a third outcome, never a silent skip**: a case without a fixture is229 counted and named (a `gap` outcome in the API runner, `test.skip` with a reason in230 the browser) — neither success nor failure.231232## Verification After Connecting2332341. Get a base-state-only spec green first (setup isolated, no test subject).2352. Run one suite, then the full run.2363. Read the runner's report list: "suite without a page" is a finding, not noise — every237 reported suite needs a registry entry or a deliberate `apiOnly` marking.