@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
@@ -1,438 +0,0 @@
1
- # 16 — Contracts: the outside world, declared
2
-
3
- Everything the outside world replies is declared as a **contract**: 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. Contracts are the whole vocabulary, on all four facets that reach the network.
4
-
5
- ## The principle
6
-
7
- > **TypeScript is behavior. `.json` is data. `.http` is a document at the boundary of YOUR OWN api** (`_requests/`, `_expected/`) — never a mock format.
8
-
9
- A contract 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 contract 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.
10
-
11
- ## `defineContract`
12
-
13
- ```typescript
14
- import { defineContract, http } from '@jterrazz/test';
15
-
16
- export default defineContract({
17
- request: http.get('/articles/{{uuid}}'),
18
- response: http.json({ id: 'a-1', title: 'Fauci and the lab leak' }),
19
- });
20
- ```
21
-
22
- | Field | Meaning |
23
- | ----------- | --------------------------------------------------------------------------------------------------------------------------- |
24
- | `request` | What outgoing call this contract speaks for — a provider builder (`http.*`, `openai.*`, `anthropic.*`) |
25
- | `response` | The reply: a fixed value, or `(request) => ContractResponse` evaluated per served request |
26
- | `times?` | How many times it may serve. **Omitted = unlimited** (a re-render or a retry replays it). `n` = exhausted after `n` serves. |
27
- | `required?` | The chain FAILS unless it was actually requested — at least once, or exactly `times` times when `times` is set. |
28
-
29
- ### Selection
30
-
31
- One engine-agnostic queue serves every facet. An observed request matches a contract when the method matches (`'*'` accepted), the url matches, the declared query params and headers are a **subset** of the observed ones, and the `match` predicate — if any — passes on the body.
32
-
33
- **The first contract in list order that matches AND is not exhausted wins.** That single rule covers the two shapes you need:
34
-
35
- ```typescript
36
- import { defineContracts, http } from '@jterrazz/test';
37
-
38
- // A retry sequence: three failures, then an unlimited tail.
39
- export default defineContracts(
40
- { request: http.get('/quote'), response: http.error(500), times: 3 },
41
- { request: http.get('/quote'), response: http.json({ quote: 'recovered' }) },
42
- );
43
- ```
44
-
45
- If every matching contract is exhausted, or nothing matches at all, the spec fails (strict — see [below](#strict-by-construction-rule-d7)). A chain that declares **no** contract is not guarded at all: strictness begins with the first `.intercept()`.
46
-
47
- ### `times` and `required` — saying what you mean
48
-
49
- `times` is how a spec asserts a **call count** without reaching for a spy: `times: 3` on a failing contract 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:
50
-
51
- ```typescript
52
- import { defineContract, http } from '@jterrazz/test';
53
-
54
- // If the screen never asks for the 1M range, the spec fails — instead of
55
- // passing green on a chart that silently rendered the default range.
56
- export default defineContract({
57
- request: http.get('/indicators', { query: { range: '1M' } }),
58
- required: true,
59
- response: http.json({ points: [] }),
60
- });
61
- ```
62
-
63
- Without `required`, a contract nobody calls is invisible: the test passes, and the assertion it was supposed to protect never ran.
64
-
65
- ## `defineContracts` — it's contracts all the way down
66
-
67
- A test imports **one** artifact: the world its feature lives in. `defineContracts` composes contracts, lists, and other composites into it, recursively, order preserved.
68
-
69
- ```typescript
70
- import { defineContracts, http } from '@jterrazz/test';
71
-
72
- const events = defineContracts({
73
- request: http.get('/events'),
74
- response: http.json({ items: [] }),
75
- });
76
-
77
- // A composite extends a composite — same function, all the way down.
78
- export default defineContracts(events, {
79
- request: http.get('/articles/{{uuid}}'),
80
- response: http.json({ id: 'a-1' }),
81
- });
82
- ```
83
-
84
- ### `.with()` — replacement by route, then prepend
85
-
86
- `.with(...)` derives a **variant** without mutating the base. Two steps, in this order:
87
-
88
- 1. Every base contract 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).
89
- 2. The overrides are **prepended**, keeping their own order.
90
-
91
- 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 contract survives — but the override is checked first, so the one article that is gone answers 410 while every other article still answers 200.
92
-
93
- ```typescript
94
- import { defineContract, defineContracts, http } from '@jterrazz/test';
95
-
96
- const newsroom = defineContracts(
97
- { request: http.get('/events'), response: http.json({ items: [] }) },
98
- { request: http.get('/articles/{{uuid}}'), response: http.json({ id: 'a-1' }) },
99
- );
100
-
101
- export default newsroom;
102
-
103
- // A scenario: one article is gone, the rest of the world is unchanged.
104
- export const withArticleGone = (id: string) =>
105
- newsroom.with(
106
- defineContract({
107
- request: http.get(`/articles/${id}`),
108
- response: http.error(410, { error: 'gone' }),
109
- }),
110
- );
111
- ```
112
-
113
- ## The facade pattern
114
-
115
- The file layout follows the same split: a **public facade** per feature, **internal units** under it.
116
-
117
- ```
118
- specs/<facet>/<feature>/
119
- ├── <feature>.test.ts
120
- └── contracts/
121
- ├── newsroom.contracts.ts PUBLIC — default export = the world,
122
- │ named exports = its scenarios
123
- ├── http/
124
- │ ├── events.ts a unit contract
125
- │ ├── events.response.json the payload it serves
126
- │ ├── events.fr.response.json a qualified payload — same owner
127
- │ └── article-gone.ts a factory: (id) => Contract
128
- ├── openai/
129
- │ ├── classify-article.ts
130
- │ └── classify-article.request.ts the exact prompt it matches
131
- └── anthropic/
132
- └── draft-reply.ts
133
- ```
134
-
135
- The rules, enforced by [C4, C10 and C11](19-linting.md):
136
-
137
- - The **root** holds only `*.contracts.ts` facades and the provider directories `http`, `openai`, `anthropic`. The folder carries the provider — filenames drop it and go back to being business names.
138
- - A facade default-exports a `defineContracts(...)` composition; its **named exports are scenario factories** (`withArticleGone(id)`), so every variant of the world is named next to the world.
139
- - A unit contract is `<provider>/<kebab-name>.ts` and default-exports `defineContract(...)` or a factory returning one. Its `request` builder must name its own folder.
140
- - Data with real mass goes to a sibling `.response.json` (served) or `.request.ts` (matched). The stem before the FIRST dot pairs the data with its contract: `events.fr.response.json` belongs to `events.ts`. An orphan payload is an error.
141
- - **Tests import only `*.contracts.ts`.** Provider folders are internal — reaching into one from a test is an error (C10). A scenario belongs in the facade, not in the test.
142
- - Shared data has ONE owner: a second contract imports `./events.response.json` directly. No copies, no `shared/` folder.
143
-
144
- A unit contract may import `@jterrazz/test`, its neighbours inside `contracts/`, **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".
145
-
146
- ```typescript
147
- // contracts/newsroom.contracts.ts — the facade a test imports
148
- import { defineContracts } from '@jterrazz/test';
149
-
150
- import articleGone from './http/article-gone.js';
151
- import events from './http/events.js';
152
-
153
- const newsroom = defineContracts(events);
154
-
155
- export default newsroom;
156
-
157
- export const withArticleGone = (id: string) => newsroom.with(articleGone(id));
158
- ```
159
-
160
- ```typescript
161
- // contracts/http/events.ts — a unit, with its payload beside it
162
- import { defineContract, http } from '@jterrazz/test';
163
-
164
- import payload from './events.response.json' with { type: 'json' };
165
-
166
- export default defineContract({
167
- request: http.get('/events'),
168
- response: http.json(payload),
169
- });
170
- ```
171
-
172
- ## Using them: `.intercept()`
173
-
174
- Every facet that reaches the network takes the same three forms:
175
-
176
- | Form | Use |
177
- | ----------------------------------- | ---------------------------------------------------------- |
178
- | `.intercept(contracts)` | A composite — the normal case, one import per feature |
179
- | `.intercept(contract)` / `([a, b])` | A single contract or a list |
180
- | `.intercept(request, response)` | An inline pair, for one-off plumbing that deserves no name |
181
-
182
- ```typescript
183
- import { http } from '@jterrazz/test';
184
- import { expect, test } from 'vitest';
185
-
186
- import { api } from '../api.specification.js';
187
- import newsroom from './contracts/newsroom.contracts.js';
188
-
189
- test('serves the declared world', async () => {
190
- // Given - a one-off inline pair alongside the feature's world
191
- const result = await api
192
- .intercept(newsroom)
193
- .intercept(http.any(/analytics\.example/), http.json({ ok: true }))
194
- .get('/report');
195
-
196
- // Then - the report rendered
197
- expect(result.status).toBe(200);
198
- });
199
- ```
200
-
201
- Repeated `.intercept()` calls **append**. Composition and override semantics live in `defineContracts` / `.with()`, never in call order.
202
-
203
- ## `intercept()` — the same double, with no chain
204
-
205
- A module test has no runner and therefore no chain to hang contracts on, and its subject can still reach the network. `intercept(...)` is that case, and it takes the same three forms:
206
-
207
- ```typescript
208
- import { http, intercept } from '@jterrazz/test';
209
- import { expect, test } from 'vitest';
210
-
211
- test('reads the rate off the provider', async () => {
212
- // Given - the one call this module makes, declared for the block
213
- await using _ = await intercept(http.get('https://rates.test/eur'), http.json({ rate: 1.07 }));
214
-
215
- // Then - the module answers with what the provider replied
216
- const response = await fetch('https://rates.test/eur');
217
- await expect(response.json()).resolves.toStrictEqual({ rate: 1.07 });
218
- });
219
- ```
220
-
221
- The scope is an `AsyncDisposable`: the handlers come off at the end of the block however the block ends, and an outgoing request no contract accepted is thrown THERE — the same strictness a chain gets (rule D7), which is what makes it a guard and not a convenience.
222
-
223
- **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.
224
-
225
- `await using _ = await intercept(…)` carries two awaits and both are load-bearing. The inner one waits for the engine 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.
226
-
227
- 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:
228
-
229
- ```typescript
230
- async function rateProvider() {
231
- return await intercept(http.get('https://rates.test/eur'), http.json({ rate: 1.07 }));
232
- }
233
-
234
- // In each test: await using _ = await rateProvider();
235
- ```
236
-
237
- 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.
238
-
239
- 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 F6 and F8 refuse a test and a consumer respectively.
240
-
241
- **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 contract can answer it. `intercept(contracts, { origin: 'http://console.test' })` gives the block that base — the relative call resolves against it, the contracts match as they would any other request (a path-form contract 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.
242
-
243
- ```typescript
244
- await using _ = await intercept(posts, { origin: 'http://console.test' });
245
- ```
246
-
247
- **An empty list is refused.** `intercept(defineContracts())` guards nothing, and a subject with no network needs no intercept at all — the message says so. "This subject makes no network call" is said the other way round: declare the call with `http.unreachable()` on every feed it could reach, and the spec fails if it makes one.
248
-
249
- **A cancelled body does not settle, and here is why.** `response.body.cancel()` never resolves while msw is listening. Reading the same body with `.text()`/`.json()` resolves at once, and so does a `getReader().read()`; only the cancellation hangs.
250
-
251
- The cause is msw's, above the interceptor, and it is the STREAMS specification working as written: msw hands the caller one branch of a TEED stream — the other branch is its own, for the `response` lifecycle event it emits — and a tee branch's `cancel()` resolves only once BOTH branches have been cancelled. msw never cancels its own, so the caller's promise stays pending forever.
252
-
253
- Four measurements place it, on msw 2.15.0 with `@mswjs/interceptors` 0.41.9 under node 24:
254
-
255
- | Setup | `body.cancel()` |
256
- | ------------------------------------------------ | --------------- |
257
- | a real server, nothing installed | 12 ms |
258
- | `FetchInterceptor` alone, passing through | 12 ms |
259
- | msw listening, the request passing THROUGH to it | never |
260
- | msw listening, the reply MOCKED | never |
261
-
262
- So it is neither undici, nor the mocked body, nor `@mswjs/interceptors`, nor this package's seam: a passthrough request hangs exactly as a mocked one does, and the one thing both have that the interceptor alone has not is msw's tee. The nearest upstream issue, [mswjs/interceptors#799](https://github.com/mswjs/interceptors/issues/799), is a passthrough body closed as fixed, and it is the wrong layer. The repro is `specs/api/intercepts/body-cancel.spec.ts`, skipped: it is the acceptance test of the fix.
263
-
264
- That is the ONE escape rule M3 sanctions, and it is narrow because the upstream option carries no allow-list. A subject whose own code cancels a body keeps `vi.stubGlobal('fetch')`, behind a directive written on the call that states the defect:
265
-
266
- ```typescript
267
- // oxlint-disable-next-line vitest/no-restricted-vi-methods -- reason: the subject cancels the reply body, which never settles through intercept() under msw's node interceptor (docs/16 § A cancelled body does not settle)
268
- vi.stubGlobal('fetch', fetchStub);
269
- ```
270
-
271
- Every other subject has `intercept()`, and this one gets it back the day the seam answers.
272
-
273
- **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 contracts last and its strictness is total ([07](07-component.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.
274
-
275
- ## Two engines, one queue
276
-
277
- | Facets | Engine | Notes |
278
- | ------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- |
279
- | `api`, `jobs` | **MSW**, in-process | The app runs here, so its outgoing requests pass through this process |
280
- | `website`, `mobile` | the **declared stub backend** (plain `node:http`) | Started by the runner, its URL injected/exposed; CORS and `OPTIONS` handled for you |
281
-
282
- Both consume the **same** selection queue, so a contract behaves identically on either side: first match wins, `times` exhausts, `required` verifies at chain end. The old divergence — consume-once on MSW, sticky-last on the stub — is gone; "sticky" is now just the default (`times` omitted), written down.
283
-
284
- The stub resets between chains, the way databases do: one chain = one terminal action, and its contracts replace the previous chain's wholesale.
285
-
286
- ## Request builders
287
-
288
- ### `http.*` — any URL
289
-
290
- ```typescript
291
- import { http } from '@jterrazz/test';
292
-
293
- http.get('https://rates.example.com/v1/latest');
294
- http.get('/articles/{{uuid}}'); // path form: any origin, structural segment
295
- http.post('https://api.shoply.dev/orders', {
296
- body: { user: { role: 'admin' } },
297
- headers: { 'x-tenant': 'acme' },
298
- query: { lang: 'en' },
299
- });
300
- ```
301
-
302
- `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 ([15 — Tokens](15-tokens.md)).
303
-
304
- The optional `filter` narrows beyond method + URL, and these are **filters**, so subset is the right default:
305
-
306
- - `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.
307
- - `headers` / `query`: subset match (header names case-insensitive); a string value is **exact**, a RegExp is `test()`.
308
-
309
- ### `openai.*` / `anthropic.*`
310
-
311
- | Builder | Targets | Filters |
312
- | ----------------------------------- | --------------------------------------------------------------- | ------------------------------------------------- |
313
- | `openai.chat(filter?)` | Chat Completions API | `model`, `system`, `user`, `tools`, `temperature` |
314
- | `openai.responses(filter?, url?)` | Responses API (AI-SDK style); `url` overrides the endpoint | `model`, `system`, `user`, `tools` |
315
- | `anthropic.messages(filter?, url?)` | Messages API; `url` overrides the endpoint for a custom gateway | `model`, `system`, `user`, `tools` |
316
-
317
- ### Matching: strings are EXACT
318
-
319
- A string filter on a provider builder means **exact equality**. Looser matching is explicit — a RegExp, or the code-only matcher `match.includes(...)`:
320
-
321
- ```typescript
322
- import { defineContract, match, openai } from '@jterrazz/test';
323
-
324
- // Exact — the app's own builder produces the prompt, so it cannot drift.
325
- const PROMPT = 'Classify the article into one of: TECH, POLITICS, SPORT.';
326
-
327
- export default defineContract({
328
- request: openai.chat({ user: PROMPT }),
329
- response: openai.reply({ category: 'TECH' }),
330
- });
331
-
332
- // Looser, on purpose: per-run data makes an exact prompt impractical.
333
- export const byPrefix = defineContract({
334
- request: openai.chat({ user: match.includes('Classify the article') }),
335
- response: openai.reply({ category: 'TECH' }),
336
- });
337
- ```
338
-
339
- Exactness is the default because a substring filter silently cross-matches: two prompts sharing a preamble both match the first contract, 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.
340
-
341
- ## Response builders
342
-
343
- | Response | Produces |
344
- | ------------------------------------------ | ----------------------------------------------------------------------------------- |
345
- | `http.json(body, init?)` | JSON response — `init: { status?, headers?, delay? }` |
346
- | `http.text(body, init?)` | `text/plain` response |
347
- | `http.error(status, body?)` | HTTP error, optional JSON body |
348
- | `http.empty(status = 204)` | Empty response |
349
- | `http.stream(chunks, init?)` | A body that arrives in PIECES — `init: { contentType?, delay?, status?, headers? }` |
350
- | `http.sse(events, init?)` | A server-sent event stream: one chunk per event, `text/event-stream` |
351
- | `http.unreachable()` | No response at all: the request fails in TRANSPORT and the caller's `fetch` rejects |
352
- | `openai.reply(data)` | `data` wrapped in a valid Chat Completions envelope |
353
- | `anthropic.reply(data)` | `data` (object or text) wrapped in a Messages envelope |
354
- | `openai.error(status)` / `anthropic.error` | Provider HTTP error (e.g. `429` rate limit) |
355
- | `openai.timeout()` / `anthropic.timeout()` | A provider that never answers within the caller's timeout |
356
- | `openai.malformed(text)` | HTTP 200 whose assistant content is `text` — an unparseable payload to handle |
357
-
358
- The point: your contract states the **business payload** (`{ category: 'TECH' }`) and the builder produces the provider's full wire format around it.
359
-
360
- `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. Both engines honour it: msw's node interceptor and its service worker share the response shape.
361
-
362
- ### Streamed replies
363
-
364
- 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 engines honour them: msw's server, msw's worker, and the `node:http` stub the website and mobile facets serve from.
365
-
366
- ```typescript
367
- import { http, intercept } from '@jterrazz/test';
368
- import { expect, test } from 'vitest';
369
-
370
- test('renders a streamed reply as its pieces land', async () => {
371
- // Given - a provider answering in three pieces, ten milliseconds apart
372
- await using _ = await intercept(
373
- http.get('https://tokens.test/stream'),
374
- http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 }),
375
- );
376
-
377
- // Then - the reader sees them in that order
378
- const response = await fetch('https://tokens.test/stream');
379
- await expect(response.text()).resolves.toBe('Hello, world');
380
- });
381
- ```
382
-
383
- `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.
384
-
385
- | `SseEvent` field | Becomes |
386
- | ---------------- | ----------------------------------------------------------------- |
387
- | `data` | the `data:` lines — an object is JSON, a string is sent verbatim |
388
- | `event` | the `event:` name; omitted, the client sees the default `message` |
389
- | `id` | the `id:` the client echoes as `Last-Event-ID` when it reconnects |
390
- | `retry` | the `retry:` hint, in milliseconds |
391
-
392
- Provider streams — `openai.reply({ stream: true })` and its Anthropic twin — are not here: no test on the workbench mocks a provider's wire protocol for a stream, and the three that stream mock the AI SDK's model instead. They arrive when one does.
393
-
394
- ### Dynamic responses
395
-
396
- A `response` may be a function of the incoming request — `(request: MatchableRequest) => ContractResponse` — evaluated per served request:
397
-
398
- ```typescript
399
- import { defineContract, http } from '@jterrazz/test';
400
-
401
- export default defineContract({
402
- request: http.post('https://gateway.shoply.dev/v1/echo'),
403
- response: (request) => http.json({ received: request.body }),
404
- });
405
- ```
406
-
407
- 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.
408
-
409
- ## Strict by construction (rule D7)
410
-
411
- The moment a chain declares one contract, the network is guarded: every outgoing request during the action must match a declared, non-exhausted contract. Anything else fails the spec with an explicit error that rejects the action promise (never an unhandled rejection):
412
-
413
- ```
414
- Unmatched outgoing HTTP request during spec: POST https://api.openai.com/v1/chat/completions
415
- Declared contracts:
416
- - POST https://api.openai.com/v1/chat/completions (exhausted)
417
- Every outgoing request of a chain that declares contracts must match one — declare it, or raise `times`.
418
- ```
419
-
420
- At chain end the same channel verifies `required` contracts: one that was declared and never requested fails the spec, naming its route.
421
-
422
- Two scoping notes:
423
-
424
- - **A chain with zero contracts does not guard its network** — a deliberate, documented boundary.
425
-
426
- ## Pitfalls
427
-
428
- - **Reaching into a provider folder from a test.** `import events from './contracts/http/events.js'` is an error (C10). Add a named scenario export to the facade instead — that is what facades are for.
429
- - **Pinning a prompt by copying it.** A 550-line prompt pasted into a contract is a fossil. Import the app's real builder, or match a stable prefix with `match.includes` — the contract pins the _interaction_, not the wording.
430
- - **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.
431
- - **Writing a call count as a loop.** `for (…) chain.intercept(garbage)` is `times: 8`. The loop hides the number the spec is actually asserting.
432
- - **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.
433
- - **Letting a pipeline hit the network.** An unintercepted call is a test escaping the sandbox, not extra realism. Once one contract is declared, D7 turns a stray call into a failure.
434
- - **Duplicating a payload.** The 45-line article exists ONCE, as a `.response.json`; the second contract imports it.
435
-
436
- ## Related
437
-
438
- [10 — API specs](10-api.md) · [11 — Jobs specs](11-jobs.md) · [08 — Website specs](08-website.md) · [09 — Mobile specs](09-mobile.md) · [18 — Conventions](18-conventions.md)
@@ -1,188 +0,0 @@
1
- # 17 — Services: databases, caches, and their init scripts
2
-
3
- Infrastructure is declared as a **named record** of service factories on the runner. The record key is the ONLY name a service has: it is your test vocabulary in `.seed()` / `.table()` targeting, it types the `server`/`jobs` factory parameters, it is the name the startup report prints, and — kebab-cased — it is the folder the service reads its init script from. A handle carries no second name of its own.
4
-
5
- ## Service factories
6
-
7
- All four import from the package root (rule F1):
8
-
9
- ```typescript
10
- import { postgres, process, redis, sqlite } from '@jterrazz/test';
11
- ```
12
-
13
- | Factory | Backing | Options | Connection string shape |
14
- | ------------ | -------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------- |
15
- | `postgres()` | Docker container | `image`, `env` | `postgresql://user:pass@host:port/db` |
16
- | `redis()` | Docker container | `image` | `redis://host:port` |
17
- | `sqlite()` | **No Docker** — a template file copied per worker | `init` (SQL file) or `prismaSchema` (runs `prisma db push`) | `file:/…/….sqlite` |
18
- | `process()` | **No Docker** — a child process the framework owns | `command`, `ready`, `port`, `cwd`, `env`, `before`, `timeout` | `http://127.0.0.1:port` |
19
-
20
- **Each factory names its own optional peer**, and names it at startup rather than at install time: `postgres()` and `redis()` need `testcontainers` (and `pg` / `redis` for the driver a table read uses), `sqlite()` needs `better-sqlite3`, and `process()` needs nothing but node. A project that declares none of them installs none of them — the seams are optional peers since 16.0, loaded lazily by the one module that owns each ([04 — Operating](04-operating.md#what-a-consumer-must-bring)). The refusal, when one is missing, names the facet that asked, the peer, the install command of the package manager the project's own lockfile names, and — under pnpm — the `onlyBuiltDependencies` line a native binding also needs before it is built at all.
21
-
22
- `image` overrides the container image and `env` (postgres only) the environment variables; each factory carries a default for both, so a record that states neither still starts a real service. After the runner starts, each service handle exposes `.connectionString`, which is what you pass to your app:
23
-
24
- ```typescript
25
- export const { api, cleanup } = await specification.api({
26
- services: {
27
- db: postgres(), // → reported as "db", init from docker/db/
28
- analyticsDb: postgres(), // → "analytics-db", init from docker/analytics-db/
29
- cache: redis(), // → "cache"
30
- },
31
- server: ({ db, analyticsDb, cache }) =>
32
- createApp({
33
- databaseUrl: db.connectionString,
34
- analyticsDatabaseUrl: analyticsDb.connectionString,
35
- redisUrl: cache.connectionString,
36
- }),
37
- });
38
- ```
39
-
40
- ## `process()` — the one shape an external process takes
41
-
42
- A site's dev server, an API the site calls, the bundler a simulator loads from: each of them is a command, a way of knowing it is ready, and a lifetime. `process()` is the one shape for all three, and the framework owns the lifetime — without it each would be declared somewhere else, and the last one would be a `beforeAll` spawning a child and an `afterAll` that sometimes forgets to kill it.
43
-
44
- The options type is `ProcessOptions` — one shape for the one external process every facet starts.
45
-
46
- | Option | Means |
47
- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
48
- | `command` | the shell command that starts it, receiving the chosen port as `PORT` |
49
- | `ready` | a **path** polled until it answers (default `/`), or a **RegExp** over the child's output whose one capture group is the port |
50
- | `port` | a fixed port instead of a free one the OS assigns |
51
- | `cwd` | a working directory relative to the project root |
52
- | `env` | the child's environment — an object, or a function of the services started beside it |
53
- | `before` | a one-shot command run ONCE per evaluation of the specification module — under vitest's default isolation, once per test file — which must exit 0: a build, a migration, a fixture load |
54
- | `timeout` | the readiness budget, in milliseconds (default 30 000) |
55
-
56
- It sits in a `services` record like any other service, so it starts after the databases and can be handed their connection strings, and it is stopped with the specification:
57
-
58
- ```typescript
59
- import { postgres, process, specification } from '@jterrazz/test';
60
-
61
- const api = process({
62
- before: 'make build',
63
- command: 'bin/server web --port $PORT',
64
- env: ({ db }) => ({ DATABASE_URL: db.connectionString }),
65
- ready: '/health',
66
- });
67
-
68
- export const { cleanup, website } = await specification.website({
69
- server: (services) =>
70
- process({
71
- command: 'next dev',
72
- env: { NEXT_PUBLIC_API_URL: services.api.connectionString },
73
- }),
74
- services: { api, db: postgres() },
75
- });
76
- ```
77
-
78
- `website()` and `mobile()` take a `services` record for exactly this: a site started beside the API it calls, a simulator started beside its Metro bundler (`process({ command: 'expo start', ready: /Metro waiting on .*:(\d+)/ })`). What used to be a bundler bootstrap the repository maintained is a declaration.
79
-
80
- **The run's id is minted, never sampled.** Every process of one specification is handed `TEST_RUN_ID` in its environment — one value per RUN, where a run is one evaluation of the specification module, so two test files importing the same specification get a child each with an id of its own. A test that needs a label unique to its run reads that variable; it never builds one from `Date.now()` or `Math.random()`, which rule D16 refuses under an oracle and which makes two runs of the same suite disagree.
81
-
82
- ## The services record — three jobs for one key
83
-
84
- 1. **Vocabulary.** The key is how tests target the service: `.seed('x.sql', { database: 'analyticsDb' })`, `result.table('events', { database: 'analyticsDb' })`. Typed — a typo is a compile error.
85
- 2. **Typing.** The `server` / `jobs` factory receives the exact same record, fully typed, once everything is started (rule A8).
86
- 3. **Identity.** The key is the name the service is known by: the startup report prints it, and its **kebab-case** conversion is the folder the init script is read from — `analyticsDb` reports as `analytics-db` and initialises from `docker/analytics-db/init.sql`, with no option to state anywhere:
87
-
88
- ```typescript
89
- services: {
90
- db: postgres(), // → "db"
91
- analyticsDb: postgres(), // → "analytics-db"
92
- }
93
- ```
94
-
95
- In CLI mode, the record additionally drives env injection into the child process: `<KEY>_URL` per service — the key uppercased to **CONSTANT_CASE** at camelCase boundaries (`analyticsDb` → `ANALYTICS_DB_URL`) — plus `DATABASE_URL` / `REDIS_URL` when unambiguous (rule B6 — see [CLI specs](12-cli.md#auto-injected-connection-urls-rule-b6)).
96
-
97
- ### The `database:` rule (A7)
98
-
99
- With **2 or more databases** in the record, `database:` is mandatory on every `.seed()` and `.table()`. With exactly **one**, it is forbidden (redundant). There is no in-between: the option is either always present or never present within a project.
100
-
101
- ```typescript
102
- // one database → no database: anywhere
103
- await migrateCli.seed('legacy-schema.sql').exec('up');
104
- await expect(result.table('schema_migrations')).toMatchRows({ … });
105
-
106
- // two databases → database: everywhere
107
- await api.seed('catalog.sql', { database: 'db' }).request('new-order.http');
108
- await expect(result.table('events', { database: 'analyticsDb' })).toMatchRows({ … });
109
- ```
110
-
111
- ## `docker/<service>/init.sql`
112
-
113
- ```
114
- docker/
115
- ├── db/
116
- │ └── init.sql # schema for the "db" service — runs on service start
117
- └── analytics-db/
118
- └── init.sql # schema for the "analytics-db" service
119
- ```
120
-
121
- - `docker/<service>/init.sql` executes when the corresponding service starts — matched by the **kebab-case of the record key**, so the analytics schema above belongs to `analytics-db/` (the folder `analyticsDb` names).
122
- - A project with one database needs no folder of its own: a postgres handle falls back to `docker/postgres/init.sql` when nothing sits under its own name.
123
- - Testcontainers starts each declared service from the handle's image and environment. There is no second definition of the stack to keep in step.
124
-
125
- ## A contract's `required` is verified per `.call()`
126
-
127
- A chain's scope is ONE terminal action, and a contract'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 contract and then performs two actions has declared it for the first one: the second starts a fresh registration, and a `required` contract unmet by the action it belongs to fails that action's chain.
128
-
129
- This is the same boundary rule B1 already states from the other side — one chain, one action — read from the contract's end. A test that wants a contract to hold across two actions writes two chains, each declaring what it expects to see.
130
-
131
- ## Per-worker isolation (rule G2)
132
-
133
- Vitest runs test files in parallel workers; the framework isolates them automatically — no configuration, no sharding:
134
-
135
- | Service | Isolation strategy |
136
- | ------------ | ----------------------------------- |
137
- | `postgres()` | Cloned schema per worker |
138
- | `redis()` | Dedicated database index per worker |
139
- | `sqlite()` | Template file copied per worker |
140
-
141
- On top of worker isolation, **every chain resets the databases** (rule B1): a spec starts from seeds, never from a previous spec's leftovers.
142
-
143
- ## Root auto-discovery (rule A9)
144
-
145
- The framework locates the project root by walking **up from the specification file** to the **nearest** directory carrying `package.json`, so in a workspace the member being tested wins over the repository root above it. The `root` option is an override for the cases where the convention cannot work (e.g. fixtures deliberately kept outside the package) — pointing `root` at the directory the walk would find anyway is redundant (future lint warning).
146
-
147
- ## SQLite without Docker
148
-
149
- For CLIs (or apps) on SQLite, tests run with zero Docker:
150
-
151
- ```typescript
152
- export const { cli, cleanup } = await specification.cli('shoply', {
153
- services: {
154
- db: sqlite({ init: './schema.sql' }), // schema from a SQL file
155
- // or: db: sqlite({ prismaSchema: './prisma/schema.prisma' })
156
- },
157
- });
158
- ```
159
-
160
- `sqlite()` builds a template database once (from `init` SQL or by running `prisma db push` on `prismaSchema`), then hands each worker its own copy. `.seed()` and `result.table()` work identically to Postgres.
161
-
162
- `prismaSchema` is resolved against the **current working directory** and passed to the CLI as `npx prisma db push --force-reset --schema <absolute path>` — the schema is named explicitly, so it does not have to be the one Prisma would discover from a `prisma.config.ts` at the cwd. Declare no `prismaSchema` and the bare invocation runs, leaving discovery to Prisma.
163
-
164
- ### Where the template lives
165
-
166
- The template is a **project** artefact: `<root>/.artifacts/vitest/sqlite/template-<sha8>.sqlite`, under the root A9 discovered ([above](#root-auto-discovery-rule-a9)) and covered by the same `.gitignore` line as everything else in `.artifacts/` ([02 — Developing](02-developing.md#artefacts-live-under-artifacts)).
167
-
168
- It used to live in the machine-global OS tmpdir, where two checkouts of one repository shared a single file: whichever ran first built it, the other silently inherited that schema, and a branch that changed the schema poisoned the branch beside it. A path under the project root cannot be reached from another checkout at all. Deleting `.artifacts/` is how you force a cold rebuild.
169
-
170
- The file name stays **keyed on the schema** — the digest taken over the kind of schema (`init` / `prismaSchema`), its resolved path and its content. Within one project that is what makes reuse mean "the same schema": edit a schema and the next run builds a new template instead of reading the old one, whose header is perfectly valid and whose tables are wrong.
171
-
172
- ### One worker builds, the others wait
173
-
174
- Workers race for a cold cache, so the build is serialised by a lock beside the template — and a worker that loses the race **waits** for the winner rather than building too. It is taken with an exclusive create, so exactly one worker can win it; the check-then-write it replaced let several believe they had won, and the concurrent `prisma db push` calls that followed died on `database is locked`. A suite kept sequential (`fileParallelism: false`) only for that reason can go parallel again.
175
-
176
- Two more properties fall out of it: the winner builds on a private path and **renames** the finished file into place, so a reader never sees a half-built template (the SQLite header is written long before the tables are); and a lock nobody has touched for two minutes is treated as a crashed holder's and broken, so a killed worker cannot wedge the suite.
177
-
178
- ## Pitfalls
179
-
180
- - **Naming the `init.sql` folder after the raw record key.** The folder is the key's **kebab-case** form: key `analyticsDb` reads `docker/analytics-db/init.sql`, not `docker/analyticsDb/`.
181
- - **Sharing state across specs "because the container is shared".** The container is shared; the data is not — chains reset databases (rule B1), and workers are isolated (rule G2).
182
- - **Passing `root` that auto-discovery would have found.** Redundant override (rule A9).
183
- - **Reading a green local run as proof that `prismaSchema` is wired.** The template is cached across runs, so a checkout that built it once never re-runs `db push` — a schema path that would fail on a fresh clone (or in CI) stays invisible. Delete `.artifacts/vitest/sqlite/` to test the cold path.
184
- - **Counting `sqlite()` + `redis()` as "2 databases" for rule A7.** The rule counts **databases**; with one SQL database and one redis, `database:` stays forbidden on `.seed()`/`.table()` and `DATABASE_URL`/`REDIS_URL` are both unambiguous for CLI injection (rule B6).
185
-
186
- ## Related
187
-
188
- [10 — API specs](10-api.md) · [11 — Jobs specs](11-jobs.md) · [12 — CLI specs](12-cli.md) · [18 — Conventions](18-conventions.md)