@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,498 @@
|
|
|
1
|
+
# Stubs: the outside world, declared
|
|
2
|
+
|
|
3
|
+
Everything the outside world replies is declared as a **stub**: a request to match and a response to serve, together in one named artifact. There is no second form: no mock format, no fixture-path string, no `.http` intercept file. Stubs are the whole vocabulary, on every specification that reaches the network: `api`, `job`, `integration`, `component`, `website` and `mobile`.
|
|
4
|
+
|
|
5
|
+
A stub states what the provider replies, and nothing runs it against the provider it stands for, so it is named for what it does, never for a verification it does not make.
|
|
6
|
+
|
|
7
|
+
## The principle
|
|
8
|
+
|
|
9
|
+
> **TypeScript is behavior. `.json` is data. `.http` is a document at the boundary of YOUR OWN api** (`__requests__/`, `__expected__/`), never a mock format.
|
|
10
|
+
|
|
11
|
+
A stub is behavior: it decides what matches and what is served, it can count, it can be required, it can compose. That belongs in TypeScript, where the type checker and the app's own builders are reachable. A 200-line payload is not behavior: it is data, and it goes to a sibling `.json` the stub imports. And `.http` keeps the one role it is good at: a complete, readable document of a request your api receives or a response your api returns.
|
|
12
|
+
|
|
13
|
+
## `defineStub`
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { defineStub, http } from '@jterrazz/test/stubs';
|
|
17
|
+
|
|
18
|
+
export default defineStub({
|
|
19
|
+
request: http.get('/articles/{{uuid}}'),
|
|
20
|
+
response: http.json({ id: 'a-1', title: 'The library opens on Sundays' }),
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The request is written as a path, so it matches that path on any origin: the app's real host, wherever its config points, does not matter. A full URL (`'https://news.example/articles/{{uuid}}'`) matches that origin alone ([§ `http.*`](#http-any-url)).
|
|
25
|
+
|
|
26
|
+
| Field | Meaning |
|
|
27
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
28
|
+
| `request` | What outgoing call this stub speaks for: a stub builder (`http.*`, `openai.*`, `anthropic.*`) |
|
|
29
|
+
| `response` | The reply: a fixed value, or `(request) => StubResponse` evaluated per served request |
|
|
30
|
+
| `times?` | How many times it may serve. **Omitted = unlimited** (a re-render or a retry replays it). `n` = exhausted after `n` serves. |
|
|
31
|
+
| `required?` | The chain FAILS unless it was actually requested: at least once, or exactly `times` times when `times` is set, zero included ([`never`](#never-a-call-that-must-not-happen)). |
|
|
32
|
+
|
|
33
|
+
### Selection
|
|
34
|
+
|
|
35
|
+
One queue serves all three deliveries. An observed request matches a stub when the method matches (`'*'` accepted), the url matches, the declared query params and headers are a **subset** of the observed ones, none of the headers it states absent is present, and the `match` predicate, if any, passes on the body.
|
|
36
|
+
|
|
37
|
+
**The first stub in list order that matches AND is not exhausted wins**, except a never stub (`required` with `times: 0`), which takes every request it matches ([`never`](#never-a-call-that-must-not-happen)). That single rule covers the two shapes you need:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { defineStubs, http } from '@jterrazz/test/stubs';
|
|
41
|
+
|
|
42
|
+
// A retry sequence: three failures, then an unlimited tail.
|
|
43
|
+
export default defineStubs(
|
|
44
|
+
{ request: http.get('/quote'), response: http.error(500), times: 3 },
|
|
45
|
+
{ request: http.get('/quote'), response: http.json({ quote: 'recovered' }) },
|
|
46
|
+
);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If every matching stub is exhausted, or nothing matches at all, the spec fails (strict; see [below](#strict-by-construction)). Under node and on the stub backend, a chain that declares **no** stub is not guarded: strictness begins with the first `.intercept()`. In a page it is ([Component specs](../guides/component-specs.md)).
|
|
50
|
+
|
|
51
|
+
### `times` and `required`: saying what you mean
|
|
52
|
+
|
|
53
|
+
`times` is how a spec asserts a **call count** without reaching for a spy: `times: 3` on a failing stub plus an unlimited success tail says "the app retries exactly three times before it succeeds". `required: true` is the other half: it turns a declaration nobody exercised into a failure:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { defineStub, http } from '@jterrazz/test/stubs';
|
|
57
|
+
|
|
58
|
+
// If the screen never asks for the 1M range, the spec fails, instead of
|
|
59
|
+
// passing green on a chart that silently rendered the default range.
|
|
60
|
+
export default defineStub({
|
|
61
|
+
request: http.get('/indicators', { query: { range: '1M' } }),
|
|
62
|
+
required: true,
|
|
63
|
+
response: http.json({ points: [] }),
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Without `required`, a stub nobody calls is invisible: the test passes, and the assertion it was supposed to protect never ran.
|
|
68
|
+
|
|
69
|
+
### `never`: a call that must not happen
|
|
70
|
+
|
|
71
|
+
The other half of a call count is zero. `http.never(request)` is the stub `required` with `times: 0`, and it states that the subject must not reach the route within the chain's terminal action, or the `intercept()` scope, it is declared for:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { http } from '@jterrazz/test/stubs';
|
|
75
|
+
import { describe, expect, test } from 'vitest';
|
|
76
|
+
|
|
77
|
+
import { api } from '../api.specification.js';
|
|
78
|
+
|
|
79
|
+
describe('preview', () => {
|
|
80
|
+
test('a preview never places the order', async () => {
|
|
81
|
+
// Given - the shop's order endpoint declared never requested
|
|
82
|
+
const result = await api
|
|
83
|
+
.intercept(http.never(http.post('https://shop.test/orders')))
|
|
84
|
+
.post('/orders/preview', { items: ['book'] });
|
|
85
|
+
|
|
86
|
+
// Then - the preview answered without reaching the shop
|
|
87
|
+
expect(result.response).toMatch(`
|
|
88
|
+
HTTP/1.1 200 OK
|
|
89
|
+
content-type: application/json
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
"items": ["book"],
|
|
93
|
+
"placed": false
|
|
94
|
+
}
|
|
95
|
+
`);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Unlike an exhausted stub, a never stub is never skipped: the first request it matches is taken by it, whatever stub follows on the same route, and the subject receives a `418` whose body names the route, a status no HTTP client retries, so an SDK's backoff neither slows the failing spec nor repeats the call in the refusal. The chain then fails at its end, naming the route and every call that reached it. `http.unreachable()` beside an empty error says the call FAILED; only `never` says it did not happen. `openai.never(request)` and `anthropic.never(request)` are the same stub, so a unit of `openai/` or `anthropic/` calls its own folder's builder. It is a stub like any other: it turns the chain's network strict, and in `.with()` it claims its route.
|
|
101
|
+
|
|
102
|
+
## `defineStubs`: it's stubs all the way down
|
|
103
|
+
|
|
104
|
+
A test imports **one** artifact: the world its feature lives in. `defineStubs` composes stubs, lists, and other composites into it, recursively, order preserved.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { defineStubs, http } from '@jterrazz/test/stubs';
|
|
108
|
+
|
|
109
|
+
const events = defineStubs({
|
|
110
|
+
request: http.get('/events'),
|
|
111
|
+
response: http.json({ items: [] }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// A composite extends a composite: same function, all the way down.
|
|
115
|
+
export default defineStubs(events, {
|
|
116
|
+
request: http.get('/articles/{{uuid}}'),
|
|
117
|
+
response: http.json({ id: 'a-1' }),
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `.with()`: replacement by route, then prepend
|
|
122
|
+
|
|
123
|
+
`.with(...)` derives a **variant** without mutating the base. Two steps, in this order:
|
|
124
|
+
|
|
125
|
+
1. Every base stub whose **route** an override claims is removed. Same route = same `method` + the same declared url pattern (string equality of the declared path, `.source` equality for a RegExp). Filters do not enter it, `without.headers` included: an override `http.get('/me', { without: { headers: ['authorization'] } })` removes every base `GET /me` stub, the authenticated one too, so an override that must keep both states both.
|
|
126
|
+
2. The overrides are **prepended**, keeping their own order.
|
|
127
|
+
|
|
128
|
+
The prepend is what makes a _more specific_ override work without deleting the generic route it lives beside: `/articles/gone-1` does not claim the route `/articles/{{uuid}}`, so the generic stub survives, but the override is checked first, so the one article that is gone answers 410 while every other article still answers 200.
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import { defineStub, defineStubs, http } from '@jterrazz/test/stubs';
|
|
132
|
+
|
|
133
|
+
const newsroom = defineStubs(
|
|
134
|
+
{ request: http.get('/events'), response: http.json({ items: [] }) },
|
|
135
|
+
{ request: http.get('/articles/{{uuid}}'), response: http.json({ id: 'a-1' }) },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
export default newsroom;
|
|
139
|
+
|
|
140
|
+
// A scenario: one article is gone, the rest of the world is unchanged.
|
|
141
|
+
export const withArticleGone = (id: string) =>
|
|
142
|
+
newsroom.with(
|
|
143
|
+
defineStub({
|
|
144
|
+
request: http.get(`/articles/${id}`),
|
|
145
|
+
response: http.error(410, { error: 'gone' }),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## The facade pattern
|
|
151
|
+
|
|
152
|
+
The file layout follows the same split: a **public facade** per feature, **internal units** under it.
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
specs/<specification>/<feature>/
|
|
156
|
+
├── <feature>.test.ts
|
|
157
|
+
├── __fixtures__/
|
|
158
|
+
│ └── events.json the payload a unit serves
|
|
159
|
+
└── __setup__/
|
|
160
|
+
└── stubs/
|
|
161
|
+
├── newsroom.stubs.ts PUBLIC: the world and its scenarios, by name
|
|
162
|
+
├── http/
|
|
163
|
+
│ ├── events.ts a unit stub
|
|
164
|
+
│ └── article-gone.ts a factory: (id) => Stub
|
|
165
|
+
├── openai/
|
|
166
|
+
│ ├── classify-article.ts
|
|
167
|
+
│ └── classify-article.request.ts the exact prompt it matches
|
|
168
|
+
└── anthropic/
|
|
169
|
+
└── draft-reply.ts
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Stubs are TypeScript that prepares specs, so their root sits in a `__setup__/`, the one of the lowest folder the specs using them share ([Conventions § The naming recap](../concepts/conventions.md#the-naming-recap)). A payload is data, and `__setup__/` holds code alone: it sits in the `__fixtures__/` beside that `__setup__/`, placed like any ground.
|
|
173
|
+
|
|
174
|
+
The rules, enforced by [`stubs-root-holds-facades-and-kinds`](linting.md#stubs-root-holds-facades-and-kinds), [`stubs-imported-through-facade`](linting.md#stubs-imported-through-facade) and [`stub-data-has-a-builder`](linting.md#stub-data-has-a-builder):
|
|
175
|
+
|
|
176
|
+
- The **root** holds only `*.stubs.ts` facades and one folder per stub **kind**: a builder the package exports: `http`, `openai`, `anthropic`, a closed list that grows only with the exports. The folder carries the kind; filenames drop it and go back to being business names.
|
|
177
|
+
- A facade **exports its builders and sets by name**: the world (`export const newsroom = defineStubs(...)`) and its **scenario factories** (`withArticleGone(id)`), so every variant of the world is named next to the world; a facade that exports nothing is an error. A default export is not required: a test imports the name it calls, and a default no test reads is dead code the unused-export pass reports. When a facade has one, it is a composition: a `defineStubs(...)`, a scenario of a sibling facade (`export default pipeline({ ingestion: 'ai-facts' })`, `pipeline` imported from `./pipeline.stubs.js`) or a call to a builder it declares to return `Stubs`; `stubs-root-holds-facades-and-kinds` judges the default by where its builder comes from, since the linter reads no types.
|
|
178
|
+
- A unit stub is `<kind>/<kebab-name>.ts` and default-exports `defineStub(...)` or a factory returning one. It calls its own folder's builder alone: an `openai/` unit that replies with `http.error(...)` is an error, however it is written. A provider's streamed reply is that provider's stub (`openai.stream`, `anthropic.stream`); any other server-sent stream is an `http` stub.
|
|
179
|
+
- Data with real mass goes to a payload in `__fixtures__/` (served), which the unit imports, or to a sibling `.request.ts` (matched). The stem before the FIRST dot pairs a `.request.ts` with its stub: `classify-article.request.ts` belongs to `classify-article.ts`, and an orphan one is an error.
|
|
180
|
+
- **Tests import only `*.stubs.ts`.** Kind folders are internal; reaching into one from a test is an error (`stubs-imported-through-facade`). A scenario belongs in the facade, not in the test.
|
|
181
|
+
- Shared data has ONE owner: a second stub imports the same `__fixtures__/events.json`. No copies.
|
|
182
|
+
|
|
183
|
+
A unit stub may import the framework (its builders from `@jterrazz/test/stubs`, rule `service-or-stub-from-its-subpath`), its neighbours inside `stubs/`, **and the app's own source**. Importing the app's real prompt builder is preferred over pinning its output, so a domain change surfaces as a type error or a readable diff instead of a silent "unmatched request".
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// __setup__/stubs/newsroom.stubs.ts: the facade a test imports
|
|
187
|
+
import { defineStubs } from '@jterrazz/test/stubs';
|
|
188
|
+
|
|
189
|
+
import articleGone from './http/article-gone.js';
|
|
190
|
+
import events from './http/events.js';
|
|
191
|
+
|
|
192
|
+
export const newsroom = defineStubs(events);
|
|
193
|
+
|
|
194
|
+
export const withArticleGone = (id: string) => newsroom.with(articleGone(id));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// __setup__/stubs/http/events.ts: a unit, serving its payload from __fixtures__/
|
|
199
|
+
import { defineStub, http } from '@jterrazz/test/stubs';
|
|
200
|
+
|
|
201
|
+
import payload from '../../../__fixtures__/events.json' with { type: 'json' };
|
|
202
|
+
|
|
203
|
+
export default defineStub({
|
|
204
|
+
request: http.get('/events'),
|
|
205
|
+
response: http.json(payload),
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Using them: `.intercept()`
|
|
210
|
+
|
|
211
|
+
Every specification that reaches the network takes the same three forms:
|
|
212
|
+
|
|
213
|
+
| Form | Use |
|
|
214
|
+
| ------------------------------- | ---------------------------------------------------------- |
|
|
215
|
+
| `.intercept(stubs)` | A composite, the normal case, one import per feature |
|
|
216
|
+
| `.intercept(stub)` / `([a, b])` | A single stub or a list |
|
|
217
|
+
| `.intercept(request, response)` | An inline pair, for one-off plumbing that deserves no name |
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { http } from '@jterrazz/test/stubs';
|
|
221
|
+
import { describe, expect, test } from 'vitest';
|
|
222
|
+
|
|
223
|
+
import { api } from '../api.specification.js';
|
|
224
|
+
import { newsroom } from './__setup__/stubs/newsroom.stubs.js';
|
|
225
|
+
|
|
226
|
+
describe('report', () => {
|
|
227
|
+
test('serves the declared world', async () => {
|
|
228
|
+
// Given - a one-off inline pair alongside the feature's world
|
|
229
|
+
const result = await api
|
|
230
|
+
.intercept(newsroom)
|
|
231
|
+
.intercept(http.any(/analytics\.example/), http.json({ ok: true }))
|
|
232
|
+
.get('/report');
|
|
233
|
+
|
|
234
|
+
// Then - the report rendered
|
|
235
|
+
expect(result.status).toBe(200);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Repeated `.intercept()` calls **append**. Composition and override semantics live in `defineStubs` / `.with()`, never in call order.
|
|
241
|
+
|
|
242
|
+
## `intercept()`: the same double, with no chain
|
|
243
|
+
|
|
244
|
+
A module test has no runner and therefore no chain to hang stubs on, and its subject can still reach the network. `intercept(...)` is that case, and it takes the same three forms:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import { http, intercept } from '@jterrazz/test/stubs';
|
|
248
|
+
import { describe, expect, test } from 'vitest';
|
|
249
|
+
|
|
250
|
+
describe('rate provider', () => {
|
|
251
|
+
test('reads the rate off the provider', async () => {
|
|
252
|
+
// Given - the one call this module makes, declared for the block
|
|
253
|
+
await using _ = await intercept(
|
|
254
|
+
http.get('https://rates.test/eur'),
|
|
255
|
+
http.json({ rate: 1.07 }),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Then - the module answers with what the provider replied
|
|
259
|
+
const response = await fetch('https://rates.test/eur');
|
|
260
|
+
await expect(response.json()).resolves.toStrictEqual({ rate: 1.07 });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The scope is an `AsyncDisposable`: the handlers come off at the end of the block however the block ends, and an outgoing request no stub accepted is thrown THERE, with the same strictness a chain gets (rule `network-strict-after-first-stub`), which is what makes it a guard and not a convenience.
|
|
266
|
+
|
|
267
|
+
**The refusal lands at DISPOSAL, not at the call.** An undeclared request is not rejected where the subject made it: the `fetch` proceeds and fails on its own, and the violation is raised when the scope closes. That is the right place, because a subject that catches its own network failures (a retry loop, an `onError` callback, a fallback value) would swallow a rejection thrown at the call site and the spec would pass on a call nobody declared. Disposal happens after the subject is done, so nothing can catch it.
|
|
268
|
+
|
|
269
|
+
`await using _ = await intercept(...)` carries two awaits and both are load-bearing. The inner one waits for the network double to be listening; without it the subject could reach the real network before the first handler is in place. The shape refuses that spelling rather than leaving it to discipline: `intercept()` answers a promise, a promise is not an `AsyncDisposable`, and `await using _ = intercept(...)` does not compile.
|
|
270
|
+
|
|
271
|
+
A file that declares the same world in several tests factors it into a function, and the two awaits survive the move: the helper is `async`, and it awaits what it hands back:
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
async function rateProvider() {
|
|
275
|
+
return await intercept(http.get('https://rates.test/eur'), http.json({ rate: 1.07 }));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// In each test: await using _ = await rateProvider();
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Both keywords are the rulebook's, not taste: a function returning a promise is `async` (`typescript/promise-function-async`) and a returned promise is awaited (`typescript/return-await`), so the other spellings do not lint.
|
|
282
|
+
|
|
283
|
+
It is real in both runtimes (msw's server under node, msw's worker in a page), so a module test and a component test declare the outside world the same way. What it replaces is `vi.stubGlobal('fetch')` and a direct `msw/node` import, which rules `no-second-test-runtime` and `no-redundant-test-dependency` refuse a test and a consumer respectively.
|
|
284
|
+
|
|
285
|
+
**A subject that fetches a PATH states where the path lives.** `fetch('/api/posts')` is browser code: under node the URL has no base, so the request fails before any stub can answer it. `intercept(stubs, { origin: 'http://console.test' })` gives the block that base: the relative call resolves against it, the stubs match as they would any other request (a path-form stub ignores the origin, an absolute one compares it), and `fetch` is put back when the scope ends. It is the seam's own wrapping, so no test has to own one.
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
await using _ = await intercept(posts, { origin: 'http://console.test' });
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**An empty list is refused.** `intercept(defineStubs())` guards nothing, and a subject with no network needs no intercept at all, and the message says so. "This subject makes no network call" is said the other way round: declare each call it could make with [`http.never()`](#never-a-call-that-must-not-happen), and the spec fails if it makes one.
|
|
292
|
+
|
|
293
|
+
**A request fetch refuses is refused.** Under node, a `fetch` whose RequestInit fetch itself would refuse (an empty method, a forbidden one such as `TRACE`, a `GET` or `HEAD` with a body, an invalid header) rejects with fetch's own `TypeError` before any stub sees it. msw alone would build its request with `init.method || 'GET'` and serve the first three, so a subject's literal method mutated to `''` would be answered by a `GET` stub and its mutant survive. `specs/integration/intercept/refused-init.spec.ts` is the proof. In a page the browser's fetch refuses them before the worker.
|
|
294
|
+
|
|
295
|
+
**A body the subject cancels settles.** Under node, every reply to `fetch` reaches the subject with its body as a stream this package owns, so `response.body.cancel()` (what an SDK client does with a body it will not read, before a retry) resolves at once. The status, the headers and the address the reply came from are read as they were served. msw keeps a branch of every body for itself and never cancels it; the owned stream is what stands between that branch and the subject's cancel. A reply no stub answered is owned too, since msw branches the body of a request it passes through to the network as well. `specs/api/intercept/body-cancel.spec.ts` is the proof for a stub's reply: an app that cancels the body of `http.error(503, { ... })` answers its request. `specs/integration/intercept/passthrough-cancel.spec.ts` is the proof for a reply passed through to a real server. In a page nothing is wrapped: the worker's reply reaches the subject through the browser's own network stack, and its cancel settles as served; `specs/component/intercept/quote-status.test.tsx` is the proof.
|
|
296
|
+
|
|
297
|
+
**Around a `.render()` the chain's registration wins.** A rendered unit's network is declared on the chain (`component.intercept(c).render(...)`) because the render registers its stubs last and its strictness is total ([Component specs](../guides/component-specs.md)). A module-scope `intercept()` opened around a `.render()` is shadowed by it and proves nothing. In a `.test.tsx`, `intercept()` is for code the TEST calls itself, never for what the render fetches.
|
|
298
|
+
|
|
299
|
+
## Three deliveries, one queue
|
|
300
|
+
|
|
301
|
+
A stub reaches the subject by one of three deliveries, named for where the subject runs.
|
|
302
|
+
|
|
303
|
+
| Specifications | Delivery | Notes |
|
|
304
|
+
| --------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
305
|
+
| `api`, `job`, `integration`, and a module's `intercept()` | **same process**: msw server | The subject runs here, so its outgoing requests pass through this process |
|
|
306
|
+
| `component` | **browser page**: msw worker | The component runs in Chromium, so the service worker answers its requests there |
|
|
307
|
+
| `website`, `mobile` | **other process**: the declared stub backend (plain `node:http`) | Started by the runner, its URL injected/exposed; CORS and `OPTIONS` handled for you |
|
|
308
|
+
|
|
309
|
+
All three consume the **same** selection queue, so a stub behaves identically whichever answers it: first match wins, `times` exhausts, `required` verifies at chain end, and "sticky" is the default (`times` omitted).
|
|
310
|
+
|
|
311
|
+
The stub backend resets between chains, the way databases do: one chain = one terminal action, and its stubs replace the previous chain's wholesale.
|
|
312
|
+
|
|
313
|
+
## Request builders
|
|
314
|
+
|
|
315
|
+
### `http.*`: any URL
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
import { http } from '@jterrazz/test/stubs';
|
|
319
|
+
|
|
320
|
+
http.get('https://rates.example.com/v1/latest');
|
|
321
|
+
http.get('/articles/{{uuid}}'); // path form: any origin, structural segment
|
|
322
|
+
http.post('https://api.shoply.dev/orders', {
|
|
323
|
+
body: { user: { role: 'admin' } },
|
|
324
|
+
headers: { 'x-tenant': 'acme' },
|
|
325
|
+
query: { lang: 'en' },
|
|
326
|
+
});
|
|
327
|
+
http.get('/me', { without: { headers: ['authorization'] } }); // fires only for an anonymous request
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`http.get | post | put | patch | delete | any(urlOrPath, filter?)`. A **path-form** url (`'/articles/{{uuid}}'`) matches that path on **any origin** (the app's real host does not matter), and `{{token}}` segments match structurally ([Tokens](tokens.md)).
|
|
331
|
+
|
|
332
|
+
The optional `filter` narrows beyond method + URL, and these are **filters**, so subset is the right default:
|
|
333
|
+
|
|
334
|
+
- `body`: an object is a deep **subset** match whose leaves may be `match.*` matchers; a string is a containment test; a RegExp is a `test()` over the raw text body.
|
|
335
|
+
- `headers` / `query`: subset match (header names case-insensitive); a string value is **exact**, a RegExp is `test()`.
|
|
336
|
+
- `without.headers`: the headers the request must **not** carry: a name listed there, present with any value, keeps the stub from firing, and the strict refusal lists the stub with the headers it forbids (`GET /me (without header authorization)`). A subset proves presence only, so absence is stated here; a name both in `headers` and in `without.headers` is refused when the stub is declared. In a page the stub sees the headers the subject set, never those the browser adds after the service worker, `cookie` among them.
|
|
337
|
+
|
|
338
|
+
### `openai.*` / `anthropic.*`
|
|
339
|
+
|
|
340
|
+
| Builder | Targets | Filters |
|
|
341
|
+
| ----------------------------------- | --------------------------------------------------------------- | ------------------------------------------------- |
|
|
342
|
+
| `openai.chat(filter?, url?)` | Chat Completions API; `url` overrides the endpoint | `model`, `system`, `user`, `tools`, `temperature` |
|
|
343
|
+
| `openai.responses(filter?, url?)` | Responses API (AI-SDK style); `url` overrides the endpoint | `model`, `system`, `user`, `tools` |
|
|
344
|
+
| `anthropic.messages(filter?, url?)` | Messages API; `url` overrides the endpoint for a custom gateway | `model`, `system`, `user`, `tools` |
|
|
345
|
+
|
|
346
|
+
### Matching: strings are EXACT
|
|
347
|
+
|
|
348
|
+
A string filter on a provider builder means **exact equality**. Looser matching is explicit: a RegExp, or the code-only matcher `match.includes(...)`:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
import { match } from '@jterrazz/test';
|
|
352
|
+
import { defineStub, openai } from '@jterrazz/test/stubs';
|
|
353
|
+
|
|
354
|
+
// Exact: the app's own builder produces the prompt, so it cannot drift.
|
|
355
|
+
const PROMPT = 'Classify the article into one of: TECH, POLITICS, SPORT.';
|
|
356
|
+
|
|
357
|
+
export default defineStub({
|
|
358
|
+
request: openai.chat({ user: PROMPT }),
|
|
359
|
+
response: openai.reply({ category: 'TECH' }),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Looser, on purpose: per-run data makes an exact prompt impractical.
|
|
363
|
+
export const byPrefix = defineStub({
|
|
364
|
+
request: openai.chat({ user: match.includes('Classify the article') }),
|
|
365
|
+
response: openai.reply({ category: 'TECH' }),
|
|
366
|
+
});
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Exactness is the default because a substring filter silently cross-matches: two prompts sharing a preamble both match the first stub, and the spec goes green on the wrong reply. `match.includes` is a code-only matcher, like `match.regex`; the `{{token}}` file vocabulary does not grow.
|
|
370
|
+
|
|
371
|
+
## Response builders
|
|
372
|
+
|
|
373
|
+
| Response | Produces |
|
|
374
|
+
| ------------------------------------------ | ----------------------------------------------------------------------------------- |
|
|
375
|
+
| `http.json(body, init?)` | JSON response: `init: { status?, headers?, delay? }` |
|
|
376
|
+
| `http.text(body, init?)` | `text/plain` response |
|
|
377
|
+
| `http.error(status, body?)` | HTTP error, optional JSON body |
|
|
378
|
+
| `http.empty(status = 204)` | Empty response |
|
|
379
|
+
| `http.stream(chunks, init?)` | A body that arrives in PIECES: `init: { contentType?, delay?, status?, headers? }` |
|
|
380
|
+
| `http.sse(events, init?)` | A server-sent event stream: one chunk per event, `text/event-stream` |
|
|
381
|
+
| `http.unreachable()` | No response at all: the request fails in TRANSPORT and the caller's `fetch` rejects |
|
|
382
|
+
| `openai.reply(data)` | `data` wrapped in a valid Chat Completions envelope |
|
|
383
|
+
| `anthropic.reply(data)` | `data` (object or text) wrapped in a Messages envelope |
|
|
384
|
+
| `openai.error(status)` / `anthropic.error` | Provider HTTP error (e.g. `429` rate limit) |
|
|
385
|
+
| `openai.timeout()` / `anthropic.timeout()` | A provider that never answers within the caller's timeout |
|
|
386
|
+
| `openai.malformed(text)` | HTTP 200 whose assistant content is `text`, an unparseable payload to handle |
|
|
387
|
+
| `openai.stream(pieces, init?)` | The assistant's text streamed, one delta per piece; see Streamed replies |
|
|
388
|
+
| `anthropic.stream(pieces, init?)` | The assistant's text streamed as Messages events, one delta per piece |
|
|
389
|
+
|
|
390
|
+
The point: your stub states the **business payload** (`{ category: 'TECH' }`) and the builder produces the provider's full wire format around it.
|
|
391
|
+
|
|
392
|
+
`http.unreachable()` is the one that is not a reply. A subject usually has two failure branches ("the server said no" and "nothing answered"), and only a status can express the first. The second is what a caller meets when the service is not running, and it is the branch behind a message like _the server did not answer, check that it is running_; `http.error(503)` tests the other one. All three deliveries honour it: msw's server and msw's worker fail the request in transport, and the stub backend drops the connection.
|
|
393
|
+
|
|
394
|
+
### Streamed replies
|
|
395
|
+
|
|
396
|
+
A subject that reads a reply AS IT LANDS (a token-by-token render, a progress bar, a client that reconnects on a half-read body) is specified by the chunks and their order, which a single serialised body cannot state. `http.stream()` and `http.sse()` say that, and all three deliveries honour them: msw's server, msw's worker, and the `node:http` stub backend the website and mobile specifications serve from.
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
import { http, intercept } from '@jterrazz/test/stubs';
|
|
400
|
+
import { describe, expect, test } from 'vitest';
|
|
401
|
+
|
|
402
|
+
describe('streamed reply', () => {
|
|
403
|
+
test('renders a streamed reply as its pieces land', async () => {
|
|
404
|
+
// Given - a provider answering in three pieces, ten milliseconds apart
|
|
405
|
+
await using _ = await intercept(
|
|
406
|
+
http.get('https://tokens.test/stream'),
|
|
407
|
+
http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 }),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// Then - the reader sees them in that order
|
|
411
|
+
const response = await fetch('https://tokens.test/stream');
|
|
412
|
+
await expect(response.text()).resolves.toBe('Hello, world');
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
`http.sse(events)` frames each event the way an `EventSource` reads it (`event:`, `id:`, `retry:` and one `data:` line per line of payload, then the blank line that ends the frame) and serves the lot as `text/event-stream`. An object payload is JSON; a string is sent as-is.
|
|
418
|
+
|
|
419
|
+
| `SseEvent` field | Becomes |
|
|
420
|
+
| ---------------- | ----------------------------------------------------------------- |
|
|
421
|
+
| `data` | the `data:` lines: an object is JSON, a string is sent verbatim |
|
|
422
|
+
| `event` | the `event:` name; omitted, the client sees the default `message` |
|
|
423
|
+
| `id` | the `id:` the client echoes as `Last-Event-ID` when it reconnects |
|
|
424
|
+
| `retry` | the `retry:` hint, in milliseconds |
|
|
425
|
+
|
|
426
|
+
A provider's streamed reply is its provider's stub, not an `http` one: `openai.stream(pieces)` and `anthropic.stream(pieces)` serve the text as the provider does under `stream: true`, one delta per piece in order, so the unit sits in `stubs/openai/` or `stubs/anthropic/` with the request it answers.
|
|
427
|
+
|
|
428
|
+
| Builder | Wire |
|
|
429
|
+
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
430
|
+
| `openai.stream(pieces)` | Chat Completions: unnamed `chat.completion.chunk` events (the role, the deltas, the finish), then `data: [DONE]` |
|
|
431
|
+
| `openai.stream(pieces, { api: 'responses' })` | Responses: named, numbered events, one `response.output_text.delta` per piece, `response.completed` last |
|
|
432
|
+
| `anthropic.stream(pieces)` | Messages: `message_start`, a text block, a `ping`, one `text_delta` per piece, `message_stop` last |
|
|
433
|
+
|
|
434
|
+
Both take `delay`, the milliseconds between two events, and `openai.stream` takes `usage: { promptTokens, completionTokens }`: a Chat Completions stream then ends on the usage chunk (no choice, the tokens) before `[DONE]`, as `stream_options.include_usage` asks, and a Responses stream states them in its completed response. Any other server-sent stream is an `http.sse()` stub.
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// specs/api/assistant/__setup__/stubs/openai/greeting.ts
|
|
438
|
+
import { defineStub, openai } from '@jterrazz/test/stubs';
|
|
439
|
+
|
|
440
|
+
export default defineStub({
|
|
441
|
+
request: openai.chat({ user: 'Say hello' }),
|
|
442
|
+
response: openai.stream(['Hel', 'lo, ', 'world']),
|
|
443
|
+
});
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Dynamic responses
|
|
447
|
+
|
|
448
|
+
A `response` may be a function of the incoming request (`(request: MatchableRequest) => StubResponse`), evaluated per served request:
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
import { defineStub, http } from '@jterrazz/test/stubs';
|
|
452
|
+
|
|
453
|
+
export default defineStub({
|
|
454
|
+
request: http.post('https://gateway.shoply.dev/v1/echo'),
|
|
455
|
+
response: (request) => http.json({ received: request.body }),
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
The `request` handed to it is the same `MatchableRequest` the match ran on: `{ body, headers, url }`, with `body` already parsed when the payload is JSON. Prefer a fixed response whenever the reply is known ahead of time; a dynamic one is for echo endpoints, request-derived ids, and per-call variation.
|
|
460
|
+
|
|
461
|
+
## Strict by construction
|
|
462
|
+
|
|
463
|
+
The moment a chain declares one stub, the network is guarded: every outgoing request during the action must match a declared, non-exhausted stub. Anything else fails the spec with an explicit error that rejects the action promise (never an unhandled rejection):
|
|
464
|
+
|
|
465
|
+
```text
|
|
466
|
+
found: an unmatched outgoing request, POST https://api.openai.com/v1/chat/completions; declared:
|
|
467
|
+
- POST https://api.openai.com/v1/chat/completions (exhausted after 1)
|
|
468
|
+
why: From its first stub, a chain’s network is strict: every outgoing request matches a declared stub.
|
|
469
|
+
fix: declare a stub for POST https://api.openai.com/v1/chat/completions, or raise the `times` of the one it exhausted
|
|
470
|
+
read: docs/reference/linting.md#network-strict-after-first-stub
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
At chain end the same channel verifies `required` stubs: one that was declared and never requested fails the spec, naming its route, and a never stub that was reached fails it, naming the route and each call ([never](#never-a-call-that-must-not-happen)). Every outcome of one action is reported together: a reached never stub, an unmatched request and an unmet `required` stub each keep their own refusal in the one error.
|
|
474
|
+
|
|
475
|
+
### `required` is verified per terminal action
|
|
476
|
+
|
|
477
|
+
A chain's scope is ONE terminal action, and a stub's `required` is checked at the end of it, not at the end of the test, and not at the end of the file. A spec that declares a stub and then performs two actions has declared it for the first one: the second starts a fresh registration, and a `required` stub unmet by the action it belongs to fails that action's chain.
|
|
478
|
+
|
|
479
|
+
This is the same boundary rule `one-terminal-action-per-chain` already states from the other side (one chain, one action), read from the stub's end. A test that wants a stub to hold across two actions writes two chains, each declaring what it expects to see.
|
|
480
|
+
|
|
481
|
+
Two scoping notes:
|
|
482
|
+
|
|
483
|
+
- **A chain with zero stubs does not guard its network**: a deliberate, documented boundary.
|
|
484
|
+
- **A rendered component is the exception.** Its network is strict even with no stub declared, because a page shares its process with nothing else the test does ([Component specs](../guides/component-specs.md#unique-here)).
|
|
485
|
+
|
|
486
|
+
## Pitfalls
|
|
487
|
+
|
|
488
|
+
- **Reaching into a kind folder from a test.** `import events from './__setup__/stubs/http/events.js'` is an error (`stubs-imported-through-facade`). Add a named scenario export to the facade instead; that is what facades are for.
|
|
489
|
+
- **Pinning a prompt by copying it.** A 550-line prompt pasted into a stub is a fossil. Import the app's real builder, or match a stable prefix with `match.includes`. The stub pins the _interaction_, not the wording.
|
|
490
|
+
- **Expecting a substring to match.** Provider string filters are exact. A prompt that "should obviously match" but does not is almost always a whitespace or a template difference; use `match.includes` if that is genuinely what you mean.
|
|
491
|
+
- **Writing a call count as a loop.** `for (...) chain.intercept(garbage)` is `times: 8`. The loop hides the number the spec is actually asserting.
|
|
492
|
+
- **Relying on declaration specificity instead of order.** Selection is first-match. `.with()` prepends precisely so an override wins without deleting the generic route. Outside `.with()`, order is yours to get right.
|
|
493
|
+
- **Letting a pipeline hit the network.** An unintercepted call is a test escaping the sandbox, not extra realism. Once one stub is declared, `network-strict-after-first-stub` turns a stray call into a failure.
|
|
494
|
+
- **Duplicating a payload.** The 45-line article exists ONCE, as a file of `__fixtures__/`; the second stub imports it.
|
|
495
|
+
|
|
496
|
+
## Related
|
|
497
|
+
|
|
498
|
+
[API specs](../guides/api-specs.md) · [Job specs](../guides/job-specs.md) · [Website specs](../guides/website-specs.md) · [Mobile specs](../guides/mobile-specs.md) · [Conventions](../concepts/conventions.md)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Time and doubles
|
|
2
|
+
|
|
3
|
+
Two things around a subject are neither a service nor the network: the time it reads, and a collaborator it is handed. This page covers `clock`, which pins time, and `mockOf()`, which doubles an injected port, and the doubles ladder, which says when a double is the right tool at all.
|
|
4
|
+
|
|
5
|
+
## The clock
|
|
6
|
+
|
|
7
|
+
A value a test samples (`Date.now()`, `new Date()`, `Math.random()`, `randomUUID()`) is different on every run, so it never reaches an expectation (rule `no-sampled-value-in-expectation`). Time is the one of those the framework pins for you, and `clock` is the whole vocabulary:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import { clock } from '@jterrazz/test';
|
|
11
|
+
import { describe, expect, test } from 'vitest';
|
|
12
|
+
|
|
13
|
+
function issueReceipt(total: number): { issuedAt: string; total: number } {
|
|
14
|
+
return { issuedAt: new Date().toISOString(), total };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('issueReceipt', () => {
|
|
18
|
+
test('stamps the moment the receipt was issued', () => {
|
|
19
|
+
// Given - the calendar pinned for this scope
|
|
20
|
+
using _ = clock.at('2026-03-04T09:30:00Z');
|
|
21
|
+
|
|
22
|
+
// Then - the stamp is the stated instant
|
|
23
|
+
expect(issueReceipt(42)).toEqual({ issuedAt: '2026-03-04T09:30:00.000Z', total: 42 });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The expectation compares what the subject returned, and the instant is a value the test wrote. A test that pins the clock may also read `new Date()` itself, since under the pin that reading is the constant it chose, but such a test checks the clock rather than the subject.
|
|
29
|
+
|
|
30
|
+
| Form | Pins | For |
|
|
31
|
+
| ------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
|
32
|
+
| `clock.at(iso)` | the calendar (`Date`) | a subject that reads the time: a stamp, an expiry, a greeting |
|
|
33
|
+
| `clock.run(iso?)` | the calendar and the scheduler | a subject whose behaviour is elapsed time: a debounce, a backoff, a poll |
|
|
34
|
+
| `clock.advance(ms)` | none | moving the pinned clock on; under `run()`, firing every timer that came due |
|
|
35
|
+
|
|
36
|
+
**`at()` leaves timers running.** Promises, renders and the framework's own polling keep working, because only `Date` is pinned. **`run()` queues timers instead**, so `setTimeout` and friends fire only when `await clock.advance(ms)` makes them due. That is how a test proves a five-second backoff without waiting five seconds, and without the arbitrary sleep rule `no-sleep` forbids.
|
|
37
|
+
|
|
38
|
+
**Both are scopes.** `using _ = clock.at(...)` gives the real clock back when the scope ends, however it ends. `vi.useFakeTimers()` and `vi.setSystemTime()` are what this replaces: each owns a teardown a test will one day forget, and rule `no-vitest-time-or-global-stub` refuses them, naming `clock`.
|
|
39
|
+
|
|
40
|
+
**One clock per test.** A second scope taken inside the first is refused rather than nested: ending the inner one would end the outer one too, since the only state vitest's timers can give back is the real clock. `clock.advance()` with no clock pinned is refused, and so is an instant `Date` cannot read. An instant is an ISO-8601 string, `'2026-03-04T09:30:00Z'`.
|
|
41
|
+
|
|
42
|
+
A `beforeEach`/`afterEach` pair can hold the scope by hand while a suite migrates:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
let pinned: Disposable;
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
pinned = clock.at('2026-03-04T09:30:00Z');
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
pinned[Symbol.dispose]();
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It works, and it is a stopover: `using _ = clock.at(...)` inside the test puts the instant where the test that needs it is read.
|
|
55
|
+
|
|
56
|
+
A timer a subject sets and must release is proven by `vi.getTimerCount()` after `clock.run()`: a count of zero says the `clearTimeout` ran, and kills the mutants of a `finally` or of the clear itself without a directive.
|
|
57
|
+
|
|
58
|
+
### `.clock()` on a chain
|
|
59
|
+
|
|
60
|
+
A chain pins the same instant for its own action with `.clock(iso)`, released when the action resolves:
|
|
61
|
+
|
|
62
|
+
| On | Pins |
|
|
63
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
64
|
+
| `api`, `job`, `integration` | the `Date` the app, the job or the module reads; they share the test's process |
|
|
65
|
+
| `component` | the page's `Date`, and nothing else: faking a page's timers would stop React's scheduler |
|
|
66
|
+
| `website` (`.visit()` only) | the page's calendar, before its first byte is parsed. A `.fetch()` opens no page and refuses the pairing |
|
|
67
|
+
| `cli` | nothing: a binary is another process. Its instant travels through its own env (`TZ`), or the golden absorbs it with `{{iso8601}}` |
|
|
68
|
+
|
|
69
|
+
A file whose chains all share one instant states it once, on a view: `const morning = api.with({ clock: '2026-03-04T09:30:00Z' })` ([Conventions § What a file shares](../concepts/conventions.md#what-a-file-shares)). A chain whose clock is refused leaves nothing armed: the stubs it armed before the clock are released with the refusal.
|
|
70
|
+
|
|
71
|
+
## Doubles
|
|
72
|
+
|
|
73
|
+
### The doubles ladder
|
|
74
|
+
|
|
75
|
+
When a subject needs something it does not own, take the FIRST rung that fits. The list is closed: nothing below the fourth rung is a rung.
|
|
76
|
+
|
|
77
|
+
1. **The real thing**, in the test's process: a pure module, a Hono app handed to `server`, a component mounted in a real Chromium.
|
|
78
|
+
2. **A declared service:** `sqlite()`, `postgres()`, `redis()`, `process()` ([Services](services.md)): real, isolated per worker, reset at the start of every chain.
|
|
79
|
+
3. **A stub:** `.intercept()` on a chain, or `intercept()` around module code ([Stubs](stubs.md)). Strict from the first stub: a request no stub answers fails the test.
|
|
80
|
+
4. **A typed port double:** `mockOf<Port>()` for an injected interface, `vi.fn<Fn>()` for a function. Asserting a call on one is a boundary observation; asserting only on doubles the test built proves nothing.
|
|
81
|
+
|
|
82
|
+
**Off the ladder**, each with where to go instead:
|
|
83
|
+
|
|
84
|
+
| Instead of | Use |
|
|
85
|
+
| ------------------------------------------------ | ----------------------------------------------- |
|
|
86
|
+
| `vi.stubGlobal('fetch')`, `msw`, `nock`, `sinon` | `intercept()` or `.intercept()`, a stub |
|
|
87
|
+
| `vi.useFakeTimers()`, `vi.setSystemTime()` | `clock` |
|
|
88
|
+
| `toMatchSnapshot()` | a golden, `toMatch(file('./__expected__/...'))` |
|
|
89
|
+
| `vi.mock()` of your own module | inject the port, and double it |
|
|
90
|
+
|
|
91
|
+
A native module a project genuinely cannot inject is the one exception, allowed per specifier with a reason in the `no-module-mock` options ([Linting § Deviating from a rule](linting.md#deviating-from-a-rule)). Why the ladder is closed: [ADR-005](https://github.com/jterrazz/package-test/blob/main/docs/decisions/005-the-doubles-ladder-is-closed.md).
|
|
92
|
+
|
|
93
|
+
### `mockOf()`
|
|
94
|
+
|
|
95
|
+
`mockOf<Port>()` is the one double a test builds itself, and only a module test does: every other kind declares its doubles, because the test does not hold the subject's wiring.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { mockOf } from '@jterrazz/test';
|
|
99
|
+
import { describe, expect, test } from 'vitest';
|
|
100
|
+
|
|
101
|
+
type PaymentGateway = { charge(amount: number): Promise<string> };
|
|
102
|
+
|
|
103
|
+
async function checkout(gateway: PaymentGateway, total: number): Promise<string> {
|
|
104
|
+
return `receipt for ${await gateway.charge(total)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('checkout', () => {
|
|
108
|
+
test('charges the gateway once for the order total', async () => {
|
|
109
|
+
// Given - a gateway that accepts every charge
|
|
110
|
+
const gateway = mockOf<PaymentGateway>();
|
|
111
|
+
gateway.charge.mockResolvedValue('ch_1');
|
|
112
|
+
|
|
113
|
+
// Then - the receipt names the charge, and the gateway was asked for the total
|
|
114
|
+
await expect(checkout(gateway, 42)).resolves.toBe('receipt for ch_1');
|
|
115
|
+
expect(gateway.charge).toHaveBeenCalledWith(42);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It is deep by default: reading a member nobody stubbed hands back another double, however far the port nests. `mockOf<Port>({ deep: false })` is the flat form: every member of the port and nothing beneath them.
|
|
121
|
+
|
|
122
|
+
State the port. `T` has no default, so `mockOf()` with no type argument answers for `object` and the error lands where the double is used, not where it was made. A double a runner hands the app (a class implementing the port, swapped in by the specification) is a test adapter, and lives in `__setup__/<port>.adapter.ts` ([Conventions § The naming recap](../concepts/conventions.md#the-naming-recap)).
|
|
123
|
+
|
|
124
|
+
### A rejection the subject must handle
|
|
125
|
+
|
|
126
|
+
`vi.fn().mockRejectedValue(error)` cannot prove a rejection is handled: the rejected promise it hands back exists before the subject attaches a handler, and the test passes whether or not the subject ever does. The double is a function that throws when it is called, `async () => { throw error; }`, so the rejection is born inside the subject's own call. To let an `unhandledRejection` surface before the assertion, wait one `setImmediate` tick (`await new Promise((resolve) => setImmediate(resolve))`), never a `setTimeout`, which `no-sleep` refuses.
|
|
127
|
+
|
|
128
|
+
## Pitfalls
|
|
129
|
+
|
|
130
|
+
- **`vi.setSystemTime` instead of `clock`.** Taken without a scope, it outlasts the test, and the next test in the file inherits a clock nobody declared.
|
|
131
|
+
- **Sleeping to let time pass.** `await new Promise((r) => setTimeout(r, 5000))` waits for real and hides how long the behaviour needs; `clock.run()` and `clock.advance(5000)` state it.
|
|
132
|
+
- **`vi.mock()` to avoid injecting a port.** Every importer of that module gets the double, and the design pressure the injection would have applied is gone.
|
|
133
|
+
- **A double inside an integration spec.** The integration kind exists so the module meets the real thing; a `mockOf()` there is a module test that started a container for nothing.
|
|
134
|
+
|
|
135
|
+
## Related
|
|
136
|
+
|
|
137
|
+
[Module tests](../guides/module-tests.md) · [Stubs](stubs.md) · [Services](services.md) · [Conventions](../concepts/conventions.md)
|