@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 +72 -20
- package/dist/ambiguity.js +126 -0
- package/dist/appium.adapter.js +386 -0
- package/dist/index.d.ts +345 -7
- package/dist/index.js +602 -20
- package/dist/oxlint.cjs +104 -61
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +104 -61
- package/dist/playwright.adapter.js +3 -127
- package/package.json +13 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @jterrazz/test
|
|
2
2
|
|
|
3
|
-
Declarative testing framework for APIs, jobs, CLIs, and
|
|
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
|
-
|
|
108
|
+
### Mobile testing (iOS simulator)
|
|
109
109
|
|
|
110
|
-
|
|
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
|
|
220
|
-
| ------------------------------------------ | ------- |
|
|
221
|
-
| `.request("create-user.http")` | api | `HttpResult`
|
|
222
|
-
| `.get(path)` / `.delete(path)` | api | `HttpResult`
|
|
223
|
-
| `.post(path, body?)` / `.put(path, body?)` | api | `HttpResult`
|
|
224
|
-
| `.trigger("name")` | jobs | `BaseResult`
|
|
225
|
-
| `.exec("args")` | cli | `CliResult`
|
|
226
|
-
| `.exec(["build", "start"])` | cli | `CliResult`
|
|
227
|
-
| `.exec("dev", { waitFor, timeout? })` | cli | `CliResult`
|
|
228
|
-
| `.fetch(path)` | website | `FetchResult`
|
|
229
|
-
| `.visit(path, scenario?)` | website | `PageResult`
|
|
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
|
|
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 };
|