@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.
Files changed (309) hide show
  1. package/README.md +45 -171
  2. package/dist/browser/index.js +1 -2980
  3. package/dist/browser/services.js +1 -0
  4. package/dist/browser/stubs.js +1 -0
  5. package/dist/browser-shared-index.browser.js +3233 -0
  6. package/dist/checker.d.ts +6 -1
  7. package/dist/checker.js +3721 -2624
  8. package/dist/config.d.ts +40 -0
  9. package/dist/config.js +309 -0
  10. package/dist/in-place.global-setup.d.ts +12 -0
  11. package/dist/in-place.global-setup.js +21 -0
  12. package/dist/index.d.ts +1641 -3004
  13. package/dist/index.js +5095 -3835
  14. package/dist/mobile-session.global-setup.d.ts +15 -0
  15. package/dist/mobile-session.global-setup.js +31 -0
  16. package/dist/oxlint.cjs +6069 -5634
  17. package/dist/oxlint.d.cts +43 -7212
  18. package/dist/oxlint.d.ts +43 -7212
  19. package/dist/oxlint.js +3979 -5799
  20. package/dist/route-coverage.global-setup.d.ts +43 -0
  21. package/dist/route-coverage.global-setup.js +1 -0
  22. package/dist/services.d.ts +3 -0
  23. package/dist/services.global-setup.d.ts +43 -0
  24. package/dist/services.global-setup.js +1 -0
  25. package/dist/services.js +597 -0
  26. package/dist/shared-appium.adapter.js +428 -0
  27. package/dist/shared-artifacts.js +34 -0
  28. package/dist/shared-chapter.js +7 -0
  29. package/dist/shared-container.port.d.ts +31 -0
  30. package/dist/shared-derived-projects.js +73 -0
  31. package/dist/shared-device.port.d.ts +189 -0
  32. package/dist/shared-exchange.js +447 -0
  33. package/dist/shared-in-place.js +172 -0
  34. package/dist/shared-index.services.d.ts +346 -0
  35. package/dist/shared-index.stubs.d.ts +287 -0
  36. package/dist/shared-json-shape.js +17 -0
  37. package/dist/shared-landmarks.js +71 -0
  38. package/dist/shared-ledger.js +68 -0
  39. package/dist/shared-listened-port.js +19 -0
  40. package/dist/shared-literate-plugin.js +184 -0
  41. package/dist/shared-manifest.js +1321 -0
  42. package/dist/shared-pause.js +22 -0
  43. package/dist/shared-peer.js +88 -0
  44. package/dist/shared-playwright.adapter.js +328 -0
  45. package/dist/shared-process-alive.js +13 -0
  46. package/dist/shared-process.adapter.js +74 -0
  47. package/dist/shared-record-key.js +13 -0
  48. package/dist/shared-refusal.js +35 -0
  49. package/dist/shared-resolve.js +24 -0
  50. package/dist/shared-route-coverage.global-setup.js +187 -0
  51. package/dist/shared-scenario-failure.js +198 -0
  52. package/dist/shared-serve.js +407 -0
  53. package/dist/shared-service-images.js +10 -0
  54. package/dist/shared-services.global-setup.js +174 -0
  55. package/dist/shared-session-ledger.js +305 -0
  56. package/dist/shared-setup-entry.js +12 -0
  57. package/dist/shared-source-scan.js +133 -0
  58. package/dist/shared-spec-document.js +303 -0
  59. package/dist/shared-spec-grammar.js +66 -0
  60. package/dist/shared-specifications.js +26 -0
  61. package/dist/shared-specs-modules.js +1033 -0
  62. package/dist/shared-stub-reply.js +167 -0
  63. package/dist/shared-stub-server.js +142 -0
  64. package/dist/shared-temp-root.global-setup.js +73 -0
  65. package/dist/shared-test-baseline.js +26 -0
  66. package/dist/shared-test-section.js +58 -0
  67. package/dist/shared-testcontainers.adapter.js +306 -0
  68. package/dist/shared-tokens.js +55 -0
  69. package/dist/shared-yaml-document.js +119 -0
  70. package/dist/stubs.d.ts +2 -0
  71. package/dist/stubs.js +657 -0
  72. package/dist/temp-root.global-setup.d.ts +7 -0
  73. package/dist/temp-root.global-setup.js +1 -0
  74. package/dist/vitest.d.ts +93 -164
  75. package/dist/vitest.js +382 -550
  76. package/docs/README.md +82 -36
  77. package/docs/concepts/architecture.md +118 -0
  78. package/docs/concepts/conventions.md +333 -0
  79. package/docs/guides/api-specs.md +374 -0
  80. package/docs/guides/cli-specs.md +566 -0
  81. package/docs/guides/component-specs.md +252 -0
  82. package/docs/guides/develop.md +97 -0
  83. package/docs/guides/integration-specs.md +181 -0
  84. package/docs/guides/job-specs.md +180 -0
  85. package/docs/guides/lint-specs.md +174 -0
  86. package/docs/guides/mobile-specs.md +319 -0
  87. package/docs/guides/module-tests.md +149 -0
  88. package/docs/guides/release.md +44 -0
  89. package/docs/guides/spec-documents.md +189 -0
  90. package/docs/guides/website-specs.md +413 -0
  91. package/docs/reference/assertions.md +345 -0
  92. package/docs/reference/glossary.md +20 -0
  93. package/docs/reference/linting.md +483 -0
  94. package/docs/reference/services.md +236 -0
  95. package/docs/reference/stubs.md +500 -0
  96. package/docs/reference/time-and-doubles.md +137 -0
  97. package/docs/reference/tokens.md +258 -0
  98. package/docs/reference/visitor.md +221 -0
  99. package/docs/start/getting-started.md +187 -0
  100. package/docs/start/set-up-a-project.md +228 -0
  101. package/package.json +56 -36
  102. package/schema/spec.schema.json +43 -17
  103. package/dist/appium.adapter.js +0 -489
  104. package/dist/artifacts.js +0 -32
  105. package/dist/fixtures.js +0 -110
  106. package/dist/literate.js +0 -1747
  107. package/dist/match.js +0 -804
  108. package/dist/playwright.adapter.js +0 -438
  109. package/dist/server.js +0 -160
  110. package/dist/stream.js +0 -321
  111. package/dist/substring-warning.js +0 -282
  112. package/docs/01-architecture.md +0 -95
  113. package/docs/02-developing.md +0 -351
  114. package/docs/03-testing.md +0 -495
  115. package/docs/04-operating.md +0 -101
  116. package/docs/05-module-tests.md +0 -152
  117. package/docs/06-integration.md +0 -180
  118. package/docs/07-component.md +0 -246
  119. package/docs/08-website.md +0 -404
  120. package/docs/09-mobile.md +0 -270
  121. package/docs/10-api.md +0 -264
  122. package/docs/11-jobs.md +0 -157
  123. package/docs/12-cli.md +0 -677
  124. package/docs/13-elements.md +0 -248
  125. package/docs/14-assertions.md +0 -314
  126. package/docs/15-tokens.md +0 -253
  127. package/docs/16-contracts.md +0 -438
  128. package/docs/17-services.md +0 -188
  129. package/docs/18-conventions.md +0 -332
  130. package/docs/19-linting.md +0 -512
  131. package/docs/decisions/001-v9-conventions-on-three-channels.md +0 -50
  132. package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +0 -49
  133. package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +0 -84
  134. package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +0 -81
  135. package/docs/decisions/005-the-doubles-ladder-is-closed.md +0 -86
  136. package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +0 -245
  137. package/docs/decisions/007-compose-mode-leaves-the-package.md +0 -53
  138. package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +0 -75
  139. package/docs/decisions/_template.md +0 -22
  140. package/docs/reference/classes/Matcher.md +0 -120
  141. package/docs/reference/classes/ProcessHandle.md +0 -368
  142. package/docs/reference/functions/defineContract.md +0 -39
  143. package/docs/reference/functions/defineContracts.md +0 -36
  144. package/docs/reference/functions/disabled.md +0 -28
  145. package/docs/reference/functions/enabled.md +0 -20
  146. package/docs/reference/functions/focused.md +0 -26
  147. package/docs/reference/functions/mockOf.md +0 -79
  148. package/docs/reference/functions/postgres.md +0 -31
  149. package/docs/reference/functions/process.md +0 -33
  150. package/docs/reference/functions/redis.md +0 -28
  151. package/docs/reference/functions/required.md +0 -41
  152. package/docs/reference/functions/selected.md +0 -26
  153. package/docs/reference/functions/sqlite.md +0 -33
  154. package/docs/reference/functions/testId.md +0 -20
  155. package/docs/reference/functions/text.md +0 -40
  156. package/docs/reference/functions/valued.md +0 -28
  157. package/docs/reference/functions/waitUntil.md +0 -46
  158. package/docs/reference/functions/within.md +0 -29
  159. package/docs/reference/index.md +0 -196
  160. package/docs/reference/interfaces/BaseResult.md +0 -87
  161. package/docs/reference/interfaces/CallResult.md +0 -160
  162. package/docs/reference/interfaces/CaptureScope.md +0 -82
  163. package/docs/reference/interfaces/CliResult.md +0 -253
  164. package/docs/reference/interfaces/ContainerAccessor.md +0 -166
  165. package/docs/reference/interfaces/DirectoryAccessor.md +0 -33
  166. package/docs/reference/interfaces/FetchResult.md +0 -180
  167. package/docs/reference/interfaces/FilesystemAccessor.md +0 -46
  168. package/docs/reference/interfaces/HttpResult.md +0 -126
  169. package/docs/reference/interfaces/JsonAccessor.md +0 -46
  170. package/docs/reference/interfaces/PageResult.md +0 -356
  171. package/docs/reference/interfaces/RenderResult.md +0 -72
  172. package/docs/reference/interfaces/RenderedText.md +0 -110
  173. package/docs/reference/interfaces/ResponseAccessor.md +0 -47
  174. package/docs/reference/interfaces/ScreenResult.md +0 -128
  175. package/docs/reference/interfaces/TableAccessor.md +0 -26
  176. package/docs/reference/interfaces/TextAccessor.md +0 -96
  177. package/docs/reference/type-aliases/ApiHandle.md +0 -68
  178. package/docs/reference/type-aliases/ApiSpecification.md +0 -213
  179. package/docs/reference/type-aliases/ApiSpecificationOptions.md +0 -68
  180. package/docs/reference/type-aliases/BrowserConsoleMessage.md +0 -30
  181. package/docs/reference/type-aliases/BrowserLinkElement.md +0 -50
  182. package/docs/reference/type-aliases/BrowserMetaElement.md +0 -40
  183. package/docs/reference/type-aliases/BrowserOpenOptions.md +0 -87
  184. package/docs/reference/type-aliases/BrowserPage.md +0 -132
  185. package/docs/reference/type-aliases/BrowserPort.md +0 -51
  186. package/docs/reference/type-aliases/CliEnv.md +0 -11
  187. package/docs/reference/type-aliases/CliHandle.md +0 -80
  188. package/docs/reference/type-aliases/CliInput.md +0 -36
  189. package/docs/reference/type-aliases/CliOutput.md +0 -46
  190. package/docs/reference/type-aliases/CliPort.md +0 -58
  191. package/docs/reference/type-aliases/CliSpecification.md +0 -144
  192. package/docs/reference/type-aliases/CliSpecificationOptions.md +0 -134
  193. package/docs/reference/type-aliases/ComponentChain.md +0 -156
  194. package/docs/reference/type-aliases/ComponentScenario.md +0 -20
  195. package/docs/reference/type-aliases/ComponentUi.md +0 -47
  196. package/docs/reference/type-aliases/ComponentVisitor.md +0 -10
  197. package/docs/reference/type-aliases/ContainerPort.md +0 -113
  198. package/docs/reference/type-aliases/Contract.md +0 -68
  199. package/docs/reference/type-aliases/ContractInput.md +0 -13
  200. package/docs/reference/type-aliases/ContractRequest.md +0 -95
  201. package/docs/reference/type-aliases/ContractResponder.md +0 -23
  202. package/docs/reference/type-aliases/ContractResponse.md +0 -78
  203. package/docs/reference/type-aliases/ContractResponseValue.md +0 -13
  204. package/docs/reference/type-aliases/Contracts.md +0 -48
  205. package/docs/reference/type-aliases/DatabaseKeys.md +0 -16
  206. package/docs/reference/type-aliases/DatabasePort.md +0 -72
  207. package/docs/reference/type-aliases/DeviceOpenOptions.md +0 -46
  208. package/docs/reference/type-aliases/DevicePort.md +0 -51
  209. package/docs/reference/type-aliases/DeviceScreen.md +0 -32
  210. package/docs/reference/type-aliases/DeviceTimeouts.md +0 -39
  211. package/docs/reference/type-aliases/DockerSpecConfig.md +0 -43
  212. package/docs/reference/type-aliases/DomMount.md +0 -21
  213. package/docs/reference/type-aliases/ElementKind.md +0 -27
  214. package/docs/reference/type-aliases/ElementMatch.md +0 -74
  215. package/docs/reference/type-aliases/ElementOptions.md +0 -29
  216. package/docs/reference/type-aliases/ElementRef.md +0 -128
  217. package/docs/reference/type-aliases/ExecOptions.md +0 -37
  218. package/docs/reference/type-aliases/FileAccessor.md +0 -54
  219. package/docs/reference/type-aliases/HonoApp.md +0 -31
  220. package/docs/reference/type-aliases/HttpContractFilter.md +0 -49
  221. package/docs/reference/type-aliases/HttpResponseInit.md +0 -46
  222. package/docs/reference/type-aliases/HttpStreamInit.md +0 -58
  223. package/docs/reference/type-aliases/IntegrationHandle.md +0 -46
  224. package/docs/reference/type-aliases/IntegrationSpecification.md +0 -111
  225. package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +0 -42
  226. package/docs/reference/type-aliases/Intercept.md +0 -19
  227. package/docs/reference/type-aliases/InterceptOptions.md +0 -28
  228. package/docs/reference/type-aliases/InterceptScope.md +0 -11
  229. package/docs/reference/type-aliases/IsolationStrategy.md +0 -75
  230. package/docs/reference/type-aliases/JobHandle.md +0 -34
  231. package/docs/reference/type-aliases/JobsHandle.md +0 -57
  232. package/docs/reference/type-aliases/JobsResult.md +0 -16
  233. package/docs/reference/type-aliases/JobsSpecification.md +0 -94
  234. package/docs/reference/type-aliases/JobsSpecificationOptions.md +0 -56
  235. package/docs/reference/type-aliases/LandmarkKind.md +0 -20
  236. package/docs/reference/type-aliases/LiterateRunFlags.md +0 -27
  237. package/docs/reference/type-aliases/LiterateServeRegistration.md +0 -70
  238. package/docs/reference/type-aliases/MatchFixtureOptions.md +0 -27
  239. package/docs/reference/type-aliases/MatchableRequest.md +0 -61
  240. package/docs/reference/type-aliases/MatcherKind.md +0 -34
  241. package/docs/reference/type-aliases/MobileBackendOptions.md +0 -28
  242. package/docs/reference/type-aliases/MobileElementKind.md +0 -14
  243. package/docs/reference/type-aliases/MobileElementMatch.md +0 -60
  244. package/docs/reference/type-aliases/MobileElementRef.md +0 -20
  245. package/docs/reference/type-aliases/MobileHandle.md +0 -65
  246. package/docs/reference/type-aliases/MobileScenario.md +0 -20
  247. package/docs/reference/type-aliases/MobileSpecification.md +0 -51
  248. package/docs/reference/type-aliases/MobileSpecificationOptions.md +0 -126
  249. package/docs/reference/type-aliases/MobileVisitor.md +0 -81
  250. package/docs/reference/type-aliases/MockOfOptions.md +0 -26
  251. package/docs/reference/type-aliases/MockPort.md +0 -10
  252. package/docs/reference/type-aliases/PinnedClock.md +0 -10
  253. package/docs/reference/type-aliases/PostgresOptions.md +0 -32
  254. package/docs/reference/type-aliases/ProcessOptions.md +0 -112
  255. package/docs/reference/type-aliases/RedisOptions.md +0 -20
  256. package/docs/reference/type-aliases/RenderSubject.md +0 -12
  257. package/docs/reference/type-aliases/ScreenNode.md +0 -67
  258. package/docs/reference/type-aliases/ServerPort.md +0 -36
  259. package/docs/reference/type-aliases/ServerResponse.md +0 -46
  260. package/docs/reference/type-aliases/ServerSpec.md +0 -21
  261. package/docs/reference/type-aliases/ServiceHandle.md +0 -216
  262. package/docs/reference/type-aliases/ServiceRecord.md +0 -12
  263. package/docs/reference/type-aliases/SpecDocument.md +0 -84
  264. package/docs/reference/type-aliases/SpecEnvToken.md +0 -21
  265. package/docs/reference/type-aliases/SpecFileAssertion.md +0 -27
  266. package/docs/reference/type-aliases/SpecFixture.md +0 -30
  267. package/docs/reference/type-aliases/SpecKind.md +0 -8
  268. package/docs/reference/type-aliases/SpecRun.md +0 -108
  269. package/docs/reference/type-aliases/SpecServeEntry.md +0 -40
  270. package/docs/reference/type-aliases/SpecStream.md +0 -32
  271. package/docs/reference/type-aliases/SpecificationConfig.md +0 -290
  272. package/docs/reference/type-aliases/SqliteOptions.md +0 -36
  273. package/docs/reference/type-aliases/SseEvent.md +0 -58
  274. package/docs/reference/type-aliases/StreamBody.md +0 -61
  275. package/docs/reference/type-aliases/TextFilter.md +0 -22
  276. package/docs/reference/type-aliases/VisitScenario.md +0 -20
  277. package/docs/reference/type-aliases/Visitor.md +0 -215
  278. package/docs/reference/type-aliases/WaitUntilOptions.md +0 -46
  279. package/docs/reference/type-aliases/WebsiteBackendOptions.md +0 -37
  280. package/docs/reference/type-aliases/WebsiteHandle.md +0 -51
  281. package/docs/reference/type-aliases/WebsiteSpecification.md +0 -118
  282. package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +0 -40
  283. package/docs/reference/variables/anthropic.md +0 -19
  284. package/docs/reference/variables/banner.md +0 -10
  285. package/docs/reference/variables/button.md +0 -10
  286. package/docs/reference/variables/clock.md +0 -16
  287. package/docs/reference/variables/complementary.md +0 -10
  288. package/docs/reference/variables/component.md +0 -10
  289. package/docs/reference/variables/content.md +0 -10
  290. package/docs/reference/variables/contentinfo.md +0 -10
  291. package/docs/reference/variables/dialog.md +0 -10
  292. package/docs/reference/variables/field.md +0 -10
  293. package/docs/reference/variables/form.md +0 -10
  294. package/docs/reference/variables/heading.md +0 -10
  295. package/docs/reference/variables/http.md +0 -40
  296. package/docs/reference/variables/intercept.md +0 -11
  297. package/docs/reference/variables/link.md +0 -10
  298. package/docs/reference/variables/listitem.md +0 -10
  299. package/docs/reference/variables/main.md +0 -10
  300. package/docs/reference/variables/match.md +0 -8
  301. package/docs/reference/variables/navigation.md +0 -10
  302. package/docs/reference/variables/openai.md +0 -21
  303. package/docs/reference/variables/option.md +0 -17
  304. package/docs/reference/variables/region.md +0 -10
  305. package/docs/reference/variables/row.md +0 -10
  306. package/docs/reference/variables/search.md +0 -10
  307. package/docs/reference/variables/specification.md +0 -8
  308. package/docs/reference/variables/status.md +0 -10
  309. 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)