@jterrazz/test 14.3.0 → 15.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
@@ -87,7 +87,7 @@ runs:
87
87
  Error: no my-cli.yaml in the current directory
88
88
  ```
89
89
 
90
- Same engine as the chain, same `{{token}}` grammar, same `TEST_UPDATE=1` — which rewrites each run's exit code and streams, and nothing else. A JSON Schema ships at `@jterrazz/test/schema` so an editor validates as you type. Full grammar: [docs/04-cli.md](docs/04-cli.md#spec-documents--casespecyaml).
90
+ Same engine as the chain, same `{{token}}` grammar, same `TEST_UPDATE=1` — which rewrites each run's exit code and streams, and nothing else. A JSON Schema ships at `@jterrazz/test/schema` so an editor validates as you type. Full grammar: [docs/07-cli.md](docs/07-cli.md#spec-documents--casespecyaml).
91
91
 
92
92
  ### Website testing (browser)
93
93
 
@@ -210,7 +210,7 @@ A `JobHandle` is `{ name: string; execute: () => Promise<void> }`.
210
210
 
211
211
  ### `specification.cli(bin, { root?, services?, docker?, transform?, env?, serve? })`
212
212
 
213
- Runs a command binary against fixture projects in fresh temp directories. `env` (named environment sets) and `serve` (named servers) are the registries a [`<case>.spec.yaml`](docs/04-cli.md#spec-documents--casespecyaml) names by word. 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.
213
+ Runs a command binary against fixture projects in fresh temp directories. `env` (named environment sets) and `serve` (named servers) are the registries a [`<case>.spec.yaml`](docs/07-cli.md#spec-documents--casespecyaml) names by word. 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.
214
214
 
215
215
  ```typescript
216
216
  export const { cli, cleanup } = await specification.cli('my-migrate-tool', {
@@ -239,7 +239,7 @@ const page = await website.visit('/', async (visitor) => {
239
239
  });
240
240
  ```
241
241
 
242
- 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).
242
+ 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/14-website.md](docs/14-website.md).
243
243
 
244
244
  ### `specification.mobile({ app, device, backend?, root? })`
245
245
 
@@ -258,7 +258,7 @@ const result = await mobile.open('news://events', async (visitor) => {
258
258
  });
259
259
  ```
260
260
 
261
- 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).
261
+ 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/15-mobile.md](docs/15-mobile.md).
262
262
 
263
263
  ### Root auto-discovery
264
264
 
@@ -284,7 +284,7 @@ export default defineSpecConfig({
284
284
  | `test.testTimeout` / `hookTimeout` | `30_000` |
285
285
  | `test.exclude` | vitest's defaults **+** `**/_fixtures/**` |
286
286
 
287
- Nothing else: `fileParallelism`, `reporters`, `environment` and every `include` stay yours. Inline `projects` inherit the same defaults (vitest gives a project nothing from the root), arrays are concatenated rather than replaced, and scalars you state win. Full walkthrough, including migrating a hand-rolled config: [docs/01-getting-started.md](docs/01-getting-started.md#vitest-config-the-preset).
287
+ Nothing else: `fileParallelism`, `reporters`, `environment` and every `include` stay yours. Inline `projects` inherit the same defaults (vitest gives a project nothing from the root), arrays are concatenated rather than replaced, and scalars you state win. Full walkthrough, including migrating a hand-rolled config: [docs/02-developing.md](docs/02-developing.md#vitest-config-the-preset).
288
288
 
289
289
  Every artefact a run produces lands under `.artifacts/<tool>/` — one `.gitignore` line, one `rm -rf`. Per-run scratch (a CLI spec's temp cwd, a browser profile) stays in the OS temp dir.
290
290
 
@@ -296,7 +296,7 @@ Every artefact a run produces lands under `.artifacts/<tool>/` — one `.gitigno
296
296
  | --------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
297
297
  | `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
298
298
  | `.fixture("file")` | cli | Copy the feature-local `_fixtures/file` into the working directory |
299
- | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/name/` project into the cwd (trailing `/` = contents; layers) |
299
+ | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/<name>/` project into the cwd (trailing `/` = contents; layers) |
300
300
  | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
301
301
  | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
302
302
  | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
@@ -378,7 +378,7 @@ Location: /orders/{{uuid#order}}
378
378
  }
379
379
  ```
380
380
 
381
- See [docs/06-tokens.md](docs/06-tokens.md) for the canonical accepted form of every token.
381
+ See [docs/09-tokens.md](docs/09-tokens.md) for the canonical accepted form of every token.
382
382
 
383
383
  ## Contracts
384
384
 
@@ -413,7 +413,7 @@ export const withRatesDown = () =>
413
413
  const result = await jobs.intercept(pipeline).trigger('nightly-report');
414
414
  ```
415
415
 
416
- 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).
416
+ 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/10-contracts.md](docs/10-contracts.md).
417
417
 
418
418
  ## Docker-aware CLIs
419
419
 
@@ -442,7 +442,7 @@ test('deploy spawns a labelled container', async () => {
442
442
 
443
443
  `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.
444
444
 
445
- `sqlite()` caches its schema template inside the project — `.artifacts/vitest/sqlite/template-<key>.sqlite` — so two checkouts never share one, and workers racing for a cold cache wait for the one that is building rather than all building at once. Details: [docs/08-services.md](docs/08-services.md#where-the-template-lives).
445
+ `sqlite()` caches its schema template inside the project — `.artifacts/vitest/sqlite/template-<key>.sqlite` — so two checkouts never share one, and workers racing for a cold cache wait for the one that is building rather than all building at once. Details: [docs/11-services.md](docs/11-services.md#where-the-template-lives).
446
446
 
447
447
  ## Mocking utilities
448
448
 
@@ -457,7 +457,7 @@ import { mockOf, mockOfDate } from '@jterrazz/test';
457
457
 
458
458
  ## Conventions
459
459
 
460
- Normative rules live in the constitution ([docs/09-conventions.md](docs/09-conventions.md)); the generated per-rule catalogue is [docs/10-linting.md](docs/10-linting.md). A facet (`specs/<facet>/`) carries its runner(s) at its root and holds domain folders; the folder follows the assets:
460
+ Normative rules live in the constitution ([docs/12-conventions.md](docs/12-conventions.md)); the generated per-rule catalogue is [docs/13-linting.md](docs/13-linting.md). A facet (`specs/<facet>/`) carries its runner(s) at its root and holds domain folders; the folder follows the assets:
461
461
 
462
462
  ```
463
463
  specs/<facet>/ # api | jobs | cli | integrations | lint
@@ -477,7 +477,7 @@ Every test contains `// Given -` and `// Then -` comments (always both; `// When
477
477
 
478
478
  ### Convention enforcement — the shipped lint plugin
479
479
 
480
- 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 four-channel catalogue (each rule, its channel and rationale) is generated into [docs/10-linting.md](docs/10-linting.md).
480
+ 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 four-channel catalogue (each rule, its channel and rationale) is generated into [docs/13-linting.md](docs/13-linting.md).
481
481
 
482
482
  ## Requirements
483
483
 
package/dist/ambiguity.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/core/specification/website/ambiguity.ts
1
+ //#region src/specification/facets/website/ambiguity.ts
2
2
  /**
3
3
  * The ambiguity refusal — CONVENTIONS W3.
4
4
  *
@@ -119,7 +119,7 @@ function describeAmbiguity(options) {
119
119
  "Disambiguate with one of:",
120
120
  ...fixes,
121
121
  "",
122
- "Docs: docs/11-website.md#designating-exactly-one-element (CONVENTIONS W3)"
122
+ "Docs: docs/14-website.md#designating-exactly-one-element (CONVENTIONS W3)"
123
123
  ].join("\n");
124
124
  }
125
125
  //#endregion
@@ -2,7 +2,7 @@ import { r as formatElement, t as AmbiguousElementError } from "./ambiguity.js";
2
2
  import { mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- //#region src/core/specification/mobile/ambiguity.ts
5
+ //#region src/specification/facets/mobile/ambiguity.ts
6
6
  /**
7
7
  * The ambiguity refusal, mobile edition — CONVENTIONS W3.
8
8
  *
@@ -71,13 +71,13 @@ function describeMobileAmbiguity(options) {
71
71
  "Disambiguate with one of:",
72
72
  ...fixes,
73
73
  "",
74
- "Docs: docs/12-mobile.md#designating-exactly-one-element (CONVENTIONS W3)"
74
+ "Docs: docs/15-mobile.md#designating-exactly-one-element (CONVENTIONS W3)"
75
75
  ].join("\n");
76
76
  }
77
77
  //#endregion
78
- //#region src/core/specification/mobile/projection.ts
79
- const TAG_PATTERN = /<(?<openTag>[A-Za-z][\w.]*)(?<rawAttributes>(?:\s+[\w:.-]+="[^"]*")*)\s*(?<selfClosing>\/?)>|<\/(?<closeTag>[\w.]*)\s*>/g;
80
- const ATTRIBUTE_PATTERN = /(?<key>[\w:.-]+)="(?<value>[^"]*)"/g;
78
+ //#region src/specification/facets/mobile/projection.ts
79
+ const TAG_PATTERN = /<(?<openTag>[A-Za-z][\w.]*)(?<rawAttributes>(?:\s+[\w:.-]+="[^"]*")*)\s*(?<selfClosing>\/?)>|<\/(?<closeTag>[\w.]*)\s*>/gu;
80
+ const ATTRIBUTE_PATTERN = /(?<key>[\w:.-]+)="(?<value>[^"]*)"/gu;
81
81
  const NAMED_ENTITIES = {
82
82
  "&amp;": "&",
83
83
  "&apos;": "'",
@@ -87,7 +87,7 @@ const NAMED_ENTITIES = {
87
87
  };
88
88
  /** Decode the XML entities an attribute value can carry. */
89
89
  function decodeEntities(value) {
90
- return value.replace(/&(?:#x(?<hex>[\dA-Fa-f]+)|#(?<decimal>\d+)|(?<named>amp|apos|gt|lt|quot));/g, (entity, hex, decimal) => {
90
+ return value.replaceAll(/&(?:#x(?<hex>[\dA-Fa-f]+)|#(?<decimal>\d+)|(?<named>amp|apos|gt|lt|quot));/gu, (entity, hex, decimal) => {
91
91
  if (hex) return String.fromCodePoint(Number.parseInt(hex, 16));
92
92
  if (decimal) return String.fromCodePoint(Number(decimal));
93
93
  return NAMED_ENTITIES[entity] ?? entity;
@@ -113,8 +113,9 @@ function parsePageSource(xml) {
113
113
  children: [],
114
114
  tag: openTag
115
115
  };
116
- if (stack.length === 0) root = node;
117
- else stack[stack.length - 1].children.push(node);
116
+ const parent = stack.at(-1);
117
+ if (parent === void 0) root = node;
118
+ else parent.children.push(node);
118
119
  if (!selfClosing) stack.push(node);
119
120
  }
120
121
  if (!root) throw new Error("projectScreen(): the page source contains no XML element");
@@ -122,11 +123,11 @@ function parsePageSource(xml) {
122
123
  }
123
124
  /** The substance of a node — what survives projection, or nothing. */
124
125
  function substance(node) {
125
- const label = node.attributes["label"] || void 0;
126
- const value = node.attributes["value"] || void 0;
127
- const identifier = node.attributes["name"] || void 0;
126
+ const label = node.attributes.label || void 0;
127
+ const value = node.attributes.value || void 0;
128
+ const identifier = node.attributes.name || void 0;
128
129
  return {
129
- type: node.tag.replace(/^XCUIElementType/, ""),
130
+ type: node.tag.replace(/^XCUIElementType/u, ""),
130
131
  ...label === void 0 ? {} : { label },
131
132
  ...value === void 0 || value === label ? {} : { value },
132
133
  ...identifier === void 0 || identifier === label ? {} : { identifier }
@@ -151,9 +152,9 @@ function projectNode(node) {
151
152
  }
152
153
  /** Collect the visible texts in document order, collapsing consecutive repeats. */
153
154
  function collectTexts(node, texts) {
154
- if (node.attributes["visible"] === "true") {
155
- const text = node.attributes["label"] || node.attributes["value"] || void 0;
156
- if (text !== void 0 && texts[texts.length - 1] !== text) texts.push(text);
155
+ if (node.attributes.visible === "true") {
156
+ const text = node.attributes.label || node.attributes.value || void 0;
157
+ if (text !== void 0 && texts.at(-1) !== text) texts.push(text);
157
158
  }
158
159
  for (const child of node.children) collectTexts(child, texts);
159
160
  }
@@ -164,7 +165,8 @@ function collectTexts(node, texts) {
164
165
  */
165
166
  function projectScreen(xml) {
166
167
  let root = parsePageSource(xml);
167
- if (root.tag === "AppiumAUT" && root.children.length === 1) root = root.children[0];
168
+ const [onlyChild] = root.children;
169
+ if (root.tag === "AppiumAUT" && root.children.length === 1 && onlyChild !== void 0) root = onlyChild;
168
170
  const texts = [];
169
171
  for (const child of root.children) collectTexts(child, texts);
170
172
  const own = substance(root);
@@ -185,7 +187,9 @@ const POLL_INTERVAL_MS = 500;
185
187
  const MAX_REPORTED_MATCHES = 10;
186
188
  /** WebDriverAgent builds once per simulator — the first session is the slow one. */
187
189
  const DEFAULT_WDA_LAUNCH_TIMEOUT_MS = 24e4;
188
- const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
190
+ const delay = async (ms) => {
191
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
192
+ };
189
193
  /** Escape a name for an NSPredicate single-quoted string literal. */
190
194
  function escapePredicate(value) {
191
195
  return value.replaceAll("\\", String.raw`\\`).replaceAll("'", String.raw`\'`);
@@ -234,9 +238,9 @@ async function captureMatch(element) {
234
238
  element.getAttribute("value"),
235
239
  element.getAttribute("name")
236
240
  ]);
237
- const shortLabel = label ? label.replaceAll(/\s+/g, " ").trim().slice(0, 80) : void 0;
241
+ const shortLabel = label ? label.replaceAll(/\s+/gu, " ").trim().slice(0, 80) : void 0;
238
242
  return {
239
- type: (type ?? "Unknown").replace(/^XCUIElementType/, ""),
243
+ type: (type ?? "Unknown").replace(/^XCUIElementType/u, ""),
240
244
  ...shortLabel === void 0 ? {} : { label: shortLabel },
241
245
  ...value && value !== label ? { value } : {},
242
246
  ...identifier && identifier !== label ? { identifier } : {}
@@ -353,10 +357,10 @@ var AppiumAdapter = class {
353
357
  const count = await matches.length;
354
358
  if (count === 0) {
355
359
  if (options?.tryScroll) {
356
- const offscreen = await scope.$$(`-ios predicate string:${compilePredicate(level, { anyVisibility: true })}`);
357
- if (await offscreen.length > 0) try {
360
+ const first = (await scope.$$(`-ios predicate string:${compilePredicate(level, { anyVisibility: true })}`))[0];
361
+ if (first !== void 0) try {
358
362
  await driver.executeScript("mobile: scroll", [{
359
- elementId: offscreen[0].elementId,
363
+ elementId: first.elementId,
360
364
  toVisible: true
361
365
  }]);
362
366
  } catch {}
@@ -368,7 +372,9 @@ var AppiumAdapter = class {
368
372
  element: level,
369
373
  matches: await this.captureMatches(matches)
370
374
  }));
371
- scope = matches[0];
375
+ const next = matches[0];
376
+ if (next === void 0) return null;
377
+ scope = next;
372
378
  }
373
379
  return scope;
374
380
  }
@@ -376,7 +382,10 @@ var AppiumAdapter = class {
376
382
  async captureMatches(matches) {
377
383
  const count = Math.min(await matches.length, MAX_REPORTED_MATCHES);
378
384
  const evidence = [];
379
- for (let index = 0; index < count; index++) evidence.push(await captureMatch(matches[index]));
385
+ for (let index = 0; index < count; index++) {
386
+ const match = matches[index];
387
+ if (match !== void 0) evidence.push(await captureMatch(match));
388
+ }
380
389
  return evidence;
381
390
  }
382
391
  /** Screenshot the failing state into a temp file; never masks the original error. */
@@ -394,7 +403,7 @@ var AppiumAdapter = class {
394
403
  let excerpt = "";
395
404
  try {
396
405
  const source = await driver.getPageSource();
397
- const labels = [...new Set([...source.matchAll(/label="(?<label>[^"]{2,60})"/g)].map((match) => match.groups?.["label"] ?? ""))].slice(0, 15);
406
+ const labels = [...new Set([...source.matchAll(/label="(?<label>[^"]{2,60})"/gu)].map((match) => match.groups?.label ?? ""))].slice(0, 15);
398
407
  if (labels.length > 0) excerpt = `\nCurrently on screen: ${labels.join(" | ")}`;
399
408
  } catch {}
400
409
  return /* @__PURE__ */ new Error(`${verb}(${formatElement(element)}) timed out after ${this.actionTimeoutMs}ms — no visible match.${excerpt}`);