@jterrazz/test 18.1.0 → 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.
- package/README.md +2 -1
- package/dist/browser-shared-index.browser.js +2 -1
- package/dist/checker.js +4 -4
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -0
- package/dist/index.d.ts +146 -7
- package/dist/index.js +564 -52
- package/dist/oxlint.cjs +8 -5
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +3 -1
- package/dist/shared-manifest.js +4 -4
- package/dist/shared-specifications.js +1 -0
- package/dist/vitest.d.ts +6 -1
- package/dist/vitest.js +7 -1
- package/docs/README.md +1 -0
- package/docs/concepts/architecture.md +7 -6
- package/docs/concepts/conventions.md +2 -2
- package/docs/guides/api-specs.md +1 -1
- package/docs/guides/component-specs.md +1 -1
- package/docs/guides/deployment-specs.md +265 -0
- package/docs/guides/website-specs.md +1 -1
- package/docs/reference/assertions.md +9 -1
- package/docs/reference/glossary.md +1 -0
- package/docs/reference/linting.md +4 -4
- package/docs/reference/tokens.md +1 -0
- package/docs/start/getting-started.md +15 -14
- package/docs/start/set-up-a-project.md +2 -2
- package/package.json +1 -1
package/dist/oxlint.cjs
CHANGED
|
@@ -172,6 +172,7 @@ function testPackageOf(specifier) {
|
|
|
172
172
|
const SPECIFICATIONS = /* @__PURE__ */ new Set([
|
|
173
173
|
"api",
|
|
174
174
|
"cli",
|
|
175
|
+
"deployment",
|
|
175
176
|
"integration",
|
|
176
177
|
"job",
|
|
177
178
|
"lint",
|
|
@@ -212,10 +213,10 @@ var manifest_default = {
|
|
|
212
213
|
{
|
|
213
214
|
"audience": "consumer",
|
|
214
215
|
"channel": "static",
|
|
215
|
-
"sentence": "
|
|
216
|
+
"sentence": "Eight constructors and only eight: `specification.api()`, `specification.job()`, `specification.cli(bin)`, `specification.integration()`, `specification.website()`, `specification.deployment()`, `specification.mobile()`, `specification.lint()`. Any other member (`.app`, `.http`, `.stack`...) is an error. A rendered component starts nothing: it is a chain, not a member of this record.",
|
|
216
217
|
"specification": "model",
|
|
217
218
|
"family": "setup",
|
|
218
|
-
"fix": "Name one of the
|
|
219
|
+
"fix": "Name one of the eight, or reach for the `component` chain: a ninth subject is a decision before it is a constructor.",
|
|
219
220
|
"name": "no-unknown-constructor",
|
|
220
221
|
"rationale": "A closed surface stops parallel constructors being invented and keeps the API memorable.",
|
|
221
222
|
"reach": "all"
|
|
@@ -953,7 +954,7 @@ var manifest_default = {
|
|
|
953
954
|
{
|
|
954
955
|
"audience": "consumer",
|
|
955
956
|
"channel": "checker",
|
|
956
|
-
"sentence": "A first-level folder named for one of the
|
|
957
|
+
"sentence": "A first-level folder named for one of the eight specifications holds a `*.specification.ts(x)` calling `specification.<specification>(`. Positive-only: any other first-level name is a repository suite, judged by `specs-at-declared-depth`'s declared depth alone.",
|
|
957
958
|
"specification": "model",
|
|
958
959
|
"family": "layout",
|
|
959
960
|
"fix": "Create the specification the folder promises, or rename the folder.",
|
|
@@ -1052,7 +1053,7 @@ var manifest_default = {
|
|
|
1052
1053
|
{
|
|
1053
1054
|
"audience": "consumer",
|
|
1054
1055
|
"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__/`.",
|
|
1056
|
+
"sentence": "The first line of a depth-1 `.http` follows its grammar: a request line (`METHOD /path`, or `METHOD https://host/path` for a deployment) under `__requests__/`, a status line (`HTTP/1.1 <status>`, or `unreachable: <reason>` for an exchange that never answered) under `__expected__/`.",
|
|
1056
1057
|
"specification": "api",
|
|
1057
1058
|
"family": "expectations",
|
|
1058
1059
|
"fix": "Write the opening line the folder calls for, or move the file to the other one.",
|
|
@@ -2251,6 +2252,7 @@ function subjectsOf(source, readRequest, reading = NO_READING) {
|
|
|
2251
2252
|
const SETTINGS = {
|
|
2252
2253
|
api: ["clock", "headers"],
|
|
2253
2254
|
cli: ["env"],
|
|
2255
|
+
deployment: ["headers"],
|
|
2254
2256
|
integration: ["clock"],
|
|
2255
2257
|
job: ["clock"],
|
|
2256
2258
|
website: ["clock", "headers"]
|
|
@@ -5997,6 +5999,7 @@ const noSimulatedDom = (0, _jterrazz_typescript_rules.defineRule)({
|
|
|
5997
5999
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
5998
6000
|
"api",
|
|
5999
6001
|
"cli",
|
|
6002
|
+
"deployment",
|
|
6000
6003
|
"integration",
|
|
6001
6004
|
"job",
|
|
6002
6005
|
"lint",
|
|
@@ -6008,7 +6011,7 @@ const noUnknownConstructor = (0, _jterrazz_typescript_rules.defineRule)({
|
|
|
6008
6011
|
docs: row$7,
|
|
6009
6012
|
messages: { unknownConstructor: {
|
|
6010
6013
|
found: "`specification.{{member}}` is no constructor",
|
|
6011
|
-
fix: "call `specification.api()`, `.job()`, `.cli(bin)`, `.integration()`, `.website()`, `.mobile()` or `.lint()`; a rendered unit is the `component` chain"
|
|
6014
|
+
fix: "call `specification.api()`, `.job()`, `.cli(bin)`, `.integration()`, `.website()`, `.deployment()`, `.mobile()` or `.lint()`; a rendered unit is the `component` chain"
|
|
6012
6015
|
} },
|
|
6013
6016
|
read: (0, _jterrazz_typescript_rules.readAt)(row$7, CATALOGUE_CHAPTER),
|
|
6014
6017
|
type: "problem"
|
package/dist/oxlint.d.cts
CHANGED
|
@@ -12,7 +12,7 @@ type RowDocs<Family extends string = string> = Omit<RuleDocs, 'family' | 'name'>
|
|
|
12
12
|
/** One per row: a convention enforced two ways is two rows, so a reader knows which pass a finding comes from. */
|
|
13
13
|
channel: 'checker' | 'meta' | 'process' | 'runtime' | 'static' | 'toolchain' | 'type' | 'upstream';
|
|
14
14
|
/** Required: `model` is a statement (this reaches every specification), never a default. */
|
|
15
|
-
specification: 'api' | 'cli' | 'component' | 'integration' | 'job' | 'lint' | 'mobile' | 'model' | 'website';
|
|
15
|
+
specification: 'api' | 'cli' | 'component' | 'deployment' | 'integration' | 'job' | 'lint' | 'mobile' | 'model' | 'website';
|
|
16
16
|
family: Family;
|
|
17
17
|
rationale: string;
|
|
18
18
|
/** An `upstream` row names the rule whose option carries it; the completeness meta-test reads it. */
|
package/dist/oxlint.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ type RowDocs<Family extends string = string> = Omit<RuleDocs, 'family' | 'name'>
|
|
|
12
12
|
/** One per row: a convention enforced two ways is two rows, so a reader knows which pass a finding comes from. */
|
|
13
13
|
channel: 'checker' | 'meta' | 'process' | 'runtime' | 'static' | 'toolchain' | 'type' | 'upstream';
|
|
14
14
|
/** Required: `model` is a statement (this reaches every specification), never a default. */
|
|
15
|
-
specification: 'api' | 'cli' | 'component' | 'integration' | 'job' | 'lint' | 'mobile' | 'model' | 'website';
|
|
15
|
+
specification: 'api' | 'cli' | 'component' | 'deployment' | 'integration' | 'job' | 'lint' | 'mobile' | 'model' | 'website';
|
|
16
16
|
family: Family;
|
|
17
17
|
rationale: string;
|
|
18
18
|
/** An `upstream` row names the rule whose option carries it; the completeness meta-test reads it. */
|
package/dist/oxlint.js
CHANGED
|
@@ -368,6 +368,7 @@ const noRedundantServiceUrl = defineRule({
|
|
|
368
368
|
const SETTINGS = {
|
|
369
369
|
api: ["clock", "headers"],
|
|
370
370
|
cli: ["env"],
|
|
371
|
+
deployment: ["headers"],
|
|
371
372
|
integration: ["clock"],
|
|
372
373
|
job: ["clock"],
|
|
373
374
|
website: ["clock", "headers"]
|
|
@@ -3738,6 +3739,7 @@ const noSimulatedDom = defineRule({
|
|
|
3738
3739
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
3739
3740
|
"api",
|
|
3740
3741
|
"cli",
|
|
3742
|
+
"deployment",
|
|
3741
3743
|
"integration",
|
|
3742
3744
|
"job",
|
|
3743
3745
|
"lint",
|
|
@@ -3749,7 +3751,7 @@ const noUnknownConstructor = defineRule({
|
|
|
3749
3751
|
docs: row$7,
|
|
3750
3752
|
messages: { unknownConstructor: {
|
|
3751
3753
|
found: "`specification.{{member}}` is no constructor",
|
|
3752
|
-
fix: "call `specification.api()`, `.job()`, `.cli(bin)`, `.integration()`, `.website()`, `.mobile()` or `.lint()`; a rendered unit is the `component` chain"
|
|
3754
|
+
fix: "call `specification.api()`, `.job()`, `.cli(bin)`, `.integration()`, `.website()`, `.deployment()`, `.mobile()` or `.lint()`; a rendered unit is the `component` chain"
|
|
3753
3755
|
} },
|
|
3754
3756
|
read: readAt(row$7, CATALOGUE_CHAPTER),
|
|
3755
3757
|
type: "problem"
|
package/dist/shared-manifest.js
CHANGED
|
@@ -33,10 +33,10 @@ var manifest_default = {
|
|
|
33
33
|
{
|
|
34
34
|
"audience": "consumer",
|
|
35
35
|
"channel": "static",
|
|
36
|
-
"sentence": "
|
|
36
|
+
"sentence": "Eight constructors and only eight: `specification.api()`, `specification.job()`, `specification.cli(bin)`, `specification.integration()`, `specification.website()`, `specification.deployment()`, `specification.mobile()`, `specification.lint()`. Any other member (`.app`, `.http`, `.stack`...) is an error. A rendered component starts nothing: it is a chain, not a member of this record.",
|
|
37
37
|
"specification": "model",
|
|
38
38
|
"family": "setup",
|
|
39
|
-
"fix": "Name one of the
|
|
39
|
+
"fix": "Name one of the eight, or reach for the `component` chain: a ninth subject is a decision before it is a constructor.",
|
|
40
40
|
"name": "no-unknown-constructor",
|
|
41
41
|
"rationale": "A closed surface stops parallel constructors being invented and keeps the API memorable.",
|
|
42
42
|
"reach": "all"
|
|
@@ -774,7 +774,7 @@ var manifest_default = {
|
|
|
774
774
|
{
|
|
775
775
|
"audience": "consumer",
|
|
776
776
|
"channel": "checker",
|
|
777
|
-
"sentence": "A first-level folder named for one of the
|
|
777
|
+
"sentence": "A first-level folder named for one of the eight specifications holds a `*.specification.ts(x)` calling `specification.<specification>(`. Positive-only: any other first-level name is a repository suite, judged by `specs-at-declared-depth`'s declared depth alone.",
|
|
778
778
|
"specification": "model",
|
|
779
779
|
"family": "layout",
|
|
780
780
|
"fix": "Create the specification the folder promises, or rename the folder.",
|
|
@@ -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 deployment) 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.",
|
package/dist/vitest.d.ts
CHANGED
|
@@ -86,6 +86,11 @@ type ComponentProjectOptions = ComponentOptions & SpecificationProjectOptions;
|
|
|
86
86
|
/** A component project: renders in a real Chromium; the default `exclude` keeps it out of `specs/`. */
|
|
87
87
|
export declare function component(options: ComponentProjectOptions): Promise<TestProjectInlineConfiguration>;
|
|
88
88
|
//#endregion
|
|
89
|
+
//#region src/specifications/deployment/deployment.project.d.ts
|
|
90
|
+
/** `deployment()`: a running deployment, met over the network through complete HTTP exchanges. */
|
|
91
|
+
type DeploymentProjectOptions = SpecificationProjectOptions;
|
|
92
|
+
export declare function deployment(options?: DeploymentProjectOptions): TestProjectInlineConfiguration;
|
|
93
|
+
//#endregion
|
|
89
94
|
//#region src/specifications/integration/integration.project.d.ts
|
|
90
95
|
/** `integration()`: a module against real services, or against a golden. */
|
|
91
96
|
type IntegrationProjectOptions = InProcessProjectOptions;
|
|
@@ -135,5 +140,5 @@ export declare function unit(options?: UnitProjectOptions): TestProjectInlineCon
|
|
|
135
140
|
//#region src/index.vitest.d.ts
|
|
136
141
|
export declare const vitestConfig: (configUrl: string) => Promise<ViteUserConfig>;
|
|
137
142
|
//#endregion
|
|
138
|
-
export type { ApiProjectOptions, CliProjectOptions, ComponentProjectOptions, InProcessProjectOptions, IntegrationProjectOptions, JobProjectOptions, LintProjectOptions, LiterateDoor, LiterateOptions, LiteratePlugin, MobileProjectOptions, SpecificationProjectOptions, UnitProjectOptions, WebsiteProjectOptions };
|
|
143
|
+
export type { ApiProjectOptions, CliProjectOptions, ComponentProjectOptions, DeploymentProjectOptions, InProcessProjectOptions, IntegrationProjectOptions, JobProjectOptions, LintProjectOptions, LiterateDoor, LiterateOptions, LiteratePlugin, MobileProjectOptions, SpecificationProjectOptions, UnitProjectOptions, WebsiteProjectOptions };
|
|
139
144
|
//# sourceMappingURL=vitest.d.ts.map
|
package/dist/vitest.js
CHANGED
|
@@ -366,6 +366,11 @@ async function component(options) {
|
|
|
366
366
|
});
|
|
367
367
|
}
|
|
368
368
|
//#endregion
|
|
369
|
+
//#region src/specifications/deployment/deployment.project.ts
|
|
370
|
+
function deployment(options = {}) {
|
|
371
|
+
return specificationProject("deployment", options);
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
369
374
|
//#region src/specifications/integration/integration.project.ts
|
|
370
375
|
function integration(options = {}) {
|
|
371
376
|
return mergeConfig(runnerProject("integration", options), { test: { globalSetup: [servicesSetup()] } });
|
|
@@ -504,6 +509,7 @@ const BUILDERS = /* @__PURE__ */ new Map([
|
|
|
504
509
|
["job", job],
|
|
505
510
|
["integration", integration],
|
|
506
511
|
["website", website],
|
|
512
|
+
["deployment", deployment],
|
|
507
513
|
["mobile", mobile],
|
|
508
514
|
["component", component]
|
|
509
515
|
]);
|
|
@@ -587,6 +593,6 @@ async function derivedFrom(root) {
|
|
|
587
593
|
}
|
|
588
594
|
const vitestConfig = async (configUrl) => await derivedFrom(dirname(fileURLToPath(configUrl)));
|
|
589
595
|
//#endregion
|
|
590
|
-
export { api, cli, component, integration, job, lint, literate, mobile, unit, vitestConfig, website };
|
|
596
|
+
export { api, cli, component, deployment, integration, job, lint, literate, mobile, unit, vitestConfig, website };
|
|
591
597
|
|
|
592
598
|
//# sourceMappingURL=vitest.js.map
|
package/docs/README.md
CHANGED
|
@@ -22,6 +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
|
+
| [Deployment specs](guides/deployment-specs.md) | A running deployment, one HTTP exchange over the network: `specification.deployment()` |
|
|
25
26
|
| [API specs](guides/api-specs.md) | An HTTP app, in-process, through complete requests: `specification.api()` |
|
|
26
27
|
| [Job specs](guides/job-specs.md) | A pipeline triggered by name: `specification.job()` |
|
|
27
28
|
| [CLI specs](guides/cli-specs.md) | A binary in a fresh working directory: `specification.cli()` |
|
|
@@ -8,7 +8,7 @@ The rules themselves (what they say and why) are [Conventions](conventions.md);
|
|
|
8
8
|
|
|
9
9
|
Every specification (the code behind one kind of test) is the same four steps. A **constructor** takes options and returns a **handle**; the handle opens a **chain** of zero or more setups closed by exactly one terminal action; the action executes and resolves to a **typed result**; the result is asserted through vitest's `expect()`. Nothing else is public: there is no imperative escape hatch, because a spec that can do anything proves nothing in particular.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Eight constructors exist and the list is closed (`src/specifications/specification.ts`):
|
|
12
12
|
|
|
13
13
|
| Constructor | Handle destructures to | Subject under test |
|
|
14
14
|
| ----------------------------- | --------------------------- | ---------------------------------------------- |
|
|
@@ -17,10 +17,11 @@ Seven constructors exist and the list is closed (`src/specifications/specificati
|
|
|
17
17
|
| `specification.cli()` | `{ cli, cleanup, docker }` | A command binary in a fresh temp directory |
|
|
18
18
|
| `specification.integration()` | `{ integration, cleanup }` | A module against real services, or a golden |
|
|
19
19
|
| `specification.website()` | `{ website, cleanup, url }` | A rendered page, fetched or driven in chromium |
|
|
20
|
+
| `specification.deployment()` | `{ deployment, cleanup }` | A running deployment, over the network |
|
|
20
21
|
| `specification.mobile()` | `{ mobile, cleanup, udid }` | A native screen on an iOS simulator |
|
|
21
22
|
| `specification.lint()` | `{ lint, cleanup }` | The rules a project ships, over a fixture tree |
|
|
22
23
|
|
|
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`
|
|
24
|
+
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` and `deployment` start nothing at all, their `cleanup` releasing nothing: a deployment is running before the suite and after it, and its exchanges hold a socket for their own length only.
|
|
24
25
|
|
|
25
26
|
**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
27
|
|
|
@@ -28,7 +29,7 @@ A runner is created **once per suite**, in a `*.specification.ts` file, and impo
|
|
|
28
29
|
|
|
29
30
|
### The seam under the chain
|
|
30
31
|
|
|
31
|
-
A specification's chain is COMPOSED: `src/specifications/<specification>/<specification>.composition.ts` lists the setups (each a `*.capability.ts`) and the terminal actions (each a `*.action.ts`) it is made of and the adapter each one is armed through, `defineSpecification` opens the chain, and every action runs through the one pipeline of `src/chain/perform.ts`. Every specification (`job`, `api`, `website`, `integration`, `cli`, `mobile` and `component`) is composed, and there is no other chain. The infrastructure a chain needs is reached through **ports**, each a `*.port.ts` beside the area or the specification that owns it (`database`, `service`, `container`, `isolation` under `src/services/`; `fixture`, `link` under `src/disk/`; `server`, `cli`, `browser`, `device` in their specification), and an adapter implements one. So the chain knows "a database exists"; it never knows Postgres.
|
|
32
|
+
A specification's chain is COMPOSED: `src/specifications/<specification>/<specification>.composition.ts` lists the setups (each a `*.capability.ts`) and the terminal actions (each a `*.action.ts`) it is made of and the adapter each one is armed through, `defineSpecification` opens the chain, and every action runs through the one pipeline of `src/chain/perform.ts`. Every specification (`job`, `api`, `website`, `deployment`, `integration`, `cli`, `mobile`, `lint` and `component`) is composed, and there is no other chain. The infrastructure a chain needs is reached through **ports**, each a `*.port.ts` beside the area or the specification that owns it (`database`, `service`, `container`, `isolation` under `src/services/`; `fixture`, `link` under `src/disk/`; `server`, `cli`, `browser`, `device` in their specification), and an adapter implements one. So the chain knows "a database exists"; it never knows Postgres.
|
|
32
33
|
|
|
33
34
|
Every seam is opened lazily rather than imported, and each one is an optional peer loaded by the one module that owns it, so a project that tests no page, no component and no database installs none of them. Which peer each specification and each service needs, and what its refusal says, is [Set up a project § What to install](../start/set-up-a-project.md#what-to-install)'s list; a service's refusal is built by `src/services/peer.ts`, and a specification's by the adapter that loads its peer.
|
|
34
35
|
|
|
@@ -48,7 +49,7 @@ The root reads the sentence of a test. A test opens a **specification** on its s
|
|
|
48
49
|
| `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
50
|
| `clock/` | `chain/`, `lint/kit/`, itself, `vitest` | The pinned clock and `.clock()`, the instant grammar, the process clock adapter |
|
|
50
51
|
| `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
|
|
52
|
+
| `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/`, `exchange/` ...); 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 eight as one record |
|
|
52
53
|
| `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
54
|
| `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
55
|
| `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/` |
|
|
@@ -101,7 +102,7 @@ The bundle is built by `typescript build`, from the manifest and the names of th
|
|
|
101
102
|
|
|
102
103
|
**There are SEVEN entries and no more**: `@jterrazz/test` for what writes a spec (the constructors and their faces, `ruleTester()`, the targets, `match` and the tokens, `clock`, `mockOf()`, `packed()`), `@jterrazz/test/services` for the services a specification starts, `@jterrazz/test/stubs` for what the network replies, `@jterrazz/test/vitest` for the project helpers and the `vitestConfig` the derived `vitest.config.ts` awaits, `@jterrazz/test/oxlint` for the lint plugin, `@jterrazz/test/config` for the `test()` preset a `jterrazz.config.ts` lists, `@jterrazz/test/schema` for an editor validating a `<case>.spec.yaml`. That is rule `no-unpublished-subpath` rather than a convention of taste, and the exemption is derived from the manifest's own `exports` map (the kit's `declaredSubpaths`), so a subpath is exempt the moment it is published and stops being exempt the moment it is withdrawn. A service or a stub imported from the root is rule `service-or-stub-from-its-subpath`, whose fix moves the import: the root then loads no service constructor and no stub builder, though it still carries the startup a specification runs over the services it is handed.
|
|
103
104
|
|
|
104
|
-
**Each spec entry has two runtimes.** A component test renders in a real browser, and the node entry cannot load there: it imports node builtins at load (`node:fs` and `node:child_process` among them), while the native seams (`pg`, `better-sqlite3`, `testcontainers`) are lazy peers, opened on first use. So `exports["."]`, `exports["./services"]` and `exports["./stubs"]` each carry a `browser` condition, resolving to `dist/browser/index.js`, `dist/browser/services.js` and `dist/browser/stubs.js`: the same public surface, built for a page, where every node-only name (the
|
|
105
|
+
**Each spec entry has two runtimes.** A component test renders in a real browser, and the node entry cannot load there: it imports node builtins at load (`node:fs` and `node:child_process` among them), while the native seams (`pg`, `better-sqlite3`, `testcontainers`) are lazy peers, opened on first use. So `exports["."]`, `exports["./services"]` and `exports["./stubs"]` each carry a `browser` condition, resolving to `dist/browser/index.js`, `dist/browser/services.js` and `dist/browser/stubs.js`: the same public surface, built for a page, where every node-only name (the eight constructors, `ruleTester()`, the services, the docker accessors, the results that walk a disk) is a stub that throws where it was called, naming the specification and saying it runs under node. The split is the RUNTIME's and never the specifications': one specifier per entry, so no spec has to remember which build it may import.
|
|
105
106
|
|
|
106
107
|
`types` is stated once, and it is the node build's, so the two runtimes are ONE type surface. Each page entry takes its types from its node entry (`export type *`), so they cannot drift, and `specs/cli/node/names.spec.ts`, over the packed tarball, records every build's names and fails a value a page build lacks. The check runs that one way: a page build may carry a value its node build does not export, and the names it records show which.
|
|
107
108
|
|
|
@@ -110,7 +111,7 @@ The API is the types the package publishes, and nothing re-states it. What the c
|
|
|
110
111
|
## Pitfalls
|
|
111
112
|
|
|
112
113
|
- **Reaching for a dependency from `chain/`.** A new external package belongs to the ONE slice that needs it (a notion (`stubs/`, `services/` ...) or a specification) and nowhere else. Only a library every layer truly shares sits behind the chain, in the one folder `FRAMEWORK_LAYERS` grants it to: vitest's test hooks in `paths/`; the `yaml` library and the toolchain's config types sit in `lint/kit/project/`, which the chain reaches. Anything else the chain imports is the boundary this package cannot afford to blur, and the layer map and the library-ownership rules refuse it.
|
|
113
|
-
- **Adding
|
|
114
|
+
- **Adding a ninth constructor.** The eight are the closed vocabulary the conventions, the linter and the documentation are all shaped around. A new SUBJECT to specify is a design decision, and it earns a record in [`decisions/`](../decisions/) before it earns a constructor, as `integration` did in [ADR-004](../decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-specification.md), `lint` in [ADR-015](../decisions/015-a-rule-a-project-ships-is-the-lint-specification.md) and `deployment` in [ADR-022](../decisions/022-a-running-deployment-is-the-deployment-specification.md).
|
|
114
115
|
- **Writing a rule's normative sentence into a chapter.** It belongs in `lint/consumer/catalogue/manifest.json` beside the implementation; a chapter that restates it is the second copy the whole design exists to prevent.
|
|
115
116
|
|
|
116
117
|
## Related
|
|
@@ -166,9 +166,9 @@ Two more hold what each path says. **The verb and the folder agree**: `seed()` r
|
|
|
166
166
|
|
|
167
167
|
| Thing | Rule |
|
|
168
168
|
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
169
|
-
| Specs root | `specs/` (`api/`, `job/`, `cli/`, `integration/`, `website/`, `mobile/`, `lint/`): one folder per constructor, beside the special folders of what the specifications share: `__setup__/`, `__fixtures__/`, `__seeds__/`, `__requests__/`, `__expected__/`, and any repository suite ([the third door](#the-third-door-a-repository-suite))
|
|
169
|
+
| Specs root | `specs/` (`api/`, `job/`, `cli/`, `integration/`, `website/`, `deployment/`, `mobile/`, `lint/`): one folder per constructor, beside the special folders of what the specifications share: `__setup__/`, `__fixtures__/`, `__seeds__/`, `__requests__/`, `__expected__/`, and any repository suite ([the third door](#the-third-door-a-repository-suite)) |
|
|
170
170
|
| Specification | `specs/<specification>/<name>.specification.ts` (at the specification root) |
|
|
171
|
-
| Instances | `api`, `job`, `cli`, `integration`, `website`, `mobile`, `lint`, enforced by the destructuring (`no-runner-alias`)
|
|
171
|
+
| Instances | `api`, `job`, `cli`, `integration`, `website`, `deployment`, `mobile`, `lint`, enforced by the destructuring (`no-runner-alias`) |
|
|
172
172
|
| Spec file | `specs/<specification>/<subject>/<aspect>.spec.ts`, the assembled product's word (`file-suffix-matches-place`); the subject's folder derived where the specification has one ([One file, one subject](#one-file-one-subject)) |
|
|
173
173
|
| Spec document | `<case>.spec.yaml`, beside the spec it belongs to, never under `__expected__/` ([Spec documents](../guides/spec-documents.md)) |
|
|
174
174
|
| Module test | `__tests__/<file>.test.ts`, in the folder of `<file>.ts` (under `src/`) |
|
package/docs/guides/api-specs.md
CHANGED
|
@@ -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) is a deployment probe, and this specification does not claim it: probe the running deployment with [`specification.deployment()`](deployment-specs.md).
|
|
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
|
|
|
@@ -32,7 +32,7 @@ The subject is a unit that draws itself IN A BROWSER, which leaves four neighbou
|
|
|
32
32
|
| A React island inside an `.astro` page | The island is a rendered unit; only the file around it is not | a `.test.tsx` in the `__tests__/` of the island's folder, here |
|
|
33
33
|
| A Next.js server component | It renders on the server and reads what the server reads; there is no tree to mount | a website spec, through the served page |
|
|
34
34
|
|
|
35
|
-
**What runs under node stays there.** In a page, each node-only name the entry publishes (the
|
|
35
|
+
**What runs under node stays there.** In a page, each node-only name the entry publishes (the eight constructors, the services, the docker accessors, the results that walk a disk) refuses where it is called: a page has no filesystem, no child process and no socket.
|
|
36
36
|
|
|
37
37
|
## The constructor: there is none
|
|
38
38
|
|
|
@@ -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)
|
|
@@ -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
|
|