@jterrazz/test 10.0.0 → 10.1.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 via `.intercept('<name>.http')`.
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.
@@ -213,20 +262,22 @@ When `root` is absent, the framework walks up from the specification file to the
213
262
  | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
214
263
  | `.intercept(contract)` | api, jobs | Intercept an outgoing HTTP call with a declared contract |
215
264
  | `.intercept(trigger, response)` | api, jobs | Inline intercept for one-off cases |
265
+ | `.intercept("two-events.http")` | all but cli | Declared exchanges from `intercepts/<name>.http` — MSW on api/jobs, the stub backend on website/mobile |
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
 
@@ -307,7 +358,7 @@ export default defineContract({
307
358
  const result = await jobs.intercept(classifyProduct).trigger('nightly-report');
308
359
  ```
309
360
 
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.
361
+ Inline `.intercept(trigger, response)` and JSON fixtures (`intercepts/<provider>/<name>.json`) remain for one-off cases; `.intercept('<name>.http')` loads declared exchanges from a bi-block `intercepts/<name>.http` file — the form website/mobile chains use, served by the declared `backend` stub. 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.
311
362
 
312
363
  ## Docker-aware CLIs
313
364
 
@@ -359,7 +410,7 @@ specs/<facet>/ # api | jobs | cli | integrations | lint
359
410
  ├── seeds/ # *.sql ONLY — database state
360
411
  ├── requests/ # *.http — inputs: COMPLETE request (method, path, headers, body)
361
412
  ├── contracts/ # <name>.<provider>.ts — declared external interactions
362
- ├── intercepts/ # <provider>/<name>.json — inline intercept fixtures
413
+ ├── intercepts/ # <provider>/<name>.json — inline intercept fixtures; <name>.http — declared exchanges (flat)
363
414
  ├── fixtures/ # domain-local files/dirs copied into the cwd (cli) — shared pool lives at specs/fixtures/
364
415
  └── expected/ # ALL expected fixtures, FLAT (incl. response *.http) — a slash in the name creates a subfolder
365
416
  ```
@@ -374,14 +425,15 @@ These conventions are not just prose: the package ships an oxlint plugin (`@jter
374
425
 
375
426
  ## Requirements
376
427
 
377
- - **Docker** - testcontainers for node mode, docker compose for compose mode; not needed for `sqlite()`, plain cli specs, or website specs
428
+ - **Docker** - testcontainers for node mode, docker compose for compose mode; not needed for `sqlite()`, plain cli specs, website specs, or mobile specs
378
429
  - **vitest** - peer dependency
379
430
  - **playwright** - optional peer dependency, only needed for `.visit()`: `npm install -D playwright && npx playwright install chromium`
431
+ - **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
432
  - **msw** - bundled as a direct dependency (powers `.intercept()`); no separate install
381
433
  - **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
434
 
383
435
  ## Docs
384
436
 
385
- - Guide (chapters): [docs/README.md](docs/README.md) — getting started, API/jobs/CLI/website specs, assertions, tokens, contracts, services, conventions, linting
437
+ - Guide (chapters): [docs/README.md](docs/README.md) — getting started, API/jobs/CLI/website/mobile specs, assertions, tokens, contracts, services, conventions, linting
386
438
  - API reference: committed under [docs/reference/](docs/reference/) — compiled from source by `npm run docs`
387
439
  - 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 };