@jterrazz/test 16.1.1 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/README.md +45 -171
  2. package/dist/browser/index.js +2 -2980
  3. package/dist/browser/services.js +2 -0
  4. package/dist/browser/stubs.js +2 -0
  5. package/dist/checker.d.ts +6 -1
  6. package/dist/checker.js +4534 -2628
  7. package/dist/config.d.ts +40 -0
  8. package/dist/config.js +309 -0
  9. package/dist/in-place.global-setup.d.ts +11 -0
  10. package/dist/in-place.global-setup.js +21 -0
  11. package/dist/index.d.ts +1653 -3004
  12. package/dist/index.js +5226 -3845
  13. package/dist/mobile-session.global-setup.d.ts +14 -0
  14. package/dist/mobile-session.global-setup.js +31 -0
  15. package/dist/oxlint.cjs +6069 -5634
  16. package/dist/oxlint.d.cts +43 -7212
  17. package/dist/oxlint.d.ts +43 -7212
  18. package/dist/oxlint.js +6127 -5693
  19. package/dist/route-coverage.global-setup.d.ts +42 -0
  20. package/dist/route-coverage.global-setup.js +2 -0
  21. package/dist/services.d.ts +3 -0
  22. package/dist/services.global-setup.d.ts +43 -0
  23. package/dist/services.global-setup.js +2 -0
  24. package/dist/services.js +597 -0
  25. package/dist/shared-appium.adapter.js +428 -0
  26. package/dist/shared-artifacts.js +32 -0
  27. package/dist/shared-chapter.js +7 -0
  28. package/dist/shared-container.port.d.ts +31 -0
  29. package/dist/shared-derived-projects.js +58 -0
  30. package/dist/shared-device.port.d.ts +189 -0
  31. package/dist/shared-exchange.js +447 -0
  32. package/dist/shared-in-place.js +172 -0
  33. package/dist/shared-index.browser.js +3191 -0
  34. package/dist/shared-index.services.d.ts +346 -0
  35. package/dist/shared-index.stubs.d.ts +287 -0
  36. package/dist/shared-json-shape.js +17 -0
  37. package/dist/shared-landmarks.js +71 -0
  38. package/dist/shared-ledger.js +68 -0
  39. package/dist/shared-literate-plugin.js +183 -0
  40. package/dist/shared-manifest.js +1321 -0
  41. package/dist/shared-pause.js +11 -0
  42. package/dist/shared-peer.js +88 -0
  43. package/dist/shared-playwright.adapter.js +330 -0
  44. package/dist/shared-process.adapter.js +74 -0
  45. package/dist/shared-refusal.js +35 -0
  46. package/dist/shared-resolve.js +24 -0
  47. package/dist/shared-route-coverage.global-setup.js +187 -0
  48. package/dist/shared-scenario-failure.js +198 -0
  49. package/dist/shared-serve.js +365 -0
  50. package/dist/shared-service-images.js +10 -0
  51. package/dist/shared-services.global-setup.js +179 -0
  52. package/dist/shared-session-ledger.js +301 -0
  53. package/dist/shared-setup-entry.js +12 -0
  54. package/dist/shared-source-scan.js +133 -0
  55. package/dist/shared-spec-document.js +365 -0
  56. package/dist/shared-specifications.js +26 -0
  57. package/dist/shared-stream-body.js +130 -0
  58. package/dist/shared-stub-server.js +190 -0
  59. package/dist/shared-temp-root.global-setup.js +80 -0
  60. package/dist/shared-test-baseline.js +26 -0
  61. package/dist/shared-test-section.js +58 -0
  62. package/dist/shared-testcontainers.adapter.js +203 -0
  63. package/dist/shared-tokens.js +55 -0
  64. package/dist/shared-yaml-document.js +119 -0
  65. package/dist/stubs.d.ts +2 -0
  66. package/dist/stubs.js +657 -0
  67. package/dist/temp-root.global-setup.d.ts +7 -0
  68. package/dist/temp-root.global-setup.js +2 -0
  69. package/dist/vitest.d.ts +97 -163
  70. package/dist/vitest.js +373 -525
  71. package/docs/README.md +82 -36
  72. package/docs/concepts/architecture.md +118 -0
  73. package/docs/concepts/conventions.md +333 -0
  74. package/docs/guides/api-specs.md +374 -0
  75. package/docs/guides/cli-specs.md +564 -0
  76. package/docs/guides/component-specs.md +252 -0
  77. package/docs/guides/develop.md +94 -0
  78. package/docs/guides/integration-specs.md +181 -0
  79. package/docs/guides/job-specs.md +180 -0
  80. package/docs/guides/lint-specs.md +174 -0
  81. package/docs/guides/mobile-specs.md +319 -0
  82. package/docs/guides/module-tests.md +149 -0
  83. package/docs/guides/release.md +43 -0
  84. package/docs/guides/spec-documents.md +189 -0
  85. package/docs/guides/website-specs.md +413 -0
  86. package/docs/reference/assertions.md +345 -0
  87. package/docs/reference/glossary.md +20 -0
  88. package/docs/reference/linting.md +483 -0
  89. package/docs/reference/services.md +236 -0
  90. package/docs/reference/stubs.md +498 -0
  91. package/docs/reference/time-and-doubles.md +137 -0
  92. package/docs/reference/tokens.md +258 -0
  93. package/docs/reference/visitor.md +221 -0
  94. package/docs/start/getting-started.md +187 -0
  95. package/docs/start/set-up-a-project.md +214 -0
  96. package/package.json +50 -30
  97. package/schema/spec.schema.json +43 -17
  98. package/dist/appium.adapter.js +0 -489
  99. package/dist/artifacts.js +0 -32
  100. package/dist/fixtures.js +0 -110
  101. package/dist/literate.js +0 -1747
  102. package/dist/match.js +0 -804
  103. package/dist/playwright.adapter.js +0 -438
  104. package/dist/server.js +0 -160
  105. package/dist/stream.js +0 -321
  106. package/dist/substring-warning.js +0 -282
  107. package/docs/01-architecture.md +0 -95
  108. package/docs/02-developing.md +0 -351
  109. package/docs/03-testing.md +0 -495
  110. package/docs/04-operating.md +0 -101
  111. package/docs/05-module-tests.md +0 -152
  112. package/docs/06-integration.md +0 -180
  113. package/docs/07-component.md +0 -246
  114. package/docs/08-website.md +0 -404
  115. package/docs/09-mobile.md +0 -270
  116. package/docs/10-api.md +0 -264
  117. package/docs/11-jobs.md +0 -157
  118. package/docs/12-cli.md +0 -677
  119. package/docs/13-elements.md +0 -248
  120. package/docs/14-assertions.md +0 -314
  121. package/docs/15-tokens.md +0 -253
  122. package/docs/16-contracts.md +0 -438
  123. package/docs/17-services.md +0 -188
  124. package/docs/18-conventions.md +0 -332
  125. package/docs/19-linting.md +0 -512
  126. package/docs/decisions/001-v9-conventions-on-three-channels.md +0 -50
  127. package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +0 -49
  128. package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +0 -84
  129. package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +0 -81
  130. package/docs/decisions/005-the-doubles-ladder-is-closed.md +0 -86
  131. package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +0 -245
  132. package/docs/decisions/007-compose-mode-leaves-the-package.md +0 -53
  133. package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +0 -75
  134. package/docs/decisions/_template.md +0 -22
  135. package/docs/reference/classes/Matcher.md +0 -120
  136. package/docs/reference/classes/ProcessHandle.md +0 -368
  137. package/docs/reference/functions/defineContract.md +0 -39
  138. package/docs/reference/functions/defineContracts.md +0 -36
  139. package/docs/reference/functions/disabled.md +0 -28
  140. package/docs/reference/functions/enabled.md +0 -20
  141. package/docs/reference/functions/focused.md +0 -26
  142. package/docs/reference/functions/mockOf.md +0 -79
  143. package/docs/reference/functions/postgres.md +0 -31
  144. package/docs/reference/functions/process.md +0 -33
  145. package/docs/reference/functions/redis.md +0 -28
  146. package/docs/reference/functions/required.md +0 -41
  147. package/docs/reference/functions/selected.md +0 -26
  148. package/docs/reference/functions/sqlite.md +0 -33
  149. package/docs/reference/functions/testId.md +0 -20
  150. package/docs/reference/functions/text.md +0 -40
  151. package/docs/reference/functions/valued.md +0 -28
  152. package/docs/reference/functions/waitUntil.md +0 -46
  153. package/docs/reference/functions/within.md +0 -29
  154. package/docs/reference/index.md +0 -196
  155. package/docs/reference/interfaces/BaseResult.md +0 -87
  156. package/docs/reference/interfaces/CallResult.md +0 -160
  157. package/docs/reference/interfaces/CaptureScope.md +0 -82
  158. package/docs/reference/interfaces/CliResult.md +0 -253
  159. package/docs/reference/interfaces/ContainerAccessor.md +0 -166
  160. package/docs/reference/interfaces/DirectoryAccessor.md +0 -33
  161. package/docs/reference/interfaces/FetchResult.md +0 -180
  162. package/docs/reference/interfaces/FilesystemAccessor.md +0 -46
  163. package/docs/reference/interfaces/HttpResult.md +0 -126
  164. package/docs/reference/interfaces/JsonAccessor.md +0 -46
  165. package/docs/reference/interfaces/PageResult.md +0 -356
  166. package/docs/reference/interfaces/RenderResult.md +0 -72
  167. package/docs/reference/interfaces/RenderedText.md +0 -110
  168. package/docs/reference/interfaces/ResponseAccessor.md +0 -47
  169. package/docs/reference/interfaces/ScreenResult.md +0 -128
  170. package/docs/reference/interfaces/TableAccessor.md +0 -26
  171. package/docs/reference/interfaces/TextAccessor.md +0 -96
  172. package/docs/reference/type-aliases/ApiHandle.md +0 -68
  173. package/docs/reference/type-aliases/ApiSpecification.md +0 -213
  174. package/docs/reference/type-aliases/ApiSpecificationOptions.md +0 -68
  175. package/docs/reference/type-aliases/BrowserConsoleMessage.md +0 -30
  176. package/docs/reference/type-aliases/BrowserLinkElement.md +0 -50
  177. package/docs/reference/type-aliases/BrowserMetaElement.md +0 -40
  178. package/docs/reference/type-aliases/BrowserOpenOptions.md +0 -87
  179. package/docs/reference/type-aliases/BrowserPage.md +0 -132
  180. package/docs/reference/type-aliases/BrowserPort.md +0 -51
  181. package/docs/reference/type-aliases/CliEnv.md +0 -11
  182. package/docs/reference/type-aliases/CliHandle.md +0 -80
  183. package/docs/reference/type-aliases/CliInput.md +0 -36
  184. package/docs/reference/type-aliases/CliOutput.md +0 -46
  185. package/docs/reference/type-aliases/CliPort.md +0 -58
  186. package/docs/reference/type-aliases/CliSpecification.md +0 -144
  187. package/docs/reference/type-aliases/CliSpecificationOptions.md +0 -134
  188. package/docs/reference/type-aliases/ComponentChain.md +0 -156
  189. package/docs/reference/type-aliases/ComponentScenario.md +0 -20
  190. package/docs/reference/type-aliases/ComponentUi.md +0 -47
  191. package/docs/reference/type-aliases/ComponentVisitor.md +0 -10
  192. package/docs/reference/type-aliases/ContainerPort.md +0 -113
  193. package/docs/reference/type-aliases/Contract.md +0 -68
  194. package/docs/reference/type-aliases/ContractInput.md +0 -13
  195. package/docs/reference/type-aliases/ContractRequest.md +0 -95
  196. package/docs/reference/type-aliases/ContractResponder.md +0 -23
  197. package/docs/reference/type-aliases/ContractResponse.md +0 -78
  198. package/docs/reference/type-aliases/ContractResponseValue.md +0 -13
  199. package/docs/reference/type-aliases/Contracts.md +0 -48
  200. package/docs/reference/type-aliases/DatabaseKeys.md +0 -16
  201. package/docs/reference/type-aliases/DatabasePort.md +0 -72
  202. package/docs/reference/type-aliases/DeviceOpenOptions.md +0 -46
  203. package/docs/reference/type-aliases/DevicePort.md +0 -51
  204. package/docs/reference/type-aliases/DeviceScreen.md +0 -32
  205. package/docs/reference/type-aliases/DeviceTimeouts.md +0 -39
  206. package/docs/reference/type-aliases/DockerSpecConfig.md +0 -43
  207. package/docs/reference/type-aliases/DomMount.md +0 -21
  208. package/docs/reference/type-aliases/ElementKind.md +0 -27
  209. package/docs/reference/type-aliases/ElementMatch.md +0 -74
  210. package/docs/reference/type-aliases/ElementOptions.md +0 -29
  211. package/docs/reference/type-aliases/ElementRef.md +0 -128
  212. package/docs/reference/type-aliases/ExecOptions.md +0 -37
  213. package/docs/reference/type-aliases/FileAccessor.md +0 -54
  214. package/docs/reference/type-aliases/HonoApp.md +0 -31
  215. package/docs/reference/type-aliases/HttpContractFilter.md +0 -49
  216. package/docs/reference/type-aliases/HttpResponseInit.md +0 -46
  217. package/docs/reference/type-aliases/HttpStreamInit.md +0 -58
  218. package/docs/reference/type-aliases/IntegrationHandle.md +0 -46
  219. package/docs/reference/type-aliases/IntegrationSpecification.md +0 -111
  220. package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +0 -42
  221. package/docs/reference/type-aliases/Intercept.md +0 -19
  222. package/docs/reference/type-aliases/InterceptOptions.md +0 -28
  223. package/docs/reference/type-aliases/InterceptScope.md +0 -11
  224. package/docs/reference/type-aliases/IsolationStrategy.md +0 -75
  225. package/docs/reference/type-aliases/JobHandle.md +0 -34
  226. package/docs/reference/type-aliases/JobsHandle.md +0 -57
  227. package/docs/reference/type-aliases/JobsResult.md +0 -16
  228. package/docs/reference/type-aliases/JobsSpecification.md +0 -94
  229. package/docs/reference/type-aliases/JobsSpecificationOptions.md +0 -56
  230. package/docs/reference/type-aliases/LandmarkKind.md +0 -20
  231. package/docs/reference/type-aliases/LiterateRunFlags.md +0 -27
  232. package/docs/reference/type-aliases/LiterateServeRegistration.md +0 -70
  233. package/docs/reference/type-aliases/MatchFixtureOptions.md +0 -27
  234. package/docs/reference/type-aliases/MatchableRequest.md +0 -61
  235. package/docs/reference/type-aliases/MatcherKind.md +0 -34
  236. package/docs/reference/type-aliases/MobileBackendOptions.md +0 -28
  237. package/docs/reference/type-aliases/MobileElementKind.md +0 -14
  238. package/docs/reference/type-aliases/MobileElementMatch.md +0 -60
  239. package/docs/reference/type-aliases/MobileElementRef.md +0 -20
  240. package/docs/reference/type-aliases/MobileHandle.md +0 -65
  241. package/docs/reference/type-aliases/MobileScenario.md +0 -20
  242. package/docs/reference/type-aliases/MobileSpecification.md +0 -51
  243. package/docs/reference/type-aliases/MobileSpecificationOptions.md +0 -126
  244. package/docs/reference/type-aliases/MobileVisitor.md +0 -81
  245. package/docs/reference/type-aliases/MockOfOptions.md +0 -26
  246. package/docs/reference/type-aliases/MockPort.md +0 -10
  247. package/docs/reference/type-aliases/PinnedClock.md +0 -10
  248. package/docs/reference/type-aliases/PostgresOptions.md +0 -32
  249. package/docs/reference/type-aliases/ProcessOptions.md +0 -112
  250. package/docs/reference/type-aliases/RedisOptions.md +0 -20
  251. package/docs/reference/type-aliases/RenderSubject.md +0 -12
  252. package/docs/reference/type-aliases/ScreenNode.md +0 -67
  253. package/docs/reference/type-aliases/ServerPort.md +0 -36
  254. package/docs/reference/type-aliases/ServerResponse.md +0 -46
  255. package/docs/reference/type-aliases/ServerSpec.md +0 -21
  256. package/docs/reference/type-aliases/ServiceHandle.md +0 -216
  257. package/docs/reference/type-aliases/ServiceRecord.md +0 -12
  258. package/docs/reference/type-aliases/SpecDocument.md +0 -84
  259. package/docs/reference/type-aliases/SpecEnvToken.md +0 -21
  260. package/docs/reference/type-aliases/SpecFileAssertion.md +0 -27
  261. package/docs/reference/type-aliases/SpecFixture.md +0 -30
  262. package/docs/reference/type-aliases/SpecKind.md +0 -8
  263. package/docs/reference/type-aliases/SpecRun.md +0 -108
  264. package/docs/reference/type-aliases/SpecServeEntry.md +0 -40
  265. package/docs/reference/type-aliases/SpecStream.md +0 -32
  266. package/docs/reference/type-aliases/SpecificationConfig.md +0 -290
  267. package/docs/reference/type-aliases/SqliteOptions.md +0 -36
  268. package/docs/reference/type-aliases/SseEvent.md +0 -58
  269. package/docs/reference/type-aliases/StreamBody.md +0 -61
  270. package/docs/reference/type-aliases/TextFilter.md +0 -22
  271. package/docs/reference/type-aliases/VisitScenario.md +0 -20
  272. package/docs/reference/type-aliases/Visitor.md +0 -215
  273. package/docs/reference/type-aliases/WaitUntilOptions.md +0 -46
  274. package/docs/reference/type-aliases/WebsiteBackendOptions.md +0 -37
  275. package/docs/reference/type-aliases/WebsiteHandle.md +0 -51
  276. package/docs/reference/type-aliases/WebsiteSpecification.md +0 -118
  277. package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +0 -40
  278. package/docs/reference/variables/anthropic.md +0 -19
  279. package/docs/reference/variables/banner.md +0 -10
  280. package/docs/reference/variables/button.md +0 -10
  281. package/docs/reference/variables/clock.md +0 -16
  282. package/docs/reference/variables/complementary.md +0 -10
  283. package/docs/reference/variables/component.md +0 -10
  284. package/docs/reference/variables/content.md +0 -10
  285. package/docs/reference/variables/contentinfo.md +0 -10
  286. package/docs/reference/variables/dialog.md +0 -10
  287. package/docs/reference/variables/field.md +0 -10
  288. package/docs/reference/variables/form.md +0 -10
  289. package/docs/reference/variables/heading.md +0 -10
  290. package/docs/reference/variables/http.md +0 -40
  291. package/docs/reference/variables/intercept.md +0 -11
  292. package/docs/reference/variables/link.md +0 -10
  293. package/docs/reference/variables/listitem.md +0 -10
  294. package/docs/reference/variables/main.md +0 -10
  295. package/docs/reference/variables/match.md +0 -8
  296. package/docs/reference/variables/navigation.md +0 -10
  297. package/docs/reference/variables/openai.md +0 -21
  298. package/docs/reference/variables/option.md +0 -17
  299. package/docs/reference/variables/region.md +0 -10
  300. package/docs/reference/variables/row.md +0 -10
  301. package/docs/reference/variables/search.md +0 -10
  302. package/docs/reference/variables/specification.md +0 -8
  303. package/docs/reference/variables/status.md +0 -10
  304. package/docs/reference/variables/table.md +0 -10
@@ -0,0 +1,564 @@
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
+ 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.
55
+
56
+ ```typescript
57
+ test('shows help', async () => {
58
+ // Given
59
+ const result = await cli.exec('--help');
60
+
61
+ // Then - full stdout snapshot; ANSI already stripped, dynamics covered by tokens
62
+ expect(result.exitCode).toBe(0);
63
+ expect(result.stdout).toMatch(file('./__expected__/help.txt'));
64
+ });
65
+ ```
66
+
67
+ #### Long-running processes: `waitFor` / `timeout`
68
+
69
+ ```typescript
70
+ test('dev server boots', async () => {
71
+ // Given - long-running: resolved on pattern match, killed at timeout otherwise
72
+ const result = await cli
73
+ .fixture(directory('../../__fixtures__/base-shop/'))
74
+ .exec('dev --port 0', { waitFor: 'Listening on', timeout: 10_000 });
75
+
76
+ // Then
77
+ expect(result.stdout.text).toContain('Listening on');
78
+ });
79
+ ```
80
+
81
+ - `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`).
82
+ - `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`.
83
+ - 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)).
84
+ - 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.
85
+ - Passing either option marks the run as long-running; combining them with the array (sequence) form is an error.
86
+
87
+ #### `cwd`: a folder of the working directory
88
+
89
+ `{ 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')`.
90
+
91
+ 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.
92
+
93
+ #### Sequences: array form
94
+
95
+ ```typescript
96
+ test('build then start chain stops at first failure', async () => {
97
+ // Given - a sequence in the SAME working directory
98
+ const result = await cli
99
+ .fixture(directory('../../__fixtures__/base-shop/'))
100
+ .exec(['build', 'start --check']);
101
+
102
+ // Then
103
+ expect(result.exitCode).toBe(0);
104
+ });
105
+ ```
106
+
107
+ 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.
108
+
109
+ ### Working-directory semantics
110
+
111
+ Every spec gets a brand-new temp directory (`mkdtemp`) as its cwd. The runner never writes into your fixtures.
112
+
113
+ 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.
114
+
115
+ 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)).
116
+
117
+ A fixture is named by `file()` or `directory()`, with its path from the spec file spelled in full; the wrapper states what it names:
118
+
119
+ | Call | Names | Copy effect |
120
+ | -------------------------------------------------- | ------------------------------ | --------------------------------------------- |
121
+ | `.fixture(file('./__fixtures__/config.toml'))` | one file beside the spec | `<cwd>/config.toml` (the file under its name) |
122
+ | `.fixture(directory('./__fixtures__/base-shop/'))` | a directory beside the spec | its **contents spread** into `<cwd>` |
123
+ | `.fixture(directory('../../__fixtures__/shop/'))` | a directory of the shared pool | its **contents spread** into `<cwd>` |
124
+
125
+ 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.
126
+
127
+ | Setup | Effect on the cwd |
128
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
129
+ | _(nothing)_ | Empty directory |
130
+ | `.fixture(directory('../../__fixtures__/base-shop/'))` | Spread a whole shared project into the cwd |
131
+ | `.fixture(file('./__fixtures__/config.toml'))` | Copy the leaf's own `__fixtures__/config.toml` into the cwd |
132
+ | `.seed(file('./__seeds__/users.sql'))` | SQL into a service database (the `database:` option: [Services § The `database:` rule](../reference/services.md#the-database-rule)) |
133
+
134
+ ```typescript
135
+ test('scaffolds a new shop', async () => {
136
+ // Given - empty cwd (every spec = fresh mkdtemp)
137
+ const result = await cli.exec('create my-shop');
138
+
139
+ // Then - precise files...
140
+ expect(result.exitCode).toBe(0);
141
+ expect(result.file('my-shop/shoply.yaml').exists).toBe(true);
142
+ expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop');
143
+ expect(result.file('my-shop/.env').exists).toBe(false);
144
+
145
+ // ...or a tree snapshot (structured added/removed/changed diff)
146
+ await expect(result.directory('my-shop')).toMatch(directory('./__expected__/shop-scaffold/'));
147
+ });
148
+
149
+ test('upgrade rewrites the workspace predictably', async () => {
150
+ // Given
151
+ const result = await cli.fixture(directory('../../__fixtures__/base-shop/')).exec('upgrade');
152
+
153
+ // Then - snapshot of the ENTIRE cwd + flat read of the file list
154
+ await expect(result.filesystem).toMatch(directory('./__expected__/upgraded-shop/'));
155
+ expect(await result.filesystem.files()).toContain('shoply.lock');
156
+ });
157
+
158
+ test('seeds a single fixture file', async () => {
159
+ // Given
160
+ const result = await cli
161
+ .fixture(file('./__fixtures__/legacy-config.toml'))
162
+ .exec('migrate-config');
163
+
164
+ // Then
165
+ expect(result.file('shoply.yaml').exists).toBe(true);
166
+ });
167
+ ```
168
+
169
+ 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`).
170
+
171
+ ### `.env()`: child process environment
172
+
173
+ ```typescript
174
+ test('isolates HOME per spec', async () => {
175
+ // Given - HOME on the temp cwd, TZ pinned, stray variable removed
176
+ const result = await cli
177
+ .env({ HOME: '$WORKDIR', TZ: 'UTC', SHOPLY_TOKEN: null })
178
+ .exec('login --offline');
179
+
180
+ // Then
181
+ expect(result.file('.shoply/credentials').exists).toBe(true);
182
+ });
183
+ ```
184
+
185
+ - `'$WORKDIR'` expands to the spec's temp cwd; pin `HOME`, caches, config dirs onto isolated ground.
186
+ - `null` **removes** a variable from the child env, to shield the spec from the developer's shell.
187
+ - Repeated `.env()` calls merge; later keys win.
188
+ - `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`.
189
+
190
+ #### Auto-injected connection URLs
191
+
192
+ When the runner has `services`, the framework injects into the child env, automatically:
193
+
194
+ | Variable | Rule |
195
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
196
+ | `<KEY>_URL` | One per record key, uppercased to CONSTANT_CASE at camelCase boundaries: `db:` → `DB_URL`, `analyticsDb:` → `ANALYTICS_DB_URL`, `cache:` → `CACHE_URL` |
197
+ | `DATABASE_URL` | Standard alias, only when the record has **exactly one** SQL database |
198
+ | `REDIS_URL` | Standard alias, only when the record has **exactly one** redis |
199
+
200
+ `.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.
201
+
202
+ ```typescript
203
+ // One SQL database → DATABASE_URL and DB_URL both injected, no database: option (rule `database-named-only-when-several`)
204
+ export const { cli: migrateCli, cleanup } = await specification.cli('shoply-migrate', {
205
+ services: { db: postgres() },
206
+ });
207
+
208
+ test('migrates a legacy schema', async () => {
209
+ // Given - DATABASE_URL / DB_URL injected automatically into the child env
210
+ const result = await migrateCli.seed(file('./__seeds__/legacy-schema.sql')).exec('up');
211
+
212
+ // Then
213
+ expect(result.exitCode).toBe(0);
214
+ await expect(result.table('schema_migrations')).toMatch([['20260716']]);
215
+ });
216
+ ```
217
+
218
+ ### File state is the shape of a fixture tree
219
+
220
+ 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.
221
+
222
+ ```typescript
223
+ // specs/cli/deploy/__fixtures__/two-shops/ ← committed, shaped like the target cwd
224
+ // shoply.yaml
225
+ // shops/alpha/config.yaml
226
+ // shops/beta/config.yaml
227
+
228
+ test('deploys from a shaped workspace', async () => {
229
+ // Given - the tree is spread into the cwd; layering a second fixture would
230
+ // overwrite matching files (last write wins)
231
+ const result = await cli.fixture(directory('./__fixtures__/two-shops/')).exec('deploy alpha');
232
+
233
+ // Then
234
+ expect(result.exitCode).toBe(0);
235
+ });
236
+ ```
237
+
238
+ #### Where a fixture lives
239
+
240
+ 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`).
241
+
242
+ ### `.link()`: a package the working directory resolves
243
+
244
+ 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.
245
+
246
+ | Call | Links |
247
+ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
248
+ | `.link('@acme/sdk')` | the package the spec's own project installed, found as Node finds it from the spec |
249
+ | `.link('@acme/sdk', directory('./__fixtures__/sdk/'))` | the package a directory holds: a stub install, a broken one, an older version |
250
+ | `.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)) |
251
+
252
+ ```typescript
253
+ test('loads the plugin the project installed', async () => {
254
+ // Given - a project fixture, and the plugin it imports linked into its node_modules
255
+ const result = await cli
256
+ .fixture(directory('./__fixtures__/shop/'))
257
+ .link('@acme/sdk')
258
+ .exec('check');
259
+
260
+ // Then
261
+ expect(result.exitCode).toBe(0);
262
+ });
263
+ ```
264
+
265
+ 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.
266
+
267
+ ### A package specified from its artefact
268
+
269
+ 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.
270
+
271
+ ```typescript
272
+ test('a consumer loads the published entry', async () => {
273
+ // Given - a consumer module importing the package by its published name
274
+ const result = await cli
275
+ .fixture(directory('./__fixtures__/consumer/'))
276
+ .link('@acme/telemetry', packed())
277
+ .exec('consumer.mjs');
278
+
279
+ // Then
280
+ expect(result.exitCode).toBe(0);
281
+ });
282
+ ```
283
+
284
+ 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.
285
+
286
+ 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.
287
+
288
+ **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.
289
+
290
+ **`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.
291
+
292
+ ### `.prepare()`: what no fixture can carry
293
+
294
+ 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.
295
+
296
+ ```typescript
297
+ test('refuses a tracked build artefact', async () => {
298
+ // Given - a project, made a repository whose index holds a buildinfo its .gitignore excludes
299
+ const result = await cli
300
+ .fixture(directory('./__fixtures__/project/'))
301
+ .prepare((workdir) => {
302
+ writeFileSync(join(workdir, 'tsconfig.tsbuildinfo'), '{}\n');
303
+ execFileSync('git', ['init', '--quiet'], { cwd: workdir });
304
+ execFileSync('git', ['add', '--force', 'tsconfig.tsbuildinfo'], { cwd: workdir });
305
+ })
306
+ .exec('check');
307
+
308
+ // Then
309
+ expect(result.exitCode).toBe(1);
310
+ });
311
+ ```
312
+
313
+ 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()`.
314
+
315
+ ### The chain never reaches a real system
316
+
317
+ 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).
318
+
319
+ The framework supplies the ground; the chain states the sandbox, in three verbs:
320
+
321
+ | Verb | What it closes |
322
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------ |
323
+ | `.fixture(directory('../../__fixtures__/<name>/'))` | spreads a `bin/` of stub binaries into the spec's temp cwd |
324
+ | `.env({ PATH: '$WORKDIR/bin:/usr/bin:/bin' })` | puts those stubs FIRST; the binary finds the stub, never the tool installed on the host |
325
+ | `.env({ HOME: '$WORKDIR' })` | moves config dirs, caches and credential stores onto the temp cwd, off the operator's home |
326
+
327
+ 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.
328
+
329
+ 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.
330
+
331
+ `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.
332
+
333
+ ```typescript
334
+ test('reports what the cluster holds', async () => {
335
+ // Given - stubs first on PATH, HOME on the temp cwd: nothing can reach out
336
+ const result = await cli
337
+ .fixture(directory('../../__fixtures__/cluster-stub/'))
338
+ .env({ HOME: '$WORKDIR', PATH: '$WORKDIR/bin:/usr/bin:/bin', TZ: 'UTC' })
339
+ .exec('cluster pods');
340
+
341
+ // Then
342
+ expect(result.exitCode).toBe(0);
343
+ expect(result.stdout).toMatch(file('./__expected__/pods.txt'));
344
+ });
345
+ ```
346
+
347
+ #### The shape of a stub fixture
348
+
349
+ A stub fixture is a `bin/` holding one script per binary the run must not find for real:
350
+
351
+ ```text
352
+ specs/__fixtures__/cluster-stub/
353
+ └── bin/
354
+ ├── kubectl
355
+ └── helm
356
+ ```
357
+
358
+ 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:
359
+
360
+ ```bash
361
+ #!/bin/sh
362
+ # Every invocation this suite cans. Anything else is a chain reaching for an
363
+ # answer nobody wrote: fail loudly rather than improvise one.
364
+ case "$*" in
365
+ "get pods -A -o json")
366
+ cat <<'JSON'
367
+ {"items":[{"metadata":{"name":"api-abc12","namespace":"prod"},"status":{"phase":"Running"}}]}
368
+ JSON
369
+ ;;
370
+ *)
371
+ echo "cluster-stub kubectl: unexpected invocation: $*" >&2
372
+ exit 64
373
+ ;;
374
+ esac
375
+ ```
376
+
377
+ 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.
378
+
379
+ **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.
380
+
381
+ **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:
382
+
383
+ ```bash
384
+ #!/bin/sh
385
+ # Route argv to the file that answers it. The ANSWERS are the fixture; this
386
+ # Script only says which one the question maps to.
387
+ answers="${0%/*}/../answers/cluster"
388
+ case "$*" in
389
+ "get pods -A -o json") file="$answers/pods.json" ;;
390
+ "get nodes -o json") file="$answers/nodes.json" ;;
391
+ *) echo "cluster-stub kubectl: unexpected invocation: $*" >&2; exit 64 ;;
392
+ esac
393
+ [ -f "$file" ] || { echo "cluster-stub kubectl: no answer file at $file" >&2; exit 64; }
394
+ cat "$file"
395
+ ```
396
+
397
+ 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.
398
+
399
+ 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.
400
+
401
+ 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).
402
+
403
+ ### Declared backend
404
+
405
+ 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.
406
+
407
+ ## The result
408
+
409
+ 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.
410
+
411
+ ### Streams, JSON, grep
412
+
413
+ ```typescript
414
+ test('fails on unknown command with a useful error', async () => {
415
+ // Given
416
+ const result = await cli.exec('frobnicate');
417
+
418
+ // Then
419
+ expect(result.exitCode).toBe(1);
420
+ expect(result.stderr.text).toContain("Unknown command 'frobnicate'");
421
+ expect(result.stdout.text).toBe(''); // .text: ANSI stripped, transform applied
422
+ });
423
+
424
+ test('emits machine-readable config', async () => {
425
+ // Given
426
+ const result = await cli.exec('config --json');
427
+
428
+ // Then
429
+ expect(result.json.value).toMatchObject({ name: 'shoply' }); // read + native matcher
430
+ expect(result.json).toMatch(file('./__expected__/config.json')); // __expected__/config.json
431
+ });
432
+
433
+ test('lints a shop and reports per-file blocks', async () => {
434
+ // Given
435
+ const result = await cli.fixture(directory('../../__fixtures__/base-shop/')).exec('lint');
436
+
437
+ // Then - snapshot the whole surface; grep is the scalpel for targeted probes
438
+ expect(result.stdout).toMatch(file('./__expected__/lint-output.txt')); // full snapshot, tokens for volatile parts
439
+ expect(result.stdout.grep('products/ok.yaml').text).not.toContain('error'); // absence probe
440
+ });
441
+ ```
442
+
443
+ `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).
444
+
445
+ **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).
446
+
447
+ ## Unique here
448
+
449
+ ### Spec documents: `<case>.spec.yaml`
450
+
451
+ 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.
452
+
453
+ #### Registration: once per app
454
+
455
+ The document names its ground by WORD; the code says what those words mean, in the `specification.cli()` options:
456
+
457
+ ```typescript
458
+ // apps/cli/specs/cli/cli.specification.ts, in a workspace
459
+ export const { cli, cleanup } = await specification.cli(bin, {
460
+ env: {
461
+ frozen: { HOME: '$WORKDIR', TZ: 'UTC' },
462
+ },
463
+ serve: {
464
+ mcp: {
465
+ // Relative to apps/cli/, the project root, not the repo root
466
+ command: 'bun specs/harness/mcp-server.ts',
467
+ env: 'SHOPLY_MCP_ORIGIN',
468
+ ready: /listening on port (?<port>\d+)/,
469
+ url: (port) => `http://127.0.0.1:${port}/mcp`,
470
+ },
471
+ },
472
+ });
473
+ ```
474
+
475
+ 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.
476
+
477
+ `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.
478
+
479
+ **`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.
480
+
481
+ ### Docker-aware mode
482
+
483
+ 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.
484
+
485
+ ```typescript
486
+ export const {
487
+ cli: deploy,
488
+ cleanup,
489
+ docker,
490
+ } = await specification.cli('shoply', {
491
+ docker: {
492
+ envVar: 'SHOPLY_TEST_RUN', // run id injected into the child env
493
+ nameLabel: 'dev.shoply.shop.name', // label used as the .container(name) key
494
+ testRunLabel: 'dev.shoply.test.run', // label the CLI must put on every container
495
+ },
496
+ });
497
+ ```
498
+
499
+ 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.
500
+
501
+ 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:
502
+
503
+ ```typescript
504
+ test('deploy spawns a labelled container', async () => {
505
+ // Given - await using ⇒ containers force-removed at scope exit (rule `docker-exec-bound-with-await-using`)
506
+ await using result = await deploy
507
+ .fixture(directory('../../__fixtures__/two-shops/'))
508
+ .exec('deploy alpha');
509
+
510
+ // Then - lazy access (zero Docker calls if you never touch .container)
511
+ const shop = result.container('alpha');
512
+ expect(shop.exists).toBe(true);
513
+ await expect(shop).toBeRunning();
514
+
515
+ // Reads INSIDE the container, same vocabulary as on the host
516
+ expect(shop.file('/app/shoply.yaml').content).toContain('name: alpha');
517
+ const inside = await shop.exec('ls /app');
518
+ expect(inside.stdout.text).toContain('shoply.yaml');
519
+ expect(shop.stdout.text).toContain('shop ready'); // container logs
520
+
521
+ // Absent ≠ crash
522
+ expect(result.container('nope').exists).toBe(false);
523
+ expect(result.containerIds).toHaveLength(1);
524
+ });
525
+
526
+ test('destroy removes the container created in an earlier run', async () => {
527
+ // Given - spawn then destroy: the whole runner shares one run-id scope
528
+ await using first = await deploy.exec('deploy alpha');
529
+ await using second = await deploy.exec('destroy alpha');
530
+
531
+ // Then
532
+ expect(second.exitCode).toBe(0);
533
+ expect(second.container('alpha').exists).toBe(false);
534
+ });
535
+ ```
536
+
537
+ 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.
538
+
539
+ 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`.
540
+
541
+ ## Pitfalls
542
+
543
+ - **Reaching for `.spawn()`.** It does not exist; `.exec(args, { waitFor, timeout })` is the unified execution method.
544
+ - **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`).
545
+ - **Assigning a Docker-aware result without `await using`.** Error (rule `docker-exec-bound-with-await-using`): that is the leak-cleanup mechanism.
546
+ - **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.
547
+ - **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`.
548
+ - **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.
549
+ - **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`).
550
+ - **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`).
551
+ - **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.
552
+ - **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/')`.
553
+ - **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.
554
+ - **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.
555
+ - **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.
556
+ - **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>/`).
557
+ - **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.
558
+ - **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.
559
+ - **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.
560
+ - **`@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.
561
+
562
+ ## Related
563
+
564
+ [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)