@jterrazz/test 16.1.0 → 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 -2867
  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 +4551 -2612
  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 -3002
  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 +6073 -5616
  16. package/dist/oxlint.d.cts +43 -7212
  17. package/dist/oxlint.d.ts +43 -7212
  18. package/dist/oxlint.js +6065 -5609
  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 +374 -507
  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 -349
  104. package/dist/server.js +0 -160
  105. package/dist/stream.js +0 -321
  106. package/dist/substring-warning.js +0 -244
  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 -242
  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 -305
  125. package/docs/19-linting.md +0 -510
  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
@@ -1,2867 +1,2 @@
1
- import { TestRunner, expect, inject, onTestFinished, vi } from "vitest";
2
- import { commands, page, userEvent } from "vitest/browser";
3
- import { mock, mockDeep } from "vitest-mock-extended";
4
- //#region src/model/contracts/contract.ts
5
- /**
6
- * Declare one contract. Identity function — its value is the enforced shape
7
- * and the naming convention:
8
- *
9
- * @example
10
- * // specs/api/reports/contracts/openai/classify-article.ts
11
- * import { defineContract, openai } from '@jterrazz/test';
12
- *
13
- * export default defineContract({
14
- * request: openai.responses({ user: PROMPT, tools: ['classify'] }),
15
- * response: openai.reply({ categories: ['TECH'] }),
16
- * });
17
- *
18
- * // Dynamic — the response is computed from the observed request:
19
- * export default defineContract({
20
- * request: http.post('https://api.example.com/echo'),
21
- * response: (request) => http.json({ received: request.body }),
22
- * });
23
- */
24
- function defineContract(contract) {
25
- return contract;
26
- }
27
- /** Is this a {@link Contracts} composite (rather than a contract or a list)? */
28
- function isContracts(value) {
29
- return typeof value === "object" && value !== null && Array.isArray(value.contracts) && typeof value.with === "function";
30
- }
31
- /** Is this a single {@link Contract} (rather than a bare request half)? */
32
- function isContract(value) {
33
- return typeof value === "object" && value !== null && "request" in value && "response" in value;
34
- }
35
- /**
36
- * The route a contract claims — method + the DECLARED url source. Two
37
- * contracts share a route when they would be written on the same line of a
38
- * declaration: same method, same url pattern (`.source` for a RegExp).
39
- */
40
- function routeKeyOf(contract) {
41
- const { method, url } = contract.request;
42
- return url instanceof RegExp ? `${method} re:${url.source}` : `${method} str:${url}`;
43
- }
44
- /** Human-readable route of a contract — used in every failure message. */
45
- function describeRoute(contract) {
46
- const { method, url } = contract.request;
47
- return `${method === "*" ? "ANY" : method} ${url instanceof RegExp ? String(url) : url}`;
48
- }
49
- /** Flatten contracts, lists, and composites into one ordered list. */
50
- function flatten(items) {
51
- const flat = [];
52
- for (const item of items) if (Array.isArray(item)) flat.push(...flatten(item));
53
- else if (isContracts(item)) flat.push(...item.contracts);
54
- else flat.push(item);
55
- return flat;
56
- }
57
- /**
58
- * Compose contracts into the artifact a test imports — it's contracts all the
59
- * way down: a composite may extend contracts, lists, and other composites,
60
- * recursively, order preserved.
61
- *
62
- * `.with(...)` derives a scenario: contracts whose route the overrides claim
63
- * are removed from the base, and the overrides are prepended. Under
64
- * first-match selection a more specific override (`/articles/gone-1`) also
65
- * wins over a generic base route (`/articles/{{uuid}}`) it does not replace.
66
- *
67
- * @example
68
- * // contracts/newsroom.contracts.ts
69
- * export default defineContracts(events, articleById);
70
- * export const withArticleGone = (id: string) =>
71
- * newsroom.with(articleGone(id));
72
- */
73
- function defineContracts(...items) {
74
- const contracts = Object.freeze(flatten(items));
75
- return {
76
- contracts,
77
- with(...overrides) {
78
- const added = flatten(overrides);
79
- const claimed = new Set(added.map(routeKeyOf));
80
- const kept = contracts.filter((contract) => !claimed.has(routeKeyOf(contract)));
81
- return defineContracts([...added, ...kept]);
82
- }
83
- };
84
- }
85
- /** Normalize any accepted input into a flat contract list. */
86
- function contractsOf(input) {
87
- return flatten([input]);
88
- }
89
- //#endregion
90
- //#region src/model/contracts/intercept.ts
91
- /**
92
- * The network double in MODULE scope — the same contracts, the same queue and
93
- * the same strictness as a chain's `.intercept()`, for the one kind of test
94
- * that has no chain to hang them on.
95
- *
96
- * A module test whose subject reaches the network had exactly two ways out
97
- * before this: `vi.stubGlobal('fetch')`, which replaces the platform with a
98
- * hand-written stand-in, or `msw/node` imported directly, which F6 refuses a
99
- * test and F8 refuses a consumer to declare. Both answer "what did the outside
100
- * world reply" in a dialect nothing else in the repository speaks.
101
- *
102
- * The scope is an `AsyncDisposable`, so the handlers come off at the end of
103
- * the block however the block ends — and the strict violation (an outgoing
104
- * request no contract accepted) is raised THERE, which is what makes the
105
- * double a guard rather than a convenience.
106
- */
107
- /**
108
- * The contracts a call declared, whichever of the two forms it used. Exported
109
- * for its own module test: the bare-request refusal is a runtime guard the
110
- * compiler already states, so no typed call site can reach it.
111
- *
112
- * @internal
113
- */
114
- function declared(requestOrContracts, maybeResponse) {
115
- if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) return contractsOf(requestOrContracts);
116
- if (maybeResponse === void 0) throw new Error("intercept(): a bare request needs its response — pass a contract (defineContract({ request, response })) or the inline pair intercept(request, response).");
117
- return [{
118
- request: requestOrContracts,
119
- response: maybeResponse
120
- }];
121
- }
122
- /** Is this argument the options object rather than a response? */
123
- function isOptions(value) {
124
- return typeof value === "object" && value !== null && !Array.isArray(value) && "origin" in value;
125
- }
126
- /**
127
- * Resolve every relative request against `origin` for the life of the scope.
128
- *
129
- * The framework owns the seam so a test never has to: this is the one place
130
- * `fetch` is wrapped, it is put back when the scope ends, and what it does is
131
- * the one thing a page would have done for free — turn `/api/posts` into a URL.
132
- *
133
- * Validating the origin and INSTALLING the wrapper are two steps because they
134
- * happen at two moments: the refusal of a bad origin belongs before anything
135
- * starts, while the wrapper may only go on once the engine is listening — it
136
- * has to sit ON TOP of whatever `fetch` the engine installed, and be taken off
137
- * before the engine takes its own back.
138
- */
139
- function resolveRelativeAgainst(origin) {
140
- let base;
141
- try {
142
- base = new URL(origin);
143
- } catch {
144
- throw new Error(`intercept(): \`origin\` states where a relative request resolves — \`${origin}\` is not an absolute URL (e.g. 'http://console.test').`);
145
- }
146
- return () => {
147
- const engine = globalThis.fetch;
148
- globalThis.fetch = async (input, init) => typeof input === "string" && input.startsWith("/") ? await engine(new URL(input, base).toString(), init) : await engine(input, init);
149
- return () => {
150
- globalThis.fetch = engine;
151
- };
152
- };
153
- }
154
- /**
155
- * Build the module-scope double on one engine. The node entry hands it msw's
156
- * server, the page's entry hands it msw's worker: one shape, two transports,
157
- * exactly as a chain's `.intercept()` already has it.
158
- *
159
- * @internal
160
- */
161
- function interceptThrough(register) {
162
- async function intercept(requestOrContracts, maybeResponseOrOptions, maybeOptions) {
163
- const options = isOptions(maybeOptions) ? maybeOptions : isOptions(maybeResponseOrOptions) ? maybeResponseOrOptions : void 0;
164
- const contracts = declared(requestOrContracts, options === maybeResponseOrOptions ? void 0 : maybeResponseOrOptions);
165
- if (contracts.length === 0) throw new Error("intercept(): declare at least one contract — an empty list guards nothing, and a subject with no network needs no intercept at all.");
166
- const installOrigin = options?.origin === void 0 ? void 0 : resolveRelativeAgainst(options.origin);
167
- const registration = await register(contracts);
168
- const restoreOrigin = installOrigin?.();
169
- return { [Symbol.asyncDispose]: async () => {
170
- restoreOrigin?.();
171
- const violation = registration.violation();
172
- registration.cleanup();
173
- await Promise.resolve();
174
- if (violation) throw violation;
175
- } };
176
- }
177
- return intercept;
178
- }
179
- //#endregion
180
- //#region src/model/matching/match.ts
181
- /** Every kind usable as a `{{token}}` in fixture files (all but ref/regex/includes). */
182
- const TOKEN_KINDS = [
183
- "any",
184
- "base64",
185
- "date",
186
- "duration",
187
- "email",
188
- "float",
189
- "hex",
190
- "int",
191
- "ip",
192
- "iso8601",
193
- "number",
194
- "path",
195
- "port",
196
- "semver",
197
- "sha",
198
- "string",
199
- "time",
200
- "ulid",
201
- "url",
202
- "uuid",
203
- "workdir"
204
- ];
205
- /**
206
- * A dynamic-value matcher. Created via the {@link match} factories — never
207
- * constructed directly by user code.
208
- */
209
- var Matcher = class {
210
- /** For kind 'includes': the substring the actual value must contain. */
211
- includes;
212
- kind;
213
- /** For kind 'ref': the capture to assert inequality against. */
214
- notRef;
215
- /** For kind 'ref': the capture name. */
216
- refName;
217
- /** For kind 'regex': the pattern the actual value must match. */
218
- regex;
219
- constructor(kind, options = {}) {
220
- this.includes = options.includes;
221
- this.kind = kind;
222
- this.notRef = options.notRef;
223
- this.refName = options.refName;
224
- this.regex = options.regex;
225
- }
226
- /** Placeholder-style rendering used in failure diffs and serialization. */
227
- toString() {
228
- switch (this.kind) {
229
- case "includes": return `{{includes:${this.includes}}}`;
230
- case "ref": return this.notRef ? `{{ref#${this.refName}!${this.notRef}}}` : `{{ref#${this.refName}}}`;
231
- case "regex": return `{{regex:${this.regex?.source}}}`;
232
- default: return this.refName ? `{{${this.kind}#${this.refName}}}` : `{{${this.kind}}}`;
233
- }
234
- }
235
- toJSON() {
236
- return this.toString();
237
- }
238
- };
239
- const typed = (kind) => () => new Matcher(kind);
240
- const match = {
241
- /** Matches anything. */
242
- any: typed("any"),
243
- /** Matches a base64 string. */
244
- base64: typed("base64"),
245
- /** Matches a calendar date (`YYYY-MM-DD`). */
246
- date: typed("date"),
247
- /** Matches a human duration (`12ms`, `1.5s`, `2m`, `3h`). */
248
- duration: typed("duration"),
249
- /** Matches an email address. */
250
- email: typed("email"),
251
- /**
252
- * Matches a float. In JSON contexts any finite number passes (JSON does
253
- * not distinguish `42` from `42.0`); in text contexts the decimal part
254
- * is required (`4.2`, never `42`).
255
- */
256
- float: typed("float"),
257
- /** Matches a hexadecimal string. */
258
- hex: typed("hex"),
259
- /**
260
- * Matches a string CONTAINING the given substring. Code-only, like
261
- * {@link match.regex} — the `{{token}}` fixture vocabulary does not grow.
262
- * The explicit escape hatch now that contract string filters
263
- * (`openai.chat({ user })`) mean exact equality.
264
- */
265
- includes: (substring) => new Matcher("includes", { includes: substring }),
266
- /** Matches an integer (or an integer string in text contexts). */
267
- int: typed("int"),
268
- /** Matches an IPv4 address. */
269
- ip: typed("ip"),
270
- /** Matches an ISO-8601 timestamp string. */
271
- iso8601: typed("iso8601"),
272
- /** Matches a number (or a numeric string in text contexts). */
273
- number: typed("number"),
274
- /** Matches a filesystem path (`/...` or `./...`). */
275
- path: typed("path"),
276
- /** Matches a TCP/UDP port number (0-65535). */
277
- port: typed("port"),
278
- /**
279
- * Capture-and-compare. The first occurrence of `ref(name)` captures the
280
- * actual value; every later occurrence must strictly equal the capture.
281
- * `{ not: other }` additionally asserts inequality with the capture named
282
- * `other`. Scope: the current spec execution (reset per chain).
283
- */
284
- ref: (name, options) => new Matcher("ref", {
285
- notRef: options?.not,
286
- refName: name
287
- }),
288
- /** Matches a string against the given regular expression. */
289
- regex: (regex) => new Matcher("regex", { regex }),
290
- /** Matches a semantic version (`1.2.3`, `2.0.0-rc.1`). */
291
- semver: typed("semver"),
292
- /** Matches a git SHA (7-64 hex chars). */
293
- sha: typed("sha"),
294
- /** Matches any string. */
295
- string: typed("string"),
296
- /** Matches a wall-clock time (`HH:MM` or `HH:MM:SS`). */
297
- time: typed("time"),
298
- /** Matches a ULID. */
299
- ulid: typed("ulid"),
300
- /** Matches an http(s) URL. */
301
- url: typed("url"),
302
- /** Matches a UUID string. */
303
- uuid: typed("uuid"),
304
- /** Matches the exact working directory of the current spec. */
305
- workdir: typed("workdir")
306
- };
307
- /**
308
- * Named captures recorded by `match.ref()` / `{{type#ref}}` placeholders.
309
- * One scope lives on each spec result — every assertion chained off the same
310
- * result shares it, and a new chain starts fresh.
311
- */
312
- var CaptureScope = class {
313
- values = /* @__PURE__ */ new Map();
314
- /**
315
- * The exact working directory of the current spec, when the framework
316
- * knows it (cli mode). Drives the `{{workdir}}` token / `match.workdir()`.
317
- */
318
- workdir;
319
- constructor(workdir) {
320
- this.workdir = workdir;
321
- }
322
- get(name) {
323
- return this.values.get(name);
324
- }
325
- has(name) {
326
- return this.values.has(name);
327
- }
328
- set(name, value) {
329
- this.values.set(name, value);
330
- }
331
- };
332
- //#endregion
333
- //#region src/model/result/grep.ts
334
- /**
335
- * Extract text blocks from output that contain a pattern.
336
- * Splits by blank lines (how linter/compiler output is structured),
337
- * returns only blocks matching the pattern.
338
- *
339
- * @example
340
- * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
341
- * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
342
- */
343
- function grep(output, pattern) {
344
- return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
345
- }
346
- //#endregion
347
- //#region src/model/result/text.ts
348
- const ANSI_RE = /\u001B\[[0-9;]*m/gu;
349
- /** Strip ANSI escape sequences from a string. */
350
- function stripAnsiCodes(value) {
351
- return value.replace(ANSI_RE, "");
352
- }
353
- /**
354
- * Read-only accessor for a captured text handle — THE universal one: stdout,
355
- * stderr, container logs, and file text all surface as a `TextAccessor`.
356
- *
357
- * Backed by a primitive string, exposed via {@link text}, but also provides
358
- * `toString()` / `valueOf()` for string coercion, so common patterns like
359
- * `String(result.stdout)` and template-literal interpolation still work.
360
- *
361
- * ANSI escape sequences are stripped by default before every comparison
362
- * (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
363
- * an escape hatch for application noise not covered by the `{{token}}`
364
- * grammar.
365
- *
366
- * Text operations are **closed** over the type: `.grep(pattern)` returns a
367
- * `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
368
- * context and the `transform`, so results are chainable
369
- * (`result.stdout.grep(a).grep(b)`) and snapshot-able
370
- * (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
371
- *
372
- * Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
373
- * and `expect(result.stdout).toMatch('name.txt')` (resolved against
374
- * `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
375
- */
376
- var TextAccessor = class TextAccessor {
377
- /** @internal Ref-capture scope shared by the current spec execution. */
378
- captures;
379
- /** @internal Stream label used in failure messages ("stdout" / "stderr"). */
380
- streamName;
381
- /** @internal Test-file directory — fixture resolution root for matchers. */
382
- testDir;
383
- /** The raw captured text (never transformed, ANSI preserved). */
384
- text;
385
- /** @internal Normaliser applied before comparisons, never to fixtures. */
386
- transform;
387
- constructor(value, streamName, testDir, options = {}) {
388
- this.text = value;
389
- this.streamName = streamName;
390
- this.testDir = testDir;
391
- this.transform = options.transform;
392
- this.captures = options.captures ?? new CaptureScope();
393
- }
394
- /** @internal The text as compared by matchers — ANSI stripped, transform applied. */
395
- get comparableText() {
396
- const stripped = stripAnsiCodes(this.text);
397
- return this.transform ? this.transform(stripped) : stripped;
398
- }
399
- /**
400
- * Keep only the blank-line-separated blocks of the text that contain
401
- * `pattern` (how linter/compiler output is structured), returned as a new
402
- * `TextAccessor` — the same subject type, so the result is chainable and
403
- * snapshot-able. The scalpel for probing large tool outputs; snapshot the
404
- * whole surface by default, reach for `.grep()` for targeted checks.
405
- */
406
- grep(pattern) {
407
- return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
408
- captures: this.captures,
409
- transform: this.transform
410
- });
411
- }
412
- toString() {
413
- return this.text;
414
- }
415
- valueOf() {
416
- return this.text;
417
- }
418
- };
419
- //#endregion
420
- //#region src/model/matching/structural.ts
421
- /**
422
- * Structural comparison engine shared by every fixture matcher.
423
- *
424
- * Handles three kinds of "expected" values:
425
- * - plain JSON values → strict deep equality
426
- * - {@link Matcher} instances (code-side `match.*`)
427
- * - strings containing `{{placeholder}}` forms (file-side fixtures)
428
- *
429
- * One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
430
- * works in `_expected/*.http` (body and headers), `_expected/*.json`, and
431
- * text snapshots (`_expected/*.txt`).
432
- *
433
- * Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
434
- * {@link CaptureScope} supplied by the caller.
435
- */
436
- 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}";
437
- const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
438
- const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
439
- const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
440
- const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
441
- const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
442
- const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
443
- const INT_SOURCE = String.raw`-?\d+`;
444
- const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
445
- const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
446
- const SHA_SOURCE = "[0-9a-f]{7,64}";
447
- const HEX_SOURCE = "[0-9a-fA-F]+";
448
- const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
449
- 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})`;
450
- const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
451
- const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
452
- const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
453
- const EMBEDDED_SOURCES = {
454
- base64: BASE64_SOURCE,
455
- date: DATE_SOURCE,
456
- duration: DURATION_SOURCE,
457
- email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
458
- float: FLOAT_SOURCE,
459
- hex: HEX_SOURCE,
460
- int: INT_SOURCE,
461
- ip: IP_SOURCE,
462
- iso8601: ISO8601_SOURCE,
463
- number: NUMBER_SOURCE,
464
- path: String.raw`\.{0,2}\/[^\s"'<>]*`,
465
- port: PORT_SOURCE,
466
- semver: SEMVER_SOURCE,
467
- sha: SHA_SOURCE,
468
- time: TIME_SOURCE,
469
- ulid: ULID_SOURCE,
470
- url: URL_SOURCE,
471
- uuid: UUID_SOURCE
472
- };
473
- const wholeRe = (source) => new RegExp(`^(?:${source})$`, "u");
474
- const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
475
- const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "gu");
476
- /** Whether a fixture string contains at least one `{{placeholder}}`. */
477
- function hasPlaceholders(value) {
478
- PLACEHOLDER_RE.lastIndex = 0;
479
- return PLACEHOLDER_RE.test(value);
480
- }
481
- /** Key-order-independent stringification for captured-object comparison. */
482
- function stableStringify(value) {
483
- if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
484
- 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(",")}}`;
485
- return JSON.stringify(value) ?? "undefined";
486
- }
487
- function capturedEquals(a, b) {
488
- if (Object.is(a, b)) return true;
489
- if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
490
- if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
491
- return false;
492
- }
493
- function recordRef(name, actual, scope) {
494
- if (scope.has(name)) return capturedEquals(scope.get(name), actual);
495
- scope.set(name, actual);
496
- return true;
497
- }
498
- function isPortValue(value) {
499
- return Number.isInteger(value) && value >= 0 && value <= 65535;
500
- }
501
- function kindMatches(kind, actual, scope) {
502
- switch (kind) {
503
- case "any": return true;
504
- case "float":
505
- if (typeof actual === "number") return Number.isFinite(actual);
506
- return typeof actual === "string" && WHOLE_RES.float.test(actual);
507
- case "int":
508
- if (typeof actual === "number") return Number.isInteger(actual);
509
- return typeof actual === "string" && WHOLE_RES.int.test(actual);
510
- case "number":
511
- if (typeof actual === "number") return Number.isFinite(actual);
512
- return typeof actual === "string" && WHOLE_RES.number.test(actual);
513
- case "port":
514
- if (typeof actual === "number") return isPortValue(actual);
515
- return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
516
- case "string": return typeof actual === "string";
517
- case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
518
- default: {
519
- const re = WHOLE_RES[kind];
520
- return re !== void 0 && typeof actual === "string" && re.test(actual);
521
- }
522
- }
523
- }
524
- function matcherMatches(matcher, actual, scope) {
525
- if (matcher.kind === "includes") return typeof actual === "string" && actual.includes(matcher.includes);
526
- if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
527
- if (matcher.kind === "ref") {
528
- if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
529
- return recordRef(matcher.refName, actual, scope);
530
- }
531
- return kindMatches(matcher.kind, actual, scope);
532
- }
533
- function placeholderSource(kind, scope) {
534
- if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
535
- const source = EMBEDDED_SOURCES[kind];
536
- if (source) return source;
537
- return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
538
- }
539
- function escapeRegExp(text) {
540
- return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
541
- }
542
- function parsePlaceholderString(expected, scope) {
543
- PLACEHOLDER_RE.lastIndex = 0;
544
- const refs = [];
545
- let source = "";
546
- let lastIndex = 0;
547
- let single = null;
548
- let count = 0;
549
- for (const found of expected.matchAll(PLACEHOLDER_RE)) {
550
- const kind = found.groups.kind;
551
- const { ref } = found.groups;
552
- source += escapeRegExp(expected.slice(lastIndex, found.index));
553
- source += `(${placeholderSource(kind, scope)})`;
554
- refs.push({
555
- index: count,
556
- kind,
557
- ref
558
- });
559
- count++;
560
- lastIndex = found.index + found[0].length;
561
- if (found.index === 0 && found[0].length === expected.length) single = {
562
- kind,
563
- ref
564
- };
565
- }
566
- source += escapeRegExp(expected.slice(lastIndex));
567
- return {
568
- pattern: new RegExp(`^${source}$`, "u"),
569
- refs,
570
- single,
571
- source
572
- };
573
- }
574
- /**
575
- * Unanchored regex source matching a fixture string — plain text escaped
576
- * verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
577
- * declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
578
- */
579
- function placeholderPatternSource(expected) {
580
- return parsePlaceholderString(expected, new CaptureScope()).source;
581
- }
582
- function placeholderStringMatches(expected, actual, scope) {
583
- const parsed = parsePlaceholderString(expected, scope);
584
- if (parsed.single) {
585
- if (!kindMatches(parsed.single.kind, actual, scope)) return false;
586
- if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
587
- return true;
588
- }
589
- if (typeof actual !== "string" && typeof actual !== "number") return false;
590
- const text = String(actual);
591
- const found = parsed.pattern.exec(text);
592
- if (!found) return false;
593
- for (const entry of parsed.refs) {
594
- if (!entry.ref) continue;
595
- if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
596
- }
597
- return true;
598
- }
599
- function isPlainObject(value) {
600
- return value !== null && typeof value === "object" && !Array.isArray(value);
601
- }
602
- /**
603
- * Deep structural equality with matcher / placeholder support. Ref captures
604
- * are recorded in `scope` in traversal order (arrays left-to-right, object
605
- * keys in expected-key order).
606
- */
607
- function structuralEquals(expected, actual, scope) {
608
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
609
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
610
- if (Array.isArray(expected)) {
611
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
612
- return expected.every((item, i) => structuralEquals(item, actual[i], scope));
613
- }
614
- if (isPlainObject(expected)) {
615
- if (!isPlainObject(actual)) return false;
616
- const expectedKeys = Object.keys(expected);
617
- const actualKeys = Object.keys(actual);
618
- if (expectedKeys.length !== actualKeys.length) return false;
619
- return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
620
- }
621
- return Object.is(expected, actual);
622
- }
623
- /**
624
- * Deep structural SUBSET match (toMatchObject-style) with matcher /
625
- * placeholder support. Plain objects match when every expected key is present
626
- * and recursively subset-matches — the actual value may carry extra keys.
627
- * Arrays require equal length with each element subset-matched. Leaves fall
628
- * back to {@link structuralEquals} semantics (matchers, placeholders, strict
629
- * equality). Used by request-body filters on intercept triggers.
630
- */
631
- function structuralSubset(expected, actual, scope) {
632
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
633
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
634
- if (Array.isArray(expected)) {
635
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
636
- return expected.every((item, i) => structuralSubset(item, actual[i], scope));
637
- }
638
- if (isPlainObject(expected)) {
639
- if (!isPlainObject(actual)) return false;
640
- return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
641
- }
642
- return Object.is(expected, actual);
643
- }
644
- /**
645
- * Multi-line text comparison with `{{token}}` support — used by text
646
- * snapshots (`_expected/*.txt`). Without placeholders this is strict equality.
647
- */
648
- function textEquals(expected, actual, scope) {
649
- if (!hasPlaceholders(expected)) return expected === actual;
650
- return placeholderStringMatches(expected, actual, scope);
651
- }
652
- //#endregion
653
- //#region src/model/contracts/queue.ts
654
- /**
655
- * The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
656
- * Both engines consume it: the MSW integration on api/jobs chains, and the
657
- * declared stub backend on website/mobile chains.
658
- *
659
- * Selection: the FIRST contract in declaration order that matches the observed
660
- * request and is not exhausted. A contract with no `times` is unlimited (a
661
- * re-render or a retry replays it); `times: n` is spent after n serves, so an
662
- * ordered sequence is written as finite contracts before an unlimited tail:
663
- * `[error500 ×3, ok]`.
664
- *
665
- * Strictness (CONVENTIONS D7): when nothing matches — including "everything
666
- * that matches is exhausted" — the engine answers 501, records the request,
667
- * and the chain fails with {@link ContractQueue.unmatchedError}. A chain that
668
- * declared zero contracts is not guarded at all (unchanged boundary).
669
- */
670
- /** Base used to parse origin-relative observed URLs (`/events?x=1`). */
671
- const RELATIVE_BASE = "http://contract.invalid";
672
- /** Split a declared url into its base (origin+path, or path) and query halves.
673
- * Never through `new URL()`, which would percent-encode `{{token}}` braces. */
674
- function splitDeclared(url) {
675
- const separator = url.indexOf("?");
676
- if (separator === -1) return {
677
- base: url,
678
- query: new URLSearchParams()
679
- };
680
- return {
681
- base: url.slice(0, separator),
682
- query: new URLSearchParams(url.slice(separator + 1))
683
- };
684
- }
685
- /** Decode percent-escapes, falling back to the raw text on malformed input. */
686
- function safeDecode(text) {
687
- try {
688
- return decodeURIComponent(text);
689
- } catch {
690
- return text;
691
- }
692
- }
693
- /** Token-aware comparison of one declared value against an observed one. */
694
- function valueMatches(declared, observed) {
695
- return textEquals(declared, observed, new CaptureScope());
696
- }
697
- /**
698
- * Does an observed URL satisfy a declared one? A RegExp tests the full URL; a
699
- * PATH FORM (`/articles/{{uuid}}`) ignores the origin; an absolute string
700
- * compares origin+pathname. In both string forms `{{token}}` segments compare
701
- * structurally and declared query params are a SUBSET of the observed ones.
702
- */
703
- function urlMatches(declared, observed) {
704
- if (declared instanceof RegExp) return declared.test(observed);
705
- let url;
706
- try {
707
- url = new URL(observed, RELATIVE_BASE);
708
- } catch {
709
- return false;
710
- }
711
- const { base, query } = splitDeclared(declared);
712
- const observedBase = base.startsWith("/") ? url.pathname : `${url.origin}${url.pathname}`;
713
- const decodedBase = base.startsWith("/") ? safeDecode(url.pathname) : `${url.origin}${safeDecode(url.pathname)}`;
714
- if (!valueMatches(base, observedBase) && !valueMatches(base, decodedBase)) return false;
715
- for (const key of new Set(query.keys())) {
716
- const observedValues = url.searchParams.getAll(key);
717
- if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
718
- }
719
- return true;
720
- }
721
- /**
722
- * Does an observed request satisfy a contract? Method (`*` matches any), URL,
723
- * then the contract's own `match` predicate (headers/query/body filters).
724
- */
725
- function contractMatches(contract, request) {
726
- const { match, method, url } = contract.request;
727
- if (method !== "*" && method.toUpperCase() !== request.method.toUpperCase()) return false;
728
- if (!urlMatches(url, request.url)) return false;
729
- return match ? match(request) : true;
730
- }
731
- /**
732
- * The URL pattern an out-of-process router (MSW) must register so the request
733
- * REACHES the queue. Deliberately no narrower than {@link contractMatches} —
734
- * the predicate above stays the authority on what actually matches.
735
- */
736
- function routePatternOf(declared) {
737
- if (declared instanceof RegExp) return declared;
738
- const { base } = splitDeclared(declared);
739
- if (base.startsWith("/")) return new RegExp(String.raw`^https?:\/\/[^/?#]+${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
740
- if (hasPlaceholders(base)) return new RegExp(String.raw`^${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
741
- return base;
742
- }
743
- /** How a declared contract reads in a failure message: spent, served, or silent. */
744
- function stateOf({ contract, served }) {
745
- const { times } = contract;
746
- if (times !== void 0 && served >= times) return ` (exhausted after ${times})`;
747
- return served === 0 ? "" : ` (served ${served} time(s))`;
748
- }
749
- var ContractQueue = class {
750
- entries;
751
- constructor(contracts) {
752
- this.entries = contracts.map((contract) => ({
753
- contract,
754
- served: 0
755
- }));
756
- }
757
- /** How many contracts the chain declared. */
758
- get size() {
759
- return this.entries.length;
760
- }
761
- /**
762
- * Routes to register with an out-of-process router, deduped by pattern —
763
- * two contracts on the same URL share one handler and one queue.
764
- */
765
- get routes() {
766
- const routes = /* @__PURE__ */ new Map();
767
- for (const { contract } of this.entries) {
768
- const url = routePatternOf(contract.request.url);
769
- const key = String(url);
770
- const route = routes.get(key) ?? {
771
- methods: [],
772
- url
773
- };
774
- if (!route.methods.includes(contract.request.method)) route.methods.push(contract.request.method);
775
- routes.set(key, route);
776
- }
777
- return [...routes.values()];
778
- }
779
- /** The declared routes, in order — the enumeration a 501 body carries. */
780
- declaredRoutes() {
781
- return this.entries.map(({ contract }) => describeRoute(contract));
782
- }
783
- /**
784
- * Serve the first contract in declaration order that matches and is not
785
- * exhausted, or null when nothing matches (including "everything that
786
- * matches is spent") — the strict-violation signal.
787
- */
788
- take(request) {
789
- for (const entry of this.entries) {
790
- const { times } = entry.contract;
791
- if (times !== void 0 && entry.served >= times) continue;
792
- if (!contractMatches(entry.contract, request)) continue;
793
- entry.served += 1;
794
- return entry.contract;
795
- }
796
- return null;
797
- }
798
- /**
799
- * The chain-end failure for contracts declared `required: true` that were
800
- * never requested (or not exactly `times` times), or null when every
801
- * requirement held.
802
- */
803
- requiredError() {
804
- const unmet = [];
805
- for (const { contract, served } of this.entries) {
806
- if (!contract.required) continue;
807
- const { times } = contract;
808
- if (times === void 0) {
809
- if (served === 0) unmet.push(` - ${describeRoute(contract)} — declared and required but never requested`);
810
- } else if (served !== times) unmet.push(` - ${describeRoute(contract)} — declared required with times: ${times} but was requested ${served} time(s)`);
811
- }
812
- if (unmet.length === 0) return null;
813
- return /* @__PURE__ */ new Error(`Required contract(s) never satisfied during the chain:\n${unmet.join("\n")}\nA required contract states the call MUST happen — either the code no longer makes it, or the contract no longer describes it.`);
814
- }
815
- /**
816
- * The strict failure for a request that matched no contract (CONVENTIONS
817
- * D7): method + URL of the offending request, plus every declared contract
818
- * and how often it was served.
819
- */
820
- unmatchedError(method, url) {
821
- const declared = this.entries.length === 0 ? " (no contracts declared)" : this.entries.map((entry) => ` - ${describeRoute(entry.contract)}${stateOf(entry)}`).join("\n");
822
- return /* @__PURE__ */ new Error(`Unmatched outgoing HTTP request during spec: ${method} ${url}\nDeclared contracts:\n${declared}\nEvery outgoing request of a chain that declares contracts must match one — add a contract for it (or raise its \`times\` when it is exhausted).`);
823
- }
824
- };
825
- //#endregion
826
- //#region src/model/timing/pause.ts
827
- /**
828
- * One answer to "wait" — the only `setTimeout` the model owns.
829
- *
830
- * Two things in the framework genuinely have to wait: the spacing between the
831
- * pieces of a declared stream, and the poll behind `waitUntil`. Everything
832
- * else that waits is a spec doing it by hand, which is what J2 refuses. Stated
833
- * once so there is one place to look when time stops behaving.
834
- */
835
- async function pause(ms) {
836
- await new Promise((resolve) => {
837
- setTimeout(resolve, ms);
838
- });
839
- }
840
- //#endregion
841
- //#region src/model/contracts/stream.ts
842
- /**
843
- * A declared stream, made real — ONE serialisation for all three engines.
844
- *
845
- * msw's server and msw's worker take a `ReadableStream` body verbatim
846
- * (https://mswjs.io/docs/http/mocking-responses/streaming) and the `node:http`
847
- * stub pipes one into its response, so what "arriving in pieces" MEANS —
848
- * the order, the spacing, the close after the last chunk — is stated here and
849
- * nowhere else. A second copy of it would be a second answer to "what does the
850
- * subject read", and the facets would drift.
851
- */
852
- /**
853
- * The pieces of `body`, in order, `delayBetweenChunks` milliseconds apart,
854
- * closing after the last one.
855
- */
856
- function toReadableStream(body) {
857
- const encoder = new TextEncoder();
858
- let index = 0;
859
- return new ReadableStream({ async pull(controller) {
860
- const chunk = body.chunks[index];
861
- if (chunk === void 0) {
862
- controller.close();
863
- return;
864
- }
865
- if (body.delayBetweenChunks > 0 && index > 0) await pause(body.delayBetweenChunks);
866
- index += 1;
867
- controller.enqueue(encoder.encode(chunk));
868
- } });
869
- }
870
- //#endregion
871
- //#region src/model/contracts/types.ts
872
- /**
873
- * The marker that tells a STREAMED body from a value that happens to be an
874
- * object. A body reaches three different engines (msw's server, msw's worker,
875
- * the `node:http` stub) and each of them serialises "an object" as JSON, so a
876
- * stream declared as a plain value arrives as `{}`. The tag is what each
877
- * engine recognises before it reaches for `JSON.stringify`.
878
- */
879
- const STREAM_BODY = "@jterrazz/test:stream";
880
- /** Is this body a declared stream rather than a value to serialise? */
881
- function isStreamBody(body) {
882
- return typeof body === "object" && body !== null && "kind" in body && body.kind === "@jterrazz/test:stream";
883
- }
884
- //#endregion
885
- //#region src/seams/msw/handlers.ts
886
- /**
887
- * A contract answers the request that was made, never a copy of an earlier one.
888
- *
889
- * In a page the reply travels through a real service worker and a real HTTP
890
- * cache, so a second render of the same component can be served the FIRST
891
- * test's answer and never reach the queue at all — a contract that was
892
- * exhausted, or replaced, silently keeps answering. Stated as a default, so a
893
- * spec whose subject IS caching can still say otherwise.
894
- */
895
- const NEVER_CACHED = { "cache-control": "no-store" };
896
- /** Turn a contract response into the msw reply, body kind by body kind. */
897
- function toMswResponse(msw, response) {
898
- const { body, headers, status = 200 } = response;
899
- if (response.transport === "network-error") return msw.HttpResponse.error();
900
- if (body === null || body === void 0) return new msw.HttpResponse(null, {
901
- headers: {
902
- ...NEVER_CACHED,
903
- ...headers
904
- },
905
- status
906
- });
907
- if (isStreamBody(body)) return new msw.HttpResponse(toReadableStream(body), {
908
- headers: {
909
- ...NEVER_CACHED,
910
- "content-type": body.contentType,
911
- ...headers
912
- },
913
- status
914
- });
915
- if (typeof body === "string") return new msw.HttpResponse(body, {
916
- headers: {
917
- ...NEVER_CACHED,
918
- "content-type": "text/plain; charset=utf-8",
919
- ...headers
920
- },
921
- status
922
- });
923
- return msw.HttpResponse.json(body, {
924
- headers: {
925
- ...NEVER_CACHED,
926
- ...headers
927
- },
928
- status
929
- });
930
- }
931
- /**
932
- * The handlers one chain's contracts become, plus the trailing catch-all that
933
- * records any request no contract accepted (CONVENTIONS D7). The caller decides
934
- * what to do with the recorded violation — the api chain rethrows it, the
935
- * component chain fails the render. An EMPTY contract list is still a guard:
936
- * the catch-all alone says "this subject has no network".
937
- *
938
- * `bypass` names the traffic that is not the subject's: under node there is
939
- * none, in a page it is everything the runner fetches to BE a page.
940
- */
941
- function buildContractHandlers(msw, contracts, bypass) {
942
- const queue = new ContractQueue(contracts);
943
- let violation = null;
944
- const recordViolation = (method, url) => {
945
- violation ??= queue.unmatchedError(method, url);
946
- return msw.HttpResponse.json({ error: `@jterrazz/test strict contracts: unmatched request ${method} ${url}` }, {
947
- headers: NEVER_CACHED,
948
- status: 501
949
- });
950
- };
951
- const handlers = [];
952
- for (const route of queue.routes) for (const method of route.methods) {
953
- const handlerFn = method === "*" ? msw.http.all : msw.http[method.toLowerCase()];
954
- if (!handlerFn) continue;
955
- handlers.push(handlerFn(route.url, async ({ request }) => {
956
- const observed = await observe(request);
957
- const contract = queue.take(observed);
958
- if (!contract) return recordViolation(request.method, request.url);
959
- const response = typeof contract.response === "function" ? contract.response(observed) : contract.response;
960
- if (response.delay) await new Promise((resolve) => setTimeout(resolve, response.delay));
961
- return toMswResponse(msw, response);
962
- }));
963
- }
964
- handlers.push(msw.http.all("*", ({ request }) => bypass?.(request.url) === true ? msw.passthrough() : recordViolation(request.method, request.url)));
965
- return {
966
- handlers,
967
- violation: () => violation ?? queue.requiredError()
968
- };
969
- }
970
- /** The request as the queue matches it — body parsed when it is JSON. */
971
- async function observe(request) {
972
- let body = null;
973
- const rawText = await request.clone().text();
974
- if (rawText) try {
975
- body = JSON.parse(rawText);
976
- } catch {
977
- body = rawText;
978
- }
979
- const headers = {};
980
- request.headers.forEach((value, key) => {
981
- headers[key.toLowerCase()] = value;
982
- });
983
- return {
984
- body,
985
- headers,
986
- method: request.method.toUpperCase(),
987
- url: request.url
988
- };
989
- }
990
- //#endregion
991
- //#region src/seams/msw/worker.ts
992
- /**
993
- * What the runner itself fetches. Everything else a page asks for is the
994
- * subject's own traffic and answers to its contracts (D7).
995
- */
996
- const RUNNER_PREFIXES = [
997
- "/.artifacts/",
998
- "/@fs/",
999
- "/@id/",
1000
- "/@vite",
1001
- "/node_modules/",
1002
- "/__vitest"
1003
- ];
1004
- let workerInstance = null;
1005
- /**
1006
- * Is this the runner's own traffic rather than the subject's?
1007
- *
1008
- * In a page EVERY module is an HTTP request: the test file, the seam, the
1009
- * dependency cache Vite optimised, the runner's own channel. None of them is
1010
- * the component's network, and none of them can be declared by a contract, so
1011
- * they are recognised by PREFIX — never by extension, because msw bypasses a
1012
- * "common asset" path before `onUnhandledRequest` ever runs and an extension
1013
- * test would let a real `/api/posts.json` through on the same ground.
1014
- */
1015
- function isRunnerRequest(url) {
1016
- const { pathname } = new URL(url);
1017
- return RUNNER_PREFIXES.some((prefix) => pathname.startsWith(prefix));
1018
- }
1019
- /**
1020
- * Start the worker once per page. The script is served at `/mockServiceWorker.js`
1021
- * by the plugin `component()` registers, out of the package's OWN `msw`
1022
- * install — so no consumer ever runs `msw init` and no consumer declares `msw`.
1023
- */
1024
- async function ensureContractWorker() {
1025
- if (workerInstance !== null) return;
1026
- const { setupWorker } = await import("msw/browser");
1027
- workerInstance = setupWorker();
1028
- await workerInstance.start({
1029
- onUnhandledRequest: (request, print) => {
1030
- if (!isRunnerRequest(request.url)) print.error();
1031
- },
1032
- quiet: true,
1033
- serviceWorker: { url: "/mockServiceWorker.js" }
1034
- });
1035
- }
1036
- /**
1037
- * Register one render's contracts on the page's worker.
1038
- *
1039
- * The worker starts even when the list is empty: on this facet D7 is TOTAL —
1040
- * a component that was given no contract has no network, and a fetch it makes
1041
- * anyway is named by {@link ContractRegistration.violation} rather than
1042
- * answered by whatever the dev server happens to have at that path.
1043
- */
1044
- async function registerWorkerContracts(contracts) {
1045
- await ensureContractWorker();
1046
- const { handlers, violation } = buildContractHandlers(await import("msw"), contracts, isRunnerRequest);
1047
- workerInstance.use(...handlers);
1048
- return {
1049
- cleanup: () => {
1050
- workerInstance.resetHandlers();
1051
- },
1052
- violation
1053
- };
1054
- }
1055
- /** Drop every handler the page still carries — the per-test reset. */
1056
- function resetWorkerContracts() {
1057
- if (workerInstance !== null) workerInstance.resetHandlers();
1058
- }
1059
- //#endregion
1060
- //#region src/model/chain/ground.ts
1061
- /**
1062
- * The GROUND a row of specs stands on — the naming law of a spec tree.
1063
- *
1064
- * In a row of specs, a MEMBER is a spec: `<aspect>.test.ts`, `<case>.spec.yaml`,
1065
- * `<name>.specification.ts`, and the facet/domain folders that hold them.
1066
- * Everything else in that row is what the members STAND ON — inert material the
1067
- * framework resolves BY PATH, never imported, never executed — and it carries a
1068
- * LEADING UNDERSCORE. A spec's own folder never does.
1069
- *
1070
- * Four names, and only four: `_fixtures/` (file state), `_expected/` (goldens),
1071
- * `_requests/` (inputs), `_seeds/` (database state). `contracts/` is NOT ground —
1072
- * a contract is TypeScript a spec imports, behaviour composed with the spec's own,
1073
- * so it stays a member. `docker/` is not ground either: it sits at the PROJECT
1074
- * root beside `package.json`, outside any row of specs.
1075
- *
1076
- * This module is the single home of those names. Every resolver, matcher, lint
1077
- * rule and checker pass reads them from here, so the runtime and the static
1078
- * channels cannot disagree about where a spec's ground lives.
1079
- */
1080
- /** Goldens: every expected fixture, flat, extension included. */
1081
- const GROUND_EXPECTED = "_expected";
1082
- //#endregion
1083
- //#region src/model/chain/reporter.ts
1084
- const GREEN = "\x1B[32m";
1085
- const RED = "\x1B[31m";
1086
- const RESET = "\x1B[0m";
1087
- const strictEquals = (expected, actual) => expected === actual;
1088
- function renderLines(expectedLines, actualLines, equals) {
1089
- const rendered = [];
1090
- let marked = 0;
1091
- const maxLines = Math.max(expectedLines.length, actualLines.length);
1092
- for (let i = 0; i < maxLines; i++) {
1093
- const exp = expectedLines[i];
1094
- const act = actualLines[i];
1095
- if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
1096
- rendered.push(` ${exp}`);
1097
- continue;
1098
- }
1099
- marked++;
1100
- if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
1101
- if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
1102
- }
1103
- return {
1104
- marked,
1105
- rendered
1106
- };
1107
- }
1108
- /**
1109
- * Render an expected/received diff over two texts.
1110
- *
1111
- * `equals` is how a line is judged EQUAL. Callers that compare through the
1112
- * `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
1113
- * matched is shown as equal — with its token, not as a `-`/`+` pair against
1114
- * the concrete URL. Marking a line the comparison ACCEPTED buries the one it
1115
- * rejected: the reader then has to work out which of the differences is the
1116
- * actual cause.
1117
- *
1118
- * Safety net: if the token-aware pass marks nothing at all, the whole diff
1119
- * would render as a clean block against a failure that really happened (a
1120
- * `#ref` captured inconsistently across lines, say). The strict rendering is
1121
- * used instead, so a diff always shows the reader something.
1122
- */
1123
- function formatStdoutDiff(file, expected, actual, options) {
1124
- const lines = [];
1125
- lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1126
- const expectedLines = expected.split("\n");
1127
- const actualLines = actual.split("\n");
1128
- const equals = options?.equals ?? strictEquals;
1129
- const first = renderLines(expectedLines, actualLines, equals);
1130
- const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
1131
- return [...lines, ...rendered].join("\n");
1132
- }
1133
- //#endregion
1134
- //#region src/model/result/text-assertions.ts
1135
- /** The fixture name carries its extension — the name IS the file. */
1136
- function requireExtension(name, subject) {
1137
- if (!/\.[A-Za-z0-9]+$/u.test(name)) throw new Error(`toMatch("${name}"): the extension is part of the name and is required for ${subject} subjects (e.g. "help.txt").`);
1138
- }
1139
- /**
1140
- * Compare a captured stream against the CONTENT of its golden. Text snapshots
1141
- * share the unified `{{token}}` grammar (CONVENTIONS D4), and the diff judges
1142
- * each line through the SAME grammar the comparison used — so a token line
1143
- * that matched is shown as equal instead of competing with the real mismatch
1144
- * for the reader's attention.
1145
- */
1146
- function compareStreamText(accessor, name, expected) {
1147
- const actual = accessor.comparableText;
1148
- if (textEquals(expected, actual, accessor.captures)) return {
1149
- message: () => `expected ${accessor.streamName} not to match _expected/${name}`,
1150
- pass: true
1151
- };
1152
- return {
1153
- message: () => formatStdoutDiff(name, expected, actual, { equals: (expectedLine, actualLine) => textEquals(expectedLine, actualLine, new CaptureScope(accessor.captures.workdir)) }),
1154
- pass: false
1155
- };
1156
- }
1157
- /** `expect(stream).toBeEmpty()` — the stream you usually want silent. */
1158
- function textIsEmpty(accessor) {
1159
- const content = accessor.comparableText;
1160
- const pass = content === "";
1161
- return {
1162
- message: () => pass ? `expected ${accessor.streamName} not to be empty` : `Expected ${accessor.streamName} to be empty, but it contains:\n${content}`,
1163
- pass
1164
- };
1165
- }
1166
- /** `expect(stream).toContain('…')` — the scalpel, never the default (D11). */
1167
- function textContains(accessor, expected) {
1168
- const actual = accessor.comparableText;
1169
- const pass = actual.includes(expected);
1170
- return {
1171
- message: () => pass ? `expected ${accessor.streamName} not to contain ${JSON.stringify(expected)}` : `${accessor.streamName} does not contain expected substring.\n expected to contain: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual.length > 500 ? `${actual.slice(0, 500)}…` : actual)}`,
1172
- pass
1173
- };
1174
- }
1175
- /**
1176
- * `toMatch` and `toContain` on a subject that is NOT an accessor.
1177
- *
1178
- * Both matchers are overridden globally, for every subject in the project, so
1179
- * whatever the framework does not own has to keep meaning what vitest's own
1180
- * matcher meant. Stated once here because the two builds register two different
1181
- * `expect.extend` calls: a copy in the page is how `expect(['ab']).toContain('b')`
1182
- * ends up passing on one side of the seam and failing on the other.
1183
- */
1184
- /** vitest's `toMatch` for a plain string: a substring, or a regular expression. */
1185
- function nativeMatch(received, expected) {
1186
- if (typeof received !== "string") throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
1187
- const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
1188
- return {
1189
- message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
1190
- pass
1191
- };
1192
- }
1193
- /** vitest's `toContain` for a plain string (substring) or an iterable (membership). */
1194
- function nativeContain(received, expected) {
1195
- if (typeof received === "string") {
1196
- const pass = received.includes(String(expected));
1197
- return {
1198
- message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1199
- pass
1200
- };
1201
- }
1202
- if (isIterable(received)) {
1203
- const pass = [...received].includes(expected);
1204
- return {
1205
- message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1206
- pass
1207
- };
1208
- }
1209
- throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
1210
- }
1211
- /** Does this value hand out its members? A Set and an array both do; a DOM node does not. */
1212
- function isIterable(value) {
1213
- return value !== null && value !== void 0 && typeof value[Symbol.iterator] === "function";
1214
- }
1215
- //#endregion
1216
- //#region src/model/elements/ambiguity.ts
1217
- /**
1218
- * The ambiguity refusal — CONVENTIONS W3.
1219
- *
1220
- * A visit descriptor must designate exactly one element. Acting on "the
1221
- * first match" is the failure mode this module exists to prevent: the spec
1222
- * keeps passing while the visitor clicks something else, and nothing ever
1223
- * reports it. So when a descriptor matches several elements the framework
1224
- * refuses, and the refusal has to carry everything needed to fix it without
1225
- * opening a browser — the descriptor in the caller's own vocabulary, every
1226
- * candidate, and the concrete rewrites that would resolve it.
1227
- *
1228
- * Pure string building: it takes captured data and returns a message, so the
1229
- * wording is unit-testable and the browser integration stays a thin adapter.
1230
- */
1231
- /** `kind` → the constructor that builds it, so errors echo the caller's source. */
1232
- const CONSTRUCTORS = {
1233
- banner: "banner",
1234
- button: "button",
1235
- complementary: "complementary",
1236
- contentinfo: "contentinfo",
1237
- dialog: "dialog",
1238
- field: "field",
1239
- form: "form",
1240
- heading: "heading",
1241
- link: "link",
1242
- listitem: "listitem",
1243
- main: "main",
1244
- navigation: "navigation",
1245
- option: "option",
1246
- region: "region",
1247
- row: "row",
1248
- search: "search",
1249
- status: "status",
1250
- table: "table",
1251
- testId: "testId",
1252
- text: "content"
1253
- };
1254
- /** The landmark a context string maps back to, for a copy-pasteable suggestion. */
1255
- const CONTEXT_LANDMARKS = {
1256
- aside: "complementary()",
1257
- banner: "banner()",
1258
- complementary: "complementary()",
1259
- contentinfo: "contentinfo()",
1260
- footer: "contentinfo()",
1261
- form: "form()",
1262
- header: "banner()",
1263
- main: "main()",
1264
- nav: "navigation()",
1265
- navigation: "navigation()",
1266
- search: "search()"
1267
- };
1268
- /**
1269
- * Render a descriptor as the source that would build it — `link('Articles')`,
1270
- * `within(navigation(), link('Articles', { exact: false }))`. The error speaks
1271
- * the vocabulary the author wrote, never playwright's — so the option appears
1272
- * only when the AUTHOR stated one, whichever way.
1273
- */
1274
- function formatElement(element) {
1275
- const bare = formatBare(element);
1276
- return element.scope ? `within(${formatElement(element.scope)}, ${bare})` : bare;
1277
- }
1278
- function formatBare(element) {
1279
- const args = [];
1280
- if (element.name !== void 0) args.push(JSON.stringify(element.name));
1281
- if (element.exact !== void 0) args.push(`{ exact: ${String(element.exact)} }`);
1282
- return `${CONSTRUCTORS[element.kind]}(${args.join(", ")})`;
1283
- }
1284
- /** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
1285
- function formatMatch(match, index) {
1286
- const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
1287
- const named = match.accessibleName ? ` named ${JSON.stringify(match.accessibleName)}` : "";
1288
- const context = match.context ? ` in <${match.context}>` : "";
1289
- return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${named}${context}`;
1290
- }
1291
- function quoteDetail(match) {
1292
- return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
1293
- }
1294
- /** Each landmark the candidates sit in, and how many of them it holds. */
1295
- function landmarkCounts(matches) {
1296
- const counts = /* @__PURE__ */ new Map();
1297
- for (const match of matches) {
1298
- const landmark = match.context === void 0 ? void 0 : CONTEXT_LANDMARKS[match.context];
1299
- if (landmark !== void 0) counts.set(landmark, (counts.get(landmark) ?? 0) + 1);
1300
- }
1301
- return [...counts].toSorted(([, a], [, b]) => a - b);
1302
- }
1303
- /**
1304
- * The rewrites worth offering, in the order they should be tried. Scoping is
1305
- * always available; `exact` only when it would actually narrow the set, and
1306
- * the count it would leave is stated so a still-ambiguous suggestion never
1307
- * reads as a fix.
1308
- */
1309
- function formatFixes(element, matches) {
1310
- const fixes = [];
1311
- const narrowing = landmarkCounts(matches).filter(([, count]) => count < matches.length);
1312
- if (!element.scope) {
1313
- const [first, ...rest] = narrowing;
1314
- const alsoHere = rest.length > 0 ? ` [also here: ${rest.map(([name]) => name).join(", ")}]` : "";
1315
- fixes.push(first === void 0 ? `scope it within(<a container holding only this one>, ${formatBare(element)})` : `scope it within(${first[0]}, ${formatBare(element)}) [leaves ${first[1]} of ${matches.length}]${alsoHere}`);
1316
- }
1317
- if (element.exact === false && element.name !== void 0) {
1318
- const remaining = matches.filter((match) => match.text === element.name).length;
1319
- if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
1320
- ...element,
1321
- exact: true
1322
- })} [leaves ${remaining} of ${matches.length}]`);
1323
- }
1324
- fixes.push("other element a heading(), button() or field() may name one thing where this does not");
1325
- return fixes;
1326
- }
1327
- /**
1328
- * The W3 refusal. A distinct class so the visit wrapper can recognize an
1329
- * already-complete message and let it through instead of nesting it inside
1330
- * `visit scenario failed: …`, which would print the whole thing twice.
1331
- */
1332
- var AmbiguousElementError = class extends Error {
1333
- name = "AmbiguousElementError";
1334
- };
1335
- /**
1336
- * Build the refusal thrown when a descriptor matches more than one element.
1337
- *
1338
- * @param options.element The descriptor as the caller wrote it.
1339
- * @param options.matches Every candidate, in DOM order (already truncated).
1340
- * @param options.url The page the visitor was on when it happened.
1341
- */
1342
- function describeAmbiguity(options) {
1343
- const { element, matches, url } = options;
1344
- const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
1345
- return [
1346
- `Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on ${url}.`,
1347
- "",
1348
- "A spec must designate exactly one element. Acting on the first match would let",
1349
- "this test keep passing while the visitor interacts with something else.",
1350
- "",
1351
- "Matched:",
1352
- ...matches.map((match, index) => formatMatch(match, index)),
1353
- "",
1354
- "Disambiguate with one of:",
1355
- ...fixes,
1356
- "",
1357
- "Docs: docs/13-elements.md#designating-exactly-one-element (CONVENTIONS W3)"
1358
- ].join("\n");
1359
- }
1360
- //#endregion
1361
- //#region src/model/elements/substring-warning.ts
1362
- /**
1363
- * The transitional warning for a name that matches only as a SUBSTRING.
1364
- *
1365
- * Until 16.0 a descriptor's name matched any element whose accessible name
1366
- * CONTAINED it, so `link('Articles')` also designated "Read Articles" — and a
1367
- * test could pass for years against the wrong element without anyone noticing,
1368
- * because the wrong element was usually next to the right one. Exact is the
1369
- * default now.
1370
- *
1371
- * A lint rule cannot carry this: whether a name matched whole or in part is a
1372
- * fact about a RUN, and a verdict fed by a previous run's artefact is not
1373
- * deterministic. So it is a runtime warning, printed by the adapter that did
1374
- * the looking, at the moment it has both answers in hand — nothing found under
1375
- * exact, something found under substring.
1376
- *
1377
- * It is printed ONCE per descriptor per process: a scenario that clicks the
1378
- * same link in ten tests has one thing to fix, not ten lines to read.
1379
- */
1380
- /** How long the window lasts, stated in the message itself. */
1381
- const WINDOW = "through 16.x";
1382
- /** Descriptors already reported in this process. */
1383
- const reported = /* @__PURE__ */ new Set();
1384
- /** The identity a descriptor is deduplicated by — kind, name, and its scope chain. */
1385
- function identityOf(element) {
1386
- const own = `${element.kind}:${element.name ?? ""}`;
1387
- return element.scope === void 0 ? own : `${identityOf(element.scope)}>${own}`;
1388
- }
1389
- /**
1390
- * Report that `element` designated nothing as a whole name, but would have
1391
- * designated something as a substring. Returns whether a line was printed,
1392
- * which is what the adapters' own tests assert on.
1393
- */
1394
- function warnSubstringOnly(element, where, found) {
1395
- const identity = identityOf(element);
1396
- if (reported.has(identity)) return false;
1397
- reported.add(identity);
1398
- const spelling = found === void 0 ? "" : ` The name to write is '${found.replaceAll(/\s+/gu, " ").trim()}'.`;
1399
- console.warn(`@jterrazz/test: ${element.kind}('${element.name ?? ""}') matched nothing as a whole accessible name, and matched only as a SUBSTRING of a longer one (${where}).${spelling} Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. The substring is still resolved, with this warning, ${WINDOW}; gone in 17.0, where the same descriptor is simply not found.`);
1400
- return true;
1401
- }
1402
- /**
1403
- * The descriptor to retry with, LEVEL by level, or `undefined` when nothing
1404
- * would change.
1405
- *
1406
- * Every level of the chain is asked the same question the target is asked —
1407
- * `within(button('Proof of authorship'), content('Verified'))` against a
1408
- * button named "Proof of authorship Verified" failed on the SCOPE, and a
1409
- * warning that only ever widened the target printed nothing at all. The chain
1410
- * is walked outside-in and rebuilt as it goes, since an inner level's answer
1411
- * is meaningless under a scope that designates nothing.
1412
- *
1413
- * A level that states an `exact` of its own is left alone: an author who wrote
1414
- * `{ exact: false }` chose the substring, and one who wrote `{ exact: true }`
1415
- * was already exact.
1416
- *
1417
- * Asked BEFORE the action, not after it fails: a verb waits its whole
1418
- * actionability budget, which is the test's own budget, so a retry afterwards
1419
- * never runs — the test is already over. The probes are counts, which wait for
1420
- * nothing, and the common case (the name matches whole) costs one per level.
1421
- */
1422
- async function widenedForWindow(element, where, probe) {
1423
- const chain = [];
1424
- for (let level = element; level; level = level.scope) chain.unshift(level);
1425
- let rebuilt;
1426
- let widened = false;
1427
- for (const level of chain) {
1428
- const candidate = {
1429
- ...level,
1430
- ...rebuilt === void 0 ? {} : { scope: rebuilt }
1431
- };
1432
- const loose = await widenedLevel(candidate, where, probe);
1433
- widened ||= loose !== void 0;
1434
- rebuilt = loose ?? candidate;
1435
- }
1436
- return widened ? rebuilt : void 0;
1437
- }
1438
- /**
1439
- * The one level, widened and warned about, or `undefined` when it designates
1440
- * something already, states its own `exact`, carries no name, or would
1441
- * designate nothing either way.
1442
- */
1443
- async function widenedLevel(candidate, where, probe) {
1444
- if (candidate.exact !== void 0 || candidate.name === void 0) return;
1445
- try {
1446
- if (await probe.count(candidate) > 0) return;
1447
- const loose = {
1448
- ...candidate,
1449
- exact: false
1450
- };
1451
- if (await probe.count(loose) !== 1) return;
1452
- warnSubstringOnly(candidate, where, await probe.nameOf(loose));
1453
- return loose;
1454
- } catch {
1455
- return;
1456
- }
1457
- }
1458
- //#endregion
1459
- //#region src/seams/vitest-browser/vitest-browser.adapter.ts
1460
- /**
1461
- * The browser-mode adapter — the component facet's half of the one element
1462
- * vocabulary.
1463
- *
1464
- * Its translation table is the playwright adapter's, descriptor for
1465
- * descriptor: a `button('Save')` means `getByRole('button', { name })` whether
1466
- * the surface is a served page or a mounted component, and the W3 refusal
1467
- * enumerates candidates in the same words. Two adapters, one dialect — which
1468
- * is the whole reason a page spec and a component spec read alike.
1469
- *
1470
- * What differs is where the code runs. Playwright drives a page from the
1471
- * outside and reads it through `evaluate`; here the test IS in the page, so a
1472
- * candidate is described from the live element and the ARIA tree is the one
1473
- * thing that has to go back out (`server.commands`, the provider's own frame).
1474
- */
1475
- /** How many candidates the ambiguity error enumerates before truncating. */
1476
- const MAX_REPORTED_MATCHES = 10;
1477
- /** The landmark ancestors the ambiguity evidence names. */
1478
- const LANDMARKS = "nav, header, footer, main, aside, section, form, [role]";
1479
- /**
1480
- * The roles a FIELD can carry — what `field('Name')` designates.
1481
- *
1482
- * A field is named by its ACCESSIBLE name, not by the text of the label
1483
- * element that happens to wrap it: `<label><span>Channel</span><select>…` has
1484
- * a label whose text carries every option of the select, while the control's
1485
- * accessible name is `Channel` — which is what the ARIA tree shows and what a
1486
- * screen reader reads. Resolving by label text alone made the ordinary
1487
- * wrapping-label form unreachable under the 16.0 whole-name default.
1488
- *
1489
- * The label-text locator stays in the union for the controls ARIA gives no
1490
- * role at all (a date or colour input), which nothing else would find.
1491
- */
1492
- const FIELD_ROLES = [
1493
- "checkbox",
1494
- "combobox",
1495
- "radio",
1496
- "searchbox",
1497
- "slider",
1498
- "spinbutton",
1499
- "switch",
1500
- "textbox"
1501
- ];
1502
- /**
1503
- * Translate a user-facing descriptor into a Browser Mode locator, resolving
1504
- * `scope` outside-in so `within(navigation(), link('X'))` searches the nav
1505
- * subtree. No `.first()` anywhere: designating exactly one element is
1506
- * CONVENTIONS W3, and taking the first match is the silent wrong-element bug
1507
- * the rule exists to prevent.
1508
- */
1509
- function locate(root, element) {
1510
- const scope = element.scope ? locate(root, element.scope) : root;
1511
- const exact = element.exact ?? true;
1512
- const name = element.name ?? "";
1513
- if (element.kind === "field") {
1514
- let field = scope.getByLabelText(name, { exact });
1515
- for (const role of FIELD_ROLES) field = field.or(scope.getByRole(role, {
1516
- exact,
1517
- name
1518
- }));
1519
- return field;
1520
- }
1521
- if (element.kind === "testId") return scope.getByTestId(name);
1522
- if (element.kind === "text") return scope.getByText(name, { exact });
1523
- return scope.getByRole(element.kind, element.name === void 0 ? { exact } : {
1524
- exact,
1525
- name: element.name
1526
- });
1527
- }
1528
- /** Describe the live candidates — the evidence the W3 refusal enumerates. */
1529
- function candidates(locator) {
1530
- return locator.elements().slice(0, MAX_REPORTED_MATCHES).map((node) => {
1531
- const landmark = node.parentElement?.closest(LANDMARKS);
1532
- const context = landmark?.getAttribute("role") ?? landmark?.tagName.toLowerCase();
1533
- const detail = node.getAttribute("href") ?? node.getAttribute("name");
1534
- const text = (node.textContent ?? "").replaceAll(/\s+/gu, " ").trim().slice(0, 80);
1535
- const label = node.getAttribute("aria-label")?.trim();
1536
- return {
1537
- accessibleName: label !== void 0 && label !== text ? label : void 0,
1538
- context: context ?? void 0,
1539
- detail: detail ?? void 0,
1540
- tag: node.tagName.toLowerCase(),
1541
- text
1542
- };
1543
- });
1544
- }
1545
- /** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
1546
- function ambiguousLevel(element) {
1547
- const chain = [];
1548
- for (let current = element; current; current = current.scope) chain.unshift(current);
1549
- for (const level of chain.slice(0, -1)) if (locate(page, level).elements().length > 1) return level;
1550
- return element;
1551
- }
1552
- /** What the transitional window may ask of the mounted page. */
1553
- const PROBE = {
1554
- count: async (element) => await Promise.resolve(locate(page, element).elements().length),
1555
- nameOf: async (element) => {
1556
- const [match] = candidates(locate(page, element));
1557
- return await Promise.resolve(match?.accessibleName ?? match?.text);
1558
- }
1559
- };
1560
- /**
1561
- * Run one visitor action, turning "more than one match" into the W3 refusal.
1562
- * The ambiguous LEVEL is identified first: a scope matching several landmarks
1563
- * is the real fault, and naming the target would send the author to fix the
1564
- * wrong descriptor.
1565
- */
1566
- async function act(element, action) {
1567
- const target = await widenedForWindow(element, globalThis.location.href, PROBE) ?? element;
1568
- const locator = locate(page, target);
1569
- try {
1570
- return await action(locator);
1571
- } catch (error) {
1572
- if (locator.elements().length <= 1) throw error;
1573
- const culprit = ambiguousLevel(target);
1574
- const matches = candidates(locate(page, culprit));
1575
- const url = globalThis.location.href;
1576
- throw new AmbiguousElementError(describeAmbiguity({
1577
- element: culprit,
1578
- matches,
1579
- url
1580
- }));
1581
- }
1582
- }
1583
- /**
1584
- * Is this descriptor answered by PRESENCE rather than by visibility?
1585
- *
1586
- * The options of a collapsed `<select>` are in the document and in the
1587
- * accessibility tree, and not one of them has a box on the screen — asking
1588
- * whether an option is visible would answer "no" for every select a visitor
1589
- * never opened.
1590
- */
1591
- function byPresence(element) {
1592
- return element.kind === "option";
1593
- }
1594
- /** A control's live value — the property, never the attribute. */
1595
- function valueOf(node) {
1596
- return node instanceof HTMLInputElement || node instanceof HTMLSelectElement || node instanceof HTMLTextAreaElement ? node.value : void 0;
1597
- }
1598
- /**
1599
- * Is the element gone — or, with a state modifier, no longer in that state? A
1600
- * node that left the document and one that is still there but not rendered are
1601
- * the same answer to a spec.
1602
- */
1603
- function isGone(locator, element) {
1604
- const node = locator.query();
1605
- if (element.focused === true) return node !== document.activeElement;
1606
- if (element.disabled !== void 0) return node === null || node.matches(":disabled") !== element.disabled;
1607
- if (element.selected !== void 0) return node === null || node.matches(":checked") !== element.selected;
1608
- if (element.value !== void 0) return node === null || valueOf(node) !== element.value;
1609
- return node === null || (byPresence(element) ? false : !node.checkVisibility());
1610
- }
1611
- /**
1612
- * How long `gone()` waits — what is LEFT of the running test's budget.
1613
- *
1614
- * Every other verb already gets it: a locator action and `expect.element`
1615
- * default their timeout to the remaining budget, and only a bare
1616
- * `expect.poll` falls back to vitest's one second. That second made `gone()`
1617
- * the one verb that could not outlast a slow answer — a button taken off the
1618
- * screen when a two-second request returns was "still on the screen".
1619
- *
1620
- * The 100ms margin is vitest's own: the poll has to give up just BEFORE the
1621
- * test does, or the failure is a bare timeout naming no element.
1622
- */
1623
- function remainingBudget() {
1624
- const current = TestRunner.getCurrentTest();
1625
- const started = current?.result?.startTime;
1626
- if (current === void 0 || started === void 0) return;
1627
- return Math.max(current.timeout - (Date.now() - started) - 100, 0);
1628
- }
1629
- /** `expect.poll` over that budget — the retry the matchers get for free. */
1630
- function polling(probe, message) {
1631
- const timeout = remainingBudget();
1632
- return expect.poll(probe, {
1633
- message,
1634
- ...timeout === void 0 ? {} : { timeout }
1635
- });
1636
- }
1637
- /** A key name is a key; a single character is typed as itself. */
1638
- function keystroke(key) {
1639
- return key.length > 1 ? `{${key}}` : key;
1640
- }
1641
- /** The verbs a rendered surface shares with a page, plus the two a parent owns. */
1642
- function componentVerbs(surface) {
1643
- return {
1644
- check: async (element) => {
1645
- await act(element, async (locator) => {
1646
- const node = locator.element();
1647
- if (!(node instanceof HTMLInputElement) || !node.checked) await locator.click();
1648
- });
1649
- },
1650
- click: async (element) => {
1651
- await act(element, async (locator) => {
1652
- await locator.click();
1653
- });
1654
- },
1655
- fill: async (element, value) => {
1656
- await act(element, async (locator) => {
1657
- await locator.fill(value);
1658
- });
1659
- },
1660
- gone: async (element) => {
1661
- await act(element, async (locator) => {
1662
- await polling(() => isGone(locator, element), `${formatElement(element)} is still on the screen`).toBeTruthy();
1663
- });
1664
- },
1665
- hover: async (element) => {
1666
- await act(element, async (locator) => {
1667
- await locator.hover();
1668
- });
1669
- },
1670
- press: async (key) => {
1671
- await userEvent.keyboard(keystroke(key));
1672
- },
1673
- rerender: surface.rerender,
1674
- see: async (element) => {
1675
- await act(element, async (locator) => {
1676
- if (element.focused === true) {
1677
- await expect.element(locator).toHaveFocus();
1678
- return;
1679
- }
1680
- if (element.disabled === true) {
1681
- await expect.element(locator).toBeDisabled();
1682
- return;
1683
- }
1684
- if (element.disabled === false) {
1685
- await expect.element(locator).toBeEnabled();
1686
- return;
1687
- }
1688
- if (element.selected !== void 0) {
1689
- await polling(() => locator.query()?.matches(":checked") === true, `${formatElement(element)} is not the selected one`).toBe(element.selected);
1690
- return;
1691
- }
1692
- if (element.value !== void 0) {
1693
- await expect.element(locator).toHaveValue(element.value);
1694
- return;
1695
- }
1696
- if (byPresence(element)) {
1697
- await expect.element(locator).toBeInTheDocument();
1698
- return;
1699
- }
1700
- await expect.element(locator).toBeVisible();
1701
- });
1702
- },
1703
- select: async (element, option) => {
1704
- await act(element, async (locator) => {
1705
- await locator.selectOptions(option);
1706
- });
1707
- },
1708
- unmount: surface.unmount
1709
- };
1710
- }
1711
- /** The ARIA snapshot of the rendered `<body>` — the golden a tree is asserted as. */
1712
- async function ariaTree() {
1713
- return await commands.ariaTree();
1714
- }
1715
- /** A golden's content, read server-side from `_expected/` beside the test. */
1716
- async function readGolden(name) {
1717
- return await commands.goldenRead(name);
1718
- }
1719
- /** Write a golden server-side, and say where — update mode's half. */
1720
- async function writeGolden(name, content) {
1721
- return await commands.goldenWrite(name, content);
1722
- }
1723
- /** Mount a React subject through the optional `vitest-browser-react` adapter. */
1724
- async function mountReact(ui) {
1725
- let render;
1726
- try {
1727
- ({render} = await import("vitest-browser-react"));
1728
- } catch (error) {
1729
- throw new Error(`component.render(<X />) needs vitest-browser-react and react (optional peers): npm install -D vitest-browser-react react react-dom (${String(error)})`, { cause: error });
1730
- }
1731
- const rendered = await render(ui);
1732
- return {
1733
- container: rendered.container,
1734
- rerender: rendered.rerender,
1735
- unmount: rendered.unmount
1736
- };
1737
- }
1738
- /**
1739
- * Mount a vanilla DOM subject: the function is handed a fresh container and
1740
- * may return its own teardown — the shape a DOM library states one in. There
1741
- * is no react here, and no react is loaded.
1742
- */
1743
- function mountDom(mount) {
1744
- const container = document.createElement("div");
1745
- document.body.append(container);
1746
- const teardown = mount(container);
1747
- return {
1748
- container,
1749
- rerender: () => {
1750
- throw new Error("rerender() replaces a React tree; a DOM function is re-rendered by calling it again in a new render().");
1751
- },
1752
- unmount: async () => {
1753
- teardown?.();
1754
- container.remove();
1755
- await Promise.resolve();
1756
- }
1757
- };
1758
- }
1759
- //#endregion
1760
- //#region src/seams/vitest-browser/golden.ts
1761
- /** Update mode crosses the seam as a provided value, never as an env read. */
1762
- function updating() {
1763
- return inject("update") === true;
1764
- }
1765
- async function matchGolden(accessor, name, frozen) {
1766
- requireExtension(name, "stream");
1767
- const actual = accessor.comparableText;
1768
- const stored = await readGolden(name);
1769
- if (stored === null) {
1770
- if (updating() && !frozen) {
1771
- const path = await writeGolden(name, actual);
1772
- return {
1773
- message: () => `wrote ${path}`,
1774
- pass: true
1775
- };
1776
- }
1777
- return {
1778
- message: () => `${accessor.streamName} fixture "${name}" does not exist under ${GROUND_EXPECTED}/ beside this test.\nRun with TEST_UPDATE=1 (or vitest -u) to create it.`,
1779
- pass: false
1780
- };
1781
- }
1782
- const comparison = compareStreamText(accessor, name, stored);
1783
- if (!comparison.pass && updating() && !frozen) {
1784
- await writeGolden(name, actual);
1785
- return {
1786
- message: () => `updated ${GROUND_EXPECTED}/${name}`,
1787
- pass: true
1788
- };
1789
- }
1790
- return comparison;
1791
- }
1792
- async function toMatch(received, expected, options) {
1793
- if (received instanceof TextAccessor) {
1794
- if (typeof expected !== "string") throw new TypeError("toMatch on accessors takes a fixture name (extension included) — for a regex, assert on the raw text instead: expect(x.text).toMatch(/re/).");
1795
- return await matchGolden(received, expected, options?.frozen === true);
1796
- }
1797
- return nativeMatch(received, expected);
1798
- }
1799
- function toContain(received, expected) {
1800
- if (received instanceof TextAccessor) return textContains(received, String(expected));
1801
- return nativeContain(received, expected);
1802
- }
1803
- function toBeEmpty(received) {
1804
- if (!(received instanceof TextAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected a text accessor.");
1805
- return textIsEmpty(received);
1806
- }
1807
- let registered = false;
1808
- /**
1809
- * Register the page's matchers with vitest's `expect`. Idempotent, and called
1810
- * from the browser entry itself: a component test imports `@jterrazz/test` to
1811
- * get `component`, and that import IS the registration.
1812
- */
1813
- function registerBrowserMatchers() {
1814
- if (registered) return;
1815
- registered = true;
1816
- expect.extend({
1817
- toBeEmpty,
1818
- toContain,
1819
- toMatch
1820
- });
1821
- }
1822
- //#endregion
1823
- //#region src/browser/node-only.ts
1824
- /**
1825
- * The refusals the browser build ships in place of what only node can do.
1826
- *
1827
- * `@jterrazz/test` has ONE specifier and two runtimes, so the page gets the
1828
- * same NAMES as the server: a component test that reaches for
1829
- * `specification.api()` or `postgres()` fails on the line that reached, saying
1830
- * where that name runs — not on an unresolved import three layers down, and
1831
- * not silently on `undefined`.
1832
- */
1833
- /**
1834
- * The refusal, in one line, whatever the name. Returns `never`, so a stub is
1835
- * an ordinary arrow function the page's entry annotates with the node
1836
- * signature — no cast, and no way for the two surfaces to disagree.
1837
- */
1838
- function refuse(name) {
1839
- throw new Error(`${name} runs under node: a page has no filesystem, no child process and no socket. Specify it from a test collected by a node project (unit/api/jobs/cli/website/mobile); a rendered thing is a \`.test.tsx\` beside its component.`);
1840
- }
1841
- /**
1842
- * A node-only CLASS, present under the same name. A class has a shape a stub
1843
- * cannot honour — every instance member of the real one — so this is the one
1844
- * place the page's entry states a type it does not structurally satisfy; what
1845
- * the value can actually do is throw, which is the whole of the contract.
1846
- *
1847
- * The stub is a constructible FUNCTION, never an arrow: `new` on an arrow
1848
- * throws "is not a constructor" before the refusal can run, so the page would
1849
- * lose the one thing the stub exists to say — which name it reached for.
1850
- */
1851
- const nodeOnlyClass = (name) => function NodeOnly() {
1852
- return refuse(name);
1853
- };
1854
- //#endregion
1855
- //#region src/seams/vitest-browser/page-runtime.ts
1856
- /**
1857
- * The runner, as the page reaches it.
1858
- *
1859
- * `vitest` is the framework's one sanctioned coupling and it lives in the
1860
- * layers that own a runner — `src/vitest/` for the config side, this seam for
1861
- * the page's. The component model asks for "after this test", "pin the clock"
1862
- * and "what did the project provide" and never learns which runner answers.
1863
- */
1864
- /** Run `teardown` when the current test ends, however it ends. No hook in the spec. */
1865
- function afterThisTest(teardown) {
1866
- onTestFinished(teardown);
1867
- }
1868
- /**
1869
- * Freeze the page's `Date` at `iso`. `Date` ONLY: faking the page's timers
1870
- * would stop React's scheduler and nothing would ever render.
1871
- */
1872
- function pinClock(iso) {
1873
- vi.useFakeTimers({
1874
- now: new Date(iso),
1875
- toFake: ["Date"]
1876
- });
1877
- }
1878
- /** Give the page its real clock back. */
1879
- function releaseClock() {
1880
- vi.useRealTimers();
1881
- }
1882
- /** The instant `component({ clock })` pinned for every render, if it did. */
1883
- function providedClock() {
1884
- return inject("componentClock");
1885
- }
1886
- /** The page size the project states — what a per-test `.viewport()` is restored to. */
1887
- function providedViewport() {
1888
- return inject("componentViewport");
1889
- }
1890
- /** Resize the page the test renders into. */
1891
- async function setViewport(size) {
1892
- await page.viewport(size.width, size.height);
1893
- }
1894
- /**
1895
- * The project's `wrap`, left on `globalThis` by the setup module the project
1896
- * loads. A shared symbol rather than an import: the setup module and this
1897
- * bundle are two graphs in one page and share no module instance.
1898
- */
1899
- function projectWrapper() {
1900
- const wrap = globalThis[Symbol.for("@jterrazz/test:component-wrap")];
1901
- return typeof wrap === "function" ? wrap : void 0;
1902
- }
1903
- //#endregion
1904
- //#region src/facets/component/rendered-text.ts
1905
- /**
1906
- * A stream captured INSIDE the page.
1907
- *
1908
- * Structurally identical to any other {@link TextAccessor} — the same
1909
- * `{{token}}` grammar, the same ANSI stripping, the same `.grep()` — and
1910
- * marked so the matcher augmentation can say the one thing that differs: its
1911
- * golden lives on the server, so `toMatch` is awaited here where it is
1912
- * synchronous everywhere else.
1913
- */
1914
- var RenderedText = class extends TextAccessor {
1915
- /** The marker the `toMatch` overload reads. Never asserted on directly. */
1916
- capturedInPage = true;
1917
- };
1918
- //#endregion
1919
- //#region src/facets/component/component.result.ts
1920
- /**
1921
- * What a render resolves to — the component as the page saw it, after the
1922
- * scenario ran.
1923
- *
1924
- * Five accessors and no more: the ARIA outline a golden pins, the two
1925
- * renderings a probe reads, and the two streams a spec wants silent. Nothing
1926
- * here reaches back into React — a spec that needs the component's internals
1927
- * is asserting the implementation, not the surface a user meets.
1928
- */
1929
- var RenderResult = class {
1930
- /** The browser console as a stream — one `[type] text` line per message. */
1931
- console;
1932
- /** Rendered text of the whole document body (`innerText`). */
1933
- content;
1934
- /** Console messages of type `error` only, plus any uncaught page error. */
1935
- errors;
1936
- /** The mounted markup, serialised — the escape hatch for a class or an attribute. */
1937
- html;
1938
- /** The ARIA snapshot of the rendered body — the outline a golden pins. */
1939
- tree;
1940
- constructor(streams) {
1941
- this.console = new RenderedText(streams.console, "console", "");
1942
- this.content = new RenderedText(streams.content, "content", "");
1943
- this.errors = new RenderedText(streams.errors, "errors", "");
1944
- this.html = new RenderedText(streams.html, "html", "");
1945
- this.tree = new RenderedText(streams.tree, "tree", "");
1946
- }
1947
- };
1948
- //#endregion
1949
- //#region src/facets/component/component.chain.ts
1950
- /** The console methods a render records; everything else is left alone. */
1951
- const CONSOLE_METHODS = [
1952
- "debug",
1953
- "error",
1954
- "info",
1955
- "log",
1956
- "warn"
1957
- ];
1958
- const EMPTY = {
1959
- clock: null,
1960
- contracts: [],
1961
- viewport: null,
1962
- wrap: null
1963
- };
1964
- /** Record what the page writes to the console for the length of the render. */
1965
- function recordConsole() {
1966
- const entries = [];
1967
- const originals = /* @__PURE__ */ new Map();
1968
- for (const method of CONSOLE_METHODS) {
1969
- const original = console[method];
1970
- originals.set(method, original);
1971
- console[method] = (...args) => {
1972
- entries.push({
1973
- text: args.map(String).join(" "),
1974
- type: method
1975
- });
1976
- original(...args);
1977
- };
1978
- }
1979
- const onError = (event) => {
1980
- entries.push({
1981
- text: event.message,
1982
- type: "error"
1983
- });
1984
- };
1985
- globalThis.addEventListener("error", onError);
1986
- let stopped = false;
1987
- return {
1988
- entries,
1989
- stop: () => {
1990
- if (stopped) return;
1991
- stopped = true;
1992
- for (const method of CONSOLE_METHODS) {
1993
- const original = originals.get(method);
1994
- if (original !== void 0) console[method] = original;
1995
- }
1996
- globalThis.removeEventListener("error", onError);
1997
- }
1998
- };
1999
- }
2000
- /** Is the subject a function that fills a container, rather than a React tree? */
2001
- function isDomMount(subject) {
2002
- return typeof subject === "function";
2003
- }
2004
- /** A bare request needs its response — the same refusal every facet gives. */
2005
- function inlinePair(request, response) {
2006
- if (response === void 0) throw new Error(".intercept(): a bare request needs its response — pass a contract (defineContract({ request, response })) or the inline pair .intercept(request, response).");
2007
- return {
2008
- request,
2009
- response
2010
- };
2011
- }
2012
- /**
2013
- * What the page is still holding, and the teardown that lets it go.
2014
- *
2015
- * The React adapter's own auto-cleanup is a `beforeEach` registered when its
2016
- * module loads — and this package loads it lazily, INSIDE the first render, so
2017
- * that hook never covers the test that triggered it. The seam does the unmount
2018
- * itself, on the test that rendered: two renders' markup sharing one document
2019
- * is how a second `button('Open')` appears and a descriptor that named exactly
2020
- * one element starts refusing.
2021
- */
2022
- let mounted = null;
2023
- /** Everything one test may have left behind, undone — no hook in any spec. */
2024
- async function resetComponentScope(resized) {
2025
- await unmountHeld();
2026
- if (resized) await setViewport(providedViewport());
2027
- resetWorkerContracts();
2028
- releaseClock();
2029
- }
2030
- /** Take down whatever is still mounted, and hold nothing afterwards. */
2031
- async function unmountHeld() {
2032
- const surface = mounted;
2033
- mounted = null;
2034
- if (surface !== null) try {
2035
- await surface.unmount();
2036
- } catch {}
2037
- }
2038
- /** Mount the subject, dressing a React tree in the chain's wrapper and the project's. */
2039
- async function mount(state, subject) {
2040
- if (isDomMount(subject)) return mountDom(subject);
2041
- const outer = projectWrapper();
2042
- const dress = (ui) => {
2043
- const inner = state.wrap ? state.wrap(ui) : ui;
2044
- return outer ? outer(inner) : inner;
2045
- };
2046
- const surface = await mountReact(dress(subject));
2047
- return {
2048
- ...surface,
2049
- rerender: async (ui) => {
2050
- await surface.rerender(dress(ui));
2051
- }
2052
- };
2053
- }
2054
- /** The terminal action: mount, run the scenario, capture what the page shows. */
2055
- async function render(state, subject, scenario) {
2056
- await unmountHeld();
2057
- const recorder = recordConsole();
2058
- afterThisTest(async () => {
2059
- recorder.stop();
2060
- await resetComponentScope(state.viewport !== null);
2061
- });
2062
- if (state.viewport !== null) await setViewport(state.viewport);
2063
- const instant = state.clock ?? providedClock();
2064
- if (instant !== void 0) pinClock(instant);
2065
- const registration = await registerWorkerContracts(state.contracts);
2066
- const surface = await mount(state, subject);
2067
- mounted = surface;
2068
- try {
2069
- if (scenario) await scenario(componentVerbs(surface));
2070
- } finally {
2071
- recorder.stop();
2072
- }
2073
- const violation = registration.violation();
2074
- if (violation !== null) throw violation;
2075
- const { entries } = recorder;
2076
- return new RenderResult({
2077
- console: entries.map((entry) => `[${entry.type}] ${entry.text}`).join("\n"),
2078
- content: document.body.innerText,
2079
- errors: entries.filter((entry) => entry.type === "error").map((entry) => entry.text).join("\n"),
2080
- html: surface.container.innerHTML,
2081
- tree: await ariaTree()
2082
- });
2083
- }
2084
- /** Every setup forks a chain, so the exported handle never carries a test's state. */
2085
- function chainOf(state) {
2086
- return {
2087
- clock: (iso) => chainOf({
2088
- ...state,
2089
- clock: iso
2090
- }),
2091
- intercept: (requestOrContracts, maybeResponse) => {
2092
- const added = Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts) ? contractsOf(requestOrContracts) : [inlinePair(requestOrContracts, maybeResponse)];
2093
- return chainOf({
2094
- ...state,
2095
- contracts: [...state.contracts, ...added]
2096
- });
2097
- },
2098
- render: async (subject, scenario) => await render(state, subject, scenario),
2099
- viewport: (size) => chainOf({
2100
- ...state,
2101
- viewport: size
2102
- }),
2103
- wrap: (wrapper) => chainOf({
2104
- ...state,
2105
- wrap: wrapper
2106
- })
2107
- };
2108
- }
2109
- /** The handle a component test imports. */
2110
- const component = chainOf(EMPTY);
2111
- //#endregion
2112
- //#region src/model/elements/elements.ts
2113
- const named = (kind) => (name, options) => ({
2114
- kind,
2115
- name,
2116
- ...options?.exact === void 0 ? {} : { exact: options.exact }
2117
- });
2118
- /** A button (or element with the button role), by accessible name. */
2119
- const button = named("button");
2120
- /** A form field, by label. */
2121
- const field = named("field");
2122
- /** A heading, by accessible name. */
2123
- const heading = named("heading");
2124
- /** A link, by accessible name. */
2125
- const link = named("link");
2126
- /** An element containing the given text. */
2127
- const content = named("text");
2128
- /** The escape hatch: an element by `data-testid`. Prefer user-facing elements. */
2129
- const testId = (id) => ({
2130
- kind: "testId",
2131
- name: id
2132
- });
2133
- const landmark = (kind) => (name, options) => ({
2134
- kind,
2135
- ...name === void 0 ? {} : { name },
2136
- ...options?.exact === void 0 ? {} : { exact: options.exact }
2137
- });
2138
- /** The `banner` landmark — the page header. */
2139
- const banner = landmark("banner");
2140
- /** The `complementary` landmark — an `<aside>`, a sidebar. */
2141
- const complementary = landmark("complementary");
2142
- /** The `contentinfo` landmark — the page footer. */
2143
- const contentinfo = landmark("contentinfo");
2144
- /** The `form` landmark — a form carrying an accessible name. */
2145
- const form = landmark("form");
2146
- /** The `main` landmark — the primary content of the document. */
2147
- const main = landmark("main");
2148
- /** The `navigation` landmark — a `<nav>`. Name it when a page has several. */
2149
- const navigation = landmark("navigation");
2150
- /** The `region` landmark — a `<section>` carrying an accessible name. */
2151
- const region = landmark("region");
2152
- /** The `search` landmark. */
2153
- const search = landmark("search");
2154
- /**
2155
- * Restrict a descriptor to the inside of another — the answer to ambiguity,
2156
- * and the one the framework prefers over a test id.
2157
- *
2158
- * click(within(navigation(), link('Articles')))
2159
- *
2160
- * Composes outside-in: the scope may itself be scoped, so a deeply nested
2161
- * target reads `within(main(), within(region('Series'), link('Part 2')))`.
2162
- * Any descriptor works as a scope, including `testId()` when a container has
2163
- * no landmark role to stand on.
2164
- */
2165
- const within = (scope, target) => ({
2166
- ...target,
2167
- scope: target.scope ? {
2168
- ...target.scope,
2169
- scope
2170
- } : scope
2171
- });
2172
- /** A modal or non-modal `<dialog>`, by accessible name. A closed one is absent. */
2173
- const dialog = landmark("dialog");
2174
- /** A live region announcing a result — `role="status"`, an `<output>`. */
2175
- const status = landmark("status");
2176
- /** A table, by its caption or accessible name. */
2177
- const table = landmark("table");
2178
- /** A row of a table or grid, by the text of its cells. */
2179
- const row = landmark("row");
2180
- /** An item of a list, by its text. Retires the `.first()` of an unnamed `<li>`. */
2181
- const listitem = landmark("listitem");
2182
- /**
2183
- * An option of a select or a listbox, by its label — named inside the field
2184
- * that holds it: `within(field('Channel'), option('LinkedIn'))`.
2185
- *
2186
- * Asserted by PRESENCE, not by visibility: the options of a collapsed
2187
- * `<select>` are in the document and in the accessibility tree, and none of
2188
- * them has a box on the screen until it is opened. `see(option('LinkedIn'))`
2189
- * therefore asks "is it offered", and `see(selected(option('LinkedIn')))`
2190
- * asks which one the field is on.
2191
- */
2192
- const option = named("option");
2193
- /**
2194
- * Where the keyboard is: `see(focused(button('Open')))` asserts the element has
2195
- * focus, `gone(focused(x))` that it does not.
2196
- *
2197
- * A modifier rather than a descriptor of its own, because focus is a STATE of
2198
- * an element the vocabulary can already name. It is a verb's business and never
2199
- * a golden's: the accessibility tree carries no focus, so a tree snapshot of a
2200
- * dialog that took the keyboard and one that did not are byte-identical.
2201
- */
2202
- const focused = (element) => ({
2203
- ...element,
2204
- focused: true
2205
- });
2206
- /**
2207
- * Whether the element accepts input: `see(disabled(button('Delete')))` asserts
2208
- * it refuses, `see(enabled(button('Delete')))` that it takes it.
2209
- *
2210
- * Modifiers rather than descriptors, for the same reason as `focused`: being
2211
- * disabled is a state of an element the vocabulary already names. Unlike focus,
2212
- * the ARIA tree carries it, so a golden pins a whole outline's enablement and
2213
- * these name the ONE control a test is about. They also buy the direction a
2214
- * behavioural substitute cannot reach — clicking a disabled control is a
2215
- * timeout, never an assertion.
2216
- */
2217
- const disabled = (element) => ({
2218
- ...element,
2219
- disabled: true
2220
- });
2221
- /** The other direction of {@link disabled} — the element takes input. */
2222
- const enabled = (element) => ({
2223
- ...element,
2224
- disabled: false
2225
- });
2226
- /**
2227
- * Which option the field is on: `see(selected(option('LinkedIn')))`, and
2228
- * `gone(selected(option('X')))` for the one it left.
2229
- *
2230
- * A modifier for the same reason as {@link disabled}: being chosen is a state
2231
- * of an element the vocabulary already names. No matcher answers for it — a
2232
- * `<option>` is not a checkbox — so both adapters read the `:checked` the CSS
2233
- * spec gives a selected option.
2234
- */
2235
- const selected = (element) => ({
2236
- ...element,
2237
- selected: true
2238
- });
2239
- /**
2240
- * What the field holds: `see(valued(field('Title'), 'Launch teaser'))`, and
2241
- * `gone(valued(field('Title'), '…'))` for a value it no longer holds.
2242
- *
2243
- * A modifier for the same reason as {@link disabled}: what a field holds is a
2244
- * state of an element the vocabulary already names, and this is how the ONE
2245
- * field a test is about is waited for — a golden pins a whole outline instead.
2246
- * Read from the node's live value, never from the `value` ATTRIBUTE, which a
2247
- * controlled input never moves.
2248
- */
2249
- const valued = (element, value) => ({
2250
- ...element,
2251
- value
2252
- });
2253
- //#endregion
2254
- //#region src/model/contracts/filters.ts
2255
- /** Does an observed text satisfy a declared {@link TextFilter}? */
2256
- function matchesText(filter, actual) {
2257
- if (filter === void 0) return true;
2258
- if (typeof filter === "string") return actual === filter;
2259
- if (filter instanceof RegExp) return filter.test(actual);
2260
- return structuralEquals(filter, actual, new CaptureScope());
2261
- }
2262
- //#endregion
2263
- //#region src/seams/anthropic/anthropic.ts
2264
- const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
2265
- function matchesFilter(body, filter) {
2266
- if (!matchesText(filter.model, body?.model ?? "")) return false;
2267
- if (!matchesText(filter.system, typeof body?.system === "string" ? body.system : "")) return false;
2268
- const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
2269
- if (!matchesText(filter.user, typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg))) return false;
2270
- if (filter.tools) {
2271
- const names = body?.tools?.map((t) => t.name).filter(Boolean) ?? [];
2272
- if (!filter.tools.every((t) => names.includes(t))) return false;
2273
- }
2274
- return true;
2275
- }
2276
- function buildReply(data) {
2277
- return {
2278
- status: 200,
2279
- body: {
2280
- id: "msg-test",
2281
- type: "message",
2282
- role: "assistant",
2283
- content: [{
2284
- type: "text",
2285
- text: typeof data === "string" ? data : JSON.stringify(data)
2286
- }],
2287
- model: "claude-sonnet-4-20250514",
2288
- stop_reason: "end_turn",
2289
- usage: {
2290
- input_tokens: 10,
2291
- output_tokens: 10
2292
- }
2293
- }
2294
- };
2295
- }
2296
- /**
2297
- * Anthropic API intercept helpers.
2298
- */
2299
- const anthropic = {
2300
- /**
2301
- * Request: match Messages API calls, optionally routed through a
2302
- * custom gateway URL. When used with a JSON fixture file, the data is
2303
- * returned as-is (no wrapping) because Anthropic fixtures are typically
2304
- * already in the Messages API response shape.
2305
- *
2306
- * @example
2307
- * anthropic.messages()
2308
- * anthropic.messages({ system: /classify/ })
2309
- * anthropic.messages({ user: buildPrompt() }) // string = EXACT equality
2310
- * anthropic.messages({ user: /classify/ }, GATEWAY)
2311
- */
2312
- messages(filter, url) {
2313
- return {
2314
- adapter: "anthropic",
2315
- method: "POST",
2316
- url: url ?? ANTHROPIC_MESSAGES_URL,
2317
- match: filter ? ({ body }) => matchesFilter(body, filter) : void 0,
2318
- wrap(data) {
2319
- if (data && typeof data === "object" && !Array.isArray(data)) return {
2320
- status: 200,
2321
- body: data
2322
- };
2323
- return buildReply(data);
2324
- }
2325
- };
2326
- },
2327
- /** Response: wrap data in Anthropic messages format. */
2328
- reply(data) {
2329
- return buildReply(data);
2330
- },
2331
- /** Response: return an Anthropic error. */
2332
- error(status, message) {
2333
- return {
2334
- status,
2335
- body: {
2336
- type: "error",
2337
- error: {
2338
- type: status === 429 ? "rate_limit_error" : "api_error",
2339
- message: message ?? `Anthropic error (${status})`
2340
- }
2341
- }
2342
- };
2343
- },
2344
- /** Response: simulate a timeout. */
2345
- timeout() {
2346
- return {
2347
- status: 200,
2348
- body: {},
2349
- delay: 3e4
2350
- };
2351
- }
2352
- };
2353
- //#endregion
2354
- //#region src/model/contracts/http.ts
2355
- function wrapJson(data) {
2356
- return {
2357
- status: 200,
2358
- body: data
2359
- };
2360
- }
2361
- const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
2362
- const STREAM_CONTENT_TYPE = "application/octet-stream";
2363
- const SSE_CONTENT_TYPE = "text/event-stream";
2364
- /** The wire form of one event — the frame an `EventSource` parses. */
2365
- function frameEvent(event) {
2366
- const lines = [];
2367
- if (event.event !== void 0) lines.push(`event: ${event.event}`);
2368
- if (event.id !== void 0) lines.push(`id: ${event.id}`);
2369
- if (event.retry !== void 0) lines.push(`retry: ${event.retry}`);
2370
- const payload = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
2371
- for (const line of payload.split("\n")) lines.push(`data: ${line}`);
2372
- return `${lines.join("\n")}\n\n`;
2373
- }
2374
- function matchesBody(body, expected) {
2375
- if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
2376
- if (expected instanceof RegExp) {
2377
- const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
2378
- return expected.test(text);
2379
- }
2380
- return structuralSubset(expected, body, new CaptureScope());
2381
- }
2382
- function matchesEntries(expected, lookup) {
2383
- return Object.entries(expected).every(([key, value]) => {
2384
- const actual = lookup(key);
2385
- if (actual === void 0) return false;
2386
- return value instanceof RegExp ? value.test(actual) : actual === value;
2387
- });
2388
- }
2389
- /**
2390
- * Build the `match` predicate for an HTTP request filter, or `undefined` when
2391
- * no filter is supplied (fires on any URL/method match).
2392
- */
2393
- function buildMatch(filter) {
2394
- if (!filter) return;
2395
- return (request) => {
2396
- if (filter.body !== void 0 && !matchesBody(request.body, filter.body)) return false;
2397
- if (filter.headers && !matchesEntries(filter.headers, (key) => request.headers[key.toLowerCase()])) return false;
2398
- if (filter.query) {
2399
- let params;
2400
- try {
2401
- params = new URL(request.url, "http://contract.invalid").searchParams;
2402
- } catch {
2403
- return false;
2404
- }
2405
- if (!matchesEntries(filter.query, (key) => params.get(key) ?? void 0)) return false;
2406
- }
2407
- return true;
2408
- };
2409
- }
2410
- function declare(method, url, filter) {
2411
- return {
2412
- adapter: "http",
2413
- match: buildMatch(filter),
2414
- method,
2415
- url,
2416
- wrap: wrapJson
2417
- };
2418
- }
2419
- /**
2420
- * Generic HTTP contract helpers for any URL. The url is absolute (string or
2421
- * RegExp), or a PATH FORM starting with `/` — `http.get('/articles/{{uuid}}')`
2422
- * matches that path on ANY origin, which is what an app calling its own
2423
- * backend needs. An optional {@link HttpContractFilter} narrows matching by
2424
- * body, headers, or query — a request that hits the URL/method but fails the
2425
- * filter counts as unmatched (strict contracts, CONVENTIONS D7).
2426
- *
2427
- * @example
2428
- * defineContract({ request: http.get('/articles/{{uuid}}'), response: http.json(article) })
2429
- * defineContract({ request: http.post(URL, { body: { user: 'alice' } }), response: http.empty() })
2430
- */
2431
- const http = {
2432
- any(url, filter) {
2433
- return declare("*", url, filter);
2434
- },
2435
- delete(url, filter) {
2436
- return declare("DELETE", url, filter);
2437
- },
2438
- get(url, filter) {
2439
- return declare("GET", url, filter);
2440
- },
2441
- patch(url, filter) {
2442
- return declare("PATCH", url, filter);
2443
- },
2444
- post(url, filter) {
2445
- return declare("POST", url, filter);
2446
- },
2447
- put(url, filter) {
2448
- return declare("PUT", url, filter);
2449
- },
2450
- /** Response: a body-less reply (204 by default). */
2451
- empty(status = 204) {
2452
- return {
2453
- status,
2454
- body: null
2455
- };
2456
- },
2457
- /** Response: an error status. Without a body, `{ error: 'HTTP <status>' }`. */
2458
- error(status, body) {
2459
- return {
2460
- status,
2461
- body: body === void 0 ? { error: `HTTP ${status}` } : body
2462
- };
2463
- },
2464
- /** Response: a JSON body (200 by default). */
2465
- json(body, init) {
2466
- return {
2467
- status: init?.status ?? 200,
2468
- body,
2469
- delay: init?.delay,
2470
- headers: init?.headers
2471
- };
2472
- },
2473
- /**
2474
- * Response: no response at all — the request fails in transport, the way a
2475
- * server that is not running fails it. `fetch` rejects; no status is served.
2476
- */
2477
- unreachable() {
2478
- return {
2479
- body: null,
2480
- transport: "network-error"
2481
- };
2482
- },
2483
- /**
2484
- * Response: a body that arrives in PIECES, written in order.
2485
- *
2486
- * A subject that reads a response as it lands — a token-by-token render, a
2487
- * progress bar, a reconnect on a half-read body — is specified by the
2488
- * chunks and their order, which a single serialised body cannot state.
2489
- * Honoured by all three engines: msw's server, msw's worker, and the
2490
- * `node:http` stub the website and mobile facets serve from.
2491
- *
2492
- * @example
2493
- * http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 })
2494
- */
2495
- stream(chunks, init) {
2496
- return {
2497
- status: init?.status ?? 200,
2498
- body: {
2499
- chunks: [...chunks],
2500
- contentType: init?.contentType ?? STREAM_CONTENT_TYPE,
2501
- delayBetweenChunks: init?.delay ?? 0,
2502
- kind: STREAM_BODY
2503
- },
2504
- headers: init?.headers
2505
- };
2506
- },
2507
- /**
2508
- * Response: a server-sent event stream — one chunk per event, framed the
2509
- * way an `EventSource` reads them, served as `text/event-stream`.
2510
- *
2511
- * @example
2512
- * http.sse([{ data: { token: 'Hel' } }, { data: { token: 'lo' } }, { event: 'done', data: '' }])
2513
- */
2514
- sse(events, init) {
2515
- return {
2516
- status: init?.status ?? 200,
2517
- body: {
2518
- chunks: events.map(frameEvent),
2519
- contentType: SSE_CONTENT_TYPE,
2520
- delayBetweenChunks: init?.delay ?? 0,
2521
- kind: STREAM_BODY
2522
- },
2523
- headers: {
2524
- "cache-control": "no-cache",
2525
- ...init?.headers
2526
- }
2527
- };
2528
- },
2529
- /** Response: a text body, served as `text/plain` (200 by default). */
2530
- text(body, init) {
2531
- return {
2532
- status: init?.status ?? 200,
2533
- body,
2534
- delay: init?.delay,
2535
- headers: {
2536
- "content-type": TEXT_CONTENT_TYPE,
2537
- ...init?.headers
2538
- }
2539
- };
2540
- }
2541
- };
2542
- //#endregion
2543
- //#region src/seams/openai/openai.ts
2544
- const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
2545
- const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
2546
- function matchesChatFilter(body, filter) {
2547
- if (!matchesText(filter.model, body?.model ?? "")) return false;
2548
- if (!matchesText(filter.system, body?.messages?.find((m) => m.role === "system")?.content ?? "")) return false;
2549
- if (!matchesText(filter.user, body?.messages?.find((m) => m.role === "user")?.content ?? "")) return false;
2550
- if (filter.tools) {
2551
- const names = body?.tools?.map((t) => t.function?.name).filter(Boolean) ?? [];
2552
- if (!filter.tools.every((t) => names.includes(t))) return false;
2553
- }
2554
- if (filter.temperature !== void 0 && body?.temperature !== filter.temperature) return false;
2555
- return true;
2556
- }
2557
- function matchesResponsesFilter(body, filter) {
2558
- if (!matchesText(filter.model, body?.model ?? "")) return false;
2559
- const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
2560
- if (!matchesText(filter.system, body?.instructions || systemInput)) return false;
2561
- const userText = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
2562
- if (!matchesText(filter.user, userText)) return false;
2563
- if (filter.tools) {
2564
- const names = body?.tools?.map((t) => t.name ?? t.function?.name).filter(Boolean) ?? [];
2565
- if (!filter.tools.every((t) => names.includes(t))) return false;
2566
- }
2567
- return true;
2568
- }
2569
- function buildChatReply(data) {
2570
- const content = typeof data === "string" ? data : JSON.stringify(data);
2571
- return {
2572
- status: 200,
2573
- body: {
2574
- id: "chatcmpl-test",
2575
- object: "chat.completion",
2576
- created: Math.floor(Date.now() / 1e3),
2577
- model: "gpt-4o-test",
2578
- choices: [{
2579
- index: 0,
2580
- message: {
2581
- role: "assistant",
2582
- content
2583
- },
2584
- finish_reason: "stop"
2585
- }],
2586
- usage: {
2587
- prompt_tokens: 10,
2588
- completion_tokens: 10,
2589
- total_tokens: 20
2590
- }
2591
- }
2592
- };
2593
- }
2594
- function buildResponsesReply(data) {
2595
- const text = typeof data === "string" ? data : JSON.stringify(data);
2596
- return {
2597
- status: 200,
2598
- body: {
2599
- id: "resp-test",
2600
- object: "response",
2601
- created_at: Math.floor(Date.now() / 1e3),
2602
- model: "gpt-4o-test",
2603
- output: [{
2604
- type: "message",
2605
- id: "msg-test",
2606
- role: "assistant",
2607
- content: [{
2608
- type: "output_text",
2609
- text,
2610
- annotations: []
2611
- }]
2612
- }],
2613
- usage: {
2614
- input_tokens: 10,
2615
- output_tokens: 10,
2616
- total_tokens: 20
2617
- }
2618
- }
2619
- };
2620
- }
2621
- /**
2622
- * OpenAI API intercept helpers.
2623
- */
2624
- const openai = {
2625
- /**
2626
- * Request: match Chat Completions API calls. STRING filters mean EXACT
2627
- * equality (pass the app's own prompt builder); loosen deliberately with a
2628
- * RegExp or `match.includes(...)`.
2629
- *
2630
- * @example
2631
- * openai.chat() // any chat call
2632
- * openai.chat({ model: 'gpt-4o' }) // exact model
2633
- * openai.chat({ system: buildPrompt() }) // exact system prompt
2634
- * openai.chat({ system: /classify/ }) // pattern
2635
- */
2636
- chat(filter) {
2637
- return {
2638
- adapter: "openai",
2639
- method: "POST",
2640
- url: OPENAI_CHAT_URL,
2641
- match: filter ? ({ body }) => matchesChatFilter(body, filter) : void 0,
2642
- wrap: buildChatReply
2643
- };
2644
- },
2645
- /**
2646
- * Request: match Responses API calls (AI SDK v5+) with auto-wrapping.
2647
- * String filters mean EXACT equality (see {@link openai.chat}).
2648
- * When used with a JSON file, the data is automatically wrapped in the
2649
- * Responses API envelope.
2650
- *
2651
- * @param filter - Optional body filters.
2652
- * @param url - Custom gateway URL (default: api.openai.com).
2653
- *
2654
- * @example
2655
- * openai.responses({ user: /Report Ingestion/ }, GATEWAY)
2656
- */
2657
- responses(filter, url) {
2658
- return {
2659
- adapter: "openai",
2660
- method: "POST",
2661
- url: url ?? OPENAI_RESPONSES_URL,
2662
- match: filter ? ({ body }) => matchesResponsesFilter(body, filter) : void 0,
2663
- wrap: buildResponsesReply
2664
- };
2665
- },
2666
- /**
2667
- * Response: wrap data in Chat Completions format.
2668
- *
2669
- * @example
2670
- * openai.reply({ categories: ['TECH'] })
2671
- */
2672
- reply(data) {
2673
- return buildChatReply(data);
2674
- },
2675
- /** Response: return an OpenAI error. */
2676
- error(status, message) {
2677
- return {
2678
- status,
2679
- body: { error: {
2680
- message: message ?? `OpenAI error (${status})`,
2681
- type: status === 429 ? "rate_limit_error" : "api_error",
2682
- code: status === 429 ? "rate_limit_exceeded" : null
2683
- } }
2684
- };
2685
- },
2686
- /** Response: return malformed content. */
2687
- malformed(content) {
2688
- return buildChatReply(content);
2689
- },
2690
- /** Response: simulate a timeout. */
2691
- timeout() {
2692
- return {
2693
- status: 200,
2694
- body: {},
2695
- delay: 3e4
2696
- };
2697
- }
2698
- };
2699
- //#endregion
2700
- //#region src/model/doubles/mock-of.ts
2701
- function mockOf(options) {
2702
- return options?.deep === false ? mock() : mockDeep();
2703
- }
2704
- //#endregion
2705
- //#region src/model/clock/clock.ts
2706
- /** The instant `iso` names, or a refusal that says what an instant looks like. */
2707
- function instantOf(method, iso) {
2708
- const at = new Date(iso);
2709
- if (Number.isNaN(at.getTime())) throw new TypeError(`clock.${method}(): "${iso}" is not an instant — pass an ISO-8601 string (e.g. '2026-03-04T09:30:00Z').`);
2710
- return at;
2711
- }
2712
- /**
2713
- * One clock per test — a second one taken inside the first is refused rather
2714
- * than nested.
2715
- *
2716
- * Disposing gives the REAL clock back, which is the only thing vitest's fake
2717
- * timers can restore: the instant, the `toFake` set and the queue a previous
2718
- * scope held are not readable, so an inner scope that ended would silently end
2719
- * the outer one too. A refusal says which of the two the test meant.
2720
- */
2721
- function take(method, options) {
2722
- if (vi.isFakeTimers()) throw new Error(`clock.${method}(): a clock is already pinned — one scope per test. End the first scope before taking another, or state the instant on the chain (\`.clock(iso)\`) rather than around it.`);
2723
- vi.useFakeTimers(options);
2724
- return { [Symbol.dispose]: () => {
2725
- vi.useRealTimers();
2726
- } };
2727
- }
2728
- const clock = {
2729
- /**
2730
- * Move the pinned clock forward by `ms` — and, under {@link clock.run},
2731
- * fire every callback that came due, awaiting what each of them started.
2732
- *
2733
- * Only a pinned clock moves: called with no clock taken, it says so rather
2734
- * than silently doing nothing to the real one.
2735
- */
2736
- async advance(ms) {
2737
- if (!vi.isFakeTimers()) throw new Error("clock.advance(): no clock is pinned — take one first with `using _ = clock.at('<iso>')` (the calendar) or `using _ = clock.run()` (the scheduler).");
2738
- await vi.advanceTimersByTimeAsync(ms);
2739
- },
2740
- /**
2741
- * Pin `Date` at `iso` for the current scope. `Date` ONLY: the scheduler
2742
- * stays real, so a subject that awaits, renders or polls keeps running.
2743
- *
2744
- * @example
2745
- * using _ = clock.at('2026-03-04T09:30:00Z');
2746
- * expect(stampedAt()).toBe('2026-03-04T09:30:00.000Z');
2747
- */
2748
- at(iso) {
2749
- return take("at", {
2750
- now: instantOf("at", iso),
2751
- toFake: ["Date"]
2752
- });
2753
- },
2754
- /**
2755
- * Take the scheduler as well as the calendar: `setTimeout`, `setInterval`
2756
- * and their friends queue instead of firing, and {@link clock.advance} is
2757
- * what makes them due. For a subject whose behaviour IS elapsed time — a
2758
- * debounce, a retry backoff, a poll — where waiting for real would be the
2759
- * arbitrary sleep J2 forbids.
2760
- *
2761
- * @example
2762
- * using _ = clock.run('2026-03-04T09:30:00Z');
2763
- * const pending = retrying(failsTwice);
2764
- * await clock.advance(5_000);
2765
- * await expect(pending).resolves.toBe('ok');
2766
- */
2767
- run(iso) {
2768
- return take("run", iso === void 0 ? {} : { now: instantOf("run", iso) });
2769
- }
2770
- };
2771
- //#endregion
2772
- //#region src/model/assertions/required.ts
2773
- /**
2774
- * The value, or a failure that says what was missing and why it mattered.
2775
- *
2776
- * A test reaching into a captured structure — a row, a header, the third
2777
- * element of a list — meets `T | undefined` and has to do something about it.
2778
- * The two things it usually does are both wrong: `!` erases the question, and
2779
- * an `if (!x) throw new Error('missing')` re-states the same three lines in
2780
- * every repository (the estate had two hand-written copies of exactly this).
2781
- * `required` is that gesture with the reason attached, so the failure reads as
2782
- * a sentence rather than as a `TypeError` two frames further down.
2783
- *
2784
- * @example
2785
- * const id = required(result.response.body.id, 'the creation reply carries the new id');
2786
- */
2787
- function required(value, why) {
2788
- if (value === null || value === void 0) throw new Error(`required(): ${why} — got ${value === null ? "null" : "undefined"}. State it in the Given, or assert on what the subject actually produced.`);
2789
- return value;
2790
- }
2791
- //#endregion
2792
- //#region src/model/assertions/wait-until.ts
2793
- const DEFAULT_TIMEOUT_MS = 5e3;
2794
- const DEFAULT_INTERVAL_MS = 50;
2795
- /**
2796
- * Wait until a condition holds — the sanctioned answer to "the thing I am
2797
- * specifying is not ready yet".
2798
- *
2799
- * A spec that sleeps for a guessed duration is a spec that is slow on a fast
2800
- * machine and flaky on a slow one, which is why J2 refuses `setTimeout` under
2801
- * `specs/`. Every facet that drives something already waits on a CONDITION —
2802
- * a served page, a banner on stdout, an element the visitor can see — and this
2803
- * is that primitive where no facet owns the waiting: a background write to
2804
- * land, a queue to drain, a file to appear.
2805
- *
2806
- * The failure names the condition and how long it was given, so a timeout is a
2807
- * sentence rather than "expected true, got false".
2808
- *
2809
- * The budget is measured on `performance.now()`, not on `Date` — `clock.at()`
2810
- * freezes the calendar, and a deadline read off a frozen calendar never
2811
- * arrives: the poll would run until vitest killed the test with a sentence
2812
- * about the file rather than about the condition.
2813
- *
2814
- * Not for a subject under `clock.run()`: there the scheduler is the test's, and
2815
- * `clock.advance(ms)` is what makes time pass.
2816
- *
2817
- * @example
2818
- * await waitUntil(() => outbox.length === 1, { why: 'the job wrote its one message' });
2819
- */
2820
- async function waitUntil(predicate, options = {}) {
2821
- const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
2822
- const interval = options.interval ?? DEFAULT_INTERVAL_MS;
2823
- const deadline = performance.now() + timeout;
2824
- for (;;) {
2825
- if (await predicate()) return;
2826
- if (performance.now() >= deadline) throw new Error(`waitUntil(): ${options.why ?? "the condition"} did not hold within ${timeout}ms. Raise the budget if the wait is legitimate, or wait on what actually changes.`);
2827
- await pause(interval);
2828
- }
2829
- }
2830
- //#endregion
2831
- //#region src/browser/index.ts
2832
- registerBrowserMatchers();
2833
- /**
2834
- * The module-scope network double, on msw's WORKER — the same contracts and
2835
- * the same strictness the node entry publishes, served by the one engine a
2836
- * page has. Everything new in this release that a page can use is real here.
2837
- */
2838
- const intercept = interceptThrough(registerWorkerContracts);
2839
- /**
2840
- * The six constructors, present and refusing. A component test never reaches
2841
- * one: what it renders is a unit, and the assembled product behind an entry is
2842
- * specified from a node project.
2843
- */
2844
- const specification = {
2845
- api: () => refuse("specification.api()"),
2846
- cli: () => refuse("specification.cli()"),
2847
- integration: () => refuse("specification.integration()"),
2848
- jobs: () => refuse("specification.jobs()"),
2849
- mobile: () => refuse("specification.mobile()"),
2850
- website: () => refuse("specification.website()")
2851
- };
2852
- const process = () => refuse("process()");
2853
- const ProcessHandle = nodeOnlyClass("ProcessHandle");
2854
- const postgres = () => refuse("postgres()");
2855
- const redis = () => refuse("redis()");
2856
- const sqlite = () => refuse("sqlite()");
2857
- /**
2858
- * `text()` is real here: only its anchor differs. Under node the accessor
2859
- * remembers the caller's directory so a golden resolves against it; in a page
2860
- * the golden commands read the test's own path server-side, so there is
2861
- * nothing for the accessor to carry.
2862
- */
2863
- function text(value) {
2864
- return new TextAccessor(value, "text", "");
2865
- }
2866
- //#endregion
2867
- export { Matcher, ProcessHandle, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, focused, form, heading, http, intercept, link, listitem, main, match, mockOf, navigation, openai, option, postgres, process, redis, region, required, row, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };
1
+ import { A as region, B as option, C as component, D as form, E as contentinfo, F as dialog, G as directory, H as status, I as field, J as defineStubs, K as file, L as heading, M as within, N as button, O as main, P as content, R as link, S as http, T as complementary, U as table, V as row, W as testId, X as match, Y as Matcher, _ as clock, a as ruleTester, b as openai, c as waitUntil, d as disabled, f as enabled, g as mockOf, h as valued, i as redis, j as search, k as navigation, l as required, m as selected, n as postgres, o as specification, p as focused, q as defineStub, r as process, s as sqlite, t as ProcessHandle, u as text, v as packed, w as banner, x as anthropic, y as intercept, z as listitem } from "../shared-index.browser.js";
2
+ export { Matcher, ProcessHandle, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineStub, defineStubs, dialog, directory, disabled, enabled, field, file, focused, form, heading, http, intercept, link, listitem, main, match, mockOf, navigation, openai, option, packed, postgres, process, redis, region, required, row, ruleTester, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };