@jterrazz/test 16.1.0 → 17.0.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 +45 -171
- package/dist/browser/index.js +2 -2867
- package/dist/browser/services.js +2 -0
- package/dist/browser/stubs.js +2 -0
- package/dist/checker.d.ts +6 -1
- package/dist/checker.js +4551 -2612
- package/dist/config.d.ts +40 -0
- package/dist/config.js +309 -0
- package/dist/in-place.global-setup.d.ts +11 -0
- package/dist/in-place.global-setup.js +21 -0
- package/dist/index.d.ts +1653 -3002
- package/dist/index.js +5226 -3845
- package/dist/mobile-session.global-setup.d.ts +14 -0
- package/dist/mobile-session.global-setup.js +31 -0
- package/dist/oxlint.cjs +6073 -5616
- package/dist/oxlint.d.cts +43 -7212
- package/dist/oxlint.d.ts +43 -7212
- package/dist/oxlint.js +6065 -5609
- package/dist/route-coverage.global-setup.d.ts +42 -0
- package/dist/route-coverage.global-setup.js +2 -0
- package/dist/services.d.ts +3 -0
- package/dist/services.global-setup.d.ts +43 -0
- package/dist/services.global-setup.js +2 -0
- package/dist/services.js +597 -0
- package/dist/shared-appium.adapter.js +428 -0
- package/dist/shared-artifacts.js +32 -0
- package/dist/shared-chapter.js +7 -0
- package/dist/shared-container.port.d.ts +31 -0
- package/dist/shared-derived-projects.js +58 -0
- package/dist/shared-device.port.d.ts +189 -0
- package/dist/shared-exchange.js +447 -0
- package/dist/shared-in-place.js +172 -0
- package/dist/shared-index.browser.js +3191 -0
- package/dist/shared-index.services.d.ts +346 -0
- package/dist/shared-index.stubs.d.ts +287 -0
- package/dist/shared-json-shape.js +17 -0
- package/dist/shared-landmarks.js +71 -0
- package/dist/shared-ledger.js +68 -0
- package/dist/shared-literate-plugin.js +183 -0
- package/dist/shared-manifest.js +1321 -0
- package/dist/shared-pause.js +11 -0
- package/dist/shared-peer.js +88 -0
- package/dist/shared-playwright.adapter.js +330 -0
- package/dist/shared-process.adapter.js +74 -0
- package/dist/shared-refusal.js +35 -0
- package/dist/shared-resolve.js +24 -0
- package/dist/shared-route-coverage.global-setup.js +187 -0
- package/dist/shared-scenario-failure.js +198 -0
- package/dist/shared-serve.js +365 -0
- package/dist/shared-service-images.js +10 -0
- package/dist/shared-services.global-setup.js +179 -0
- package/dist/shared-session-ledger.js +301 -0
- package/dist/shared-setup-entry.js +12 -0
- package/dist/shared-source-scan.js +133 -0
- package/dist/shared-spec-document.js +365 -0
- package/dist/shared-specifications.js +26 -0
- package/dist/shared-stream-body.js +130 -0
- package/dist/shared-stub-server.js +190 -0
- package/dist/shared-temp-root.global-setup.js +80 -0
- package/dist/shared-test-baseline.js +26 -0
- package/dist/shared-test-section.js +58 -0
- package/dist/shared-testcontainers.adapter.js +203 -0
- package/dist/shared-tokens.js +55 -0
- package/dist/shared-yaml-document.js +119 -0
- package/dist/stubs.d.ts +2 -0
- package/dist/stubs.js +657 -0
- package/dist/temp-root.global-setup.d.ts +7 -0
- package/dist/temp-root.global-setup.js +2 -0
- package/dist/vitest.d.ts +97 -163
- package/dist/vitest.js +374 -507
- package/docs/README.md +82 -36
- package/docs/concepts/architecture.md +118 -0
- package/docs/concepts/conventions.md +333 -0
- package/docs/guides/api-specs.md +374 -0
- package/docs/guides/cli-specs.md +564 -0
- package/docs/guides/component-specs.md +252 -0
- package/docs/guides/develop.md +94 -0
- package/docs/guides/integration-specs.md +181 -0
- package/docs/guides/job-specs.md +180 -0
- package/docs/guides/lint-specs.md +174 -0
- package/docs/guides/mobile-specs.md +319 -0
- package/docs/guides/module-tests.md +149 -0
- package/docs/guides/release.md +43 -0
- package/docs/guides/spec-documents.md +189 -0
- package/docs/guides/website-specs.md +413 -0
- package/docs/reference/assertions.md +345 -0
- package/docs/reference/glossary.md +20 -0
- package/docs/reference/linting.md +483 -0
- package/docs/reference/services.md +236 -0
- package/docs/reference/stubs.md +498 -0
- package/docs/reference/time-and-doubles.md +137 -0
- package/docs/reference/tokens.md +258 -0
- package/docs/reference/visitor.md +221 -0
- package/docs/start/getting-started.md +187 -0
- package/docs/start/set-up-a-project.md +214 -0
- package/package.json +50 -30
- package/schema/spec.schema.json +43 -17
- package/dist/appium.adapter.js +0 -489
- package/dist/artifacts.js +0 -32
- package/dist/fixtures.js +0 -110
- package/dist/literate.js +0 -1747
- package/dist/match.js +0 -804
- package/dist/playwright.adapter.js +0 -349
- package/dist/server.js +0 -160
- package/dist/stream.js +0 -321
- package/dist/substring-warning.js +0 -244
- package/docs/01-architecture.md +0 -95
- package/docs/02-developing.md +0 -351
- package/docs/03-testing.md +0 -495
- package/docs/04-operating.md +0 -101
- package/docs/05-module-tests.md +0 -152
- package/docs/06-integration.md +0 -180
- package/docs/07-component.md +0 -246
- package/docs/08-website.md +0 -404
- package/docs/09-mobile.md +0 -270
- package/docs/10-api.md +0 -264
- package/docs/11-jobs.md +0 -157
- package/docs/12-cli.md +0 -677
- package/docs/13-elements.md +0 -242
- package/docs/14-assertions.md +0 -314
- package/docs/15-tokens.md +0 -253
- package/docs/16-contracts.md +0 -438
- package/docs/17-services.md +0 -188
- package/docs/18-conventions.md +0 -305
- package/docs/19-linting.md +0 -510
- package/docs/decisions/001-v9-conventions-on-three-channels.md +0 -50
- package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +0 -49
- package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +0 -84
- package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +0 -81
- package/docs/decisions/005-the-doubles-ladder-is-closed.md +0 -86
- package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +0 -245
- package/docs/decisions/007-compose-mode-leaves-the-package.md +0 -53
- package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +0 -75
- package/docs/decisions/_template.md +0 -22
- package/docs/reference/classes/Matcher.md +0 -120
- package/docs/reference/classes/ProcessHandle.md +0 -368
- package/docs/reference/functions/defineContract.md +0 -39
- package/docs/reference/functions/defineContracts.md +0 -36
- package/docs/reference/functions/disabled.md +0 -28
- package/docs/reference/functions/enabled.md +0 -20
- package/docs/reference/functions/focused.md +0 -26
- package/docs/reference/functions/mockOf.md +0 -79
- package/docs/reference/functions/postgres.md +0 -31
- package/docs/reference/functions/process.md +0 -33
- package/docs/reference/functions/redis.md +0 -28
- package/docs/reference/functions/required.md +0 -41
- package/docs/reference/functions/selected.md +0 -26
- package/docs/reference/functions/sqlite.md +0 -33
- package/docs/reference/functions/testId.md +0 -20
- package/docs/reference/functions/text.md +0 -40
- package/docs/reference/functions/valued.md +0 -28
- package/docs/reference/functions/waitUntil.md +0 -46
- package/docs/reference/functions/within.md +0 -29
- package/docs/reference/index.md +0 -196
- package/docs/reference/interfaces/BaseResult.md +0 -87
- package/docs/reference/interfaces/CallResult.md +0 -160
- package/docs/reference/interfaces/CaptureScope.md +0 -82
- package/docs/reference/interfaces/CliResult.md +0 -253
- package/docs/reference/interfaces/ContainerAccessor.md +0 -166
- package/docs/reference/interfaces/DirectoryAccessor.md +0 -33
- package/docs/reference/interfaces/FetchResult.md +0 -180
- package/docs/reference/interfaces/FilesystemAccessor.md +0 -46
- package/docs/reference/interfaces/HttpResult.md +0 -126
- package/docs/reference/interfaces/JsonAccessor.md +0 -46
- package/docs/reference/interfaces/PageResult.md +0 -356
- package/docs/reference/interfaces/RenderResult.md +0 -72
- package/docs/reference/interfaces/RenderedText.md +0 -110
- package/docs/reference/interfaces/ResponseAccessor.md +0 -47
- package/docs/reference/interfaces/ScreenResult.md +0 -128
- package/docs/reference/interfaces/TableAccessor.md +0 -26
- package/docs/reference/interfaces/TextAccessor.md +0 -96
- package/docs/reference/type-aliases/ApiHandle.md +0 -68
- package/docs/reference/type-aliases/ApiSpecification.md +0 -213
- package/docs/reference/type-aliases/ApiSpecificationOptions.md +0 -68
- package/docs/reference/type-aliases/BrowserConsoleMessage.md +0 -30
- package/docs/reference/type-aliases/BrowserLinkElement.md +0 -50
- package/docs/reference/type-aliases/BrowserMetaElement.md +0 -40
- package/docs/reference/type-aliases/BrowserOpenOptions.md +0 -87
- package/docs/reference/type-aliases/BrowserPage.md +0 -132
- package/docs/reference/type-aliases/BrowserPort.md +0 -51
- package/docs/reference/type-aliases/CliEnv.md +0 -11
- package/docs/reference/type-aliases/CliHandle.md +0 -80
- package/docs/reference/type-aliases/CliInput.md +0 -36
- package/docs/reference/type-aliases/CliOutput.md +0 -46
- package/docs/reference/type-aliases/CliPort.md +0 -58
- package/docs/reference/type-aliases/CliSpecification.md +0 -144
- package/docs/reference/type-aliases/CliSpecificationOptions.md +0 -134
- package/docs/reference/type-aliases/ComponentChain.md +0 -156
- package/docs/reference/type-aliases/ComponentScenario.md +0 -20
- package/docs/reference/type-aliases/ComponentUi.md +0 -47
- package/docs/reference/type-aliases/ComponentVisitor.md +0 -10
- package/docs/reference/type-aliases/ContainerPort.md +0 -113
- package/docs/reference/type-aliases/Contract.md +0 -68
- package/docs/reference/type-aliases/ContractInput.md +0 -13
- package/docs/reference/type-aliases/ContractRequest.md +0 -95
- package/docs/reference/type-aliases/ContractResponder.md +0 -23
- package/docs/reference/type-aliases/ContractResponse.md +0 -78
- package/docs/reference/type-aliases/ContractResponseValue.md +0 -13
- package/docs/reference/type-aliases/Contracts.md +0 -48
- package/docs/reference/type-aliases/DatabaseKeys.md +0 -16
- package/docs/reference/type-aliases/DatabasePort.md +0 -72
- package/docs/reference/type-aliases/DeviceOpenOptions.md +0 -46
- package/docs/reference/type-aliases/DevicePort.md +0 -51
- package/docs/reference/type-aliases/DeviceScreen.md +0 -32
- package/docs/reference/type-aliases/DeviceTimeouts.md +0 -39
- package/docs/reference/type-aliases/DockerSpecConfig.md +0 -43
- package/docs/reference/type-aliases/DomMount.md +0 -21
- package/docs/reference/type-aliases/ElementKind.md +0 -27
- package/docs/reference/type-aliases/ElementMatch.md +0 -74
- package/docs/reference/type-aliases/ElementOptions.md +0 -29
- package/docs/reference/type-aliases/ElementRef.md +0 -128
- package/docs/reference/type-aliases/ExecOptions.md +0 -37
- package/docs/reference/type-aliases/FileAccessor.md +0 -54
- package/docs/reference/type-aliases/HonoApp.md +0 -31
- package/docs/reference/type-aliases/HttpContractFilter.md +0 -49
- package/docs/reference/type-aliases/HttpResponseInit.md +0 -46
- package/docs/reference/type-aliases/HttpStreamInit.md +0 -58
- package/docs/reference/type-aliases/IntegrationHandle.md +0 -46
- package/docs/reference/type-aliases/IntegrationSpecification.md +0 -111
- package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +0 -42
- package/docs/reference/type-aliases/Intercept.md +0 -19
- package/docs/reference/type-aliases/InterceptOptions.md +0 -28
- package/docs/reference/type-aliases/InterceptScope.md +0 -11
- package/docs/reference/type-aliases/IsolationStrategy.md +0 -75
- package/docs/reference/type-aliases/JobHandle.md +0 -34
- package/docs/reference/type-aliases/JobsHandle.md +0 -57
- package/docs/reference/type-aliases/JobsResult.md +0 -16
- package/docs/reference/type-aliases/JobsSpecification.md +0 -94
- package/docs/reference/type-aliases/JobsSpecificationOptions.md +0 -56
- package/docs/reference/type-aliases/LandmarkKind.md +0 -20
- package/docs/reference/type-aliases/LiterateRunFlags.md +0 -27
- package/docs/reference/type-aliases/LiterateServeRegistration.md +0 -70
- package/docs/reference/type-aliases/MatchFixtureOptions.md +0 -27
- package/docs/reference/type-aliases/MatchableRequest.md +0 -61
- package/docs/reference/type-aliases/MatcherKind.md +0 -34
- package/docs/reference/type-aliases/MobileBackendOptions.md +0 -28
- package/docs/reference/type-aliases/MobileElementKind.md +0 -14
- package/docs/reference/type-aliases/MobileElementMatch.md +0 -60
- package/docs/reference/type-aliases/MobileElementRef.md +0 -20
- package/docs/reference/type-aliases/MobileHandle.md +0 -65
- package/docs/reference/type-aliases/MobileScenario.md +0 -20
- package/docs/reference/type-aliases/MobileSpecification.md +0 -51
- package/docs/reference/type-aliases/MobileSpecificationOptions.md +0 -126
- package/docs/reference/type-aliases/MobileVisitor.md +0 -81
- package/docs/reference/type-aliases/MockOfOptions.md +0 -26
- package/docs/reference/type-aliases/MockPort.md +0 -10
- package/docs/reference/type-aliases/PinnedClock.md +0 -10
- package/docs/reference/type-aliases/PostgresOptions.md +0 -32
- package/docs/reference/type-aliases/ProcessOptions.md +0 -112
- package/docs/reference/type-aliases/RedisOptions.md +0 -20
- package/docs/reference/type-aliases/RenderSubject.md +0 -12
- package/docs/reference/type-aliases/ScreenNode.md +0 -67
- package/docs/reference/type-aliases/ServerPort.md +0 -36
- package/docs/reference/type-aliases/ServerResponse.md +0 -46
- package/docs/reference/type-aliases/ServerSpec.md +0 -21
- package/docs/reference/type-aliases/ServiceHandle.md +0 -216
- package/docs/reference/type-aliases/ServiceRecord.md +0 -12
- package/docs/reference/type-aliases/SpecDocument.md +0 -84
- package/docs/reference/type-aliases/SpecEnvToken.md +0 -21
- package/docs/reference/type-aliases/SpecFileAssertion.md +0 -27
- package/docs/reference/type-aliases/SpecFixture.md +0 -30
- package/docs/reference/type-aliases/SpecKind.md +0 -8
- package/docs/reference/type-aliases/SpecRun.md +0 -108
- package/docs/reference/type-aliases/SpecServeEntry.md +0 -40
- package/docs/reference/type-aliases/SpecStream.md +0 -32
- package/docs/reference/type-aliases/SpecificationConfig.md +0 -290
- package/docs/reference/type-aliases/SqliteOptions.md +0 -36
- package/docs/reference/type-aliases/SseEvent.md +0 -58
- package/docs/reference/type-aliases/StreamBody.md +0 -61
- package/docs/reference/type-aliases/TextFilter.md +0 -22
- package/docs/reference/type-aliases/VisitScenario.md +0 -20
- package/docs/reference/type-aliases/Visitor.md +0 -215
- package/docs/reference/type-aliases/WaitUntilOptions.md +0 -46
- package/docs/reference/type-aliases/WebsiteBackendOptions.md +0 -37
- package/docs/reference/type-aliases/WebsiteHandle.md +0 -51
- package/docs/reference/type-aliases/WebsiteSpecification.md +0 -118
- package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +0 -40
- package/docs/reference/variables/anthropic.md +0 -19
- package/docs/reference/variables/banner.md +0 -10
- package/docs/reference/variables/button.md +0 -10
- package/docs/reference/variables/clock.md +0 -16
- package/docs/reference/variables/complementary.md +0 -10
- package/docs/reference/variables/component.md +0 -10
- package/docs/reference/variables/content.md +0 -10
- package/docs/reference/variables/contentinfo.md +0 -10
- package/docs/reference/variables/dialog.md +0 -10
- package/docs/reference/variables/field.md +0 -10
- package/docs/reference/variables/form.md +0 -10
- package/docs/reference/variables/heading.md +0 -10
- package/docs/reference/variables/http.md +0 -40
- package/docs/reference/variables/intercept.md +0 -11
- package/docs/reference/variables/link.md +0 -10
- package/docs/reference/variables/listitem.md +0 -10
- package/docs/reference/variables/main.md +0 -10
- package/docs/reference/variables/match.md +0 -8
- package/docs/reference/variables/navigation.md +0 -10
- package/docs/reference/variables/openai.md +0 -21
- package/docs/reference/variables/option.md +0 -17
- package/docs/reference/variables/region.md +0 -10
- package/docs/reference/variables/row.md +0 -10
- package/docs/reference/variables/search.md +0 -10
- package/docs/reference/variables/specification.md +0 -8
- package/docs/reference/variables/status.md +0 -10
- package/docs/reference/variables/table.md +0 -10
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# API specs (`specification.api`)
|
|
2
|
+
|
|
3
|
+
`specification.api()` tests an HTTP API through real requests. Your app runs **in this process**, built by the `server` factory from the real services the runner started, so a request reaches it without a socket, and a stub, a clock and a golden all mean something because the app shares this process's world.
|
|
4
|
+
|
|
5
|
+
Use it when the subject under test is an HTTP surface. For background pipelines use [job](job-specs.md); for binaries use [cli](cli-specs.md).
|
|
6
|
+
|
|
7
|
+
## What it specifies
|
|
8
|
+
|
|
9
|
+
An api spec answers one question: **given this state and this request, what does the HTTP app answer, and what does it leave behind?** The subject is the assembled app met through its own entry (the handler chain, the middleware, the serialisation, the database writes), and the request is a complete HTTP exchange, written as a file a person can read. A single handler function called directly is a module test's ([Module tests](module-tests.md)); a repository against a real database with no HTTP in sight is an integration spec's ([Integration specs](integration-specs.md)).
|
|
10
|
+
|
|
11
|
+
### The app runs in THIS process
|
|
12
|
+
|
|
13
|
+
| Aspect | What it means |
|
|
14
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
15
|
+
| Your app | In-process, built by `server(services)`; no container, no socket |
|
|
16
|
+
| Real services | Containers for `postgres()` and `redis()`, a file copy for `sqlite()`, a child process for `process()` |
|
|
17
|
+
| What it proves | Application logic against real databases, caches and processes |
|
|
18
|
+
| Parallel isolation | Per-worker schema / db-index / file copy (rule `state-isolated-per-worker`) |
|
|
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.
|
|
21
|
+
|
|
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
|
+
|
|
24
|
+
## The constructor
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
// specs/api/api.specification.ts
|
|
28
|
+
import { afterAll } from 'vitest';
|
|
29
|
+
import { specification } from '@jterrazz/test';
|
|
30
|
+
import { postgres, redis } from '@jterrazz/test/services';
|
|
31
|
+
import { compose } from '../../src/composition/compose.js';
|
|
32
|
+
import type { App } from '../../src/composition/compose.js';
|
|
33
|
+
import { testConfig } from './__setup__/config.js';
|
|
34
|
+
import { idleMailer } from './__setup__/mailer.js';
|
|
35
|
+
|
|
36
|
+
let app: App | undefined;
|
|
37
|
+
|
|
38
|
+
export const { api, cleanup } = await specification.api({
|
|
39
|
+
services: {
|
|
40
|
+
db: postgres(), // → reported as "db", init from docker/db/
|
|
41
|
+
analyticsDb: postgres(), // → "analytics-db", init from docker/analytics-db/
|
|
42
|
+
cache: redis(), // → "cache"
|
|
43
|
+
},
|
|
44
|
+
// testConfig points the app's configuration at each started service
|
|
45
|
+
server: (services) => {
|
|
46
|
+
app = compose(testConfig(services), { mailer: idleMailer });
|
|
47
|
+
return app.server;
|
|
48
|
+
},
|
|
49
|
+
// root: absent, so auto-discovery (see below)
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await app?.[Symbol.asyncDispose]();
|
|
54
|
+
await cleanup();
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### How the runner builds the app
|
|
59
|
+
|
|
60
|
+
`server` builds the app the way its process entry does: through the composition root, `compose(config, overrides)`. The runner hands it a configuration pointing at the started services, and, by name, the ports a spec must not reach for real (an agent, a mailer, a clock-driven worker), each a typed member of `Partial<Ports>`, so a wrong double fails the type check rather than a run. It hands the specification the part a request meets, the server, and keeps the app: the specification stops the services it started, never the app built on them, so the runner's `afterAll` disposes the app (the database connections and the workers it built), then stops any double it handed in that runs, which the app's dispose leaves to its caller, before `cleanup` stops the services under it. No container is looked up and nothing is cast: what `compose()` is and why the hexagonal map lets only the entry and the runners import it are `@jterrazz/typescript`'s [The composition root](https://github.com/jterrazz/package-typescript/blob/main/docs/concepts/the-composition-root.md).
|
|
61
|
+
|
|
62
|
+
### Options
|
|
63
|
+
|
|
64
|
+
| Option | Required | Description |
|
|
65
|
+
| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
66
|
+
| `services` | yes (if the app needs infra) | Named record of service factories (`postgres()`, `redis()`, `sqlite()`). Keys are your test vocabulary; see [services](../reference/services.md) |
|
|
67
|
+
| `server` | yes | `(services) => app`: receives the started services record, fully typed (rule `server-sees-declared-services`) |
|
|
68
|
+
| `root` | no | Override for root resolution, reserved for cases where the convention is not enough (rule `no-redundant-root`) |
|
|
69
|
+
|
|
70
|
+
### Root resolution
|
|
71
|
+
|
|
72
|
+
Without `root`, the framework finds the package being tested by walking up from the specification file. The walk, the tree it reads and when a `root` is redundant are [Services § Root auto-discovery](../reference/services.md#root-auto-discovery)'s.
|
|
73
|
+
|
|
74
|
+
## The chain
|
|
75
|
+
|
|
76
|
+
### Setups (chainable)
|
|
77
|
+
|
|
78
|
+
| Setup | Description |
|
|
79
|
+
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `.seed(file('./__seeds__/file.sql'))` | Load that `.sql` file, named by its path from the spec file, beside the test that reads it, or at the level of the tests that share it (rule `ground-shared-or-inline`) |
|
|
81
|
+
| `.seed(file('./__seeds__/file.sql'), { database })` | Target a database by its record key ([Services § The `database:` rule](../reference/services.md#the-database-rule)) |
|
|
82
|
+
| `.headers({ 'Name': 'value' })` | Set request headers; repeated calls merge |
|
|
83
|
+
| `.intercept(stub)` | Mock an outgoing HTTP call with a declared [stub](../reference/stubs.md) |
|
|
84
|
+
| `.intercept(trigger, response)` | Inline intercept for one-off cases |
|
|
85
|
+
| `.clock('2026-03-04T09:30:00Z')` | Pin the app's `Date` for this chain, released when the action resolves ([Time and doubles](../reference/time-and-doubles.md#the-clock)) |
|
|
86
|
+
| `api.with({ clock, headers })` | A view of the specification whose every chain starts with what the whole file shares ([Conventions](../concepts/conventions.md#what-a-file-shares)) |
|
|
87
|
+
|
|
88
|
+
Stubs are **strict** (rule `network-strict-after-first-stub`): once a chain declares one, an outgoing request no stub answers fails the spec ([Stubs § Strict by construction](../reference/stubs.md#strict-by-construction)). `.intercept()` and `.clock()` both work because the app runs in THIS process: msw intercepts its outgoing requests, and the pinned `Date` is the one it reads.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
describe('language', () => {
|
|
92
|
+
test('serves french content', async () => {
|
|
93
|
+
// Given - headers inline
|
|
94
|
+
const result = await api.headers({ 'Accept-Language': 'fr' }).get('/welcome');
|
|
95
|
+
|
|
96
|
+
// Then - the whole response
|
|
97
|
+
expect(result.response).toMatch(`
|
|
98
|
+
HTTP/1.1 200 OK
|
|
99
|
+
content-type: application/json
|
|
100
|
+
|
|
101
|
+
{"message": "Bienvenue"}
|
|
102
|
+
`);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### Isolation between specs (rules `one-terminal-action-per-chain`, `sequence-expressed-by-seeding`)
|
|
108
|
+
|
|
109
|
+
Databases are **reset at the start of every chain**. A spec never depends on a previous spec, and there is no "flow" mode: sequential scenarios are expressed through seeds:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
describe('isolation', () => {
|
|
113
|
+
test('starts clean between specs', async () => {
|
|
114
|
+
// Given - nothing (every chain resets the databases: one spec = ONE action)
|
|
115
|
+
const result = await api.get('/orders');
|
|
116
|
+
|
|
117
|
+
// Then
|
|
118
|
+
await expect(result.table('orders', { database: 'db' })).toBeEmpty();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Actions (terminal)
|
|
124
|
+
|
|
125
|
+
Exactly one per chain (rule `one-terminal-action-per-chain`). Each executes the spec and resolves to the result.
|
|
126
|
+
|
|
127
|
+
| Action | Description |
|
|
128
|
+
| -------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
129
|
+
| ``.request(`POST /users ...`)`` | Execute the complete request written in the test, `.http` text, dedented |
|
|
130
|
+
| `.request(file('./__requests__/file.http'))` | Execute the complete request that `.http` file describes, when several tests send it |
|
|
131
|
+
| `.get(path, { params }?)` | Inline GET, for simple cases where a file would be excessive |
|
|
132
|
+
| `.post(path, body?, { params }?)` | Inline POST |
|
|
133
|
+
| `.put(path, body?, { params }?)` | Inline PUT |
|
|
134
|
+
| `.delete(path, { params }?)` | Inline DELETE |
|
|
135
|
+
|
|
136
|
+
The `body?` of `.post()` / `.put()` is a plain object, JSON-serialized with a `Content-Type: application/json` default header, or a body file from `__requests__/`, `.post(path, file('./__requests__/order.json'))`: a `.json` is parsed and sent as that value, any other extension is sent as its text. A complete request (method, headers and body) goes through `.request(file('./__requests__/file.http'))`, whose body section is sent **raw**: its surrounding whitespace is trimmed, but everything between is preserved byte-for-byte (interior double spaces, indentation, and non-JSON text are intact).
|
|
137
|
+
|
|
138
|
+
A route with parameters is written as the app declares it, its values beside it: `.get('/indicators/:symbol/chart', { params: { symbol: 'btc' } })`. The template types the params (every `:name` it holds takes a value, and no other name does: rule `route-written-as-template`), and the chain sends the path filled, each value URI-encoded, the query kept. The template is what the test's reader, the subject reader (`one-subject-per-spec`, `spec-in-subject-folder`) and route coverage read, so none of them has to guess that `btc` is a value. A path with no parameter takes no options, or `{ params: {} }` to state that it is the route itself.
|
|
139
|
+
|
|
140
|
+
There is no label argument: the vitest test name is the spec's only description (rule `no-chain-label`).
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
test('returns 404 with a useful body', async () => {
|
|
144
|
+
// Given - empty database
|
|
145
|
+
const result = await api.get('/users/999');
|
|
146
|
+
|
|
147
|
+
// Then - the whole response, written in place
|
|
148
|
+
expect(result.response).toMatch(`
|
|
149
|
+
HTTP/1.1 404 Not Found
|
|
150
|
+
|
|
151
|
+
{"error": "User 999 not found"}
|
|
152
|
+
`);
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### `.http` request files: full format
|
|
157
|
+
|
|
158
|
+
Requests live in `__requests__/`, one file per request, extension `.http`; the body files a verb sends live beside them, each named with its extension (rule `request-file-extension-known`). A request file is the **complete** request: method + path on the first line, then headers, then a blank line, then the body.
|
|
159
|
+
|
|
160
|
+
```http
|
|
161
|
+
### __requests__/create-user.http
|
|
162
|
+
POST /users
|
|
163
|
+
Content-Type: application/json
|
|
164
|
+
Accept-Language: fr
|
|
165
|
+
|
|
166
|
+
{ "name": "Alice" }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Format rules:
|
|
170
|
+
|
|
171
|
+
- First line: `METHOD /path`; the file must start with it (rule `http-first-line-follows-its-grammar`). The path is relative to the app under test.
|
|
172
|
+
- Header lines: `Name: value`, one per line, immediately after the request line.
|
|
173
|
+
- Blank line, then the body (optional; a GET usually has none).
|
|
174
|
+
- Executed with `api.request(file('./__requests__/create-user.http'))`. The argument is the file's path from the spec file, spelled in full; the type refuses a bare name (rule `disk-named-by-relative-path`).
|
|
175
|
+
|
|
176
|
+
Expected responses live in `__expected__/`, like every other golden, same extension, and start with a status line (rule `expected-headers-are-a-subset`):
|
|
177
|
+
|
|
178
|
+
```http
|
|
179
|
+
### __expected__/user-created.http
|
|
180
|
+
HTTP/1.1 201 Created
|
|
181
|
+
Content-Type: application/json
|
|
182
|
+
Location: /users/{{uuid#user}}
|
|
183
|
+
|
|
184
|
+
{ "id": "{{uuid#user}}", "name": "Alice" }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- First line: `HTTP/1.1 <status>`, mandatory.
|
|
188
|
+
- Headers are matched as a **subset**: listed headers must match, unlisted headers are unconstrained (rule `expected-headers-are-a-subset`). A header name compares case-blind, as HTTP reads it, so `Content-Type` and `content-type` are one header; its value compares as written.
|
|
189
|
+
- Body and headers both accept `{{token}}` placeholders, including `#ref` captures: `{{uuid#user}}` above must be the _same_ UUID in the `Location` header and the body. See [tokens](../reference/tokens.md).
|
|
190
|
+
- The body is read by its `content-type` (the file's own, or the actual response's when the file states none), with its surrounding whitespace trimmed on both sides. A text type (`text/*`, `+xml`, `application/xml`, `application/x-ndjson`, ...) is the text it is, even a `42` or a `true`: an HTML page, an event stream, a plain message. A binary type (`audio/*`, `font/*`, `image/*`, `video/*`, and under `application/` exactly `gzip`, `octet-stream`, `pdf`, `protobuf`, `vnd.google.protobuf`, `wasm`, `x-gzip`, `x-protobuf`, `x-tar` and `zip`) is not read: its body is `null`, so its `.http` states the status and the headers and no body. Any other type (a JSON type, one the lists above do not name such as `application/graphql` or `multipart/*`, or no type at all) is the JSON value its text parses to, or its text when it does not parse.
|
|
191
|
+
|
|
192
|
+
## The result
|
|
193
|
+
|
|
194
|
+
The result of an API action exposes read-only accessors (rule `result-accessor-read-only`); all assertions go through `expect()`:
|
|
195
|
+
|
|
196
|
+
| Member | Type | Description |
|
|
197
|
+
| --------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
198
|
+
| `result.status` | `number` | HTTP status code; assert with native `expect(...).toBe(...)` |
|
|
199
|
+
| `result.response` | response subject | Full response (status + headers + body), compared whole, `toMatch(<.http text>)` or `toMatch(file(...))` |
|
|
200
|
+
| `result.response.body` | body | The JSON value, or the text of a text body or one that is not JSON; `null` when empty or binary; for native assertions |
|
|
201
|
+
| `result.table(name, opts?)` | table subject | Database table, compared whole, `toMatch(<rows>)` / `toBeEmpty()` (async, `await expect`) |
|
|
202
|
+
|
|
203
|
+
`expect(result.response).toMatch(file('./__expected__/user-created.http'))` reads the path it is given, like every other golden matcher (rule `golden-read-from-its-path`): nothing resolves against a folder the line does not name. The full matcher reference is in [assertions](../reference/assertions.md).
|
|
204
|
+
|
|
205
|
+
Beyond the result, the `specification.api()` handle destructures to `{ api, cleanup, docker }`. The `docker(containerId)` reader lazily runs `docker inspect` and returns a `ContainerAccessor` for an arbitrary container id, usable with `await expect(docker(id)).toBeRunning()` and the sync read accessors (`.exists`, `.status`, `.file(path)`, logs). An unknown id yields `exists: false` instead of throwing. (`specification.job()` has no `docker` member; jobs never spawn containers.)
|
|
206
|
+
|
|
207
|
+
## Unique here
|
|
208
|
+
|
|
209
|
+
### Full example: a multi-database order flow
|
|
210
|
+
|
|
211
|
+
```http
|
|
212
|
+
### __requests__/new-order.http
|
|
213
|
+
POST /orders
|
|
214
|
+
Content-Type: application/json
|
|
215
|
+
|
|
216
|
+
{ "sku": "KB-42", "quantity": 1 }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```http
|
|
220
|
+
### __expected__/order-created.http
|
|
221
|
+
HTTP/1.1 201 Created
|
|
222
|
+
Content-Type: application/json
|
|
223
|
+
Location: /orders/{{uuid#order}}
|
|
224
|
+
|
|
225
|
+
{
|
|
226
|
+
"id": "{{uuid#order}}",
|
|
227
|
+
"status": "pending",
|
|
228
|
+
"total": "{{number}}",
|
|
229
|
+
"createdAt": "{{iso8601}}",
|
|
230
|
+
"paymentIntent": { "orderId": "{{uuid#order}}", "expiresAt": "{{iso8601}}" }
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// specs/api/orders/create/analytics.spec.ts: POST /orders, what it writes where
|
|
236
|
+
import { describe, expect, test } from 'vitest';
|
|
237
|
+
import { file, match } from '@jterrazz/test';
|
|
238
|
+
import { api } from '../../api.specification.js';
|
|
239
|
+
|
|
240
|
+
describe('analytics', () => {
|
|
241
|
+
test('ingests an order event into analytics', async () => {
|
|
242
|
+
// Given - catalog in the main database, analytics empty
|
|
243
|
+
const result = await api
|
|
244
|
+
.seed(file('./__seeds__/catalog.sql'), { database: 'db' })
|
|
245
|
+
.request(file('./__requests__/new-order.http'));
|
|
246
|
+
|
|
247
|
+
// Then - one row per database, targeted by record key
|
|
248
|
+
await expect(result.table('orders', { database: 'db' })).toMatch([
|
|
249
|
+
['{{uuid}}', 'pending', '{{number}}', '{{iso8601}}'],
|
|
250
|
+
]);
|
|
251
|
+
await expect(result.table('events', { database: 'analyticsDb' })).toMatch([
|
|
252
|
+
[1, '{{uuid}}', 'order_created'],
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('returns the created order with consistent ids', async () => {
|
|
257
|
+
// Given
|
|
258
|
+
const result = await api
|
|
259
|
+
.seed(file('./__seeds__/catalog.sql'), { database: 'db' })
|
|
260
|
+
.request(file('./__requests__/new-order.http'));
|
|
261
|
+
|
|
262
|
+
// Then - {{uuid#order}} appears 3 times (Location header included), all equal
|
|
263
|
+
expect(result.response).toMatch(file('./__expected__/order-created.http'));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('links the analytics event to the created order', async () => {
|
|
267
|
+
// Given
|
|
268
|
+
const result = await api
|
|
269
|
+
.seed(file('./__seeds__/catalog.sql'), { database: 'db' })
|
|
270
|
+
.request(file('./__requests__/new-order.http'));
|
|
271
|
+
|
|
272
|
+
// Then - same id on both sides, without ever knowing its value
|
|
273
|
+
await expect(result.table('orders', { database: 'db' })).toMatch([
|
|
274
|
+
[match.ref('order'), 'pending', '{{number}}', '{{iso8601}}'],
|
|
275
|
+
]);
|
|
276
|
+
await expect(result.table('events', { database: 'analyticsDb' })).toMatch([
|
|
277
|
+
[1, match.ref('order'), 'order_created'],
|
|
278
|
+
]);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
> This project declares two databases, so every `.seed()` and `.table()` names one ([Services § The `database:` rule](../reference/services.md#the-database-rule)).
|
|
284
|
+
|
|
285
|
+
### A case, written whole
|
|
286
|
+
|
|
287
|
+
A case is a TypeScript spec whose oracle is the WHOLE result, written where the test reads it: the given is the chain's setups (a seed is always a `.sql` file named with `file()`, beside the test when it alone reads it), the action is the request, and the response is compared whole to its `.http` text in place, tokens allowed ([Assertions](../reference/assertions.md#one-verb-compares-a-whole-result-and-its-argument-says-where-the-expectation-lives)):
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
test('the users seeded are the users listed, in the order they were inserted', async () => {
|
|
291
|
+
// Given - two users, from the seed beside the test
|
|
292
|
+
const result = await api.seed(file('./__seeds__/alice-and-bob.sql')).get('/users');
|
|
293
|
+
|
|
294
|
+
// Then - the whole response, and the table it read
|
|
295
|
+
expect(result.response).toMatch(`
|
|
296
|
+
HTTP/1.1 200 OK
|
|
297
|
+
content-type: application/json
|
|
298
|
+
|
|
299
|
+
{"users": [{"name": "Alice", "email": "alice@test.com"}, {"name": "Bob", "email": "bob@test.com"}]}
|
|
300
|
+
`);
|
|
301
|
+
await expect(result.table('users')).toMatch([
|
|
302
|
+
[1, 'Alice', 'alice@test.com'],
|
|
303
|
+
[2, 'Bob', 'bob@test.com'],
|
|
304
|
+
]);
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
A journey (a request carrying what an earlier response returned) is code: read the value from the first result, send it in the next chain. `TEST_UPDATE=1` writes an empty `toMatch('')` with what came back ([Tokens](../reference/tokens.md#a-value-written-in-place-is-rewritten-in-the-test)).
|
|
309
|
+
|
|
310
|
+
### Route coverage
|
|
311
|
+
|
|
312
|
+
Line coverage says which lines of the app ran; it cannot say which routes no case asked for. The api project measures it at run time: every request a test sends is recorded, and after the last test the run names each route the app declares that no request reached. The measure is the `api()` project's global setup; a project written by hand measures nothing.
|
|
313
|
+
|
|
314
|
+
```text
|
|
315
|
+
api routes: all 12 declared routes reached
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### What is measured
|
|
319
|
+
|
|
320
|
+
- **The table is the app's own** (Hono's `app.routes`, read from the app each `specification.api()` started), so it is the table that served the tests, never a copy that can drift from it.
|
|
321
|
+
- **A middleware is left out.** An entry whose handler takes `next` is one, by Hono's own rule. A route `app.all()` declares is judged like any other, reached by a request of any method.
|
|
322
|
+
- **A path parameter** (`:id`) takes any one segment, a `*` tail the rest of the path.
|
|
323
|
+
- **A request written as a template is matched by its template**: a parameter where the route declares one, each literal segment as itself, so `.get('/users/:id', { params: { id: 'me' } })` reaches `GET /users/:id` and never a `GET /users/me` the filled path also fits.
|
|
324
|
+
|
|
325
|
+
#### The ratchet
|
|
326
|
+
|
|
327
|
+
A route no test reached fails the run, each one named. The count is a ratchet, held under `.jterrazz/baseline.json › test › routes/unreached`: `TEST_UPDATE=1` records where the run stands, and the checker's own recordings keep that key. The entry is exact in both directions: a run that reaches more routes than it held fails too, since the debt fell, until `TEST_UPDATE=1` records the lower count.
|
|
328
|
+
|
|
329
|
+
#### When a run is not judged
|
|
330
|
+
|
|
331
|
+
A run is judged, and `TEST_UPDATE=1` records, only when it is green and whole. Otherwise it prints a line and records nothing:
|
|
332
|
+
|
|
333
|
+
| The run | Prints | Why |
|
|
334
|
+
| ---------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
335
|
+
| a file of it failed | `api routes: not judged`, with the count of failed files | a failed file may not have sent its requests; `TEST_UPDATE=1` refuses and keeps the entry |
|
|
336
|
+
| a shard (`vitest --shard`) | `api routes: not judged` | its requests are a part of the run's; it writes its ledgers to `.artifacts/vitest/routes/shard-<index>-<count>/` |
|
|
337
|
+
| a merge (`vitest --merge-reports`) | nothing | a merge runs no global setup; this package's own split judges its routes after the merge, over every shard's ledgers ([Test § What CI runs](https://github.com/jterrazz/package-test/blob/main/docs/guides/test.md#what-ci-runs)) |
|
|
338
|
+
| partial: some test files (`vitest --changed`, a file filter) or tests (`-t`) | `api routes: not judged, since a partial run ...` | it reached only some routes; a shard that `-t` filters marks its ledger folder partial, so its split is not judged at the merge |
|
|
339
|
+
|
|
340
|
+
#### An app that is not Hono
|
|
341
|
+
|
|
342
|
+
A `server` that returns anything but the Hono app itself exposes no table the specification reads, and fails the run, since a measure against no table would read as every route reached. It states its routes instead, as an OpenAPI document (JSON or YAML) named in `jterrazz.config.ts`:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
// jterrazz.config.ts
|
|
346
|
+
import { defineConfig } from '@jterrazz/typescript/config';
|
|
347
|
+
|
|
348
|
+
export default defineConfig({ kind: 'api', platform: 'node', test: { routes: './openapi.yaml' } });
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Sending a request needs one more thing such an app may lack: `server` must return an object whose `request(path, init)` answers a Fetch `Response`, which is Hono's own method. Express and Fastify have none, so the project writes that wrapper, for instance one that starts the app on a free port once and forwards each call with `fetch`. Route coverage has no off switch: a project that cannot name its routes cannot run an api spec.
|
|
352
|
+
|
|
353
|
+
#### A route taken on any method
|
|
354
|
+
|
|
355
|
+
A route the app takes on any method (`app.all('/mcp', ...)`) is one the member names in `jterrazz.config.ts`, `test: { methodless: { '/mcp': 'the MCP transport answers GET and POST' } }`, so the checker files its specs under its path whatever verb they send ([Conventions § The subject, and its standard name](../concepts/conventions.md#the-subject-and-its-standard-name)). The run holds that list to the app's own table, and fails naming each mismatch, under `TEST_UPDATE=1` too, since the list is written by hand and nothing records it: the checker files the specs without running the app, so it reads the paths from the settings file, each with why the app takes it on any method:
|
|
356
|
+
|
|
357
|
+
- a route the table takes on any method that the list misses;
|
|
358
|
+
- a path the list names that the table takes on its own methods only, or holds nowhere.
|
|
359
|
+
|
|
360
|
+
The list is judged whenever the app exposes a route table of its own, an OpenAPI document declared or not; a `server` that is not the Hono app exposes none, and leaves the list unjudged.
|
|
361
|
+
|
|
362
|
+
The app runs in THIS process, always: there is no second mode, no container binding and no `TEST_MODE`. Why compose mode left, and what was weighed against keeping it: [ADR-007](https://github.com/jterrazz/package-test/blob/main/docs/decisions/007-compose-mode-leaves-the-package.md).
|
|
363
|
+
|
|
364
|
+
## Pitfalls
|
|
365
|
+
|
|
366
|
+
- **Getting `database:` wrong for the number of databases.** See [Services § The `database:` rule](../reference/services.md#the-database-rule).
|
|
367
|
+
- **Expecting unlisted response headers to be constrained.** Response `__expected__/*.http` header matching is subset-only; if a header must be _absent_, that is not expressible in the file; assert on it in code.
|
|
368
|
+
- **Chaining two actions** (`api.get(...).get(...)`) or reusing state across tests. One chain = one terminal action; databases reset per chain (rules `one-terminal-action-per-chain`, `sequence-expressed-by-seeding`).
|
|
369
|
+
- **Forgetting that `.intercept()` is strict.** After the first `.intercept()`, an outgoing request no stub answers fails the spec ([Stubs § Strict by construction](../reference/stubs.md#strict-by-construction)).
|
|
370
|
+
- **Putting the request body in the test file when it has any substance.** Requests of more than a line or two belong in `__requests__/*.http`; inline `.post()` is for trivial cases.
|
|
371
|
+
|
|
372
|
+
## Related
|
|
373
|
+
|
|
374
|
+
[Job specs](job-specs.md) · [Assertions](../reference/assertions.md) · [Tokens](../reference/tokens.md) · [Stubs](../reference/stubs.md) · [Services](../reference/services.md)
|