@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.
- package/README.md +2 -3
- package/dist/browser-shared-index.browser.js +1 -2
- package/dist/checker.js +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -2
- package/dist/index.d.ts +196 -194
- package/dist/index.js +1742 -1690
- package/dist/oxlint.cjs +5 -8
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +1 -3
- package/dist/shared-exchange.js +1 -1
- package/dist/shared-in-place.js +1 -1
- package/dist/shared-manifest.js +4 -4
- package/dist/shared-route-coverage.global-setup.js +1 -1
- package/dist/shared-session-ledger.js +1 -1
- package/dist/shared-specifications.js +0 -1
- package/dist/shared-stub-server.js +13 -0
- package/dist/shared-testcontainers.adapter.js +1 -1
- package/dist/stubs.js +1 -1
- package/dist/vitest.d.ts +1 -6
- package/dist/vitest.js +2 -8
- package/docs/README.md +1 -2
- package/docs/concepts/architecture.md +15 -16
- package/docs/concepts/conventions.md +2 -2
- package/docs/guides/api-specs.md +172 -3
- package/docs/guides/component-specs.md +1 -1
- package/docs/guides/website-specs.md +1 -1
- package/docs/reference/assertions.md +2 -2
- package/docs/reference/glossary.md +1 -1
- package/docs/reference/linting.md +4 -4
- package/docs/reference/tokens.md +1 -1
- package/docs/start/getting-started.md +14 -15
- package/docs/start/set-up-a-project.md +2 -2
- package/package.json +1 -1
- package/docs/guides/deployment-specs.md +0 -265
|
@@ -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
|
|
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
|
|
161
|
-
|
|
|
162
|
-
| Does it render in a browser as a whole served page?
|
|
163
|
-
| Does it render as a component (React, a DOM function, a hook)?
|
|
164
|
-
| Does it render on a simulator?
|
|
165
|
-
|
|
|
166
|
-
|
|
|
167
|
-
| Is it
|
|
168
|
-
| Is it a
|
|
169
|
-
| Is it a
|
|
170
|
-
| Is it a module
|
|
171
|
-
| Is it a
|
|
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
|
|
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
|
-
|
|
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`, `
|
|
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.
|
|
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)
|