@jterrazz/test 15.2.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,495 +1,197 @@
1
1
  # @jterrazz/test
2
2
 
3
- Declarative testing framework for APIs, jobs, CLIs, websites, and mobile apps. Five constructors — `specification.api()`, `specification.jobs()`, `specification.cli()`, `specification.website()`, `specification.mobile()` — and specs that read as sentences: given → action → assertions. The vitest test name is the spec's description; all assertions go through `expect()` with auto-registered, subject-typed matchers.
3
+ Declarative testing framework for modules, APIs, jobs, CLIs, rendered components, websites and native apps. Six constructors — `specification.api()`, `specification.jobs()`, `specification.cli()`, `specification.integration()`, `specification.website()`, `specification.mobile()` — plus the `component` chain, which starts nothing and needs none, and the module test, which needs neither.
4
+
5
+ A spec reads as a sentence: given → action → assertions. The vitest test name is its only description, every assertion goes through `expect()` with subject-typed matchers, and the infrastructure a spec declares — Postgres, Redis, SQLite, a child process, a real Chromium, an iOS simulator — is started, isolated per worker and cleaned up for you.
4
6
 
5
7
  ```bash
6
8
  npm install -D @jterrazz/test vitest
7
9
  ```
8
10
 
9
- Everything a spec needs imports from `@jterrazz/test`. The exceptions are the two TOOL subpaths, which no spec ever imports: `@jterrazz/test/oxlint` (the zero-runtime lint plugin and its config fragment, loading no test runtime) and `@jterrazz/test/vitest` (what `vitest.config.ts` imports — the `defineSpecConfig()` preset and the `literate()` plugin).
11
+ Everything a spec needs imports from `@jterrazz/test`; the entries beside it are tools no spec imports, and they are listed once — [docs/01 § What the tree publishes](docs/01-architecture.md#what-the-tree-publishes).
10
12
 
11
- ## Quick start
13
+ ## One kind of test, one example
12
14
 
13
- ### API testing (HTTP)
15
+ Which kind a test is, is the first question — [the fork](docs/18-conventions.md#the-fork--which-kind-of-test-this-is) asks it in nine lines and the answer fixes the folder, the constructor and the project.
14
16
 
15
- ```typescript
16
- // specs/api/api.specification.ts
17
- import { afterAll } from 'vitest';
18
- import { postgres, specification } from '@jterrazz/test';
19
- import { createApp } from '../../src/app.js';
20
-
21
- export const { api, cleanup } = await specification.api({
22
- services: { db: postgres() }, // → compose service "db"
23
- server: ({ db }) => createApp({ databaseUrl: db.connectionString }),
24
- });
25
-
26
- afterAll(cleanup);
27
- ```
17
+ **A module** — beside its code, nothing started ([docs/05](docs/05-module-tests.md)):
28
18
 
29
19
  ```typescript
30
- // specs/api/users/users.test.ts
20
+ // src/domain/ranking.test.ts
21
+ import { clock } from '@jterrazz/test';
31
22
  import { expect, test } from 'vitest';
32
- import { api } from '../api.specification.js';
33
-
34
- test('creates a user', async () => {
35
- // Given - the complete request from _requests/create-user.http
36
- const result = await api.request('create-user.http');
37
-
38
- // Then - status + headers + body from _expected/user-created.http; row in db
39
- expect(result.response).toMatch('user-created.http');
40
- await expect(result.table('users')).toMatchRows({
41
- columns: ['name'],
42
- rows: [['Alice']],
43
- });
44
- });
45
- ```
46
-
47
- ### CLI testing
48
-
49
- ```typescript
50
- // specs/cli/cli.specification.ts
51
- import { resolve } from 'node:path';
52
- import { afterAll } from 'vitest';
53
- import { specification } from '@jterrazz/test';
54
-
55
- export const { cli, cleanup } = await specification.cli(
56
- resolve(import.meta.dirname, '../../bin/my-cli.sh'),
57
- );
58
23
 
59
- afterAll(cleanup);
60
- ```
24
+ import { rank } from './ranking.js';
61
25
 
62
- ```typescript
63
- // specs/cli/build/build.test.ts
64
- import { expect, test } from 'vitest';
65
- import { cli } from '../cli.specification.js';
26
+ test('ranks a fresher post above an older one of equal score', () => {
27
+ // Given - two posts of equal score, one written this morning
28
+ using _ = clock.at('2026-03-04T09:30:00Z');
66
29
 
67
- test('builds the project', async () => {
68
- // Given - sample app project spread into the cwd
69
- const result = await cli.fixture('$FIXTURES/sample-app/').exec('build');
70
-
71
- // Then - ESM output, no CJS
72
- expect(result.exitCode).toBe(0);
73
- expect(result.stdout).toContain('Build completed');
74
- expect(result.file('dist/index.js').exists).toBe(true);
75
- expect(result.file('dist/index.cjs').exists).toBe(false);
30
+ // Then - the fresher one leads
31
+ expect(rank(posts).map((post) => post.id)).toStrictEqual(['fresh', 'stale']);
76
32
  });
77
33
  ```
78
34
 
79
- A CLI session can also BE the spec file. A `<case>.spec.yaml` document states one scenario — its ground, then its runs — and executes either as a test file of its own (the `literate()` vite plugin) or through `cli.run('case.spec.yaml')`:
80
-
81
- ```yaml
82
- description: refuses to build without a manifest
83
- runs:
84
- - command: build
85
- exit: 1
86
- stderr: |
87
- Error: no my-cli.yaml in the current directory
88
- ```
89
-
90
- Same engine as the chain, same `{{token}}` grammar, same `TEST_UPDATE=1` — which rewrites each run's exit code and streams, and nothing else. A JSON Schema ships at `@jterrazz/test/schema` so an editor validates as you type. Full grammar: [docs/07-cli.md](docs/07-cli.md#spec-documents--casespecyaml).
91
-
92
- ### Website testing (browser)
35
+ **An integration spec** — a module against the real thing, or against a golden ([docs/06](docs/06-integration.md)):
93
36
 
94
37
  ```typescript
95
- // specs/website/website.specification.ts
96
- import { specification } from '@jterrazz/test';
97
- import { afterAll } from 'vitest';
98
-
99
- export const { cleanup, website } = await specification.website({
100
- server: { command: 'node specs/_fixtures/website-app/server.mjs', ready: '/' },
101
- });
102
-
103
- afterAll(cleanup);
104
- ```
105
-
106
- ```typescript
107
- // specs/website/visit/head.test.ts
38
+ // specs/integration/posts/find.spec.ts
108
39
  import { expect, test } from 'vitest';
109
- import { website } from '../website.specification.js';
110
40
 
111
- test('captures the full head surface of a rendered page', async () => {
112
- // Given - the fixture homepage
113
- const result = await website.visit('/');
41
+ import { integration } from '../integration.specification.js';
114
42
 
115
- // Then - one golden covers title, canonical, alternates, and metas
116
- expect(result.status).toBe(200);
117
- expect(result.head).toMatch('home.head.json');
118
- });
119
- ```
43
+ test('finds the post the migration wrote', async () => {
44
+ // Given - a seeded database
45
+ const result = await integration.seed('posts.sql').call(({ db }) => findPost(db, 'p-1'));
120
46
 
121
- ### Mobile testing (iOS simulator)
122
-
123
- ```typescript
124
- // specs/mobile/mobile.specification.ts
125
- import { specification } from '@jterrazz/test';
126
- import { afterAll } from 'vitest';
127
-
128
- export const { cleanup, mobile } = await specification.mobile({
129
- app: { bundleId: 'com.jterrazz.fakenews' },
130
- device: { name: 'iPhone 17', os: '26.5' },
47
+ // Then - the row comes back as the repository shapes it
48
+ expect(result.value).toMatch('found.json');
131
49
  });
132
-
133
- afterAll(cleanup);
134
50
  ```
135
51
 
136
- ```typescript
137
- // specs/mobile/events/feed.test.ts
138
- import { expect, test } from 'vitest';
139
- import { mobile } from '../mobile.specification.js';
140
-
141
- test('shows the events feed behind its deep link', async () => {
142
- // Given - the events screen
143
- const result = await mobile.open('news://events');
144
-
145
- // Then - one golden covers the whole projected accessibility tree
146
- expect(result.screen).toMatch('events.screen.json');
147
- });
148
- ```
149
-
150
- Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.fetch()`, `.visit()`, `.open()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
151
-
152
- ## The five constructors
153
-
154
- One constructor per tested interface, each returning a record destructured with its canonical name:
155
-
156
- | Constructor | Returns | Terminal actions |
157
- | --------------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
158
- | `specification.api(options)` | `{ api, cleanup, docker, orchestrator }` | `.request(file)`, `.get()`, `.post()`, `.put()`, `.delete()` |
159
- | `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
160
- | `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
161
- | `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
162
- | `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
163
-
164
- ### `specification.api({ services, server, mode?, root? })`
165
-
166
- One definition, two execution modes — the switch lives in `vitest.config.ts`, never in the specification file:
167
-
168
- - **`node`** (default): starts the declared services via testcontainers and runs the app in-process (Hono). Fastest feedback loop.
169
- - **`compose`**: runs `docker compose up` on `docker/compose.test.yaml` and sends real HTTP to the app service (`server` is ignored). Proves the shipped artifact.
170
-
171
- Resolution: `options.mode` > `TEST_MODE` env var > `'node'`. Only `.api()` has a mode.
52
+ **A component** — a rendered unit in a real browser, beside its code ([docs/07](docs/07-component.md)):
172
53
 
173
- ```typescript
174
- // vitest.config.ts — the mode switch lives HERE
175
- import { defineSpecConfig } from '@jterrazz/test/vitest';
176
-
177
- export default defineSpecConfig({
178
- test: {
179
- projects: [
180
- { test: { name: 'http', include: ['specs/api/**/*.test.ts'] } },
181
- {
182
- test: {
183
- name: 'http-stack',
184
- include: ['specs/api/**/*.test.ts'],
185
- env: { TEST_MODE: 'compose' },
186
- },
187
- },
188
- ],
189
- },
190
- });
191
- ```
192
-
193
- `services` is a named record. Keys type the `server` factory parameters, name databases for `.seed()`/`.table()` (`{ database: 'analyticsDb' }`), and drive the compose binding — a handle with no `composeService` option links to the compose service named exactly like its key, else the kebab-case conversion of the key (`analyticsDb` → `analytics-db`). If both names exist the binding is ambiguous and throws; `composeService` is the escape hatch for non-derivable names.
54
+ ```tsx
55
+ // src/presentation/post-table.test.tsx
56
+ import { component, content } from '@jterrazz/test';
57
+ import { expect, test } from 'vitest';
194
58
 
195
- ### `specification.jobs({ services, jobs, root? })`
59
+ import { PostTable } from './post-table.js';
196
60
 
197
- Background jobs run in-process by definition — no HTTP server, no mode:
61
+ test('says how much of the collection the table is showing', async () => {
62
+ // Given - a page of one row answering for a collection of two hundred
63
+ const result = await component.intercept(listing).render(<PostTable />, async (visitor) => {
64
+ await visitor.see(content('Showing 1 of 200 posts'));
65
+ });
198
66
 
199
- ```typescript
200
- export const { jobs, cleanup } = await specification.jobs({
201
- services: { db: postgres() },
202
- jobs: ({ db }) => [nightlyReport(db)], // (services) => JobHandle[], or a static array
67
+ // Then - the table is qualified by what it is not showing
68
+ await expect(result.tree).toMatch('one-of-two-hundred.aria.yaml');
203
69
  });
204
-
205
- // In a test:
206
- const result = await jobs.seed('pending.sql').trigger('nightly-report');
207
70
  ```
208
71
 
209
- A `JobHandle` is `{ name: string; execute: () => Promise<void> }`.
210
-
211
- ### `specification.cli(bin, { root?, services?, docker?, transform?, env?, serve? })`
212
-
213
- Runs a command binary against fixture projects in fresh temp directories. `env` (named environment sets) and `serve` (named servers) are the registries a [`<case>.spec.yaml`](docs/07-cli.md#spec-documents--casespecyaml) names by word. With `services`, connection URLs are injected into the child env automatically: `<KEY>_URL` per record key (CONSTANT_CASE at camelCase boundaries — `analyticsDb` → `ANALYTICS_DB_URL`), plus `DATABASE_URL` (exactly one SQL database) and `REDIS_URL` (exactly one redis). `.env()` overrides; `null` unsets.
72
+ **A website** — the served page, driven in a browser ([docs/08](docs/08-website.md)):
214
73
 
215
74
  ```typescript
216
- export const { cli, cleanup } = await specification.cli('my-migrate-tool', {
217
- services: { db: postgres() },
218
- });
219
-
220
- // DATABASE_URL / DB_URL are already in the child env:
221
- const result = await cli.seed('legacy-schema.sql').exec('up');
222
- ```
223
-
224
- ### `specification.website({ server?, url?, backend?, external?, root? })`
225
-
226
- Tests a rendered website: `.fetch(path)` for a raw HTTP exchange (redirects never followed), `.visit(path, scenario?)` for a page rendered in a real chromium. Exactly one of `server` (start the site locally — a free port injected as `PORT`, polled on `ready`) or `url` (target a running site) is required. `backend: { env, port? }` (server mode only) additionally starts a declared stub backend and injects its URL into the server child under `env`; each chain declares what it serves with `.intercept(contracts)`, the same contracts form `api`/`jobs` use.
75
+ // specs/website/subscribe/subscribe.spec.ts
76
+ import { button, content, field } from '@jterrazz/test';
77
+ import { expect, test } from 'vitest';
227
78
 
228
- ```typescript
229
- export const { website, cleanup } = await specification.website({
230
- server: { command: 'node specs/_fixtures/website-app/server.mjs', ready: '/' },
231
- });
79
+ import { website } from '../website.specification.js';
232
80
 
233
- // Raw exchange — status + headers, redirects surface as 3xx
234
- const redirect = await website.fetch('/old');
81
+ test('subscribes through the form and captures the final state', async () => {
82
+ // Given - a visitor on the homepage
83
+ const result = await website.visit('/', async (visitor) => {
84
+ await visitor.fill(field('Email'), 'visitor@site.test');
85
+ await visitor.click(button('Subscribe'));
86
+ await visitor.see(content('Thanks for subscribing'));
87
+ });
235
88
 
236
- // Rendered page, optionally driven by a scenario (the When)
237
- const page = await website.visit('/', async (visitor) => {
238
- await visitor.click(link('Articles'));
89
+ // Then - the page says so, and the console is clean
90
+ expect(result.content).toContain('Thanks for subscribing');
91
+ await expect(result.errors).toBeEmpty();
239
92
  });
240
93
  ```
241
94
 
242
- The handle destructures to `{ website, cleanup, url }` — no `docker`, no `orchestrator`. `.visit()` needs playwright (`npm install -D playwright && npx playwright install chromium`) — an optional peer dependency, only loaded when a spec actually renders a page. Full reference: [docs/14-website.md](docs/14-website.md).
243
-
244
- ### `specification.mobile({ app, device, backend?, root? })`
245
-
246
- Tests a native app on the iOS simulator through a real XCUITest session (appium): `.open(deepLink?, scenario?)` terminates and relaunches the app (deterministic fresh state), applies the deep link, runs the scenario, and captures the final screen — the projected accessibility tree plus the visible texts. The simulator is resolved by `device: { name, os?, udid? }` via `xcrun simctl` (refusing on zero or several matches) and booted when shut down; the appium server is spawned from the caller project on a free port.
95
+ **A mobile screen** — the installed app on a simulator ([docs/09](docs/09-mobile.md)):
247
96
 
248
97
  ```typescript
249
- export const { mobile, cleanup, udid } = await specification.mobile({
250
- app: { bundleId: 'com.jterrazz.fakenews' },
251
- device: { name: 'iPhone 17', os: '26.5' },
252
- });
253
-
254
- // A screen behind its deep link, driven by a scenario (the When)
255
- const result = await mobile.open('news://events', async (visitor) => {
256
- await visitor.tap(button('Enquête Fauci COVID-19'));
257
- await visitor.see(content('rapports'));
258
- });
259
- ```
260
-
261
- The handle destructures to `{ mobile, cleanup, udid }` (plus `backendUrl` with `backend: { port? }` — a declared stub backend whose URL the CALLER wires into its own bundler env; the framework never touches Metro). The element vocabulary is the website facet's, unchanged — `button`, `field`, `content`, `testId`, `within` — landmarks excepted (an iOS screen has no ARIA regions; they refuse at runtime). Requires the app installed on the simulator plus the optional peers: `npm install -D appium webdriverio && npx appium driver install xcuitest`. Full reference: [docs/15-mobile.md](docs/15-mobile.md).
262
-
263
- ### Root auto-discovery
264
-
265
- When `root` is absent, the framework walks up from the specification file to the **nearest** directory carrying `package.json` or `docker/compose.test.yaml`. In a workspace that is the member being tested, not the repository root above it. Pass `root` only when the convention does not fit. `root` is strictly the **project root** (compose detection + local-bin resolution, or the cwd of a `specification.website()` server command) — it is not a fixtures root; `.fixture()` resolves its own paths.
266
-
267
- ## vitest config — `defineSpecConfig()`
98
+ // specs/mobile/events/bookmark.spec.ts
99
+ import { button, content } from '@jterrazz/test';
100
+ import { expect, test } from 'vitest';
268
101
 
269
- `@jterrazz/test/vitest` is the subpath `vitest.config.ts` imports, never a spec. Beside `literate()` it exports the shared preset: what you pass is a plain vite/vitest config merged **over** its defaults, and `literate:` adds the plugin.
102
+ import { mobile } from '../mobile.specification.js';
270
103
 
271
- ```typescript
272
- import { defineSpecConfig } from '@jterrazz/test/vitest';
104
+ test('bookmarks an event from its detail screen', async () => {
105
+ // Given - a visitor on the events feed
106
+ const result = await mobile.open('news://events', async (visitor) => {
107
+ await visitor.tap(button('Fauci inquiry'));
108
+ await visitor.see(content('reports'));
109
+ await visitor.tap(button('Bookmark'));
110
+ });
273
111
 
274
- export default defineSpecConfig({
275
- literate: { specification: './specs/cli/cli.specification.ts' },
276
- test: { include: ['specs/**/*.test.ts'] },
112
+ // Then - the capture reflects the screen after the interaction
113
+ expect(result.content).toContain('Bookmarked');
277
114
  });
278
115
  ```
279
116
 
280
- | Sets | To |
281
- | ---------------------------------- | ------------------------------------------------------ |
282
- | `cacheDir` | `.artifacts/vitest` |
283
- | `test.coverage.reportsDirectory` | `.artifacts/vitest/coverage` (bring your own provider) |
284
- | `test.testTimeout` / `hookTimeout` | `30_000` |
285
- | `test.exclude` | vitest's defaults **+** `**/_fixtures/**` |
286
-
287
- Nothing else: `fileParallelism`, `reporters`, `environment` and every `include` stay yours. Inline `projects` inherit the same defaults (vitest gives a project nothing from the root), arrays are concatenated rather than replaced, and scalars you state win. Full walkthrough, including migrating a hand-rolled config: [docs/02-developing.md](docs/02-developing.md#vitest-config-the-preset).
288
-
289
- Every artefact a run produces lands under `.artifacts/<tool>/` — one `.gitignore` line, one `rm -rf`. Per-run scratch (a CLI spec's temp cwd, a browser profile) stays in the OS temp dir.
290
-
291
- ## Builder API
292
-
293
- ### Setup (chainable)
294
-
295
- | Method | Facets | Description |
296
- | --------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
297
- | `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
298
- | `.fixture("file")` | cli | Copy the feature-local `_fixtures/file` into the working directory |
299
- | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/<name>/` project into the cwd (trailing `/` = contents; layers) |
300
- | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
301
- | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
302
- | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
303
- | `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
304
- | `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
305
-
306
- ### Actions (terminal)
307
-
308
- | Method | Facet | Resolves to | Description |
309
- | ------------------------------------------ | ------- | -------------- | ---------------------------------------------------------------------------------------- |
310
- | `.request("create-user.http")` | api | `HttpResult` | Send the COMPLETE request from `_requests/<file>` (method, path, headers, raw body) |
311
- | `.get(path)` / `.delete(path)` | api | `HttpResult` | Inline requests for simple cases |
312
- | `.post(path, body?)` / `.put(path, body?)` | api | `HttpResult` | Inline body: plain object, JSON-serialized |
313
- | `.trigger("name")` | jobs | `BaseResult` | Execute a registered job |
314
- | `.exec("args")` | cli | `CliResult` | Run the command |
315
- | `.exec(["build", "start"])` | cli | `CliResult` | Sequence in the same cwd; stops on first non-zero exit |
316
- | `.exec("dev", { waitFor, timeout? })` | cli | `CliResult` | Long-running: resolves at the pattern, killed at `timeout` (default 10 s) |
317
- | `.run("case.spec.yaml")` | cli | `CliResult` | Run a `<case>.spec.yaml` — its ground and EVERY run asserted; the last one returns |
318
- | `.fetch(path)` | website | `FetchResult` | One raw HTTP exchange — redirects surface as 3xx, never followed |
319
- | `.visit(path, scenario?)` | website | `PageResult` | Render the page in a shared chromium; with a scenario, the capture is the final state |
320
- | `.open(deepLink?, scenario?)` | mobile | `ScreenResult` | Relaunch the app fresh on the simulator; with a scenario, the capture is the final state |
321
-
322
- One chain = one terminal action; databases reset at the start of every chain. Every cli spec runs in a fresh, empty temp directory.
323
-
324
- ## Assertions — everything through `expect()`
325
-
326
- Accessors are **read-only**; the framework registers subject-typed matchers on vitest's `expect` automatically. `await` is required exactly where IO happens (tables, trees, containers).
117
+ **An API** — the assembled app, met through a complete HTTP exchange ([docs/10](docs/10-api.md)):
327
118
 
328
119
  ```typescript
329
- // HTTP
330
- expect(result.status).toBe(201);
331
- expect(result.response).toMatch('user-created.http'); // _expected/<name> — status + header subset + body
332
- expect(result.response.body).toEqual({ error: 'User 999 not found' });
333
-
334
- // Tables (async — queries the database)
335
- await expect(result.table('orders', { database: 'db' })).toMatchRows({
336
- columns: ['id', 'status', 'created_at'],
337
- rows: [[match.uuid(), 'pending', match.iso8601()]],
338
- });
339
- await expect(result.table('orders', { database: 'db' })).toBeEmpty();
340
-
341
- // Streams (ANSI stripped by default; .text stays raw)
342
- expect(result.stdout).toContain('Build completed');
343
- expect(result.stdout).toMatch('help.txt'); // _expected/help.txt — {{token}}-aware
344
- expect(result.json).toMatch('config.json'); // _expected/config.json
345
- expect(result.json.value).toMatchObject({ name: 'shoply' });
346
-
347
- // Files & trees
348
- expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop');
349
- await expect(result.directory('my-shop')).toMatch('shop-scaffold'); // _expected/shop-scaffold/
350
- await expect(result.filesystem).toMatch('upgraded-shop'); // whole cwd
351
-
352
- // Containers (docker-aware cli)
353
- await expect(result.container('alpha')).toBeRunning();
354
- ```
355
-
356
- `toMatch` always resolves against `_expected/<name>` — every subject, no exceptions (only `.request()` reads `_requests/`). The folder is flat: a slash in the name creates a subfolder; the extension is part of the name and required, except for tree snapshots which are directories.
357
-
358
- **Updating snapshots:** `TEST_UPDATE=1` or `vitest -u`. Update mode writes **tokens**, not values — segments covered by an existing placeholder are preserved, and `{{workdir}}` is substituted automatically.
359
-
360
- ## Dynamic values — one `{{token}}` grammar
361
-
362
- The same vocabulary works in `_expected/*.http` (body AND headers), `_expected/*.json`, text snapshots, and tree-snapshot file contents — and in code via `match.*`:
363
-
364
- `uuid` `ulid` `iso8601` `date` `time` `duration` `number` `int` `float` `semver` `sha` `hex` `base64` `port` `ip` `url` `email` `path` `workdir` `string` `any`
365
-
366
- Each token is capturable via `{{type#ref}}`: the first occurrence captures, later occurrences must be equal (scope: one spec). Code-side: `match.ref('order')`, `match.ref('intent', { not: 'order' })`, `match.regex(/…/)`.
367
-
368
- ```http
369
- ### _expected/order-created.http
370
- HTTP/1.1 201 Created
371
- Content-Type: application/json
372
- Location: /orders/{{uuid#order}}
373
-
374
- {
375
- "id": "{{uuid#order}}",
376
- "total": "{{number}}",
377
- "createdAt": "{{iso8601}}"
378
- }
379
- ```
380
-
381
- See [docs/09-tokens.md](docs/09-tokens.md) for the canonical accepted form of every token.
382
-
383
- ## Contracts
120
+ // specs/api/users/create-user.spec.ts
121
+ import { expect, test } from 'vitest';
384
122
 
385
- Everything the outside world replies is a **contract** — a request to match and a response to serve, declared together. A feature owns a `contracts/` folder: a public `<name>.contracts.ts` facade (default export = the world, named exports = its scenarios) over internal `<provider>/<name>.ts` units, `provider ∈ { http, openai, anthropic }`.
123
+ import { api } from '../api.specification.js';
386
124
 
387
- ```typescript
388
- // contracts/openai/classify-product.ts
389
- import { defineContract, openai } from '@jterrazz/test';
125
+ test('creates a user and returns its location', async () => {
126
+ // Given - an empty users table
127
+ const result = await api.seed('empty.sql').request('create-user.http');
390
128
 
391
- export default defineContract({
392
- request: openai.responses({ user: /Product Classification/, tools: ['classify'] }),
393
- response: openai.reply({ category: 'ELECTRONICS', confidence: 0.97 }),
129
+ // Then - the response matches the golden, tokens and all
130
+ expect(result.response).toMatch('user-created.http');
131
+ await expect(result.table('users')).toMatchRows({
132
+ columns: ['name'],
133
+ rows: [['Alice']],
134
+ });
394
135
  });
395
136
  ```
396
137
 
397
- ```typescript
398
- // contracts/pipeline.contracts.ts
399
- import { defineContracts, http } from '@jterrazz/test';
400
-
401
- import classifyProduct from './openai/classify-product.js';
402
- import exchangeRates from './http/exchange-rates.js';
403
-
404
- const pipeline = defineContracts(classifyProduct, exchangeRates);
405
-
406
- export default pipeline;
407
-
408
- export const withRatesDown = () =>
409
- pipeline.with({ request: http.get('/rates'), response: http.error(503) });
410
- ```
138
+ **A job** — what a name triggers, in-process ([docs/11](docs/11-jobs.md)):
411
139
 
412
140
  ```typescript
413
- const result = await jobs.intercept(pipeline).trigger('nightly-report');
414
- ```
415
-
416
- Selection is first-match, one queue for every facet: `times` bounds how often a contract serves (omitted = unlimited, so retries and re-renders replay it), `required: true` fails the chain if it was never requested. Provider string filters are **exact** — the loose forms are explicit (`RegExp`, `match.includes('…')`). Failure simulation: `openai.error(429)`, `anthropic.timeout()`, `openai.malformed('not json')`. MSW ships as a direct dependency — no separate install. Full chapter: [docs/10-contracts.md](docs/10-contracts.md).
141
+ // specs/jobs/digest/daily-digest.spec.ts
142
+ import { expect, test } from 'vitest';
417
143
 
418
- ## Docker-aware CLIs
144
+ import { jobs } from '../jobs.specification.js';
419
145
 
420
- For CLIs that spawn containers, declare `docker: { envVar, nameLabel, testRunLabel }`. The runner injects a unique test-run id into the child env; the tested binary must label its containers with `testRunLabel=<id>`. Results expose lazy `.container(name)` accessors — and must be bound with `await using` so leaked containers are force-removed at scope exit:
146
+ test('writes one digest row per active subscriber', async () => {
147
+ // Given - two active subscribers and one cancelled
148
+ const result = await jobs.seed('subscribers.sql').trigger('daily-digest');
421
149
 
422
- ```typescript
423
- test('deploy spawns a labelled container', async () => {
424
- // Given
425
- await using result = await cli.fixture('$FIXTURES/two-shops/').exec('deploy alpha');
426
-
427
- // Then - property reads are sync; only the matcher is async
428
- const shop = result.container('alpha');
429
- expect(shop.exists).toBe(true);
430
- await expect(shop).toBeRunning();
431
- expect(shop.file('/app/shoply.yaml').content).toContain('name: alpha');
150
+ // Then - only the active ones were written
151
+ await expect(result.table('digests')).toMatchRows({
152
+ columns: ['to'],
153
+ rows: [['a@site.test'], ['b@site.test']],
154
+ });
432
155
  });
433
156
  ```
434
157
 
435
- ## Service factories
436
-
437
- | Factory | Options | Connection string |
438
- | ------------ | -------------------------------- | ------------------------------------- |
439
- | `postgres()` | `composeService`, `image`, `env` | `postgresql://user:pass@host:port/db` |
440
- | `redis()` | `composeService`, `image` | `redis://host:port` |
441
- | `sqlite()` | `init` or `prismaSchema` | `file:/…/….sqlite` |
442
-
443
- `docker/compose.test.yaml` is the single source of truth for test infrastructure; `docker/<service>/init.sql` runs when the corresponding service starts. Parallel isolation is automatic per vitest worker: postgres clones a schema, redis assigns a database index, sqlite copies the template file, compose mode gets a dedicated project.
444
-
445
- `sqlite()` caches its schema template inside the project — `.artifacts/vitest/sqlite/template-<key>.sqlite` — so two checkouts never share one, and workers racing for a cold cache wait for the one that is building rather than all building at once. Details: [docs/11-services.md](docs/11-services.md#where-the-template-lives).
446
-
447
- ## Mocking utilities
448
-
449
- ```typescript
450
- import { mockOf, mockOfDate } from '@jterrazz/test';
451
- ```
452
-
453
- | Export | Description |
454
- | ------------- | ------------------------------------------------------------ |
455
- | `mockOf<T>()` | Deep mock of any interface (wraps `vitest-mock-extended`) |
456
- | `mockOfDate` | Freeze/reset the global Date via `.set(date)` and `.reset()` |
457
-
458
- ## Conventions
459
-
460
- Normative rules live in the constitution ([docs/12-conventions.md](docs/12-conventions.md)); the generated per-rule catalogue is [docs/13-linting.md](docs/13-linting.md). A facet (`specs/<facet>/`) carries its runner(s) at its root and holds domain folders; the folder follows the assets:
158
+ **A CLI** — the built binary, usually as a document ([docs/12](docs/12-cli.md)):
461
159
 
160
+ ```yaml
161
+ # specs/cli/scaffold/new-project.spec.yaml
162
+ description: scaffolds a project into an empty directory
163
+ fixture: empty-dir
164
+ runs:
165
+ - command: new my-app
166
+ exitCode: 0
167
+ stdout: scaffolded.txt
168
+ files:
169
+ my-app/package.json: package.json
462
170
  ```
463
- specs/<facet>/ # api | jobs | cli | integrations | lint
464
- ├── <facet>.specification.ts # runner(s) at the facet ROOT (rule C1)
465
- └── <domain>/ # a product command/area — 1..n test files
466
- ├── <aspect>.test.ts
467
- ├── _seeds/ # *.sql ONLY — database state
468
- ├── _requests/ # *.http — inputs: COMPLETE request (method, path, headers, body)
469
- ├── contracts/ # <name>.contracts.ts facade + <provider>/<name>.ts units + their .response.json / .request.ts data
470
- ├── _fixtures/ # domain-local files/dirs copied into the cwd (cli) — shared pool lives at specs/_fixtures/
471
- └── _expected/ # ALL expected fixtures, FLAT (incl. response *.http) — a slash in the name creates a subfolder
472
- ```
473
-
474
- A test with its OWN asset dirs gets its own domain folder; tests without local assets group as sibling `<aspect>.test.ts` files inside a named group folder (the folder follows the assets). `.fixture(path)` is the one verb that copies into the cwd: domain-local (`_fixtures/…`) or shared (`$FIXTURES/…` → `specs/_fixtures/…`), with rsync trailing-slash semantics and layering. `.seed()` is SQL-only.
475
171
 
476
- Every test contains `// Given -` and `// Then -` comments (always both; `// When -` only if the action is not obvious — the chain IS the when). User-facing framework env vars: `TEST_MODE` and `TEST_UPDATE` — the only ones you set; the framework also reads vitest's `VITEST_POOL_ID` for per-worker isolation.
172
+ ## The map
477
173
 
478
- ### Convention enforcement — the shipped lint plugin
174
+ Everything is stated ONCE, in the chapter that owns it — a second copy here would be a second answer, and the one a reader meets first is the one that goes stale. The corpus map is [docs/README.md](docs/README.md).
479
175
 
480
- These conventions are not just prose: the package ships an oxlint plugin (`@jterrazz/test/oxlint`) with ~40 AST rules, plus a `jterrazz-test-check` binary (the conventions checker) that reads the data fixtures and cross-file relationships oxlint cannot. Wire the plugin into your `oxlint.config.ts` and run `jterrazz-test-check specs` in CI — the full four-channel catalogue (each rule, its channel and rationale) is generated into [docs/13-linting.md](docs/13-linting.md).
176
+ | Subject | Chapter |
177
+ | ---------------------------------------------------------------- | ---------------------------------------------------------------- |
178
+ | What the framework is, its trees, its channels, its four entries | [docs/01](docs/01-architecture.md) |
179
+ | Installing it, the preset, the project helpers | [docs/02](docs/02-developing.md#vitest-config-the-preset) |
180
+ | What a consumer must bring: the peers, Docker, the pinned pair | [docs/04](docs/04-operating.md#what-a-consumer-must-bring) |
181
+ | Each kind of test, one chapter, the same seven sections | [docs/05](docs/05-module-tests.md)–[docs/12](docs/12-cli.md) |
182
+ | Naming an element: descriptors, verbs, `within`, exact names | [docs/13](docs/13-elements.md) |
183
+ | Every matcher, by subject | [docs/14](docs/14-assertions.md) |
184
+ | The `{{token}}` grammar, `#ref` captures, update mode | [docs/15](docs/15-tokens.md) |
185
+ | Contracts, selection, provider builders, `intercept()` | [docs/16](docs/16-contracts.md) |
186
+ | Services, init scripts, per-worker isolation, `process()` | [docs/17](docs/17-services.md) |
187
+ | The conventions, and the catalogue that enforces them | [docs/18](docs/18-conventions.md), [docs/19](docs/19-linting.md) |
481
188
 
482
- ## Requirements
189
+ The conventions are not prose alone: the package ships an oxlint plugin (`@jterrazz/test/oxlint`) and a `jterrazz-test-check` binary that reads the fixtures and cross-file relationships oxlint cannot. Every diagnostic ends in a rule id and an anchor into the generated catalogue.
483
190
 
484
- - **Docker** - testcontainers for node mode, docker compose for compose mode; not needed for `sqlite()`, plain cli specs, website specs, or mobile specs
485
- - **vitest** - peer dependency
486
- - **playwright** - optional peer dependency, only needed for `.visit()`: `npm install -D playwright && npx playwright install chromium`
487
- - **appium + webdriverio** - optional peer dependencies, only needed for `specification.mobile()`: `npm install -D appium webdriverio && npx appium driver install xcuitest` — plus Xcode, a simulator, and the app installed on it
488
- - **msw** - bundled as a direct dependency (powers `.intercept()`); no separate install
489
- - **hono** (or any web framework) - supplied by your project for in-process apps; the adapter only needs an object with a `request()` method, so it is not a peer
191
+ Node 24 and vitest 5 are the required peers; everything else — a browser, a database driver, a simulator — is an optional peer, loaded the first time a spec needs it and named in the refusal when it is not there. What to install, and when: [docs/04](docs/04-operating.md#what-a-consumer-must-bring).
490
192
 
491
193
  ## Docs
492
194
 
493
- - Guide (chapters): [docs/README.md](docs/README.md) — getting started, API/jobs/CLI/website/mobile specs, assertions, tokens, contracts, services, conventions, linting
494
- - API reference: committed under [docs/reference/](docs/reference/) — compiled from source by `npm run docs`
495
- - Agent skill: [skills/jterrazz-test/](skills/jterrazz-test/) — mental model, per-facet references, generated rule reference
195
+ - The corpus: [docs/README.md](docs/README.md) — the spine, one chapter per kind, the shared references, the enforcement.
196
+ - API reference: [docs/reference/](docs/reference/) — compiled from source by `npm run docs`.
197
+ - Agent skill: [skills/jterrazz-test/](skills/jterrazz-test/) — the fork, the mental model, and a generated signature card per kind.