@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.
- package/README.md +209 -230
- package/dist/checker.d.ts +1 -0
- package/dist/checker.js +741 -0
- package/dist/index.d.ts +1296 -529
- package/dist/index.js +3303 -1261
- package/dist/intercept.js +129 -290
- package/dist/match.js +153 -0
- package/dist/oxlint.cjs +2931 -0
- package/dist/oxlint.d.cts +150 -0
- package/dist/oxlint.d.ts +150 -0
- package/dist/oxlint.js +2925 -0
- package/package.json +47 -41
- package/dist/chunk.cjs +0 -28
- package/dist/index.cjs +0 -1814
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -734
- package/dist/index.js.map +0 -1
- package/dist/intercept.cjs +0 -313
- package/dist/intercept.cjs.map +0 -1
- package/dist/intercept.d.cts +0 -105
- package/dist/intercept.d.ts +0 -105
- package/dist/intercept.js.map +0 -1
- package/dist/intercept2.cjs +0 -81
- package/dist/intercept2.cjs.map +0 -1
- package/dist/intercept2.js +0 -81
- package/dist/intercept2.js.map +0 -1
- package/dist/mock-of.cjs +0 -32
- package/dist/mock-of.cjs.map +0 -1
- package/dist/mock-of.d.cts +0 -27
- package/dist/mock-of.d.ts +0 -27
- package/dist/mock-of.js +0 -19
- package/dist/mock-of.js.map +0 -1
- package/dist/mock.cjs +0 -4
- package/dist/mock.d.cts +0 -2
- package/dist/mock.d.ts +0 -2
- package/dist/mock.js +0 -2
- package/dist/services.cjs +0 -5
- package/dist/services.d.cts +0 -2
- package/dist/services.d.ts +0 -2
- package/dist/services.js +0 -2
- package/dist/sqlite.adapter.cjs +0 -350
- package/dist/sqlite.adapter.cjs.map +0 -1
- package/dist/sqlite.adapter.d.cts +0 -209
- package/dist/sqlite.adapter.d.ts +0 -209
- package/dist/sqlite.adapter.js +0 -331
- package/dist/sqlite.adapter.js.map +0 -1
- package/dist/types.d.cts +0 -42
- 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.
|
|
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
|
-
//
|
|
16
|
+
// specs/api/api.specification.ts
|
|
15
17
|
import { afterAll } from 'vitest';
|
|
16
|
-
import {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
26
|
+
afterAll(cleanup);
|
|
31
27
|
```
|
|
32
28
|
|
|
33
29
|
```typescript
|
|
34
|
-
//
|
|
35
|
-
import {
|
|
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 -
|
|
39
|
-
const result = await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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']
|
|
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
|
-
//
|
|
50
|
+
// specs/cli/cli.specification.ts
|
|
57
51
|
import { resolve } from 'node:path';
|
|
58
|
-
import {
|
|
52
|
+
import { afterAll } from 'vitest';
|
|
53
|
+
import { specification } from '@jterrazz/test';
|
|
59
54
|
|
|
60
|
-
export const
|
|
61
|
-
|
|
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
|
-
//
|
|
67
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
## The three constructors
|
|
85
82
|
|
|
86
|
-
|
|
83
|
+
One constructor per tested interface, each returning a record destructured with its canonical name:
|
|
87
84
|
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
// In a test:
|
|
131
|
+
const result = await jobs.seed('pending.sql').trigger('nightly-report');
|
|
114
132
|
```
|
|
115
133
|
|
|
116
|
-
|
|
134
|
+
A `JobHandle` is `{ name: string; execute: () => Promise<void> }`.
|
|
117
135
|
|
|
118
|
-
|
|
136
|
+
### `specification.cli(bin, { root?, services?, docker?, transform? })`
|
|
119
137
|
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
140
|
+
```typescript
|
|
141
|
+
export const { cli, cleanup } = await specification.cli('my-migrate-tool', {
|
|
142
|
+
services: { db: postgres() },
|
|
126
143
|
});
|
|
127
144
|
|
|
128
|
-
//
|
|
129
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
## Builder API
|
|
151
154
|
|
|
152
|
-
|
|
155
|
+
### Setup (chainable)
|
|
153
156
|
|
|
154
|
-
| Method
|
|
155
|
-
|
|
|
156
|
-
| `.
|
|
157
|
-
| `.
|
|
158
|
-
| `.
|
|
159
|
-
| `.
|
|
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
|
-
|
|
167
|
+
### Actions (terminal)
|
|
162
168
|
|
|
163
|
-
| Method
|
|
164
|
-
|
|
|
165
|
-
| `.
|
|
166
|
-
| `.
|
|
167
|
-
| `.
|
|
168
|
-
| `.
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
## Assertions — everything through `expect()`
|
|
173
182
|
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
209
|
+
// Containers (docker-aware cli)
|
|
210
|
+
await expect(result.container('alpha')).toBeRunning();
|
|
211
|
+
```
|
|
194
212
|
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
+
## Dynamic values — one `{{token}}` grammar
|
|
204
218
|
|
|
205
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
231
|
+
{
|
|
232
|
+
"id": "{{uuid#order}}",
|
|
233
|
+
"total": "{{number}}",
|
|
234
|
+
"createdAt": "{{iso8601}}"
|
|
235
|
+
}
|
|
236
|
+
```
|
|
220
237
|
|
|
221
|
-
|
|
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
|
-
|
|
240
|
+
## Intercept contracts
|
|
228
241
|
|
|
229
|
-
|
|
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
|
-
|
|
244
|
+
```typescript
|
|
245
|
+
// contracts/classify-product.openai.ts
|
|
246
|
+
import { defineContract, openai } from '@jterrazz/test';
|
|
234
247
|
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
255
|
+
const result = await jobs.intercept(classifyProduct).trigger('nightly-report');
|
|
256
|
+
```
|
|
240
257
|
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
services: [db, analyticsDb],
|
|
246
|
-
root: '../../',
|
|
247
|
-
});
|
|
260
|
+
## Docker-aware CLIs
|
|
248
261
|
|
|
249
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
├──
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
319
|
+
### Convention enforcement — the shipped lint plugin
|
|
321
320
|
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
330
|
+
## Docs
|
|
352
331
|
|
|
353
|
-
-
|
|
354
|
-
-
|
|
355
|
-
-
|
|
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 { };
|