@jterrazz/test 18.1.0 → 18.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/dist/oxlint.cjs CHANGED
@@ -1052,7 +1052,7 @@ var manifest_default = {
1052
1052
  {
1053
1053
  "audience": "consumer",
1054
1054
  "channel": "checker",
1055
- "sentence": "The first line of a depth-1 `.http` follows its grammar: a request line (`METHOD /path`) under `__requests__/`, a status line (`HTTP/1.1 <status>`) under `__expected__/`.",
1055
+ "sentence": "The first line of a depth-1 `.http` follows its grammar: a request line (`METHOD /path`, or `METHOD https://host/path` for a deployed api) under `__requests__/`, a status line (`HTTP/1.1 <status>`, or `unreachable: <reason>` for an exchange that never answered) under `__expected__/`.",
1056
1056
  "specification": "api",
1057
1057
  "family": "expectations",
1058
1058
  "fix": "Write the opening line the folder calls for, or move the file to the other one.",
@@ -1,5 +1,5 @@
1
- import { t as isRecord } from "./shared-json-shape.js";
2
1
  import { n as refusal } from "./shared-refusal.js";
2
+ import { t as isRecord } from "./shared-json-shape.js";
3
3
  import { t as TOKEN_KINDS } from "./shared-tokens.js";
4
4
  //#region src/expectations/tokens/capture-scope.ts
5
5
  /** Named captures of `match.ref()` / `{{type#ref}}`, one scope per spec result. */
@@ -1,6 +1,6 @@
1
1
  import { i as safeRealpath } from "./shared-resolve.js";
2
- import { t as isRecord } from "./shared-json-shape.js";
3
2
  import { n as refusal } from "./shared-refusal.js";
3
+ import { t as isRecord } from "./shared-json-shape.js";
4
4
  import { r as closingOf, t as argumentSpans } from "./shared-source-scan.js";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -873,7 +873,7 @@ var manifest_default = {
873
873
  {
874
874
  "audience": "consumer",
875
875
  "channel": "checker",
876
- "sentence": "The first line of a depth-1 `.http` follows its grammar: a request line (`METHOD /path`) under `__requests__/`, a status line (`HTTP/1.1 <status>`) under `__expected__/`.",
876
+ "sentence": "The first line of a depth-1 `.http` follows its grammar: a request line (`METHOD /path`, or `METHOD https://host/path` for a deployed api) under `__requests__/`, a status line (`HTTP/1.1 <status>`, or `unreachable: <reason>` for an exchange that never answered) under `__expected__/`.",
877
877
  "specification": "api",
878
878
  "family": "expectations",
879
879
  "fix": "Write the opening line the folder calls for, or move the file to the other one.",
@@ -1,5 +1,5 @@
1
- import { t as isRecord } from "./shared-json-shape.js";
2
1
  import { n as refusal } from "./shared-refusal.js";
2
+ import { t as isRecord } from "./shared-json-shape.js";
3
3
  import { a as readLedgers, i as measure, n as clearLedgers, r as markPartial, s as shardLedgersDir, t as ROUTES_DIR, u as shouldUpdateSnapshots } from "./shared-ledger.js";
4
4
  import { u as parseYamlSource } from "./shared-yaml-document.js";
5
5
  import { r as testSettingsAt } from "./shared-test-section.js";
@@ -1,6 +1,6 @@
1
1
  import { t as ServedProcess } from "./shared-serve.js";
2
- import { t as isRecord } from "./shared-json-shape.js";
3
2
  import { n as refusal } from "./shared-refusal.js";
3
+ import { t as isRecord } from "./shared-json-shape.js";
4
4
  import { d as VITEST_ARTIFACTS_DIR } from "./shared-artifacts.js";
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
@@ -97,6 +97,7 @@ async function loadMsw() {
97
97
  };
98
98
  }
99
99
  let serverInstance = null;
100
+ let registrations = 0;
100
101
  let refuseOffTheWire = null;
101
102
  function offTheWireError(request) {
102
103
  return new Error(refusal("network-strict-after-first-stub", {
@@ -128,14 +129,26 @@ async function registerStubs(stubs) {
128
129
  const { handlers, refuse, violation } = buildStubHandlers(msw, stubs);
129
130
  server.use(...handlers);
130
131
  refuseOffTheWire = (request) => refuse(offTheWireError(request), `unreadable request ${request.method} ${request.url}`);
132
+ registrations += 1;
133
+ let released = false;
131
134
  return {
132
135
  cleanup: () => {
136
+ if (released) return;
137
+ released = true;
133
138
  refuseOffTheWire = null;
134
139
  server.resetHandlers();
140
+ registrations -= 1;
141
+ if (registrations === 0) closeServer();
135
142
  },
136
143
  violation
137
144
  };
138
145
  }
146
+ function closeServer() {
147
+ if (serverInstance !== null) {
148
+ serverInstance.close();
149
+ serverInstance = null;
150
+ }
151
+ }
139
152
  //#endregion
140
153
  export { registerStubs };
141
154
 
@@ -1,5 +1,5 @@
1
- import { t as isRecord } from "./shared-json-shape.js";
2
1
  import { n as refusal, t as invariant } from "./shared-refusal.js";
2
+ import { t as isRecord } from "./shared-json-shape.js";
3
3
  import { t as listenedPort } from "./shared-listened-port.js";
4
4
  import { t as loadPeer } from "./shared-peer.js";
5
5
  import { execSync } from "node:child_process";
package/dist/stubs.js CHANGED
@@ -1,5 +1,5 @@
1
- import { t as isRecord } from "./shared-json-shape.js";
2
1
  import { n as refusal } from "./shared-refusal.js";
2
+ import { t as isRecord } from "./shared-json-shape.js";
3
3
  import { a as defineStubs, d as structuralEquals, f as structuralSubset, i as defineStub, o as describeRoute, r as declared, t as STREAM_BODY, y as CaptureScope } from "./shared-exchange.js";
4
4
  //#region src/stubs/kinds/http-filter.ts
5
5
  function rawOf(body) {
package/dist/vitest.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { r as writeInPlace } from "./shared-in-place.js";
2
- import { t as isRecord } from "./shared-json-shape.js";
3
2
  import { n as refusal$1, t as invariant } from "./shared-refusal.js";
3
+ import { t as isRecord } from "./shared-json-shape.js";
4
4
  import { i as renderText, n as literate, o as resolveSpecPath } from "./shared-literate-plugin.js";
5
5
  import { u as shouldUpdateSnapshots } from "./shared-ledger.js";
6
6
  import { c as REPORTER_OUTPUT_FILES, d as VITEST_ARTIFACTS_DIR, i as COVERAGE_DIR, l as SCREENSHOTS_DIR, n as ATTACHMENTS_DIR, o as MODULE_CACHE_DIR, r as BLOB_DIR, s as NODE_COMPILE_CACHE_DIR, t as ARTIFACTS_DIR } from "./shared-artifacts.js";
package/docs/README.md CHANGED
@@ -22,7 +22,7 @@ The pages follow the package's four export families (what is tested, what surrou
22
22
  | [Component specs](guides/component-specs.md) | A rendered unit in a real Chromium, in its folder's `__tests__/`: the `component` chain |
23
23
  | [Website specs](guides/website-specs.md) | A served page, fetched or driven in a browser: `specification.website()` |
24
24
  | [Mobile specs](guides/mobile-specs.md) | A native app on an iOS simulator: `specification.mobile()` |
25
- | [API specs](guides/api-specs.md) | An HTTP app, in-process, through complete requests: `specification.api()` |
25
+ | [API specs](guides/api-specs.md) | An HTTP app, in-process or deployed at a `url`, through requests: `specification.api()` |
26
26
  | [Job specs](guides/job-specs.md) | A pipeline triggered by name: `specification.job()` |
27
27
  | [CLI specs](guides/cli-specs.md) | A binary in a fresh working directory: `specification.cli()` |
28
28
  | [Spec documents](guides/spec-documents.md) | A CLI session written as data: the `<case>.spec.yaml` format |
@@ -10,17 +10,17 @@ Every specification (the code behind one kind of test) is the same four steps. A
10
10
 
11
11
  Seven constructors exist and the list is closed (`src/specifications/specification.ts`):
12
12
 
13
- | Constructor | Handle destructures to | Subject under test |
14
- | ----------------------------- | --------------------------- | ---------------------------------------------- |
15
- | `specification.api()` | `{ api, cleanup, docker }` | An HTTP API, built and served in this process |
16
- | `specification.job()` | `{ job, cleanup }` | A background pipeline, triggered by name |
17
- | `specification.cli()` | `{ cli, cleanup, docker }` | A command binary in a fresh temp directory |
18
- | `specification.integration()` | `{ integration, cleanup }` | A module against real services, or a golden |
19
- | `specification.website()` | `{ website, cleanup, url }` | A rendered page, fetched or driven in chromium |
20
- | `specification.mobile()` | `{ mobile, cleanup, udid }` | A native screen on an iOS simulator |
21
- | `specification.lint()` | `{ lint, cleanup }` | The rules a project ships, over a fixture tree |
22
-
23
- The asymmetry in that column is the model, not an oversight: `docker` reads a container the subject itself started, and neither a pipeline triggered by name nor a module called in-process is specified by the containers it starts, so `job` and `integration` are handed none; `website` and `mobile` drive a browser and a simulator rather than an orchestrated stack, so they carry neither; and `lint` starts nothing at all, its `cleanup` releasing nothing.
13
+ | Constructor | Handle destructures to | Subject under test |
14
+ | ----------------------------- | --------------------------- | -------------------------------------------------- |
15
+ | `specification.api()` | `{ api, cleanup, docker }` | An HTTP API, served in this process, or at a `url` |
16
+ | `specification.job()` | `{ job, cleanup }` | A background pipeline, triggered by name |
17
+ | `specification.cli()` | `{ cli, cleanup, docker }` | A command binary in a fresh temp directory |
18
+ | `specification.integration()` | `{ integration, cleanup }` | A module against real services, or a golden |
19
+ | `specification.website()` | `{ website, cleanup, url }` | A rendered page, fetched or driven in chromium |
20
+ | `specification.mobile()` | `{ mobile, cleanup, udid }` | A native screen on an iOS simulator |
21
+ | `specification.lint()` | `{ lint, cleanup }` | The rules a project ships, over a fixture tree |
22
+
23
+ The asymmetry in that column is the model, not an oversight: `docker` reads a container the subject itself started, and neither a pipeline triggered by name nor a module called in-process is specified by the containers it starts, so `job` and `integration` are handed none; `website` and `mobile` drive a browser and a simulator rather than an orchestrated stack, so they carry neither; and `lint` starts nothing at all, its `cleanup` releasing nothing. An `api` met at a `url` is the same subject on another target ([ADR-022](../decisions/022-a-deployed-api-is-an-api-spec-with-a-url.md)): it starts nothing either, so its handle is `{ api, cleanup }`, and its chain is a second composition of the api specification, `apiDeployedDefinition` in `specifications/api/deployed/deployed.composition.ts`, which offers only what a request over the network can do.
24
24
 
25
25
  **One specification has no constructor**, and the same reason explains it: a rendered component starts nothing. `component` ([Component specs](../guides/component-specs.md)) is a chain the package exports directly (`component.intercept(...).render(<X />)`), so there is no handle to destructure and no `cleanup` to pass to `afterAll`. What every render of a project shares (the providers, the Vite pipeline, the viewport) is the PROJECT's, stated once by `component()` in `vitest.config.ts`.
26
26
 
@@ -48,7 +48,7 @@ The root reads the sentence of a test. A test opens a **specification** on its s
48
48
  | `disk/` | `chain/`, `lint/kit/`, itself | How a test names the disk and what a chain lays on it before its action: `file()` and `directory()` (`spec-path.ts`) and their resolution from the spec file (`resolved-spec-path.ts`), `.fixture()` and `.link()`, their ports and the file-system adapters behind them |
49
49
  | `clock/` | `chain/`, `lint/kit/`, itself, `vitest` | The pinned clock and `.clock()`, the instant grammar, the process clock adapter |
50
50
  | `mocks/` | `chain/`, `lint/kit/`, itself, `vitest`, `vitest-mock-extended` | `mockOf()`, the one double of a port |
51
- | `specifications/` | `chain/`, every notion above, `lint/kit/`, each specification its own library; a specification's project reaches the runner config (`vitest/project`) | What is tested, one slice per constructor: `<specification>.face.ts` (the names it publishes), `<specification>.composition.ts` (the composition), `<specification>.handle.ts` (the constructor), `<specification>.project.ts` (its Vitest project), and its concept folders (`request/`, `server/`, `routes/`, `documents/`, `render/`, `device/` ...); the lint specification holds `rule-tester.ts` beside them, `ruleTester()`, the other half of the kit to test a rule; at its root, `specification.ts`, the seven as one record |
51
+ | `specifications/` | `chain/`, every notion above, `lint/kit/`, each specification its own library; a specification's project reaches the runner config (`vitest/project`) | What is tested, one slice per constructor: `<specification>.face.ts` (the names it publishes), `<specification>.composition.ts` (the composition), `<specification>.handle.ts` (the constructor), `<specification>.project.ts` (its Vitest project), and its concept folders (`request/`, `server/`, `routes/`, `deployed/`, `documents/`, `render/`, `device/` ...); the lint specification holds `rule-tester.ts` beside them, `ruleTester()`, the other half of the kit to test a rule; at its root, `specification.ts`, the seven as one record |
52
52
  | `vitest/` | itself, `chain/`, `lint/kit/`, update mode's setup, `vitest`, never a specification | What every Vitest project receives, knowing no specification: `preset.ts` (the root config), `project.ts` (the defaults every project is built on, `specificationProject`) and `unit.project.ts` (`unit()`), which wire the global setups their owners provide and resolve (`expectations/in-place/`, `chain/paths/temp-root`); a specification's own setups (`services/containers/`, `specifications/mobile/session/`) ride on its project |
53
53
  | `standard/` | `@jterrazz/typescript/config`, nothing of the tree | The defaults of the `test` section, data only: `standard.ts`, which the `test()` preset hands on as written ([`@jterrazz/typescript`'s Configuration § The standard](https://github.com/jterrazz/package-typescript/blob/main/docs/reference/configuration.md#the-standard)) |
54
54
  | `lint/` | itself and `src/standard/`, never the rest of `src/`: `self/` alone reads the product it holds to account | At the root, not under `src/`: the tool-facing channel, laid out below. The layer map names its files `lint/**`, and cruises it beside `src/` |
@@ -1,6 +1,6 @@
1
1
  # API specs (`specification.api`)
2
2
 
3
- `specification.api()` tests an HTTP API through real requests. Your app runs **in this process**, built by the `server` factory from the real services the runner started, so a request reaches it without a socket, and a stub, a clock and a golden all mean something because the app shares this process's world.
3
+ `specification.api()` tests an HTTP API through real requests. Your app runs **in this process**, built by the `server` factory from the real services the runner started, so a request reaches it without a socket, and a stub, a clock and a golden all mean something because the app shares this process's world. Or it is **already deployed**, named by its `url`, and each request crosses the network to it ([Deployed target (`url`)](#deployed-target-url)): the subject is the same HTTP API, and where it runs is an option, as a website is started by `server` or met at a `url`.
4
4
 
5
5
  Use it when the subject under test is an HTTP surface. For background pipelines use [job](job-specs.md); for binaries use [cli](cli-specs.md).
6
6
 
@@ -17,7 +17,7 @@ An api spec answers one question: **given this state and this request, what does
17
17
  | What it proves | Application logic against real databases, caches and processes |
18
18
  | Parallel isolation | Per-worker schema / db-index / file copy (rule `state-isolated-per-worker`) |
19
19
 
20
- Because the app shares this process, three things the framework offers are real here and nowhere else: `.intercept()` (msw runs in-process), `.clock()` (it pins the `Date` the app reads), and a golden of the response the app actually built. What the SHIPPED artefact does (its Dockerfile, its wiring, its networking) is a deployment probe, and this specification does not claim it.
20
+ Because the app shares this process, three things the framework offers are real here and nowhere else: `.intercept()` (msw runs in-process), `.clock()` (it pins the `Date` the app reads), and a golden of the response the app actually built. What the SHIPPED artefact does (its Dockerfile, its wiring, its networking, its certificate) is met at its `url` instead ([Deployed target (`url`)](#deployed-target-url)), where no stub, no clock and no seed reaches it.
21
21
 
22
22
  A `postgres()` service runs `docker/<service>/init.sql` when it starts, under the kebab-case of its record key; `sqlite()` takes its schema from its own `init` or `prismaSchema` option instead ([Services](../reference/services.md#dockerserviceinitsql)).
23
23
 
@@ -64,9 +64,19 @@ afterAll(async () => {
64
64
  | Option | Required | Description |
65
65
  | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
66
66
  | `services` | yes (if the app needs infra) | Named record of service factories (`postgres()`, `redis()`, `sqlite()`). Keys are your test vocabulary; see [services](../reference/services.md) |
67
- | `server` | yes | `(services) => app`: receives the started services record, fully typed (rule `server-sees-declared-services`) |
67
+ | `server` | yes, or `url` | `(services) => app`: receives the started services record, fully typed (rule `server-sees-declared-services`) |
68
+ | `url` | yes, or `server` | The deployed api's base URL: its own target, exclusive of `server`, `services` and `root` ([Deployed target (`url`)](#deployed-target-url)) |
68
69
  | `root` | no | Override for root resolution, reserved for cases where the convention is not enough (rule `no-redundant-root`) |
69
70
 
71
+ Exactly one of `server` and `url` names the app, as with a website: both, neither, an empty `url` or one that is no `http(s)` URL is refused before anything starts, and so are `services` or `root` beside a `url` (a deployed api is wired to its own services) and an option of a deployed api beside a `server`. Against a `url`, four more options shape each request:
72
+
73
+ | Option | Default | Description |
74
+ | ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
75
+ | `timeout` | `5000` | The milliseconds one request may take, connection to last byte, before it reads `unreachable: timeout`. A positive number |
76
+ | `insecure` | `false` | Accept a certificate the trust check refuses (self-signed, expired, another host's); `result.tls` still says why |
77
+ | `ca` | none | A certificate authority trusted beside the ones node trusts: its PEM text, a value like any string |
78
+ | `recordedHeaders` | `['content-type', 'location']` | The headers update mode records into a golden that states none yet, those the response carries ([Deployed target (`url`)](#deployed-target-url)) |
79
+
70
80
  ### Root resolution
71
81
 
72
82
  Without `root`, the framework finds the package being tested by walking up from the specification file. The walk, the tree it reads and when a `root` is redundant are [Services § Root auto-discovery](../reference/services.md#root-auto-discovery)'s.
@@ -361,6 +371,165 @@ The list is judged whenever the app exposes a route table of its own, an OpenAPI
361
371
 
362
372
  The app runs in THIS process, always: there is no second mode, no container binding and no `TEST_MODE`. Why compose mode left, and what was weighed against keeping it: [ADR-007](https://github.com/jterrazz/package-test/blob/main/docs/decisions/007-compose-mode-leaves-the-package.md).
363
373
 
374
+ ## Deployed target (`url`)
375
+
376
+ An api already deployed (a preview your CI just shipped, every host of a platform, a node's address or a closed port) is the same subject met elsewhere: `url` names it, and each request goes over the network, with no browser. The answer is compared as an in-process one is (the same `result.response`, the same `.http` golden, header subset, `{{tokens}}` and `TEST_UPDATE=1`), and two things only a network has join it: an exchange that never answered is a result, and the certificate it came over is read off `result.tls`. The decision is [ADR-022](https://github.com/jterrazz/package-test/blob/main/docs/decisions/022-a-deployed-api-is-an-api-spec-with-a-url.md).
377
+
378
+ ```typescript
379
+ // specs/api/deployed.specification.ts
380
+ import { specification } from '@jterrazz/test';
381
+ import { afterAll } from 'vitest';
382
+
383
+ export const { api, cleanup } = await specification.api({
384
+ url: process.env.DEPLOYED_URL ?? 'https://preview.site.example',
385
+ });
386
+
387
+ afterAll(cleanup);
388
+ ```
389
+
390
+ The handle destructures to `{ api, cleanup }`, no `docker`: nothing is started, so `cleanup` releases nothing, and it is passed to `afterAll` like every specification's. The transport is node's own `http`, `https`, `tls` and `net`: nothing to install.
391
+
392
+ ### What a deployed chain offers
393
+
394
+ | Offered | Not offered, refused by the type and at run time |
395
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
396
+ | `.get(target)`, `.request(<.http text> \| file(...))` | `.post()`, `.put()`, `.delete()`: a request other than a GET is written whole with `.request()` |
397
+ | `.headers()`, `api.with({ headers })`, `.connect(address)` | `.seed()`, `.intercept()`, `.clock()`: a deployed api's database, network and clock are its own |
398
+ | `result.response`, `result.tls` | `result.table()`, `result.file()`, `result.directory()`: what it left behind is its own |
399
+
400
+ ### The target
401
+
402
+ Every request names one URL: written whole (`https://grafana.site.example/`), or a path (`/health`) read from `url`, its own path kept (`https://site.example/app` and `/health` are `https://site.example/app/health`). One specification therefore reaches every host of a platform. A target that is neither, and a URL of another scheme, are refused. A request written as `.http` text or in a `__requests__/` file names its URL on the first line, whole (`GET https://site.example/health`, HTTP's absolute form) or as a path; its body is sent as written. A redirect is the answer, never followed:
403
+
404
+ ```typescript
405
+ test('a moved page answers its redirect, never followed', async () => {
406
+ // Given - the address of the page before the move
407
+ const result = await api.get('/blog');
408
+
409
+ // Then - the 301 itself, and where it points
410
+ expect(result.response).toMatch(`
411
+ HTTP/1.1 301 Moved Permanently
412
+ location: https://site.example/articles
413
+ `);
414
+ });
415
+ ```
416
+
417
+ ### Setup: `.connect()`
418
+
419
+ `.connect(address)` opens the socket on another address than the URL's, as curl's `--connect-to`: the `Host` header and the TLS server name stay the URL's, so the certificate is checked against the host the URL names. It is how a node's IP, a tailnet address or a NodePort is probed under the name it serves:
420
+
421
+ ```typescript
422
+ test("the node's NodePort serves the public host itself", async () => {
423
+ // Given - the public URL, its socket opened on the node
424
+ const result = await api.connect('100.64.0.7:30443').get('https://site.example/health');
425
+
426
+ // Then - the same answer the edge gives
427
+ expect(result.response).toMatch(`
428
+ HTTP/1.1 200 OK
429
+ content-type: application/json
430
+
431
+ {"status": "ok"}
432
+ `);
433
+ });
434
+ ```
435
+
436
+ The address is `host:port`, `[v6]:port`, or a bare host, which keeps the URL's port; a later `.connect()` wins. `.headers()` reads a name case-blind, so a `Host` the test names replaces the URL's, the TLS server name staying the URL's as with curl's `-H Host:`: the probe of a public edge asked for an internal host, `api.headers({ Host: 'grafana.internal.example' }).get('https://site.example/')`, compared to `HTTP/1.1 403 Forbidden`. Every request carries `user-agent: @jterrazz/test` unless a header replaces it.
437
+
438
+ ### Unreachable is a result
439
+
440
+ A request that never answered is never thrown. Its response is the one line `unreachable: <reason>`:
441
+
442
+ | Reason | When |
443
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
444
+ | `refused` | Nothing listens on the port, or a firewall rejects it |
445
+ | `timeout` | No whole response within `timeout`: a firewall dropping the packets, a server that accepts and never answers |
446
+ | `dns` | No resolver knows the name |
447
+ | `reset` | The connection was cut before the response ended |
448
+ | `no route` | The network has no way to the address |
449
+ | `tls <CODE>` | The handshake failed, or the certificate was refused: `tls CERT_HAS_EXPIRED`, `tls DEPTH_ZERO_SELF_SIGNED_CERT`, `tls ERR_TLS_CERT_ALTNAME_INVALID`, `tls UNABLE_TO_VERIFY_LEAF_SIGNATURE` |
450
+
451
+ So a negative probe is one sentence, with no `try`/`catch` and no "it should throw" that passes when anything throws:
452
+
453
+ ```typescript
454
+ test('the NodePort is closed to the internet', async () => {
455
+ // Given - the public address, on the port the cluster must not expose
456
+ const result = await api.connect('203.0.113.10:30080').get('http://site.example/');
457
+
458
+ // Then - the firewall drops it
459
+ expect(result.response).toMatch('unreachable: timeout');
460
+ });
461
+ ```
462
+
463
+ The line is a golden like any other: a reason takes tokens (`unreachable: tls {{string}}` where the code varies by platform), a golden file may hold it, and `TEST_UPDATE=1` writes it. An answer expected where none came, or the reverse, fails naming both outcomes (`Response outcome mismatch`, [Assertions](../reference/assertions.md#resultresponse-http-response-api)).
464
+
465
+ ### `result.tls`: the certificate
466
+
467
+ Over https, `result.tls` holds what the handshake presented, whether or not it was trusted:
468
+
469
+ | Fact | Description |
470
+ | -------------------- | ---------------------------------------------------------------------------------- |
471
+ | `subject` | The subject's common name |
472
+ | `altNames` | The names it is valid for, without their `DNS:` or `IP Address:` prefix |
473
+ | `issuer` | The issuer's common name, or its organisation |
474
+ | `validFrom` | A `Date` |
475
+ | `validTo` | A `Date` |
476
+ | `daysRemaining` | Whole days from the request to `validTo`, negative once expired |
477
+ | `covers(host)` | Whether it is valid for `host`, a wildcard covering one label as a client reads it |
478
+ | `authorized` | Whether it chains to a trusted authority and covers the URL's host |
479
+ | `authorizationError` | The code the trust check refused with, `undefined` when trusted |
480
+ | `protocol` | The TLS version negotiated (`TLSv1.3`) |
481
+
482
+ ```typescript
483
+ test('the certificate covers the host with a month to spare', async () => {
484
+ // Given - the public host
485
+ const result = await api.get('https://site.example/');
486
+
487
+ // Then - the answer, and the certificate it came over
488
+ expect(result.response).toMatch('HTTP/1.1 200 OK');
489
+ expect(result.tls?.covers('site.example')).toBe(true);
490
+ expect(result.tls?.daysRemaining).toBeGreaterThanOrEqual(30);
491
+ });
492
+ ```
493
+
494
+ A refused certificate reads `unreachable: tls <CODE>` and keeps its facts, so an expired one says by how many days. With `insecure: true` the request goes on over it, and `authorized` and `authorizationError` still say why it would have been refused. A private authority is read by the specification file that names it, `ca: readFileSync(new URL('__fixtures__/ca.pem', import.meta.url), 'utf8')`: the option takes the certificate, not a path.
495
+
496
+ ### Update mode against a url
497
+
498
+ `TEST_UPDATE=1` writes a deployed golden as it writes any response golden ([Tokens](../reference/tokens.md#update-mode-tokens-are-preserved)), with two differences that keep a probe's golden about the probe: a golden that states nothing yet records the status, the `recordedHeaders` the response carries, and the body; and a golden that states no body keeps stating none, so a long HTML page is not written into a check of its status. An exchange that never answered is written as its one line.
499
+
500
+ ### A platform suite, from the routes it discovers
501
+
502
+ A conformance suite reads its hosts from the platform when its specification file loads, then asks each one, one row of a `test.each` per host:
503
+
504
+ ```typescript
505
+ // specs/api/routes/answers.spec.ts
506
+ import { describe, expect, test } from 'vitest';
507
+
508
+ import { api, publicHosts } from '../deployed.specification.js';
509
+
510
+ describe('answers', () => {
511
+ test.each(publicHosts)('%s answers over a certificate that covers it', async (host) => {
512
+ // Given - the host, as the platform routes it
513
+ const result = await api.get(`https://${host}/`);
514
+
515
+ // Then - an answer, and a certificate naming the host with a month to spare
516
+ expect(result.response).toMatch('HTTP/1.1 {{int}}');
517
+ expect(result.tls?.covers(host)).toBe(true);
518
+ expect(result.tls?.daysRemaining).toBeGreaterThanOrEqual(30);
519
+ });
520
+ });
521
+ ```
522
+
523
+ `publicHosts` is what the specification file read from the cluster (an `IngressRoute` list, a DNS zone), so a host added to the platform is probed with no line of the suite changed. A value written in place under `test.each` is one value for every row, so it holds tokens where rows differ; a golden per host is `toMatch(file(...))` under `__expected__/`. An application repository points the same specification at its own URL and runs `npx vitest --run --project api` after its deploy step, the URL in the environment. Route coverage is the in-process app's: a deployed api exposes no route table, and records no request.
524
+
525
+ ### Pitfalls against a url
526
+
527
+ - **Probing what the in-process spec proves.** A field of the JSON a handler builds is the app's logic, proven where its database and clock are the test's; against a `url`, keep the golden to what the edge decides.
528
+ - **A deadline longer than the test's.** `timeout` bounds one request, vitest's budget (30 s) the test: a suite of firewalled ports waits the whole `timeout` for each.
529
+ - **An endpoint that streams forever** reads `unreachable: timeout`: probe it with a request the server ends.
530
+ - **A reason read as stable across platforms.** A handshake's failure is named by the TLS library node runs on: `unreachable: tls {{string}}` in a golden meant for every machine.
531
+ - **A proxy in the environment.** `HTTPS_PROXY` is not read: a request goes where its URL or its `.connect()` says.
532
+
364
533
  ## Pitfalls
365
534
 
366
535
  - **Getting `database:` wrong for the number of databases.** See [Services § The `database:` rule](../reference/services.md#the-database-rule).
@@ -2,7 +2,7 @@
2
2
 
3
3
  `specification.website()` tests a rendered website (its raw HTTP surface: redirects, robots.txt, headers; and its rendered surface: title, head metadata, JSON-LD, console, and full user scenarios) through a real Chromium. It starts the site itself, or targets one already running.
4
4
 
5
- Use it when the subject under test is a browser-rendered page. For a JSON/HTTP API surface use [api](api-specs.md); for a binary use [cli](cli-specs.md).
5
+ Use it when the subject under test is a browser-rendered page. For a JSON/HTTP API surface use [api](api-specs.md), in this process or deployed at a `url` with no browser; for a binary use [cli](cli-specs.md).
6
6
 
7
7
  ## What it specifies
8
8
 
@@ -105,6 +105,14 @@ Response status mismatch (./__expected__/user-created.http)
105
105
 
106
106
  A header mismatch has the same shape (`Response header mismatch (name)`, with `header:` / `expected:` / `received:` lines; a missing header shows `received: (absent)`). A body mismatch prints a line-by-line structural diff (`Response mismatch (name)`, `- Expected` / `+ Received`) of the expected body (with matchers and tokens rendered as their placeholder text, `{{uuid#order}}`) against the actual body: a JSON value as its pretty-printed lines, and a text body (when both sides are text) as its own lines, so an HTML page or an event stream reads as written. The diff is literal: it does not annotate captured ref values, and a failed `{{uuid#order}}` re-occurrence surfaces only as the two differing lines.
107
107
 
108
+ A request to a deployed api (`specification.api({ url })`) that never answered is the one line `unreachable: <reason>` ([API specs § Unreachable is a result](../guides/api-specs.md#unreachable-is-a-result)), compared as the status line is, tokens allowed; against a status line, or another reason, the failure names both outcomes:
109
+
110
+ ```text
111
+ Response outcome mismatch (the value in place)
112
+ expected: unreachable: timeout
113
+ received: HTTP/1.1 200
114
+ ```
115
+
108
116
  `.not`: `expect(result.response).not.toMatch(file('./__expected__/user-created.http'))` passes when any part diverges. Rarely useful; prefer positive fixtures.
109
117
 
110
118
  ## `result.table(name, { database }?)`: database tables (api, job, cli, integration)
@@ -15,6 +15,7 @@ The words this manual uses, one meaning each.
15
15
  | seam | A place where the framework meets an outside tool it loads on demand: a database driver, a browser, a simulator. Each is an optional peer |
16
16
  | ratchet | A count of debt kept in `.jterrazz/baseline.json` that may only fall: an unreached route, a convention not yet followed. A run that raises it fails, and `TEST_UPDATE=1` records a fall |
17
17
  | specimen | A small sample app a testing tool keeps under `specs/__fixtures__/` to test itself against. Only a repository that tests a testing tool has one |
18
+ | unreachable | What a request to a deployed api (`specification.api({ url })`) reads when no response came (refused, timed out, unresolved, its certificate refused): the one line `unreachable: <reason>`, compared whole like a status line rather than thrown ([API specs](../guides/api-specs.md#unreachable-is-a-result)) |
18
19
  | door | Where a test file may sit: in the `__tests__/` of its module's folder (a unit), under `specs/<kind>/` (an assembled product), or in a repository suite (the third door) |
19
20
 
20
21
  The examples use made-up products: `shoply`, a shop CLI; a news app with an events feed; and hosts under `.test` and `.example`, which never resolve on the internet.
@@ -251,7 +251,7 @@ Every rule a project using the framework meets, across its enforcement channels
251
251
  | <a id="result-compared-whole"></a>`result-compared-whole` | model | static | tests | A test that reads a response, a json or a table (`<result>.response`, `<result>.json`, `<result>.table('<name>')` of a result it awaited) compares it WHOLE, once: `toMatch` handed the whole result written in place or its golden, or a table's `toBeEmpty()`. A precise assertion on one of its properties (`expect(result.json.value.total).toBe(1)`) is allowed only IN ADDITION, and the first read of a result no whole comparison reaches is an error. A negation, a RegExp and a read through a helper are no whole comparison; a result read through a helper, a computed table name, or a comparison made in a helper is out of reach, and the rule stays silent. A read whose value is only CARRIED into a later request of the same test (every use of it, through the names and helpers it passes, ends in the arguments of a chain off a handle the test awaits, in place or through the name that holds it: a path, `params`, a body, a header, as the cursor a page hands back) claims nothing and owes no comparison; one `expect(...)` of that value makes it a read again, and the result the test does claim is still compared whole. | Write `expect(<result>.<subject>).toMatch('')` and run `TEST_UPDATE=1`: the whole result is written in place; keep the precise assertion beside it if one property IS the subject. | Probes are a partial oracle: what they do not name is free to regress unseen. The whole result, written where the test reads it, is the default oracle (approval testing) and a precise assertion states what the test is about on top of it. |
252
252
  | <a id="no-vitest-snapshot"></a>`no-vitest-snapshot` | model | upstream | tests | No vitest snapshot matcher: `toMatchSnapshot`, `toMatchInlineSnapshot`, `toMatchFileSnapshot`, `toMatchAriaSnapshot`, `toMatchScreenshot`, `toThrowErrorMatchingSnapshot` and `toThrowErrorMatchingInlineSnapshot` are refused by `vitest/no-restricted-matchers`, whose option the `test()` preset sets (ADR-005). | Write the golden: `expect(subject).toMatch(file('./__expected__/<name>.<ext>'))`; under `src/` compare to a literal with `toStrictEqual`. | A snapshot is written by the run that was supposed to be judged by it: nobody reads the diff, and a wrong answer becomes the expectation the moment someone re-records. |
253
253
  | <a id="document-follows-its-grammar"></a>`document-follows-its-grammar` | cli | checker | document | A `<case>.spec.yaml` document follows its grammar: closed keys (`description`, `fixture`, `env`, `serve`, `runs`) and closed keys inside a run; `description:` and `runs:` required; `command:` and `exit:` required in every run, `exit:` an integer literal, `waitFor:` only on the last run and never with `stdin:`, a `files:` path relative and under the workdir; every `fixture:` path relative, reaching `__fixtures__/`. | Write the key the grammar names; the message prints the accepted set. | The grammar is read by the runner's own parser: the document lint accepts is exactly the one the runner runs. |
254
- | <a id="http-first-line-follows-its-grammar"></a>`http-first-line-follows-its-grammar` | api | checker | ground | The first line of a depth-1 `.http` follows its grammar: a request line (`METHOD /path`) under `__requests__/`, a status line (`HTTP/1.1 <status>`) under `__expected__/`. | Write the opening line the folder calls for, or move the file to the other one. | The opening line is what tells a request from a response: constraining it catches misplaced files. |
254
+ | <a id="http-first-line-follows-its-grammar"></a>`http-first-line-follows-its-grammar` | api | checker | ground | The first line of a depth-1 `.http` follows its grammar: a request line (`METHOD /path`, or `METHOD https://host/path` for a deployed api) under `__requests__/`, a status line (`HTTP/1.1 <status>`, or `unreachable: <reason>` for an exchange that never answered) under `__expected__/`. | Write the opening line the folder calls for, or move the file to the other one. | The opening line is what tells a request from a response: constraining it catches misplaced files. |
255
255
  | <a id="no-empty-expected-stream"></a>`no-empty-expected-stream` | cli | checker | document | An expected stream whose whole content is `{{any}}` is an error: the run executes and proves nothing. | Regenerate with `TEST_UPDATE=1` and keep tokens only for the parts that move. | The `vitest/expect-expect` mirror for documents: an assertion that accepts anything is an absent assertion. |
256
256
  | <a id="no-malformed-ref"></a>`no-malformed-ref` | model | checker | ground | A malformed ref of a known kind (`{{iso8601#}}`, `{{uuid #id}}`) in a text file under `__expected__/` is an error. | Write `{{kind#name}}` with no space, and a name for the ref. | A malformed capture would fail silently: naming it makes it visible early. |
257
257
  | <a id="no-silent-refusal"></a>`no-silent-refusal` | cli | checker | document | A run whose `exit:` is non-zero with neither `stdout:` nor `stderr:` is an error: the document does not say why the command refused. | Write the refusal the command prints: that is what the case proves. | A silent refusal is either a defect in the product, or a golden that forgot the words went to stderr. |
@@ -145,6 +145,7 @@ await expect(result.table('payment_intents', { database: 'db' })).toMatch([
145
145
  - Segments already covered by a placeholder are **preserved**: a fixture line `Done in {{duration}}` stays `Done in {{duration}}` after an update, no matter what duration the run printed.
146
146
  - Values the framework knows to be dynamic are substituted automatically: the spec's cwd becomes `{{workdir}}` in the written fixture without you doing anything.
147
147
  - A golden that does not exist yet, or holds nothing but whitespace, is **recorded** from the output: a `.http`, a `.json` and a text golden alike, and a document's `response:` file too: a run that records stands without the goldens it is about to write. A golden is started by naming it, never by stubbing it.
148
+ - A recorded response golden states the status, the `content-type` and the body; a deployed api's (`specification.api({ url })`) states the headers its specification records (`recordedHeaders`), keeps stating no body where it stated none, and an exchange that never answered is written as its one line, `unreachable: <reason>` ([API specs § Deployed target (`url`)](../guides/api-specs.md#deployed-target-url)).
148
149
 
149
150
  Example, starting from actual output:
150
151
 
@@ -162,7 +162,7 @@ Ask the questions in order: the first yes is the kind, and the kind decides wher
162
162
  | Does it render in a browser as a whole served page? | website | `specs/website/<domain>/<aspect>.spec.ts` | `specification.website()` | `website` | [Website specs](../guides/website-specs.md) |
163
163
  | Does it render as a component (React, a DOM function, a hook)? | component | `__tests__/<file>.test.tsx` by `<file>.tsx` | none, the `component` chain | `component` | [Component specs](../guides/component-specs.md) |
164
164
  | Does it render on a simulator? | mobile | `specs/mobile/<domain>/<aspect>.spec.ts` | `specification.mobile()` | `mobile` | [Mobile specs](../guides/mobile-specs.md) |
165
- | Does it answer HTTP? | api | `specs/api/<resource>/<action>/<aspect>.spec.ts` | `specification.api()` | `api` | [API specs](../guides/api-specs.md) |
165
+ | Does it answer HTTP, in this process or deployed at a url? | api | `specs/api/<resource>/<action>/<aspect>.spec.ts` | `specification.api()` | `api` | [API specs](../guides/api-specs.md) |
166
166
  | Is it triggered by name, in-process? | job | `specs/job/<job>/<aspect>.spec.ts` | `specification.job()` | `job` | [Job specs](../guides/job-specs.md) |
167
167
  | Is it a binary? | cli | `specs/cli/<command>/` (+ `<case>.spec.yaml`) | `specification.cli(bin)` | `cli` | [CLI specs](../guides/cli-specs.md), [Spec documents](../guides/spec-documents.md) |
168
168
  | Is it a rule the project ships: a plugin, a checker, a preset? | lint | `specs/lint/<rule>/<aspect>.spec.ts` | `specification.lint()` | `lint` | [Lint specs](../guides/lint-specs.md) |
@@ -30,7 +30,7 @@ npm install -D @jterrazz/test @jterrazz/typescript vitest
30
30
  | A lint spec, `ruleTester()` | `oxlint` |
31
31
  | Coverage | `@vitest/coverage-v8` |
32
32
 
33
- `msw`, which delivers stubbed network replies, is a direct dependency: nothing to install. An API spec hands your app to `server`, which must answer `request(path, init)` with a Fetch `Response` and, for route coverage, expose Hono's route table. A Hono app does both. Any other framework needs a wrapper of your own for the first, and an OpenAPI document named in `test: { routes }` for the second ([API specs § An app that is not Hono](../guides/api-specs.md#an-app-that-is-not-hono)).
33
+ An api spec against a deployed `url` needs nothing: it speaks to the network through node's own `http`, `https` and `tls`. `msw`, which delivers stubbed network replies, is a direct dependency: nothing to install. An API spec hands your app to `server`, which must answer `request(path, init)` with a Fetch `Response` and, for route coverage, expose Hono's route table. A Hono app does both. Any other framework needs a wrapper of your own for the first, and an OpenAPI document named in `test: { routes }` for the second ([API specs § An app that is not Hono](../guides/api-specs.md#an-app-that-is-not-hono)).
34
34
 
35
35
  **A missing peer is named when it is needed.** A specification's refusal names the packages it lacks and the `npm install -D` that brings them. A service's refusal names the service that asked, the peer, and the install command of the package manager your lockfile names; under pnpm a native binding must also be listed to be built, and the message adds that line:
36
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "18.1.0",
3
+ "version": "18.3.0",
4
4
  "description": "Declarative testing framework for HTTP APIs, CLIs, background jobs, websites, mobile apps, rendered components and the rules a project ships: one entry point, real infrastructure and a real browser, golden-file assertions, plus an oxlint convention plugin.",
5
5
  "keywords": [
6
6
  "api-testing",