@jterrazz/test 16.1.1 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/README.md +45 -171
  2. package/dist/browser/index.js +1 -2980
  3. package/dist/browser/services.js +1 -0
  4. package/dist/browser/stubs.js +1 -0
  5. package/dist/browser-shared-index.browser.js +3233 -0
  6. package/dist/checker.d.ts +6 -1
  7. package/dist/checker.js +3721 -2624
  8. package/dist/config.d.ts +40 -0
  9. package/dist/config.js +309 -0
  10. package/dist/in-place.global-setup.d.ts +12 -0
  11. package/dist/in-place.global-setup.js +21 -0
  12. package/dist/index.d.ts +1641 -3004
  13. package/dist/index.js +5095 -3835
  14. package/dist/mobile-session.global-setup.d.ts +15 -0
  15. package/dist/mobile-session.global-setup.js +31 -0
  16. package/dist/oxlint.cjs +6069 -5634
  17. package/dist/oxlint.d.cts +43 -7212
  18. package/dist/oxlint.d.ts +43 -7212
  19. package/dist/oxlint.js +3979 -5799
  20. package/dist/route-coverage.global-setup.d.ts +43 -0
  21. package/dist/route-coverage.global-setup.js +1 -0
  22. package/dist/services.d.ts +3 -0
  23. package/dist/services.global-setup.d.ts +43 -0
  24. package/dist/services.global-setup.js +1 -0
  25. package/dist/services.js +597 -0
  26. package/dist/shared-appium.adapter.js +428 -0
  27. package/dist/shared-artifacts.js +34 -0
  28. package/dist/shared-chapter.js +7 -0
  29. package/dist/shared-container.port.d.ts +31 -0
  30. package/dist/shared-derived-projects.js +73 -0
  31. package/dist/shared-device.port.d.ts +189 -0
  32. package/dist/shared-exchange.js +447 -0
  33. package/dist/shared-in-place.js +172 -0
  34. package/dist/shared-index.services.d.ts +346 -0
  35. package/dist/shared-index.stubs.d.ts +287 -0
  36. package/dist/shared-json-shape.js +17 -0
  37. package/dist/shared-landmarks.js +71 -0
  38. package/dist/shared-ledger.js +68 -0
  39. package/dist/shared-listened-port.js +19 -0
  40. package/dist/shared-literate-plugin.js +184 -0
  41. package/dist/shared-manifest.js +1321 -0
  42. package/dist/shared-pause.js +22 -0
  43. package/dist/shared-peer.js +88 -0
  44. package/dist/shared-playwright.adapter.js +328 -0
  45. package/dist/shared-process-alive.js +13 -0
  46. package/dist/shared-process.adapter.js +74 -0
  47. package/dist/shared-record-key.js +13 -0
  48. package/dist/shared-refusal.js +35 -0
  49. package/dist/shared-resolve.js +24 -0
  50. package/dist/shared-route-coverage.global-setup.js +187 -0
  51. package/dist/shared-scenario-failure.js +198 -0
  52. package/dist/shared-serve.js +407 -0
  53. package/dist/shared-service-images.js +10 -0
  54. package/dist/shared-services.global-setup.js +174 -0
  55. package/dist/shared-session-ledger.js +305 -0
  56. package/dist/shared-setup-entry.js +12 -0
  57. package/dist/shared-source-scan.js +133 -0
  58. package/dist/shared-spec-document.js +303 -0
  59. package/dist/shared-spec-grammar.js +66 -0
  60. package/dist/shared-specifications.js +26 -0
  61. package/dist/shared-specs-modules.js +1033 -0
  62. package/dist/shared-stub-reply.js +167 -0
  63. package/dist/shared-stub-server.js +142 -0
  64. package/dist/shared-temp-root.global-setup.js +73 -0
  65. package/dist/shared-test-baseline.js +26 -0
  66. package/dist/shared-test-section.js +58 -0
  67. package/dist/shared-testcontainers.adapter.js +306 -0
  68. package/dist/shared-tokens.js +55 -0
  69. package/dist/shared-yaml-document.js +119 -0
  70. package/dist/stubs.d.ts +2 -0
  71. package/dist/stubs.js +657 -0
  72. package/dist/temp-root.global-setup.d.ts +7 -0
  73. package/dist/temp-root.global-setup.js +1 -0
  74. package/dist/vitest.d.ts +93 -164
  75. package/dist/vitest.js +382 -550
  76. package/docs/README.md +82 -36
  77. package/docs/concepts/architecture.md +118 -0
  78. package/docs/concepts/conventions.md +333 -0
  79. package/docs/guides/api-specs.md +374 -0
  80. package/docs/guides/cli-specs.md +566 -0
  81. package/docs/guides/component-specs.md +252 -0
  82. package/docs/guides/develop.md +97 -0
  83. package/docs/guides/integration-specs.md +181 -0
  84. package/docs/guides/job-specs.md +180 -0
  85. package/docs/guides/lint-specs.md +174 -0
  86. package/docs/guides/mobile-specs.md +319 -0
  87. package/docs/guides/module-tests.md +149 -0
  88. package/docs/guides/release.md +44 -0
  89. package/docs/guides/spec-documents.md +189 -0
  90. package/docs/guides/website-specs.md +413 -0
  91. package/docs/reference/assertions.md +345 -0
  92. package/docs/reference/glossary.md +20 -0
  93. package/docs/reference/linting.md +483 -0
  94. package/docs/reference/services.md +236 -0
  95. package/docs/reference/stubs.md +500 -0
  96. package/docs/reference/time-and-doubles.md +137 -0
  97. package/docs/reference/tokens.md +258 -0
  98. package/docs/reference/visitor.md +221 -0
  99. package/docs/start/getting-started.md +187 -0
  100. package/docs/start/set-up-a-project.md +228 -0
  101. package/package.json +56 -36
  102. package/schema/spec.schema.json +43 -17
  103. package/dist/appium.adapter.js +0 -489
  104. package/dist/artifacts.js +0 -32
  105. package/dist/fixtures.js +0 -110
  106. package/dist/literate.js +0 -1747
  107. package/dist/match.js +0 -804
  108. package/dist/playwright.adapter.js +0 -438
  109. package/dist/server.js +0 -160
  110. package/dist/stream.js +0 -321
  111. package/dist/substring-warning.js +0 -282
  112. package/docs/01-architecture.md +0 -95
  113. package/docs/02-developing.md +0 -351
  114. package/docs/03-testing.md +0 -495
  115. package/docs/04-operating.md +0 -101
  116. package/docs/05-module-tests.md +0 -152
  117. package/docs/06-integration.md +0 -180
  118. package/docs/07-component.md +0 -246
  119. package/docs/08-website.md +0 -404
  120. package/docs/09-mobile.md +0 -270
  121. package/docs/10-api.md +0 -264
  122. package/docs/11-jobs.md +0 -157
  123. package/docs/12-cli.md +0 -677
  124. package/docs/13-elements.md +0 -248
  125. package/docs/14-assertions.md +0 -314
  126. package/docs/15-tokens.md +0 -253
  127. package/docs/16-contracts.md +0 -438
  128. package/docs/17-services.md +0 -188
  129. package/docs/18-conventions.md +0 -332
  130. package/docs/19-linting.md +0 -512
  131. package/docs/decisions/001-v9-conventions-on-three-channels.md +0 -50
  132. package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +0 -49
  133. package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +0 -84
  134. package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +0 -81
  135. package/docs/decisions/005-the-doubles-ladder-is-closed.md +0 -86
  136. package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +0 -245
  137. package/docs/decisions/007-compose-mode-leaves-the-package.md +0 -53
  138. package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +0 -75
  139. package/docs/decisions/_template.md +0 -22
  140. package/docs/reference/classes/Matcher.md +0 -120
  141. package/docs/reference/classes/ProcessHandle.md +0 -368
  142. package/docs/reference/functions/defineContract.md +0 -39
  143. package/docs/reference/functions/defineContracts.md +0 -36
  144. package/docs/reference/functions/disabled.md +0 -28
  145. package/docs/reference/functions/enabled.md +0 -20
  146. package/docs/reference/functions/focused.md +0 -26
  147. package/docs/reference/functions/mockOf.md +0 -79
  148. package/docs/reference/functions/postgres.md +0 -31
  149. package/docs/reference/functions/process.md +0 -33
  150. package/docs/reference/functions/redis.md +0 -28
  151. package/docs/reference/functions/required.md +0 -41
  152. package/docs/reference/functions/selected.md +0 -26
  153. package/docs/reference/functions/sqlite.md +0 -33
  154. package/docs/reference/functions/testId.md +0 -20
  155. package/docs/reference/functions/text.md +0 -40
  156. package/docs/reference/functions/valued.md +0 -28
  157. package/docs/reference/functions/waitUntil.md +0 -46
  158. package/docs/reference/functions/within.md +0 -29
  159. package/docs/reference/index.md +0 -196
  160. package/docs/reference/interfaces/BaseResult.md +0 -87
  161. package/docs/reference/interfaces/CallResult.md +0 -160
  162. package/docs/reference/interfaces/CaptureScope.md +0 -82
  163. package/docs/reference/interfaces/CliResult.md +0 -253
  164. package/docs/reference/interfaces/ContainerAccessor.md +0 -166
  165. package/docs/reference/interfaces/DirectoryAccessor.md +0 -33
  166. package/docs/reference/interfaces/FetchResult.md +0 -180
  167. package/docs/reference/interfaces/FilesystemAccessor.md +0 -46
  168. package/docs/reference/interfaces/HttpResult.md +0 -126
  169. package/docs/reference/interfaces/JsonAccessor.md +0 -46
  170. package/docs/reference/interfaces/PageResult.md +0 -356
  171. package/docs/reference/interfaces/RenderResult.md +0 -72
  172. package/docs/reference/interfaces/RenderedText.md +0 -110
  173. package/docs/reference/interfaces/ResponseAccessor.md +0 -47
  174. package/docs/reference/interfaces/ScreenResult.md +0 -128
  175. package/docs/reference/interfaces/TableAccessor.md +0 -26
  176. package/docs/reference/interfaces/TextAccessor.md +0 -96
  177. package/docs/reference/type-aliases/ApiHandle.md +0 -68
  178. package/docs/reference/type-aliases/ApiSpecification.md +0 -213
  179. package/docs/reference/type-aliases/ApiSpecificationOptions.md +0 -68
  180. package/docs/reference/type-aliases/BrowserConsoleMessage.md +0 -30
  181. package/docs/reference/type-aliases/BrowserLinkElement.md +0 -50
  182. package/docs/reference/type-aliases/BrowserMetaElement.md +0 -40
  183. package/docs/reference/type-aliases/BrowserOpenOptions.md +0 -87
  184. package/docs/reference/type-aliases/BrowserPage.md +0 -132
  185. package/docs/reference/type-aliases/BrowserPort.md +0 -51
  186. package/docs/reference/type-aliases/CliEnv.md +0 -11
  187. package/docs/reference/type-aliases/CliHandle.md +0 -80
  188. package/docs/reference/type-aliases/CliInput.md +0 -36
  189. package/docs/reference/type-aliases/CliOutput.md +0 -46
  190. package/docs/reference/type-aliases/CliPort.md +0 -58
  191. package/docs/reference/type-aliases/CliSpecification.md +0 -144
  192. package/docs/reference/type-aliases/CliSpecificationOptions.md +0 -134
  193. package/docs/reference/type-aliases/ComponentChain.md +0 -156
  194. package/docs/reference/type-aliases/ComponentScenario.md +0 -20
  195. package/docs/reference/type-aliases/ComponentUi.md +0 -47
  196. package/docs/reference/type-aliases/ComponentVisitor.md +0 -10
  197. package/docs/reference/type-aliases/ContainerPort.md +0 -113
  198. package/docs/reference/type-aliases/Contract.md +0 -68
  199. package/docs/reference/type-aliases/ContractInput.md +0 -13
  200. package/docs/reference/type-aliases/ContractRequest.md +0 -95
  201. package/docs/reference/type-aliases/ContractResponder.md +0 -23
  202. package/docs/reference/type-aliases/ContractResponse.md +0 -78
  203. package/docs/reference/type-aliases/ContractResponseValue.md +0 -13
  204. package/docs/reference/type-aliases/Contracts.md +0 -48
  205. package/docs/reference/type-aliases/DatabaseKeys.md +0 -16
  206. package/docs/reference/type-aliases/DatabasePort.md +0 -72
  207. package/docs/reference/type-aliases/DeviceOpenOptions.md +0 -46
  208. package/docs/reference/type-aliases/DevicePort.md +0 -51
  209. package/docs/reference/type-aliases/DeviceScreen.md +0 -32
  210. package/docs/reference/type-aliases/DeviceTimeouts.md +0 -39
  211. package/docs/reference/type-aliases/DockerSpecConfig.md +0 -43
  212. package/docs/reference/type-aliases/DomMount.md +0 -21
  213. package/docs/reference/type-aliases/ElementKind.md +0 -27
  214. package/docs/reference/type-aliases/ElementMatch.md +0 -74
  215. package/docs/reference/type-aliases/ElementOptions.md +0 -29
  216. package/docs/reference/type-aliases/ElementRef.md +0 -128
  217. package/docs/reference/type-aliases/ExecOptions.md +0 -37
  218. package/docs/reference/type-aliases/FileAccessor.md +0 -54
  219. package/docs/reference/type-aliases/HonoApp.md +0 -31
  220. package/docs/reference/type-aliases/HttpContractFilter.md +0 -49
  221. package/docs/reference/type-aliases/HttpResponseInit.md +0 -46
  222. package/docs/reference/type-aliases/HttpStreamInit.md +0 -58
  223. package/docs/reference/type-aliases/IntegrationHandle.md +0 -46
  224. package/docs/reference/type-aliases/IntegrationSpecification.md +0 -111
  225. package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +0 -42
  226. package/docs/reference/type-aliases/Intercept.md +0 -19
  227. package/docs/reference/type-aliases/InterceptOptions.md +0 -28
  228. package/docs/reference/type-aliases/InterceptScope.md +0 -11
  229. package/docs/reference/type-aliases/IsolationStrategy.md +0 -75
  230. package/docs/reference/type-aliases/JobHandle.md +0 -34
  231. package/docs/reference/type-aliases/JobsHandle.md +0 -57
  232. package/docs/reference/type-aliases/JobsResult.md +0 -16
  233. package/docs/reference/type-aliases/JobsSpecification.md +0 -94
  234. package/docs/reference/type-aliases/JobsSpecificationOptions.md +0 -56
  235. package/docs/reference/type-aliases/LandmarkKind.md +0 -20
  236. package/docs/reference/type-aliases/LiterateRunFlags.md +0 -27
  237. package/docs/reference/type-aliases/LiterateServeRegistration.md +0 -70
  238. package/docs/reference/type-aliases/MatchFixtureOptions.md +0 -27
  239. package/docs/reference/type-aliases/MatchableRequest.md +0 -61
  240. package/docs/reference/type-aliases/MatcherKind.md +0 -34
  241. package/docs/reference/type-aliases/MobileBackendOptions.md +0 -28
  242. package/docs/reference/type-aliases/MobileElementKind.md +0 -14
  243. package/docs/reference/type-aliases/MobileElementMatch.md +0 -60
  244. package/docs/reference/type-aliases/MobileElementRef.md +0 -20
  245. package/docs/reference/type-aliases/MobileHandle.md +0 -65
  246. package/docs/reference/type-aliases/MobileScenario.md +0 -20
  247. package/docs/reference/type-aliases/MobileSpecification.md +0 -51
  248. package/docs/reference/type-aliases/MobileSpecificationOptions.md +0 -126
  249. package/docs/reference/type-aliases/MobileVisitor.md +0 -81
  250. package/docs/reference/type-aliases/MockOfOptions.md +0 -26
  251. package/docs/reference/type-aliases/MockPort.md +0 -10
  252. package/docs/reference/type-aliases/PinnedClock.md +0 -10
  253. package/docs/reference/type-aliases/PostgresOptions.md +0 -32
  254. package/docs/reference/type-aliases/ProcessOptions.md +0 -112
  255. package/docs/reference/type-aliases/RedisOptions.md +0 -20
  256. package/docs/reference/type-aliases/RenderSubject.md +0 -12
  257. package/docs/reference/type-aliases/ScreenNode.md +0 -67
  258. package/docs/reference/type-aliases/ServerPort.md +0 -36
  259. package/docs/reference/type-aliases/ServerResponse.md +0 -46
  260. package/docs/reference/type-aliases/ServerSpec.md +0 -21
  261. package/docs/reference/type-aliases/ServiceHandle.md +0 -216
  262. package/docs/reference/type-aliases/ServiceRecord.md +0 -12
  263. package/docs/reference/type-aliases/SpecDocument.md +0 -84
  264. package/docs/reference/type-aliases/SpecEnvToken.md +0 -21
  265. package/docs/reference/type-aliases/SpecFileAssertion.md +0 -27
  266. package/docs/reference/type-aliases/SpecFixture.md +0 -30
  267. package/docs/reference/type-aliases/SpecKind.md +0 -8
  268. package/docs/reference/type-aliases/SpecRun.md +0 -108
  269. package/docs/reference/type-aliases/SpecServeEntry.md +0 -40
  270. package/docs/reference/type-aliases/SpecStream.md +0 -32
  271. package/docs/reference/type-aliases/SpecificationConfig.md +0 -290
  272. package/docs/reference/type-aliases/SqliteOptions.md +0 -36
  273. package/docs/reference/type-aliases/SseEvent.md +0 -58
  274. package/docs/reference/type-aliases/StreamBody.md +0 -61
  275. package/docs/reference/type-aliases/TextFilter.md +0 -22
  276. package/docs/reference/type-aliases/VisitScenario.md +0 -20
  277. package/docs/reference/type-aliases/Visitor.md +0 -215
  278. package/docs/reference/type-aliases/WaitUntilOptions.md +0 -46
  279. package/docs/reference/type-aliases/WebsiteBackendOptions.md +0 -37
  280. package/docs/reference/type-aliases/WebsiteHandle.md +0 -51
  281. package/docs/reference/type-aliases/WebsiteSpecification.md +0 -118
  282. package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +0 -40
  283. package/docs/reference/variables/anthropic.md +0 -19
  284. package/docs/reference/variables/banner.md +0 -10
  285. package/docs/reference/variables/button.md +0 -10
  286. package/docs/reference/variables/clock.md +0 -16
  287. package/docs/reference/variables/complementary.md +0 -10
  288. package/docs/reference/variables/component.md +0 -10
  289. package/docs/reference/variables/content.md +0 -10
  290. package/docs/reference/variables/contentinfo.md +0 -10
  291. package/docs/reference/variables/dialog.md +0 -10
  292. package/docs/reference/variables/field.md +0 -10
  293. package/docs/reference/variables/form.md +0 -10
  294. package/docs/reference/variables/heading.md +0 -10
  295. package/docs/reference/variables/http.md +0 -40
  296. package/docs/reference/variables/intercept.md +0 -11
  297. package/docs/reference/variables/link.md +0 -10
  298. package/docs/reference/variables/listitem.md +0 -10
  299. package/docs/reference/variables/main.md +0 -10
  300. package/docs/reference/variables/match.md +0 -8
  301. package/docs/reference/variables/navigation.md +0 -10
  302. package/docs/reference/variables/openai.md +0 -21
  303. package/docs/reference/variables/option.md +0 -17
  304. package/docs/reference/variables/region.md +0 -10
  305. package/docs/reference/variables/row.md +0 -10
  306. package/docs/reference/variables/search.md +0 -10
  307. package/docs/reference/variables/specification.md +0 -8
  308. package/docs/reference/variables/status.md +0 -10
  309. package/docs/reference/variables/table.md +0 -10
@@ -0,0 +1,566 @@
1
+ # 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](api-specs.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 ([Module tests](module-tests.md)). Most of them are written as documents rather than as code; [Spec documents](spec-documents.md#the-three-doors-one-runner) says when each door 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
+ `bin` takes one of two forms. An absolute path (as above, or the built `dist/cli.js` of the package under test) runs that file. A bare name is looked up in the project's `node_modules/.bin/`, then in the `node_modules/.bin/` of the directory vitest runs from, and is otherwise handed to the shell, which finds it on `PATH`: `specification.cli('shoply')` runs an installed `shoply`. npm links the binaries of your dependencies there, never your own package's `bin`, so a project testing its own command passes the absolute path of the file its `bin` names. A relative path is neither: every spec runs in a fresh temp directory, so it resolves against that directory, not your project.
27
+
28
+ ### Options
29
+
30
+ | Option | Description |
31
+ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | `root` | **Project-root override** only (rule `no-redundant-root`): anchors local-bin resolution and the artefact paths. It is _not_ a fixtures root: a fixture's path is read from the spec file. Auto-discovery applies when omitted |
33
+ | `services` | Named record of infrastructure services (`postgres()`, `redis()`, `sqlite()`); connection URLs are auto-injected into the child env (rule `service-urls-injected`) |
34
+ | `docker` | Opt-in Docker awareness: `{ envVar, nameLabel, testRunLabel }`. See [Docker-aware mode](#docker-aware-mode) |
35
+ | `backend` | A **stub backend** started before the first run: `{ env, port? }`. Its URL is handed to every run in `env`, and `.intercept()` declares the stubs it serves; see [Declared backend](#declared-backend) |
36
+ | `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 |
37
+ | `env` | Named **environment sets** a spec document names by bare word (`env: [frozen]`); see [Registration](#registration-once-per-app) |
38
+ | `serve` | Named **servers** a spec document starts (`serve: [mcp]`): `{ command, ready, url, env }`; see [Registration](#registration-once-per-app) |
39
+ | `transform` | **Escape hatch only** (rule `no-transform-duplicating-tokens`): 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 error |
40
+ | `commands` | The words the binary's commands are spelled with (`['db', 'migrate']`), read by the conventions checker and never by the run: a chain's subject is the leading words of its command line the list of ITS runner holds: `exec('db migrate --dry')` is filed under `cli/db/migrate/` (`one-subject-per-spec`, `spec-in-subject-folder`), whichever other runner the spec imports. Written in the call or bound to a `const` of the runner's file. Without it the first word alone is the subject |
41
+
42
+ ## The chain
43
+
44
+ ### `.exec()`: the single execution method
45
+
46
+ `.exec(args?, options?)` is the **only** way to run the binary; there is no `.spawn()`. 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:
47
+
48
+ ```typescript
49
+ const result = await cli.exec(); // invoke the binary with no arguments
50
+ ```
51
+
52
+ The binary is spawned, never run with `spawnSync`: the test's event loop keeps turning while it runs, so a server the test itself started (an OTLP collector, a stub API on an ephemeral port) answers the binary, one-shot or long-running alike.
53
+
54
+ A one-shot run's whole process group ends with the test that ran it, whether the binary exited or not: `SIGTERM`, then `SIGKILL` after a 2 s grace, and the test is held until the group is gone. A process deaf to `SIGTERM` (a Playwright launcher's handler only closes its browsers) is killed before the worker can be torn down, so nothing the binary forked outlives the run.
55
+
56
+ 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.
57
+
58
+ ```typescript
59
+ test('shows help', async () => {
60
+ // Given
61
+ const result = await cli.exec('--help');
62
+
63
+ // Then - full stdout snapshot; ANSI already stripped, dynamics covered by tokens
64
+ expect(result.exitCode).toBe(0);
65
+ expect(result.stdout).toMatch(file('./__expected__/help.txt'));
66
+ });
67
+ ```
68
+
69
+ #### Long-running processes: `waitFor` / `timeout`
70
+
71
+ ```typescript
72
+ test('dev server boots', async () => {
73
+ // Given - long-running: resolved on pattern match, killed at timeout otherwise
74
+ const result = await cli
75
+ .fixture(directory('../../__fixtures__/base-shop/'))
76
+ .exec('dev --port 0', { waitFor: 'Listening on', timeout: 10_000 });
77
+
78
+ // Then
79
+ expect(result.stdout.text).toContain('Listening on');
80
+ });
81
+ ```
82
+
83
+ - `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`).
84
+ - `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`.
85
+ - On resolution (match, exit, or timeout) the child is ended with the escalation every `process()` takes ([Services § `process()`](../reference/services.md#process-the-one-shape-an-external-process-takes)).
86
+ - 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.
87
+ - Passing either option marks the run as long-running; combining them with the array (sequence) form is an error.
88
+
89
+ #### `cwd`: a folder of the working directory
90
+
91
+ `{ cwd: 'packages/site' }` runs the command in a folder of the working directory, named relative to it: a workspace member a tool judges from inside, a clone whose parent holds what the tool must not read. The folder is one the fixtures laid; a path leaving the working directory, absolute or climbing out of it, is refused before anything runs, and so is a folder the working directory does not hold. `cwd` is no long-running option: it combines with a sequence, and with `waitFor`. The result still reads the working directory as a whole: `result.file('packages/site/out.txt')`.
92
+
93
+ 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.
94
+
95
+ #### Sequences: array form
96
+
97
+ ```typescript
98
+ test('build then start chain stops at first failure', async () => {
99
+ // Given - a sequence in the SAME working directory
100
+ const result = await cli
101
+ .fixture(directory('../../__fixtures__/base-shop/'))
102
+ .exec(['build', 'start --check']);
103
+
104
+ // Then
105
+ expect(result.exitCode).toBe(0);
106
+ });
107
+ ```
108
+
109
+ Commands run sequentially in the same cwd; the sequence stops at the first failure. Its subject, the folder the spec files under, is its LAST command: that is the one the Then reads, and the commands before it are the Given, which name no subject: a docker session opens with `up` and is not filed under it. This is still one terminal action: one spec, one `.exec()` (rule `one-terminal-action-per-chain`). An empty array throws (`exec([]) requires at least one command`), and `{ waitFor, timeout }` cannot be combined with the array form.
110
+
111
+ ### Working-directory semantics
112
+
113
+ Every spec gets a brand-new temp directory (`mkdtemp`) as its cwd. The runner never writes into your fixtures.
114
+
115
+ That directory lives in the **OS temp dir**, inside the run's own temp root, and it stays there on purpose. It is scratch, created for one spec and removed once that spec's test has finished (at the run's end under `test.concurrent`, see [Set up a project](../start/set-up-a-project.md#where-a-run-writes)), not something the project produced, so it does not belong under `.artifacts/` with the caches a next run reuses (see [Set up a project](../start/set-up-a-project.md#where-a-run-writes)). 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.
116
+
117
+ Two disjoint verbs shape a spec's state (rule `seeds-are-sql`): **`.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; only what git cannot carry is laid by a step ([`.prepare()`](#prepare-what-no-fixture-can-carry)).
118
+
119
+ A fixture is named by `file()` or `directory()`, with its path from the spec file spelled in full; the wrapper states what it names:
120
+
121
+ | Call | Names | Copy effect |
122
+ | -------------------------------------------------- | ------------------------------ | --------------------------------------------- |
123
+ | `.fixture(file('./__fixtures__/config.toml'))` | one file beside the spec | `<cwd>/config.toml` (the file under its name) |
124
+ | `.fixture(directory('./__fixtures__/base-shop/'))` | a directory beside the spec | its **contents spread** into `<cwd>` |
125
+ | `.fixture(directory('../../__fixtures__/shop/'))` | a directory of the shared pool | its **contents spread** into `<cwd>` |
126
+
127
+ A directory's path ends in `/`, and the type refuses one that does not; a `file()` that names a directory on disk, or a `directory()` that names a file, is refused with the form to write (rule `disk-named-by-relative-path-at-runtime`). A command that expects the project one level down gets a fixture tree shaped that way: `./__fixtures__/nested/shop/...`, spread. The shared pool is `specs/__fixtures__/`, and a spec reaches it by the path that climbs to it; nothing hides behind a marker. Chained fixtures **layer** in order: a later one overwrites files from an earlier one.
128
+
129
+ | Setup | Effect on the cwd |
130
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
131
+ | _(nothing)_ | Empty directory |
132
+ | `.fixture(directory('../../__fixtures__/base-shop/'))` | Spread a whole shared project into the cwd |
133
+ | `.fixture(file('./__fixtures__/config.toml'))` | Copy the leaf's own `__fixtures__/config.toml` into the cwd |
134
+ | `.seed(file('./__seeds__/users.sql'))` | SQL into a service database (the `database:` option: [Services § The `database:` rule](../reference/services.md#the-database-rule)) |
135
+
136
+ ```typescript
137
+ test('scaffolds a new shop', async () => {
138
+ // Given - empty cwd (every spec = fresh mkdtemp)
139
+ const result = await cli.exec('create my-shop');
140
+
141
+ // Then - precise files...
142
+ expect(result.exitCode).toBe(0);
143
+ expect(result.file('my-shop/shoply.yaml').exists).toBe(true);
144
+ expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop');
145
+ expect(result.file('my-shop/.env').exists).toBe(false);
146
+
147
+ // ...or a tree snapshot (structured added/removed/changed diff)
148
+ await expect(result.directory('my-shop')).toMatch(directory('./__expected__/shop-scaffold/'));
149
+ });
150
+
151
+ test('upgrade rewrites the workspace predictably', async () => {
152
+ // Given
153
+ const result = await cli.fixture(directory('../../__fixtures__/base-shop/')).exec('upgrade');
154
+
155
+ // Then - snapshot of the ENTIRE cwd + flat read of the file list
156
+ await expect(result.filesystem).toMatch(directory('./__expected__/upgraded-shop/'));
157
+ expect(await result.filesystem.files()).toContain('shoply.lock');
158
+ });
159
+
160
+ test('seeds a single fixture file', async () => {
161
+ // Given
162
+ const result = await cli
163
+ .fixture(file('./__fixtures__/legacy-config.toml'))
164
+ .exec('migrate-config');
165
+
166
+ // Then
167
+ expect(result.file('shoply.yaml').exists).toBe(true);
168
+ });
169
+ ```
170
+
171
+ A tree golden is a directory, compared by `toMatch(directory('./__expected__/shop-scaffold/'))`; its path ends in `/` where a file golden's carries its extension (rule `golden-path-has-extension`).
172
+
173
+ ### `.env()`: child process environment
174
+
175
+ ```typescript
176
+ test('isolates HOME per spec', async () => {
177
+ // Given - HOME on the temp cwd, TZ pinned, stray variable removed
178
+ const result = await cli
179
+ .env({ HOME: '$WORKDIR', TZ: 'UTC', SHOPLY_TOKEN: null })
180
+ .exec('login --offline');
181
+
182
+ // Then
183
+ expect(result.file('.shoply/credentials').exists).toBe(true);
184
+ });
185
+ ```
186
+
187
+ - `'$WORKDIR'` expands to the spec's temp cwd; pin `HOME`, caches, config dirs onto isolated ground.
188
+ - `null` **removes** a variable from the child env, to shield the spec from the developer's shell.
189
+ - Repeated `.env()` calls merge; later keys win.
190
+ - `cli.with({ env })` is the environment a whole FILE shares: every chain of the view it hands back starts from it, and a chain's own `.env()` merges over it ([Conventions](../concepts/conventions.md#what-a-file-shares)). What every run of the binary needs, whichever file, is the runner's `defaults`.
191
+
192
+ #### Auto-injected connection URLs
193
+
194
+ When the runner has `services`, the framework injects into the child env, automatically:
195
+
196
+ | Variable | Rule |
197
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
198
+ | `<KEY>_URL` | One per record key, uppercased to CONSTANT_CASE at camelCase boundaries: `db:` → `DB_URL`, `analyticsDb:` → `ANALYTICS_DB_URL`, `cache:` → `CACHE_URL` |
199
+ | `DATABASE_URL` | Standard alias, only when the record has **exactly one** SQL database |
200
+ | `REDIS_URL` | Standard alias, only when the record has **exactly one** redis |
201
+
202
+ `.env()` overrides any injected value; `null` removes it. Re-declaring what injection already provides (`.env({ DATABASE_URL: db.connectionString })`) is redundant: rule [`no-redundant-service-url`](../reference/linting.md#no-redundant-service-url) refuses it.
203
+
204
+ ```typescript
205
+ // One SQL database → DATABASE_URL and DB_URL both injected, no database: option (rule `database-named-only-when-several`)
206
+ export const { cli: migrateCli, cleanup } = await specification.cli('shoply-migrate', {
207
+ services: { db: postgres() },
208
+ });
209
+
210
+ test('migrates a legacy schema', async () => {
211
+ // Given - DATABASE_URL / DB_URL injected automatically into the child env
212
+ const result = await migrateCli.seed(file('./__seeds__/legacy-schema.sql')).exec('up');
213
+
214
+ // Then
215
+ expect(result.exitCode).toBe(0);
216
+ await expect(result.table('schema_migrations')).toMatch([['20260716']]);
217
+ });
218
+ ```
219
+
220
+ ### File state is the shape of a fixture tree
221
+
222
+ CLI-mode `.seed()` is **SQL only**: it carries database state, nothing else (rule `seeds-are-sql`). To put files or config trees into the cwd, use `.fixture(directory(...))` with a fixture laid out exactly as the cwd should look. There is no "seed handler" or path-prefix dispatch.
223
+
224
+ ```typescript
225
+ // specs/cli/deploy/__fixtures__/two-shops/ ← committed, shaped like the target cwd
226
+ // shoply.yaml
227
+ // shops/alpha/config.yaml
228
+ // shops/beta/config.yaml
229
+
230
+ test('deploys from a shaped workspace', async () => {
231
+ // Given - the tree is spread into the cwd; layering a second fixture would
232
+ // overwrite matching files (last write wins)
233
+ const result = await cli.fixture(directory('./__fixtures__/two-shops/')).exec('deploy alpha');
234
+
235
+ // Then
236
+ expect(result.exitCode).toBe(0);
237
+ });
238
+ ```
239
+
240
+ #### Where a fixture lives
241
+
242
+ A fixture tree sits beside the one leaf that reads it, `./__fixtures__/<name>/` or, when several leaves share it, in the `__fixtures__/` of the lowest folder they share, `specs/__fixtures__/` when they span specifications, reached by the path that climbs to it. That law holds for every reserved folder, `__setup__/` included, and its owner is [Conventions](../concepts/conventions.md#the-naming-recap), with the three rules that hold it (`no-reach-into-sibling-ground`, `no-duplicate-ground`, `ground-shared-or-inline`).
243
+
244
+ ### `.link()`: a package the working directory resolves
245
+
246
+ A fixture tree carries no `node_modules`: git never tracks one. A binary whose subject imports a package (a checker that loads a plugin, a build that resolves a peer) needs that package where Node looks for it, and `.link(name, source?)` puts it there: `<cwd>/node_modules/<name>`, a symlink to the package, never a copy and never an install, except for a packed package, which is the install a consumer gets.
247
+
248
+ | Call | Links |
249
+ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
250
+ | `.link('@acme/sdk')` | the package the spec's own project installed, found as Node finds it from the spec |
251
+ | `.link('@acme/sdk', directory('./__fixtures__/sdk/'))` | the package a directory holds: a stub install, a broken one, an older version |
252
+ | `.link('@acme/sdk', packed())` | the tarball `npm pack` writes for the spec's own project, as a consumer installs it ([below](#a-package-specified-from-its-artefact)) |
253
+
254
+ ```typescript
255
+ test('loads the plugin the project installed', async () => {
256
+ // Given - a project fixture, and the plugin it imports linked into its node_modules
257
+ const result = await cli
258
+ .fixture(directory('./__fixtures__/shop/'))
259
+ .link('@acme/sdk')
260
+ .exec('check');
261
+
262
+ // Then
263
+ expect(result.exitCode).toBe(0);
264
+ });
265
+ ```
266
+
267
+ A link is armed after every fixture, so it replaces a package a fixture laid under the same name. The name is a package name (`@scope/name` or `name`), and one name is linked once per chain; a bare name no `node_modules` above the spec holds, or a `directory()` naming nothing, is refused before the binary runs. So is a working directory whose `node_modules` (or scope folder) is a symlink (a fixture can carry one, a binary can lay one, and the link would replace the real install it points to), or a file, which leaves the link no folder to be laid in.
268
+
269
+ ### A package specified from its artefact
270
+
271
+ A library is specified as its consumers receive it: from the tarball, never from its source tree. `packed()` names that tarball, and `.link(name, packed())` lays it: `npm pack` over the project the spec belongs to (its nearest `package.json`), unpacked and COPIED to `node_modules/<name>`, beside a link to each package its manifest requires at runtime (`dependencies`, `optionalDependencies`, every peer not marked optional), taken from where the package is developed, and its `bin` entries in `node_modules/.bin/`. What `files` leaves out is not there, a subpath the `exports` map forgets does not resolve, and a dependency left in `devDependencies` is missing, each the failure a consumer would meet.
272
+
273
+ ```typescript
274
+ test('a consumer loads the published entry', async () => {
275
+ // Given - a consumer module importing the package by its published name
276
+ const result = await cli
277
+ .fixture(directory('./__fixtures__/consumer/'))
278
+ .link('@acme/telemetry', packed())
279
+ .exec('consumer.mjs');
280
+
281
+ // Then
282
+ expect(result.exitCode).toBe(0);
283
+ });
284
+ ```
285
+
286
+ The project is packed once per test process, from what its build left in `dist/` (so the spec runs after `make build`, as the Makefile's `test: build` orders it), and under a mutation run the sandbox's own build is what gets packed. `packed(directory('./__fixtures__/.../'))` packs another package the spec holds. The name is the one the package's manifest declares: linking it under another is refused before the binary runs. So is a `directory()` naming nothing, a file or a folder with no `package.json`, a manifest that is no JSON, a runtime dependency no checkout above the package installed, a package `npm pack` cannot pack (its refusal quotes what npm said) and, as for a plain link, a working directory whose `node_modules` or scope folder is a symlink, which the install would otherwise delete through. Each is refused before anything is deleted or laid, so a refused install leaves what an earlier one laid as it was.
287
+
288
+ The install lays the package and what its manifest requires, never what npm would hoist beside them: a consumer whose install holds `vite` under `vitest`, `@types/node` or its own devDependencies links each by name (`.link('vite')`), so every package the consumer reaches is one its spec names.
289
+
290
+ **The declarations a consumer compiles against** are checked the same way: a consumer fixture holding a `.ts` module and a `tsconfig.json`, the compiler and the Node types linked beside the packed package, and `tsc` run over it: `.link('@acme/telemetry', packed()).link('typescript').link('@types/node').exec('node_modules/typescript/bin/tsc -p .')`, an exit of `0` and an empty stdout.
291
+
292
+ **`NODE_OPTIONS` reaches the binary on purpose.** A mutation run hands every spawned node its coverage reporter through it; a `defaults` that sets `NODE_OPTIONS: null` makes every mutant read as "no test runs". Clear what the product reads, never that.
293
+
294
+ ### `.prepare()`: what no fixture can carry
295
+
296
+ A fixture tree is what git tracks, and some ground git never tracks: a `.git` for a tool that reads a repository, an empty folder, a file the repository's own `.gitignore` keeps out of the fixture, an index holding one file and not another. `.prepare(step)` runs a function of the working directory's absolute path once every fixture and link is laid, in the order the steps are stated, before the binary runs; a step may be async, and one that throws fails the chain before anything runs.
297
+
298
+ ```typescript
299
+ test('refuses a tracked build artefact', async () => {
300
+ // Given - a project, made a repository whose index holds a buildinfo its .gitignore excludes
301
+ const result = await cli
302
+ .fixture(directory('./__fixtures__/project/'))
303
+ .prepare((workdir) => {
304
+ writeFileSync(join(workdir, 'tsconfig.tsbuildinfo'), '{}\n');
305
+ execFileSync('git', ['init', '--quiet'], { cwd: workdir });
306
+ execFileSync('git', ['add', '--force', 'tsconfig.tsbuildinfo'], { cwd: workdir });
307
+ })
308
+ .exec('check');
309
+
310
+ // Then
311
+ expect(result.exitCode).toBe(1);
312
+ });
313
+ ```
314
+
315
+ A step is the Given of state no fixture can hold, never a second way to write one: a file a fixture could carry belongs in the fixture, where it is read in the tree rather than in code, and a package belongs to `.link()`.
316
+
317
+ ### The chain never reaches a real system
318
+
319
+ 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 `network-strict-after-first-stub`, in [stubs](../reference/stubs.md).
320
+
321
+ The framework supplies the ground; the chain states the sandbox, in three verbs:
322
+
323
+ | Verb | What it closes |
324
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------ |
325
+ | `.fixture(directory('../../__fixtures__/<name>/'))` | spreads a `bin/` of stub binaries into the spec's temp cwd |
326
+ | `.env({ PATH: '$WORKDIR/bin:/usr/bin:/bin' })` | puts those stubs FIRST; the binary finds the stub, never the tool installed on the host |
327
+ | `.env({ HOME: '$WORKDIR' })` | moves config dirs, caches and credential stores onto the temp cwd, off the operator's home |
328
+
329
+ A sealed `HOME` hides what the product itself shells out to. Pinning `HOME` onto the temp cwd also removes the operator's docker CLI plugins and git configuration, so a product that runs `docker build` loses its buildx plugin and prints a legacy-builder notice a racing stream then carries. Seal the product's own home, the folder it writes its configuration to (`.env({ MYAPP_HOME: '$WORKDIR' })`), and leave the operator's `HOME` for the tools the product shells out to.
330
+
331
+ A spec document cannot take a lock on a resource the suite shares, such as the one named container a TypeScript specification starts under a lock. A spec that needs a shared resource is code: a `*.spec.ts` that takes the lock the others take.
332
+
333
+ `PATH` is not rewritten for you: `.fixture(directory(...))` 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.
334
+
335
+ ```typescript
336
+ test('reports what the cluster holds', async () => {
337
+ // Given - stubs first on PATH, HOME on the temp cwd: nothing can reach out
338
+ const result = await cli
339
+ .fixture(directory('../../__fixtures__/cluster-stub/'))
340
+ .env({ HOME: '$WORKDIR', PATH: '$WORKDIR/bin:/usr/bin:/bin', TZ: 'UTC' })
341
+ .exec('cluster pods');
342
+
343
+ // Then
344
+ expect(result.exitCode).toBe(0);
345
+ expect(result.stdout).toMatch(file('./__expected__/pods.txt'));
346
+ });
347
+ ```
348
+
349
+ #### The shape of a stub fixture
350
+
351
+ A stub fixture is a `bin/` holding one script per binary the run must not find for real:
352
+
353
+ ```text
354
+ specs/__fixtures__/cluster-stub/
355
+ └── bin/
356
+ ├── kubectl
357
+ └── helm
358
+ ```
359
+
360
+ 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:
361
+
362
+ ```bash
363
+ #!/bin/sh
364
+ # Every invocation this suite cans. Anything else is a chain reaching for an
365
+ # answer nobody wrote: fail loudly rather than improvise one.
366
+ case "$*" in
367
+ "get pods -A -o json")
368
+ cat <<'JSON'
369
+ {"items":[{"metadata":{"name":"api-abc12","namespace":"prod"},"status":{"phase":"Running"}}]}
370
+ JSON
371
+ ;;
372
+ *)
373
+ echo "cluster-stub kubectl: unexpected invocation: $*" >&2
374
+ exit 64
375
+ ;;
376
+ esac
377
+ ```
378
+
379
+ 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.
380
+
381
+ **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.
382
+
383
+ **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:
384
+
385
+ ```bash
386
+ #!/bin/sh
387
+ # Route argv to the file that answers it. The ANSWERS are the fixture; this
388
+ # Script only says which one the question maps to.
389
+ answers="${0%/*}/../answers/cluster"
390
+ case "$*" in
391
+ "get pods -A -o json") file="$answers/pods.json" ;;
392
+ "get nodes -o json") file="$answers/nodes.json" ;;
393
+ *) echo "cluster-stub kubectl: unexpected invocation: $*" >&2; exit 64 ;;
394
+ esac
395
+ [ -f "$file" ] || { echo "cluster-stub kubectl: no answer file at $file" >&2; exit 64; }
396
+ cat "$file"
397
+ ```
398
+
399
+ 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.
400
+
401
+ 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.
402
+
403
+ 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).
404
+
405
+ ### Declared backend
406
+
407
+ A binary that sends its requests to a configurable origin (a collector, an ingest endpoint, an API) can be served stubs: `specification.cli(bin, { backend: { env: 'INGEST_URL' } })` starts a stub backend once, and every run receives its address in `INGEST_URL`. The chain declares what it answers with `.intercept(http.post('/ingest'), http.json({ accepted: true }))`: paths, since the origin is the backend's own. A request matching no declared stub is answered 501 and fails the run, naming the request; a stub declared `never` fails it if reached. What the binary sent is on the result: `result.requests` lists every request the backend received during the run, in order, matched or not, each `{ body, headers, method, url }` with the body parsed when it is JSON. `expect(result.requests.map(({ body }) => body)).toStrictEqual([{ report: 'daily' }])` compares an ingest payload whole, with no server of the spec's own. A chain begins with none: a result lists its own run's requests. A library whose host is written into its code cannot be pointed at a backend, and its specs stay in the process they run in.
408
+
409
+ ## The result
410
+
411
+ 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 [Assertions](../reference/assertions.md)'s; what follows is the part only this specification has.
412
+
413
+ ### Streams, JSON, grep
414
+
415
+ ```typescript
416
+ test('fails on unknown command with a useful error', async () => {
417
+ // Given
418
+ const result = await cli.exec('frobnicate');
419
+
420
+ // Then
421
+ expect(result.exitCode).toBe(1);
422
+ expect(result.stderr.text).toContain("Unknown command 'frobnicate'");
423
+ expect(result.stdout.text).toBe(''); // .text: ANSI stripped, transform applied
424
+ });
425
+
426
+ test('emits machine-readable config', async () => {
427
+ // Given
428
+ const result = await cli.exec('config --json');
429
+
430
+ // Then
431
+ expect(result.json.value).toMatchObject({ name: 'shoply' }); // read + native matcher
432
+ expect(result.json).toMatch(file('./__expected__/config.json')); // __expected__/config.json
433
+ });
434
+
435
+ test('lints a shop and reports per-file blocks', async () => {
436
+ // Given
437
+ const result = await cli.fixture(directory('../../__fixtures__/base-shop/')).exec('lint');
438
+
439
+ // Then - snapshot the whole surface; grep is the scalpel for targeted probes
440
+ expect(result.stdout).toMatch(file('./__expected__/lint-output.txt')); // full snapshot, tokens for volatile parts
441
+ expect(result.stdout.grep('products/ok.yaml').text).not.toContain('error'); // absence probe
442
+ });
443
+ ```
444
+
445
+ `result.stdout` / `result.stderr` are `TextAccessor` subjects, the universal text handle (stdout, stderr, container logs, file text), ANSI-stripped for every comparison ([Tokens § Where tokens work](../reference/tokens.md#where-tokens-work)): `.text` is that comparable text (the runner's `transform` applied too) and `.raw` the capture as it was. Text operations are **closed** over the type: `.grep(pattern)` cuts the blank-line-separated blocks of the capture, its colour codes stripped and BEFORE the transform (so a pattern the transform removes still finds its block), and returns a `TextAccessor` (not a string) whose `.text` reads through the same transform, so it chains (`result.stdout.grep(a).grep(b)`) and snapshots (`expect(result.stdout.grep('users.ts')).toMatch(file('./__expected__/block.txt'))`). There is no `result.grep()`: the source is always explicit. `result.json` parses stdout as JSON (`.value` for the parsed object).
446
+
447
+ **Tool output gets a snapshot per scoped use case (rule `output-asserted-as-a-golden`).** For linter/compiler/CLI output, prefer a per-use-case fixture project + a full `expect(result.stdout).toMatch(file('./__expected__/<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](../reference/assertions.md).
448
+
449
+ ## Unique here
450
+
451
+ ### Spec documents: `<case>.spec.yaml`
452
+
453
+ Most cli cases are documents: a session written as data (the ground, the runs, what each printed and left on disk), collected as a test file of its own by the `cli()` project. The format is [Spec documents](spec-documents.md): its keys, its paths, the three doors that run it, failure and update. What stays here is what only this constructor owns: the words a document names its ground by.
454
+
455
+ #### Registration: once per app
456
+
457
+ The document names its ground by WORD; the code says what those words mean, in the `specification.cli()` options:
458
+
459
+ ```typescript
460
+ // apps/cli/specs/cli/cli.specification.ts, in a workspace
461
+ export const { cli, cleanup } = await specification.cli(bin, {
462
+ env: {
463
+ frozen: { HOME: '$WORKDIR', TZ: 'UTC' },
464
+ },
465
+ serve: {
466
+ mcp: {
467
+ // Relative to apps/cli/, the project root, not the repo root
468
+ command: 'bun specs/harness/mcp-server.ts',
469
+ env: 'SHOPLY_MCP_ORIGIN',
470
+ ready: /listening on port (?<port>\d+)/,
471
+ url: (port) => `http://127.0.0.1:${port}/mcp`,
472
+ },
473
+ },
474
+ });
475
+ ```
476
+
477
+ A `serve` entry is spawned with the document's extra `KEY: value` merged into its environment, and its **cwd is the project root**: rule `no-redundant-root`'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.
478
+
479
+ `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.
480
+
481
+ **`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.
482
+
483
+ ### Docker-aware mode
484
+
485
+ For CLIs that spawn Docker containers, declare the `docker` option (rule `containers-removed-with-the-run`). The specification refuses to start where no Docker daemon answers: a run that reads containers and could see none would pass on nothing.
486
+
487
+ ```typescript
488
+ export const {
489
+ cli: deploy,
490
+ cleanup,
491
+ docker,
492
+ } = await specification.cli('shoply', {
493
+ docker: {
494
+ envVar: 'SHOPLY_TEST_RUN', // run id injected into the child env
495
+ nameLabel: 'dev.shoply.shop.name', // label used as the .container(name) key
496
+ testRunLabel: 'dev.shoply.test.run', // label the CLI must put on every container
497
+ },
498
+ });
499
+ ```
500
+
501
+ 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.
502
+
503
+ A Docker-aware runner **requires `await using`** on every result (rule `docker-exec-bound-with-await-using`; the checker infers the runners from the `docker:` option), so leaked containers are cleaned by label filter at scope exit:
504
+
505
+ ```typescript
506
+ test('deploy spawns a labelled container', async () => {
507
+ // Given - await using ⇒ containers force-removed at scope exit (rule `docker-exec-bound-with-await-using`)
508
+ await using result = await deploy
509
+ .fixture(directory('../../__fixtures__/two-shops/'))
510
+ .exec('deploy alpha');
511
+
512
+ // Then - lazy access (zero Docker calls if you never touch .container)
513
+ const shop = result.container('alpha');
514
+ expect(shop.exists).toBe(true);
515
+ await expect(shop).toBeRunning();
516
+
517
+ // Reads INSIDE the container, same vocabulary as on the host
518
+ expect(shop.file('/app/shoply.yaml').content).toContain('name: alpha');
519
+ const inside = await shop.exec('ls /app');
520
+ expect(inside.stdout.text).toContain('shoply.yaml');
521
+ expect(shop.stdout.text).toContain('shop ready'); // container logs
522
+
523
+ // Absent ≠ crash
524
+ expect(result.container('nope').exists).toBe(false);
525
+ expect(result.containerIds).toHaveLength(1);
526
+ });
527
+
528
+ test('destroy removes the container created in an earlier run', async () => {
529
+ // Given - spawn then destroy: the whole runner shares one run-id scope
530
+ await using first = await deploy.exec('deploy alpha');
531
+ await using second = await deploy.exec('destroy alpha');
532
+
533
+ // Then
534
+ expect(second.exitCode).toBe(0);
535
+ expect(second.container('alpha').exists).toBe(false);
536
+ });
537
+ ```
538
+
539
+ 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.
540
+
541
+ 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`.
542
+
543
+ ## Pitfalls
544
+
545
+ - **Reaching for `.spawn()`.** It does not exist; `.exec(args, { waitFor, timeout })` is the unified execution method.
546
+ - **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 `no-transform-duplicating-tokens`).
547
+ - **Assigning a Docker-aware result without `await using`.** Error (rule `docker-exec-bound-with-await-using`): that is the leak-cleanup mechanism.
548
+ - **Re-declaring injected URLs.** `.env({ DATABASE_URL: ... })` duplicates rule `service-urls-injected`'s auto-injection, and [`no-redundant-service-url`](../reference/linting.md#no-redundant-service-url) refuses it. Override only to _change_ it, `null` to remove it.
549
+ - **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, and the spec then tests the operator's machine. Pin `PATH` and `HOME` on `$WORKDIR`.
550
+ - **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.
551
+ - **Reaching for `.project()` or `seedHandlers`.** Both are gone: one verb copies files, `.fixture()`, handed a `file()` or a `directory()`, and `.seed()` is SQL-only (rule `seeds-are-sql`).
552
+ - **A bare fixture name.** `.fixture('shop/')` and `directory('shop/')` do not compile: the path is spelled from the spec: `./__fixtures__/shop/` beside it, `../../__fixtures__/shop/` in the ground at the specs root (rule `disk-named-by-relative-path`).
553
+ - **Building a `node_modules` by hand.** `mkdirSync` and `symlinkSync` around a raw `spawnSync` is a chain the runner already is: `.link()` lays the package, `.link(name, packed())` the published tarball with what it requires, and the spec stays one chain.
554
+ - **Forgetting the extension in `file('./__expected__/help')`.** The extension is part of the path (rule `golden-path-has-extension`); a tree is `directory('./__expected__/help/')`.
555
+ - **Reading a sequence's first command.** A sequence's result is the LAST command's (its streams and exit code are what the Then reads), and its subject is the last command as well; the commands before it are the Given. A spec that must read an earlier step runs it as its own spec.
556
+ - **A serve-and-read consumer.** A spec that starts a server in one command and reads it in the next races the server's start: serve with `waitFor`, or give the reader a stand-in that fails loudly when nothing answers.
557
+ - **Opening a browser, or reaching a client's network.** Put a stand-in opener on `PATH` and set `$BROWSER` to it so a command that opens a page writes the URL it was handed to a file; give a client that reaches the network a proxy that refuses every request and names it.
558
+ - **Specifying an application through its built entry.** A spec that runs `dist/index.js` needs the build first: the Makefile of every kind whose specs run `dist/` reads `test: build`, and the path is named in the spec as the build leaves it (`named-path-exists` accepts a path under `dist/` or `.artifacts/<tool>/`).
559
+ - **Confusing `text()` with `content`.** `text(value)` promotes a string (a message, a report) to a golden-comparable subject (ANSI stripped, tokens read); `result.file(path).content` is the UTF-8 text of a file the run left, to be read or compared as a file.
560
+ - **A resource shared across spec files.** Files run in worker processes of their own: a port, a docker name or a database two files take at once needs a lock taken in `__setup__/` (a file under `.artifacts/` created exclusively), not a project made serial by name.
561
+ - **A resource the product itself mutates.** A mutable resource the product writes and reads across its own steps (an image tag every build retags, a cache entry) is locked by the product around its window, not by the specs: no spec lock covers two processes of the product itself, so a second run between its build and its create retags what the first was about to use. Give each run a tag of its own, or serialise the window in the product.
562
+ - **`@types/node` in a package that reads no Node.** A library that does not read `node:` modules declares no `@types/node`; one that reads Node declares it, and a spec project follows its package.
563
+
564
+ ## Related
565
+
566
+ [Spec documents](spec-documents.md) · [Set up a project](../start/set-up-a-project.md) · [Assertions](../reference/assertions.md) · [Tokens](../reference/tokens.md) · [Services](../reference/services.md)