@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 +101 -35
- package/dist/ambiguity.js +126 -0
- package/dist/appium.adapter.js +399 -0
- package/dist/checker.js +0 -1
- package/dist/index.d.ts +500 -101
- package/dist/index.js +587 -464
- package/dist/intercept.js +37 -84
- package/dist/match.js +12 -1
- package/dist/oxlint.cjs +420 -117
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +420 -117
- package/dist/playwright.adapter.js +3 -127
- package/dist/queue.js +555 -0
- 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 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(
|
|
215
|
-
| `.intercept(
|
|
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
|
|
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
|
|
|
@@ -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
|
-
##
|
|
343
|
+
## Contracts
|
|
293
344
|
|
|
294
|
-
|
|
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.
|
|
348
|
+
// contracts/openai/classify-product.ts
|
|
298
349
|
import { defineContract, openai } from '@jterrazz/test';
|
|
299
350
|
|
|
300
351
|
export default defineContract({
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 };
|