@jterrazz/test 14.2.0 → 15.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 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
 
@@ -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
@@ -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
@@ -71,7 +71,7 @@ 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
package/dist/artifacts.js CHANGED
@@ -1671,11 +1671,19 @@ function updates(outcomes, scope) {
1671
1671
  }));
1672
1672
  }
1673
1673
  /**
1674
- * Start every server the document names, in declaration order, before the first
1675
- * run. They all stay live for the whole file and are stopped together — one
1676
- * document is one scenario, and its servers are part of its ground.
1674
+ * Start every server the document names, in declaration order, after `fixture:`
1675
+ * has been copied and before the first run. They all stay live for the whole
1676
+ * file and are stopped together — one document is one scenario, and its servers
1677
+ * are part of its ground.
1678
+ *
1679
+ * Every child is seeded with `TEST_WORKDIR`, the resolved working directory the
1680
+ * `{{workdir}}` token holds and `$WORKDIR` expands to. The fixtures are already
1681
+ * on disk when the child starts, so a served stub reads the world the document
1682
+ * laid down exactly as a stub binary reads its own fixture tree: a scenario is
1683
+ * a layer of files, never a flag threaded through the server. A document's
1684
+ * mapping-form env wins over it, key by key.
1677
1685
  */
1678
- async function startServers(document, config, displayPath) {
1686
+ async function startServers(document, config, displayPath, workdir) {
1679
1687
  const registry = config.serveRegistry ?? {};
1680
1688
  const started = [];
1681
1689
  const env = {};
@@ -1692,7 +1700,10 @@ async function startServers(document, config, displayPath) {
1692
1700
  const adapter = new ServeAdapter({
1693
1701
  command: registration.command,
1694
1702
  ready: registration.ready
1695
- }, config.root ?? process.cwd(), "cli", entry.env);
1703
+ }, config.root ?? process.cwd(), "cli", {
1704
+ TEST_WORKDIR: workdir,
1705
+ ...entry.env
1706
+ });
1696
1707
  started.push(adapter);
1697
1708
  try {
1698
1709
  await adapter.start();
@@ -1749,8 +1760,9 @@ async function runSpecDocument(options) {
1749
1760
  const { dest, src } = copyPlan(fixture.path, fileDir, workDir);
1750
1761
  cpSync(src, dest, { recursive: true });
1751
1762
  }
1752
- const servers = await startServers(document, config, displayPath);
1753
- const scope = new CaptureScope(safeRealpath(workDir));
1763
+ const workdir = safeRealpath(workDir);
1764
+ const servers = await startServers(document, config, displayPath, workdir);
1765
+ const scope = new CaptureScope(workdir);
1754
1766
  const env = expandWorkdir({
1755
1767
  ...options.baseEnv,
1756
1768
  ...servers.env,
package/dist/checker.js CHANGED
@@ -415,7 +415,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
415
415
  violations.push({
416
416
  file: rel,
417
417
  line,
418
- message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/10-linting.md)`,
418
+ message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/13-linting.md)`,
419
419
  severity: "error"
420
420
  });
421
421
  }
@@ -471,13 +471,13 @@ function checkDatabaseProperty(rootDir) {
471
471
  if (count >= 2 && !hasDatabase) violations.push({
472
472
  file: rel,
473
473
  line,
474
- message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/10-linting.md)`,
474
+ message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/13-linting.md)`,
475
475
  severity: "error"
476
476
  });
477
477
  else if (count === 1 && hasDatabase) violations.push({
478
478
  file: rel,
479
479
  line,
480
- message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/10-linting.md)`,
480
+ message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/13-linting.md)`,
481
481
  severity: "error"
482
482
  });
483
483
  }
@@ -525,7 +525,7 @@ function checkDeadFixtures(rootDir) {
525
525
  violations.push({
526
526
  file: rel,
527
527
  line: 1,
528
- message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts or *.spec.yaml (C9 — see docs/10-linting.md)`,
528
+ message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts or *.spec.yaml (C9 — see docs/13-linting.md)`,
529
529
  severity: "error"
530
530
  });
531
531
  continue;
@@ -551,7 +551,7 @@ function checkDeadFixtures(rootDir) {
551
551
  violations.push({
552
552
  file: relEntry,
553
553
  line: 1,
554
- message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/10-linting.md)`,
554
+ message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/13-linting.md)`,
555
555
  severity
556
556
  });
557
557
  }
@@ -576,7 +576,7 @@ function checkPoolFixtures(rootDir) {
576
576
  violations.push({
577
577
  file: relEntry,
578
578
  line: 1,
579
- message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/10-linting.md)`,
579
+ message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/13-linting.md)`,
580
580
  severity: "error"
581
581
  });
582
582
  }
@@ -678,7 +678,7 @@ function checkPoolFixtureSharing(rootDir) {
678
678
  return {
679
679
  file: relEntry,
680
680
  line: 1,
681
- message: `${relEntry}: only ${relLeaf} reaches for this pool fixture — the pool is what SEVERAL leaves share; move it to ${relLeaf}/${GROUND_FIXTURES}/${entry} and reference it as '${entry}' (C14 — see docs/10-linting.md, or run the checker with --fix)`,
681
+ message: `${relEntry}: only ${relLeaf} reaches for this pool fixture — the pool is what SEVERAL leaves share; move it to ${relLeaf}/${GROUND_FIXTURES}/${entry} and reference it as '${entry}' (C14 — see docs/13-linting.md, or run the checker with --fix)`,
682
682
  severity: "error"
683
683
  };
684
684
  });
@@ -731,7 +731,7 @@ function checkLocalFixtureReach(rootDir) {
731
731
  violations.push({
732
732
  file: rel,
733
733
  line,
734
- message: `${rel}:${line}: fixture "${path}" reaches outside ${relative(rootDir, ground)} — a leaf's ground sits beside it; ground several leaves share goes to the $FIXTURES pool at ${relative(rootDir, pool)} (C15 — see docs/10-linting.md)`,
734
+ message: `${rel}:${line}: fixture "${path}" reaches outside ${relative(rootDir, ground)} — a leaf's ground sits beside it; ground several leaves share goes to the $FIXTURES pool at ${relative(rootDir, pool)} (C15 — see docs/13-linting.md)`,
735
735
  severity: "error"
736
736
  });
737
737
  }
@@ -1152,7 +1152,7 @@ function checkSpecConventions(text, rel, path) {
1152
1152
  ].map(({ line, message, rule, severity }) => ({
1153
1153
  file: rel,
1154
1154
  line,
1155
- message: `${rel}:${line}: ${message} (${rule} — see docs/10-linting.md)`,
1155
+ message: `${rel}:${line}: ${message} (${rule} — see docs/13-linting.md)`,
1156
1156
  severity
1157
1157
  }));
1158
1158
  }
@@ -1183,7 +1183,7 @@ function checkSpecDescriptionsUnique(rootDir) {
1183
1183
  violations.push({
1184
1184
  file: rel,
1185
1185
  line: document.descriptionLine,
1186
- message: `${rel}:${document.descriptionLine}: "${document.description}" is already the description of ${first} — two documents of one directory cannot share a title (j4-spec-description-unique — see docs/10-linting.md)`,
1186
+ message: `${rel}:${document.descriptionLine}: "${document.description}" is already the description of ${first} — two documents of one directory cannot share a title (j4-spec-description-unique — see docs/13-linting.md)`,
1187
1187
  severity: "error"
1188
1188
  });
1189
1189
  }
@@ -1345,7 +1345,7 @@ function checkSpecFile(text, rel) {
1345
1345
  return [{
1346
1346
  file: rel,
1347
1347
  line: error.line,
1348
- message: `${error.message} (D4b — see docs/10-linting.md)`,
1348
+ message: `${error.message} (D4b — see docs/13-linting.md)`,
1349
1349
  severity: "error"
1350
1350
  }];
1351
1351
  }
@@ -1401,13 +1401,13 @@ function checkConventionFiles(rootDir) {
1401
1401
  if (depth1 && !REQUEST_LINE.test(firstLine(text))) violations.push({
1402
1402
  file: rel,
1403
1403
  line: 1,
1404
- message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/10-linting.md)`,
1404
+ message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/13-linting.md)`,
1405
1405
  severity: "error"
1406
1406
  });
1407
1407
  for (const { line, token } of findKnownTokens(text)) violations.push({
1408
1408
  file: rel,
1409
1409
  line,
1410
- message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/10-linting.md)`,
1410
+ message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/13-linting.md)`,
1411
1411
  severity: "warn",
1412
1412
  token
1413
1413
  });
@@ -1418,7 +1418,7 @@ function checkConventionFiles(rootDir) {
1418
1418
  if (depth1 && entry.name.endsWith(".http") && !STATUS_LINE.test(firstLine(text))) violations.push({
1419
1419
  file: rel,
1420
1420
  line: 1,
1421
- message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/10-linting.md)`,
1421
+ message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/13-linting.md)`,
1422
1422
  severity: "error"
1423
1423
  });
1424
1424
  for (const { line, token } of findUnknownTokens(text)) violations.push({