@jterrazz/test 16.1.1 → 17.0.0

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