@jterrazz/test 10.0.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @jterrazz/test
2
2
 
3
- Declarative testing framework for APIs, jobs, CLIs, and websites. Four constructors — `specification.api()`, `specification.jobs()`, `specification.cli()`, `specification.website()` — and specs that read as sentences: given → action → assertions. The vitest test name is the spec's description; all assertions go through `expect()` with auto-registered, subject-typed matchers.
3
+ Declarative testing framework for APIs, jobs, CLIs, websites, and mobile apps. Five constructors — `specification.api()`, `specification.jobs()`, `specification.cli()`, `specification.website()`, `specification.mobile()` — and specs that read as sentences: given → action → assertions. The vitest test name is the spec's description; all assertions go through `expect()` with auto-registered, subject-typed matchers.
4
4
 
5
5
  ```bash
6
6
  npm install -D @jterrazz/test vitest
@@ -105,9 +105,38 @@ test('captures the full head surface of a rendered page', async () => {
105
105
  });
106
106
  ```
107
107
 
108
- Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.fetch()`, `.visit()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
108
+ ### Mobile testing (iOS simulator)
109
109
 
110
- ## The four constructors
110
+ ```typescript
111
+ // specs/mobile/mobile.specification.ts
112
+ import { specification } from '@jterrazz/test';
113
+ import { afterAll } from 'vitest';
114
+
115
+ export const { cleanup, mobile } = await specification.mobile({
116
+ app: { bundleId: 'com.jterrazz.fakenews' },
117
+ device: { name: 'iPhone 17', os: '26.5' },
118
+ });
119
+
120
+ afterAll(cleanup);
121
+ ```
122
+
123
+ ```typescript
124
+ // specs/mobile/events/feed.test.ts
125
+ import { expect, test } from 'vitest';
126
+ import { mobile } from '../mobile.specification.js';
127
+
128
+ test('shows the events feed behind its deep link', async () => {
129
+ // Given - the events screen
130
+ const result = await mobile.open('news://events');
131
+
132
+ // Then - one golden covers the whole projected accessibility tree
133
+ expect(result.screen).toMatch('events.screen.json');
134
+ });
135
+ ```
136
+
137
+ Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.fetch()`, `.visit()`, `.open()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
138
+
139
+ ## The five constructors
111
140
 
112
141
  One constructor per tested interface, each returning a record destructured with its canonical name:
113
142
 
@@ -117,6 +146,7 @@ One constructor per tested interface, each returning a record destructured with
117
146
  | `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
118
147
  | `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
119
148
  | `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
149
+ | `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
120
150
 
121
151
  ### `specification.api({ services, server, mode?, root? })`
122
152
 
@@ -176,9 +206,9 @@ export const { cli, cleanup } = await specification.cli('my-migrate-tool', {
176
206
  const result = await cli.seed('legacy-schema.sql').exec('up');
177
207
  ```
178
208
 
179
- ### `specification.website({ server?, url?, external?, root? })`
209
+ ### `specification.website({ server?, url?, backend?, external?, root? })`
180
210
 
181
- 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.
211
+ 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.
182
212
 
183
213
  ```typescript
184
214
  export const { website, cleanup } = await specification.website({
@@ -196,6 +226,25 @@ const page = await website.visit('/', async (visitor) => {
196
226
 
197
227
  The handle destructures to `{ website, cleanup, url }` — no `docker`, no `orchestrator`. `.visit()` needs playwright (`npm install -D playwright && npx playwright install chromium`) — an optional peer dependency, only loaded when a spec actually renders a page. Full reference: [docs/11-website.md](docs/11-website.md).
198
228
 
229
+ ### `specification.mobile({ app, device, backend?, root? })`
230
+
231
+ 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.
232
+
233
+ ```typescript
234
+ export const { mobile, cleanup, udid } = await specification.mobile({
235
+ app: { bundleId: 'com.jterrazz.fakenews' },
236
+ device: { name: 'iPhone 17', os: '26.5' },
237
+ });
238
+
239
+ // A screen behind its deep link, driven by a scenario (the When)
240
+ const result = await mobile.open('news://events', async (visitor) => {
241
+ await visitor.tap(button('Enquête Fauci COVID-19'));
242
+ await visitor.see(content('rapports'));
243
+ });
244
+ ```
245
+
246
+ 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/12-mobile.md](docs/12-mobile.md).
247
+
199
248
  ### Root auto-discovery
200
249
 
201
250
  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, or the cwd of a `specification.website()` server command) — it is not a fixtures root; `.fixture()` resolves its own paths.
@@ -204,29 +253,31 @@ When `root` is absent, the framework walks up from the specification file to the
204
253
 
205
254
  ### Setup (chainable)
206
255
 
207
- | Method | Facets | Description |
208
- | --------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------ |
209
- | `.seed("file.sql", { database? })` | all | Load SQL from `seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
210
- | `.fixture("file")` | cli | Copy the feature-local `fixtures/file` into the working directory |
211
- | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/fixtures/name/` project into the cwd (trailing `/` = contents; layers) |
212
- | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
213
- | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
214
- | `.intercept(contract)` | api, jobs | Intercept an outgoing HTTP call with a declared contract |
215
- | `.intercept(trigger, response)` | api, jobs | Inline intercept for one-off cases |
256
+ | Method | Facets | Description |
257
+ | --------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
258
+ | `.seed("file.sql", { database? })` | all | Load SQL from `seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
259
+ | `.fixture("file")` | cli | Copy the feature-local `fixtures/file` into the working directory |
260
+ | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/fixtures/name/` project into the cwd (trailing `/` = contents; layers) |
261
+ | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
262
+ | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
263
+ | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite MSW on api/jobs, the stub backend on website/mobile |
264
+ | `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
265
+ | `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
216
266
 
217
267
  ### Actions (terminal)
218
268
 
219
- | Method | Facet | Resolves to | Description |
220
- | ------------------------------------------ | ------- | ------------- | ------------------------------------------------------------------------------------- |
221
- | `.request("create-user.http")` | api | `HttpResult` | Send the COMPLETE request from `requests/<file>` (method, path, headers, raw body) |
222
- | `.get(path)` / `.delete(path)` | api | `HttpResult` | Inline requests for simple cases |
223
- | `.post(path, body?)` / `.put(path, body?)` | api | `HttpResult` | Inline body: plain object, JSON-serialized |
224
- | `.trigger("name")` | jobs | `BaseResult` | Execute a registered job |
225
- | `.exec("args")` | cli | `CliResult` | Run the command |
226
- | `.exec(["build", "start"])` | cli | `CliResult` | Sequence in the same cwd; stops on first non-zero exit |
227
- | `.exec("dev", { waitFor, timeout? })` | cli | `CliResult` | Long-running: resolves at the pattern, killed at `timeout` (default 10 s) |
228
- | `.fetch(path)` | website | `FetchResult` | One raw HTTP exchange — redirects surface as 3xx, never followed |
229
- | `.visit(path, scenario?)` | website | `PageResult` | Render the page in a shared chromium; with a scenario, the capture is the final state |
269
+ | Method | Facet | Resolves to | Description |
270
+ | ------------------------------------------ | ------- | -------------- | ---------------------------------------------------------------------------------------- |
271
+ | `.request("create-user.http")` | api | `HttpResult` | Send the COMPLETE request from `requests/<file>` (method, path, headers, raw body) |
272
+ | `.get(path)` / `.delete(path)` | api | `HttpResult` | Inline requests for simple cases |
273
+ | `.post(path, body?)` / `.put(path, body?)` | api | `HttpResult` | Inline body: plain object, JSON-serialized |
274
+ | `.trigger("name")` | jobs | `BaseResult` | Execute a registered job |
275
+ | `.exec("args")` | cli | `CliResult` | Run the command |
276
+ | `.exec(["build", "start"])` | cli | `CliResult` | Sequence in the same cwd; stops on first non-zero exit |
277
+ | `.exec("dev", { waitFor, timeout? })` | cli | `CliResult` | Long-running: resolves at the pattern, killed at `timeout` (default 10 s) |
278
+ | `.fetch(path)` | website | `FetchResult` | One raw HTTP exchange — redirects surface as 3xx, never followed |
279
+ | `.visit(path, scenario?)` | website | `PageResult` | Render the page in a shared chromium; with a scenario, the capture is the final state |
280
+ | `.open(deepLink?, scenario?)` | mobile | `ScreenResult` | Relaunch the app fresh on the simulator; with a scenario, the capture is the final state |
230
281
 
231
282
  One chain = one terminal action; databases reset at the start of every chain. Every cli spec runs in a fresh, empty temp directory.
232
283
 
@@ -289,25 +340,40 @@ Location: /orders/{{uuid#order}}
289
340
 
290
341
  See [docs/06-tokens.md](docs/06-tokens.md) for the canonical accepted form of every token.
291
342
 
292
- ## Intercept contracts
343
+ ## Contracts
293
344
 
294
- 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 }`:
345
+ Everything the outside world replies is a **contract** a request to match and a response to serve, declared together. A feature owns a `contracts/` folder: a public `<name>.contracts.ts` facade (default export = the world, named exports = its scenarios) over internal `<provider>/<name>.ts` units, `provider ∈ { http, openai, anthropic }`.
295
346
 
296
347
  ```typescript
297
- // contracts/classify-product.openai.ts
348
+ // contracts/openai/classify-product.ts
298
349
  import { defineContract, openai } from '@jterrazz/test';
299
350
 
300
351
  export default defineContract({
301
- trigger: openai.responses({ user: /Product Classification/, tools: ['classify'] }),
352
+ request: openai.responses({ user: /Product Classification/, tools: ['classify'] }),
302
353
  response: openai.reply({ category: 'ELECTRONICS', confidence: 0.97 }),
303
354
  });
304
355
  ```
305
356
 
306
357
  ```typescript
307
- const result = await jobs.intercept(classifyProduct).trigger('nightly-report');
358
+ // contracts/pipeline.contracts.ts
359
+ import { defineContracts, http } from '@jterrazz/test';
360
+
361
+ import classifyProduct from './openai/classify-product.js';
362
+ import exchangeRates from './http/exchange-rates.js';
363
+
364
+ const pipeline = defineContracts(classifyProduct, exchangeRates);
365
+
366
+ export default pipeline;
367
+
368
+ export const withRatesDown = () =>
369
+ pipeline.with({ request: http.get('/rates'), response: http.error(503) });
370
+ ```
371
+
372
+ ```typescript
373
+ const result = await jobs.intercept(pipeline).trigger('nightly-report');
308
374
  ```
309
375
 
310
- 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.
376
+ 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/07-contracts.md](docs/07-contracts.md).
311
377
 
312
378
  ## Docker-aware CLIs
313
379
 
@@ -358,8 +424,7 @@ specs/<facet>/ # api | jobs | cli | integrations | lint
358
424
  ├── <aspect>.test.ts
359
425
  ├── seeds/ # *.sql ONLY — database state
360
426
  ├── requests/ # *.http — inputs: COMPLETE request (method, path, headers, body)
361
- ├── contracts/ # <name>.<provider>.ts declared external interactions
362
- ├── intercepts/ # <provider>/<name>.json — inline intercept fixtures
427
+ ├── contracts/ # <name>.contracts.ts facade + <provider>/<name>.ts units + their .response.json / .request.ts data
363
428
  ├── fixtures/ # domain-local files/dirs copied into the cwd (cli) — shared pool lives at specs/fixtures/
364
429
  └── expected/ # ALL expected fixtures, FLAT (incl. response *.http) — a slash in the name creates a subfolder
365
430
  ```
@@ -374,14 +439,15 @@ These conventions are not just prose: the package ships an oxlint plugin (`@jter
374
439
 
375
440
  ## Requirements
376
441
 
377
- - **Docker** - testcontainers for node mode, docker compose for compose mode; not needed for `sqlite()`, plain cli specs, or website specs
442
+ - **Docker** - testcontainers for node mode, docker compose for compose mode; not needed for `sqlite()`, plain cli specs, website specs, or mobile specs
378
443
  - **vitest** - peer dependency
379
444
  - **playwright** - optional peer dependency, only needed for `.visit()`: `npm install -D playwright && npx playwright install chromium`
445
+ - **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
380
446
  - **msw** - bundled as a direct dependency (powers `.intercept()`); no separate install
381
447
  - **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
382
448
 
383
449
  ## Docs
384
450
 
385
- - Guide (chapters): [docs/README.md](docs/README.md) — getting started, API/jobs/CLI/website specs, assertions, tokens, contracts, services, conventions, linting
451
+ - Guide (chapters): [docs/README.md](docs/README.md) — getting started, API/jobs/CLI/website/mobile specs, assertions, tokens, contracts, services, conventions, linting
386
452
  - API reference: committed under [docs/reference/](docs/reference/) — compiled from source by `npm run docs`
387
453
  - Agent skill: [skills/jterrazz-test/](skills/jterrazz-test/) — mental model, per-facet references, generated rule reference
@@ -0,0 +1,126 @@
1
+ //#region src/core/specification/website/ambiguity.ts
2
+ /**
3
+ * The ambiguity refusal — CONVENTIONS W3.
4
+ *
5
+ * A visit descriptor must designate exactly one element. Acting on "the
6
+ * first match" is the failure mode this module exists to prevent: the spec
7
+ * keeps passing while the visitor clicks something else, and nothing ever
8
+ * reports it. So when a descriptor matches several elements the framework
9
+ * refuses, and the refusal has to carry everything needed to fix it without
10
+ * opening a browser — the descriptor in the caller's own vocabulary, every
11
+ * candidate, and the concrete rewrites that would resolve it.
12
+ *
13
+ * Pure string building: it takes captured data and returns a message, so the
14
+ * wording is unit-testable and the browser integration stays a thin adapter.
15
+ */
16
+ /** `kind` → the constructor that builds it, so errors echo the caller's source. */
17
+ const CONSTRUCTORS = {
18
+ banner: "banner",
19
+ button: "button",
20
+ complementary: "complementary",
21
+ contentinfo: "contentinfo",
22
+ field: "field",
23
+ form: "form",
24
+ heading: "heading",
25
+ link: "link",
26
+ main: "main",
27
+ navigation: "navigation",
28
+ region: "region",
29
+ search: "search",
30
+ testId: "testId",
31
+ text: "content"
32
+ };
33
+ /** The landmark a context string maps back to, for a copy-pasteable suggestion. */
34
+ const CONTEXT_LANDMARKS = {
35
+ aside: "complementary()",
36
+ banner: "banner()",
37
+ complementary: "complementary()",
38
+ contentinfo: "contentinfo()",
39
+ footer: "contentinfo()",
40
+ form: "form()",
41
+ header: "banner()",
42
+ main: "main()",
43
+ nav: "navigation()",
44
+ navigation: "navigation()",
45
+ search: "search()"
46
+ };
47
+ /**
48
+ * Render a descriptor as the source that would build it — `link('Articles')`,
49
+ * `within(navigation(), link('Articles', { exact: true }))`. The error speaks
50
+ * the vocabulary the author wrote, never playwright's.
51
+ */
52
+ function formatElement(element) {
53
+ const bare = formatBare(element);
54
+ return element.scope ? `within(${formatElement(element.scope)}, ${bare})` : bare;
55
+ }
56
+ function formatBare(element) {
57
+ const args = [];
58
+ if (element.name !== void 0) args.push(JSON.stringify(element.name));
59
+ if (element.exact) args.push("{ exact: true }");
60
+ return `${CONSTRUCTORS[element.kind]}(${args.join(", ")})`;
61
+ }
62
+ /** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
63
+ function formatMatch(match, index) {
64
+ const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
65
+ const context = match.context ? ` in <${match.context}>` : "";
66
+ return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${context}`;
67
+ }
68
+ function quoteDetail(match) {
69
+ return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
70
+ }
71
+ /**
72
+ * The rewrites worth offering, in the order they should be tried. Scoping is
73
+ * always available; `exact` only when it would actually narrow the set, and
74
+ * the count it would leave is stated so a still-ambiguous suggestion never
75
+ * reads as a fix.
76
+ */
77
+ function formatFixes(element, matches) {
78
+ const fixes = [];
79
+ const landmarks = [...new Set(matches.map((match) => match.context && CONTEXT_LANDMARKS[match.context]).filter((landmark) => Boolean(landmark)))];
80
+ if (landmarks.length > 0 && !element.scope) fixes.push(`scope it within(${landmarks[0]}, ${formatBare(element)})${landmarks.length > 1 ? ` [also here: ${landmarks.slice(1).join(", ")}]` : ""}`);
81
+ else if (!element.scope) fixes.push(`scope it within(main(), ${formatBare(element)})`);
82
+ if (!element.exact && element.name !== void 0) {
83
+ const remaining = matches.filter((match) => match.text === element.name).length;
84
+ if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
85
+ ...element,
86
+ exact: true
87
+ })} [leaves ${remaining} of ${matches.length}]`);
88
+ }
89
+ fixes.push("other element a heading(), button() or field() may name one thing where this does not");
90
+ return fixes;
91
+ }
92
+ /**
93
+ * The W3 refusal. A distinct class so the visit wrapper can recognize an
94
+ * already-complete message and let it through instead of nesting it inside
95
+ * `visit scenario failed: …`, which would print the whole thing twice.
96
+ */
97
+ var AmbiguousElementError = class extends Error {
98
+ name = "AmbiguousElementError";
99
+ };
100
+ /**
101
+ * Build the refusal thrown when a descriptor matches more than one element.
102
+ *
103
+ * @param options.element The descriptor as the caller wrote it.
104
+ * @param options.matches Every candidate, in DOM order (already truncated).
105
+ * @param options.url The page the visitor was on when it happened.
106
+ */
107
+ function describeAmbiguity(options) {
108
+ const { element, matches, url } = options;
109
+ const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
110
+ return [
111
+ `Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on ${url}.`,
112
+ "",
113
+ "A spec must designate exactly one element. Acting on the first match would let",
114
+ "this test keep passing while the visitor interacts with something else.",
115
+ "",
116
+ "Matched:",
117
+ ...matches.map((match, index) => formatMatch(match, index)),
118
+ "",
119
+ "Disambiguate with one of:",
120
+ ...fixes,
121
+ "",
122
+ "Docs: docs/11-website.md#designating-exactly-one-element (CONVENTIONS W3)"
123
+ ].join("\n");
124
+ }
125
+ //#endregion
126
+ export { describeAmbiguity as n, formatElement as r, AmbiguousElementError as t };