@jterrazz/test 7.1.0 → 9.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.
Files changed (48) hide show
  1. package/README.md +209 -230
  2. package/dist/checker.d.ts +1 -0
  3. package/dist/checker.js +741 -0
  4. package/dist/index.d.ts +1296 -529
  5. package/dist/index.js +3303 -1261
  6. package/dist/intercept.js +129 -290
  7. package/dist/match.js +153 -0
  8. package/dist/oxlint.cjs +2931 -0
  9. package/dist/oxlint.d.cts +150 -0
  10. package/dist/oxlint.d.ts +150 -0
  11. package/dist/oxlint.js +2925 -0
  12. package/package.json +47 -41
  13. package/dist/chunk.cjs +0 -28
  14. package/dist/index.cjs +0 -1814
  15. package/dist/index.cjs.map +0 -1
  16. package/dist/index.d.cts +0 -734
  17. package/dist/index.js.map +0 -1
  18. package/dist/intercept.cjs +0 -313
  19. package/dist/intercept.cjs.map +0 -1
  20. package/dist/intercept.d.cts +0 -105
  21. package/dist/intercept.d.ts +0 -105
  22. package/dist/intercept.js.map +0 -1
  23. package/dist/intercept2.cjs +0 -81
  24. package/dist/intercept2.cjs.map +0 -1
  25. package/dist/intercept2.js +0 -81
  26. package/dist/intercept2.js.map +0 -1
  27. package/dist/mock-of.cjs +0 -32
  28. package/dist/mock-of.cjs.map +0 -1
  29. package/dist/mock-of.d.cts +0 -27
  30. package/dist/mock-of.d.ts +0 -27
  31. package/dist/mock-of.js +0 -19
  32. package/dist/mock-of.js.map +0 -1
  33. package/dist/mock.cjs +0 -4
  34. package/dist/mock.d.cts +0 -2
  35. package/dist/mock.d.ts +0 -2
  36. package/dist/mock.js +0 -2
  37. package/dist/services.cjs +0 -5
  38. package/dist/services.d.cts +0 -2
  39. package/dist/services.d.ts +0 -2
  40. package/dist/services.js +0 -2
  41. package/dist/sqlite.adapter.cjs +0 -350
  42. package/dist/sqlite.adapter.cjs.map +0 -1
  43. package/dist/sqlite.adapter.d.cts +0 -209
  44. package/dist/sqlite.adapter.d.ts +0 -209
  45. package/dist/sqlite.adapter.js +0 -331
  46. package/dist/sqlite.adapter.js.map +0 -1
  47. package/dist/types.d.cts +0 -42
  48. package/dist/types.d.ts +0 -42
package/README.md CHANGED
@@ -1,51 +1,45 @@
1
1
  # @jterrazz/test
2
2
 
3
- Declarative testing framework for APIs and CLIs. Same fluent builder API, three execution modes.
3
+ Declarative testing framework for APIs, jobs, and CLIs. Three constructors `specification.api()`, `specification.jobs()`, `specification.cli()` — 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.
4
4
 
5
5
  ```bash
6
6
  npm install -D @jterrazz/test vitest
7
7
  ```
8
8
 
9
+ Everything imports from `@jterrazz/test` — the one exception is the tool-facing `@jterrazz/test/oxlint` subpath (the zero-runtime lint plugin and its config fragment), which never loads any test runtime.
10
+
9
11
  ## Quick start
10
12
 
11
13
  ### API testing (HTTP)
12
14
 
13
15
  ```typescript
14
- // tests/setup/integration.specification.ts
16
+ // specs/api/api.specification.ts
15
17
  import { afterAll } from 'vitest';
16
- import { spec, app } from '@jterrazz/test';
17
- import { postgres } from '@jterrazz/test/services';
18
+ import { postgres, specification } from '@jterrazz/test';
18
19
  import { createApp } from '../../src/app.js';
19
20
 
20
- const db = postgres({ compose: 'db' });
21
-
22
- export const run = await spec(
23
- app(() => createApp({ databaseUrl: db.connectionString })),
24
- {
25
- services: [db],
26
- root: '../../',
27
- },
28
- );
21
+ export const { api, cleanup } = await specification.api({
22
+ services: { db: postgres() }, // → compose service "db"
23
+ server: ({ db }) => createApp({ databaseUrl: db.connectionString }),
24
+ });
29
25
 
30
- afterAll(() => run.cleanup());
26
+ afterAll(cleanup);
31
27
  ```
32
28
 
33
29
  ```typescript
34
- // tests/e2e/users/users.e2e.test.ts
35
- import { run } from '../../setup/integration.specification.js';
30
+ // specs/api/users/users.test.ts
31
+ import { expect, test } from 'vitest';
32
+ import { api } from '../api.specification.js';
36
33
 
37
34
  test('creates a user', async () => {
38
- // Given - one existing user
39
- const result = await run('creates user')
40
- .seed('initial-users.sql')
41
- .post('/users', 'new-user.json')
42
- .run();
43
-
44
- // Then - user created
45
- expect(result.status).toBe(201);
46
- await result.table('users').toMatch({
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({
47
41
  columns: ['name'],
48
- rows: [['Alice'], ['Bob']],
42
+ rows: [['Alice']],
49
43
  });
50
44
  });
51
45
  ```
@@ -53,303 +47,288 @@ test('creates a user', async () => {
53
47
  ### CLI testing
54
48
 
55
49
  ```typescript
56
- // tests/setup/cli.specification.ts
50
+ // specs/cli/cli.specification.ts
57
51
  import { resolve } from 'node:path';
58
- import { spec, command } from '@jterrazz/test';
52
+ import { afterAll } from 'vitest';
53
+ import { specification } from '@jterrazz/test';
59
54
 
60
- export const run = await spec(command(resolve(import.meta.dirname, '../../bin/my-cli.sh')), {
61
- root: '../fixtures',
62
- });
55
+ export const { cli, cleanup } = await specification.cli(
56
+ resolve(import.meta.dirname, '../../bin/my-cli.sh'),
57
+ );
58
+
59
+ afterAll(cleanup);
63
60
  ```
64
61
 
65
62
  ```typescript
66
- // tests/e2e/build/build.e2e.test.ts
67
- import { run } from '../../setup/cli.specification.js';
63
+ // specs/cli/build/build.test.ts
64
+ import { expect, test } from 'vitest';
65
+ import { cli } from '../cli.specification.js';
68
66
 
69
67
  test('builds the project', async () => {
70
- // Given - sample app project
71
- const result = await run('build').project('sample-app').exec('build').run();
68
+ // Given - sample app project spread into the cwd
69
+ const result = await cli.fixture('$FIXTURES/sample-app/').exec('build');
72
70
 
73
- // Then - ESM output with source maps
71
+ // Then - ESM output, no CJS
74
72
  expect(result.exitCode).toBe(0);
75
73
  expect(result.stdout).toContain('Build completed');
76
74
  expect(result.file('dist/index.js').exists).toBe(true);
77
75
  expect(result.file('dist/index.cjs').exists).toBe(false);
78
- expect(result.file('dist/index.js').content).toContain('Hello');
79
76
  });
80
77
  ```
81
78
 
82
- ## Specification runners
79
+ Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
83
80
 
84
- Three modes, same builder API. Each handles infrastructure and cleanup automatically.
81
+ ## The three constructors
85
82
 
86
- ### `spec(app(...))` - testcontainers + in-process app
83
+ One constructor per tested interface, each returning a record destructured with its canonical name:
87
84
 
88
- Starts real containers via testcontainers. App runs in-process (Hono). Fastest feedback loop.
85
+ | Constructor | Returns | Terminal actions |
86
+ | --------------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
87
+ | `specification.api(options)` | `{ api, cleanup, docker, orchestrator }` | `.request(file)`, `.get()`, `.post()`, `.put()`, `.delete()` |
88
+ | `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
89
+ | `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
89
90
 
90
- ```typescript
91
- import { spec, app } from '@jterrazz/test';
92
- import { postgres, redis } from '@jterrazz/test/services';
91
+ ### `specification.api({ services, server, mode?, root? })`
93
92
 
94
- const db = postgres({ compose: 'db' });
95
- const cache = redis({ compose: 'cache' });
93
+ One definition, two execution modes the switch lives in `vitest.config.ts`, never in the specification file:
96
94
 
97
- export const run = await spec(
98
- app(() => createApp({ databaseUrl: db.connectionString })),
99
- {
100
- services: [db, cache],
101
- root: '../../',
95
+ - **`node`** (default): starts the declared services via testcontainers and runs the app in-process (Hono). Fastest feedback loop.
96
+ - **`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.
97
+
98
+ Resolution: `options.mode` > `TEST_MODE` env var > `'node'`. Only `.api()` has a mode.
99
+
100
+ ```typescript
101
+ // vitest.config.ts — the mode switch lives HERE
102
+ export default defineConfig({
103
+ test: {
104
+ projects: [
105
+ { test: { name: 'http', include: ['specs/api/**/*.test.ts'] } },
106
+ {
107
+ test: {
108
+ name: 'http-stack',
109
+ include: ['specs/api/**/*.test.ts'],
110
+ env: { TEST_MODE: 'compose' },
111
+ },
112
+ },
113
+ ],
102
114
  },
103
- );
115
+ });
104
116
  ```
105
117
 
106
- ### `spec(stack(...))` - docker compose up + real HTTP
118
+ `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.
107
119
 
108
- Starts the full `docker/compose.test.yaml` stack. App URL and databases auto-detected.
120
+ ### `specification.jobs({ services, jobs, root? })`
121
+
122
+ Background jobs run in-process by definition — no HTTP server, no mode:
109
123
 
110
124
  ```typescript
111
- import { spec, stack } from '@jterrazz/test';
125
+ export const { jobs, cleanup } = await specification.jobs({
126
+ services: { db: postgres() },
127
+ jobs: ({ db }) => [nightlyReport(db)], // (services) => JobHandle[], or a static array
128
+ });
112
129
 
113
- export const run = await spec(stack('../../'));
130
+ // In a test:
131
+ const result = await jobs.seed('pending.sql').trigger('nightly-report');
114
132
  ```
115
133
 
116
- ### `spec(command(...))` - local command execution
134
+ A `JobHandle` is `{ name: string; execute: () => Promise<void> }`.
117
135
 
118
- Runs CLI commands against fixture projects in temp directories. Optionally starts infrastructure.
136
+ ### `specification.cli(bin, { root?, services?, docker?, transform? })`
119
137
 
120
- ```typescript
121
- import { spec, command } from '@jterrazz/test';
122
- import { postgres } from '@jterrazz/test/services';
138
+ Runs a command binary against fixture projects in fresh temp directories. 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.
123
139
 
124
- export const run = await spec(command(resolve(import.meta.dirname, '../../bin/my-cli.sh')), {
125
- root: '../fixtures',
140
+ ```typescript
141
+ export const { cli, cleanup } = await specification.cli('my-migrate-tool', {
142
+ services: { db: postgres() },
126
143
  });
127
144
 
128
- // With infrastructure (CLI that needs a database)
129
- const db = postgres({ compose: 'db' });
130
-
131
- export const run = await spec(command('my-migrate-tool'), {
132
- root: '../fixtures',
133
- services: [db],
134
- });
145
+ // DATABASE_URL / DB_URL are already in the child env:
146
+ const result = await cli.seed('legacy-schema.sql').exec('up');
135
147
  ```
136
148
 
137
- ## Builder API
138
-
139
- Every test follows the same pattern: `run("label") -> setup -> action -> assertions`.
140
-
141
- ### Setup (cross-mode)
149
+ ### Root auto-discovery
142
150
 
143
- | Method | Description |
144
- | ---------------------------------------- | --------------------------------------------------------- |
145
- | `.seed("file.sql")` | Load SQL from `seeds/file.sql` into the default database |
146
- | `.seed("file.sql", { service: "name" })` | Load SQL into a specific database |
147
- | `.fixture("file")` | Copy `fixtures/file` into the CLI working directory |
148
- | `.project("name")` | Copy `fixtures/name/` into a fresh temp dir and run there |
151
+ When `root` is absent, the framework walks up from the specification file to the first directory containing `docker/compose.test.yaml`, else the first containing `package.json`. Pass `root` only when the convention does not fit. `root` is strictly the **project root** (compose detection + local-bin resolution) — it is not a fixtures root; `.fixture()` resolves its own paths.
149
152
 
150
- ### Actions (one per spec, mutually exclusive)
153
+ ## Builder API
151
154
 
152
- **HTTP:**
155
+ ### Setup (chainable)
153
156
 
154
- | Method | Description |
155
- | -------------------------- | --------------------------------------------- |
156
- | `.get(path)` | HTTP GET request |
157
- | `.post(path, "body.json")` | HTTP POST with body from `requests/body.json` |
158
- | `.put(path, "body.json")` | HTTP PUT with body from `requests/body.json` |
159
- | `.delete(path)` | HTTP DELETE request |
157
+ | Method | Facets | Description |
158
+ | --------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------ |
159
+ | `.seed("file.sql", { database? })` | all | Load SQL from `seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
160
+ | `.fixture("file")` | cli | Copy the feature-local `fixtures/file` into the working directory |
161
+ | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/fixtures/name/` project into the cwd (trailing `/` = contents; layers) |
162
+ | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
163
+ | `.headers({ "Accept-Language": "fr" })` | api | Set HTTP request headers (merge on top of `.http` file headers) |
164
+ | `.intercept(contract)` | api, jobs | Intercept an outgoing HTTP call with a declared contract |
165
+ | `.intercept(trigger, response)` | api, jobs | Inline intercept for one-off cases |
160
166
 
161
- **CLI:**
167
+ ### Actions (terminal)
162
168
 
163
- | Method | Description |
164
- | -------------------------------------- | ------------------------------------------------------------------------------------- |
165
- | `.exec("args")` | Run command (blocking) |
166
- | `.exec(["build", "start"])` | Run commands sequentially in same directory |
167
- | `.spawn("args", { waitFor, timeout })` | Run long-lived process, resolve on pattern match or timeout |
168
- | `.env({ KEY: "value" })` | Set env vars on the child process (`null` unsets, `$WORKDIR` expands to the temp cwd) |
169
+ | Method | Facet | Resolves to | Description |
170
+ | ------------------------------------------ | ----- | ------------ | ---------------------------------------------------------------------------------- |
171
+ | `.request("create-user.http")` | api | `HttpResult` | Send the COMPLETE request from `requests/<file>` (method, path, headers, raw body) |
172
+ | `.get(path)` / `.delete(path)` | api | `HttpResult` | Inline requests for simple cases |
173
+ | `.post(path, body?)` / `.put(path, body?)` | api | `HttpResult` | Inline body: plain object, JSON-serialized |
174
+ | `.trigger("name")` | jobs | `BaseResult` | Execute a registered job |
175
+ | `.exec("args")` | cli | `CliResult` | Run the command |
176
+ | `.exec(["build", "start"])` | cli | `CliResult` | Sequence in the same cwd; stops on first non-zero exit |
177
+ | `.exec("dev", { waitFor, timeout? })` | cli | `CliResult` | Long-running: resolves at the pattern, killed at `timeout` (default 10 s) |
169
178
 
170
- Every CLI spec runs in a **fresh, empty temp directory** by default. `.project("name")` starts from a copy of `fixtures/name/`; `.fixture("file")` seeds specific files into the temp dir.
179
+ One chain = one terminal action; databases reset at the start of every chain. Every cli spec runs in a fresh, empty temp directory.
171
180
 
172
- ### Assertions
181
+ ## Assertions — everything through `expect()`
173
182
 
174
- Result properties are raw values -- use vitest `expect()` for assertions. Database and response file assertions use custom async methods.
183
+ 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).
175
184
 
176
- **Raw values (vitest expect):**
185
+ ```typescript
186
+ // HTTP
187
+ expect(result.status).toBe(201);
188
+ expect(result.response).toMatch('user-created.http'); // expected/<name> — status + header subset + body
189
+ expect(result.response.body).toEqual({ error: 'User 999 not found' });
177
190
 
178
- | Expression | Description |
179
- | -------------------------------------------- | --------------------------- |
180
- | `expect(result.exitCode).toBe(0)` | CLI exit code |
181
- | `expect(result.status).toBe(201)` | HTTP status code |
182
- | `expect(result.stdout).toContain("hello")` | CLI stdout contains string |
183
- | `expect(result.stderr).not.toContain("err")` | CLI stderr does not contain |
191
+ // Tables (async — queries the database)
192
+ await expect(result.table('orders', { database: 'db' })).toMatchRows({
193
+ columns: ['id', 'status', 'created_at'],
194
+ rows: [[match.uuid(), 'pending', match.iso8601()]],
195
+ });
196
+ await expect(result.table('orders', { database: 'db' })).toBeEmpty();
184
197
 
185
- **Files (result.file returns {exists, content}):**
198
+ // Streams (ANSI stripped by default; .text stays raw)
199
+ expect(result.stdout).toContain('Build completed');
200
+ expect(result.stdout).toMatch('help.txt'); // expected/help.txt — {{token}}-aware
201
+ expect(result.json).toMatch('config.json'); // expected/config.json
202
+ expect(result.json.value).toMatchObject({ name: 'shoply' });
186
203
 
187
- | Expression | Description |
188
- | ----------------------------------------------------------------- | --------------------------- |
189
- | `expect(result.file("dist/index.js").exists).toBe(true)` | Assert file exists |
190
- | `expect(result.file("dist/index.js").content).toContain("Hello")` | Assert file contains string |
191
- | `expect(result.file("dist/index.cjs").exists).toBe(false)` | Assert file does not exist |
204
+ // Files & trees
205
+ expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop');
206
+ await expect(result.directory('my-shop')).toMatch('shop-scaffold'); // expected/shop-scaffold/
207
+ await expect(result.filesystem).toMatch('upgraded-shop'); // whole cwd
192
208
 
193
- **Directories (CLI scaffolding / codegen output):**
209
+ // Containers (docker-aware cli)
210
+ await expect(result.container('alpha')).toBeRunning();
211
+ ```
194
212
 
195
- | Expression | Description |
196
- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
197
- | `await result.directory("out").toMatchFixture("go-api")` | Snapshot the tree against `expected/go-api/`, structured diff on mismatch |
198
- | `await result.directory().toMatchFixture("scaffold", { ignore })` | Pass extra ignore patterns; defaults already skip `.git`, `node_modules`, etc. |
199
- | `await result.directory("out").files()` | List all files (recursive, sorted) for ad-hoc assertions |
213
+ `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.
200
214
 
201
- Run with `JTERRAZZ_TEST_UPDATE=1` (or vitest `-u`) to overwrite fixtures with the current output.
215
+ **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.
202
216
 
203
- **Grep (scoped text matching):**
217
+ ## Dynamic values — one `{{token}}` grammar
204
218
 
205
- ```typescript
206
- expect(result.grep('unused-var.ts')).toContain('no-unused-vars');
207
- expect(result.grep('valid/sorted.ts')).not.toContain('sort-imports');
208
- ```
219
+ The same vocabulary works in `expected/*.http` (body AND headers), `expected/*.json`, text snapshots, and tree-snapshot file contents — and in code via `match.*`:
209
220
 
210
- `result.grep(pattern)` filters multi-line output to the block matching `pattern`, returning a string for vitest assertions.
221
+ `uuid` `ulid` `iso8601` `date` `time` `duration` `number` `int` `float` `semver` `sha` `hex` `base64` `port` `ip` `url` `email` `path` `workdir` `string` `any`
211
222
 
212
- **Response (HTTP body):**
223
+ 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(/…/)`.
213
224
 
214
- | Expression | Description |
215
- | ----------------------------------------------- | ---------------------------------------------------------------------------- |
216
- | `result.response.toMatchFile("expected.json")` | Custom -- compares body to `responses/expected.json`, shows diff on mismatch |
217
- | `expect(result.response.body).toEqual({ ... })` | Raw body object for vitest assertions |
225
+ ```http
226
+ ### expected/order-created.http
227
+ HTTP/1.1 201 Created
228
+ Content-Type: application/json
229
+ Location: /orders/{{uuid#order}}
218
230
 
219
- **Tables (custom async -- database queries):**
231
+ {
232
+ "id": "{{uuid#order}}",
233
+ "total": "{{number}}",
234
+ "createdAt": "{{iso8601}}"
235
+ }
236
+ ```
220
237
 
221
- | Expression | Description |
222
- | ------------------------------------------------------------------------------- | ------------------------------ |
223
- | `await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] })` | Assert database table contents |
224
- | `await result.table("events", { service: "analytics-db" }).toMatch({ ... })` | Assert on a specific database |
225
- | `await result.table("users").toBeEmpty()` | Assert database table is empty |
238
+ See [docs/06-tokens.md](docs/06-tokens.md) for the canonical accepted form of every token.
226
239
 
227
- **Docker (container assertions):**
240
+ ## Intercept contracts
228
241
 
229
- | Expression | Description |
230
- | ------------------- | ------------------------------- |
231
- | `runner.docker(id)` | Access a docker container by id |
242
+ External interactions (LLM providers, third-party APIs) are declared as **contracts**: one file per interaction under `contracts/`, flat, with a provider suffix — `contracts/<name>.<provider>.ts`, `provider ∈ { openai, anthropic, http }`:
232
243
 
233
- ## Multi-database support
244
+ ```typescript
245
+ // contracts/classify-product.openai.ts
246
+ import { defineContract, openai } from '@jterrazz/test';
234
247
 
235
- When multiple databases are declared, `seed()` and `result.table()` accept `{ service: "name" }` to target a specific database by its compose name. Without `service`, both default to the first postgres.
248
+ export default defineContract({
249
+ trigger: openai.responses({ user: /Product Classification/, tools: ['classify'] }),
250
+ response: openai.reply({ category: 'ELECTRONICS', confidence: 0.97 }),
251
+ });
252
+ ```
236
253
 
237
254
  ```typescript
238
- import { spec, app } from '@jterrazz/test';
239
- import { postgres } from '@jterrazz/test/services';
255
+ const result = await jobs.intercept(classifyProduct).trigger('nightly-report');
256
+ ```
240
257
 
241
- const db = postgres({ compose: "db" });
242
- const analyticsDb = postgres({ compose: "analytics-db" });
258
+ Inline `.intercept(trigger, response)` and JSON fixtures (`intercepts/<provider>/<name>.json`) remain for one-off cases. Failure simulation: `openai.error(429)`, `anthropic.timeout()`, `openai.malformed('not json')`. Intercepts queue FIFO per trigger. MSW ships as a direct dependency — no separate install.
243
259
 
244
- const run = await spec(app(() => createApp({ ... })), {
245
- services: [db, analyticsDb],
246
- root: '../../',
247
- });
260
+ ## Docker-aware CLIs
248
261
 
249
- const result = await run("cross-db")
250
- .seed("users.sql")
251
- .seed("events.sql", { service: "analytics-db" })
252
- .post("/users", "request.json")
253
- .run();
262
+ 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:
254
263
 
255
- expect(result.status).toBe(201);
256
- await result.table("users").toMatch({ columns: ["name"], rows: [["Alice"]] });
257
- await result.table("events", { service: "analytics-db" }).toMatch({
258
- columns: ["type"],
259
- rows: [["user_created"]],
264
+ ```typescript
265
+ test('deploy spawns a labelled container', async () => {
266
+ // Given
267
+ await using result = await cli.fixture('$FIXTURES/two-shops/').exec('deploy alpha');
268
+
269
+ // Then - property reads are sync; only the matcher is async
270
+ const shop = result.container('alpha');
271
+ expect(shop.exists).toBe(true);
272
+ await expect(shop).toBeRunning();
273
+ expect(shop.file('/app/shoply.yaml').content).toContain('name: alpha');
260
274
  });
261
275
  ```
262
276
 
263
277
  ## Service factories
264
278
 
265
- ```typescript
266
- import { postgres, redis } from '@jterrazz/test/services';
279
+ | Factory | Options | Connection string |
280
+ | ------------ | -------------------------------- | ------------------------------------- |
281
+ | `postgres()` | `composeService`, `image`, `env` | `postgresql://user:pass@host:port/db` |
282
+ | `redis()` | `composeService`, `image` | `redis://host:port` |
283
+ | `sqlite()` | `init` or `prismaSchema` | `file:/tmp/....sqlite` |
267
284
 
268
- const db = postgres({ compose: 'db' });
269
- const cache = redis({ compose: 'cache' });
270
- ```
271
-
272
- Service handles read image and environment from `docker/compose.test.yaml`. After the runner starts, `.connectionString` is populated from the running container.
273
-
274
- | Factory | Options | Connection string |
275
- | ------------ | ------------------------- | ------------------------------------- |
276
- | `postgres()` | `compose`, `image`, `env` | `postgresql://user:pass@host:port/db` |
277
- | `redis()` | `compose`, `image` | `redis://host:port` |
285
+ `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.
278
286
 
279
287
  ## Mocking utilities
280
288
 
281
289
  ```typescript
282
- import { mockOf, mockOfDate } from '@jterrazz/test/mock';
290
+ import { mockOf, mockOfDate } from '@jterrazz/test';
283
291
  ```
284
292
 
285
- | Export | Description |
286
- | ------------- | -------------------------------------------- |
287
- | `mockOf<T>()` | Deep mock of any interface |
288
- | `mockOfDate` | Date mocking via `.set(date)` and `.reset()` |
293
+ | Export | Description |
294
+ | ------------- | ------------------------------------------------------------ |
295
+ | `mockOf<T>()` | Deep mock of any interface (wraps `vitest-mock-extended`) |
296
+ | `mockOfDate` | Freeze/reset the global Date via `.set(date)` and `.reset()` |
289
297
 
290
298
  ## Conventions
291
299
 
292
- ### Docker
300
+ Normative rules live in [CONVENTIONS.md](CONVENTIONS.md). A facet (`specs/<facet>/`) carries its runner(s) at its root and holds domain folders; the folder follows the assets:
293
301
 
294
302
  ```
295
- docker/
296
- ├── compose.test.yaml # Source of truth for test infrastructure
297
- ├── postgres/
298
- │ └── init.sql # Auto-run on container start
303
+ specs/<facet>/ # api | jobs | cli | integrations | lint
304
+ ├── <facet>.specification.ts # runner(s) at the facet ROOT (rule C1)
305
+ └── <domain>/ # a product command/area — 1..n test files
306
+ ├── <aspect>.test.ts
307
+ ├── seeds/ # *.sql ONLY — database state
308
+ ├── requests/ # *.http — inputs: COMPLETE request (method, path, headers, body)
309
+ ├── contracts/ # <name>.<provider>.ts — declared external interactions
310
+ ├── intercepts/ # <provider>/<name>.json — inline intercept fixtures
311
+ ├── fixtures/ # domain-local files/dirs copied into the cwd (cli) — shared pool lives at specs/fixtures/
312
+ └── expected/ # ALL expected fixtures, FLAT (incl. response *.http) — a slash in the name creates a subfolder
299
313
  ```
300
314
 
301
- ### Test structure
315
+ 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.
302
316
 
303
- ```
304
- tests/
305
- ├── e2e/ # Full-stack specification tests
306
- │ └── {feature}/
307
- │ ├── {feature}.e2e.test.ts
308
- │ ├── seeds/ # Database state setup
309
- │ ├── fixtures/ # Files copied into CLI working dir
310
- │ ├── requests/ # HTTP request bodies
311
- │ ├── responses/ # Expected HTTP responses
312
- │ └── expected/ # Expected CLI output
313
- ├── integration/ # Infrastructure tests (containers)
314
- └── setup/ # Specification runners, fixtures, helpers
315
- ├── fixtures/ # Shared fixture projects
316
- ├── helpers/ # Shared test utilities
317
- └── *.specification.ts # Runner setup files
318
- ```
317
+ 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.
319
318
 
320
- ### File naming
319
+ ### Convention enforcement — the shipped lint plugin
321
320
 
322
- | Type | Suffix | Location |
323
- | ----------- | ---------------------- | --------------------- |
324
- | Unit | `.test.ts` | Colocated with source |
325
- | Integration | `.integration.test.ts` | `tests/integration/` |
326
- | E2E | `.e2e.test.ts` | `tests/e2e/` |
321
+ 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 catalogue (each rule, its channel and rationale) lives in [CONVENTIONS-CATALOG.md](CONVENTIONS-CATALOG.md).
327
322
 
328
- ### Test writing
329
-
330
- Every test uses `// Given` and `// Then` comments. Always both, never one without the other.
331
-
332
- ```typescript
333
- test('creates a user and returns 201', async () => {
334
- // Given - two existing users
335
- const result = await run('creates user')
336
- .seed('initial-users.sql')
337
- .post('/users', 'new-user.json')
338
- .run();
339
-
340
- // Then - user created with all three in table
341
- expect(result.status).toBe(201);
342
- await result.table('users').toMatch({
343
- columns: ['name'],
344
- rows: [['Alice'], ['Bob'], ['Charlie']],
345
- });
346
- });
347
- ```
323
+ ## Requirements
348
324
 
349
- `// When` is only used if the action isn't obvious. The spec builder chain (`.seed().post().run()` / `.project().exec().run()`) IS the when.
325
+ - **Docker** - testcontainers for node mode, docker compose for compose mode; not needed for `sqlite()` or plain cli specs
326
+ - **vitest** - peer dependency
327
+ - **msw** - bundled as a direct dependency (powers `.intercept()`); no separate install
328
+ - **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
350
329
 
351
- ## Requirements
330
+ ## Docs
352
331
 
353
- - **Docker** -- testcontainers for `spec(app(...))`, docker compose for `spec(stack(...))`
354
- - **vitest** -- peer dependency
355
- - **hono** -- optional peer, only needed for `spec(app(...))` mode with in-process apps
332
+ - Guide (chapters): [docs/README.md](docs/README.md) getting started, API/jobs/CLI specs, assertions, tokens, contracts, services, conventions
333
+ - API reference + LLM-friendly docs: <https://jterrazz.github.io/package-test/>
334
+ - Agent ingestion: <https://jterrazz.github.io/package-test/llms-full.txt>
@@ -0,0 +1 @@
1
+ export { };