@jterrazz/test 15.3.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 +120 -427
- package/dist/ambiguity.js +7 -6
- package/dist/appium.adapter.js +12 -11
- package/dist/artifacts.js +25 -1735
- package/dist/browser/index.js +256 -233
- package/dist/checker.d.ts +1 -1
- package/dist/checker.js +870 -73
- package/dist/fixtures.js +110 -0
- package/dist/index.d.ts +927 -971
- package/dist/index.js +2116 -2261
- package/dist/literate.js +1747 -0
- package/dist/match.js +625 -84
- package/dist/oxlint.cjs +3515 -1483
- package/dist/oxlint.d.cts +60 -48
- package/dist/oxlint.d.ts +60 -48
- package/dist/oxlint.js +3515 -1483
- package/dist/playwright.adapter.js +100 -6
- package/dist/{intercept.js → server.js} +2 -2
- package/dist/stream.js +6 -6
- package/dist/vitest.d.ts +67 -48
- package/dist/vitest.js +161 -85
- package/package.json +31 -11
- package/dist/spec-document.js +0 -651
package/README.md
CHANGED
|
@@ -1,504 +1,197 @@
|
|
|
1
1
|
# @jterrazz/test
|
|
2
2
|
|
|
3
|
-
Declarative testing framework for APIs, jobs, CLIs,
|
|
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
|
|
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
|
-
##
|
|
13
|
+
## One kind of test, one example
|
|
12
14
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
60
|
-
```
|
|
24
|
+
import { rank } from './ranking.js';
|
|
61
25
|
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
112
|
-
// Given - the fixture homepage
|
|
113
|
-
const result = await website.visit('/');
|
|
41
|
+
import { integration } from '../integration.specification.js';
|
|
114
42
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()`, `.call()`, `.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 six 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.integration(opts?)` | `{ integration, cleanup }` | `.call((services) => …)` |
|
|
162
|
-
| `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
|
|
163
|
-
| `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
|
|
164
|
-
|
|
165
|
-
A rendered component has no constructor: it starts nothing, so it is reached through the `component` chain directly (docs/16).
|
|
166
|
-
|
|
167
|
-
### `specification.api({ services, server, mode?, root? })`
|
|
168
|
-
|
|
169
|
-
One definition, two execution modes — the switch lives in `vitest.config.ts`, never in the specification file:
|
|
170
|
-
|
|
171
|
-
- **`node`** (default): starts the declared services via testcontainers and runs the app in-process (Hono). Fastest feedback loop.
|
|
172
|
-
- **`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.
|
|
52
|
+
**A component** — a rendered unit in a real browser, beside its code ([docs/07](docs/07-component.md)):
|
|
173
53
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
import { defineSpecConfig } from '@jterrazz/test/vitest';
|
|
179
|
-
|
|
180
|
-
export default defineSpecConfig({
|
|
181
|
-
test: {
|
|
182
|
-
projects: [
|
|
183
|
-
{ test: { name: 'http', include: ['specs/api/**/*.test.ts'] } },
|
|
184
|
-
{
|
|
185
|
-
test: {
|
|
186
|
-
name: 'http-stack',
|
|
187
|
-
include: ['specs/api/**/*.test.ts'],
|
|
188
|
-
env: { TEST_MODE: 'compose' },
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
|
-
],
|
|
192
|
-
},
|
|
193
|
-
});
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
`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';
|
|
197
58
|
|
|
198
|
-
|
|
59
|
+
import { PostTable } from './post-table.js';
|
|
199
60
|
|
|
200
|
-
|
|
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
|
+
});
|
|
201
66
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
services: { db: postgres() },
|
|
205
|
-
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');
|
|
206
69
|
});
|
|
207
|
-
|
|
208
|
-
// In a test:
|
|
209
|
-
const result = await jobs.seed('pending.sql').trigger('nightly-report');
|
|
210
70
|
```
|
|
211
71
|
|
|
212
|
-
A
|
|
213
|
-
|
|
214
|
-
### `specification.cli(bin, { root?, services?, docker?, transform?, env?, serve? })`
|
|
215
|
-
|
|
216
|
-
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)):
|
|
217
73
|
|
|
218
74
|
```typescript
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// DATABASE_URL / DB_URL are already in the child env:
|
|
224
|
-
const result = await cli.seed('legacy-schema.sql').exec('up');
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
### `specification.website({ server?, url?, backend?, external?, root? })`
|
|
228
|
-
|
|
229
|
-
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';
|
|
230
78
|
|
|
231
|
-
|
|
232
|
-
export const { website, cleanup } = await specification.website({
|
|
233
|
-
server: { command: 'node specs/_fixtures/website-app/server.mjs', ready: '/' },
|
|
234
|
-
});
|
|
79
|
+
import { website } from '../website.specification.js';
|
|
235
80
|
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
});
|
|
238
88
|
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
await
|
|
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();
|
|
242
92
|
});
|
|
243
93
|
```
|
|
244
94
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
### `specification.mobile({ app, device, backend?, root? })`
|
|
248
|
-
|
|
249
|
-
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)):
|
|
250
96
|
|
|
251
97
|
```typescript
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// A screen behind its deep link, driven by a scenario (the When)
|
|
258
|
-
const result = await mobile.open('news://events', async (visitor) => {
|
|
259
|
-
await visitor.tap(button('Enquête Fauci COVID-19'));
|
|
260
|
-
await visitor.see(content('rapports'));
|
|
261
|
-
});
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
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).
|
|
265
|
-
|
|
266
|
-
### Root auto-discovery
|
|
267
|
-
|
|
268
|
-
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.
|
|
269
|
-
|
|
270
|
-
## vitest config — `defineSpecConfig()`
|
|
98
|
+
// specs/mobile/events/bookmark.spec.ts
|
|
99
|
+
import { button, content } from '@jterrazz/test';
|
|
100
|
+
import { expect, test } from 'vitest';
|
|
271
101
|
|
|
272
|
-
|
|
102
|
+
import { mobile } from '../mobile.specification.js';
|
|
273
103
|
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
});
|
|
276
111
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
test: { include: ['specs/**/*.test.ts'] },
|
|
112
|
+
// Then - the capture reflects the screen after the interaction
|
|
113
|
+
expect(result.content).toContain('Bookmarked');
|
|
280
114
|
});
|
|
281
115
|
```
|
|
282
116
|
|
|
283
|
-
|
|
284
|
-
| ---------------------------------- | ------------------------------------------------------ |
|
|
285
|
-
| `cacheDir` | `.artifacts/vitest` |
|
|
286
|
-
| `test.coverage.reportsDirectory` | `.artifacts/vitest/coverage` (bring your own provider) |
|
|
287
|
-
| `test.testTimeout` / `hookTimeout` | `30_000` |
|
|
288
|
-
| `test.exclude` | vitest's defaults **+** `**/_fixtures/**` |
|
|
289
|
-
|
|
290
|
-
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).
|
|
291
|
-
|
|
292
|
-
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.
|
|
293
|
-
|
|
294
|
-
## Builder API
|
|
295
|
-
|
|
296
|
-
### Setup (chainable)
|
|
297
|
-
|
|
298
|
-
| Method | Facets | Description |
|
|
299
|
-
| --------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
300
|
-
| `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
|
|
301
|
-
| `.fixture("file")` | cli | Copy the feature-local `_fixtures/file` into the working directory |
|
|
302
|
-
| `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/<name>/` project into the cwd (trailing `/` = contents; layers) |
|
|
303
|
-
| `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
|
|
304
|
-
| `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
|
|
305
|
-
| `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
|
|
306
|
-
| `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
|
|
307
|
-
| `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
|
|
308
|
-
| `.clock("2026-03-04T09:30:00Z")` | api, jobs, integration, website | Pin the calendar for this chain — the page's on website, this process's elsewhere |
|
|
309
|
-
|
|
310
|
-
### Actions (terminal)
|
|
311
|
-
|
|
312
|
-
| Method | Facet | Resolves to | Description |
|
|
313
|
-
| ------------------------------------------ | ------- | -------------- | ---------------------------------------------------------------------------------------- |
|
|
314
|
-
| `.request("create-user.http")` | api | `HttpResult` | Send the COMPLETE request from `_requests/<file>` (method, path, headers, raw body) |
|
|
315
|
-
| `.get(path)` / `.delete(path)` | api | `HttpResult` | Inline requests for simple cases |
|
|
316
|
-
| `.post(path, body?)` / `.put(path, body?)` | api | `HttpResult` | Inline body: plain object, JSON-serialized |
|
|
317
|
-
| `.trigger("name")` | jobs | `BaseResult` | Execute a registered job |
|
|
318
|
-
| `.exec("args")` | cli | `CliResult` | Run the command |
|
|
319
|
-
| `.exec(["build", "start"])` | cli | `CliResult` | Sequence in the same cwd; stops on first non-zero exit |
|
|
320
|
-
| `.exec("dev", { waitFor, timeout? })` | cli | `CliResult` | Long-running: resolves at the pattern, killed at `timeout` (default 10 s) |
|
|
321
|
-
| `.run("case.spec.yaml")` | cli | `CliResult` | Run a `<case>.spec.yaml` — its ground and EVERY run asserted; the last one returns |
|
|
322
|
-
| `.fetch(path)` | website | `FetchResult` | One raw HTTP exchange — redirects surface as 3xx, never followed |
|
|
323
|
-
| `.visit(path, scenario?)` | website | `PageResult` | Render the page in a shared chromium; with a scenario, the capture is the final state |
|
|
324
|
-
| `.open(deepLink?, scenario?)` | mobile | `ScreenResult` | Relaunch the app fresh on the simulator; with a scenario, the capture is the final state |
|
|
325
|
-
|
|
326
|
-
One chain = one terminal action; databases reset at the start of every chain. Every cli spec runs in a fresh, empty temp directory.
|
|
327
|
-
|
|
328
|
-
## Assertions — everything through `expect()`
|
|
329
|
-
|
|
330
|
-
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)):
|
|
331
118
|
|
|
332
119
|
```typescript
|
|
333
|
-
//
|
|
334
|
-
expect
|
|
335
|
-
expect(result.response).toMatch('user-created.http'); // _expected/<name> — status + header subset + body
|
|
336
|
-
expect(result.response.body).toEqual({ error: 'User 999 not found' });
|
|
337
|
-
|
|
338
|
-
// Tables (async — queries the database)
|
|
339
|
-
await expect(result.table('orders', { database: 'db' })).toMatchRows({
|
|
340
|
-
columns: ['id', 'status', 'created_at'],
|
|
341
|
-
rows: [[match.uuid(), 'pending', match.iso8601()]],
|
|
342
|
-
});
|
|
343
|
-
await expect(result.table('orders', { database: 'db' })).toBeEmpty();
|
|
344
|
-
|
|
345
|
-
// Streams (ANSI stripped by default; .text stays raw)
|
|
346
|
-
expect(result.stdout).toContain('Build completed');
|
|
347
|
-
expect(result.stdout).toMatch('help.txt'); // _expected/help.txt — {{token}}-aware
|
|
348
|
-
expect(result.json).toMatch('config.json'); // _expected/config.json
|
|
349
|
-
expect(result.json.value).toMatchObject({ name: 'shoply' });
|
|
350
|
-
|
|
351
|
-
// Files & trees
|
|
352
|
-
expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop');
|
|
353
|
-
await expect(result.directory('my-shop')).toMatch('shop-scaffold'); // _expected/shop-scaffold/
|
|
354
|
-
await expect(result.filesystem).toMatch('upgraded-shop'); // whole cwd
|
|
355
|
-
|
|
356
|
-
// Containers (docker-aware cli)
|
|
357
|
-
await expect(result.container('alpha')).toBeRunning();
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
`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.
|
|
361
|
-
|
|
362
|
-
**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.
|
|
363
|
-
|
|
364
|
-
## Dynamic values — one `{{token}}` grammar
|
|
365
|
-
|
|
366
|
-
The same vocabulary works in `_expected/*.http` (body AND headers), `_expected/*.json`, text snapshots, and tree-snapshot file contents — and in code via `match.*`:
|
|
367
|
-
|
|
368
|
-
`uuid` `ulid` `iso8601` `date` `time` `duration` `number` `int` `float` `semver` `sha` `hex` `base64` `port` `ip` `url` `email` `path` `workdir` `string` `any`
|
|
369
|
-
|
|
370
|
-
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(/…/)`.
|
|
371
|
-
|
|
372
|
-
```http
|
|
373
|
-
### _expected/order-created.http
|
|
374
|
-
HTTP/1.1 201 Created
|
|
375
|
-
Content-Type: application/json
|
|
376
|
-
Location: /orders/{{uuid#order}}
|
|
377
|
-
|
|
378
|
-
{
|
|
379
|
-
"id": "{{uuid#order}}",
|
|
380
|
-
"total": "{{number}}",
|
|
381
|
-
"createdAt": "{{iso8601}}"
|
|
382
|
-
}
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
See [docs/09-tokens.md](docs/09-tokens.md) for the canonical accepted form of every token.
|
|
120
|
+
// specs/api/users/create-user.spec.ts
|
|
121
|
+
import { expect, test } from 'vitest';
|
|
386
122
|
|
|
387
|
-
|
|
123
|
+
import { api } from '../api.specification.js';
|
|
388
124
|
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
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
|
+
});
|
|
398
135
|
});
|
|
399
136
|
```
|
|
400
137
|
|
|
401
|
-
|
|
402
|
-
// contracts/pipeline.contracts.ts
|
|
403
|
-
import { defineContracts, http } from '@jterrazz/test';
|
|
404
|
-
|
|
405
|
-
import classifyProduct from './openai/classify-product.js';
|
|
406
|
-
import exchangeRates from './http/exchange-rates.js';
|
|
407
|
-
|
|
408
|
-
const pipeline = defineContracts(classifyProduct, exchangeRates);
|
|
409
|
-
|
|
410
|
-
export default pipeline;
|
|
411
|
-
|
|
412
|
-
export const withRatesDown = () =>
|
|
413
|
-
pipeline.with({ request: http.get('/rates'), response: http.error(503) });
|
|
414
|
-
```
|
|
138
|
+
**A job** — what a name triggers, in-process ([docs/11](docs/11-jobs.md)):
|
|
415
139
|
|
|
416
140
|
```typescript
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
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';
|
|
421
143
|
|
|
422
|
-
|
|
144
|
+
import { jobs } from '../jobs.specification.js';
|
|
423
145
|
|
|
424
|
-
|
|
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');
|
|
425
149
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
// Then - property reads are sync; only the matcher is async
|
|
432
|
-
const shop = result.container('alpha');
|
|
433
|
-
expect(shop.exists).toBe(true);
|
|
434
|
-
await expect(shop).toBeRunning();
|
|
435
|
-
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
|
+
});
|
|
436
155
|
});
|
|
437
156
|
```
|
|
438
157
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
| Factory | Options | Connection string |
|
|
442
|
-
| ------------ | -------------------------------- | ------------------------------------- |
|
|
443
|
-
| `postgres()` | `composeService`, `image`, `env` | `postgresql://user:pass@host:port/db` |
|
|
444
|
-
| `redis()` | `composeService`, `image` | `redis://host:port` |
|
|
445
|
-
| `sqlite()` | `init` or `prismaSchema` | `file:/…/….sqlite` |
|
|
446
|
-
|
|
447
|
-
`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.
|
|
448
|
-
|
|
449
|
-
`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).
|
|
158
|
+
**A CLI** — the built binary, usually as a document ([docs/12](docs/12-cli.md)):
|
|
450
159
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
| `mockOf<T>()` · `mockOf<T>({ deep: false })` | Deep double of a port (a member never stubbed answers with another double), or the flat one | [docs/12](docs/12-conventions.md#the-doubles-ladder) |
|
|
462
|
-
| `mockOfDate` | Freeze/reset the global `Date` via `.set(date)` / `.reset()` — `clock` is what replaces it | [docs/12](docs/12-conventions.md#time--one-primitive-two-depths) |
|
|
463
|
-
| `required(value, why)` | The value, or a failure naming what was missing and why it mattered — instead of `!` | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
|
|
464
|
-
| `waitUntil(predicate, { timeout?, interval?, why? })` | Wait on a CONDITION, never a duration, on a real-time budget a pinned clock cannot starve | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
|
|
465
|
-
| `unit()` `component()` `api()` `jobs()` `cli()` `integration()` `website()` `mobile()` | The vitest project helpers, each taking `{ include, exclude, timeout, serial }` on top of the project it already is | [docs/02](docs/02-developing.md#vitest-config-the-preset) |
|
|
466
|
-
|
|
467
|
-
## Conventions
|
|
468
|
-
|
|
469
|
-
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:
|
|
470
|
-
|
|
471
|
-
```
|
|
472
|
-
specs/<facet>/ # api | jobs | cli | integration | website | mobile
|
|
473
|
-
├── <facet>.specification.ts # runner(s) at the facet ROOT (rule C1)
|
|
474
|
-
└── <domain>/ # a product command/area — 1..n test files
|
|
475
|
-
├── <aspect>.test.ts
|
|
476
|
-
├── _seeds/ # *.sql ONLY — database state
|
|
477
|
-
├── _requests/ # *.http — inputs: COMPLETE request (method, path, headers, body)
|
|
478
|
-
├── contracts/ # <name>.contracts.ts facade + <provider>/<name>.ts units + their .response.json / .request.ts data
|
|
479
|
-
├── _fixtures/ # domain-local files/dirs copied into the cwd (cli) — shared pool lives at specs/_fixtures/
|
|
480
|
-
└── _expected/ # ALL expected fixtures, FLAT (incl. response *.http) — a slash in the name creates a subfolder
|
|
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
|
|
481
170
|
```
|
|
482
171
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
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
|
|
486
173
|
|
|
487
|
-
|
|
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).
|
|
488
175
|
|
|
489
|
-
|
|
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) |
|
|
490
188
|
|
|
491
|
-
|
|
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.
|
|
492
190
|
|
|
493
|
-
|
|
494
|
-
- **vitest** - peer dependency
|
|
495
|
-
- **playwright** - optional peer dependency, only needed for `.visit()`: `npm install -D playwright && npx playwright install chromium`
|
|
496
|
-
- **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
|
|
497
|
-
- **msw** - bundled as a direct dependency (powers `.intercept()` on a chain and `intercept()` in module scope, under node and in a page); no separate install
|
|
498
|
-
- **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).
|
|
499
192
|
|
|
500
193
|
## Docs
|
|
501
194
|
|
|
502
|
-
-
|
|
503
|
-
- API reference:
|
|
504
|
-
- Agent skill: [skills/jterrazz-test/](skills/jterrazz-test/) — mental model,
|
|
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.
|