@jterrazz/test 16.1.1 → 18.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 +1 -2980
- package/dist/browser/services.js +1 -0
- package/dist/browser/stubs.js +1 -0
- package/dist/browser-shared-index.browser.js +3233 -0
- package/dist/checker.d.ts +6 -1
- package/dist/checker.js +3721 -2624
- package/dist/config.d.ts +40 -0
- package/dist/config.js +309 -0
- package/dist/in-place.global-setup.d.ts +12 -0
- package/dist/in-place.global-setup.js +21 -0
- package/dist/index.d.ts +1641 -3004
- package/dist/index.js +5095 -3835
- package/dist/mobile-session.global-setup.d.ts +15 -0
- package/dist/mobile-session.global-setup.js +31 -0
- package/dist/oxlint.cjs +6069 -5634
- package/dist/oxlint.d.cts +43 -7212
- package/dist/oxlint.d.ts +43 -7212
- package/dist/oxlint.js +3979 -5799
- package/dist/route-coverage.global-setup.d.ts +43 -0
- package/dist/route-coverage.global-setup.js +1 -0
- package/dist/services.d.ts +3 -0
- package/dist/services.global-setup.d.ts +43 -0
- package/dist/services.global-setup.js +1 -0
- package/dist/services.js +597 -0
- package/dist/shared-appium.adapter.js +428 -0
- package/dist/shared-artifacts.js +34 -0
- package/dist/shared-chapter.js +7 -0
- package/dist/shared-container.port.d.ts +31 -0
- package/dist/shared-derived-projects.js +73 -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.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-listened-port.js +19 -0
- package/dist/shared-literate-plugin.js +184 -0
- package/dist/shared-manifest.js +1321 -0
- package/dist/shared-pause.js +22 -0
- package/dist/shared-peer.js +88 -0
- package/dist/shared-playwright.adapter.js +328 -0
- package/dist/shared-process-alive.js +13 -0
- package/dist/shared-process.adapter.js +74 -0
- package/dist/shared-record-key.js +13 -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 +407 -0
- package/dist/shared-service-images.js +10 -0
- package/dist/shared-services.global-setup.js +174 -0
- package/dist/shared-session-ledger.js +305 -0
- package/dist/shared-setup-entry.js +12 -0
- package/dist/shared-source-scan.js +133 -0
- package/dist/shared-spec-document.js +303 -0
- package/dist/shared-spec-grammar.js +66 -0
- package/dist/shared-specifications.js +26 -0
- package/dist/shared-specs-modules.js +1033 -0
- package/dist/shared-stub-reply.js +167 -0
- package/dist/shared-stub-server.js +142 -0
- package/dist/shared-temp-root.global-setup.js +73 -0
- package/dist/shared-test-baseline.js +26 -0
- package/dist/shared-test-section.js +58 -0
- package/dist/shared-testcontainers.adapter.js +306 -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 +1 -0
- package/dist/vitest.d.ts +93 -164
- package/dist/vitest.js +382 -550
- 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 +566 -0
- package/docs/guides/component-specs.md +252 -0
- package/docs/guides/develop.md +97 -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 +44 -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 +500 -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 +228 -0
- package/package.json +56 -36
- 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 -438
- package/dist/server.js +0 -160
- package/dist/stream.js +0 -321
- package/dist/substring-warning.js +0 -282
- 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 -248
- 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 -332
- package/docs/19-linting.md +0 -512
- 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,500 @@
|
|
|
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 builds the `Request` from the RequestInit, as fetch does, and a subject's literal method mutated to `''` is never answered by a `GET` stub. `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 method off the HTTP parser is refused.** Under node, msw reads a request off the socket with an HTTP parser, which takes only the capitalised methods of node's `http.METHODS`; fetch upper-cases DELETE, GET, HEAD, OPTIONS, POST and PUT and sends any other method as written, so a subject's `patch` (or `Patch`, or `FOO`) would pass through msw unread and reach the real network. While stubs are armed, such a `fetch` never leaves the process: the subject receives a `501` naming it, and the chain fails on rule `network-strict-after-first-stub`, though a `PATCH` stub declares the route. `specs/integration/intercept/refused-init.spec.ts` is the proof. In a page no parser stands between the subject and the handlers: the worker hands msw the request itself, whose method lookup ignores case, so a `PATCH` stub answers a `patch` (the lookup's proof is `src/stubs/delivery/__tests__/msw-handlers.test.ts`).
|
|
296
|
+
|
|
297
|
+
**A body the subject cancels settles.** Under node, `response.body.cancel()` on a reply to `fetch` (what an SDK client does with a body it will not read, before a retry) resolves at once, whether a stub answered or the request passed through to the network: msw hands the reply to undici over a socket, so the body the subject holds is undici's own. `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 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.
|
|
298
|
+
|
|
299
|
+
**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.
|
|
300
|
+
|
|
301
|
+
## Three deliveries, one queue
|
|
302
|
+
|
|
303
|
+
A stub reaches the subject by one of three deliveries, named for where the subject runs.
|
|
304
|
+
|
|
305
|
+
| Specifications | Delivery | Notes |
|
|
306
|
+
| --------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
307
|
+
| `api`, `job`, `integration`, and a module's `intercept()` | **same process**: msw server | The subject runs here, so its outgoing requests pass through this process |
|
|
308
|
+
| `component` | **browser page**: msw worker | The component runs in Chromium, so the service worker answers its requests there |
|
|
309
|
+
| `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 |
|
|
310
|
+
|
|
311
|
+
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).
|
|
312
|
+
|
|
313
|
+
The stub backend resets between chains, the way databases do: one chain = one terminal action, and its stubs replace the previous chain's wholesale.
|
|
314
|
+
|
|
315
|
+
## Request builders
|
|
316
|
+
|
|
317
|
+
### `http.*`: any URL
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
import { http } from '@jterrazz/test/stubs';
|
|
321
|
+
|
|
322
|
+
http.get('https://rates.example.com/v1/latest');
|
|
323
|
+
http.get('/articles/{{uuid}}'); // path form: any origin, structural segment
|
|
324
|
+
http.post('https://api.shoply.dev/orders', {
|
|
325
|
+
body: { user: { role: 'admin' } },
|
|
326
|
+
headers: { 'x-tenant': 'acme' },
|
|
327
|
+
query: { lang: 'en' },
|
|
328
|
+
});
|
|
329
|
+
http.get('/me', { without: { headers: ['authorization'] } }); // fires only for an anonymous request
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`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)).
|
|
333
|
+
|
|
334
|
+
The optional `filter` narrows beyond method + URL, and these are **filters**, so subset is the right default:
|
|
335
|
+
|
|
336
|
+
- `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.
|
|
337
|
+
- `headers` / `query`: subset match (header names case-insensitive); a string value is **exact**, a RegExp is `test()`.
|
|
338
|
+
- `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.
|
|
339
|
+
|
|
340
|
+
### `openai.*` / `anthropic.*`
|
|
341
|
+
|
|
342
|
+
| Builder | Targets | Filters |
|
|
343
|
+
| ----------------------------------- | --------------------------------------------------------------- | ------------------------------------------------- |
|
|
344
|
+
| `openai.chat(filter?, url?)` | Chat Completions API; `url` overrides the endpoint | `model`, `system`, `user`, `tools`, `temperature` |
|
|
345
|
+
| `openai.responses(filter?, url?)` | Responses API (AI-SDK style); `url` overrides the endpoint | `model`, `system`, `user`, `tools` |
|
|
346
|
+
| `anthropic.messages(filter?, url?)` | Messages API; `url` overrides the endpoint for a custom gateway | `model`, `system`, `user`, `tools` |
|
|
347
|
+
|
|
348
|
+
### Matching: strings are EXACT
|
|
349
|
+
|
|
350
|
+
A string filter on a provider builder means **exact equality**. Looser matching is explicit: a RegExp, or the code-only matcher `match.includes(...)`:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
import { match } from '@jterrazz/test';
|
|
354
|
+
import { defineStub, openai } from '@jterrazz/test/stubs';
|
|
355
|
+
|
|
356
|
+
// Exact: the app's own builder produces the prompt, so it cannot drift.
|
|
357
|
+
const PROMPT = 'Classify the article into one of: TECH, POLITICS, SPORT.';
|
|
358
|
+
|
|
359
|
+
export default defineStub({
|
|
360
|
+
request: openai.chat({ user: PROMPT }),
|
|
361
|
+
response: openai.reply({ category: 'TECH' }),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Looser, on purpose: per-run data makes an exact prompt impractical.
|
|
365
|
+
export const byPrefix = defineStub({
|
|
366
|
+
request: openai.chat({ user: match.includes('Classify the article') }),
|
|
367
|
+
response: openai.reply({ category: 'TECH' }),
|
|
368
|
+
});
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
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.
|
|
372
|
+
|
|
373
|
+
## Response builders
|
|
374
|
+
|
|
375
|
+
| Response | Produces |
|
|
376
|
+
| ------------------------------------------ | ----------------------------------------------------------------------------------- |
|
|
377
|
+
| `http.json(body, init?)` | JSON response: `init: { status?, headers?, delay? }` |
|
|
378
|
+
| `http.text(body, init?)` | `text/plain` response |
|
|
379
|
+
| `http.error(status, body?)` | HTTP error, optional JSON body |
|
|
380
|
+
| `http.empty(status = 204)` | Empty response |
|
|
381
|
+
| `http.stream(chunks, init?)` | A body that arrives in PIECES: `init: { contentType?, delay?, status?, headers? }` |
|
|
382
|
+
| `http.sse(events, init?)` | A server-sent event stream: one chunk per event, `text/event-stream` |
|
|
383
|
+
| `http.unreachable()` | No response at all: the request fails in TRANSPORT and the caller's `fetch` rejects |
|
|
384
|
+
| `openai.reply(data)` | `data` wrapped in a valid Chat Completions envelope |
|
|
385
|
+
| `anthropic.reply(data)` | `data` (object or text) wrapped in a Messages envelope |
|
|
386
|
+
| `openai.error(status)` / `anthropic.error` | Provider HTTP error (e.g. `429` rate limit) |
|
|
387
|
+
| `openai.timeout()` / `anthropic.timeout()` | A provider that never answers within the caller's timeout |
|
|
388
|
+
| `openai.malformed(text)` | HTTP 200 whose assistant content is `text`, an unparseable payload to handle |
|
|
389
|
+
| `openai.stream(pieces, init?)` | The assistant's text streamed, one delta per piece; see Streamed replies |
|
|
390
|
+
| `anthropic.stream(pieces, init?)` | The assistant's text streamed as Messages events, one delta per piece |
|
|
391
|
+
|
|
392
|
+
The point: your stub states the **business payload** (`{ category: 'TECH' }`) and the builder produces the provider's full wire format around it.
|
|
393
|
+
|
|
394
|
+
`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.
|
|
395
|
+
|
|
396
|
+
### Streamed replies
|
|
397
|
+
|
|
398
|
+
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.
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
import { http, intercept } from '@jterrazz/test/stubs';
|
|
402
|
+
import { describe, expect, test } from 'vitest';
|
|
403
|
+
|
|
404
|
+
describe('streamed reply', () => {
|
|
405
|
+
test('renders a streamed reply as its pieces land', async () => {
|
|
406
|
+
// Given - a provider answering in three pieces, ten milliseconds apart
|
|
407
|
+
await using _ = await intercept(
|
|
408
|
+
http.get('https://tokens.test/stream'),
|
|
409
|
+
http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 }),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Then - the reader sees them in that order
|
|
413
|
+
const response = await fetch('https://tokens.test/stream');
|
|
414
|
+
await expect(response.text()).resolves.toBe('Hello, world');
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`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.
|
|
420
|
+
|
|
421
|
+
| `SseEvent` field | Becomes |
|
|
422
|
+
| ---------------- | ----------------------------------------------------------------- |
|
|
423
|
+
| `data` | the `data:` lines: an object is JSON, a string is sent verbatim |
|
|
424
|
+
| `event` | the `event:` name; omitted, the client sees the default `message` |
|
|
425
|
+
| `id` | the `id:` the client echoes as `Last-Event-ID` when it reconnects |
|
|
426
|
+
| `retry` | the `retry:` hint, in milliseconds |
|
|
427
|
+
|
|
428
|
+
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.
|
|
429
|
+
|
|
430
|
+
| Builder | Wire |
|
|
431
|
+
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
432
|
+
| `openai.stream(pieces)` | Chat Completions: unnamed `chat.completion.chunk` events (the role, the deltas, the finish), then `data: [DONE]` |
|
|
433
|
+
| `openai.stream(pieces, { api: 'responses' })` | Responses: named, numbered events, one `response.output_text.delta` per piece, `response.completed` last |
|
|
434
|
+
| `anthropic.stream(pieces)` | Messages: `message_start`, a text block, a `ping`, one `text_delta` per piece, `message_stop` last |
|
|
435
|
+
|
|
436
|
+
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.
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
// specs/api/assistant/__setup__/stubs/openai/greeting.ts
|
|
440
|
+
import { defineStub, openai } from '@jterrazz/test/stubs';
|
|
441
|
+
|
|
442
|
+
export default defineStub({
|
|
443
|
+
request: openai.chat({ user: 'Say hello' }),
|
|
444
|
+
response: openai.stream(['Hel', 'lo, ', 'world']),
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Dynamic responses
|
|
449
|
+
|
|
450
|
+
A `response` may be a function of the incoming request (`(request: MatchableRequest) => StubResponse`), evaluated per served request:
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
import { defineStub, http } from '@jterrazz/test/stubs';
|
|
454
|
+
|
|
455
|
+
export default defineStub({
|
|
456
|
+
request: http.post('https://gateway.shoply.dev/v1/echo'),
|
|
457
|
+
response: (request) => http.json({ received: request.body }),
|
|
458
|
+
});
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
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.
|
|
462
|
+
|
|
463
|
+
## Strict by construction
|
|
464
|
+
|
|
465
|
+
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):
|
|
466
|
+
|
|
467
|
+
```text
|
|
468
|
+
found: an unmatched outgoing request, POST https://api.openai.com/v1/chat/completions; declared:
|
|
469
|
+
- POST https://api.openai.com/v1/chat/completions (exhausted after 1)
|
|
470
|
+
why: From its first stub, a chain’s network is strict: every outgoing request matches a declared stub.
|
|
471
|
+
fix: declare a stub for POST https://api.openai.com/v1/chat/completions, or raise the `times` of the one it exhausted
|
|
472
|
+
read: docs/reference/linting.md#network-strict-after-first-stub
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
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.
|
|
476
|
+
|
|
477
|
+
### `required` is verified per terminal action
|
|
478
|
+
|
|
479
|
+
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.
|
|
480
|
+
|
|
481
|
+
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.
|
|
482
|
+
|
|
483
|
+
Two scoping notes:
|
|
484
|
+
|
|
485
|
+
- **A chain with zero stubs does not guard its network**: a deliberate, documented boundary.
|
|
486
|
+
- **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)).
|
|
487
|
+
|
|
488
|
+
## Pitfalls
|
|
489
|
+
|
|
490
|
+
- **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.
|
|
491
|
+
- **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.
|
|
492
|
+
- **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.
|
|
493
|
+
- **Writing a call count as a loop.** `for (...) chain.intercept(garbage)` is `times: 8`. The loop hides the number the spec is actually asserting.
|
|
494
|
+
- **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.
|
|
495
|
+
- **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.
|
|
496
|
+
- **Duplicating a payload.** The 45-line article exists ONCE, as a file of `__fixtures__/`; the second stub imports it.
|
|
497
|
+
|
|
498
|
+
## Related
|
|
499
|
+
|
|
500
|
+
[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)
|