@jterrazz/test 15.2.0 → 15.3.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
@@ -147,19 +147,22 @@ test('shows the events feed behind its deep link', async () => {
147
147
  });
148
148
  ```
149
149
 
150
- 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()`.
150
+ Actions are **terminal**: `.request()`, `.get()`, `.trigger()`, `.exec()`, `.call()`, `.fetch()`, `.visit()`, `.open()` execute the spec and resolve to a precisely typed result. There is no `.run()`, no label, and no `.spawn()`.
151
151
 
152
- ## The five constructors
152
+ ## The six constructors
153
153
 
154
154
  One constructor per tested interface, each returning a record destructured with its canonical name:
155
155
 
156
- | Constructor | Returns | Terminal actions |
157
- | --------------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
158
- | `specification.api(options)` | `{ api, cleanup, docker, orchestrator }` | `.request(file)`, `.get()`, `.post()`, `.put()`, `.delete()` |
159
- | `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
160
- | `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
161
- | `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
162
- | `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
156
+ | Constructor | Returns | Terminal actions |
157
+ | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------------ |
158
+ | `specification.api(options)` | `{ api, cleanup, docker, orchestrator }` | `.request(file)`, `.get()`, `.post()`, `.put()`, `.delete()` |
159
+ | `specification.jobs(options)` | `{ jobs, cleanup, orchestrator }` | `.trigger(name)` |
160
+ | `specification.cli(bin, options)` | `{ cli, cleanup, docker, orchestrator }` | `.exec(args, { waitFor?, timeout? }?)` |
161
+ | `specification.integration(opts?)` | `{ integration, cleanup }` | `.call((services) => …)` |
162
+ | `specification.website(options)` | `{ website, cleanup, url }` | `.fetch(path)`, `.visit(path, scenario?)` |
163
+ | `specification.mobile(options)` | `{ mobile, cleanup, udid }` | `.open(deepLink?, scenario?)` |
164
+
165
+ A rendered component has no constructor: it starts nothing, so it is reached through the `component` chain directly (docs/16).
163
166
 
164
167
  ### `specification.api({ services, server, mode?, root? })`
165
168
 
@@ -292,16 +295,17 @@ Every artefact a run produces lands under `.artifacts/<tool>/` — one `.gitigno
292
295
 
293
296
  ### Setup (chainable)
294
297
 
295
- | Method | Facets | Description |
296
- | --------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
297
- | `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
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) |
300
- | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
301
- | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
302
- | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
303
- | `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
304
- | `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
298
+ | Method | Facets | Description |
299
+ | --------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
300
+ | `.seed("file.sql", { database? })` | all | Load SQL from `_seeds/` — `database` is the record key (mandatory with ≥ 2 databases, forbidden with 1) |
301
+ | `.fixture("file")` | cli | Copy the feature-local `_fixtures/file` into the working directory |
302
+ | `.fixture("$FIXTURES/name/")` | cli | Spread the shared `specs/_fixtures/<name>/` project into the cwd (trailing `/` = contents; layers) |
303
+ | `.env({ KEY: "value" })` | cli | Set env vars on the child (`null` unsets, `$WORKDIR` expands, calls merge) |
304
+ | `.headers({ "Accept-Language": "fr" })` | api, website | Set HTTP request headers (merge on top of `.http` file headers, or on the browser context) |
305
+ | `.intercept(contracts)` | all but cli | Declare the world: a `defineContracts(...)` composite — MSW on api/jobs, the stub backend on website/mobile |
306
+ | `.intercept(contract)` / `([a, b])` | all but cli | A single contract, or an ordered list |
307
+ | `.intercept(request, response)` | all but cli | Inline pair, for one-off plumbing |
308
+ | `.clock("2026-03-04T09:30:00Z")` | api, jobs, integration, website | Pin the calendar for this chain — the page's on website, this process's elsewhere |
305
309
 
306
310
  ### Actions (terminal)
307
311
 
@@ -444,23 +448,28 @@ test('deploy spawns a labelled container', async () => {
444
448
 
445
449
  `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
450
 
447
- ## Mocking utilities
451
+ ## Also exported
448
452
 
449
- ```typescript
450
- import { mockOf, mockOfDate } from '@jterrazz/test';
451
- ```
453
+ Everything below comes from the package root (rule F1) — except the project helpers, which are `@jterrazz/test/vitest`'s. One line each; the chapter owns the detail.
452
454
 
453
- | Export | Description |
454
- | ------------- | ------------------------------------------------------------ |
455
- | `mockOf<T>()` | Deep mock of any interface (wraps `vitest-mock-extended`) |
456
- | `mockOfDate` | Freeze/reset the global Date via `.set(date)` and `.reset()` |
455
+ | Export | Is | Chapter |
456
+ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
457
+ | `clock.at(iso)` · `.run(iso?)` · `.advance(ms)` | The one time primitive — a `using` scope that pins `Date`, replacing `vi.useFakeTimers()` and `mockOfDate` | [docs/12](docs/12-conventions.md#time--one-primitive-two-depths) |
458
+ | `intercept(…)` | The same network double with no chain: `await using _ = await intercept(…)` in a module test | [docs/10](docs/10-contracts.md#intercept--the-same-double-with-no-chain) |
459
+ | `process({ command, ready?, … })` | The one shape an external process takes — a dev server, an API, a bundler, owned by the runner | [docs/11](docs/11-services.md#process--the-one-shape-an-external-process-takes) |
460
+ | `http.stream(chunks, init?)` · `http.sse(events)` | A chunked or server-sent response body a contract replies with | [docs/10](docs/10-contracts.md) |
461
+ | `mockOf<T>()` · `mockOf<T>({ deep: false })` | Deep double of a port (a member never stubbed answers with another double), or the flat one | [docs/12](docs/12-conventions.md#the-doubles-ladder) |
462
+ | `mockOfDate` | Freeze/reset the global `Date` via `.set(date)` / `.reset()` — `clock` is what replaces it | [docs/12](docs/12-conventions.md#time--one-primitive-two-depths) |
463
+ | `required(value, why)` | The value, or a failure naming what was missing and why it mattered — instead of `!` | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
464
+ | `waitUntil(predicate, { timeout?, interval?, why? })` | Wait on a CONDITION, never a duration, on a real-time budget a pinned clock cannot starve | [docs/08](docs/08-assertions.md#two-helpers-that-are-not-matchers) |
465
+ | `unit()` `component()` `api()` `jobs()` `cli()` `integration()` `website()` `mobile()` | The vitest project helpers, each taking `{ include, exclude, timeout, serial }` on top of the project it already is | [docs/02](docs/02-developing.md#vitest-config-the-preset) |
457
466
 
458
467
  ## Conventions
459
468
 
460
469
  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
470
 
462
471
  ```
463
- specs/<facet>/ # api | jobs | cli | integrations | lint
472
+ specs/<facet>/ # api | jobs | cli | integration | website | mobile
464
473
  ├── <facet>.specification.ts # runner(s) at the facet ROOT (rule C1)
465
474
  └── <domain>/ # a product command/area — 1..n test files
466
475
  ├── <aspect>.test.ts
@@ -485,7 +494,7 @@ These conventions are not just prose: the package ships an oxlint plugin (`@jter
485
494
  - **vitest** - peer dependency
486
495
  - **playwright** - optional peer dependency, only needed for `.visit()`: `npm install -D playwright && npx playwright install chromium`
487
496
  - **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
488
- - **msw** - bundled as a direct dependency (powers `.intercept()`); no separate install
497
+ - **msw** - bundled as a direct dependency (powers `.intercept()` on a chain and `intercept()` in module scope, under node and in a page); no separate install
489
498
  - **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
490
499
 
491
500
  ## Docs
package/dist/artifacts.js CHANGED
@@ -2,7 +2,7 @@ import { i as SpecSyntaxError, l as updateSpecFile, s as readSpecFile } from "./
2
2
  import { a as copyPlan, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
3
3
  import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
- import { execSync, spawn } from "node:child_process";
5
+ import { execSync, spawn, spawnSync } from "node:child_process";
6
6
  import { readdir } from "node:fs/promises";
7
7
  import { createServer } from "node:net";
8
8
  //#region src/specification/facets/_common/result/grep.ts
@@ -123,7 +123,13 @@ var JsonAccessor = class {
123
123
  this.transform = transform;
124
124
  this.captures = captures ?? new CaptureScope();
125
125
  }
126
- /** The parsed JSON value. Throws if the text is not valid JSON. */
126
+ /**
127
+ * The parsed JSON value. Throws if the text is not valid JSON.
128
+ *
129
+ * `Value` is `unknown` unless the subject that produced this accessor
130
+ * knows better — `.call<T>()` does, and hands its T straight through so a
131
+ * one-field reading needs no cast in the test.
132
+ */
127
133
  get value() {
128
134
  const stripped = stripAnsiCodes(this.rawText);
129
135
  const source = this.transform ? this.transform(stripped) : stripped;
@@ -1259,6 +1265,16 @@ function shouldUpdateSnapshots() {
1259
1265
  }
1260
1266
  /** Standard hint appended to missing-fixture errors. */
1261
1267
  const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
1268
+ /**
1269
+ * The hint a FROZEN golden gets instead. `{ frozen }` is the switch that tells
1270
+ * update mode to leave this file alone, so sending its author to `TEST_UPDATE=1`
1271
+ * would send them to a run that does nothing.
1272
+ */
1273
+ const FROZEN_HINT = "This golden is `{ frozen }`: update mode never writes it. Write the file by hand, or drop `{ frozen }` for one TEST_UPDATE=1 run and put it back.";
1274
+ /** The hint a missing golden earns, given whether it is frozen. */
1275
+ function missingHint(frozen) {
1276
+ return frozen ? FROZEN_HINT : UPDATE_HINT;
1277
+ }
1262
1278
  //#endregion
1263
1279
  //#region src/specification/facets/website/serve.adapter.ts
1264
1280
  /** Grace period between SIGTERM and the SIGKILL escalation. */
@@ -1316,14 +1332,16 @@ var ServeAdapter = class {
1316
1332
  }
1317
1333
  /** Start the server and resolve with its base URL once it is ready. */
1318
1334
  async start() {
1335
+ this.runBefore();
1319
1336
  const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
1320
1337
  const port = banner ? null : this.options.port ?? await findFreePort();
1321
1338
  const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
1322
1339
  this.child = spawn(this.options.command, [], {
1323
- cwd: this.root,
1340
+ cwd: this.workingDirectory(),
1324
1341
  detached: process.platform !== "win32",
1325
1342
  env: {
1326
1343
  ...process.env,
1344
+ ...typeof this.options.env === "function" ? {} : this.options.env,
1327
1345
  ...this.extraEnv,
1328
1346
  ...port === null ? {} : { PORT: String(port) }
1329
1347
  },
@@ -1384,6 +1402,25 @@ var ServeAdapter = class {
1384
1402
  }
1385
1403
  }
1386
1404
  }
1405
+ /** Where the child runs: the stated `cwd` under the root, or the root. */
1406
+ workingDirectory() {
1407
+ return this.options.cwd === void 0 ? this.root : resolve(this.root, this.options.cwd);
1408
+ }
1409
+ /**
1410
+ * The one-shot command, run before the process is spawned. It must exit 0:
1411
+ * a build that failed is not a spec that failed later on a missing file,
1412
+ * and the difference is what this reports.
1413
+ */
1414
+ runBefore() {
1415
+ const { before } = this.options;
1416
+ if (before === void 0) return;
1417
+ const outcome = spawnSync(before, [], {
1418
+ cwd: this.workingDirectory(),
1419
+ encoding: "utf8",
1420
+ shell: true
1421
+ });
1422
+ if (outcome.status !== 0) throw new Error(`specification.${this.facet}(): \`before\` exited ${outcome.status ?? "without a status"}.\nCommand: ${before}\nOutput:\n${(outcome.stdout ?? "") + (outcome.stderr ?? "")}`);
1423
+ }
1387
1424
  /** Terminate the server process group (idempotent). */
1388
1425
  async stop() {
1389
1426
  const { child } = this;
@@ -1702,4 +1739,4 @@ const COVERAGE_DIR = `${VITEST_ARTIFACTS_DIR}/coverage`;
1702
1739
  /** Where `sqlite()` caches the schema template it copies per worker. */
1703
1740
  const SQLITE_TEMPLATE_DIR = `${VITEST_ARTIFACTS_DIR}/sqlite`;
1704
1741
  //#endregion
1705
- export { mergePreservingPlaceholders as A, DirectoryAccessor as C, inspectContainer as D, findContainersByLabel as E, structuralSubset as F, textEquals as I, TableAccessor as L, placeholderPatternSource as M, renderExpected as N, removeContainers as O, structuralEquals as P, JsonAccessor as R, FilesystemAccessor as S, walkDirectory as T, validateDatabaseOption as _, specDescription as a, resolveCommand as b, shouldUpdateSnapshots as c, formatStartupReport as d, formatStdoutDiff as f, BaseResult as g, CliResult as h, runSpecDocument as i, mergeTextPreservingPlaceholders as j, hasPlaceholders as k, formatDirectoryDiff as l, ContainerAccessor as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, formatTableDiff as p, VITEST_ARTIFACTS_DIR as r, UPDATE_HINT as s, COVERAGE_DIR as t, formatResponseDiff as u, discoverRoot as v, diffDirectories as w, resolveRoot as x, expandWorkdir as y, TextAccessor as z };
1742
+ export { mergePreservingPlaceholders as A, DirectoryAccessor as C, inspectContainer as D, findContainersByLabel as E, structuralSubset as F, textEquals as I, TableAccessor as L, placeholderPatternSource as M, renderExpected as N, removeContainers as O, structuralEquals as P, JsonAccessor as R, FilesystemAccessor as S, walkDirectory as T, validateDatabaseOption as _, specDescription as a, resolveCommand as b, shouldUpdateSnapshots as c, formatStartupReport as d, formatStdoutDiff as f, BaseResult as g, CliResult as h, runSpecDocument as i, mergeTextPreservingPlaceholders as j, hasPlaceholders as k, formatDirectoryDiff as l, ContainerAccessor as m, SQLITE_TEMPLATE_DIR as n, ServeAdapter as o, formatTableDiff as p, VITEST_ARTIFACTS_DIR as r, missingHint as s, COVERAGE_DIR as t, formatResponseDiff as u, discoverRoot as v, diffDirectories as w, resolveRoot as x, expandWorkdir as y, TextAccessor as z };