@jterrazz/test 18.0.4 → 18.2.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.
@@ -0,0 +1,265 @@
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)
@@ -134,6 +134,10 @@ A preset's own `oxlint` (its plugin, its rulebook) is not run by `presets`: the
134
134
 
135
135
  A linter that fails and reports no diagnostic is refused in four parts rather than read as a clean tree: a plugin that does not load, a config oxlint cannot build, a checker that cannot start.
136
136
 
137
+ ### One module graph per worker
138
+
139
+ The lint project runs a worker's files in one module graph (`isolate: false`), as the standard hands it: each spec lays its own fixture tree and runs the linters as processes of their own, so a file leaves nothing in a module for the next one, and the worker, the framework and the kit load once per worker instead of once per file. A project whose lint specs keep state in a module (a cache, a counter, a stub set at load) states `isolate: true` for its `lint` project in `jterrazz.config.ts`, with its reason.
140
+
137
141
  ### A rule pack: a nested Vitest and a reporter fixture
138
142
 
139
143
  A package that ships lint rules for other projects proves them by running the real binary on a tree: the spec starts a nested `vitest` over a fixture project (`.fixture(directory(...))`) with a reporter fixture that prints one line per finding, and compares that output whole. A rule alone is `ruleTester()`.
@@ -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); for what a running deployment answers with no browser (its status, headers, certificate, and the addresses that refuse) use [deployment](deployment-specs.md); for a binary use [cli](cli-specs.md).
6
6
 
7
7
  ## What it specifies
8
8
 
@@ -79,7 +79,7 @@ Plain values take vitest's native matchers. No framework matcher exists (or is n
79
79
  | `await result.filesystem.files()` | `string[]` | `expect(await result.filesystem.files()).toContain('shoply.lock')` |
80
80
  | `await cli.run(file('./<case>.spec.yaml'))` | `CliResult` | the document asserts itself; the LAST run's result comes back; see [Spec documents](../guides/spec-documents.md#the-three-doors-one-runner) |
81
81
 
82
- ## `result.response`: HTTP response (api)
82
+ ## `result.response`: HTTP response (api, deployment)
83
83
 
84
84
  | Matcher | Sync/async | Example |
85
85
  | -------------------------------- | ---------- | --------------------------------------------------------------------------- |
@@ -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
+ An exchange with a running deployment that never answered is the one line `unreachable: <reason>` ([Deployment specs § Unreachable is a result](../guides/deployment-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 an exchange with a running deployment 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 ([Deployment specs](../guides/deployment-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.