@jterrazz/test 16.1.1 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/README.md +45 -171
  2. package/dist/browser/index.js +2 -2980
  3. package/dist/browser/services.js +2 -0
  4. package/dist/browser/stubs.js +2 -0
  5. package/dist/checker.d.ts +6 -1
  6. package/dist/checker.js +4534 -2628
  7. package/dist/config.d.ts +40 -0
  8. package/dist/config.js +309 -0
  9. package/dist/in-place.global-setup.d.ts +11 -0
  10. package/dist/in-place.global-setup.js +21 -0
  11. package/dist/index.d.ts +1653 -3004
  12. package/dist/index.js +5226 -3845
  13. package/dist/mobile-session.global-setup.d.ts +14 -0
  14. package/dist/mobile-session.global-setup.js +31 -0
  15. package/dist/oxlint.cjs +6069 -5634
  16. package/dist/oxlint.d.cts +43 -7212
  17. package/dist/oxlint.d.ts +43 -7212
  18. package/dist/oxlint.js +6127 -5693
  19. package/dist/route-coverage.global-setup.d.ts +42 -0
  20. package/dist/route-coverage.global-setup.js +2 -0
  21. package/dist/services.d.ts +3 -0
  22. package/dist/services.global-setup.d.ts +43 -0
  23. package/dist/services.global-setup.js +2 -0
  24. package/dist/services.js +597 -0
  25. package/dist/shared-appium.adapter.js +428 -0
  26. package/dist/shared-artifacts.js +32 -0
  27. package/dist/shared-chapter.js +7 -0
  28. package/dist/shared-container.port.d.ts +31 -0
  29. package/dist/shared-derived-projects.js +58 -0
  30. package/dist/shared-device.port.d.ts +189 -0
  31. package/dist/shared-exchange.js +447 -0
  32. package/dist/shared-in-place.js +172 -0
  33. package/dist/shared-index.browser.js +3191 -0
  34. package/dist/shared-index.services.d.ts +346 -0
  35. package/dist/shared-index.stubs.d.ts +287 -0
  36. package/dist/shared-json-shape.js +17 -0
  37. package/dist/shared-landmarks.js +71 -0
  38. package/dist/shared-ledger.js +68 -0
  39. package/dist/shared-literate-plugin.js +183 -0
  40. package/dist/shared-manifest.js +1321 -0
  41. package/dist/shared-pause.js +11 -0
  42. package/dist/shared-peer.js +88 -0
  43. package/dist/shared-playwright.adapter.js +330 -0
  44. package/dist/shared-process.adapter.js +74 -0
  45. package/dist/shared-refusal.js +35 -0
  46. package/dist/shared-resolve.js +24 -0
  47. package/dist/shared-route-coverage.global-setup.js +187 -0
  48. package/dist/shared-scenario-failure.js +198 -0
  49. package/dist/shared-serve.js +365 -0
  50. package/dist/shared-service-images.js +10 -0
  51. package/dist/shared-services.global-setup.js +179 -0
  52. package/dist/shared-session-ledger.js +301 -0
  53. package/dist/shared-setup-entry.js +12 -0
  54. package/dist/shared-source-scan.js +133 -0
  55. package/dist/shared-spec-document.js +365 -0
  56. package/dist/shared-specifications.js +26 -0
  57. package/dist/shared-stream-body.js +130 -0
  58. package/dist/shared-stub-server.js +190 -0
  59. package/dist/shared-temp-root.global-setup.js +80 -0
  60. package/dist/shared-test-baseline.js +26 -0
  61. package/dist/shared-test-section.js +58 -0
  62. package/dist/shared-testcontainers.adapter.js +203 -0
  63. package/dist/shared-tokens.js +55 -0
  64. package/dist/shared-yaml-document.js +119 -0
  65. package/dist/stubs.d.ts +2 -0
  66. package/dist/stubs.js +657 -0
  67. package/dist/temp-root.global-setup.d.ts +7 -0
  68. package/dist/temp-root.global-setup.js +2 -0
  69. package/dist/vitest.d.ts +97 -163
  70. package/dist/vitest.js +373 -525
  71. package/docs/README.md +82 -36
  72. package/docs/concepts/architecture.md +118 -0
  73. package/docs/concepts/conventions.md +333 -0
  74. package/docs/guides/api-specs.md +374 -0
  75. package/docs/guides/cli-specs.md +564 -0
  76. package/docs/guides/component-specs.md +252 -0
  77. package/docs/guides/develop.md +94 -0
  78. package/docs/guides/integration-specs.md +181 -0
  79. package/docs/guides/job-specs.md +180 -0
  80. package/docs/guides/lint-specs.md +174 -0
  81. package/docs/guides/mobile-specs.md +319 -0
  82. package/docs/guides/module-tests.md +149 -0
  83. package/docs/guides/release.md +43 -0
  84. package/docs/guides/spec-documents.md +189 -0
  85. package/docs/guides/website-specs.md +413 -0
  86. package/docs/reference/assertions.md +345 -0
  87. package/docs/reference/glossary.md +20 -0
  88. package/docs/reference/linting.md +483 -0
  89. package/docs/reference/services.md +236 -0
  90. package/docs/reference/stubs.md +498 -0
  91. package/docs/reference/time-and-doubles.md +137 -0
  92. package/docs/reference/tokens.md +258 -0
  93. package/docs/reference/visitor.md +221 -0
  94. package/docs/start/getting-started.md +187 -0
  95. package/docs/start/set-up-a-project.md +214 -0
  96. package/package.json +50 -30
  97. package/schema/spec.schema.json +43 -17
  98. package/dist/appium.adapter.js +0 -489
  99. package/dist/artifacts.js +0 -32
  100. package/dist/fixtures.js +0 -110
  101. package/dist/literate.js +0 -1747
  102. package/dist/match.js +0 -804
  103. package/dist/playwright.adapter.js +0 -438
  104. package/dist/server.js +0 -160
  105. package/dist/stream.js +0 -321
  106. package/dist/substring-warning.js +0 -282
  107. package/docs/01-architecture.md +0 -95
  108. package/docs/02-developing.md +0 -351
  109. package/docs/03-testing.md +0 -495
  110. package/docs/04-operating.md +0 -101
  111. package/docs/05-module-tests.md +0 -152
  112. package/docs/06-integration.md +0 -180
  113. package/docs/07-component.md +0 -246
  114. package/docs/08-website.md +0 -404
  115. package/docs/09-mobile.md +0 -270
  116. package/docs/10-api.md +0 -264
  117. package/docs/11-jobs.md +0 -157
  118. package/docs/12-cli.md +0 -677
  119. package/docs/13-elements.md +0 -248
  120. package/docs/14-assertions.md +0 -314
  121. package/docs/15-tokens.md +0 -253
  122. package/docs/16-contracts.md +0 -438
  123. package/docs/17-services.md +0 -188
  124. package/docs/18-conventions.md +0 -332
  125. package/docs/19-linting.md +0 -512
  126. package/docs/decisions/001-v9-conventions-on-three-channels.md +0 -50
  127. package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +0 -49
  128. package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +0 -84
  129. package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +0 -81
  130. package/docs/decisions/005-the-doubles-ladder-is-closed.md +0 -86
  131. package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +0 -245
  132. package/docs/decisions/007-compose-mode-leaves-the-package.md +0 -53
  133. package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +0 -75
  134. package/docs/decisions/_template.md +0 -22
  135. package/docs/reference/classes/Matcher.md +0 -120
  136. package/docs/reference/classes/ProcessHandle.md +0 -368
  137. package/docs/reference/functions/defineContract.md +0 -39
  138. package/docs/reference/functions/defineContracts.md +0 -36
  139. package/docs/reference/functions/disabled.md +0 -28
  140. package/docs/reference/functions/enabled.md +0 -20
  141. package/docs/reference/functions/focused.md +0 -26
  142. package/docs/reference/functions/mockOf.md +0 -79
  143. package/docs/reference/functions/postgres.md +0 -31
  144. package/docs/reference/functions/process.md +0 -33
  145. package/docs/reference/functions/redis.md +0 -28
  146. package/docs/reference/functions/required.md +0 -41
  147. package/docs/reference/functions/selected.md +0 -26
  148. package/docs/reference/functions/sqlite.md +0 -33
  149. package/docs/reference/functions/testId.md +0 -20
  150. package/docs/reference/functions/text.md +0 -40
  151. package/docs/reference/functions/valued.md +0 -28
  152. package/docs/reference/functions/waitUntil.md +0 -46
  153. package/docs/reference/functions/within.md +0 -29
  154. package/docs/reference/index.md +0 -196
  155. package/docs/reference/interfaces/BaseResult.md +0 -87
  156. package/docs/reference/interfaces/CallResult.md +0 -160
  157. package/docs/reference/interfaces/CaptureScope.md +0 -82
  158. package/docs/reference/interfaces/CliResult.md +0 -253
  159. package/docs/reference/interfaces/ContainerAccessor.md +0 -166
  160. package/docs/reference/interfaces/DirectoryAccessor.md +0 -33
  161. package/docs/reference/interfaces/FetchResult.md +0 -180
  162. package/docs/reference/interfaces/FilesystemAccessor.md +0 -46
  163. package/docs/reference/interfaces/HttpResult.md +0 -126
  164. package/docs/reference/interfaces/JsonAccessor.md +0 -46
  165. package/docs/reference/interfaces/PageResult.md +0 -356
  166. package/docs/reference/interfaces/RenderResult.md +0 -72
  167. package/docs/reference/interfaces/RenderedText.md +0 -110
  168. package/docs/reference/interfaces/ResponseAccessor.md +0 -47
  169. package/docs/reference/interfaces/ScreenResult.md +0 -128
  170. package/docs/reference/interfaces/TableAccessor.md +0 -26
  171. package/docs/reference/interfaces/TextAccessor.md +0 -96
  172. package/docs/reference/type-aliases/ApiHandle.md +0 -68
  173. package/docs/reference/type-aliases/ApiSpecification.md +0 -213
  174. package/docs/reference/type-aliases/ApiSpecificationOptions.md +0 -68
  175. package/docs/reference/type-aliases/BrowserConsoleMessage.md +0 -30
  176. package/docs/reference/type-aliases/BrowserLinkElement.md +0 -50
  177. package/docs/reference/type-aliases/BrowserMetaElement.md +0 -40
  178. package/docs/reference/type-aliases/BrowserOpenOptions.md +0 -87
  179. package/docs/reference/type-aliases/BrowserPage.md +0 -132
  180. package/docs/reference/type-aliases/BrowserPort.md +0 -51
  181. package/docs/reference/type-aliases/CliEnv.md +0 -11
  182. package/docs/reference/type-aliases/CliHandle.md +0 -80
  183. package/docs/reference/type-aliases/CliInput.md +0 -36
  184. package/docs/reference/type-aliases/CliOutput.md +0 -46
  185. package/docs/reference/type-aliases/CliPort.md +0 -58
  186. package/docs/reference/type-aliases/CliSpecification.md +0 -144
  187. package/docs/reference/type-aliases/CliSpecificationOptions.md +0 -134
  188. package/docs/reference/type-aliases/ComponentChain.md +0 -156
  189. package/docs/reference/type-aliases/ComponentScenario.md +0 -20
  190. package/docs/reference/type-aliases/ComponentUi.md +0 -47
  191. package/docs/reference/type-aliases/ComponentVisitor.md +0 -10
  192. package/docs/reference/type-aliases/ContainerPort.md +0 -113
  193. package/docs/reference/type-aliases/Contract.md +0 -68
  194. package/docs/reference/type-aliases/ContractInput.md +0 -13
  195. package/docs/reference/type-aliases/ContractRequest.md +0 -95
  196. package/docs/reference/type-aliases/ContractResponder.md +0 -23
  197. package/docs/reference/type-aliases/ContractResponse.md +0 -78
  198. package/docs/reference/type-aliases/ContractResponseValue.md +0 -13
  199. package/docs/reference/type-aliases/Contracts.md +0 -48
  200. package/docs/reference/type-aliases/DatabaseKeys.md +0 -16
  201. package/docs/reference/type-aliases/DatabasePort.md +0 -72
  202. package/docs/reference/type-aliases/DeviceOpenOptions.md +0 -46
  203. package/docs/reference/type-aliases/DevicePort.md +0 -51
  204. package/docs/reference/type-aliases/DeviceScreen.md +0 -32
  205. package/docs/reference/type-aliases/DeviceTimeouts.md +0 -39
  206. package/docs/reference/type-aliases/DockerSpecConfig.md +0 -43
  207. package/docs/reference/type-aliases/DomMount.md +0 -21
  208. package/docs/reference/type-aliases/ElementKind.md +0 -27
  209. package/docs/reference/type-aliases/ElementMatch.md +0 -74
  210. package/docs/reference/type-aliases/ElementOptions.md +0 -29
  211. package/docs/reference/type-aliases/ElementRef.md +0 -128
  212. package/docs/reference/type-aliases/ExecOptions.md +0 -37
  213. package/docs/reference/type-aliases/FileAccessor.md +0 -54
  214. package/docs/reference/type-aliases/HonoApp.md +0 -31
  215. package/docs/reference/type-aliases/HttpContractFilter.md +0 -49
  216. package/docs/reference/type-aliases/HttpResponseInit.md +0 -46
  217. package/docs/reference/type-aliases/HttpStreamInit.md +0 -58
  218. package/docs/reference/type-aliases/IntegrationHandle.md +0 -46
  219. package/docs/reference/type-aliases/IntegrationSpecification.md +0 -111
  220. package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +0 -42
  221. package/docs/reference/type-aliases/Intercept.md +0 -19
  222. package/docs/reference/type-aliases/InterceptOptions.md +0 -28
  223. package/docs/reference/type-aliases/InterceptScope.md +0 -11
  224. package/docs/reference/type-aliases/IsolationStrategy.md +0 -75
  225. package/docs/reference/type-aliases/JobHandle.md +0 -34
  226. package/docs/reference/type-aliases/JobsHandle.md +0 -57
  227. package/docs/reference/type-aliases/JobsResult.md +0 -16
  228. package/docs/reference/type-aliases/JobsSpecification.md +0 -94
  229. package/docs/reference/type-aliases/JobsSpecificationOptions.md +0 -56
  230. package/docs/reference/type-aliases/LandmarkKind.md +0 -20
  231. package/docs/reference/type-aliases/LiterateRunFlags.md +0 -27
  232. package/docs/reference/type-aliases/LiterateServeRegistration.md +0 -70
  233. package/docs/reference/type-aliases/MatchFixtureOptions.md +0 -27
  234. package/docs/reference/type-aliases/MatchableRequest.md +0 -61
  235. package/docs/reference/type-aliases/MatcherKind.md +0 -34
  236. package/docs/reference/type-aliases/MobileBackendOptions.md +0 -28
  237. package/docs/reference/type-aliases/MobileElementKind.md +0 -14
  238. package/docs/reference/type-aliases/MobileElementMatch.md +0 -60
  239. package/docs/reference/type-aliases/MobileElementRef.md +0 -20
  240. package/docs/reference/type-aliases/MobileHandle.md +0 -65
  241. package/docs/reference/type-aliases/MobileScenario.md +0 -20
  242. package/docs/reference/type-aliases/MobileSpecification.md +0 -51
  243. package/docs/reference/type-aliases/MobileSpecificationOptions.md +0 -126
  244. package/docs/reference/type-aliases/MobileVisitor.md +0 -81
  245. package/docs/reference/type-aliases/MockOfOptions.md +0 -26
  246. package/docs/reference/type-aliases/MockPort.md +0 -10
  247. package/docs/reference/type-aliases/PinnedClock.md +0 -10
  248. package/docs/reference/type-aliases/PostgresOptions.md +0 -32
  249. package/docs/reference/type-aliases/ProcessOptions.md +0 -112
  250. package/docs/reference/type-aliases/RedisOptions.md +0 -20
  251. package/docs/reference/type-aliases/RenderSubject.md +0 -12
  252. package/docs/reference/type-aliases/ScreenNode.md +0 -67
  253. package/docs/reference/type-aliases/ServerPort.md +0 -36
  254. package/docs/reference/type-aliases/ServerResponse.md +0 -46
  255. package/docs/reference/type-aliases/ServerSpec.md +0 -21
  256. package/docs/reference/type-aliases/ServiceHandle.md +0 -216
  257. package/docs/reference/type-aliases/ServiceRecord.md +0 -12
  258. package/docs/reference/type-aliases/SpecDocument.md +0 -84
  259. package/docs/reference/type-aliases/SpecEnvToken.md +0 -21
  260. package/docs/reference/type-aliases/SpecFileAssertion.md +0 -27
  261. package/docs/reference/type-aliases/SpecFixture.md +0 -30
  262. package/docs/reference/type-aliases/SpecKind.md +0 -8
  263. package/docs/reference/type-aliases/SpecRun.md +0 -108
  264. package/docs/reference/type-aliases/SpecServeEntry.md +0 -40
  265. package/docs/reference/type-aliases/SpecStream.md +0 -32
  266. package/docs/reference/type-aliases/SpecificationConfig.md +0 -290
  267. package/docs/reference/type-aliases/SqliteOptions.md +0 -36
  268. package/docs/reference/type-aliases/SseEvent.md +0 -58
  269. package/docs/reference/type-aliases/StreamBody.md +0 -61
  270. package/docs/reference/type-aliases/TextFilter.md +0 -22
  271. package/docs/reference/type-aliases/VisitScenario.md +0 -20
  272. package/docs/reference/type-aliases/Visitor.md +0 -215
  273. package/docs/reference/type-aliases/WaitUntilOptions.md +0 -46
  274. package/docs/reference/type-aliases/WebsiteBackendOptions.md +0 -37
  275. package/docs/reference/type-aliases/WebsiteHandle.md +0 -51
  276. package/docs/reference/type-aliases/WebsiteSpecification.md +0 -118
  277. package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +0 -40
  278. package/docs/reference/variables/anthropic.md +0 -19
  279. package/docs/reference/variables/banner.md +0 -10
  280. package/docs/reference/variables/button.md +0 -10
  281. package/docs/reference/variables/clock.md +0 -16
  282. package/docs/reference/variables/complementary.md +0 -10
  283. package/docs/reference/variables/component.md +0 -10
  284. package/docs/reference/variables/content.md +0 -10
  285. package/docs/reference/variables/contentinfo.md +0 -10
  286. package/docs/reference/variables/dialog.md +0 -10
  287. package/docs/reference/variables/field.md +0 -10
  288. package/docs/reference/variables/form.md +0 -10
  289. package/docs/reference/variables/heading.md +0 -10
  290. package/docs/reference/variables/http.md +0 -40
  291. package/docs/reference/variables/intercept.md +0 -11
  292. package/docs/reference/variables/link.md +0 -10
  293. package/docs/reference/variables/listitem.md +0 -10
  294. package/docs/reference/variables/main.md +0 -10
  295. package/docs/reference/variables/match.md +0 -8
  296. package/docs/reference/variables/navigation.md +0 -10
  297. package/docs/reference/variables/openai.md +0 -21
  298. package/docs/reference/variables/option.md +0 -17
  299. package/docs/reference/variables/region.md +0 -10
  300. package/docs/reference/variables/row.md +0 -10
  301. package/docs/reference/variables/search.md +0 -10
  302. package/docs/reference/variables/specification.md +0 -8
  303. package/docs/reference/variables/status.md +0 -10
  304. package/docs/reference/variables/table.md +0 -10
package/dist/literate.js DELETED
@@ -1,1747 +0,0 @@
1
- import { c as SpecSyntaxError, d as readSpecFile, n as Matcher, p as updateSpecFile, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
2
- import { t as copyPlan } from "./fixtures.js";
3
- import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
- import { execSync, spawn, spawnSync } from "node:child_process";
6
- import { readdir } from "node:fs/promises";
7
- import { createServer } from "node:net";
8
- //#region src/model/matching/structural.ts
9
- /**
10
- * Structural comparison engine shared by every fixture matcher.
11
- *
12
- * Handles three kinds of "expected" values:
13
- * - plain JSON values → strict deep equality
14
- * - {@link Matcher} instances (code-side `match.*`)
15
- * - strings containing `{{placeholder}}` forms (file-side fixtures)
16
- *
17
- * One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
18
- * works in `_expected/*.http` (body and headers), `_expected/*.json`, and
19
- * text snapshots (`_expected/*.txt`).
20
- *
21
- * Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
22
- * {@link CaptureScope} supplied by the caller.
23
- */
24
- const UUID_SOURCE = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
25
- const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
26
- const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
27
- const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
28
- const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
29
- const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
30
- const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
31
- const INT_SOURCE = String.raw`-?\d+`;
32
- const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
33
- const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
34
- const SHA_SOURCE = "[0-9a-f]{7,64}";
35
- const HEX_SOURCE = "[0-9a-fA-F]+";
36
- const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
37
- const PORT_SOURCE = String.raw`(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})`;
38
- const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
39
- const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
40
- const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
41
- const EMBEDDED_SOURCES = {
42
- base64: BASE64_SOURCE,
43
- date: DATE_SOURCE,
44
- duration: DURATION_SOURCE,
45
- email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
46
- float: FLOAT_SOURCE,
47
- hex: HEX_SOURCE,
48
- int: INT_SOURCE,
49
- ip: IP_SOURCE,
50
- iso8601: ISO8601_SOURCE,
51
- number: NUMBER_SOURCE,
52
- path: String.raw`\.{0,2}\/[^\s"'<>]*`,
53
- port: PORT_SOURCE,
54
- semver: SEMVER_SOURCE,
55
- sha: SHA_SOURCE,
56
- time: TIME_SOURCE,
57
- ulid: ULID_SOURCE,
58
- url: URL_SOURCE,
59
- uuid: UUID_SOURCE
60
- };
61
- const wholeRe = (source) => new RegExp(`^(?:${source})$`, "u");
62
- const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
63
- const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "gu");
64
- /** Whether a fixture string contains at least one `{{placeholder}}`. */
65
- function hasPlaceholders(value) {
66
- PLACEHOLDER_RE.lastIndex = 0;
67
- return PLACEHOLDER_RE.test(value);
68
- }
69
- /** Key-order-independent stringification for captured-object comparison. */
70
- function stableStringify(value) {
71
- if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
72
- if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
73
- return JSON.stringify(value) ?? "undefined";
74
- }
75
- function capturedEquals(a, b) {
76
- if (Object.is(a, b)) return true;
77
- if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
78
- if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
79
- return false;
80
- }
81
- function recordRef(name, actual, scope) {
82
- if (scope.has(name)) return capturedEquals(scope.get(name), actual);
83
- scope.set(name, actual);
84
- return true;
85
- }
86
- function isPortValue(value) {
87
- return Number.isInteger(value) && value >= 0 && value <= 65535;
88
- }
89
- function kindMatches(kind, actual, scope) {
90
- switch (kind) {
91
- case "any": return true;
92
- case "float":
93
- if (typeof actual === "number") return Number.isFinite(actual);
94
- return typeof actual === "string" && WHOLE_RES.float.test(actual);
95
- case "int":
96
- if (typeof actual === "number") return Number.isInteger(actual);
97
- return typeof actual === "string" && WHOLE_RES.int.test(actual);
98
- case "number":
99
- if (typeof actual === "number") return Number.isFinite(actual);
100
- return typeof actual === "string" && WHOLE_RES.number.test(actual);
101
- case "port":
102
- if (typeof actual === "number") return isPortValue(actual);
103
- return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
104
- case "string": return typeof actual === "string";
105
- case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
106
- default: {
107
- const re = WHOLE_RES[kind];
108
- return re !== void 0 && typeof actual === "string" && re.test(actual);
109
- }
110
- }
111
- }
112
- function matcherMatches(matcher, actual, scope) {
113
- if (matcher.kind === "includes") return typeof actual === "string" && actual.includes(matcher.includes);
114
- if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
115
- if (matcher.kind === "ref") {
116
- if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
117
- return recordRef(matcher.refName, actual, scope);
118
- }
119
- return kindMatches(matcher.kind, actual, scope);
120
- }
121
- function placeholderSource(kind, scope) {
122
- if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
123
- const source = EMBEDDED_SOURCES[kind];
124
- if (source) return source;
125
- return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
126
- }
127
- function escapeRegExp(text) {
128
- return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
129
- }
130
- function parsePlaceholderString(expected, scope) {
131
- PLACEHOLDER_RE.lastIndex = 0;
132
- const refs = [];
133
- let source = "";
134
- let lastIndex = 0;
135
- let single = null;
136
- let count = 0;
137
- for (const found of expected.matchAll(PLACEHOLDER_RE)) {
138
- const kind = found.groups.kind;
139
- const { ref } = found.groups;
140
- source += escapeRegExp(expected.slice(lastIndex, found.index));
141
- source += `(${placeholderSource(kind, scope)})`;
142
- refs.push({
143
- index: count,
144
- kind,
145
- ref
146
- });
147
- count++;
148
- lastIndex = found.index + found[0].length;
149
- if (found.index === 0 && found[0].length === expected.length) single = {
150
- kind,
151
- ref
152
- };
153
- }
154
- source += escapeRegExp(expected.slice(lastIndex));
155
- return {
156
- pattern: new RegExp(`^${source}$`, "u"),
157
- refs,
158
- single,
159
- source
160
- };
161
- }
162
- /**
163
- * Unanchored regex source matching a fixture string — plain text escaped
164
- * verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
165
- * declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
166
- */
167
- function placeholderPatternSource(expected) {
168
- return parsePlaceholderString(expected, new CaptureScope()).source;
169
- }
170
- function placeholderStringMatches(expected, actual, scope) {
171
- const parsed = parsePlaceholderString(expected, scope);
172
- if (parsed.single) {
173
- if (!kindMatches(parsed.single.kind, actual, scope)) return false;
174
- if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
175
- return true;
176
- }
177
- if (typeof actual !== "string" && typeof actual !== "number") return false;
178
- const text = String(actual);
179
- const found = parsed.pattern.exec(text);
180
- if (!found) return false;
181
- for (const entry of parsed.refs) {
182
- if (!entry.ref) continue;
183
- if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
184
- }
185
- return true;
186
- }
187
- function isPlainObject(value) {
188
- return value !== null && typeof value === "object" && !Array.isArray(value);
189
- }
190
- /**
191
- * Deep structural equality with matcher / placeholder support. Ref captures
192
- * are recorded in `scope` in traversal order (arrays left-to-right, object
193
- * keys in expected-key order).
194
- */
195
- function structuralEquals(expected, actual, scope) {
196
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
197
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
198
- if (Array.isArray(expected)) {
199
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
200
- return expected.every((item, i) => structuralEquals(item, actual[i], scope));
201
- }
202
- if (isPlainObject(expected)) {
203
- if (!isPlainObject(actual)) return false;
204
- const expectedKeys = Object.keys(expected);
205
- const actualKeys = Object.keys(actual);
206
- if (expectedKeys.length !== actualKeys.length) return false;
207
- return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
208
- }
209
- return Object.is(expected, actual);
210
- }
211
- /**
212
- * Deep structural SUBSET match (toMatchObject-style) with matcher /
213
- * placeholder support. Plain objects match when every expected key is present
214
- * and recursively subset-matches — the actual value may carry extra keys.
215
- * Arrays require equal length with each element subset-matched. Leaves fall
216
- * back to {@link structuralEquals} semantics (matchers, placeholders, strict
217
- * equality). Used by request-body filters on intercept triggers.
218
- */
219
- function structuralSubset(expected, actual, scope) {
220
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
221
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
222
- if (Array.isArray(expected)) {
223
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
224
- return expected.every((item, i) => structuralSubset(item, actual[i], scope));
225
- }
226
- if (isPlainObject(expected)) {
227
- if (!isPlainObject(actual)) return false;
228
- return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
229
- }
230
- return Object.is(expected, actual);
231
- }
232
- /**
233
- * Multi-line text comparison with `{{token}}` support — used by text
234
- * snapshots (`_expected/*.txt`). Without placeholders this is strict equality.
235
- */
236
- function textEquals(expected, actual, scope) {
237
- if (!hasPlaceholders(expected)) return expected === actual;
238
- return placeholderStringMatches(expected, actual, scope);
239
- }
240
- /**
241
- * Substring comparison with `{{token}}` support — the `contains` half of the
242
- * `files:` assertions of a spec document. Without placeholders this is
243
- * `String.includes`; with them the fixture text becomes an UNANCHORED pattern,
244
- * so `wrote {{path}}` is found inside a larger file, and its `#ref` captures are
245
- * recorded exactly as a whole-text comparison records them.
246
- */
247
- function textContains(expected, actual, scope) {
248
- if (!hasPlaceholders(expected)) return actual.includes(expected);
249
- const parsed = parsePlaceholderString(expected, scope);
250
- const found = new RegExp(parsed.source, "u").exec(actual);
251
- if (!found) return false;
252
- return parsed.refs.every((entry) => !entry.ref || recordRef(entry.ref, found[entry.index + 1], scope));
253
- }
254
- /**
255
- * Render an expected value for failure diffs and serialization: Matcher
256
- * instances become their placeholder text, everything else is untouched.
257
- */
258
- function renderExpected(value) {
259
- if (value instanceof Matcher) return value.toString();
260
- if (Array.isArray(value)) return value.map((item) => renderExpected(item));
261
- if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
262
- return value;
263
- }
264
- /**
265
- * Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
266
- * every string leaf of a structured value. The structural mirror of the text
267
- * path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
268
- * `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
269
- * D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
270
- */
271
- function substituteWorkdirDeep(value, workdir) {
272
- if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
273
- if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
274
- if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
275
- return value;
276
- }
277
- /**
278
- * Update-mode merge: rewrite a fixture from the actual output while
279
- * preserving every segment of the previous fixture that was covered by a
280
- * placeholder (and still matches the actual value).
281
- *
282
- * Token preservation is symmetric with the text path (CONVENTIONS D5): a
283
- * previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
284
- * RAW actual value is kept; every other leaf is taken from the actual output
285
- * with the framework-known cwd substituted back to `{{workdir}}`.
286
- */
287
- function mergePreservingPlaceholders(previous, actual, workdir) {
288
- const scope = new CaptureScope(workdir);
289
- const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
290
- function merge(prev, act) {
291
- if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
292
- if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
293
- if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
294
- return subst(act);
295
- }
296
- return merge(previous, actual);
297
- }
298
- /**
299
- * Update-mode merge for text snapshots: a previous line whose placeholders
300
- * still match an actual line is preserved; every other line is taken from the
301
- * actual output. Values the framework knows to be dynamic (`{{workdir}}`) are
302
- * substituted automatically (CONVENTIONS D5).
303
- *
304
- * Pairing is by **pattern, not by line index**. The aligned line is tried
305
- * first, so a golden that still lines up comes back byte-identical; what is
306
- * left over is then re-paired by matching each remaining previous placeholder
307
- * line against each unresolved actual line, in file order. That is what makes a
308
- * MID-FILE INSERTION safe: inserting a line shifts everything below it, and the
309
- * shifted `{{duration}}` / `{{uuid}}` lines are recognised where they landed
310
- * instead of being rewritten as raw values. Each previous line is used at most
311
- * once, so two identical placeholder lines cannot collapse into one.
312
- */
313
- function mergeTextPreservingPlaceholders(previous, actual, scope) {
314
- const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
315
- if (previous === null) return substituted;
316
- const prevLines = previous.split("\n");
317
- const actualLines = substituted.split("\n");
318
- const rawLines = actual.split("\n");
319
- const consumed = /* @__PURE__ */ new Set();
320
- const covers = (prevIndex, actualIndex) => {
321
- const prev = prevLines[prevIndex];
322
- if (prev === void 0 || !hasPlaceholders(prev)) return false;
323
- const line = actualLines[actualIndex];
324
- if (line === void 0) return false;
325
- return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, rawLines[actualIndex] ?? line, new CaptureScope(scope.workdir));
326
- };
327
- const merged = actualLines.map(() => void 0);
328
- for (const [index] of actualLines.entries()) if (covers(index, index)) {
329
- merged[index] = prevLines[index];
330
- consumed.add(index);
331
- }
332
- for (const [index, line] of actualLines.entries()) {
333
- if (merged[index] !== void 0) continue;
334
- for (const [prevIndex] of prevLines.entries()) {
335
- if (consumed.has(prevIndex) || !covers(prevIndex, index)) continue;
336
- merged[index] = prevLines[prevIndex];
337
- consumed.add(prevIndex);
338
- break;
339
- }
340
- merged[index] ??= line;
341
- }
342
- return merged.join("\n");
343
- }
344
- //#endregion
345
- //#region src/model/result/directory.ts
346
- const DEFAULT_IGNORES = [
347
- ".git",
348
- ".DS_Store",
349
- "node_modules",
350
- ".next",
351
- "dist",
352
- ".turbo",
353
- ".cache"
354
- ];
355
- /**
356
- * Recursively walk a directory, returning sorted relative paths of files only.
357
- */
358
- async function walkDirectory(root, options = {}) {
359
- const ignores = /* @__PURE__ */ new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
360
- const out = [];
361
- async function walk(current) {
362
- let entries;
363
- try {
364
- entries = await readdir(current);
365
- } catch {
366
- return;
367
- }
368
- for (const entry of entries) {
369
- if (ignores.has(entry)) continue;
370
- const abs = resolve(current, entry);
371
- const stat = statSync(abs);
372
- if (stat.isDirectory()) await walk(abs);
373
- else if (stat.isFile()) out.push(relative(root, abs).split(sep).join("/"));
374
- }
375
- }
376
- await walk(root);
377
- out.sort();
378
- return out;
379
- }
380
- /**
381
- * Compare two directory trees file-by-file. Fixture file contents honor the
382
- * unified `{{token}}` grammar (CONVENTIONS D4) — captures are recorded in
383
- * `scope` in sorted-path order.
384
- */
385
- async function diffDirectories(expectedRoot, actualRoot, options = {}) {
386
- const expectedFiles = await walkDirectory(expectedRoot, options);
387
- const actualFiles = await walkDirectory(actualRoot, options);
388
- const expectedSet = new Set(expectedFiles);
389
- const actualSet = new Set(actualFiles);
390
- const scope = options.scope ?? new CaptureScope();
391
- const added = actualFiles.filter((f) => !expectedSet.has(f));
392
- const removed = expectedFiles.filter((f) => !actualSet.has(f));
393
- const changed = [];
394
- for (const file of expectedFiles) {
395
- if (!actualSet.has(file)) continue;
396
- const expected = readFileSync(resolve(expectedRoot, file), "utf8");
397
- const actual = readFileSync(resolve(actualRoot, file), "utf8");
398
- if (!textEquals(expected, actual, scope)) changed.push({
399
- actual,
400
- expected,
401
- path: file
402
- });
403
- }
404
- return {
405
- added,
406
- changed,
407
- removed
408
- };
409
- }
410
- /**
411
- * Read-only accessor for a directory produced by a spec action.
412
- *
413
- * Assertions go through `expect()` (async — they walk the disk):
414
- * `await expect(result.directory('out')).toMatch('scaffold/out')`
415
- * compares the tree against the fixture directory `_expected/<name>/`.
416
- */
417
- var DirectoryAccessor = class {
418
- /** @internal Ref-capture scope shared by the current spec execution. */
419
- captures;
420
- /** @internal Absolute path of the directory under assertion. */
421
- root;
422
- /** @internal Test-file directory — fixture resolution root for matchers. */
423
- testDir;
424
- constructor(absPath, testDir, captures) {
425
- this.root = absPath;
426
- this.testDir = testDir;
427
- this.captures = captures ?? new CaptureScope();
428
- }
429
- /** List all files (recursively) under the directory, sorted. */
430
- async files(options = {}) {
431
- return await walkDirectory(this.root, options);
432
- }
433
- };
434
- //#endregion
435
- //#region src/model/result/filesystem.ts
436
- /**
437
- * Read-only accessor for the whole temporary working directory used by a
438
- * command spec.
439
- *
440
- * Assertions go through `expect()` (async — they walk the disk):
441
- * `await expect(result.filesystem).toMatch('scaffolded')` compares the
442
- * tree against the fixture directory `_expected/<name>/`.
443
- */
444
- var FilesystemAccessor = class {
445
- /** @internal Ref-capture scope shared by the current spec execution. */
446
- captures;
447
- /** The absolute path of the temporary working directory. */
448
- cwd;
449
- /** @internal Test-file directory — fixture resolution root for matchers. */
450
- testDir;
451
- constructor(cwd, testDir, captures) {
452
- this.cwd = cwd;
453
- this.testDir = testDir;
454
- this.captures = captures ?? new CaptureScope();
455
- }
456
- /** List all files (recursively) under the working directory, sorted. */
457
- async files(options = {}) {
458
- return await walkDirectory(this.cwd, options);
459
- }
460
- };
461
- //#endregion
462
- //#region src/model/result/grep.ts
463
- /**
464
- * Extract text blocks from output that contain a pattern.
465
- * Splits by blank lines (how linter/compiler output is structured),
466
- * returns only blocks matching the pattern.
467
- *
468
- * @example
469
- * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
470
- * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
471
- */
472
- function grep(output, pattern) {
473
- return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
474
- }
475
- //#endregion
476
- //#region src/model/result/text.ts
477
- const ANSI_RE = /\u001B\[[0-9;]*m/gu;
478
- /** Strip ANSI escape sequences from a string. */
479
- function stripAnsiCodes(value) {
480
- return value.replace(ANSI_RE, "");
481
- }
482
- /**
483
- * Read-only accessor for a captured text handle — THE universal one: stdout,
484
- * stderr, container logs, and file text all surface as a `TextAccessor`.
485
- *
486
- * Backed by a primitive string, exposed via {@link text}, but also provides
487
- * `toString()` / `valueOf()` for string coercion, so common patterns like
488
- * `String(result.stdout)` and template-literal interpolation still work.
489
- *
490
- * ANSI escape sequences are stripped by default before every comparison
491
- * (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
492
- * an escape hatch for application noise not covered by the `{{token}}`
493
- * grammar.
494
- *
495
- * Text operations are **closed** over the type: `.grep(pattern)` returns a
496
- * `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
497
- * context and the `transform`, so results are chainable
498
- * (`result.stdout.grep(a).grep(b)`) and snapshot-able
499
- * (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
500
- *
501
- * Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
502
- * and `expect(result.stdout).toMatch('name.txt')` (resolved against
503
- * `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
504
- */
505
- var TextAccessor = class TextAccessor {
506
- /** @internal Ref-capture scope shared by the current spec execution. */
507
- captures;
508
- /** @internal Stream label used in failure messages ("stdout" / "stderr"). */
509
- streamName;
510
- /** @internal Test-file directory — fixture resolution root for matchers. */
511
- testDir;
512
- /** The raw captured text (never transformed, ANSI preserved). */
513
- text;
514
- /** @internal Normaliser applied before comparisons, never to fixtures. */
515
- transform;
516
- constructor(value, streamName, testDir, options = {}) {
517
- this.text = value;
518
- this.streamName = streamName;
519
- this.testDir = testDir;
520
- this.transform = options.transform;
521
- this.captures = options.captures ?? new CaptureScope();
522
- }
523
- /** @internal The text as compared by matchers — ANSI stripped, transform applied. */
524
- get comparableText() {
525
- const stripped = stripAnsiCodes(this.text);
526
- return this.transform ? this.transform(stripped) : stripped;
527
- }
528
- /**
529
- * Keep only the blank-line-separated blocks of the text that contain
530
- * `pattern` (how linter/compiler output is structured), returned as a new
531
- * `TextAccessor` — the same subject type, so the result is chainable and
532
- * snapshot-able. The scalpel for probing large tool outputs; snapshot the
533
- * whole surface by default, reach for `.grep()` for targeted checks.
534
- */
535
- grep(pattern) {
536
- return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
537
- captures: this.captures,
538
- transform: this.transform
539
- });
540
- }
541
- toString() {
542
- return this.text;
543
- }
544
- valueOf() {
545
- return this.text;
546
- }
547
- };
548
- //#endregion
549
- //#region src/model/result/json.ts
550
- /**
551
- * Read-only accessor for a JSON payload parsed from a text stream (stdout).
552
- *
553
- * Lazily parses the JSON on first use; parse errors are deferred until the
554
- * value is read so that callers can still read the raw stream text without
555
- * triggering a throw.
556
- *
557
- * ANSI escape sequences are stripped by default before `JSON.parse`
558
- * (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
559
- * runs on the stripped text — an escape hatch for application noise not
560
- * covered by the `{{token}}` grammar.
561
- *
562
- * Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
563
- * (deep-equal against `_expected/<name>`, with `{{placeholder}}` support).
564
- */
565
- var JsonAccessor = class {
566
- /** @internal Ref-capture scope shared by the current spec execution. */
567
- captures;
568
- /** @internal */
569
- rawText;
570
- /** @internal Test-file directory — fixture resolution root for matchers. */
571
- testDir;
572
- /** @internal */
573
- transform;
574
- constructor(rawText, testDir, transform, captures) {
575
- this.rawText = rawText;
576
- this.testDir = testDir;
577
- this.transform = transform;
578
- this.captures = captures ?? new CaptureScope();
579
- }
580
- /**
581
- * The parsed JSON value. Throws if the text is not valid JSON.
582
- *
583
- * `Value` is `unknown` unless the subject that produced this accessor
584
- * knows better — `.call<T>()` does, and hands its T straight through so a
585
- * one-field reading needs no cast in the test.
586
- */
587
- get value() {
588
- const stripped = stripAnsiCodes(this.rawText);
589
- const source = this.transform ? this.transform(stripped) : stripped;
590
- try {
591
- return JSON.parse(source);
592
- } catch {
593
- const preview = source.slice(0, 200);
594
- throw new Error(`stdout is not valid JSON: ${preview}`);
595
- }
596
- }
597
- };
598
- //#endregion
599
- //#region src/model/chain/resolve.ts
600
- /** What makes a directory a project root: the thing that declares itself one. */
601
- const ROOT_MARKER = "package.json";
602
- /**
603
- * The NEAREST ancestor of `startDir` (itself included) carrying the root
604
- * marker, `undefined` when none does.
605
- *
606
- * One marker, and it is the nearest thing that declares itself a project: a
607
- * second marker higher in the tree would outrank the `package.json` of the very
608
- * package being tested, and every path the runner resolves would be measured
609
- * from the wrong unit.
610
- *
611
- * The existence probe is injected so the lint layer can pass its cached one
612
- * (A9's `a9w-redundant-root` derives the same root, and must derive it the
613
- * same way).
614
- */
615
- function findRoot(startDir, exists) {
616
- let dir = startDir;
617
- for (;;) {
618
- if (exists(resolve(dir, ROOT_MARKER))) return dir;
619
- const parent = dirname(dir);
620
- if (parent === dir) return;
621
- dir = parent;
622
- }
623
- }
624
- /**
625
- * Auto-discover the project root from a starting directory (CONVENTIONS A9):
626
- * walk up to the first directory that carries `package.json`; if none does,
627
- * the starting directory itself.
628
- */
629
- function discoverRoot(startDir) {
630
- return findRoot(startDir, existsSync) ?? startDir;
631
- }
632
- /**
633
- * Resolve the project root for a specification. An explicit `root` option is
634
- * an override (resolved from the caller's directory when relative); when
635
- * absent, the root is auto-discovered by walking up from the calling
636
- * specification file.
637
- */
638
- function resolveRoot(root, callerDir) {
639
- if (!root) return discoverRoot(callerDir);
640
- if (isAbsolute(root)) return root;
641
- return resolve(callerDir, root);
642
- }
643
- /**
644
- * Resolve a command — checks node_modules/.bin, then treats as absolute/PATH.
645
- */
646
- function resolveCommand(command, root) {
647
- if (isAbsolute(command)) return command;
648
- const binPath = resolve(root, "node_modules/.bin", command);
649
- if (existsSync(binPath)) return binPath;
650
- const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
651
- if (existsSync(cwdBinPath)) return cwdBinPath;
652
- return command;
653
- }
654
- /**
655
- * The realpath form of a working directory — what a child process prints as
656
- * `$PWD` (macOS resolves the tmpdir symlink), and therefore what `{{workdir}}`
657
- * compares against. Falls back to the path itself when it cannot be resolved.
658
- *
659
- * @internal Shared by the result scope, the literate runner and `$WORKDIR`.
660
- */
661
- function safeRealpath(path) {
662
- try {
663
- return realpathSync(path);
664
- } catch {
665
- return path;
666
- }
667
- }
668
- /**
669
- * Expand the `$WORKDIR` token in every string value of a child environment —
670
- * the one place the token is spelled out, for both doors (`.env()` on the
671
- * chain, `env:` in a `<case>.spec.yaml`).
672
- *
673
- * It expands to the RESOLVED directory, never the raw temp path: a value the
674
- * child receives comes back in its output, and it must come back spelled the
675
- * way `{{workdir}}` holds it (realpath form, CONVENTIONS D4). Expanding the raw
676
- * path made `HOME=$WORKDIR` on macOS echo `/var/folders/…` against a
677
- * `{{workdir}}` holding `/private/var/folders/…` — a golden no run could match.
678
- */
679
- function expandWorkdir(env, workDir) {
680
- const workdir = safeRealpath(workDir);
681
- const expanded = {};
682
- for (const [key, value] of Object.entries(env)) expanded[key] = typeof value === "string" ? value.replaceAll("$WORKDIR", workdir) : value;
683
- return expanded;
684
- }
685
- //#endregion
686
- //#region src/model/result/table.ts
687
- /**
688
- * Read-only accessor for a database table after a specification run.
689
- *
690
- * Assertions go through `expect()` (async — they query the database):
691
- *
692
- * await expect(result.table('users', { database: 'db' })).toMatchRows({
693
- * columns: ['name'],
694
- * rows: [['Alice']],
695
- * });
696
- * await expect(result.table('users', { database: 'db' })).toBeEmpty();
697
- */
698
- var TableAccessor = class {
699
- /** @internal Ref-capture scope shared by the current spec execution. */
700
- captures;
701
- /** @internal */
702
- db;
703
- /** The table name this accessor reads. */
704
- name;
705
- constructor(name, db, captures) {
706
- this.name = name;
707
- this.db = db;
708
- this.captures = captures ?? new CaptureScope();
709
- }
710
- /** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
711
- async query(columns) {
712
- return await this.db.query(this.name, columns);
713
- }
714
- };
715
- //#endregion
716
- //#region src/model/result/result.ts
717
- /**
718
- * Base result - common accessors available after any action type.
719
- * Extended by HttpResult, CliResult, and used directly by job results.
720
- *
721
- * Every result carries a fresh {@link CaptureScope}: `match.ref()` captures
722
- * and `{{type#ref}}` placeholders are scoped to one spec execution.
723
- */
724
- var BaseResult = class {
725
- /** @internal Ref-capture scope shared by every assertion on this result. */
726
- captures;
727
- config;
728
- testDir;
729
- workDir;
730
- constructor(options) {
731
- this.config = options.config;
732
- this.testDir = options.testDir;
733
- this.workDir = options.workDir;
734
- this.captures = new CaptureScope(options.workDir ? safeRealpath(options.workDir) : void 0);
735
- }
736
- /** Access a directory (relative to the working directory) for snapshot assertions. */
737
- directory(path = ".") {
738
- const baseDir = this.workDir ?? this.testDir;
739
- return new DirectoryAccessor(resolve(baseDir, path), this.testDir, this.captures);
740
- }
741
- /** Access a single file (relative to the working directory) for content assertions. */
742
- file(path) {
743
- const baseDir = this.workDir ?? this.testDir;
744
- const resolvedPath = resolve(baseDir, path);
745
- const exists = existsSync(resolvedPath);
746
- const { captures, testDir } = this;
747
- return {
748
- get content() {
749
- if (!exists) throw new Error(`File not found: ${path}`);
750
- return readFileSync(resolvedPath, "utf8");
751
- },
752
- exists,
753
- grep(pattern) {
754
- if (!exists) throw new Error(`File not found: ${path}`);
755
- return new TextAccessor(readFileSync(resolvedPath, "utf8"), path, testDir, { captures }).grep(pattern);
756
- }
757
- };
758
- }
759
- /** Access a database table for row-level assertions via expect() matchers. */
760
- table(tableName, options) {
761
- validateDatabaseOption("table", this.config, options?.database);
762
- const db = this.resolveDatabase(options?.database);
763
- if (!db) throw new Error(options?.database ? `table("${tableName}") requires database "${options.database}" but it was not found` : `table("${tableName}") requires a database adapter`);
764
- return new TableAccessor(tableName, db, this.captures);
765
- }
766
- resolveDatabase(databaseKey) {
767
- if (databaseKey && this.config.databases) return this.config.databases.get(databaseKey);
768
- return this.config.database;
769
- }
770
- };
771
- /**
772
- * Enforce CONVENTIONS A7: with two or more databases in the services record
773
- * the `database` option is mandatory on `.seed()` / `.table()`; with exactly
774
- * one it is forbidden (redundant).
775
- *
776
- * @internal
777
- */
778
- function validateDatabaseOption(method, config, database) {
779
- const keys = config.databaseKeys;
780
- if (!keys) return;
781
- if (keys.length >= 2 && !database) throw new Error(`${method}(): ${keys.length} databases are declared (${keys.map((k) => `"${k}"`).join(", ")}) — pass { database: <key> } to target one of them.`);
782
- if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
783
- }
784
- //#endregion
785
- //#region src/seams/docker/docker-lookup.ts
786
- /**
787
- * Thin synchronous wrappers around the `docker` CLI used by the docker() spec
788
- * mode. Sync is deliberate — the calls are fast, run only on the host, and
789
- * keep the dispose path straightforward (`Symbol.asyncDispose` can still be
790
- * async without needing these to be).
791
- */
792
- const DEFAULT_TIMEOUT = 1e4;
793
- /** Return all container IDs (running or stopped) that carry `key=value`. */
794
- function findContainersByLabel(key, value) {
795
- try {
796
- return execSync(`docker ps -aq -f label=${key}=${value}`, {
797
- encoding: "utf8",
798
- timeout: DEFAULT_TIMEOUT
799
- }).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
800
- } catch {
801
- return [];
802
- }
803
- }
804
- /** Return the raw `docker inspect` payload (object, not array) for a container. */
805
- function inspectContainer(id) {
806
- const raw = execSync(`docker inspect ${id}`, {
807
- encoding: "utf8",
808
- timeout: DEFAULT_TIMEOUT
809
- });
810
- const parsed = JSON.parse(raw);
811
- return Array.isArray(parsed) ? parsed[0] : parsed;
812
- }
813
- /** Force-remove the given container IDs in a single call. Errors are swallowed. */
814
- function removeContainers(ids) {
815
- if (ids.length === 0) return;
816
- try {
817
- execSync(`docker rm -f ${ids.join(" ")}`, {
818
- encoding: "utf8",
819
- stdio: "pipe",
820
- timeout: DEFAULT_TIMEOUT
821
- });
822
- } catch {}
823
- }
824
- //#endregion
825
- //#region src/facets/cli/cli.result.ts
826
- /**
827
- * Result from a command action (`.exec()`).
828
- *
829
- * When the runner was configured with a `docker` option, the result also
830
- * exposes container accessors and participates in `Symbol.asyncDispose`
831
- * cleanup. The Docker shell-outs are **lazy**: a test that never calls
832
- * `.container(...)` never queries the Docker daemon, so command-only tests
833
- * pay zero Docker cost even when the runner is Docker-aware.
834
- *
835
- * Dispose always runs one final label-filtered `docker ps` to catch
836
- * containers that were spawned during `.exec` but never asserted on —
837
- * tests still get leak-free cleanup even if they forget to reach into
838
- * a container.
839
- */
840
- var CliResult = class extends BaseResult {
841
- commandOutput;
842
- containersCache = null;
843
- dockerConfig;
844
- testRunId;
845
- transform;
846
- constructor(options) {
847
- super(options);
848
- this.commandOutput = options.commandOutput;
849
- this.dockerConfig = options.dockerConfig;
850
- this.testRunId = options.testRunId;
851
- this.transform = options.transform;
852
- }
853
- /** The process exit code. */
854
- get exitCode() {
855
- return this.commandOutput.exitCode;
856
- }
857
- /** Accessor for the captured standard output with file-based assertions. */
858
- get stdout() {
859
- return new TextAccessor(this.commandOutput.stdout, "stdout", this.testDir, {
860
- captures: this.captures,
861
- transform: this.transform
862
- });
863
- }
864
- /** Accessor for the captured standard error with file-based assertions. */
865
- get stderr() {
866
- return new TextAccessor(this.commandOutput.stderr, "stderr", this.testDir, {
867
- captures: this.captures,
868
- transform: this.transform
869
- });
870
- }
871
- /** Accessor for parsing stdout as JSON and asserting against JSON fixtures. */
872
- get json() {
873
- return new JsonAccessor(this.commandOutput.stdout, this.testDir, this.transform, this.captures);
874
- }
875
- /** Accessor for the temporary working directory the command ran in. */
876
- get filesystem() {
877
- if (!this.workDir) throw new Error("CliResult.filesystem requires a working directory");
878
- return new FilesystemAccessor(this.workDir, this.testDir, this.captures);
879
- }
880
- /**
881
- * Look up a container the command spawned during this run by the value of
882
- * its name-label (as declared in the `docker.nameLabel` of the
883
- * `specification.cli()` options).
884
- * First access triggers a one-shot `docker ps` + `docker inspect`
885
- * query and caches the result for the rest of the result's lifetime.
886
- * Tests that don't call this never touch Docker.
887
- *
888
- * Returns an accessor with `.exists === false` when no container
889
- * carries that name — callers can still assert absence without a try.
890
- */
891
- container(name) {
892
- this.ensureDockerAware("container");
893
- this.loadContainers();
894
- const captured = this.containersCache.get(name);
895
- if (!captured) return new ContainerAccessor(null, null, this.testDir, this.transform);
896
- return new ContainerAccessor(captured.id, captured.inspect, this.testDir, this.transform);
897
- }
898
- /** All captured container IDs. Triggers the same lazy query as `container()`. */
899
- get containerIds() {
900
- this.ensureDockerAware("containerIds");
901
- this.loadContainers();
902
- return [...this.containersCache.values()].map((c) => c.id);
903
- }
904
- async [Symbol.asyncDispose]() {
905
- if (!this.dockerConfig || !this.testRunId) return;
906
- removeContainers(this.containersCache === null ? findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId) : [...this.containersCache.values()].map((c) => c.id));
907
- }
908
- ensureDockerAware(member) {
909
- if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in the specification.cli() options.`);
910
- }
911
- loadContainers() {
912
- if (this.containersCache !== null) return;
913
- const ids = findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId);
914
- const map = /* @__PURE__ */ new Map();
915
- for (const id of ids) {
916
- let inspect;
917
- try {
918
- inspect = inspectContainer(id);
919
- } catch {
920
- continue;
921
- }
922
- const nameValue = (inspect?.Config?.Labels ?? inspect?.config?.labels ?? {})[this.dockerConfig.nameLabel];
923
- const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
924
- map.set(key, {
925
- id,
926
- inspect
927
- });
928
- }
929
- this.containersCache = map;
930
- }
931
- };
932
- //#endregion
933
- //#region src/seams/docker/container-accessor.ts
934
- const EXEC_TIMEOUT = 1e4;
935
- function readInspectState(inspect) {
936
- const state = inspect?.State ?? inspect?.state;
937
- if (!state) return {
938
- running: false,
939
- status: "unknown"
940
- };
941
- return {
942
- running: Boolean(state.Running ?? state.running ?? false),
943
- status: String(state.Status ?? state.status ?? "unknown")
944
- };
945
- }
946
- /**
947
- * Assertion accessor for a single Docker container captured by the docker()
948
- * spec mode. Mirrors the shape of {@link CliResult} so tests use the same
949
- * vocabulary (`stdout.toContain`, `file(...).content`, etc.) regardless
950
- * of where output came from.
951
- *
952
- * Sync state (`exists`, `running`, `status`) is derived from the `docker
953
- * inspect` payload captured at result-construction time. Logs are fetched
954
- * lazily on first access to `stdout`/`stderr`.
955
- */
956
- var ContainerAccessor = class {
957
- cachedLogs = null;
958
- containerId;
959
- exists;
960
- inspectData;
961
- running;
962
- status;
963
- testDir;
964
- transform;
965
- /**
966
- * Underlying Docker container ID, or `null` if no container was captured
967
- * for this name. Useful when a follow-up CLI command needs to reference
968
- * the container by id (e.g. `spwn world inspect <id>`). Prefer the other
969
- * accessors for common reads — pulling the raw id is the escape hatch.
970
- */
971
- get id() {
972
- return this.containerId;
973
- }
974
- constructor(containerId, inspectData, testDir, transform) {
975
- this.containerId = containerId;
976
- this.inspectData = inspectData;
977
- this.testDir = testDir;
978
- this.transform = transform;
979
- this.exists = containerId !== null;
980
- if (this.exists) {
981
- const state = readInspectState(inspectData);
982
- this.running = state.running;
983
- this.status = state.status;
984
- } else {
985
- this.running = false;
986
- this.status = "absent";
987
- }
988
- }
989
- /**
990
- * Raw `docker inspect` object for this container. Throws if the container
991
- * was not captured (i.e. `.exists === false`).
992
- */
993
- get inspect() {
994
- this.requireExists("inspect");
995
- return new JsonAccessor(JSON.stringify(this.inspectData), this.testDir, this.transform);
996
- }
997
- /** Captured logs (stdout+stderr combined for v1). */
998
- get stdout() {
999
- this.requireExists("stdout");
1000
- return new TextAccessor(this.loadLogs(), "stdout", this.testDir, { transform: this.transform });
1001
- }
1002
- /** Captured logs (stdout+stderr combined for v1). */
1003
- get stderr() {
1004
- this.requireExists("stderr");
1005
- return new TextAccessor(this.loadLogs(), "stderr", this.testDir, { transform: this.transform });
1006
- }
1007
- /**
1008
- * Read a file from inside the container via `docker exec cat`. The
1009
- * returned object satisfies the same {@link FileAccessor} shape used by
1010
- * the host filesystem accessor, so tests do not need to learn a new API.
1011
- */
1012
- file(path) {
1013
- this.requireExists("file");
1014
- const id = this.containerId;
1015
- const exists = this.containerExec(id, [
1016
- "test",
1017
- "-e",
1018
- path
1019
- ]).exitCode === 0;
1020
- const { testDir } = this;
1021
- const readContent = () => {
1022
- if (!exists) throw new Error(`File not found in container: ${path}`);
1023
- return execSync(`docker exec ${id} cat ${JSON.stringify(path)}`, {
1024
- encoding: "utf8",
1025
- timeout: EXEC_TIMEOUT
1026
- });
1027
- };
1028
- return {
1029
- get content() {
1030
- return readContent();
1031
- },
1032
- exists,
1033
- grep(pattern) {
1034
- return new TextAccessor(readContent(), path, testDir).grep(pattern);
1035
- }
1036
- };
1037
- }
1038
- /**
1039
- * Run a shell command inside the container and get back the same
1040
- * {@link CliResult} used for host-side executions.
1041
- */
1042
- async exec(cmd) {
1043
- this.requireExists("exec");
1044
- return new CliResult({
1045
- commandOutput: this.containerExec(this.containerId, [
1046
- "sh",
1047
- "-c",
1048
- cmd
1049
- ]),
1050
- config: {},
1051
- testDir: this.testDir,
1052
- transform: this.transform,
1053
- workDir: this.testDir
1054
- });
1055
- }
1056
- requireExists(member) {
1057
- if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
1058
- }
1059
- loadLogs() {
1060
- if (this.cachedLogs !== null) return this.cachedLogs;
1061
- try {
1062
- const out = execSync(`docker logs ${this.containerId}`, {
1063
- encoding: "utf8",
1064
- stdio: [
1065
- "ignore",
1066
- "pipe",
1067
- "pipe"
1068
- ],
1069
- timeout: EXEC_TIMEOUT
1070
- });
1071
- this.cachedLogs = out;
1072
- } catch (error) {
1073
- this.cachedLogs = typeof error?.stdout === "string" ? error.stdout : String(error?.stderr ?? "");
1074
- }
1075
- return this.cachedLogs ?? "";
1076
- }
1077
- containerExec(id, argv) {
1078
- try {
1079
- const quoted = argv.map((a) => JSON.stringify(a)).join(" ");
1080
- return {
1081
- exitCode: 0,
1082
- stderr: "",
1083
- stdout: execSync(`docker exec ${id} ${quoted}`, {
1084
- encoding: "utf8",
1085
- stdio: [
1086
- "ignore",
1087
- "pipe",
1088
- "pipe"
1089
- ],
1090
- timeout: EXEC_TIMEOUT
1091
- })
1092
- };
1093
- } catch (error) {
1094
- return {
1095
- exitCode: typeof error?.status === "number" ? error.status : 1,
1096
- stderr: typeof error?.stderr === "string" ? error.stderr : String(error?.message ?? ""),
1097
- stdout: typeof error?.stdout === "string" ? error.stdout : ""
1098
- };
1099
- }
1100
- }
1101
- };
1102
- //#endregion
1103
- //#region src/model/chain/reporter.ts
1104
- const GREEN = "\x1B[32m";
1105
- const RED = "\x1B[31m";
1106
- const DIM = "\x1B[2m";
1107
- const BOLD = "\x1B[1m";
1108
- const RESET = "\x1B[0m";
1109
- const BG_CYAN = "\x1B[46m";
1110
- const BLACK = "\x1B[30m";
1111
- const CHECK = "✓";
1112
- const CROSS = "×";
1113
- const ARROW = "→";
1114
- /** The subject, in the words of the thing that started it. */
1115
- function describeApp(app) {
1116
- if (app.type === "in-process") return "in-process (Hono)";
1117
- if (app.type === "process") return app.command === void 0 ? "a child process" : `${app.command} (child process)`;
1118
- return app.url ?? "";
1119
- }
1120
- /**
1121
- * The block printed while the world comes up: one line per declared service,
1122
- * then the app the subject is. There is one shape of world to report on, so the
1123
- * report takes no mode — the parameter that named one left with compose mode
1124
- * (ADR-007).
1125
- */
1126
- function formatStartupReport(services, app) {
1127
- const lines = [];
1128
- lines.push("", `${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`, "");
1129
- for (const service of services) if (service.error) {
1130
- lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
1131
- if (service.logs) {
1132
- const logLines = service.logs.trim().split("\n").slice(-10);
1133
- for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
1134
- }
1135
- } else {
1136
- const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
1137
- lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
1138
- }
1139
- if (app && app.type !== "none") lines.push("", ` ${DIM}${ARROW} app: ${describeApp(app)}${RESET}`);
1140
- lines.push("");
1141
- return lines.join("\n");
1142
- }
1143
- function formatTableDiff(table, columns, expected, actual) {
1144
- const lines = [];
1145
- lines.push(`Table "${table}" mismatch`, `${DIM} query: ${columns.join(", ")}${RESET}`, `${DIM} expected: ${rowLabel(expected.length)}${RESET}`, `${DIM} received: ${rowLabel(actual.length)}${RESET}`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1146
- const header = columns.join(" | ");
1147
- lines.push(`${DIM} ${header}${RESET}`);
1148
- const maxRows = Math.max(expected.length, actual.length);
1149
- for (let i = 0; i < maxRows; i++) {
1150
- const exp = expected[i];
1151
- const act = actual[i];
1152
- if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
1153
- else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
1154
- else if (exp && act) {
1155
- if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
1156
- else lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`, `${RED}+ ${formatRow(act)}${RESET}`);
1157
- }
1158
- }
1159
- if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
1160
- return lines.join("\n");
1161
- }
1162
- function formatResponseDiff(file, expected, actual) {
1163
- const lines = [];
1164
- lines.push(`Response mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1165
- const expectedLines = JSON.stringify(expected, null, 2).split("\n");
1166
- const actualLines = JSON.stringify(actual, null, 2).split("\n");
1167
- const maxLines = Math.max(expectedLines.length, actualLines.length);
1168
- for (let i = 0; i < maxLines; i++) {
1169
- const exp = expectedLines[i];
1170
- const act = actualLines[i];
1171
- if (exp === act) lines.push(` ${exp}`);
1172
- else {
1173
- if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
1174
- if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
1175
- }
1176
- }
1177
- return lines.join("\n");
1178
- }
1179
- const strictEquals = (expected, actual) => expected === actual;
1180
- function renderLines(expectedLines, actualLines, equals) {
1181
- const rendered = [];
1182
- let marked = 0;
1183
- const maxLines = Math.max(expectedLines.length, actualLines.length);
1184
- for (let i = 0; i < maxLines; i++) {
1185
- const exp = expectedLines[i];
1186
- const act = actualLines[i];
1187
- if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
1188
- rendered.push(` ${exp}`);
1189
- continue;
1190
- }
1191
- marked++;
1192
- if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
1193
- if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
1194
- }
1195
- return {
1196
- marked,
1197
- rendered
1198
- };
1199
- }
1200
- /**
1201
- * Render an expected/received diff over two texts.
1202
- *
1203
- * `equals` is how a line is judged EQUAL. Callers that compare through the
1204
- * `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
1205
- * matched is shown as equal — with its token, not as a `-`/`+` pair against
1206
- * the concrete URL. Marking a line the comparison ACCEPTED buries the one it
1207
- * rejected: the reader then has to work out which of the differences is the
1208
- * actual cause.
1209
- *
1210
- * Safety net: if the token-aware pass marks nothing at all, the whole diff
1211
- * would render as a clean block against a failure that really happened (a
1212
- * `#ref` captured inconsistently across lines, say). The strict rendering is
1213
- * used instead, so a diff always shows the reader something.
1214
- */
1215
- function formatStdoutDiff(file, expected, actual, options) {
1216
- const lines = [];
1217
- lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1218
- const expectedLines = expected.split("\n");
1219
- const actualLines = actual.split("\n");
1220
- const equals = options?.equals ?? strictEquals;
1221
- const first = renderLines(expectedLines, actualLines, equals);
1222
- const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
1223
- return [...lines, ...rendered].join("\n");
1224
- }
1225
- function formatDirectoryDiff(fixtureName, diff, hint) {
1226
- const lines = [];
1227
- const total = diff.added.length + diff.removed.length + diff.changed.length;
1228
- lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`, `${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`, "", `${GREEN}- Expected (fixture)${RESET}`, `${RED}+ Received (generated)${RESET}`, "");
1229
- for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
1230
- for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
1231
- for (const { path, expected, actual } of diff.changed) {
1232
- const expectedLines = expected.split("\n");
1233
- const actualLines = actual.split("\n");
1234
- const changedCount = countLineDifferences(expectedLines, actualLines);
1235
- lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
1236
- let shown = 0;
1237
- const maxShown = 5;
1238
- const maxLines = Math.max(expectedLines.length, actualLines.length);
1239
- for (let i = 0; i < maxLines && shown < maxShown; i++) {
1240
- const exp = expectedLines[i];
1241
- const act = actualLines[i];
1242
- if (exp !== act) {
1243
- lines.push(`${DIM} line ${i + 1}:${RESET}`);
1244
- if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
1245
- if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
1246
- shown++;
1247
- }
1248
- }
1249
- if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
1250
- }
1251
- lines.push("", `${DIM}${hint}${RESET}`);
1252
- return lines.join("\n");
1253
- }
1254
- function countLineDifferences(expected, actual) {
1255
- let count = 0;
1256
- const max = Math.max(expected.length, actual.length);
1257
- for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
1258
- return count;
1259
- }
1260
- function rowLabel(n) {
1261
- return n === 1 ? "1 row" : `${n} rows`;
1262
- }
1263
- function formatRow(row) {
1264
- return row.map((v) => String(v ?? "null")).join(" | ");
1265
- }
1266
- //#endregion
1267
- //#region src/seams/process/serve.adapter.ts
1268
- /** Grace period between SIGTERM and the SIGKILL escalation. */
1269
- const KILL_GRACE_MS = 2e3;
1270
- const DEFAULT_READY_TIMEOUT = 3e4;
1271
- const READY_POLL_INTERVAL_MS = 250;
1272
- const delay = async (ms) => {
1273
- await new Promise((resolve) => setTimeout(resolve, ms));
1274
- };
1275
- /** Ask the OS for a free TCP port. */
1276
- async function findFreePort() {
1277
- return await new Promise((resolve, reject) => {
1278
- const server = createServer();
1279
- server.listen(0, () => {
1280
- const address = server.address();
1281
- if (address === null || typeof address === "string") {
1282
- server.close();
1283
- reject(/* @__PURE__ */ new Error("could not determine a free port"));
1284
- return;
1285
- }
1286
- const { port } = address;
1287
- server.close(() => {
1288
- resolve(port);
1289
- });
1290
- });
1291
- server.on("error", reject);
1292
- });
1293
- }
1294
- /**
1295
- * Starts the site under test as a child process and waits until it answers
1296
- * HTTP. Any HTTP status counts as ready — a 404 on the ready path is still a
1297
- * listening server; only connection failures keep the poll going.
1298
- *
1299
- * The child runs through the shell in its own process group (POSIX) so
1300
- * `stop()` can terminate the whole tree — a `next start` grandchild must not
1301
- * outlive the spec run (same escalation as the cli exec adapter: SIGTERM,
1302
- * then SIGKILL after a 2 s grace).
1303
- */
1304
- var ServeAdapter = class {
1305
- /** The port the server ended up on — known only after {@link start}. */
1306
- port = null;
1307
- child = null;
1308
- /** Extra environment injected into the child (e.g. the stub backend URL). */
1309
- extraEnv;
1310
- /** The constructor named in error messages — `website`, or `mobile` (appium server). */
1311
- facet;
1312
- options;
1313
- output = "";
1314
- root;
1315
- constructor(options, root, facet = "website", extraEnv = {}) {
1316
- this.extraEnv = extraEnv;
1317
- this.facet = facet;
1318
- this.options = options;
1319
- this.root = root;
1320
- }
1321
- /** Start the server and resolve with its base URL once it is ready. */
1322
- async start() {
1323
- this.runBefore();
1324
- const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
1325
- const port = banner ? null : this.options.port ?? await findFreePort();
1326
- const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
1327
- this.child = spawn(this.options.command, [], {
1328
- cwd: this.workingDirectory(),
1329
- detached: process.platform !== "win32",
1330
- env: {
1331
- ...process.env,
1332
- ...typeof this.options.env === "function" ? {} : this.options.env,
1333
- ...this.extraEnv,
1334
- ...port === null ? {} : { PORT: String(port) }
1335
- },
1336
- shell: true,
1337
- stdio: [
1338
- "ignore",
1339
- "pipe",
1340
- "pipe"
1341
- ]
1342
- });
1343
- this.child.stdout?.on("data", (data) => {
1344
- this.output += data.toString();
1345
- });
1346
- this.child.stderr?.on("data", (data) => {
1347
- this.output += data.toString();
1348
- });
1349
- let exited = false;
1350
- this.child.on("exit", () => {
1351
- exited = true;
1352
- });
1353
- const deadline = Date.now() + timeout;
1354
- const died = () => {
1355
- throw new Error(`specification.${this.facet}(): server command exited before it was ready.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1356
- };
1357
- if (banner) for (;;) {
1358
- banner.lastIndex = 0;
1359
- const found = banner.exec(this.output);
1360
- if (found) {
1361
- const captured = Number(found[1]);
1362
- if (!Number.isInteger(captured)) {
1363
- await this.stop();
1364
- throw new Error(`specification.${this.facet}(): the ready pattern ${String(banner)} matched but its capture group is not a port ("${found[1]}").\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1365
- }
1366
- this.port = captured;
1367
- return `http://127.0.0.1:${captured}`;
1368
- }
1369
- if (exited) died();
1370
- if (Date.now() >= deadline) {
1371
- await this.stop();
1372
- throw new Error(`specification.${this.facet}(): server did not print ${String(banner)} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1373
- }
1374
- await delay(READY_POLL_INTERVAL_MS);
1375
- }
1376
- const baseUrl = `http://127.0.0.1:${port}`;
1377
- const readyUrl = `${baseUrl}${this.options.ready ?? "/"}`;
1378
- for (;;) {
1379
- if (exited) died();
1380
- try {
1381
- await fetch(readyUrl, { redirect: "manual" });
1382
- this.port = port;
1383
- return baseUrl;
1384
- } catch {
1385
- if (Date.now() >= deadline) {
1386
- await this.stop();
1387
- throw new Error(`specification.${this.facet}(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
1388
- }
1389
- await delay(READY_POLL_INTERVAL_MS);
1390
- }
1391
- }
1392
- }
1393
- /** Where the child runs: the stated `cwd` under the root, or the root. */
1394
- workingDirectory() {
1395
- return this.options.cwd === void 0 ? this.root : resolve(this.root, this.options.cwd);
1396
- }
1397
- /**
1398
- * The one-shot command, run before the process is spawned. It must exit 0:
1399
- * a build that failed is not a spec that failed later on a missing file,
1400
- * and the difference is what this reports.
1401
- */
1402
- runBefore() {
1403
- const { before } = this.options;
1404
- if (before === void 0) return;
1405
- const outcome = spawnSync(before, [], {
1406
- cwd: this.workingDirectory(),
1407
- encoding: "utf8",
1408
- shell: true
1409
- });
1410
- if (outcome.status !== 0) throw new Error(`specification.${this.facet}(): \`before\` exited ${outcome.status ?? "without a status"}.\nCommand: ${before}\nOutput:\n${(outcome.stdout ?? "") + (outcome.stderr ?? "")}`);
1411
- }
1412
- /** Terminate the server process group (idempotent). */
1413
- async stop() {
1414
- const { child } = this;
1415
- if (!child) return;
1416
- this.child = null;
1417
- await new Promise((resolve) => {
1418
- let exited = false;
1419
- child.on("exit", () => {
1420
- exited = true;
1421
- resolve();
1422
- });
1423
- if (child.exitCode !== null || child.signalCode !== null) {
1424
- resolve();
1425
- return;
1426
- }
1427
- this.kill(child, "SIGTERM");
1428
- setTimeout(() => {
1429
- if (!exited) {
1430
- this.kill(child, "SIGKILL");
1431
- resolve();
1432
- }
1433
- }, KILL_GRACE_MS).unref?.();
1434
- });
1435
- }
1436
- /** Group-aware kill: signal the process group, fall back to the child. */
1437
- kill(child, signal) {
1438
- if (child.pid !== void 0 && process.platform !== "win32") try {
1439
- process.kill(-child.pid, signal);
1440
- return;
1441
- } catch {}
1442
- child.kill(signal);
1443
- }
1444
- };
1445
- //#endregion
1446
- //#region src/runner/update.ts
1447
- /**
1448
- * Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
1449
- * CONVENTIONS E1) or vitest's `-u` / `--update` flag turn fixture mismatches
1450
- * into fixture rewrites.
1451
- */
1452
- function shouldUpdateSnapshots() {
1453
- if (process.env.TEST_UPDATE === "1") return true;
1454
- return process.argv.includes("-u") || process.argv.includes("--update");
1455
- }
1456
- /** Standard hint appended to missing-fixture errors. */
1457
- const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
1458
- /**
1459
- * The hint a FROZEN golden gets instead. `{ frozen }` is the switch that tells
1460
- * update mode to leave this file alone, so sending its author to `TEST_UPDATE=1`
1461
- * would send them to a run that does nothing.
1462
- */
1463
- const FROZEN_HINT = "This golden is `{ frozen }`: update mode never writes it. Write the file by hand, or drop `{ frozen }` for one TEST_UPDATE=1 run and put it back.";
1464
- /** The hint a missing golden earns, given whether it is frozen. */
1465
- function missingHint(frozen) {
1466
- return frozen ? FROZEN_HINT : UPDATE_HINT;
1467
- }
1468
- //#endregion
1469
- //#region src/facets/cli/literate.ts
1470
- /**
1471
- * The form a stream is compared in: ANSI stripped (rule D6) and the runner's
1472
- * `transform` applied. Nothing else — a block scalar states its own final
1473
- * newline (`|` keeps it, `|-` drops it), so the comparison is byte-exact and
1474
- * has no normalisation to hide behind.
1475
- */
1476
- function comparable(raw, transform) {
1477
- const stripped = stripAnsiCodes(raw);
1478
- return transform ? transform(stripped) : stripped;
1479
- }
1480
- /**
1481
- * Line equality AS THE COMPARISON JUDGED IT — a `{{url}}` line that matched is
1482
- * equal, whatever port the run picked. Each line is judged on its own fresh
1483
- * scope: this is a rendering, and a ref captured by an earlier line must not
1484
- * decide how a later one is displayed.
1485
- */
1486
- function tokenAwareEquals(scope) {
1487
- return (expected, actual) => textEquals(expected, actual, new CaptureScope(scope.workdir));
1488
- }
1489
- /** A path under the working directory, refusing anything that would escape it. */
1490
- function resolveUnderWorkDir(path, workDir, context) {
1491
- const target = resolve(workDir, path);
1492
- const inside = relative(workDir, target);
1493
- if (isAbsolute(path) || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`${context.displayPath}: files: "${path}" leaves the working directory — a files: key is a relative path under the cwd`);
1494
- return target;
1495
- }
1496
- /** A one-line preview of a file's content for the failure rendering. */
1497
- function preview(text) {
1498
- return JSON.stringify(text.length > 160 ? `${text.slice(0, 160)}…` : text);
1499
- }
1500
- function readIfPresent(path) {
1501
- try {
1502
- return statSync(path).isFile() ? readFileSync(path, "utf8") : null;
1503
- } catch {
1504
- return null;
1505
- }
1506
- }
1507
- /** Check one `files:` entry and render its failure, or `null` when it holds. */
1508
- function checkFile(assertion, workDir, context, scope) {
1509
- const target = resolveUnderWorkDir(assertion.path, workDir, context);
1510
- let exists;
1511
- try {
1512
- exists = statSync(target) !== void 0;
1513
- } catch {
1514
- exists = false;
1515
- }
1516
- if (assertion.kind === "absent") return exists ? `${assertion.path}: expected absent, got a file` : null;
1517
- if (!exists) return `${assertion.path}: expected to exist, got nothing at that path`;
1518
- if (assertion.kind === "exists") return null;
1519
- const content = readIfPresent(target);
1520
- if (content === null) return `${assertion.path}: expected a readable file, got a directory`;
1521
- for (const needle of assertion.contains) if (!textContains(needle.text, content, scope)) return `${assertion.path}: expected to contain ${preview(needle.text)}, got ${preview(content)}`;
1522
- if (assertion.equals !== null && !textEquals(assertion.equals.text, content, scope)) return `${assertion.path}: expected ${preview(assertion.equals.text)}, got ${preview(content)}`;
1523
- return null;
1524
- }
1525
- /**
1526
- * The failure a mismatching run throws: the description, the command that ran,
1527
- * every detail the comparison rejected, and where to open the document. The
1528
- * update hint names exactly what a rewrite touches, so it cannot be read as
1529
- * "this will re-generate my `files:`".
1530
- *
1531
- * The stack is REPLACED by the run's own line. Left alone it would carry the
1532
- * engine's frames and end inside the module the plugin generates — pointing the
1533
- * reader at a line of a file they never wrote. One frame, on the `command:` key,
1534
- * is the whole truth here.
1535
- */
1536
- function failure(document, run, context, details) {
1537
- return atLine(new Error([
1538
- "Spec mismatch",
1539
- "",
1540
- document.description,
1541
- "",
1542
- `$ ${run.command}`,
1543
- "",
1544
- details.join("\n\n"),
1545
- "",
1546
- `${context.displayPath}:${run.commandLine}`,
1547
- "Run with TEST_UPDATE=1 to rewrite the runs — the exit code and the streams, and nothing else."
1548
- ].join("\n")), context.filePath, run.commandLine);
1549
- }
1550
- /**
1551
- * Point an error's stack at one frame: the line of the `.spec.yaml` that owns
1552
- * the failure. The framework's own frames carry nothing a reader of a spec
1553
- * document can act on, and the generated module's frame actively misleads.
1554
- */
1555
- function atLine(error, filePath, line) {
1556
- error.stack = `${error.name}: ${error.message}\n at ${filePath}:${line}:1`;
1557
- return error;
1558
- }
1559
- /**
1560
- * Compare one run against what the command actually did — its exit code, its
1561
- * streams and its `files:`, all read at the same instant: the moment that
1562
- * command returned.
1563
- */
1564
- function assertRun(document, outcome, context, workDir, scope) {
1565
- const { actual, run } = outcome;
1566
- if (actual.exitCode !== run.exitCode) throw failure(document, run, context, [[
1567
- "exit code mismatch",
1568
- ` expected: ${run.exitCode}`,
1569
- ` received: ${actual.exitCode}`,
1570
- ...outcome.stderr.length > 0 ? [
1571
- "",
1572
- "stderr was:",
1573
- outcome.stderr.replace(/\n$/u, "")
1574
- ] : []
1575
- ].join("\n")]);
1576
- const equals = tokenAwareEquals(scope);
1577
- const details = [];
1578
- for (const [name, expected, received] of [[
1579
- "stdout",
1580
- run.stdout?.text ?? "",
1581
- outcome.stdout
1582
- ], [
1583
- "stderr",
1584
- run.stderr?.text ?? "",
1585
- outcome.stderr
1586
- ]]) if (!textEquals(expected, received, scope)) details.push(formatStdoutDiff(name, expected, received, { equals }));
1587
- const fileFailures = run.files.map((assertion) => checkFile(assertion, workDir, context, scope)).filter((line) => line !== null);
1588
- if (fileFailures.length > 0) details.push([
1589
- "files mismatch",
1590
- "",
1591
- ...fileFailures.map((line) => ` ${line}`)
1592
- ].join("\n"));
1593
- if (details.length > 0) throw failure(document, run, context, details);
1594
- }
1595
- /**
1596
- * The rewritten runs: exit codes and streams from the run, placeholders of the
1597
- * previous golden preserved by pattern (rule D5). A stream that ends up empty
1598
- * loses its key, so a rewritten document passes the next run.
1599
- */
1600
- function updates(outcomes, scope) {
1601
- return outcomes.map(({ actual, run, stderr, stdout }) => ({
1602
- exitCode: actual.exitCode,
1603
- stderr: mergeTextPreservingPlaceholders(run.stderr?.text ?? null, stderr, scope),
1604
- stdout: mergeTextPreservingPlaceholders(run.stdout?.text ?? null, stdout, scope)
1605
- }));
1606
- }
1607
- /**
1608
- * Start every server the document names, in declaration order, after `fixture:`
1609
- * has been copied and before the first run. They all stay live for the whole
1610
- * file and are stopped together — one document is one scenario, and its servers
1611
- * are part of its ground.
1612
- *
1613
- * Every child is seeded with `TEST_WORKDIR`, the resolved working directory the
1614
- * `{{workdir}}` token holds and `$WORKDIR` expands to. The fixtures are already
1615
- * on disk when the child starts, so a served stub reads the world the document
1616
- * laid down exactly as a stub binary reads its own fixture tree: a scenario is
1617
- * a layer of files, never a flag threaded through the server. A document's
1618
- * mapping-form env wins over it, key by key.
1619
- */
1620
- async function startServers(document, config, displayPath, workdir) {
1621
- const registry = config.serveRegistry ?? {};
1622
- const started = [];
1623
- const env = {};
1624
- const stop = async () => {
1625
- for (const adapter of started.toReversed()) await adapter.stop();
1626
- };
1627
- for (const entry of document.serve) {
1628
- const registration = registry[entry.name];
1629
- if (!registration) {
1630
- await stop();
1631
- const known = Object.keys(registry);
1632
- throw new Error(`${displayPath}:${entry.line}: serve: "${entry.name}" is not registered — ${known.length === 0 ? "specification.cli() declares no `serve` option." : `registered servers: ${known.join(", ")}.`}`);
1633
- }
1634
- const adapter = new ServeAdapter({
1635
- command: registration.command,
1636
- ready: registration.ready
1637
- }, config.root ?? process.cwd(), "cli", {
1638
- TEST_WORKDIR: workdir,
1639
- ...entry.env
1640
- });
1641
- started.push(adapter);
1642
- try {
1643
- await adapter.start();
1644
- } catch (error) {
1645
- await stop();
1646
- throw error;
1647
- }
1648
- env[registration.env] = registration.url(adapter.port);
1649
- }
1650
- return {
1651
- env,
1652
- stop
1653
- };
1654
- }
1655
- /** Resolve the document's `env:` entries against the registered sets. `$WORKDIR` expands. */
1656
- function documentEnv(document, config, displayPath) {
1657
- const sets = config.envSets ?? {};
1658
- const env = {};
1659
- for (const token of document.env) {
1660
- if (token.kind === "set") {
1661
- const declared = sets[token.name];
1662
- if (!declared) {
1663
- const known = Object.keys(sets);
1664
- throw new Error(`${displayPath}:${token.line}: env: "${token.name}" is not a registered env set — ${known.length === 0 ? "specification.cli() declares no `env` option." : `registered sets: ${known.join(", ")}.`}`);
1665
- }
1666
- Object.assign(env, declared);
1667
- continue;
1668
- }
1669
- env[token.key] = token.value;
1670
- }
1671
- return env;
1672
- }
1673
- /**
1674
- * Run one spec document end to end and resolve with the LAST run's result —
1675
- * the handle a `.test.ts` adds its own assertion to (a directory golden, a
1676
- * grep) after `cli.run('<case>.spec.yaml')`.
1677
- *
1678
- * Under `TEST_UPDATE=1` nothing is asserted: every run executes, and each one's
1679
- * exit code and streams are rewritten from the actual output with the previous
1680
- * golden's placeholders preserved.
1681
- */
1682
- async function runSpecDocument(options) {
1683
- const { config, displayPath, filePath, testDir, workDir } = options;
1684
- if (!config.command) throw new Error(".run(): spec documents require a command adapter (use specification.cli())");
1685
- let file;
1686
- try {
1687
- file = readSpecFile(readFileSync(filePath, "utf8"), displayPath);
1688
- } catch (error) {
1689
- throw error instanceof SpecSyntaxError ? atLine(error, filePath, error.line) : error;
1690
- }
1691
- const { document } = file;
1692
- const fileDir = filePath.replace(/[/\\][^/\\]*$/u, "");
1693
- for (const fixture of document.fixtures) {
1694
- const { dest, src } = copyPlan(fixture.path, fileDir, workDir);
1695
- cpSync(src, dest, { recursive: true });
1696
- }
1697
- const workdir = safeRealpath(workDir);
1698
- const servers = await startServers(document, config, displayPath, workdir);
1699
- const scope = new CaptureScope(workdir);
1700
- const env = expandWorkdir({
1701
- ...options.baseEnv,
1702
- ...servers.env,
1703
- ...documentEnv(document, config, displayPath)
1704
- }, workDir);
1705
- const rewriting = shouldUpdateSnapshots() && options.frozen !== true;
1706
- const outcomes = [];
1707
- try {
1708
- for (const run of document.runs) {
1709
- const actual = run.waitFor === null ? await config.command.exec(run.command, workDir, env, {
1710
- stdin: run.stdin ?? void 0,
1711
- timeout: run.timeout ?? void 0
1712
- }) : await config.command.watch(run.command, workDir, {
1713
- timeout: run.timeout ?? void 0,
1714
- waitFor: run.waitFor
1715
- }, env);
1716
- const outcome = {
1717
- actual,
1718
- run,
1719
- stderr: comparable(actual.stderr, config.transform),
1720
- stdout: comparable(actual.stdout, config.transform)
1721
- };
1722
- outcomes.push(outcome);
1723
- if (!rewriting) assertRun(document, outcome, {
1724
- displayPath,
1725
- filePath
1726
- }, workDir, scope);
1727
- }
1728
- } finally {
1729
- await servers.stop();
1730
- }
1731
- if (rewriting) writeFileSync(filePath, updateSpecFile(file, updates(outcomes, scope)));
1732
- return new CliResult({
1733
- commandOutput: outcomes.at(-1).actual,
1734
- config,
1735
- dockerConfig: config.dockerConfig,
1736
- testDir,
1737
- testRunId: config.dockerTestRunId,
1738
- transform: config.transform,
1739
- workDir
1740
- });
1741
- }
1742
- /** The title a transformed `.spec.yaml` module runs under — the `description:` line. */
1743
- function specDescription(content, displayPath) {
1744
- return readSpecFile(content, displayPath).document.description;
1745
- }
1746
- //#endregion
1747
- export { placeholderPatternSource as A, FilesystemAccessor as C, hasPlaceholders as D, walkDirectory as E, structuralEquals as M, structuralSubset as N, mergePreservingPlaceholders as O, textEquals as P, TextAccessor as S, diffDirectories as T, discoverRoot as _, ServeAdapter as a, resolveRoot as b, formatStartupReport as c, ContainerAccessor as d, CliResult as f, TableAccessor as g, validateDatabaseOption as h, shouldUpdateSnapshots as i, renderExpected as j, mergeTextPreservingPlaceholders as k, formatStdoutDiff as l, BaseResult as m, specDescription as n, formatDirectoryDiff as o, inspectContainer as p, missingHint as r, formatResponseDiff as s, runSpecDocument as t, formatTableDiff as u, expandWorkdir as v, DirectoryAccessor as w, JsonAccessor as x, resolveCommand as y };