@jterrazz/test 18.2.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.
@@ -14,7 +14,7 @@ Three things make a test suite expensive to keep, and the framework takes each o
14
14
 
15
15
  Five ideas cover everything else in the manual. They match the four families of the docs map, plus the files a test stands on.
16
16
 
17
- **1. A kind of test, and its runner.** What you test decides the kind: a module, a module against a real service (integration), a rendered component, a website, a running deployment, a mobile screen, an HTTP API, a job, a CLI, or the lint rules a project ships. Each kind except module and component has a constructor (`specification.api()`, `specification.cli()`, ...), called once per suite in a `*.specification.ts` file. It starts what the suite needs and returns a runner (`api`, `cli`, ...) and a `cleanup` you pass to `afterAll`. The pages call the code behind one kind a **specification**.
17
+ **1. A kind of test, and its runner.** What you test decides the kind: a module, a module against a real service (integration), a rendered component, a website, a mobile screen, an HTTP API, a job, a CLI, or the lint rules a project ships. Each kind except module and component has a constructor (`specification.api()`, `specification.cli()`, ...), called once per suite in a `*.specification.ts` file. It starts what the suite needs and returns a runner (`api`, `cli`, ...) and a `cleanup` you pass to `afterAll`. The pages call the code behind one kind a **specification**.
18
18
 
19
19
  **2. A chain: setups, then one action.** Every test is one chain on that runner: zero or more setups (`.seed()`, `.intercept()`, `.clock()`, `.env()` ...), then exactly one action (`.get()`, `.exec()`, `.visit()`, `.trigger()`, `.call()` ...), which resolves to a result. The chain reads as the test's sentence: the setups are the Given, the action is the When.
20
20
 
@@ -157,23 +157,22 @@ Ask the questions in order: the first yes is the kind, and the kind decides wher
157
157
 
158
158
  <!-- GENERATED:fork. Do not edit by hand; run `npm run docs`. Source: lint/consumer/catalogue/fork.ts -->
159
159
 
160
- | Question | Kind | Lives at | Constructor | Project | Read |
161
- | ---------------------------------------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------ | ----------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
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
- | 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
- | Does it render on a simulator? | mobile | `specs/mobile/<domain>/<aspect>.spec.ts` | `specification.mobile()` | `mobile` | [Mobile specs](../guides/mobile-specs.md) |
165
- | Is it already running somewhere, met over the network (a deployed URL, a host, a port), not started by the test? | deployment | `specs/deployment/<domain>/<aspect>.spec.ts` | `specification.deployment()` | `deployment` | [Deployment specs](../guides/deployment-specs.md) |
166
- | Does it answer HTTP? | api | `specs/api/<resource>/<action>/<aspect>.spec.ts` | `specification.api()` | `api` | [API specs](../guides/api-specs.md) |
167
- | Is it triggered by name, in-process? | job | `specs/job/<job>/<aspect>.spec.ts` | `specification.job()` | `job` | [Job specs](../guides/job-specs.md) |
168
- | 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) |
169
- | 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) |
170
- | Is it a module that needs a real service, or whose oracle is a golden? | integration | `specs/integration/<domain>/<aspect>.spec.ts` | `specification.integration()` | `integration` | [Integration specs](../guides/integration-specs.md) |
171
- | Is it a module alone? | module | `__tests__/<file>.test.ts` by `<file>.ts` | none | `unit` | [Module tests](../guides/module-tests.md) |
172
- | Is it the repository itself, a suite over several apps? | repository suite | `specs/<suite>/<domain>/<aspect>.test.ts` | none | any name | [Conventions § The third door](../concepts/conventions.md#the-third-door-a-repository-suite) |
160
+ | Question | Kind | Lives at | Constructor | Project | Read |
161
+ | ---------------------------------------------------------------------- | ---------------- | ------------------------------------------------ | ----------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
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
+ | 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
+ | 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, 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
+ | Is it triggered by name, in-process? | job | `specs/job/<job>/<aspect>.spec.ts` | `specification.job()` | `job` | [Job specs](../guides/job-specs.md) |
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
+ | 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) |
169
+ | Is it a module that needs a real service, or whose oracle is a golden? | integration | `specs/integration/<domain>/<aspect>.spec.ts` | `specification.integration()` | `integration` | [Integration specs](../guides/integration-specs.md) |
170
+ | Is it a module alone? | module | `__tests__/<file>.test.ts` by `<file>.ts` | none | `unit` | [Module tests](../guides/module-tests.md) |
171
+ | Is it the repository itself, a suite over several apps? | repository suite | `specs/<suite>/<domain>/<aspect>.test.ts` | none | any name | [Conventions § The third door](../concepts/conventions.md#the-third-door-a-repository-suite) |
173
172
 
174
173
  <!-- /GENERATED:fork -->
175
174
 
176
- The order matters: a page served over HTTP is a website, not an API, because the first question already caught it; and an app already deployed is probed as a deployment, though it answers HTTP, because the subject is what it answers where it runs. And the table is total: a folder under `specs/` is a kind's name, a special folder of what the kinds share, or a repository suite; a probe of an adapter against a real database is an integration spec like any other.
175
+ The order matters: a page served over HTTP is a website, not an API, because the first question already caught it. And the table is total: a folder under `specs/` is a kind's name, a special folder of what the kinds share, or a repository suite; a probe of an adapter against a real database is an integration spec like any other.
177
176
 
178
177
  The file suffix tells the kind apart: `.test.ts` or `.test.tsx` next to the code for a single unit, `.spec.ts` under `specs/<kind>/` for an assembled product, `<case>.spec.yaml` for a CLI session written as data, and `<kind>.specification.ts` for the file that builds a runner. The kind follows the subject, not the amount of machinery: a component needs a real browser and still sits next to its code, because it is one unit; a module that needs a real database sits under `specs/integration/`, because it is assembled with that database.
179
178
 
@@ -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
- A deployment spec 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)).
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
 
@@ -100,7 +100,7 @@ It reads the `jterrazz.config.ts` beside the module whose `import.meta.url` it i
100
100
 
101
101
  ### The project helpers
102
102
 
103
- There is one helper per kind (`unit`, `integration`, `component`, `website`, `deployment`, `mobile`, `api`, `job`, `cli` and `lint`), named by the key of a project, and exported from `@jterrazz/test/vitest` beside `vitestConfig`, for a config written by a tool of your own; called directly, `component()` takes its `root` as an absolute path, since only `vitestConfig` knows where the project sits. Each collects its folder (`specs/<kind>/**/*.spec.ts`, plus the `<case>.spec.yaml` documents for `cli()`) and carries what its kind needs: a browser for `component()`, a simulator session for `mobile()`, and for `api()`, `job()` and `integration()` a global setup that starts each service container once and shares it across every file and project of the run ([Services § One container per run](../reference/services.md#one-container-per-run)). Docker is needed only by a spec that declares `postgres()` or `redis()`: with no Docker running, each such file fails with one sentence naming Docker, and every other file runs. Every helper accepts:
103
+ There is one helper per kind (`unit`, `integration`, `component`, `website`, `mobile`, `api`, `job`, `cli` and `lint`), named by the key of a project, and exported from `@jterrazz/test/vitest` beside `vitestConfig`, for a config written by a tool of your own; called directly, `component()` takes its `root` as an absolute path, since only `vitestConfig` knows where the project sits. Each collects its folder (`specs/<kind>/**/*.spec.ts`, plus the `<case>.spec.yaml` documents for `cli()`) and carries what its kind needs: a browser for `component()`, a simulator session for `mobile()`, and for `api()`, `job()` and `integration()` a global setup that starts each service container once and shares it across every file and project of the run ([Services § One container per run](../reference/services.md#one-container-per-run)). Docker is needed only by a spec that declares `postgres()` or `redis()`: with no Docker running, each such file fails with one sentence naming Docker, and every other file runs. Every helper accepts:
104
104
 
105
105
  | Option | Does |
106
106
  | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "18.2.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",
@@ -1,265 +0,0 @@
1
- # Deployment specs (`specification.deployment`)
2
-
3
- `specification.deployment()` probes a deployment that is already running: a URL your CI just deployed, every host of a platform, a node's address or a closed port. Each test sends one complete HTTP request over the network, with no browser, and compares what came back whole, the certificate it came over beside it. A connection that never answers is a result too, so a negative probe reads like a positive one.
4
-
5
- Use it when the subject is the deployed surface itself. For the app's own behaviour, started in the test process with real databases, use [api](api-specs.md); for a page a visitor sees, rendered in a browser, use [website](website-specs.md).
6
-
7
- ## What it specifies
8
-
9
- A deployment spec answers one question: **given this address, what does the running deployment answer, and over which certificate?** The subject is what the artefact does once shipped (its edge, its routing, its TLS, its redirects, its headers, what it refuses), met from outside as a client meets it. The app's logic behind that surface is an api spec's ([API specs](api-specs.md)), proven where its stubs, its clock and its databases mean something; a deployment spec proves nothing of them, and starts none of them.
10
-
11
- | Aspect | What it means |
12
- | --------------- | ------------------------------------------------------------------------------------ |
13
- | The deployment | Already running: started by nobody in the test, reached over the network |
14
- | One exchange | One request and its whole response; a redirect is the answer, never followed |
15
- | What it proves | Status, headers, body, the certificate presented, and which addresses refuse to talk |
16
- | What it doesn't | The app's logic: no stub, no clock, no seed reaches a deployment |
17
-
18
- The transport is node's own `http`, `https`, `tls` and `net` ([ADR-022](https://github.com/jterrazz/package-test/blob/main/docs/decisions/022-a-running-deployment-is-the-deployment-specification.md)): nothing to install beyond the package.
19
-
20
- ## The constructor
21
-
22
- ```typescript
23
- // specs/deployment/deployment.specification.ts
24
- import { specification } from '@jterrazz/test';
25
- import { afterAll } from 'vitest';
26
-
27
- export const { cleanup, deployment } = await specification.deployment({
28
- url: process.env.DEPLOYED_URL ?? 'https://preview.site.example',
29
- });
30
-
31
- afterAll(cleanup);
32
- ```
33
-
34
- The handle destructures to `{ deployment, cleanup }` (rule `no-runner-alias`). The specification starts nothing, so `cleanup` releases nothing; it is passed to `afterAll` like every specification's, so no reader wonders which one forgot it.
35
-
36
- ### Options
37
-
38
- | Option | Default | Description |
39
- | ----------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40
- | `url` | none | The base a path is read from (`.get('/health')`), its own path kept: `https://site.example/app` and `/health` are `https://site.example/app/health`. A URL written whole needs none |
41
- | `timeout` | `5000` | The milliseconds one exchange may take, connection to last byte, before it reads `unreachable: timeout`. A positive number |
42
- | `insecure` | `false` | Accept a certificate the trust check refuses (self-signed, expired, another host's); `result.tls` still says why |
43
- | `ca` | none | A certificate authority trusted beside the ones node trusts: its PEM text, a value like any string |
44
- | `recordedHeaders` | `['content-type', 'location']` | The headers update mode records into a golden that states none yet, those the response carries ([Update mode](#update-mode)) |
45
-
46
- A base that is no `http://` or `https://` URL, and a `timeout` that is no positive number, are refused before the first exchange. 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.
47
-
48
- ## The chain
49
-
50
- ### The target
51
-
52
- Every action names one URL: written whole (`https://grafana.site.example/`), or a path (`/health`) read from the specification's `url`. One specification therefore reaches every host of a platform, and a suite that discovers its hosts at run time needs no specification per host. A path where the specification names no `url`, a target that is neither, and a URL of another scheme are refused.
53
-
54
- ### Actions (terminal)
55
-
56
- Exactly one per chain (rule `one-terminal-action-per-chain`).
57
-
58
- | Action | Description |
59
- | -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
60
- | `.get(target)` | A GET, with the chain's headers |
61
- | ``.request(`POST https://... ...`)`` | The complete request written in the test, `.http` text, dedented: its method, headers and body |
62
- | `.request(file('./__requests__/file.http'))` | The complete request that file describes, when several tests send it |
63
-
64
- A request file is the api's format ([API specs § `.http` request files](api-specs.md#http-request-files-full-format)), and its first line may name the URL whole (`GET https://site.example/health`, HTTP's absolute form) or a path. The body is sent as written.
65
-
66
- ```typescript
67
- test('a moved page answers its redirect, never followed', async () => {
68
- // Given - the address of the page before the move
69
- const result = await deployment.get('/blog');
70
-
71
- // Then - the 301 itself, and where it points
72
- expect(result.response).toMatch(`
73
- HTTP/1.1 301 Moved Permanently
74
- location: https://site.example/articles
75
- `);
76
- });
77
- ```
78
-
79
- ### Setup: `.connect()`
80
-
81
- `.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:
82
-
83
- ```typescript
84
- test("the node's NodePort serves the public host itself", async () => {
85
- // Given - the public URL, its socket opened on the node
86
- const result = await deployment.connect('100.64.0.7:30443').get('https://site.example/health');
87
-
88
- // Then - the same answer the edge gives
89
- expect(result.response).toMatch(`
90
- HTTP/1.1 200 OK
91
- content-type: application/json
92
-
93
- {"status": "ok"}
94
- `);
95
- });
96
- ```
97
-
98
- The address is `host:port`, `[v6]:port`, or a bare host, which keeps the URL's port. A later `.connect()` in the chain wins.
99
-
100
- ### Setup: `.headers()`
101
-
102
- `.headers({...})` sets request headers; repeated calls merge, and a name is read case-blind, so a `Host` the test names replaces the URL's. That is the host-smuggling probe: the public address, asked for an internal host, must refuse it.
103
-
104
- ```typescript
105
- test('the public edge refuses a request naming an internal host', async () => {
106
- // Given - the public address, asked for the dashboard's host
107
- const result = await deployment
108
- .headers({ Host: 'grafana.internal.example' })
109
- .get('https://site.example/');
110
-
111
- // Then - refused at the edge
112
- expect(result.response).toMatch('HTTP/1.1 403 Forbidden');
113
- });
114
- ```
115
-
116
- The TLS server name stays the URL's, as with curl's `-H Host:`. A header a request file states is replaced by the chain's of the same name.
117
-
118
- ### Setup: `deployment.with({ headers })`
119
-
120
- The headers a whole file shares are stated once, on a view of the specification whose every chain starts with them (`const crawler = deployment.with({ headers: { 'user-agent': 'GPTBot/1.0' } })`), and a chain's own `.headers()` merges over it ([Conventions](../concepts/conventions.md#what-a-file-shares)). Every request carries `user-agent: @jterrazz/test` unless a header replaces it.
121
-
122
- ## The result
123
-
124
- | Member | Type | Description |
125
- | ---------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
126
- | `result.response` | response subject | The whole response (status, headers, body), or `unreachable: <reason>`, compared whole: `toMatch(<.http text>)` or `toMatch(file(...))` |
127
- | `result.response.body` | body | The JSON value, or the text of a text body; `null` when empty or binary |
128
- | `result.tls` | `TlsFacts \| undefined` | The certificate an https exchange was presented and the trust check's verdict; undefined over http, or before a handshake completed |
129
-
130
- `result.response` is the api's response subject, so everything [API specs](api-specs.md#http-request-files-full-format) says of a golden holds: the first line is the status, the headers are a **subset** (the ones it names are judged, the rest are free), the body is read by its `content-type`, and `{{tokens}}` cover what moves. Unlike an api golden, a deployment golden often stops at the status and the headers: a golden that states no body leaves the body free.
131
-
132
- ### Unreachable is a result
133
-
134
- An exchange that never answered is never thrown. Its response is the one line `unreachable: <reason>`, and the reason is one of:
135
-
136
- | Reason | When |
137
- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
138
- | `refused` | Nothing listens on the port, or a firewall rejects it |
139
- | `timeout` | No whole response within `timeout`: a firewall dropping the packets, a server that accepts and never answers |
140
- | `dns` | No resolver knows the name |
141
- | `reset` | The connection was cut before the response ended |
142
- | `no route` | The network has no way to the address |
143
- | `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` |
144
-
145
- So a negative probe is one sentence, with no `try`/`catch` and no "it should throw" that passes when anything throws:
146
-
147
- ```typescript
148
- test('the dashboard is unreachable from the internet', async () => {
149
- // Given - the dashboard's host, asked from outside the tailnet
150
- const result = await deployment.get('https://grafana.internal.example/');
151
-
152
- // Then - no answer at all
153
- expect(result.response).toMatch('unreachable: dns');
154
- });
155
-
156
- test('the NodePort is closed to the internet', async () => {
157
- // Given - the public address, on the port the cluster must not expose
158
- const result = await deployment.connect('203.0.113.10:30080').get('http://site.example/');
159
-
160
- // Then - the firewall drops it
161
- expect(result.response).toMatch('unreachable: timeout');
162
- });
163
- ```
164
-
165
- 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:
166
-
167
- ```text
168
- Response outcome mismatch (the value in place)
169
- expected: HTTP/1.1 200
170
- received: unreachable: refused
171
- ```
172
-
173
- ### `result.tls`: the certificate
174
-
175
- Over https, `result.tls` holds what the handshake presented, whether or not it was trusted:
176
-
177
- | Fact | Description |
178
- | -------------------- | ---------------------------------------------------------------------------------- |
179
- | `subject` | The subject's common name |
180
- | `altNames` | The names it is valid for, without their `DNS:` or `IP Address:` prefix |
181
- | `issuer` | The issuer's common name, or its organisation |
182
- | `validFrom` | A `Date` |
183
- | `validTo` | A `Date` |
184
- | `daysRemaining` | Whole days from the exchange to `validTo`, negative once expired |
185
- | `covers(host)` | Whether it is valid for `host`, a wildcard covering one label as a client reads it |
186
- | `authorized` | Whether it chains to a trusted authority and covers the URL's host |
187
- | `authorizationError` | The code the trust check refused with, `undefined` when trusted |
188
- | `protocol` | The TLS version negotiated (`TLSv1.3`) |
189
-
190
- ```typescript
191
- test('the certificate covers the host with a month to spare', async () => {
192
- // Given - the public host
193
- const result = await deployment.get('https://site.example/');
194
-
195
- // Then - the answer, and the certificate it came over
196
- expect(result.response).toMatch('HTTP/1.1 200 OK');
197
- expect(result.tls?.covers('site.example')).toBe(true);
198
- expect(result.tls?.daysRemaining).toBeGreaterThanOrEqual(30);
199
- });
200
- ```
201
-
202
- A refused certificate reads `unreachable: tls <CODE>` and keeps its facts, so an expired one says by how many days. With `insecure: true` the exchange goes on over it, and `authorized` and `authorizationError` still say why it would have been refused.
203
-
204
- ### Update mode
205
-
206
- `TEST_UPDATE=1` writes a golden as it writes an api's ([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; 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.
207
-
208
- ## Unique here
209
-
210
- ### A platform suite, from the routes it discovers
211
-
212
- A conformance suite reads its hosts from the platform when it starts, then asks each one. The discovery is the specification file's, and each host is one row of a `test.each`, one exchange each:
213
-
214
- ```typescript
215
- // specs/deployment/routes/answers.spec.ts
216
- import { describe, expect, test } from 'vitest';
217
-
218
- import { deployment, publicHosts } from '../deployment.specification.js';
219
-
220
- describe('answers', () => {
221
- test.each(publicHosts)('%s answers over a certificate that covers it', async (host) => {
222
- // Given - the host, as the platform routes it
223
- const result = await deployment.get(`https://${host}/`);
224
-
225
- // Then - an answer, and a certificate naming the host with a month to spare
226
- expect(result.response).toMatch('HTTP/1.1 {{int}}');
227
- expect(result.tls?.covers(host)).toBe(true);
228
- expect(result.tls?.daysRemaining).toBeGreaterThanOrEqual(30);
229
- });
230
- });
231
- ```
232
-
233
- Where `publicHosts` is what the specification file read from the cluster before the run (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__/`.
234
-
235
- ### The check after a deploy
236
-
237
- An application repository points the specification at its own URL and runs the project after its deploy step, `npx vitest --run --project deployment`, with the URL in the environment. The same specs run against a preview, a staging or a production URL: the address is the only thing that moves.
238
-
239
- ### Folder layout
240
-
241
- ```text
242
- specs/deployment/
243
- ├── deployment.specification.ts # specification.deployment(): the base, the deadline, the trust
244
- ├── edge/
245
- │ ├── redirects.spec.ts
246
- │ └── __requests__/ # a complete request several tests send
247
- └── routes/
248
- ├── answers.spec.ts
249
- └── __expected__/ # a golden several tests share
250
- ```
251
-
252
- No `__seeds__/`: a deployment is not seeded.
253
-
254
- ## Pitfalls
255
-
256
- - **Probing what an api spec proves.** A field of the JSON a handler builds is the app's logic, proven in process where the database and the clock are the test's. A deployment spec asks what the shipped artefact answers, and keeps its goldens to what the edge decides.
257
- - **Expecting a redirect to be followed.** The 301 is the answer. A test about where it lands sends a second chain to the `location`.
258
- - **A deadline longer than the test's.** `timeout` bounds one exchange; vitest's own budget (30 s) bounds the test. A probe of a firewalled port waits the whole `timeout`, so keep it short where a suite probes many closed ports.
259
- - **An endpoint that streams forever.** An event stream never ends, so it reads `unreachable: timeout`. Probe it with a request the server ends.
260
- - **Reading a reason as stable across platforms.** A handshake's failure is named by the TLS library node runs on, so a handshake spoken to a plain port is `unreachable: tls {{string}}` in a golden meant for every machine.
261
- - **A proxy in the environment.** `HTTPS_PROXY` is not read: an exchange goes where its URL or its `.connect()` says.
262
-
263
- ## Related
264
-
265
- [API specs](api-specs.md) · [Website specs](website-specs.md) · [Assertions](../reference/assertions.md) · [Tokens](../reference/tokens.md)