@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 +11 -11
- package/dist/ambiguity.js +2 -2
- package/dist/appium.adapter.js +34 -25
- package/dist/artifacts.js +503 -525
- package/dist/checker.js +74 -73
- package/dist/index.d.ts +587 -660
- package/dist/index.js +191 -188
- package/dist/match.js +6 -16
- package/dist/oxlint.cjs +681 -873
- package/dist/oxlint.d.cts +7066 -17
- package/dist/oxlint.d.ts +7066 -17
- package/dist/oxlint.js +681 -873
- package/dist/playwright.adapter.js +36 -14
- package/dist/queue.js +27 -28
- package/dist/spec-document.js +19 -25
- package/dist/vitest.d.ts +6 -6
- package/dist/vitest.js +2 -2
- package/package.json +10 -6
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
122
|
+
"Docs: docs/14-website.md#designating-exactly-one-element (CONVENTIONS W3)"
|
|
123
123
|
].join("\n");
|
|
124
124
|
}
|
|
125
125
|
//#endregion
|
package/dist/appium.adapter.js
CHANGED
|
@@ -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/
|
|
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/
|
|
74
|
+
"Docs: docs/15-mobile.md#designating-exactly-one-element (CONVENTIONS W3)"
|
|
75
75
|
].join("\n");
|
|
76
76
|
}
|
|
77
77
|
//#endregion
|
|
78
|
-
//#region src/
|
|
79
|
-
const TAG_PATTERN = /<(?<openTag>[A-Za-z][\w.]*)(?<rawAttributes>(?:\s+[\w:.-]+="[^"]*")*)\s*(?<selfClosing>\/?)>|<\/(?<closeTag>[\w.]*)\s*>/
|
|
80
|
-
const ATTRIBUTE_PATTERN = /(?<key>[\w:.-]+)="(?<value>[^"]*)"/
|
|
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
|
"&": "&",
|
|
83
83
|
"'": "'",
|
|
@@ -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.
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
126
|
-
const value = node.attributes
|
|
127
|
-
const identifier = node.attributes
|
|
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
|
|
155
|
-
const text = node.attributes
|
|
156
|
-
if (text !== void 0 && texts
|
|
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
|
-
|
|
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) =>
|
|
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+/
|
|
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
|
|
357
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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++)
|
|
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})"/
|
|
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}`);
|