@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
package/docs/12-cli.md DELETED
@@ -1,677 +0,0 @@
1
- # 12 — CLI specs (`specification.cli`)
2
-
3
- `specification.cli(bin, options)` tests a command-line binary. Every spec runs the binary in a **fresh, empty temp directory**, captures stdout/stderr/exit code, and exposes the resulting filesystem — and, in Docker-aware mode, the containers the binary spawned. The CLI is always a local binary.
4
-
5
- Use it when the subject under test is a process invocation. If the process serves HTTP, test that surface with [api](10-api.md).
6
-
7
- ## What it specifies
8
-
9
- A CLI spec answers one question: **given this file tree, this environment and this argv, what does the binary print, what does it exit with, and what does it leave on disk?** The subject is the built command met through a child process — never an imported function, which is a module test's ([05](05-module-tests.md)). Most of them are written as literate documents rather than as code; the three doors below say when each one is right.
10
-
11
- ## The constructor
12
-
13
- ```typescript
14
- // specs/cli/cli.specification.ts
15
- import { resolve } from 'node:path';
16
- import { afterAll } from 'vitest';
17
- import { specification } from '@jterrazz/test';
18
-
19
- export const { cli, cleanup } = await specification.cli(
20
- resolve(import.meta.dirname, '../../bin/shoply.sh'),
21
- );
22
-
23
- afterAll(cleanup);
24
- ```
25
-
26
- ### Options
27
-
28
- | Option | Description |
29
- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
30
- | `root` | **Project-root override** only (rule A9): anchors local-bin resolution and the artefact paths. It is _not_ a fixtures root — `.fixture()` resolves paths on its own. Auto-discovery applies when omitted |
31
- | `services` | Named record of infrastructure services (`postgres()`, `redis()`, `sqlite()`) — connection URLs are auto-injected into the child env (rule B6) |
32
- | `docker` | Opt-in Docker awareness: `{ envVar, nameLabel, testRunLabel }` — see [Docker-aware mode](#docker-aware-mode) |
33
- | `defaults` | Environment applied to **every** run of this binary — the variables the product needs to be deterministic at all (`TZ`, `NO_COLOR`, `LANG`, a config home under the spec's own workdir). Stated once per app; a chained `.env()` and a document's `env:` both win over it |
34
- | `env` | Named **environment sets** a spec document names by bare word (`env: [frozen]`) — see [Spec documents](#spec-documents--casespecyaml) |
35
- | `serve` | Named **servers** a spec document starts (`serve: [mcp]`): `{ command, ready, url, env }` — see [Spec documents](#spec-documents--casespecyaml) |
36
- | `transform` | **Escape hatch only** (rule D6): a normalizer applied to streams before comparison, for application noise not covered by tokens. ANSI stripping is already the default; a transform that only re-implements standard tokens is a lint warning |
37
-
38
- ## The chain
39
-
40
- ### `.exec()` — the single execution method
41
-
42
- `.exec(args?, options?)` is the **only** way to run the binary — there is no `.spawn()` (rule B2). It covers short commands, long-running processes, and sequences. Called with **no arguments**, the binary runs bare (no CLI args) — clearer than the `.exec('')` idiom:
43
-
44
- ```typescript
45
- const result = await cli.exec(); // invoke the binary with no arguments
46
- ```
47
-
48
- Note the asymmetry with the array form: `.exec()` (no args) is a bare invocation, but `.exec([])` (empty array) throws — a command _sequence_ must name at least one command.
49
-
50
- ```typescript
51
- test('shows help', async () => {
52
- // Given
53
- const result = await cli.exec('--help');
54
-
55
- // Then - full stdout snapshot; ANSI already stripped, dynamics covered by tokens
56
- expect(result.exitCode).toBe(0);
57
- expect(result.stdout).toMatch('help.txt');
58
- });
59
- ```
60
-
61
- #### Long-running processes: `waitFor` / `timeout`
62
-
63
- ```typescript
64
- test('dev server boots', async () => {
65
- // Given - long-running: resolved on pattern match, killed at timeout otherwise
66
- const result = await cli
67
- .fixture('$FIXTURES/base-shop/')
68
- .exec('dev --port 0', { waitFor: 'Listening on', timeout: 10_000 });
69
-
70
- // Then
71
- expect(result.stdout).toContain('Listening on');
72
- });
73
- ```
74
-
75
- - `waitFor` — a pattern; the promise resolves (exit code 0) as soon as it appears in **stdout or stderr** (both streams are watched). If the process exits before matching, the result carries a non-zero exit code (a clean exit-0 without the pattern is reported as `1`).
76
- - `timeout` — milliseconds; the process is killed when it elapses without a match, with exit code `124`. **Default: 10 000 ms** when `waitFor` is set without a `timeout`.
77
- - On resolution (match, exit, or timeout) the child is terminated with `SIGTERM`, escalating to `SIGKILL` after a 2 s grace period if it ignores the first signal.
78
- - Termination targets the **whole process group**, not just the direct shell child: the process is spawned detached (its own group on POSIX) and signalled via the negative pid, so descendants — a `tsdown --watch` grandchild under a `dev` command, a background server the script forked — die with it instead of leaking past the spec. On platforms without group support (Windows), or when the group is already gone, it degrades to the direct child kill.
79
- - Passing either option marks the run as long-running; combining them with the array (sequence) form is an error.
80
-
81
- Quoting is identical between the one-shot and `waitFor` forms: both run the full command line through the shell (`spawnSync` vs `spawn`, `shell: true`), so a quoted argument behaves the same either way — there is no naive whitespace split.
82
-
83
- #### Sequences: array form
84
-
85
- ```typescript
86
- test('build then start chain stops at first failure', async () => {
87
- // Given - a sequence in the SAME working directory
88
- const result = await cli.fixture('$FIXTURES/base-shop/').exec(['build', 'start --check']);
89
-
90
- // Then
91
- expect(result.exitCode).toBe(0);
92
- });
93
- ```
94
-
95
- Commands run sequentially in the same cwd; the sequence stops at the first failure. This is still one terminal action — one spec, one `.exec()` (rule B1). An empty array throws (`exec([]) requires at least one command`), and `{ waitFor, timeout }` cannot be combined with the array form.
96
-
97
- ### Working-directory semantics
98
-
99
- Every spec gets a brand-new temp directory (`mkdtemp`) as its cwd. The runner never writes into your fixtures.
100
-
101
- That directory lives in the **OS temp dir**, and it stays there on purpose. It is per-RUN scratch — created for one spec, gone when the run ends — not something the project produced, so it does not belong under `.artifacts/` with the caches a next run reuses (see [02 — Developing](02-developing.md#artefacts-live-under-artifacts)). Writing it inside the project would also change what a spec sees: a binary that walks up looking for a `package.json` would find yours instead of nothing.
102
-
103
- Two disjoint verbs shape a spec's state (rule C7): **`.fixture()` carries file state** (an isolated tree copied into the cwd), **`.seed()` carries database state** (SQL only). Any "transformation" you need is expressed declaratively, in the _shape_ of the fixture tree.
104
-
105
- `.fixture(path)` resolves in one of two ways and copies with rsync's trailing-slash semantics:
106
-
107
- | Path | Resolves to | Copy effect |
108
- | ------------------------ | --------------------------------------------- | ----------------------------------------------- |
109
- | `'config.toml'` | `<domain>/_fixtures/config.toml` (leaf-local) | `<cwd>/config.toml` |
110
- | `'$FIXTURES/base-shop/'` | `<specs-root>/_fixtures/base-shop/` (pool) | **contents spread** into `<cwd>` (trailing `/`) |
111
- | `'$FIXTURES/base-shop'` | `<specs-root>/_fixtures/base-shop/` (pool) | `<cwd>/base-shop/` (dir under its own name) |
112
-
113
- > **⚠️ Trailing slash = spread vs. nest.** For a directory fixture, the trailing `/` is load-bearing — it is exactly rsync's semantics, and it changes where files land:
114
- >
115
- > | Form | Copies | Result in the cwd |
116
- > | ----------------------------- | ------------------------- | ---------------------------- |
117
- > | `.fixture('$FIXTURES/shop/')` | **contents** of `shop/` | `<cwd>/package.json`, … |
118
- > | `.fixture('$FIXTURES/shop')` | the `shop/` **directory** | `<cwd>/shop/package.json`, … |
119
- >
120
- > Almost always you want the **trailing slash** (spread the project into the cwd, so the tool runs at the root). Drop it only when the command expects the project one level down.
121
-
122
- `$FIXTURES` points at `specs/_fixtures/` (the nearest ancestor `specs/` dir + `_fixtures/`); any other `$…` marker is an error. Chained `.fixture()` calls **layer** in order — a later fixture overwrites files from an earlier one.
123
-
124
- | Setup | Effect on the cwd |
125
- | ---------------------------------- | -------------------------------------------------------------------- |
126
- | _(nothing)_ | Empty directory |
127
- | `.fixture('$FIXTURES/base-shop/')` | Spread a whole shared project into the cwd |
128
- | `.fixture('config.toml')` | Copy the feature-local `_fixtures/config.toml` into the cwd |
129
- | `.seed('…')` | SQL into a service database (rule A7 governs the `database:` option) |
130
-
131
- ```typescript
132
- test('scaffolds a new shop', async () => {
133
- // Given - empty cwd (every spec = fresh mkdtemp)
134
- const result = await cli.exec('create my-shop');
135
-
136
- // Then - precise files…
137
- expect(result.exitCode).toBe(0);
138
- expect(result.file('my-shop/shoply.yaml').exists).toBe(true);
139
- expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop');
140
- expect(result.file('my-shop/.env').exists).toBe(false);
141
-
142
- // …or a tree snapshot (structured added/removed/changed diff)
143
- await expect(result.directory('my-shop')).toMatch('shop-scaffold');
144
- });
145
-
146
- test('upgrade rewrites the workspace predictably', async () => {
147
- // Given
148
- const result = await cli.fixture('$FIXTURES/base-shop/').exec('upgrade');
149
-
150
- // Then - snapshot of the ENTIRE cwd + flat read of the file list
151
- await expect(result.filesystem).toMatch('upgraded-shop');
152
- expect(await result.filesystem.files()).toContain('shoply.lock');
153
- });
154
-
155
- test('seeds a single fixture file', async () => {
156
- // Given
157
- const result = await cli.fixture('legacy-config.toml').exec('migrate-config');
158
-
159
- // Then
160
- expect(result.file('shoply.yaml').exists).toBe(true);
161
- });
162
- ```
163
-
164
- Tree snapshots are directories under `_expected/` (`_expected/shop-scaffold/`) — the one `toMatch` argument that takes no extension (rule C6).
165
-
166
- ### `.env()` — child process environment
167
-
168
- ```typescript
169
- test('isolates HOME per spec', async () => {
170
- // Given - HOME on the temp cwd, TZ pinned, stray variable removed
171
- const result = await cli
172
- .env({ HOME: '$WORKDIR', TZ: 'UTC', SHOPLY_TOKEN: null })
173
- .exec('login --offline');
174
-
175
- // Then
176
- expect(result.file('.shoply/credentials').exists).toBe(true);
177
- });
178
- ```
179
-
180
- - `'$WORKDIR'` expands to the spec's temp cwd — pin `HOME`, caches, config dirs onto isolated ground.
181
- - `null` **removes** a variable from the child env — shield the spec from the developer's shell.
182
- - Repeated `.env()` calls merge; later keys win.
183
-
184
- #### Auto-injected connection URLs (rule B6)
185
-
186
- When the runner has `services`, the framework injects into the child env, automatically:
187
-
188
- | Variable | Rule |
189
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
190
- | `<KEY>_URL` | One per record key, uppercased to CONSTANT_CASE at camelCase boundaries: `db:` → `DB_URL`, `analyticsDb:` → `ANALYTICS_DB_URL`, `cache:` → `CACHE_URL` |
191
- | `DATABASE_URL` | Standard alias, only when the record has **exactly one** SQL database |
192
- | `REDIS_URL` | Standard alias, only when the record has **exactly one** redis |
193
-
194
- `.env()` overrides any injected value; `null` removes it. Re-declaring what injection already provides (`.env({ DATABASE_URL: db.connectionString })`) is redundant — future lint warning.
195
-
196
- ```typescript
197
- // One SQL database → DATABASE_URL and DB_URL both injected, no database: option (rule A7)
198
- export const { cli: migrateCli, cleanup } = await specification.cli('shoply-migrate', {
199
- services: { db: postgres() },
200
- });
201
-
202
- test('migrates a legacy schema', async () => {
203
- // Given - DATABASE_URL / DB_URL injected automatically into the child env
204
- const result = await migrateCli.seed('legacy-schema.sql').exec('up');
205
-
206
- // Then
207
- expect(result.exitCode).toBe(0);
208
- await expect(result.table('schema_migrations')).toMatchRows({
209
- columns: ['version'],
210
- rows: [['20260716']],
211
- });
212
- });
213
- ```
214
-
215
- ### File state is the shape of a fixture tree
216
-
217
- CLI-mode `.seed()` is **SQL only** — it carries database state, nothing else (rule C7). To put files or config trees into the cwd, use `.fixture()` with a fixture laid out exactly as the cwd should look. There is no "seed handler" or path-prefix dispatch.
218
-
219
- ```typescript
220
- // specs/cli/deploy/fixtures/two-shops/ ← committed, shaped like the target cwd
221
- // shoply.yaml
222
- // shops/alpha/config.yaml
223
- // shops/beta/config.yaml
224
-
225
- test('deploys from a shaped workspace', async () => {
226
- // Given - the tree is spread into the cwd; layering a second fixture would
227
- // overwrite matching files (last write wins)
228
- const result = await cli.fixture('two-shops/').exec('deploy alpha');
229
-
230
- // Then
231
- expect(result.exitCode).toBe(0);
232
- });
233
- ```
234
-
235
- #### Where a fixture lives
236
-
237
- The two doors are not interchangeable, and the question that picks between them is **how many leaves read this tree**.
238
-
239
- The POOL — `specs/_fixtures/`, reached by `$FIXTURES/` — is for what SEVERAL leaves share. That is the whole of its meaning: an entry there announces "other specs depend on me, change me with care". A tree only one spec directory reaches for is not shared ground, whatever folder it sits in; parked in the pool it collects the pool's caution without earning it, and the one spec that actually owns it is a directory away, invisible to the next reader. It is an error (C15's twin, **C14**), and `jterrazz-test-check --fix` moves it beside its leaf and rewrites the reference.
240
-
241
- A LEAF'S OWN ground sits beside it, as `<domain>/_fixtures/<name>/`, and is reached by the plain relative form — `.fixture('config.toml')`, `.fixture('base-shop/')`. It is read from that leaf and nowhere else: a path that climbs out of its own `_fixtures/` (a `../`, a sibling's tree) is the pool by another door, with none of the pool's visibility — the neighbour's author has no way to know you depend on them, edits their ground, and breaks a spec two folders away. That is **C15**; the repair is to promote the tree to the pool, where its sharing is declared.
242
-
243
- So: one reader, beside the leaf. Two or more, in the pool. Write it local first — promotion is a `--fix` away, and demotion is the rule the checker enforces.
244
-
245
- ### The chain never reaches a real system
246
-
247
- A CLI spec is sandboxed the way an HTTP spec is, and for the same reason: what a chain proves must come out of its fixture, never out of the machine that happened to run it. A chain that shells out to a real `kubectl`, `helm` or `gh`, opens a network connection, or reads the operator's home directory is **a test escaping the sandbox, not extra realism** — it passes on the one laptop where those tools are installed and configured, fails everywhere else, and either way it proves something about that laptop rather than about the binary under test. The HTTP half of the same rule is D7, in [contracts](16-contracts.md).
248
-
249
- The framework supplies the ground; the chain states the sandbox, in three verbs:
250
-
251
- | Verb | What it closes |
252
- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ |
253
- | `.fixture('$FIXTURES/<name>/')` | spreads a `bin/` of stub binaries into the spec's temp cwd |
254
- | `.env({ PATH: '$WORKDIR/bin:/usr/bin:/bin' })` | puts those stubs FIRST — the binary finds the stub, never the tool installed on the host |
255
- | `.env({ HOME: '$WORKDIR' })` | moves config dirs, caches and credential stores onto the temp cwd, off the operator's home |
256
-
257
- `PATH` is not rewritten for you: `.fixture()` lays the tree down, `.env()` decides what the child is allowed to find. Pin the tail of `PATH` too — inheriting the developer's own `PATH` re-opens the door the stub was there to close.
258
-
259
- ```typescript
260
- test('reports what the cluster holds', async () => {
261
- // Given - stubs first on PATH, HOME on the temp cwd: nothing can reach out
262
- const result = await cli
263
- .fixture('$FIXTURES/cluster-stub/')
264
- .env({ HOME: '$WORKDIR', PATH: '$WORKDIR/bin:/usr/bin:/bin', TZ: 'UTC' })
265
- .exec('cluster pods');
266
-
267
- // Then
268
- expect(result.exitCode).toBe(0);
269
- expect(result.stdout).toMatch('pods.txt');
270
- });
271
- ```
272
-
273
- #### The shape of a stub fixture
274
-
275
- A stub fixture is a `bin/` holding one script per binary the run must not find for real:
276
-
277
- ```
278
- specs/_fixtures/cluster-stub/
279
- └── bin/
280
- ├── kubectl
281
- └── helm
282
- ```
283
-
284
- Each script answers **by argv** and refuses everything else. The executable bit survives the copy, so a stub committed `chmod +x` is executable in the cwd:
285
-
286
- ```bash
287
- #!/bin/sh
288
- # Every invocation this suite cans. Anything else is a chain reaching for an
289
- # answer nobody wrote — fail loudly rather than improvise one.
290
- case "$*" in
291
- "get pods -A -o json")
292
- cat <<'JSON'
293
- {"items":[{"metadata":{"name":"api-abc12","namespace":"prod"},"status":{"phase":"Running"}}]}
294
- JSON
295
- ;;
296
- *)
297
- echo "cluster-stub kubectl: unexpected invocation: $*" >&2
298
- exit 64
299
- ;;
300
- esac
301
- ```
302
-
303
- The catch-all matters as much as the canned cases: a stub that shrugs at an unknown invocation hands the chain an empty success, and the spec goes green on a command nobody described.
304
-
305
- **One fixture per ANSWER of the system, not one per test.** A cluster with things in it, that same cluster holding nothing, and a cluster whose API server is down are three fixtures, not three settings of one — they are three answers to the same question, and choosing between them is choosing what the chain proves. So a new answer is a NEW fixture, never a flag threaded through an existing stub: fixtures layer (a drift stub over a healthy one), which is how one is extended without being made conditional.
306
-
307
- **The data-driven form: the script routes, a FILE answers.** When the answer is a document — a JSON payload, a listing, a manifest — heredocs stop being readable and stop layering: two worlds that differ only in what the cluster holds have to differ in the script. So the `case` maps a question to an answer file under `answers/<system>/`, and the script's whole job is routing:
308
-
309
- ```bash
310
- #!/bin/sh
311
- # Route argv to the file that answers it. The ANSWERS are the fixture; this
312
- # Script only says which one the question maps to.
313
- answers="${0%/*}/../answers/cluster"
314
- case "$*" in
315
- "get pods -A -o json") file="$answers/pods.json" ;;
316
- "get nodes -o json") file="$answers/nodes.json" ;;
317
- *) echo "cluster-stub kubectl: unexpected invocation: $*" >&2; exit 64 ;;
318
- esac
319
- [ -f "$file" ] || { echo "cluster-stub kubectl: no answer file at $file" >&2; exit 64; }
320
- cat "$file"
321
- ```
322
-
323
- Two failures, both exit 64, and they say different things: **no route** names the argv nobody described, **a routed file that is absent** names the path the fixture was supposed to carry. Neither may degrade into an empty success — which is also why **an empty answer is an EMPTY file, never a missing one**: "the cluster holds nothing" is a fixture stating `[]`, and a file that is not there is a fixture that forgot to state anything.
324
-
325
- Pick by what the stub is standing in for. **Data-driven** when the answer is a document two worlds may legitimately differ on — that difference is then a fixture layered over another, and the script never changes. **`case`** when the stub simulates an action (a write, an exit code, a banner) or answers one constant: a file per constant is a directory of one-liners, and indirection nobody reads.
326
-
327
- A served stub — a `serve:` server in a spec document — routes the same way over `$TEST_WORKDIR`, which is where that document's `fixture:` landed: see [Registration — once per app](#registration--once-per-app).
328
-
329
- ## The result
330
-
331
- A CLI result carries the streams, the exit code, the files the run left and — under Docker-aware mode — the containers it touched. Every accessor and every matcher it accepts is [14 — Assertions](14-assertions.md)'s; what follows is the part only this facet has.
332
-
333
- ### Streams, JSON, grep
334
-
335
- ```typescript
336
- test('fails on unknown command with a useful error', async () => {
337
- // Given
338
- const result = await cli.exec('frobnicate');
339
-
340
- // Then
341
- expect(result.exitCode).toBe(1);
342
- expect(result.stderr).toContain("Unknown command 'frobnicate'");
343
- expect(result.stdout.text).toBe(''); // .text = raw, never transformed
344
- });
345
-
346
- test('emits machine-readable config', async () => {
347
- // Given
348
- const result = await cli.exec('config --json');
349
-
350
- // Then
351
- expect(result.json.value).toMatchObject({ name: 'shoply' }); // read + native matcher
352
- expect(result.json).toMatch('config.json'); // _expected/config.json
353
- });
354
-
355
- test('lints a shop and reports per-file blocks', async () => {
356
- // Given
357
- const result = await cli.fixture('$FIXTURES/base-shop/').exec('lint');
358
-
359
- // Then - snapshot the whole surface; grep is the scalpel for targeted probes
360
- expect(result.stdout).toMatch('lint-output.txt'); // full snapshot, tokens for volatile parts
361
- expect(result.stdout.grep('products/ok.yaml')).not.toContain('error'); // absence probe
362
- });
363
- ```
364
-
365
- `result.stdout` / `result.stderr` are `TextAccessor` subjects — the universal text handle (stdout, stderr, container logs, file text): ANSI-stripped for every comparison (rule D6), with `.text` exposing the raw capture. Text operations are **closed** over the type: `.grep(pattern)` returns a `TextAccessor` (not a string), so it chains (`result.stdout.grep(a).grep(b)`) and snapshots (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`). There is no `result.grep()` — the source is always explicit. `result.json` parses stdout as JSON (`.value` for the parsed object).
366
-
367
- **Tool output → snapshot per scoped use case (rule D11).** For linter/compiler/CLI output, prefer a per-use-case fixture project + a full `expect(result.stdout).toMatch('<use-case>.txt')` snapshot (volatile parts covered by `{{duration}}` / `{{workdir}}` / `{{path}}` tokens, generated with `TEST_UPDATE=1`) over a cluster of greps. The fixture is the Given — no shared `beforeAll`. Keep `.grep()` for targeted presence/absence probes in large outputs. The full surface is in [assertions](14-assertions.md).
368
-
369
- ## Unique here
370
-
371
- ### Spec documents — `<case>.spec.yaml`
372
-
373
- A terminal session is already a specification: a command, its exit code, what it printed, what it left on disk. A **spec document** writes it as that — one scenario per `<case>.spec.yaml`, living **beside the spec** (never under `_expected/`, which holds goldens, not scenarios). Nothing here is new capability: it is the chain's semantics in the shape of a data file an editor can validate.
374
-
375
- ```yaml
376
- description: refuses to guess when it is run outside the checkout
377
- fixture: $FIXTURES/repositories-stub/
378
- env:
379
- - frozen
380
- - SHOPLY_ORIGIN=http://127.0.0.1:9
381
- serve:
382
- - mcp: { MCP_STUB_WITHHOLD: get-article }
383
-
384
- runs:
385
- - command: repositories
386
- exit: 1
387
- stderr: |
388
- Error: no directory with home/ and apps/ above the current directory
389
- Hint: run inside the shoply checkout
390
- - command: repositories --json
391
- exit: 0
392
- stdout: |
393
- {
394
- "data": []
395
- }
396
- ```
397
-
398
- #### The ground
399
-
400
- Everything above `runs:`. Any key outside the table below is a refusal naming the key and its line.
401
-
402
- | Key | Shape | Meaning |
403
- | -------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
404
- | `kind:` | optional, default `cli` | The facet the document describes. Reserved for a second facet; `cli` is the only one implemented |
405
- | `description:` | **mandatory**, one line | The vitest test title. Lowercase prose, no trailing period |
406
- | `fixture:` | string or list | Identical to `.fixture()`: `$FIXTURES/…` or feature-local, trailing slash = spread, executable bits preserved. A list **layers** in order |
407
- | `env:` | string or list | Each entry is either a **bare word** naming an env set registered in code, or `KEY=value` inline (`$WORKDIR` expands, as in `.env()`) |
408
- | `serve:` | string, list of strings, or `- name: { … }` | Servers registered in code, started once **per file** before the first run and shared by all of them. The mapping form adds env to that server |
409
-
410
- #### The runs
411
-
412
- `runs:` is a list of at least one run, executed **in order**, all in ONE working directory, and **every** one asserted. This is where the format goes past `.exec([...])`, which stops at the first non-zero exit and keeps only the last output: here a non-zero exit does not end the session, because each exit code is part of what the document states.
413
-
414
- **A run is judged the moment it ends**, before the next command starts — its exit code, its streams and its `files:` all read at that one instant. So a document states the working directory as it was BETWEEN two commands, not only as the session left it. A run that disagrees throws there and then, and the runs below it do not execute: once the sequence has diverged, what the rest would print is a consequence, not a fact about the program.
415
-
416
- | Key | Shape | Meaning |
417
- | ---------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
418
- | `command:` | **mandatory** | The argv AFTER the binary — exactly what `.exec('…')` takes. The binary is the runner's (`specification.cli(bin)`), so naming it here would pass it to itself as the first argument. The line goes through the shell, so quoting behaves identically |
419
- | `stdin:` | optional block scalar | Written to the child, which then reads EOF. Absent keeps today's immediately-closed pipe; a TTY is never given |
420
- | `timeout:` | optional integer | Milliseconds before the run is killed (exit code 124) |
421
- | `waitFor:` | optional, **last run only** | Long-running form: resolve as soon as this text appears, as `.exec(args, { waitFor })` does |
422
- | `exit:` | **mandatory** literal integer | The exit code the command must return |
423
- | `stdout:` | optional block scalar | Expected stdout, **byte-exact**. Absent asserts an EMPTY stream |
424
- | `stderr:` | optional block scalar | Expected stderr, byte-exact. Absent asserts an EMPTY stream |
425
- | `files:` | optional mapping | On-disk assertions under the working directory, read the moment THIS run ends (below) |
426
-
427
- **`|` and `|-` are the whole newline story.** A block scalar written `|` keeps the final newline of its text; `|-` drops it. The comparison is byte-exact against that, so a command whose output ends with `\n` is written `|`, and one that ends mid-line is written `|-`. There is no normalisation to remember, and no empty last line to spell.
428
-
429
- The whole [token vocabulary](15-tokens.md) works in `stdout`, `stderr` and the `files:` texts — `{{url}}`, `{{int}}`, `{{workdir}}`, `{{any}}`, `#ref` captures — and **never** in `description`, `command` or `exit`, which are prose and data. For wording that varies WITHIN a line (a duration phrase, a hostname, a count in a sentence), reach for `{{string}}`, which stops at the end of the line; keep `{{any}}` for a span that genuinely crosses lines.
430
-
431
- #### `files:` — what the run left behind
432
-
433
- Each key is a path relative to the working directory (never absolute, never escaping it). Each value is one of four forms:
434
-
435
- ```yaml
436
- files:
437
- .spwn/agent.yaml: { contains: 'name: bot' } # one needle, or a list of them
438
- out/report.txt:
439
- equals: | # exact text, block scalar allowed
440
- ok
441
- .spwn/lock: absent # nothing at that path
442
- out/src: exists # something at that path
443
- ```
444
-
445
- `contains` and `equals` are token-aware: `{ contains: 'wrote {{path}}' }` finds the line whatever the path turned out to be.
446
-
447
- Each run's mapping is read against the working directory **as that run left it**, so a file may be `absent` for one run and there for the next — which is how a lock, a cache or a build output is described:
448
-
449
- ```yaml
450
- description: the lock appears with the first apply and is released by the second
451
- runs:
452
- - command: status
453
- exit: 0
454
- files:
455
- .spwn/lock: absent # nothing has claimed the tree yet
456
- - command: apply --hold
457
- exit: 0
458
- files:
459
- .spwn/lock: { contains: 'pid: {{int}}' } # the same path, one command later
460
- ```
461
-
462
- Written as one mapping at the end of the session, that pair could not be stated at all: a working directory only ever holds its latest state.
463
-
464
- #### Registration — once per app
465
-
466
- The document names its ground by WORD; the code says what those words mean, in the `specification.cli()` options:
467
-
468
- ```typescript
469
- // apps/cli/specs/cli/cli.specification.ts, in a workspace
470
- export const { cli, cleanup } = await specification.cli(bin, {
471
- env: {
472
- frozen: { HOME: '$WORKDIR', TZ: 'UTC' },
473
- },
474
- serve: {
475
- mcp: {
476
- // Relative to apps/cli/ — the project root, not the repo root
477
- command: 'bun specs/harness/mcp-server.ts',
478
- env: 'SHOPLY_MCP_ORIGIN',
479
- ready: /listening on port (?<port>\d+)/,
480
- url: (port) => `http://127.0.0.1:${port}/mcp`,
481
- },
482
- },
483
- });
484
- ```
485
-
486
- A `serve` entry is spawned with the document's extra `KEY: value` merged into its environment, and its **cwd is the project root** — rule A9's root, the nearest ancestor of the specification file carrying a `package.json`. **In a workspace that is the package's own directory, not the repository root**: a spec at `<repo>/apps/cli/specs/cli/cli.specification.ts` gives `<repo>/apps/cli/`, so `command` reads `'bun specs/harness/mcp-server.ts'` and not `'bun apps/cli/specs/harness/mcp-server.ts'`. Pass the runner's `root` option to move it.
487
-
488
- `ready` is a regex over the child's output whose **first capture group** is the port the server chose (named or not — it is group 1 either way); the framework injects no `PORT`, the server announces one. `url(port)` builds the URL, and `env` names the variable it is bound to in every run's child.
489
-
490
- **`TEST_WORKDIR` is seeded into every served child** — the document's working directory in the resolved form `{{workdir}}` holds and `$WORKDIR` expands to. The servers start after `fixture:` has been copied, so a stub reads the world the document laid down (`$TEST_WORKDIR/answers/dashboard/posts.json`) exactly as a stub binary reads `${0%/*}/../answers/`: a scenario is a layer of files, not a flag threaded through the server. The mapping form overrides it like any other key — `- mcp: { TEST_WORKDIR: /elsewhere }` points that one server somewhere else.
491
-
492
- #### The schema
493
-
494
- The document's JSON Schema ships with the package at `schema/spec.schema.json`, published as the `@jterrazz/test/schema` export, and is generated from the grammar's own constants — it cannot describe a shape the parser does not read. Point an editor at it and every key, every type and every closed set is checked as you type:
495
-
496
- ```yaml
497
- # yaml-language-server: $schema=./node_modules/@jterrazz/test/schema/spec.schema.json
498
- ```
499
-
500
- A schema says which keys exist, not which ORDER they come in — JSON Schema has no vocabulary for the sequence of an object's members. The canonical order (`kind, description, fixture, env, serve, runs`, and `command, stdin, timeout, waitFor, exit, stdout, stderr, files` inside a run) is the `d4b-spec-key-order` lint pass's, which also rewrites it: `npx jterrazz-test-check specs --fix`. The full document family is in [19 — linting](19-linting.md).
501
-
502
- #### The three doors, one engine
503
-
504
- | Door | Use it when |
505
- | ---------- | ---------------------------------------------------------------------------------------------- |
506
- | **plugin** | The document IS the test — the common case |
507
- | **bridge** | The document is the scenario, but one assertion needs code (a directory golden, a grep) |
508
- | **chain** | Everything the format cannot say — see [when to reach for code](#when-to-reach-for-code) below |
509
-
510
- **The plugin.** `literate()` from `@jterrazz/test/vitest` adds the `.spec.yaml` glob to the test include and transforms each document into a one-test module. The runner then shows the document's path as the test file and `description:` as the title, so a failing scenario opens where it is written:
511
-
512
- ```typescript
513
- // vitest.config.ts
514
- import { defineConfig } from 'vitest/config';
515
- import { literate } from '@jterrazz/test/vitest';
516
-
517
- export default defineConfig({
518
- plugins: [literate({ specification: './specs/cli/cli.specification.ts' })],
519
- });
520
- ```
521
-
522
- `specification` points at the `*.specification.ts` whose exported `cli` runs the documents. It is **stated, never guessed**: a repository may declare several cli runners — different binaries, different service records — and no convention could pick the right one without silently binding a scenario to the wrong command. `include` (default `['**/*.spec.yaml']`) narrows the glob when a tree also holds documents that are _inputs_ to other specs rather than scenarios to run.
523
-
524
- **The bridge.** `cli.run('<case>.spec.yaml')` runs the document — its ground, its servers, every run asserted — and resolves with the **last** run's `CliResult`, so code adds what the document cannot express:
525
-
526
- ```typescript
527
- test('scaffolds a shop and leaves the tree we expect', async () => {
528
- // Given - the whole session, stated in the document
529
- const result = await cli.run('scaffold.spec.yaml');
530
-
531
- // Then - one assertion the format has no vocabulary for
532
- await expect(result.directory('my-shop')).toMatch('shop-scaffold');
533
- });
534
- ```
535
-
536
- `.run()` is a DOOR, not a fallback, and the criterion is written: it stays for a document whose result needs one assertion the grammar has no vocabulary for — an absence, a count, an exhaustive file list, a directory golden, a host shell-out probe — or for a document `literate()`'s static glob cannot collect, because it is generated at test time or deliberately excluded to prove a refusal. It is never for a Given built by chaining onto `cli`: no site on the workbench does that, and the default door is `literate()`, which `cli()` wires for you.
537
-
538
- The path is relative to the test file's directory, where the document lives. Setup chained before the call layers **underneath** the document's own ground: a chained `.fixture()` is copied first, the document's `fixture:` entries over it, and its `env:` wins over a chained `.env()`. `{ frozen: true }` opts one document out of the update rewrite — the same guard as `toMatch(name, { frozen: true })`, for a deliberately-wrong document whose failure rendering is the subject of a negative test.
539
-
540
- #### Failure and update
541
-
542
- On a mismatch the failure renders the description, the command, everything the comparison rejected, and where to open the document:
543
-
544
- ```
545
- Spec mismatch
546
-
547
- removes a post and names what is gone
548
-
549
- $ posts rm post-recap --yes
550
-
551
- Output mismatch (stdout)
552
-
553
- - Expected
554
- + Received
555
- …
556
-
557
- files mismatch
558
-
559
- .spwn/lock: expected absent, got a file
560
-
561
- specs/cli/removed.spec.yaml:9
562
- Run with TEST_UPDATE=1 to rewrite the runs — the exit code and the streams, and nothing else.
563
- ```
564
-
565
- A line the comparison ACCEPTED is rendered as equal, with its token — a `{{url}}` that matched is never shown as a `-`/`+` pair against the concrete URL — so the marked lines are the mismatch and nothing else. The stack carries one frame: the run's own `command:` line.
566
-
567
- `TEST_UPDATE=1` rewrites **only `exit`, `stdout` and `stderr`, per run**. The ground, the commands and the `files:` assertions are never touched, and neither are comments or key order: the YAML document is edited, not re-emitted. An empty stream loses its key, since absence already asserts emptiness. Placeholders survive by **pattern match**, not by line index, so a token stays a token wherever the line moved to: see [update mode](15-tokens.md#update-mode-tokens-are-preserved).
568
-
569
- #### When to reach for code
570
-
571
- The format states one binary, one working directory, one linear session. Reach for the chain when the spec needs:
572
-
573
- - **containers** — `docker`-aware runs, `await using`, `result.container(name)`;
574
- - **parallel fan-out** — several commands that must run at once, or an interleaving;
575
- - **host shell-outs** the binary under test is not — a `git` call, a build step staged by hand;
576
- - **structural JSON** — `result.json` against an `_expected/*.json` golden, where the comparison is by shape rather than by text;
577
- - **database state** — `.seed()` and `result.table(…)`.
578
-
579
- A document that starts wanting a conditional is a chain wearing a header. Write it in code.
580
-
581
- ### The `cli()` project — the default door, wired
582
-
583
- The project helper collects both halves of this facet: the `.spec.ts` files, and the documents — a `<case>.spec.yaml` is a test file of its own, so the literate plugin's glob has to join the include of the project that collects them, never the root config.
584
-
585
- ```typescript
586
- // vitest.config.ts
587
- import { cli, defineSpecConfig, unit } from '@jterrazz/test/vitest';
588
-
589
- export default defineSpecConfig({ test: { projects: [unit(), cli()] } });
590
- ```
591
-
592
- Called bare, it assumes the path convention: every `specs/cli/**/*.spec.yaml` becomes a test bound to `specs/cli/cli.specification.ts`. Three knobs change that, and nothing else needs stating:
593
-
594
- | `literate` | Collects |
595
- | --------------------------------- | -------------------------------------------------------- |
596
- | omitted | the convention above — the door every CLI suite opens on |
597
- | `{ specification: './other.ts' }` | the same documents, bound to another runner |
598
- | `{ include: [...] }` | a narrower glob |
599
- | `false` | no documents at all — a suite written entirely in code |
600
-
601
- A member without `"type": "module"` cannot `require()` the ESM-only helpers: name the file `vitest.config.mts` there.
602
-
603
- ### Docker-aware mode
604
-
605
- For CLIs that spawn Docker containers, declare the `docker` option (rule G3):
606
-
607
- ```typescript
608
- export const {
609
- cli: deploy,
610
- cleanup,
611
- docker,
612
- } = await specification.cli('shoply', {
613
- docker: {
614
- envVar: 'SHOPLY_TEST_RUN', // run id injected into the child env
615
- nameLabel: 'dev.shoply.shop.name', // label used as the .container(name) key
616
- testRunLabel: 'dev.shoply.test.run', // label the CLI must put on every container
617
- },
618
- });
619
- ```
620
-
621
- The contract with your binary: it must label every container it creates with `testRunLabel = <value of envVar>`. That is how the runner finds — and force-removes — the containers belonging to a run.
622
-
623
- A Docker-aware runner **requires `await using`** on every result (rule B5 — the checker infers the runners from the `docker:` option), so leaked containers are cleaned by label filter at scope exit:
624
-
625
- ```typescript
626
- test('deploy spawns a labelled container', async () => {
627
- // Given - await using ⇒ containers force-removed at scope exit (rule B5)
628
- await using result = await deploy.fixture('$FIXTURES/two-shops/').exec('deploy alpha');
629
-
630
- // Then - lazy access (zero Docker calls if you never touch .container)
631
- const shop = result.container('alpha');
632
- expect(shop.exists).toBe(true);
633
- await expect(shop).toBeRunning();
634
-
635
- // Reads INSIDE the container, same vocabulary as on the host
636
- expect(shop.file('/app/shoply.yaml').content).toContain('name: alpha');
637
- const inside = await shop.exec('ls /app');
638
- expect(inside.stdout).toContain('shoply.yaml');
639
- expect(shop.stdout).toContain('shop ready'); // container logs
640
-
641
- // Absent ≠ crash
642
- expect(result.container('nope').exists).toBe(false);
643
- expect(result.containerIds).toHaveLength(1);
644
- });
645
-
646
- test('destroy removes the container created in an earlier run', async () => {
647
- // Given - spawn then destroy: the whole runner shares one run-id scope
648
- await using first = await deploy.exec('deploy alpha');
649
- await using second = await deploy.exec('destroy alpha');
650
-
651
- // Then
652
- expect(second.exitCode).toBe(0);
653
- expect(second.container('alpha').exists).toBe(false);
654
- });
655
- ```
656
-
657
- Container accessor surface: `exists`, `running`/`toBeRunning`, `status`, `id`, `file(path)` (content read inside the container), `exec(cmd)` (returns stdout/stderr), `stdout`/`stderr` (container logs). Looking up an absent container returns `exists: false` instead of throwing. `result.containerIds` lists every container captured for the run.
658
-
659
- The runner handle also destructures to `{ cli, cleanup, docker }`. The `docker(containerId)` reader is the escape hatch for reading an **arbitrary** container by raw id (e.g. one a follow-up command referenced): it lazily runs `docker inspect` and returns the same `ContainerAccessor` type, so `await expect(docker(id)).toBeRunning()` and the sync reads work identically. Unlike `result.container(name)` it does not need `nameLabel` — it looks up by id directly; an unknown id yields `exists: false`.
660
-
661
- ## Pitfalls
662
-
663
- - **Reaching for `.spawn()`.** It does not exist — `.exec(args, { waitFor, timeout })` is the unified execution method (rule B2).
664
- - **Putting a `<case>.spec.yaml` under `_expected/`.** A spec document is the SCENARIO, not a golden — it lives beside the spec it belongs to. `_expected/` holds what an assertion resolves against.
665
- - **Writing a token in a `description:` or a `command:`.** Those are prose and data; placeholders only work in the streams and the `files:` texts.
666
- - **Quoting a stream instead of writing a block scalar.** `stdout: "a\nb\n"` is a golden no diff can show — `d4b-spec-block-scalar` rejects it and `--fix` rewrites it.
667
- - **Asserting on raw ANSI or absolute paths in snapshots.** ANSI is stripped by default; paths and timestamps belong to `{{workdir}}`, `{{path}}`, `{{iso8601}}` tokens in `_expected/*.txt` — `transform` is a last resort (rule D6).
668
- - **Assigning a Docker-aware result without `await using`.** Error (rule B5) — that is the leak-cleanup mechanism.
669
- - **Re-declaring injected URLs.** `.env({ DATABASE_URL: … })` duplicates rule B6's auto-injection — override only to _change_ it, `null` to remove it.
670
- - **Letting the chain find the real binary.** Mounting a stub fixture without pinning `PATH` (or pinning it with the developer's own `PATH` on the tail) leaves the installed `kubectl`/`gh` reachable — the spec then tests the operator's machine. Pin `PATH` and `HOME` on `$WORKDIR`.
671
- - **Writing into fixture folders from a test.** The cwd is a copy; feature-local `_fixtures/` and the shared `specs/_fixtures/` pool are templates and stay pristine.
672
- - **Reaching for `.project()` or `seedHandlers`.** Both are gone — one verb copies files (`.fixture(path)`, feature-local or `$FIXTURES/`), and `.seed()` is SQL-only (rule C7).
673
- - **Forgetting the extension in `toMatch('help')`.** The extension is part of the name (rule C6) — except for tree snapshots, which are directories.
674
-
675
- ## Related
676
-
677
- [02 — Developing](02-developing.md) · [14 — Assertions](14-assertions.md) · [15 — Tokens](15-tokens.md) · [17 — Services](17-services.md)