@jterrazz/test 15.2.0 → 16.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.
@@ -1,31 +1,180 @@
1
1
  import { TestRunner, expect, inject, onTestFinished, vi } from "vitest";
2
2
  import { commands, page, userEvent } from "vitest/browser";
3
- import { mockDeep } from "vitest-mock-extended";
4
- import MockDatePackage from "mockdate";
5
- //#region src/specification/facets/_common/ground.ts
3
+ import { mock, mockDeep } from "vitest-mock-extended";
4
+ //#region src/model/contracts/contract.ts
6
5
  /**
7
- * The GROUND a row of specs stands on — the naming law of a spec tree.
6
+ * Declare one contract. Identity function — its value is the enforced shape
7
+ * and the naming convention:
8
8
  *
9
- * In a row of specs, a MEMBER is a spec: `<aspect>.test.ts`, `<case>.spec.yaml`,
10
- * `<name>.specification.ts`, and the facet/domain folders that hold them.
11
- * Everything else in that row is what the members STAND ON — inert material the
12
- * framework resolves BY PATH, never imported, never executed — and it carries a
13
- * LEADING UNDERSCORE. A spec's own folder never does.
9
+ * @example
10
+ * // specs/api/reports/contracts/openai/classify-article.ts
11
+ * import { defineContract, openai } from '@jterrazz/test';
14
12
  *
15
- * Four names, and only four: `_fixtures/` (file state), `_expected/` (goldens),
16
- * `_requests/` (inputs), `_seeds/` (database state). `contracts/` is NOT ground —
17
- * a contract is TypeScript a spec imports, behaviour composed with the spec's own,
18
- * so it stays a member. `docker/` is not ground either: it sits at the PROJECT
19
- * root beside `package.json`, outside any row of specs.
13
+ * export default defineContract({
14
+ * request: openai.responses({ user: PROMPT, tools: ['classify'] }),
15
+ * response: openai.reply({ categories: ['TECH'] }),
16
+ * });
20
17
  *
21
- * This module is the single home of those names. Every resolver, matcher, lint
22
- * rule and checker pass reads them from here, so the runtime and the static
23
- * channels cannot disagree about where a spec's ground lives.
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
+ * });
24
23
  */
25
- /** Goldens: every expected fixture, flat, extension included. */
26
- const GROUND_EXPECTED = "_expected";
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
+ function resolveRelativeAgainst(origin) {
134
+ let base;
135
+ try {
136
+ base = new URL(origin);
137
+ } catch {
138
+ throw new Error(`intercept(): \`origin\` states where a relative request resolves — \`${origin}\` is not an absolute URL (e.g. 'http://console.test').`);
139
+ }
140
+ const original = globalThis.fetch;
141
+ globalThis.fetch = async (input, init) => typeof input === "string" && input.startsWith("/") ? await original(new URL(input, base).toString(), init) : await original(input, init);
142
+ return () => {
143
+ globalThis.fetch = original;
144
+ };
145
+ }
146
+ /**
147
+ * Build the module-scope double on one engine. The node entry hands it msw's
148
+ * server, the page's entry hands it msw's worker: one shape, two transports,
149
+ * exactly as a chain's `.intercept()` already has it.
150
+ *
151
+ * @internal
152
+ */
153
+ function interceptThrough(register) {
154
+ async function intercept(requestOrContracts, maybeResponseOrOptions, maybeOptions) {
155
+ const options = isOptions(maybeOptions) ? maybeOptions : isOptions(maybeResponseOrOptions) ? maybeResponseOrOptions : void 0;
156
+ const contracts = declared(requestOrContracts, options === maybeResponseOrOptions ? void 0 : maybeResponseOrOptions);
157
+ 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.");
158
+ const restoreOrigin = options?.origin === void 0 ? void 0 : resolveRelativeAgainst(options.origin);
159
+ let registration;
160
+ try {
161
+ registration = await register(contracts);
162
+ } catch (error) {
163
+ restoreOrigin?.();
164
+ throw error;
165
+ }
166
+ return { [Symbol.asyncDispose]: async () => {
167
+ const violation = registration.violation();
168
+ registration.cleanup();
169
+ restoreOrigin?.();
170
+ await Promise.resolve();
171
+ if (violation) throw violation;
172
+ } };
173
+ }
174
+ return intercept;
175
+ }
27
176
  //#endregion
28
- //#region src/specification/matching/match.ts
177
+ //#region src/model/matching/match.ts
29
178
  /** Every kind usable as a `{{token}}` in fixture files (all but ref/regex/includes). */
30
179
  const TOKEN_KINDS = [
31
180
  "any",
@@ -178,7 +327,94 @@ var CaptureScope = class {
178
327
  }
179
328
  };
180
329
  //#endregion
181
- //#region src/specification/matching/structural.ts
330
+ //#region src/model/result/grep.ts
331
+ /**
332
+ * Extract text blocks from output that contain a pattern.
333
+ * Splits by blank lines (how linter/compiler output is structured),
334
+ * returns only blocks matching the pattern.
335
+ *
336
+ * @example
337
+ * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
338
+ * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
339
+ */
340
+ function grep(output, pattern) {
341
+ return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
342
+ }
343
+ //#endregion
344
+ //#region src/model/result/text.ts
345
+ const ANSI_RE = /\u001B\[[0-9;]*m/gu;
346
+ /** Strip ANSI escape sequences from a string. */
347
+ function stripAnsiCodes(value) {
348
+ return value.replace(ANSI_RE, "");
349
+ }
350
+ /**
351
+ * Read-only accessor for a captured text handle — THE universal one: stdout,
352
+ * stderr, container logs, and file text all surface as a `TextAccessor`.
353
+ *
354
+ * Backed by a primitive string, exposed via {@link text}, but also provides
355
+ * `toString()` / `valueOf()` for string coercion, so common patterns like
356
+ * `String(result.stdout)` and template-literal interpolation still work.
357
+ *
358
+ * ANSI escape sequences are stripped by default before every comparison
359
+ * (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
360
+ * an escape hatch for application noise not covered by the `{{token}}`
361
+ * grammar.
362
+ *
363
+ * Text operations are **closed** over the type: `.grep(pattern)` returns a
364
+ * `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
365
+ * context and the `transform`, so results are chainable
366
+ * (`result.stdout.grep(a).grep(b)`) and snapshot-able
367
+ * (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
368
+ *
369
+ * Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
370
+ * and `expect(result.stdout).toMatch('name.txt')` (resolved against
371
+ * `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
372
+ */
373
+ var TextAccessor = class TextAccessor {
374
+ /** @internal Ref-capture scope shared by the current spec execution. */
375
+ captures;
376
+ /** @internal Stream label used in failure messages ("stdout" / "stderr"). */
377
+ streamName;
378
+ /** @internal Test-file directory — fixture resolution root for matchers. */
379
+ testDir;
380
+ /** The raw captured text (never transformed, ANSI preserved). */
381
+ text;
382
+ /** @internal Normaliser applied before comparisons, never to fixtures. */
383
+ transform;
384
+ constructor(value, streamName, testDir, options = {}) {
385
+ this.text = value;
386
+ this.streamName = streamName;
387
+ this.testDir = testDir;
388
+ this.transform = options.transform;
389
+ this.captures = options.captures ?? new CaptureScope();
390
+ }
391
+ /** @internal The text as compared by matchers — ANSI stripped, transform applied. */
392
+ get comparableText() {
393
+ const stripped = stripAnsiCodes(this.text);
394
+ return this.transform ? this.transform(stripped) : stripped;
395
+ }
396
+ /**
397
+ * Keep only the blank-line-separated blocks of the text that contain
398
+ * `pattern` (how linter/compiler output is structured), returned as a new
399
+ * `TextAccessor` — the same subject type, so the result is chainable and
400
+ * snapshot-able. The scalpel for probing large tool outputs; snapshot the
401
+ * whole surface by default, reach for `.grep()` for targeted checks.
402
+ */
403
+ grep(pattern) {
404
+ return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
405
+ captures: this.captures,
406
+ transform: this.transform
407
+ });
408
+ }
409
+ toString() {
410
+ return this.text;
411
+ }
412
+ valueOf() {
413
+ return this.text;
414
+ }
415
+ };
416
+ //#endregion
417
+ //#region src/model/matching/structural.ts
182
418
  /**
183
419
  * Structural comparison engine shared by every fixture matcher.
184
420
  *
@@ -411,1154 +647,1164 @@ function textEquals(expected, actual, scope) {
411
647
  return placeholderStringMatches(expected, actual, scope);
412
648
  }
413
649
  //#endregion
414
- //#region src/specification/facets/_common/reporter.ts
415
- const GREEN = "\x1B[32m";
416
- const RED = "\x1B[31m";
417
- const RESET = "\x1B[0m";
418
- const strictEquals = (expected, actual) => expected === actual;
419
- function renderLines(expectedLines, actualLines, equals) {
420
- const rendered = [];
421
- let marked = 0;
422
- const maxLines = Math.max(expectedLines.length, actualLines.length);
423
- for (let i = 0; i < maxLines; i++) {
424
- const exp = expectedLines[i];
425
- const act = actualLines[i];
426
- if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
427
- rendered.push(` ${exp}`);
428
- continue;
429
- }
430
- marked++;
431
- if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
432
- if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
433
- }
434
- return {
435
- marked,
436
- rendered
437
- };
438
- }
650
+ //#region src/model/contracts/queue.ts
439
651
  /**
440
- * Render an expected/received diff over two texts.
652
+ * The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
653
+ * Both engines consume it: the MSW integration on api/jobs chains, and the
654
+ * declared stub backend on website/mobile chains.
441
655
  *
442
- * `equals` is how a line is judged EQUAL. Callers that compare through the
443
- * `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
444
- * matched is shown as equal — with its token, not as a `-`/`+` pair against
445
- * the concrete URL. Marking a line the comparison ACCEPTED buries the one it
446
- * rejected: the reader then has to work out which of the differences is the
447
- * actual cause.
656
+ * Selection: the FIRST contract in declaration order that matches the observed
657
+ * request and is not exhausted. A contract with no `times` is unlimited (a
658
+ * re-render or a retry replays it); `times: n` is spent after n serves, so an
659
+ * ordered sequence is written as finite contracts before an unlimited tail:
660
+ * `[error500 ×3, ok]`.
448
661
  *
449
- * Safety net: if the token-aware pass marks nothing at all, the whole diff
450
- * would render as a clean block against a failure that really happened (a
451
- * `#ref` captured inconsistently across lines, say). The strict rendering is
452
- * used instead, so a diff always shows the reader something.
662
+ * Strictness (CONVENTIONS D7): when nothing matches — including "everything
663
+ * that matches is exhausted" — the engine answers 501, records the request,
664
+ * and the chain fails with {@link ContractQueue.unmatchedError}. A chain that
665
+ * declared zero contracts is not guarded at all (unchanged boundary).
453
666
  */
454
- function formatStdoutDiff(file, expected, actual, options) {
455
- const lines = [];
456
- lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
457
- const expectedLines = expected.split("\n");
458
- const actualLines = actual.split("\n");
459
- const equals = options?.equals ?? strictEquals;
460
- const first = renderLines(expectedLines, actualLines, equals);
461
- const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
462
- return [...lines, ...rendered].join("\n");
463
- }
464
- //#endregion
465
- //#region src/specification/facets/_common/result/text-assertions.ts
466
- /** The fixture name carries its extension — the name IS the file. */
467
- function requireExtension(name, subject) {
468
- 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").`);
469
- }
470
- /**
471
- * Compare a captured stream against the CONTENT of its golden. Text snapshots
472
- * share the unified `{{token}}` grammar (CONVENTIONS D4), and the diff judges
473
- * each line through the SAME grammar the comparison used — so a token line
474
- * that matched is shown as equal instead of competing with the real mismatch
475
- * for the reader's attention.
476
- */
477
- function compareStreamText(accessor, name, expected) {
478
- const actual = accessor.comparableText;
479
- if (textEquals(expected, actual, accessor.captures)) return {
480
- message: () => `expected ${accessor.streamName} not to match _expected/${name}`,
481
- pass: true
667
+ /** Base used to parse origin-relative observed URLs (`/events?x=1`). */
668
+ const RELATIVE_BASE = "http://contract.invalid";
669
+ /** Split a declared url into its base (origin+path, or path) and query halves.
670
+ * Never through `new URL()`, which would percent-encode `{{token}}` braces. */
671
+ function splitDeclared(url) {
672
+ const separator = url.indexOf("?");
673
+ if (separator === -1) return {
674
+ base: url,
675
+ query: new URLSearchParams()
482
676
  };
483
677
  return {
484
- message: () => formatStdoutDiff(name, expected, actual, { equals: (expectedLine, actualLine) => textEquals(expectedLine, actualLine, new CaptureScope(accessor.captures.workdir)) }),
485
- pass: false
678
+ base: url.slice(0, separator),
679
+ query: new URLSearchParams(url.slice(separator + 1))
486
680
  };
487
681
  }
488
- /** `expect(stream).toBeEmpty()` — the stream you usually want silent. */
489
- function textIsEmpty(accessor) {
490
- const content = accessor.comparableText;
491
- const pass = content === "";
492
- return {
493
- message: () => pass ? `expected ${accessor.streamName} not to be empty` : `Expected ${accessor.streamName} to be empty, but it contains:\n${content}`,
494
- pass
495
- };
682
+ /** Decode percent-escapes, falling back to the raw text on malformed input. */
683
+ function safeDecode(text) {
684
+ try {
685
+ return decodeURIComponent(text);
686
+ } catch {
687
+ return text;
688
+ }
496
689
  }
497
- /** `expect(stream).toContain('…')` — the scalpel, never the default (D11). */
498
- function textContains(accessor, expected) {
499
- const actual = accessor.comparableText;
500
- const pass = actual.includes(expected);
501
- return {
502
- 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)}`,
503
- pass
504
- };
690
+ /** Token-aware comparison of one declared value against an observed one. */
691
+ function valueMatches(declared, observed) {
692
+ return textEquals(declared, observed, new CaptureScope());
505
693
  }
506
694
  /**
507
- * `toMatch` and `toContain` on a subject that is NOT an accessor.
508
- *
509
- * Both matchers are overridden globally, for every subject in the project, so
510
- * whatever the framework does not own has to keep meaning what vitest's own
511
- * matcher meant. Stated once here because the two builds register two different
512
- * `expect.extend` calls: a copy in the page is how `expect(['ab']).toContain('b')`
513
- * ends up passing on one side of the seam and failing on the other.
695
+ * Does an observed URL satisfy a declared one? A RegExp tests the full URL; a
696
+ * PATH FORM (`/articles/{{uuid}}`) ignores the origin; an absolute string
697
+ * compares origin+pathname. In both string forms `{{token}}` segments compare
698
+ * structurally and declared query params are a SUBSET of the observed ones.
514
699
  */
515
- /** vitest's `toMatch` for a plain string: a substring, or a regular expression. */
516
- function nativeMatch(received, expected) {
517
- if (typeof received !== "string") throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
518
- const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
519
- return {
520
- message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
521
- pass
522
- };
523
- }
524
- /** vitest's `toContain` for a plain string (substring) or an iterable (membership). */
525
- function nativeContain(received, expected) {
526
- if (typeof received === "string") {
527
- const pass = received.includes(String(expected));
528
- return {
529
- message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
530
- pass
531
- };
700
+ function urlMatches(declared, observed) {
701
+ if (declared instanceof RegExp) return declared.test(observed);
702
+ let url;
703
+ try {
704
+ url = new URL(observed, RELATIVE_BASE);
705
+ } catch {
706
+ return false;
532
707
  }
533
- if (isIterable(received)) {
534
- const pass = [...received].includes(expected);
535
- return {
536
- message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
537
- pass
538
- };
708
+ const { base, query } = splitDeclared(declared);
709
+ const observedBase = base.startsWith("/") ? url.pathname : `${url.origin}${url.pathname}`;
710
+ const decodedBase = base.startsWith("/") ? safeDecode(url.pathname) : `${url.origin}${safeDecode(url.pathname)}`;
711
+ if (!valueMatches(base, observedBase) && !valueMatches(base, decodedBase)) return false;
712
+ for (const key of new Set(query.keys())) {
713
+ const observedValues = url.searchParams.getAll(key);
714
+ if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
539
715
  }
540
- throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
541
- }
542
- /** Does this value hand out its members? A Set and an array both do; a DOM node does not. */
543
- function isIterable(value) {
544
- return value !== null && value !== void 0 && typeof value[Symbol.iterator] === "function";
716
+ return true;
545
717
  }
546
- //#endregion
547
- //#region src/specification/facets/_common/result/grep.ts
548
718
  /**
549
- * Extract text blocks from output that contain a pattern.
550
- * Splits by blank lines (how linter/compiler output is structured),
551
- * returns only blocks matching the pattern.
552
- *
553
- * @example
554
- * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
555
- * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
719
+ * Does an observed request satisfy a contract? Method (`*` matches any), URL,
720
+ * then the contract's own `match` predicate (headers/query/body filters).
556
721
  */
557
- function grep(output, pattern) {
558
- return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
559
- }
560
- //#endregion
561
- //#region src/specification/facets/_common/result/text.ts
562
- const ANSI_RE = /\u001B\[[0-9;]*m/gu;
563
- /** Strip ANSI escape sequences from a string. */
564
- function stripAnsiCodes(value) {
565
- return value.replace(ANSI_RE, "");
722
+ function contractMatches(contract, request) {
723
+ const { match, method, url } = contract.request;
724
+ if (method !== "*" && method.toUpperCase() !== request.method.toUpperCase()) return false;
725
+ if (!urlMatches(url, request.url)) return false;
726
+ return match ? match(request) : true;
566
727
  }
567
728
  /**
568
- * Read-only accessor for a captured text handle — THE universal one: stdout,
569
- * stderr, container logs, and file text all surface as a `TextAccessor`.
570
- *
571
- * Backed by a primitive string, exposed via {@link text}, but also provides
572
- * `toString()` / `valueOf()` for string coercion, so common patterns like
573
- * `String(result.stdout)` and template-literal interpolation still work.
574
- *
575
- * ANSI escape sequences are stripped by default before every comparison
576
- * (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
577
- * an escape hatch for application noise not covered by the `{{token}}`
578
- * grammar.
579
- *
580
- * Text operations are **closed** over the type: `.grep(pattern)` returns a
581
- * `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
582
- * context and the `transform`, so results are chainable
583
- * (`result.stdout.grep(a).grep(b)`) and snapshot-able
584
- * (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
585
- *
586
- * Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
587
- * and `expect(result.stdout).toMatch('name.txt')` (resolved against
588
- * `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
729
+ * The URL pattern an out-of-process router (MSW) must register so the request
730
+ * REACHES the queue. Deliberately no narrower than {@link contractMatches} —
731
+ * the predicate above stays the authority on what actually matches.
589
732
  */
590
- var TextAccessor = class TextAccessor {
591
- /** @internal Ref-capture scope shared by the current spec execution. */
592
- captures;
593
- /** @internal Stream label used in failure messages ("stdout" / "stderr"). */
594
- streamName;
595
- /** @internal Test-file directory — fixture resolution root for matchers. */
596
- testDir;
597
- /** The raw captured text (never transformed, ANSI preserved). */
598
- text;
599
- /** @internal Normaliser applied before comparisons, never to fixtures. */
600
- transform;
601
- constructor(value, streamName, testDir, options = {}) {
602
- this.text = value;
603
- this.streamName = streamName;
604
- this.testDir = testDir;
605
- this.transform = options.transform;
606
- this.captures = options.captures ?? new CaptureScope();
733
+ function routePatternOf(declared) {
734
+ if (declared instanceof RegExp) return declared;
735
+ const { base } = splitDeclared(declared);
736
+ if (base.startsWith("/")) return new RegExp(String.raw`^https?:\/\/[^/?#]+${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
737
+ if (hasPlaceholders(base)) return new RegExp(String.raw`^${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
738
+ return base;
739
+ }
740
+ /** How a declared contract reads in a failure message: spent, served, or silent. */
741
+ function stateOf({ contract, served }) {
742
+ const { times } = contract;
743
+ if (times !== void 0 && served >= times) return ` (exhausted after ${times})`;
744
+ return served === 0 ? "" : ` (served ${served} time(s))`;
745
+ }
746
+ var ContractQueue = class {
747
+ entries;
748
+ constructor(contracts) {
749
+ this.entries = contracts.map((contract) => ({
750
+ contract,
751
+ served: 0
752
+ }));
607
753
  }
608
- /** @internal The text as compared by matchers — ANSI stripped, transform applied. */
609
- get comparableText() {
610
- const stripped = stripAnsiCodes(this.text);
611
- return this.transform ? this.transform(stripped) : stripped;
754
+ /** How many contracts the chain declared. */
755
+ get size() {
756
+ return this.entries.length;
612
757
  }
613
758
  /**
614
- * Keep only the blank-line-separated blocks of the text that contain
615
- * `pattern` (how linter/compiler output is structured), returned as a new
616
- * `TextAccessor` — the same subject type, so the result is chainable and
617
- * snapshot-able. The scalpel for probing large tool outputs; snapshot the
618
- * whole surface by default, reach for `.grep()` for targeted checks.
759
+ * Routes to register with an out-of-process router, deduped by pattern —
760
+ * two contracts on the same URL share one handler and one queue.
619
761
  */
620
- grep(pattern) {
621
- return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
622
- captures: this.captures,
623
- transform: this.transform
624
- });
762
+ get routes() {
763
+ const routes = /* @__PURE__ */ new Map();
764
+ for (const { contract } of this.entries) {
765
+ const url = routePatternOf(contract.request.url);
766
+ const key = String(url);
767
+ const route = routes.get(key) ?? {
768
+ methods: [],
769
+ url
770
+ };
771
+ if (!route.methods.includes(contract.request.method)) route.methods.push(contract.request.method);
772
+ routes.set(key, route);
773
+ }
774
+ return [...routes.values()];
625
775
  }
626
- toString() {
627
- return this.text;
776
+ /** The declared routes, in order — the enumeration a 501 body carries. */
777
+ declaredRoutes() {
778
+ return this.entries.map(({ contract }) => describeRoute(contract));
628
779
  }
629
- valueOf() {
630
- return this.text;
780
+ /**
781
+ * Serve the first contract in declaration order that matches and is not
782
+ * exhausted, or null when nothing matches (including "everything that
783
+ * matches is spent") — the strict-violation signal.
784
+ */
785
+ take(request) {
786
+ for (const entry of this.entries) {
787
+ const { times } = entry.contract;
788
+ if (times !== void 0 && entry.served >= times) continue;
789
+ if (!contractMatches(entry.contract, request)) continue;
790
+ entry.served += 1;
791
+ return entry.contract;
792
+ }
793
+ return null;
631
794
  }
632
- };
633
- //#endregion
634
- //#region src/specification/facets/website/ambiguity.ts
635
- /**
636
- * The ambiguity refusal — CONVENTIONS W3.
637
- *
638
- * A visit descriptor must designate exactly one element. Acting on "the
639
- * first match" is the failure mode this module exists to prevent: the spec
640
- * keeps passing while the visitor clicks something else, and nothing ever
641
- * reports it. So when a descriptor matches several elements the framework
642
- * refuses, and the refusal has to carry everything needed to fix it without
643
- * opening a browser — the descriptor in the caller's own vocabulary, every
644
- * candidate, and the concrete rewrites that would resolve it.
645
- *
646
- * Pure string building: it takes captured data and returns a message, so the
647
- * wording is unit-testable and the browser integration stays a thin adapter.
648
- */
649
- /** `kind` → the constructor that builds it, so errors echo the caller's source. */
650
- const CONSTRUCTORS = {
651
- banner: "banner",
652
- button: "button",
653
- complementary: "complementary",
654
- contentinfo: "contentinfo",
655
- dialog: "dialog",
656
- field: "field",
657
- form: "form",
658
- heading: "heading",
659
- link: "link",
660
- listitem: "listitem",
661
- main: "main",
662
- navigation: "navigation",
663
- option: "option",
664
- region: "region",
665
- row: "row",
666
- search: "search",
667
- status: "status",
668
- table: "table",
669
- testId: "testId",
670
- text: "content"
671
- };
672
- /** The landmark a context string maps back to, for a copy-pasteable suggestion. */
673
- const CONTEXT_LANDMARKS = {
674
- aside: "complementary()",
675
- banner: "banner()",
676
- complementary: "complementary()",
677
- contentinfo: "contentinfo()",
678
- footer: "contentinfo()",
679
- form: "form()",
680
- header: "banner()",
681
- main: "main()",
682
- nav: "navigation()",
683
- navigation: "navigation()",
684
- search: "search()"
685
- };
686
- /**
687
- * Render a descriptor as the source that would build it — `link('Articles')`,
688
- * `within(navigation(), link('Articles', { exact: true }))`. The error speaks
689
- * the vocabulary the author wrote, never playwright's.
690
- */
691
- function formatElement(element) {
692
- const bare = formatBare(element);
693
- return element.scope ? `within(${formatElement(element.scope)}, ${bare})` : bare;
694
- }
695
- function formatBare(element) {
696
- const args = [];
697
- if (element.name !== void 0) args.push(JSON.stringify(element.name));
698
- if (element.exact) args.push("{ exact: true }");
699
- return `${CONSTRUCTORS[element.kind]}(${args.join(", ")})`;
700
- }
701
- /** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
702
- function formatMatch(match, index) {
703
- const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
704
- const named = match.accessibleName ? ` named ${JSON.stringify(match.accessibleName)}` : "";
705
- const context = match.context ? ` in <${match.context}>` : "";
706
- return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${named}${context}`;
707
- }
708
- function quoteDetail(match) {
709
- return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
710
- }
711
- /** Each landmark the candidates sit in, and how many of them it holds. */
712
- function landmarkCounts(matches) {
713
- const counts = /* @__PURE__ */ new Map();
714
- for (const match of matches) {
715
- const landmark = match.context === void 0 ? void 0 : CONTEXT_LANDMARKS[match.context];
716
- if (landmark !== void 0) counts.set(landmark, (counts.get(landmark) ?? 0) + 1);
717
- }
718
- return [...counts].toSorted(([, a], [, b]) => a - b);
719
- }
720
- /**
721
- * The rewrites worth offering, in the order they should be tried. Scoping is
722
- * always available; `exact` only when it would actually narrow the set, and
723
- * the count it would leave is stated so a still-ambiguous suggestion never
724
- * reads as a fix.
725
- */
726
- function formatFixes(element, matches) {
727
- const fixes = [];
728
- const narrowing = landmarkCounts(matches).filter(([, count]) => count < matches.length);
729
- if (!element.scope) {
730
- const [first, ...rest] = narrowing;
731
- const alsoHere = rest.length > 0 ? ` [also here: ${rest.map(([name]) => name).join(", ")}]` : "";
732
- 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}`);
795
+ /**
796
+ * The chain-end failure for contracts declared `required: true` that were
797
+ * never requested (or not exactly `times` times), or null when every
798
+ * requirement held.
799
+ */
800
+ requiredError() {
801
+ const unmet = [];
802
+ for (const { contract, served } of this.entries) {
803
+ if (!contract.required) continue;
804
+ const { times } = contract;
805
+ if (times === void 0) {
806
+ if (served === 0) unmet.push(` - ${describeRoute(contract)} — declared and required but never requested`);
807
+ } else if (served !== times) unmet.push(` - ${describeRoute(contract)} — declared required with times: ${times} but was requested ${served} time(s)`);
808
+ }
809
+ if (unmet.length === 0) return null;
810
+ 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.`);
733
811
  }
734
- if (!element.exact && element.name !== void 0) {
735
- const remaining = matches.filter((match) => match.text === element.name).length;
736
- if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
737
- ...element,
738
- exact: true
739
- })} [leaves ${remaining} of ${matches.length}]`);
812
+ /**
813
+ * The strict failure for a request that matched no contract (CONVENTIONS
814
+ * D7): method + URL of the offending request, plus every declared contract
815
+ * and how often it was served.
816
+ */
817
+ unmatchedError(method, url) {
818
+ const declared = this.entries.length === 0 ? " (no contracts declared)" : this.entries.map((entry) => ` - ${describeRoute(entry.contract)}${stateOf(entry)}`).join("\n");
819
+ 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).`);
740
820
  }
741
- fixes.push("other element a heading(), button() or field() may name one thing where this does not");
742
- return fixes;
743
- }
744
- /**
745
- * The W3 refusal. A distinct class so the visit wrapper can recognize an
746
- * already-complete message and let it through instead of nesting it inside
747
- * `visit scenario failed: …`, which would print the whole thing twice.
748
- */
749
- var AmbiguousElementError = class extends Error {
750
- name = "AmbiguousElementError";
751
821
  };
822
+ //#endregion
823
+ //#region src/model/timing/pause.ts
752
824
  /**
753
- * Build the refusal thrown when a descriptor matches more than one element.
825
+ * One answer to "wait" — the only `setTimeout` the model owns.
754
826
  *
755
- * @param options.element The descriptor as the caller wrote it.
756
- * @param options.matches Every candidate, in DOM order (already truncated).
757
- * @param options.url The page the visitor was on when it happened.
758
- */
759
- function describeAmbiguity(options) {
760
- const { element, matches, url } = options;
761
- const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
762
- return [
763
- `Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on ${url}.`,
764
- "",
765
- "A spec must designate exactly one element. Acting on the first match would let",
766
- "this test keep passing while the visitor interacts with something else.",
767
- "",
768
- "Matched:",
769
- ...matches.map((match, index) => formatMatch(match, index)),
770
- "",
771
- "Disambiguate with one of:",
772
- ...fixes,
773
- "",
774
- "Docs: docs/14-website.md#designating-exactly-one-element (CONVENTIONS W3)"
775
- ].join("\n");
827
+ * Two things in the framework genuinely have to wait: the spacing between the
828
+ * pieces of a declared stream, and the poll behind `waitUntil`. Everything
829
+ * else that waits is a spec doing it by hand, which is what J2 refuses. Stated
830
+ * once so there is one place to look when time stops behaving.
831
+ */
832
+ async function pause(ms) {
833
+ await new Promise((resolve) => {
834
+ setTimeout(resolve, ms);
835
+ });
776
836
  }
777
837
  //#endregion
778
- //#region src/integrations/vitest-browser/vitest-browser.adapter.ts
838
+ //#region src/model/contracts/stream.ts
779
839
  /**
780
- * The browser-mode adapter — the component facet's half of the one element
781
- * vocabulary.
782
- *
783
- * Its translation table is the playwright adapter's, descriptor for
784
- * descriptor: a `button('Save')` means `getByRole('button', { name })` whether
785
- * the surface is a served page or a mounted component, and the W3 refusal
786
- * enumerates candidates in the same words. Two adapters, one dialect — which
787
- * is the whole reason a page spec and a component spec read alike.
840
+ * A declared stream, made real — ONE serialisation for all three engines.
788
841
  *
789
- * What differs is where the code runs. Playwright drives a page from the
790
- * outside and reads it through `evaluate`; here the test IS in the page, so a
791
- * candidate is described from the live element and the ARIA tree is the one
792
- * thing that has to go back out (`server.commands`, the provider's own frame).
793
- */
794
- /** How many candidates the ambiguity error enumerates before truncating. */
795
- const MAX_REPORTED_MATCHES = 10;
796
- /** The landmark ancestors the ambiguity evidence names. */
797
- const LANDMARKS = "nav, header, footer, main, aside, section, form, [role]";
798
- /**
799
- * Translate a user-facing descriptor into a Browser Mode locator, resolving
800
- * `scope` outside-in so `within(navigation(), link('X'))` searches the nav
801
- * subtree. No `.first()` anywhere: designating exactly one element is
802
- * CONVENTIONS W3, and taking the first match is the silent wrong-element bug
803
- * the rule exists to prevent.
804
- */
805
- function locate(root, element) {
806
- const scope = element.scope ? locate(root, element.scope) : root;
807
- const exact = element.exact ?? false;
808
- const name = element.name ?? "";
809
- if (element.kind === "field") return scope.getByLabelText(name, { exact });
810
- if (element.kind === "testId") return scope.getByTestId(name);
811
- if (element.kind === "text") return scope.getByText(name, { exact });
812
- return scope.getByRole(element.kind, element.name === void 0 ? { exact } : {
813
- exact,
814
- name: element.name
815
- });
816
- }
817
- /** Describe the live candidates — the evidence the W3 refusal enumerates. */
818
- function candidates(locator) {
819
- return locator.elements().slice(0, MAX_REPORTED_MATCHES).map((node) => {
820
- const landmark = node.parentElement?.closest(LANDMARKS);
821
- const context = landmark?.getAttribute("role") ?? landmark?.tagName.toLowerCase();
822
- const detail = node.getAttribute("href") ?? node.getAttribute("name");
823
- const text = (node.textContent ?? "").replaceAll(/\s+/gu, " ").trim().slice(0, 80);
824
- const label = node.getAttribute("aria-label")?.trim();
825
- return {
826
- accessibleName: label !== void 0 && label !== text ? label : void 0,
827
- context: context ?? void 0,
828
- detail: detail ?? void 0,
829
- tag: node.tagName.toLowerCase(),
830
- text
831
- };
832
- });
833
- }
834
- /** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
835
- function ambiguousLevel(element) {
836
- const chain = [];
837
- for (let current = element; current; current = current.scope) chain.unshift(current);
838
- for (const level of chain.slice(0, -1)) if (locate(page, level).elements().length > 1) return level;
839
- return element;
842
+ * msw's server and msw's worker take a `ReadableStream` body verbatim
843
+ * (https://mswjs.io/docs/http/mocking-responses/streaming) and the `node:http`
844
+ * stub pipes one into its response, so what "arriving in pieces" MEANS —
845
+ * the order, the spacing, the close after the last chunk — is stated here and
846
+ * nowhere else. A second copy of it would be a second answer to "what does the
847
+ * subject read", and the facets would drift.
848
+ */
849
+ /**
850
+ * The pieces of `body`, in order, `delayBetweenChunks` milliseconds apart,
851
+ * closing after the last one.
852
+ */
853
+ function toReadableStream(body) {
854
+ const encoder = new TextEncoder();
855
+ let index = 0;
856
+ return new ReadableStream({ async pull(controller) {
857
+ const chunk = body.chunks[index];
858
+ if (chunk === void 0) {
859
+ controller.close();
860
+ return;
861
+ }
862
+ if (body.delayBetweenChunks > 0 && index > 0) await pause(body.delayBetweenChunks);
863
+ index += 1;
864
+ controller.enqueue(encoder.encode(chunk));
865
+ } });
840
866
  }
867
+ //#endregion
868
+ //#region src/model/contracts/types.ts
841
869
  /**
842
- * Run one visitor action, turning "more than one match" into the W3 refusal.
843
- * The ambiguous LEVEL is identified first: a scope matching several landmarks
844
- * is the real fault, and naming the target would send the author to fix the
845
- * wrong descriptor.
870
+ * The marker that tells a STREAMED body from a value that happens to be an
871
+ * object. A body reaches three different engines (msw's server, msw's worker,
872
+ * the `node:http` stub) and each of them serialises "an object" as JSON, so a
873
+ * stream declared as a plain value arrives as `{}`. The tag is what each
874
+ * engine recognises before it reaches for `JSON.stringify`.
846
875
  */
847
- async function act(element, action) {
848
- const locator = locate(page, element);
849
- try {
850
- return await action(locator);
851
- } catch (error) {
852
- if (locator.elements().length <= 1) throw error;
853
- const culprit = ambiguousLevel(element);
854
- const matches = candidates(locate(page, culprit));
855
- const url = globalThis.location.href;
856
- throw new AmbiguousElementError(describeAmbiguity({
857
- element: culprit,
858
- matches,
859
- url
860
- }));
861
- }
876
+ const STREAM_BODY = "@jterrazz/test:stream";
877
+ /** Is this body a declared stream rather than a value to serialise? */
878
+ function isStreamBody(body) {
879
+ return typeof body === "object" && body !== null && "kind" in body && body.kind === "@jterrazz/test:stream";
862
880
  }
881
+ //#endregion
882
+ //#region src/seams/msw/handlers.ts
863
883
  /**
864
- * Is this descriptor answered by PRESENCE rather than by visibility?
884
+ * A contract answers the request that was made, never a copy of an earlier one.
865
885
  *
866
- * The options of a collapsed `<select>` are in the document and in the
867
- * accessibility tree, and not one of them has a box on the screen — asking
868
- * whether an option is visible would answer "no" for every select a visitor
869
- * never opened.
886
+ * In a page the reply travels through a real service worker and a real HTTP
887
+ * cache, so a second render of the same component can be served the FIRST
888
+ * test's answer and never reach the queue at all — a contract that was
889
+ * exhausted, or replaced, silently keeps answering. Stated as a default, so a
890
+ * spec whose subject IS caching can still say otherwise.
870
891
  */
871
- function byPresence(element) {
872
- return element.kind === "option";
873
- }
874
- /** A control's live value — the property, never the attribute. */
875
- function valueOf(node) {
876
- return node instanceof HTMLInputElement || node instanceof HTMLSelectElement || node instanceof HTMLTextAreaElement ? node.value : void 0;
877
- }
878
- /**
879
- * Is the element gone — or, with a state modifier, no longer in that state? A
880
- * node that left the document and one that is still there but not rendered are
881
- * the same answer to a spec.
882
- */
883
- function isGone(locator, element) {
884
- const node = locator.query();
885
- if (element.focused === true) return node !== document.activeElement;
886
- if (element.disabled !== void 0) return node === null || node.matches(":disabled") !== element.disabled;
887
- if (element.selected !== void 0) return node === null || node.matches(":checked") !== element.selected;
888
- if (element.value !== void 0) return node === null || valueOf(node) !== element.value;
889
- return node === null || (byPresence(element) ? false : !node.checkVisibility());
890
- }
891
- /**
892
- * How long `gone()` waits — what is LEFT of the running test's budget.
893
- *
894
- * Every other verb already gets it: a locator action and `expect.element`
895
- * default their timeout to the remaining budget, and only a bare
896
- * `expect.poll` falls back to vitest's one second. That second made `gone()`
897
- * the one verb that could not outlast a slow answer — a button taken off the
898
- * screen when a two-second request returns was "still on the screen".
899
- *
900
- * The 100ms margin is vitest's own: the poll has to give up just BEFORE the
901
- * test does, or the failure is a bare timeout naming no element.
902
- */
903
- function remainingBudget() {
904
- const current = TestRunner.getCurrentTest();
905
- const started = current?.result?.startTime;
906
- if (current === void 0 || started === void 0) return;
907
- return Math.max(current.timeout - (Date.now() - started) - 100, 0);
908
- }
909
- /** `expect.poll` over that budget — the retry the matchers get for free. */
910
- function polling(probe, message) {
911
- const timeout = remainingBudget();
912
- return expect.poll(probe, {
913
- message,
914
- ...timeout === void 0 ? {} : { timeout }
915
- });
916
- }
917
- /** A key name is a key; a single character is typed as itself. */
918
- function keystroke(key) {
919
- return key.length > 1 ? `{${key}}` : key;
920
- }
921
- /** The verbs a rendered surface shares with a page, plus the two a parent owns. */
922
- function componentVerbs(surface) {
923
- return {
924
- check: async (element) => {
925
- await act(element, async (locator) => {
926
- const node = locator.element();
927
- if (!(node instanceof HTMLInputElement) || !node.checked) await locator.click();
928
- });
929
- },
930
- click: async (element) => {
931
- await act(element, async (locator) => {
932
- await locator.click();
933
- });
934
- },
935
- fill: async (element, value) => {
936
- await act(element, async (locator) => {
937
- await locator.fill(value);
938
- });
939
- },
940
- gone: async (element) => {
941
- await act(element, async (locator) => {
942
- await polling(() => isGone(locator, element), `${formatElement(element)} is still on the screen`).toBeTruthy();
943
- });
944
- },
945
- hover: async (element) => {
946
- await act(element, async (locator) => {
947
- await locator.hover();
948
- });
892
+ const NEVER_CACHED = { "cache-control": "no-store" };
893
+ /** Turn a contract response into the msw reply, body kind by body kind. */
894
+ function toMswResponse(msw, response) {
895
+ const { body, headers, status = 200 } = response;
896
+ if (response.transport === "network-error") return msw.HttpResponse.error();
897
+ if (body === null || body === void 0) return new msw.HttpResponse(null, {
898
+ headers: {
899
+ ...NEVER_CACHED,
900
+ ...headers
949
901
  },
950
- press: async (key) => {
951
- await userEvent.keyboard(keystroke(key));
902
+ status
903
+ });
904
+ if (isStreamBody(body)) return new msw.HttpResponse(toReadableStream(body), {
905
+ headers: {
906
+ ...NEVER_CACHED,
907
+ "content-type": body.contentType,
908
+ ...headers
952
909
  },
953
- rerender: surface.rerender,
954
- see: async (element) => {
955
- await act(element, async (locator) => {
956
- if (element.focused === true) {
957
- await expect.element(locator).toHaveFocus();
958
- return;
959
- }
960
- if (element.disabled === true) {
961
- await expect.element(locator).toBeDisabled();
962
- return;
963
- }
964
- if (element.disabled === false) {
965
- await expect.element(locator).toBeEnabled();
966
- return;
967
- }
968
- if (element.selected !== void 0) {
969
- await polling(() => locator.query()?.matches(":checked") === true, `${formatElement(element)} is not the selected one`).toBe(element.selected);
970
- return;
971
- }
972
- if (element.value !== void 0) {
973
- await expect.element(locator).toHaveValue(element.value);
974
- return;
975
- }
976
- if (byPresence(element)) {
977
- await expect.element(locator).toBeInTheDocument();
978
- return;
979
- }
980
- await expect.element(locator).toBeVisible();
981
- });
910
+ status
911
+ });
912
+ if (typeof body === "string") return new msw.HttpResponse(body, {
913
+ headers: {
914
+ ...NEVER_CACHED,
915
+ "content-type": "text/plain; charset=utf-8",
916
+ ...headers
982
917
  },
983
- select: async (element, option) => {
984
- await act(element, async (locator) => {
985
- await locator.selectOptions(option);
986
- });
918
+ status
919
+ });
920
+ return msw.HttpResponse.json(body, {
921
+ headers: {
922
+ ...NEVER_CACHED,
923
+ ...headers
987
924
  },
988
- unmount: surface.unmount
989
- };
990
- }
991
- /** The ARIA snapshot of the rendered `<body>` — the golden a tree is asserted as. */
992
- async function ariaTree() {
993
- return await commands.ariaTree();
994
- }
995
- /** A golden's content, read server-side from `_expected/` beside the test. */
996
- async function readGolden(name) {
997
- return await commands.goldenRead(name);
998
- }
999
- /** Write a golden server-side, and say where — update mode's half. */
1000
- async function writeGolden(name, content) {
1001
- return await commands.goldenWrite(name, content);
925
+ status
926
+ });
1002
927
  }
1003
- /** Mount a React subject through the optional `vitest-browser-react` adapter. */
1004
- async function mountReact(ui) {
1005
- let render;
1006
- try {
1007
- ({render} = await import("vitest-browser-react"));
1008
- } catch (error) {
1009
- 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 });
928
+ /**
929
+ * The handlers one chain's contracts become, plus the trailing catch-all that
930
+ * records any request no contract accepted (CONVENTIONS D7). The caller decides
931
+ * what to do with the recorded violation — the api chain rethrows it, the
932
+ * component chain fails the render. An EMPTY contract list is still a guard:
933
+ * the catch-all alone says "this subject has no network".
934
+ *
935
+ * `bypass` names the traffic that is not the subject's: under node there is
936
+ * none, in a page it is everything the runner fetches to BE a page.
937
+ */
938
+ function buildContractHandlers(msw, contracts, bypass) {
939
+ const queue = new ContractQueue(contracts);
940
+ let violation = null;
941
+ const recordViolation = (method, url) => {
942
+ violation ??= queue.unmatchedError(method, url);
943
+ return msw.HttpResponse.json({ error: `@jterrazz/test strict contracts: unmatched request ${method} ${url}` }, {
944
+ headers: NEVER_CACHED,
945
+ status: 501
946
+ });
947
+ };
948
+ const handlers = [];
949
+ for (const route of queue.routes) for (const method of route.methods) {
950
+ const handlerFn = method === "*" ? msw.http.all : msw.http[method.toLowerCase()];
951
+ if (!handlerFn) continue;
952
+ handlers.push(handlerFn(route.url, async ({ request }) => {
953
+ const observed = await observe(request);
954
+ const contract = queue.take(observed);
955
+ if (!contract) return recordViolation(request.method, request.url);
956
+ const response = typeof contract.response === "function" ? contract.response(observed) : contract.response;
957
+ if (response.delay) await new Promise((resolve) => setTimeout(resolve, response.delay));
958
+ return toMswResponse(msw, response);
959
+ }));
1010
960
  }
1011
- const rendered = await render(ui);
961
+ handlers.push(msw.http.all("*", ({ request }) => bypass?.(request.url) === true ? msw.passthrough() : recordViolation(request.method, request.url)));
1012
962
  return {
1013
- container: rendered.container,
1014
- rerender: rendered.rerender,
1015
- unmount: rendered.unmount
963
+ handlers,
964
+ violation: () => violation ?? queue.requiredError()
1016
965
  };
1017
966
  }
1018
- /**
1019
- * Mount a vanilla DOM subject: the function is handed a fresh container and
1020
- * may return its own teardown — the shape a DOM library states one in. There
1021
- * is no react here, and no react is loaded.
1022
- */
1023
- function mountDom(mount) {
1024
- const container = document.createElement("div");
1025
- document.body.append(container);
1026
- const teardown = mount(container);
967
+ /** The request as the queue matches it — body parsed when it is JSON. */
968
+ async function observe(request) {
969
+ let body = null;
970
+ const rawText = await request.clone().text();
971
+ if (rawText) try {
972
+ body = JSON.parse(rawText);
973
+ } catch {
974
+ body = rawText;
975
+ }
976
+ const headers = {};
977
+ request.headers.forEach((value, key) => {
978
+ headers[key.toLowerCase()] = value;
979
+ });
1027
980
  return {
1028
- container,
1029
- rerender: () => {
1030
- throw new Error("rerender() replaces a React tree; a DOM function is re-rendered by calling it again in a new render().");
1031
- },
1032
- unmount: async () => {
1033
- teardown?.();
1034
- container.remove();
1035
- await Promise.resolve();
1036
- }
981
+ body,
982
+ headers,
983
+ method: request.method.toUpperCase(),
984
+ url: request.url
1037
985
  };
1038
986
  }
1039
987
  //#endregion
1040
- //#region src/integrations/vitest-browser/golden.ts
1041
- /** Update mode crosses the seam as a provided value, never as an env read. */
1042
- function updating() {
1043
- return inject("update") === true;
988
+ //#region src/seams/msw/worker.ts
989
+ /**
990
+ * What the runner itself fetches. Everything else a page asks for is the
991
+ * subject's own traffic and answers to its contracts (D7).
992
+ */
993
+ const RUNNER_PREFIXES = [
994
+ "/.artifacts/",
995
+ "/@fs/",
996
+ "/@id/",
997
+ "/@vite",
998
+ "/node_modules/",
999
+ "/__vitest"
1000
+ ];
1001
+ let workerInstance = null;
1002
+ /**
1003
+ * Is this the runner's own traffic rather than the subject's?
1004
+ *
1005
+ * In a page EVERY module is an HTTP request: the test file, the seam, the
1006
+ * dependency cache Vite optimised, the runner's own channel. None of them is
1007
+ * the component's network, and none of them can be declared by a contract, so
1008
+ * they are recognised by PREFIX — never by extension, because msw bypasses a
1009
+ * "common asset" path before `onUnhandledRequest` ever runs and an extension
1010
+ * test would let a real `/api/posts.json` through on the same ground.
1011
+ */
1012
+ function isRunnerRequest(url) {
1013
+ const { pathname } = new URL(url);
1014
+ return RUNNER_PREFIXES.some((prefix) => pathname.startsWith(prefix));
1044
1015
  }
1045
- async function matchGolden(accessor, name, frozen) {
1046
- requireExtension(name, "stream");
1047
- const actual = accessor.comparableText;
1048
- const stored = await readGolden(name);
1049
- if (stored === null) {
1050
- if (updating() && !frozen) {
1051
- const path = await writeGolden(name, actual);
1052
- return {
1053
- message: () => `wrote ${path}`,
1054
- pass: true
1055
- };
1056
- }
1057
- return {
1058
- 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.`,
1059
- pass: false
1060
- };
1061
- }
1062
- const comparison = compareStreamText(accessor, name, stored);
1063
- if (!comparison.pass && updating() && !frozen) {
1064
- await writeGolden(name, actual);
1065
- return {
1066
- message: () => `updated ${GROUND_EXPECTED}/${name}`,
1067
- pass: true
1068
- };
1069
- }
1070
- return comparison;
1071
- }
1072
- async function toMatch(received, expected, options) {
1073
- if (received instanceof TextAccessor) {
1074
- 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/).");
1075
- return await matchGolden(received, expected, options?.frozen === true);
1076
- }
1077
- return nativeMatch(received, expected);
1078
- }
1079
- function toContain(received, expected) {
1080
- if (received instanceof TextAccessor) return textContains(received, String(expected));
1081
- return nativeContain(received, expected);
1082
- }
1083
- function toBeEmpty(received) {
1084
- if (!(received instanceof TextAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected a text accessor.");
1085
- return textIsEmpty(received);
1086
- }
1087
- let registered = false;
1088
1016
  /**
1089
- * Register the page's matchers with vitest's `expect`. Idempotent, and called
1090
- * from the browser entry itself: a component test imports `@jterrazz/test` to
1091
- * get `component`, and that import IS the registration.
1017
+ * Start the worker once per page. The script is served at `/mockServiceWorker.js`
1018
+ * by the plugin `component()` registers, out of the package's OWN `msw`
1019
+ * install — so no consumer ever runs `msw init` and no consumer declares `msw`.
1092
1020
  */
1093
- function registerBrowserMatchers() {
1094
- if (registered) return;
1095
- registered = true;
1096
- expect.extend({
1097
- toBeEmpty,
1098
- toContain,
1099
- toMatch
1021
+ async function ensureContractWorker() {
1022
+ if (workerInstance !== null) return;
1023
+ const { setupWorker } = await import("msw/browser");
1024
+ workerInstance = setupWorker();
1025
+ await workerInstance.start({
1026
+ onUnhandledRequest: (request, print) => {
1027
+ if (!isRunnerRequest(request.url)) print.error();
1028
+ },
1029
+ quiet: true,
1030
+ serviceWorker: { url: "/mockServiceWorker.js" }
1100
1031
  });
1101
1032
  }
1102
- //#endregion
1103
- //#region src/browser/node-only.ts
1104
1033
  /**
1105
- * The refusals the browser build ships in place of what only node can do.
1034
+ * Register one render's contracts on the page's worker.
1106
1035
  *
1107
- * `@jterrazz/test` has ONE specifier and two runtimes, so the page gets the
1108
- * same NAMES as the server: a component test that reaches for
1109
- * `specification.api()` or `postgres()` fails on the line that reached, saying
1110
- * where that name runs — not on an unresolved import three layers down, and
1111
- * not silently on `undefined`.
1112
- */
1113
- /**
1114
- * The refusal, in one line, whatever the name. Returns `never`, so a stub is
1115
- * an ordinary arrow function the page's entry annotates with the node
1116
- * signature — no cast, and no way for the two surfaces to disagree.
1036
+ * The worker starts even when the list is empty: on this facet D7 is TOTAL —
1037
+ * a component that was given no contract has no network, and a fetch it makes
1038
+ * anyway is named by {@link ContractRegistration.violation} rather than
1039
+ * answered by whatever the dev server happens to have at that path.
1117
1040
  */
1118
- function refuse(name) {
1119
- 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.`);
1041
+ async function registerWorkerContracts(contracts) {
1042
+ await ensureContractWorker();
1043
+ const { handlers, violation } = buildContractHandlers(await import("msw"), contracts, isRunnerRequest);
1044
+ workerInstance.use(...handlers);
1045
+ return {
1046
+ cleanup: () => {
1047
+ workerInstance.resetHandlers();
1048
+ },
1049
+ violation
1050
+ };
1051
+ }
1052
+ /** Drop every handler the page still carries — the per-test reset. */
1053
+ function resetWorkerContracts() {
1054
+ if (workerInstance !== null) workerInstance.resetHandlers();
1120
1055
  }
1056
+ //#endregion
1057
+ //#region src/model/chain/ground.ts
1121
1058
  /**
1122
- * A node-only CLASS, present under the same name. A class has a shape a stub
1123
- * cannot honour — every instance member of the real one — so this is the one
1124
- * place the page's entry states a type it does not structurally satisfy; what
1125
- * the value can actually do is throw, which is the whole of the contract.
1059
+ * The GROUND a row of specs stands on — the naming law of a spec tree.
1126
1060
  *
1127
- * The stub is a constructible FUNCTION, never an arrow: `new` on an arrow
1128
- * throws "is not a constructor" before the refusal can run, so the page would
1129
- * lose the one thing the stub exists to say — which name it reached for.
1061
+ * In a row of specs, a MEMBER is a spec: `<aspect>.test.ts`, `<case>.spec.yaml`,
1062
+ * `<name>.specification.ts`, and the facet/domain folders that hold them.
1063
+ * Everything else in that row is what the members STAND ON — inert material the
1064
+ * framework resolves BY PATH, never imported, never executed — and it carries a
1065
+ * LEADING UNDERSCORE. A spec's own folder never does.
1066
+ *
1067
+ * Four names, and only four: `_fixtures/` (file state), `_expected/` (goldens),
1068
+ * `_requests/` (inputs), `_seeds/` (database state). `contracts/` is NOT ground —
1069
+ * a contract is TypeScript a spec imports, behaviour composed with the spec's own,
1070
+ * so it stays a member. `docker/` is not ground either: it sits at the PROJECT
1071
+ * root beside `package.json`, outside any row of specs.
1072
+ *
1073
+ * This module is the single home of those names. Every resolver, matcher, lint
1074
+ * rule and checker pass reads them from here, so the runtime and the static
1075
+ * channels cannot disagree about where a spec's ground lives.
1130
1076
  */
1131
- const nodeOnlyClass = (name) => function NodeOnly() {
1132
- return refuse(name);
1133
- };
1077
+ /** Goldens: every expected fixture, flat, extension included. */
1078
+ const GROUND_EXPECTED = "_expected";
1134
1079
  //#endregion
1135
- //#region src/specification/contracts/contract.ts
1080
+ //#region src/model/chain/reporter.ts
1081
+ const GREEN = "\x1B[32m";
1082
+ const RED = "\x1B[31m";
1083
+ const RESET = "\x1B[0m";
1084
+ const strictEquals = (expected, actual) => expected === actual;
1085
+ function renderLines(expectedLines, actualLines, equals) {
1086
+ const rendered = [];
1087
+ let marked = 0;
1088
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
1089
+ for (let i = 0; i < maxLines; i++) {
1090
+ const exp = expectedLines[i];
1091
+ const act = actualLines[i];
1092
+ if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
1093
+ rendered.push(` ${exp}`);
1094
+ continue;
1095
+ }
1096
+ marked++;
1097
+ if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
1098
+ if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
1099
+ }
1100
+ return {
1101
+ marked,
1102
+ rendered
1103
+ };
1104
+ }
1136
1105
  /**
1137
- * Declare one contract. Identity function — its value is the enforced shape
1138
- * and the naming convention:
1139
- *
1140
- * @example
1141
- * // specs/api/reports/contracts/openai/classify-article.ts
1142
- * import { defineContract, openai } from '@jterrazz/test';
1106
+ * Render an expected/received diff over two texts.
1143
1107
  *
1144
- * export default defineContract({
1145
- * request: openai.responses({ user: PROMPT, tools: ['classify'] }),
1146
- * response: openai.reply({ categories: ['TECH'] }),
1147
- * });
1108
+ * `equals` is how a line is judged EQUAL. Callers that compare through the
1109
+ * `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
1110
+ * matched is shown as equal — with its token, not as a `-`/`+` pair against
1111
+ * the concrete URL. Marking a line the comparison ACCEPTED buries the one it
1112
+ * rejected: the reader then has to work out which of the differences is the
1113
+ * actual cause.
1148
1114
  *
1149
- * // Dynamic — the response is computed from the observed request:
1150
- * export default defineContract({
1151
- * request: http.post('https://api.example.com/echo'),
1152
- * response: (request) => http.json({ received: request.body }),
1153
- * });
1115
+ * Safety net: if the token-aware pass marks nothing at all, the whole diff
1116
+ * would render as a clean block against a failure that really happened (a
1117
+ * `#ref` captured inconsistently across lines, say). The strict rendering is
1118
+ * used instead, so a diff always shows the reader something.
1154
1119
  */
1155
- function defineContract(contract) {
1156
- return contract;
1157
- }
1158
- /** Is this a {@link Contracts} composite (rather than a contract or a list)? */
1159
- function isContracts(value) {
1160
- return typeof value === "object" && value !== null && Array.isArray(value.contracts) && typeof value.with === "function";
1120
+ function formatStdoutDiff(file, expected, actual, options) {
1121
+ const lines = [];
1122
+ lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
1123
+ const expectedLines = expected.split("\n");
1124
+ const actualLines = actual.split("\n");
1125
+ const equals = options?.equals ?? strictEquals;
1126
+ const first = renderLines(expectedLines, actualLines, equals);
1127
+ const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
1128
+ return [...lines, ...rendered].join("\n");
1161
1129
  }
1162
- /** Is this a single {@link Contract} (rather than a bare request half)? */
1163
- function isContract(value) {
1164
- return typeof value === "object" && value !== null && "request" in value && "response" in value;
1130
+ //#endregion
1131
+ //#region src/model/result/text-assertions.ts
1132
+ /** The fixture name carries its extension — the name IS the file. */
1133
+ function requireExtension(name, subject) {
1134
+ 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").`);
1165
1135
  }
1166
1136
  /**
1167
- * The route a contract claims — method + the DECLARED url source. Two
1168
- * contracts share a route when they would be written on the same line of a
1169
- * declaration: same method, same url pattern (`.source` for a RegExp).
1137
+ * Compare a captured stream against the CONTENT of its golden. Text snapshots
1138
+ * share the unified `{{token}}` grammar (CONVENTIONS D4), and the diff judges
1139
+ * each line through the SAME grammar the comparison used — so a token line
1140
+ * that matched is shown as equal instead of competing with the real mismatch
1141
+ * for the reader's attention.
1170
1142
  */
1171
- function routeKeyOf(contract) {
1172
- const { method, url } = contract.request;
1173
- return url instanceof RegExp ? `${method} re:${url.source}` : `${method} str:${url}`;
1143
+ function compareStreamText(accessor, name, expected) {
1144
+ const actual = accessor.comparableText;
1145
+ if (textEquals(expected, actual, accessor.captures)) return {
1146
+ message: () => `expected ${accessor.streamName} not to match _expected/${name}`,
1147
+ pass: true
1148
+ };
1149
+ return {
1150
+ message: () => formatStdoutDiff(name, expected, actual, { equals: (expectedLine, actualLine) => textEquals(expectedLine, actualLine, new CaptureScope(accessor.captures.workdir)) }),
1151
+ pass: false
1152
+ };
1174
1153
  }
1175
- /** Human-readable route of a contract — used in every failure message. */
1176
- function describeRoute(contract) {
1177
- const { method, url } = contract.request;
1178
- return `${method === "*" ? "ANY" : method} ${url instanceof RegExp ? String(url) : url}`;
1154
+ /** `expect(stream).toBeEmpty()` — the stream you usually want silent. */
1155
+ function textIsEmpty(accessor) {
1156
+ const content = accessor.comparableText;
1157
+ const pass = content === "";
1158
+ return {
1159
+ message: () => pass ? `expected ${accessor.streamName} not to be empty` : `Expected ${accessor.streamName} to be empty, but it contains:\n${content}`,
1160
+ pass
1161
+ };
1179
1162
  }
1180
- /** Flatten contracts, lists, and composites into one ordered list. */
1181
- function flatten(items) {
1182
- const flat = [];
1183
- for (const item of items) if (Array.isArray(item)) flat.push(...flatten(item));
1184
- else if (isContracts(item)) flat.push(...item.contracts);
1185
- else flat.push(item);
1186
- return flat;
1163
+ /** `expect(stream).toContain('…')` — the scalpel, never the default (D11). */
1164
+ function textContains(accessor, expected) {
1165
+ const actual = accessor.comparableText;
1166
+ const pass = actual.includes(expected);
1167
+ return {
1168
+ 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)}`,
1169
+ pass
1170
+ };
1187
1171
  }
1188
1172
  /**
1189
- * Compose contracts into the artifact a test imports — it's contracts all the
1190
- * way down: a composite may extend contracts, lists, and other composites,
1191
- * recursively, order preserved.
1173
+ * `toMatch` and `toContain` on a subject that is NOT an accessor.
1192
1174
  *
1193
- * `.with(...)` derives a scenario: contracts whose route the overrides claim
1194
- * are removed from the base, and the overrides are prepended. Under
1195
- * first-match selection a more specific override (`/articles/gone-1`) also
1196
- * wins over a generic base route (`/articles/{{uuid}}`) it does not replace.
1197
- *
1198
- * @example
1199
- * // contracts/newsroom.contracts.ts
1200
- * export default defineContracts(events, articleById);
1201
- * export const withArticleGone = (id: string) =>
1202
- * newsroom.with(articleGone(id));
1175
+ * Both matchers are overridden globally, for every subject in the project, so
1176
+ * whatever the framework does not own has to keep meaning what vitest's own
1177
+ * matcher meant. Stated once here because the two builds register two different
1178
+ * `expect.extend` calls: a copy in the page is how `expect(['ab']).toContain('b')`
1179
+ * ends up passing on one side of the seam and failing on the other.
1203
1180
  */
1204
- function defineContracts(...items) {
1205
- const contracts = Object.freeze(flatten(items));
1181
+ /** vitest's `toMatch` for a plain string: a substring, or a regular expression. */
1182
+ function nativeMatch(received, expected) {
1183
+ if (typeof received !== "string") throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
1184
+ const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
1206
1185
  return {
1207
- contracts,
1208
- with(...overrides) {
1209
- const added = flatten(overrides);
1210
- const claimed = new Set(added.map(routeKeyOf));
1211
- const kept = contracts.filter((contract) => !claimed.has(routeKeyOf(contract)));
1212
- return defineContracts([...added, ...kept]);
1213
- }
1186
+ message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
1187
+ pass
1214
1188
  };
1215
1189
  }
1216
- /** Normalize any accepted input into a flat contract list. */
1217
- function contractsOf(input) {
1218
- return flatten([input]);
1190
+ /** vitest's `toContain` for a plain string (substring) or an iterable (membership). */
1191
+ function nativeContain(received, expected) {
1192
+ if (typeof received === "string") {
1193
+ const pass = received.includes(String(expected));
1194
+ return {
1195
+ message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1196
+ pass
1197
+ };
1198
+ }
1199
+ if (isIterable(received)) {
1200
+ const pass = [...received].includes(expected);
1201
+ return {
1202
+ message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1203
+ pass
1204
+ };
1205
+ }
1206
+ throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
1207
+ }
1208
+ /** Does this value hand out its members? A Set and an array both do; a DOM node does not. */
1209
+ function isIterable(value) {
1210
+ return value !== null && value !== void 0 && typeof value[Symbol.iterator] === "function";
1219
1211
  }
1220
1212
  //#endregion
1221
- //#region src/specification/contracts/queue.ts
1213
+ //#region src/model/elements/ambiguity.ts
1222
1214
  /**
1223
- * The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
1224
- * Both engines consume it: the MSW integration on api/jobs chains, and the
1225
- * declared stub backend on website/mobile chains.
1215
+ * The ambiguity refusal — CONVENTIONS W3.
1226
1216
  *
1227
- * Selection: the FIRST contract in declaration order that matches the observed
1228
- * request and is not exhausted. A contract with no `times` is unlimited (a
1229
- * re-render or a retry replays it); `times: n` is spent after n serves, so an
1230
- * ordered sequence is written as finite contracts before an unlimited tail:
1231
- * `[error500 ×3, ok]`.
1217
+ * A visit descriptor must designate exactly one element. Acting on "the
1218
+ * first match" is the failure mode this module exists to prevent: the spec
1219
+ * keeps passing while the visitor clicks something else, and nothing ever
1220
+ * reports it. So when a descriptor matches several elements the framework
1221
+ * refuses, and the refusal has to carry everything needed to fix it without
1222
+ * opening a browser — the descriptor in the caller's own vocabulary, every
1223
+ * candidate, and the concrete rewrites that would resolve it.
1232
1224
  *
1233
- * Strictness (CONVENTIONS D7): when nothing matches — including "everything
1234
- * that matches is exhausted" — the engine answers 501, records the request,
1235
- * and the chain fails with {@link ContractQueue.unmatchedError}. A chain that
1236
- * declared zero contracts is not guarded at all (unchanged boundary).
1225
+ * Pure string building: it takes captured data and returns a message, so the
1226
+ * wording is unit-testable and the browser integration stays a thin adapter.
1237
1227
  */
1238
- /** Base used to parse origin-relative observed URLs (`/events?x=1`). */
1239
- const RELATIVE_BASE = "http://contract.invalid";
1240
- /** Split a declared url into its base (origin+path, or path) and query halves.
1241
- * Never through `new URL()`, which would percent-encode `{{token}}` braces. */
1242
- function splitDeclared(url) {
1243
- const separator = url.indexOf("?");
1244
- if (separator === -1) return {
1245
- base: url,
1246
- query: new URLSearchParams()
1247
- };
1248
- return {
1249
- base: url.slice(0, separator),
1250
- query: new URLSearchParams(url.slice(separator + 1))
1251
- };
1228
+ /** `kind` → the constructor that builds it, so errors echo the caller's source. */
1229
+ const CONSTRUCTORS = {
1230
+ banner: "banner",
1231
+ button: "button",
1232
+ complementary: "complementary",
1233
+ contentinfo: "contentinfo",
1234
+ dialog: "dialog",
1235
+ field: "field",
1236
+ form: "form",
1237
+ heading: "heading",
1238
+ link: "link",
1239
+ listitem: "listitem",
1240
+ main: "main",
1241
+ navigation: "navigation",
1242
+ option: "option",
1243
+ region: "region",
1244
+ row: "row",
1245
+ search: "search",
1246
+ status: "status",
1247
+ table: "table",
1248
+ testId: "testId",
1249
+ text: "content"
1250
+ };
1251
+ /** The landmark a context string maps back to, for a copy-pasteable suggestion. */
1252
+ const CONTEXT_LANDMARKS = {
1253
+ aside: "complementary()",
1254
+ banner: "banner()",
1255
+ complementary: "complementary()",
1256
+ contentinfo: "contentinfo()",
1257
+ footer: "contentinfo()",
1258
+ form: "form()",
1259
+ header: "banner()",
1260
+ main: "main()",
1261
+ nav: "navigation()",
1262
+ navigation: "navigation()",
1263
+ search: "search()"
1264
+ };
1265
+ /**
1266
+ * Render a descriptor as the source that would build it — `link('Articles')`,
1267
+ * `within(navigation(), link('Articles', { exact: false }))`. The error speaks
1268
+ * the vocabulary the author wrote, never playwright's — so the option appears
1269
+ * only when the AUTHOR stated one, whichever way.
1270
+ */
1271
+ function formatElement(element) {
1272
+ const bare = formatBare(element);
1273
+ return element.scope ? `within(${formatElement(element.scope)}, ${bare})` : bare;
1252
1274
  }
1253
- /** Decode percent-escapes, falling back to the raw text on malformed input. */
1254
- function safeDecode(text) {
1255
- try {
1256
- return decodeURIComponent(text);
1257
- } catch {
1258
- return text;
1259
- }
1275
+ function formatBare(element) {
1276
+ const args = [];
1277
+ if (element.name !== void 0) args.push(JSON.stringify(element.name));
1278
+ if (element.exact !== void 0) args.push(`{ exact: ${String(element.exact)} }`);
1279
+ return `${CONSTRUCTORS[element.kind]}(${args.join(", ")})`;
1260
1280
  }
1261
- /** Token-aware comparison of one declared value against an observed one. */
1262
- function valueMatches(declared, observed) {
1263
- return textEquals(declared, observed, new CaptureScope());
1281
+ /** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
1282
+ function formatMatch(match, index) {
1283
+ const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
1284
+ const named = match.accessibleName ? ` named ${JSON.stringify(match.accessibleName)}` : "";
1285
+ const context = match.context ? ` in <${match.context}>` : "";
1286
+ return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${named}${context}`;
1287
+ }
1288
+ function quoteDetail(match) {
1289
+ return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
1290
+ }
1291
+ /** Each landmark the candidates sit in, and how many of them it holds. */
1292
+ function landmarkCounts(matches) {
1293
+ const counts = /* @__PURE__ */ new Map();
1294
+ for (const match of matches) {
1295
+ const landmark = match.context === void 0 ? void 0 : CONTEXT_LANDMARKS[match.context];
1296
+ if (landmark !== void 0) counts.set(landmark, (counts.get(landmark) ?? 0) + 1);
1297
+ }
1298
+ return [...counts].toSorted(([, a], [, b]) => a - b);
1264
1299
  }
1265
1300
  /**
1266
- * Does an observed URL satisfy a declared one? A RegExp tests the full URL; a
1267
- * PATH FORM (`/articles/{{uuid}}`) ignores the origin; an absolute string
1268
- * compares origin+pathname. In both string forms `{{token}}` segments compare
1269
- * structurally and declared query params are a SUBSET of the observed ones.
1301
+ * The rewrites worth offering, in the order they should be tried. Scoping is
1302
+ * always available; `exact` only when it would actually narrow the set, and
1303
+ * the count it would leave is stated so a still-ambiguous suggestion never
1304
+ * reads as a fix.
1270
1305
  */
1271
- function urlMatches(declared, observed) {
1272
- if (declared instanceof RegExp) return declared.test(observed);
1273
- let url;
1274
- try {
1275
- url = new URL(observed, RELATIVE_BASE);
1276
- } catch {
1277
- return false;
1306
+ function formatFixes(element, matches) {
1307
+ const fixes = [];
1308
+ const narrowing = landmarkCounts(matches).filter(([, count]) => count < matches.length);
1309
+ if (!element.scope) {
1310
+ const [first, ...rest] = narrowing;
1311
+ const alsoHere = rest.length > 0 ? ` [also here: ${rest.map(([name]) => name).join(", ")}]` : "";
1312
+ 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}`);
1278
1313
  }
1279
- const { base, query } = splitDeclared(declared);
1280
- const observedBase = base.startsWith("/") ? url.pathname : `${url.origin}${url.pathname}`;
1281
- const decodedBase = base.startsWith("/") ? safeDecode(url.pathname) : `${url.origin}${safeDecode(url.pathname)}`;
1282
- if (!valueMatches(base, observedBase) && !valueMatches(base, decodedBase)) return false;
1283
- for (const key of new Set(query.keys())) {
1284
- const observedValues = url.searchParams.getAll(key);
1285
- if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
1314
+ if (element.exact === false && element.name !== void 0) {
1315
+ const remaining = matches.filter((match) => match.text === element.name).length;
1316
+ if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
1317
+ ...element,
1318
+ exact: true
1319
+ })} [leaves ${remaining} of ${matches.length}]`);
1286
1320
  }
1287
- return true;
1321
+ fixes.push("other element a heading(), button() or field() may name one thing where this does not");
1322
+ return fixes;
1288
1323
  }
1289
1324
  /**
1290
- * Does an observed request satisfy a contract? Method (`*` matches any), URL,
1291
- * then the contract's own `match` predicate (headers/query/body filters).
1325
+ * The W3 refusal. A distinct class so the visit wrapper can recognize an
1326
+ * already-complete message and let it through instead of nesting it inside
1327
+ * `visit scenario failed: …`, which would print the whole thing twice.
1292
1328
  */
1293
- function contractMatches(contract, request) {
1294
- const { match, method, url } = contract.request;
1295
- if (method !== "*" && method.toUpperCase() !== request.method.toUpperCase()) return false;
1296
- if (!urlMatches(url, request.url)) return false;
1297
- return match ? match(request) : true;
1298
- }
1329
+ var AmbiguousElementError = class extends Error {
1330
+ name = "AmbiguousElementError";
1331
+ };
1299
1332
  /**
1300
- * The URL pattern an out-of-process router (MSW) must register so the request
1301
- * REACHES the queue. Deliberately no narrower than {@link contractMatches} —
1302
- * the predicate above stays the authority on what actually matches.
1333
+ * Build the refusal thrown when a descriptor matches more than one element.
1334
+ *
1335
+ * @param options.element The descriptor as the caller wrote it.
1336
+ * @param options.matches Every candidate, in DOM order (already truncated).
1337
+ * @param options.url The page the visitor was on when it happened.
1303
1338
  */
1304
- function routePatternOf(declared) {
1305
- if (declared instanceof RegExp) return declared;
1306
- const { base } = splitDeclared(declared);
1307
- if (base.startsWith("/")) return new RegExp(String.raw`^https?:\/\/[^/?#]+${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
1308
- if (hasPlaceholders(base)) return new RegExp(String.raw`^${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
1309
- return base;
1339
+ function describeAmbiguity(options) {
1340
+ const { element, matches, url } = options;
1341
+ const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
1342
+ return [
1343
+ `Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on ${url}.`,
1344
+ "",
1345
+ "A spec must designate exactly one element. Acting on the first match would let",
1346
+ "this test keep passing while the visitor interacts with something else.",
1347
+ "",
1348
+ "Matched:",
1349
+ ...matches.map((match, index) => formatMatch(match, index)),
1350
+ "",
1351
+ "Disambiguate with one of:",
1352
+ ...fixes,
1353
+ "",
1354
+ "Docs: docs/13-elements.md#designating-exactly-one-element (CONVENTIONS W3)"
1355
+ ].join("\n");
1310
1356
  }
1311
- /** How a declared contract reads in a failure message: spent, served, or silent. */
1312
- function stateOf({ contract, served }) {
1313
- const { times } = contract;
1314
- if (times !== void 0 && served >= times) return ` (exhausted after ${times})`;
1315
- return served === 0 ? "" : ` (served ${served} time(s))`;
1357
+ //#endregion
1358
+ //#region src/model/elements/substring-warning.ts
1359
+ /**
1360
+ * The transitional warning for a name that matches only as a SUBSTRING.
1361
+ *
1362
+ * Until 16.0 a descriptor's name matched any element whose accessible name
1363
+ * CONTAINED it, so `link('Articles')` also designated "Read Articles" — and a
1364
+ * test could pass for years against the wrong element without anyone noticing,
1365
+ * because the wrong element was usually next to the right one. Exact is the
1366
+ * default now.
1367
+ *
1368
+ * A lint rule cannot carry this: whether a name matched whole or in part is a
1369
+ * fact about a RUN, and a verdict fed by a previous run's artefact is not
1370
+ * deterministic. So it is a runtime warning, printed by the adapter that did
1371
+ * the looking, at the moment it has both answers in hand — nothing found under
1372
+ * exact, something found under substring.
1373
+ *
1374
+ * It is printed ONCE per descriptor per process: a scenario that clicks the
1375
+ * same link in ten tests has one thing to fix, not ten lines to read.
1376
+ */
1377
+ /** How long the two-release window lasts, stated in the message itself. */
1378
+ const WINDOW = "16.0 and 16.1";
1379
+ /** Descriptors already reported in this process. */
1380
+ const reported = /* @__PURE__ */ new Set();
1381
+ /** The identity a descriptor is deduplicated by — kind, name, and its scope chain. */
1382
+ function identityOf(element) {
1383
+ const own = `${element.kind}:${element.name ?? ""}`;
1384
+ return element.scope === void 0 ? own : `${identityOf(element.scope)}>${own}`;
1385
+ }
1386
+ /**
1387
+ * Report that `element` designated nothing as a whole name, but would have
1388
+ * designated something as a substring. Returns whether a line was printed,
1389
+ * which is what the adapters' own tests assert on.
1390
+ */
1391
+ function warnSubstringOnly(element, where) {
1392
+ const identity = identityOf(element);
1393
+ if (reported.has(identity)) return false;
1394
+ reported.add(identity);
1395
+ 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}). Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. This warning ships for ${WINDOW}; after that the substring match is gone and this is simply not found.`);
1396
+ return true;
1316
1397
  }
1317
- var ContractQueue = class {
1318
- entries;
1319
- constructor(contracts) {
1320
- this.entries = contracts.map((contract) => ({
1321
- contract,
1322
- served: 0
1398
+ //#endregion
1399
+ //#region src/seams/vitest-browser/vitest-browser.adapter.ts
1400
+ /**
1401
+ * The browser-mode adapter — the component facet's half of the one element
1402
+ * vocabulary.
1403
+ *
1404
+ * Its translation table is the playwright adapter's, descriptor for
1405
+ * descriptor: a `button('Save')` means `getByRole('button', { name })` whether
1406
+ * the surface is a served page or a mounted component, and the W3 refusal
1407
+ * enumerates candidates in the same words. Two adapters, one dialect — which
1408
+ * is the whole reason a page spec and a component spec read alike.
1409
+ *
1410
+ * What differs is where the code runs. Playwright drives a page from the
1411
+ * outside and reads it through `evaluate`; here the test IS in the page, so a
1412
+ * candidate is described from the live element and the ARIA tree is the one
1413
+ * thing that has to go back out (`server.commands`, the provider's own frame).
1414
+ */
1415
+ /** How many candidates the ambiguity error enumerates before truncating. */
1416
+ const MAX_REPORTED_MATCHES = 10;
1417
+ /** The landmark ancestors the ambiguity evidence names. */
1418
+ const LANDMARKS = "nav, header, footer, main, aside, section, form, [role]";
1419
+ /**
1420
+ * The roles a FIELD can carry — what `field('Name')` designates.
1421
+ *
1422
+ * A field is named by its ACCESSIBLE name, not by the text of the label
1423
+ * element that happens to wrap it: `<label><span>Channel</span><select>…` has
1424
+ * a label whose text carries every option of the select, while the control's
1425
+ * accessible name is `Channel` — which is what the ARIA tree shows and what a
1426
+ * screen reader reads. Resolving by label text alone made the ordinary
1427
+ * wrapping-label form unreachable under the 16.0 whole-name default.
1428
+ *
1429
+ * The label-text locator stays in the union for the controls ARIA gives no
1430
+ * role at all (a date or colour input), which nothing else would find.
1431
+ */
1432
+ const FIELD_ROLES = [
1433
+ "checkbox",
1434
+ "combobox",
1435
+ "radio",
1436
+ "searchbox",
1437
+ "slider",
1438
+ "spinbutton",
1439
+ "switch",
1440
+ "textbox"
1441
+ ];
1442
+ /**
1443
+ * Translate a user-facing descriptor into a Browser Mode locator, resolving
1444
+ * `scope` outside-in so `within(navigation(), link('X'))` searches the nav
1445
+ * subtree. No `.first()` anywhere: designating exactly one element is
1446
+ * CONVENTIONS W3, and taking the first match is the silent wrong-element bug
1447
+ * the rule exists to prevent.
1448
+ */
1449
+ function locate(root, element) {
1450
+ const scope = element.scope ? locate(root, element.scope) : root;
1451
+ const exact = element.exact ?? true;
1452
+ const name = element.name ?? "";
1453
+ if (element.kind === "field") {
1454
+ let field = scope.getByLabelText(name, { exact });
1455
+ for (const role of FIELD_ROLES) field = field.or(scope.getByRole(role, {
1456
+ exact,
1457
+ name
1323
1458
  }));
1459
+ return field;
1324
1460
  }
1325
- /** How many contracts the chain declared. */
1326
- get size() {
1327
- return this.entries.length;
1328
- }
1329
- /**
1330
- * Routes to register with an out-of-process router, deduped by pattern —
1331
- * two contracts on the same URL share one handler and one queue.
1332
- */
1333
- get routes() {
1334
- const routes = /* @__PURE__ */ new Map();
1335
- for (const { contract } of this.entries) {
1336
- const url = routePatternOf(contract.request.url);
1337
- const key = String(url);
1338
- const route = routes.get(key) ?? {
1339
- methods: [],
1340
- url
1341
- };
1342
- if (!route.methods.includes(contract.request.method)) route.methods.push(contract.request.method);
1343
- routes.set(key, route);
1344
- }
1345
- return [...routes.values()];
1346
- }
1347
- /** The declared routes, in order — the enumeration a 501 body carries. */
1348
- declaredRoutes() {
1349
- return this.entries.map(({ contract }) => describeRoute(contract));
1350
- }
1351
- /**
1352
- * Serve the first contract in declaration order that matches and is not
1353
- * exhausted, or null when nothing matches (including "everything that
1354
- * matches is spent") — the strict-violation signal.
1355
- */
1356
- take(request) {
1357
- for (const entry of this.entries) {
1358
- const { times } = entry.contract;
1359
- if (times !== void 0 && entry.served >= times) continue;
1360
- if (!contractMatches(entry.contract, request)) continue;
1361
- entry.served += 1;
1362
- return entry.contract;
1363
- }
1364
- return null;
1365
- }
1366
- /**
1367
- * The chain-end failure for contracts declared `required: true` that were
1368
- * never requested (or not exactly `times` times), or null when every
1369
- * requirement held.
1370
- */
1371
- requiredError() {
1372
- const unmet = [];
1373
- for (const { contract, served } of this.entries) {
1374
- if (!contract.required) continue;
1375
- const { times } = contract;
1376
- if (times === void 0) {
1377
- if (served === 0) unmet.push(` - ${describeRoute(contract)} — declared and required but never requested`);
1378
- } else if (served !== times) unmet.push(` - ${describeRoute(contract)} — declared required with times: ${times} but was requested ${served} time(s)`);
1461
+ if (element.kind === "testId") return scope.getByTestId(name);
1462
+ if (element.kind === "text") return scope.getByText(name, { exact });
1463
+ return scope.getByRole(element.kind, element.name === void 0 ? { exact } : {
1464
+ exact,
1465
+ name: element.name
1466
+ });
1467
+ }
1468
+ /** Describe the live candidates — the evidence the W3 refusal enumerates. */
1469
+ function candidates(locator) {
1470
+ return locator.elements().slice(0, MAX_REPORTED_MATCHES).map((node) => {
1471
+ const landmark = node.parentElement?.closest(LANDMARKS);
1472
+ const context = landmark?.getAttribute("role") ?? landmark?.tagName.toLowerCase();
1473
+ const detail = node.getAttribute("href") ?? node.getAttribute("name");
1474
+ const text = (node.textContent ?? "").replaceAll(/\s+/gu, " ").trim().slice(0, 80);
1475
+ const label = node.getAttribute("aria-label")?.trim();
1476
+ return {
1477
+ accessibleName: label !== void 0 && label !== text ? label : void 0,
1478
+ context: context ?? void 0,
1479
+ detail: detail ?? void 0,
1480
+ tag: node.tagName.toLowerCase(),
1481
+ text
1482
+ };
1483
+ });
1484
+ }
1485
+ /** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
1486
+ function ambiguousLevel(element) {
1487
+ const chain = [];
1488
+ for (let current = element; current; current = current.scope) chain.unshift(current);
1489
+ for (const level of chain.slice(0, -1)) if (locate(page, level).elements().length > 1) return level;
1490
+ return element;
1491
+ }
1492
+ /**
1493
+ * Run one visitor action, turning "more than one match" into the W3 refusal.
1494
+ * The ambiguous LEVEL is identified first: a scope matching several landmarks
1495
+ * is the real fault, and naming the target would send the author to fix the
1496
+ * wrong descriptor.
1497
+ */
1498
+ async function act(element, action) {
1499
+ const locator = locate(page, element);
1500
+ try {
1501
+ return await action(locator);
1502
+ } catch (error) {
1503
+ if (locator.elements().length <= 1) {
1504
+ reportSubstringOnly(element);
1505
+ throw error;
1379
1506
  }
1380
- if (unmet.length === 0) return null;
1381
- 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.`);
1382
- }
1383
- /**
1384
- * The strict failure for a request that matched no contract (CONVENTIONS
1385
- * D7): method + URL of the offending request, plus every declared contract
1386
- * and how often it was served.
1387
- */
1388
- unmatchedError(method, url) {
1389
- const declared = this.entries.length === 0 ? " (no contracts declared)" : this.entries.map((entry) => ` - ${describeRoute(entry.contract)}${stateOf(entry)}`).join("\n");
1390
- 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).`);
1507
+ const culprit = ambiguousLevel(element);
1508
+ const matches = candidates(locate(page, culprit));
1509
+ const url = globalThis.location.href;
1510
+ throw new AmbiguousElementError(describeAmbiguity({
1511
+ element: culprit,
1512
+ matches,
1513
+ url
1514
+ }));
1391
1515
  }
1392
- };
1393
- //#endregion
1394
- //#region src/integrations/msw/handlers.ts
1516
+ }
1395
1517
  /**
1396
- * A contract answers the request that was made, never a copy of an earlier one.
1518
+ * Say so when a descriptor found NOTHING as a whole name but would have found
1519
+ * something as a substring — the one shape the whole-name default changes.
1397
1520
  *
1398
- * In a page the reply travels through a real service worker and a real HTTP
1399
- * cache, so a second render of the same component can be served the FIRST
1400
- * test's answer and never reach the queue at all — a contract that was
1401
- * exhausted, or replaced, silently keeps answering. Stated as a default, so a
1402
- * spec whose subject IS caching can still say otherwise.
1521
+ * Asked only on the failure path, and only for a descriptor that stated no
1522
+ * `exact` of its own: an author who wrote `{ exact: false }` chose the
1523
+ * substring, and one who wrote `{ exact: true }` was already exact.
1403
1524
  */
1404
- const NEVER_CACHED = { "cache-control": "no-store" };
1405
- /** Turn a contract response into the msw reply, body kind by body kind. */
1406
- function toMswResponse(msw, response) {
1407
- const { body, headers, status = 200 } = response;
1408
- if (response.transport === "network-error") return msw.HttpResponse.error();
1409
- if (body === null || body === void 0) return new msw.HttpResponse(null, {
1410
- headers: {
1411
- ...NEVER_CACHED,
1412
- ...headers
1413
- },
1414
- status
1415
- });
1416
- if (typeof body === "string") return new msw.HttpResponse(body, {
1417
- headers: {
1418
- ...NEVER_CACHED,
1419
- "content-type": "text/plain; charset=utf-8",
1420
- ...headers
1421
- },
1422
- status
1423
- });
1424
- return msw.HttpResponse.json(body, {
1425
- headers: {
1426
- ...NEVER_CACHED,
1427
- ...headers
1428
- },
1429
- status
1430
- });
1525
+ function reportSubstringOnly(element) {
1526
+ if (element.exact !== void 0 || element.name === void 0) return;
1527
+ try {
1528
+ if (locate(page, element).elements().length > 0) return;
1529
+ if (locate(page, {
1530
+ ...element,
1531
+ exact: false
1532
+ }).elements().length > 0) warnSubstringOnly(element, globalThis.location.href);
1533
+ } catch {}
1431
1534
  }
1432
1535
  /**
1433
- * The handlers one chain's contracts become, plus the trailing catch-all that
1434
- * records any request no contract accepted (CONVENTIONS D7). The caller decides
1435
- * what to do with the recorded violation — the api chain rethrows it, the
1436
- * component chain fails the render. An EMPTY contract list is still a guard:
1437
- * the catch-all alone says "this subject has no network".
1536
+ * Is this descriptor answered by PRESENCE rather than by visibility?
1438
1537
  *
1439
- * `bypass` names the traffic that is not the subject's: under node there is
1440
- * none, in a page it is everything the runner fetches to BE a page.
1538
+ * The options of a collapsed `<select>` are in the document and in the
1539
+ * accessibility tree, and not one of them has a box on the screen — asking
1540
+ * whether an option is visible would answer "no" for every select a visitor
1541
+ * never opened.
1441
1542
  */
1442
- function buildContractHandlers(msw, contracts, bypass) {
1443
- const queue = new ContractQueue(contracts);
1444
- let violation = null;
1445
- const recordViolation = (method, url) => {
1446
- violation ??= queue.unmatchedError(method, url);
1447
- return msw.HttpResponse.json({ error: `@jterrazz/test strict contracts: unmatched request ${method} ${url}` }, {
1448
- headers: NEVER_CACHED,
1449
- status: 501
1450
- });
1543
+ function byPresence(element) {
1544
+ return element.kind === "option";
1545
+ }
1546
+ /** A control's live value — the property, never the attribute. */
1547
+ function valueOf(node) {
1548
+ return node instanceof HTMLInputElement || node instanceof HTMLSelectElement || node instanceof HTMLTextAreaElement ? node.value : void 0;
1549
+ }
1550
+ /**
1551
+ * Is the element gone — or, with a state modifier, no longer in that state? A
1552
+ * node that left the document and one that is still there but not rendered are
1553
+ * the same answer to a spec.
1554
+ */
1555
+ function isGone(locator, element) {
1556
+ const node = locator.query();
1557
+ if (element.focused === true) return node !== document.activeElement;
1558
+ if (element.disabled !== void 0) return node === null || node.matches(":disabled") !== element.disabled;
1559
+ if (element.selected !== void 0) return node === null || node.matches(":checked") !== element.selected;
1560
+ if (element.value !== void 0) return node === null || valueOf(node) !== element.value;
1561
+ return node === null || (byPresence(element) ? false : !node.checkVisibility());
1562
+ }
1563
+ /**
1564
+ * How long `gone()` waits — what is LEFT of the running test's budget.
1565
+ *
1566
+ * Every other verb already gets it: a locator action and `expect.element`
1567
+ * default their timeout to the remaining budget, and only a bare
1568
+ * `expect.poll` falls back to vitest's one second. That second made `gone()`
1569
+ * the one verb that could not outlast a slow answer — a button taken off the
1570
+ * screen when a two-second request returns was "still on the screen".
1571
+ *
1572
+ * The 100ms margin is vitest's own: the poll has to give up just BEFORE the
1573
+ * test does, or the failure is a bare timeout naming no element.
1574
+ */
1575
+ function remainingBudget() {
1576
+ const current = TestRunner.getCurrentTest();
1577
+ const started = current?.result?.startTime;
1578
+ if (current === void 0 || started === void 0) return;
1579
+ return Math.max(current.timeout - (Date.now() - started) - 100, 0);
1580
+ }
1581
+ /** `expect.poll` over that budget — the retry the matchers get for free. */
1582
+ function polling(probe, message) {
1583
+ const timeout = remainingBudget();
1584
+ return expect.poll(probe, {
1585
+ message,
1586
+ ...timeout === void 0 ? {} : { timeout }
1587
+ });
1588
+ }
1589
+ /** A key name is a key; a single character is typed as itself. */
1590
+ function keystroke(key) {
1591
+ return key.length > 1 ? `{${key}}` : key;
1592
+ }
1593
+ /** The verbs a rendered surface shares with a page, plus the two a parent owns. */
1594
+ function componentVerbs(surface) {
1595
+ return {
1596
+ check: async (element) => {
1597
+ await act(element, async (locator) => {
1598
+ const node = locator.element();
1599
+ if (!(node instanceof HTMLInputElement) || !node.checked) await locator.click();
1600
+ });
1601
+ },
1602
+ click: async (element) => {
1603
+ await act(element, async (locator) => {
1604
+ await locator.click();
1605
+ });
1606
+ },
1607
+ fill: async (element, value) => {
1608
+ await act(element, async (locator) => {
1609
+ await locator.fill(value);
1610
+ });
1611
+ },
1612
+ gone: async (element) => {
1613
+ await act(element, async (locator) => {
1614
+ await polling(() => isGone(locator, element), `${formatElement(element)} is still on the screen`).toBeTruthy();
1615
+ });
1616
+ },
1617
+ hover: async (element) => {
1618
+ await act(element, async (locator) => {
1619
+ await locator.hover();
1620
+ });
1621
+ },
1622
+ press: async (key) => {
1623
+ await userEvent.keyboard(keystroke(key));
1624
+ },
1625
+ rerender: surface.rerender,
1626
+ see: async (element) => {
1627
+ await act(element, async (locator) => {
1628
+ if (element.focused === true) {
1629
+ await expect.element(locator).toHaveFocus();
1630
+ return;
1631
+ }
1632
+ if (element.disabled === true) {
1633
+ await expect.element(locator).toBeDisabled();
1634
+ return;
1635
+ }
1636
+ if (element.disabled === false) {
1637
+ await expect.element(locator).toBeEnabled();
1638
+ return;
1639
+ }
1640
+ if (element.selected !== void 0) {
1641
+ await polling(() => locator.query()?.matches(":checked") === true, `${formatElement(element)} is not the selected one`).toBe(element.selected);
1642
+ return;
1643
+ }
1644
+ if (element.value !== void 0) {
1645
+ await expect.element(locator).toHaveValue(element.value);
1646
+ return;
1647
+ }
1648
+ if (byPresence(element)) {
1649
+ await expect.element(locator).toBeInTheDocument();
1650
+ return;
1651
+ }
1652
+ await expect.element(locator).toBeVisible();
1653
+ });
1654
+ },
1655
+ select: async (element, option) => {
1656
+ await act(element, async (locator) => {
1657
+ await locator.selectOptions(option);
1658
+ });
1659
+ },
1660
+ unmount: surface.unmount
1451
1661
  };
1452
- const handlers = [];
1453
- for (const route of queue.routes) for (const method of route.methods) {
1454
- const handlerFn = method === "*" ? msw.http.all : msw.http[method.toLowerCase()];
1455
- if (!handlerFn) continue;
1456
- handlers.push(handlerFn(route.url, async ({ request }) => {
1457
- const observed = await observe(request);
1458
- const contract = queue.take(observed);
1459
- if (!contract) return recordViolation(request.method, request.url);
1460
- const response = typeof contract.response === "function" ? contract.response(observed) : contract.response;
1461
- if (response.delay) await new Promise((resolve) => setTimeout(resolve, response.delay));
1462
- return toMswResponse(msw, response);
1463
- }));
1662
+ }
1663
+ /** The ARIA snapshot of the rendered `<body>` — the golden a tree is asserted as. */
1664
+ async function ariaTree() {
1665
+ return await commands.ariaTree();
1666
+ }
1667
+ /** A golden's content, read server-side from `_expected/` beside the test. */
1668
+ async function readGolden(name) {
1669
+ return await commands.goldenRead(name);
1670
+ }
1671
+ /** Write a golden server-side, and say where — update mode's half. */
1672
+ async function writeGolden(name, content) {
1673
+ return await commands.goldenWrite(name, content);
1674
+ }
1675
+ /** Mount a React subject through the optional `vitest-browser-react` adapter. */
1676
+ async function mountReact(ui) {
1677
+ let render;
1678
+ try {
1679
+ ({render} = await import("vitest-browser-react"));
1680
+ } catch (error) {
1681
+ 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 });
1464
1682
  }
1465
- handlers.push(msw.http.all("*", ({ request }) => bypass?.(request.url) === true ? msw.passthrough() : recordViolation(request.method, request.url)));
1683
+ const rendered = await render(ui);
1684
+ return {
1685
+ container: rendered.container,
1686
+ rerender: rendered.rerender,
1687
+ unmount: rendered.unmount
1688
+ };
1689
+ }
1690
+ /**
1691
+ * Mount a vanilla DOM subject: the function is handed a fresh container and
1692
+ * may return its own teardown — the shape a DOM library states one in. There
1693
+ * is no react here, and no react is loaded.
1694
+ */
1695
+ function mountDom(mount) {
1696
+ const container = document.createElement("div");
1697
+ document.body.append(container);
1698
+ const teardown = mount(container);
1466
1699
  return {
1467
- handlers,
1468
- violation: () => violation ?? queue.requiredError()
1700
+ container,
1701
+ rerender: () => {
1702
+ throw new Error("rerender() replaces a React tree; a DOM function is re-rendered by calling it again in a new render().");
1703
+ },
1704
+ unmount: async () => {
1705
+ teardown?.();
1706
+ container.remove();
1707
+ await Promise.resolve();
1708
+ }
1469
1709
  };
1470
1710
  }
1471
- /** The request as the queue matches it — body parsed when it is JSON. */
1472
- async function observe(request) {
1473
- let body = null;
1474
- const rawText = await request.clone().text();
1475
- if (rawText) try {
1476
- body = JSON.parse(rawText);
1477
- } catch {
1478
- body = rawText;
1711
+ //#endregion
1712
+ //#region src/seams/vitest-browser/golden.ts
1713
+ /** Update mode crosses the seam as a provided value, never as an env read. */
1714
+ function updating() {
1715
+ return inject("update") === true;
1716
+ }
1717
+ async function matchGolden(accessor, name, frozen) {
1718
+ requireExtension(name, "stream");
1719
+ const actual = accessor.comparableText;
1720
+ const stored = await readGolden(name);
1721
+ if (stored === null) {
1722
+ if (updating() && !frozen) {
1723
+ const path = await writeGolden(name, actual);
1724
+ return {
1725
+ message: () => `wrote ${path}`,
1726
+ pass: true
1727
+ };
1728
+ }
1729
+ return {
1730
+ 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.`,
1731
+ pass: false
1732
+ };
1479
1733
  }
1480
- const headers = {};
1481
- request.headers.forEach((value, key) => {
1482
- headers[key.toLowerCase()] = value;
1483
- });
1484
- return {
1485
- body,
1486
- headers,
1487
- method: request.method.toUpperCase(),
1488
- url: request.url
1489
- };
1734
+ const comparison = compareStreamText(accessor, name, stored);
1735
+ if (!comparison.pass && updating() && !frozen) {
1736
+ await writeGolden(name, actual);
1737
+ return {
1738
+ message: () => `updated ${GROUND_EXPECTED}/${name}`,
1739
+ pass: true
1740
+ };
1741
+ }
1742
+ return comparison;
1490
1743
  }
1491
- //#endregion
1492
- //#region src/integrations/msw/worker.ts
1744
+ async function toMatch(received, expected, options) {
1745
+ if (received instanceof TextAccessor) {
1746
+ 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/).");
1747
+ return await matchGolden(received, expected, options?.frozen === true);
1748
+ }
1749
+ return nativeMatch(received, expected);
1750
+ }
1751
+ function toContain(received, expected) {
1752
+ if (received instanceof TextAccessor) return textContains(received, String(expected));
1753
+ return nativeContain(received, expected);
1754
+ }
1755
+ function toBeEmpty(received) {
1756
+ if (!(received instanceof TextAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected a text accessor.");
1757
+ return textIsEmpty(received);
1758
+ }
1759
+ let registered = false;
1493
1760
  /**
1494
- * What the runner itself fetches. Everything else a page asks for is the
1495
- * subject's own traffic and answers to its contracts (D7).
1761
+ * Register the page's matchers with vitest's `expect`. Idempotent, and called
1762
+ * from the browser entry itself: a component test imports `@jterrazz/test` to
1763
+ * get `component`, and that import IS the registration.
1496
1764
  */
1497
- const RUNNER_PREFIXES = [
1498
- "/.artifacts/",
1499
- "/@fs/",
1500
- "/@id/",
1501
- "/@vite",
1502
- "/node_modules/",
1503
- "/__vitest"
1504
- ];
1505
- let workerInstance = null;
1765
+ function registerBrowserMatchers() {
1766
+ if (registered) return;
1767
+ registered = true;
1768
+ expect.extend({
1769
+ toBeEmpty,
1770
+ toContain,
1771
+ toMatch
1772
+ });
1773
+ }
1774
+ //#endregion
1775
+ //#region src/browser/node-only.ts
1506
1776
  /**
1507
- * Is this the runner's own traffic rather than the subject's?
1777
+ * The refusals the browser build ships in place of what only node can do.
1508
1778
  *
1509
- * In a page EVERY module is an HTTP request: the test file, the seam, the
1510
- * dependency cache Vite optimised, the runner's own channel. None of them is
1511
- * the component's network, and none of them can be declared by a contract, so
1512
- * they are recognised by PREFIX — never by extension, because msw bypasses a
1513
- * "common asset" path before `onUnhandledRequest` ever runs and an extension
1514
- * test would let a real `/api/posts.json` through on the same ground.
1779
+ * `@jterrazz/test` has ONE specifier and two runtimes, so the page gets the
1780
+ * same NAMES as the server: a component test that reaches for
1781
+ * `specification.api()` or `postgres()` fails on the line that reached, saying
1782
+ * where that name runs — not on an unresolved import three layers down, and
1783
+ * not silently on `undefined`.
1515
1784
  */
1516
- function isRunnerRequest(url) {
1517
- const { pathname } = new URL(url);
1518
- return RUNNER_PREFIXES.some((prefix) => pathname.startsWith(prefix));
1519
- }
1520
1785
  /**
1521
- * Start the worker once per page. The script is served at `/mockServiceWorker.js`
1522
- * by the plugin `component()` registers, out of the package's OWN `msw`
1523
- * install — so no consumer ever runs `msw init` and no consumer declares `msw`.
1786
+ * The refusal, in one line, whatever the name. Returns `never`, so a stub is
1787
+ * an ordinary arrow function the page's entry annotates with the node
1788
+ * signature — no cast, and no way for the two surfaces to disagree.
1524
1789
  */
1525
- async function ensureContractWorker() {
1526
- if (workerInstance !== null) return;
1527
- const { setupWorker } = await import("msw/browser");
1528
- workerInstance = setupWorker();
1529
- await workerInstance.start({
1530
- onUnhandledRequest: (request, print) => {
1531
- if (!isRunnerRequest(request.url)) print.error();
1532
- },
1533
- quiet: true,
1534
- serviceWorker: { url: "/mockServiceWorker.js" }
1535
- });
1790
+ function refuse(name) {
1791
+ 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.`);
1536
1792
  }
1537
1793
  /**
1538
- * Register one render's contracts on the page's worker.
1794
+ * A node-only CLASS, present under the same name. A class has a shape a stub
1795
+ * cannot honour — every instance member of the real one — so this is the one
1796
+ * place the page's entry states a type it does not structurally satisfy; what
1797
+ * the value can actually do is throw, which is the whole of the contract.
1539
1798
  *
1540
- * The worker starts even when the list is empty: on this facet D7 is TOTAL —
1541
- * a component that was given no contract has no network, and a fetch it makes
1542
- * anyway is named by {@link ContractRegistration.violation} rather than
1543
- * answered by whatever the dev server happens to have at that path.
1799
+ * The stub is a constructible FUNCTION, never an arrow: `new` on an arrow
1800
+ * throws "is not a constructor" before the refusal can run, so the page would
1801
+ * lose the one thing the stub exists to say — which name it reached for.
1544
1802
  */
1545
- async function registerWorkerContracts(contracts) {
1546
- await ensureContractWorker();
1547
- const { handlers, violation } = buildContractHandlers(await import("msw"), contracts, isRunnerRequest);
1548
- workerInstance.use(...handlers);
1549
- return {
1550
- cleanup: () => {
1551
- workerInstance.resetHandlers();
1552
- },
1553
- violation
1554
- };
1555
- }
1556
- /** Drop every handler the page still carries — the per-test reset. */
1557
- function resetWorkerContracts() {
1558
- if (workerInstance !== null) workerInstance.resetHandlers();
1559
- }
1803
+ const nodeOnlyClass = (name) => function NodeOnly() {
1804
+ return refuse(name);
1805
+ };
1560
1806
  //#endregion
1561
- //#region src/integrations/vitest-browser/page-runtime.ts
1807
+ //#region src/seams/vitest-browser/page-runtime.ts
1562
1808
  /**
1563
1809
  * The runner, as the page reaches it.
1564
1810
  *
@@ -1607,7 +1853,7 @@ function projectWrapper() {
1607
1853
  return typeof wrap === "function" ? wrap : void 0;
1608
1854
  }
1609
1855
  //#endregion
1610
- //#region src/specification/facets/component/rendered-text.ts
1856
+ //#region src/facets/component/rendered-text.ts
1611
1857
  /**
1612
1858
  * A stream captured INSIDE the page.
1613
1859
  *
@@ -1622,7 +1868,7 @@ var RenderedText = class extends TextAccessor {
1622
1868
  capturedInPage = true;
1623
1869
  };
1624
1870
  //#endregion
1625
- //#region src/specification/facets/component/component.result.ts
1871
+ //#region src/facets/component/component.result.ts
1626
1872
  /**
1627
1873
  * What a render resolves to — the component as the page saw it, after the
1628
1874
  * scenario ran.
@@ -1652,7 +1898,7 @@ var RenderResult = class {
1652
1898
  }
1653
1899
  };
1654
1900
  //#endregion
1655
- //#region src/specification/facets/component/component.chain.ts
1901
+ //#region src/facets/component/component.chain.ts
1656
1902
  /** The console methods a render records; everything else is left alone. */
1657
1903
  const CONSOLE_METHODS = [
1658
1904
  "debug",
@@ -1815,85 +2061,11 @@ function chainOf(state) {
1815
2061
  /** The handle a component test imports. */
1816
2062
  const component = chainOf(EMPTY);
1817
2063
  //#endregion
1818
- //#region src/specification/facets/_common/result/json.ts
1819
- /**
1820
- * Read-only accessor for a JSON payload parsed from a text stream (stdout).
1821
- *
1822
- * Lazily parses the JSON on first use; parse errors are deferred until the
1823
- * value is read so that callers can still read the raw stream text without
1824
- * triggering a throw.
1825
- *
1826
- * ANSI escape sequences are stripped by default before `JSON.parse`
1827
- * (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
1828
- * runs on the stripped text — an escape hatch for application noise not
1829
- * covered by the `{{token}}` grammar.
1830
- *
1831
- * Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
1832
- * (deep-equal against `_expected/<name>`, with `{{placeholder}}` support).
1833
- */
1834
- var JsonAccessor = class {
1835
- /** @internal Ref-capture scope shared by the current spec execution. */
1836
- captures;
1837
- /** @internal */
1838
- rawText;
1839
- /** @internal Test-file directory — fixture resolution root for matchers. */
1840
- testDir;
1841
- /** @internal */
1842
- transform;
1843
- constructor(rawText, testDir, transform, captures) {
1844
- this.rawText = rawText;
1845
- this.testDir = testDir;
1846
- this.transform = transform;
1847
- this.captures = captures ?? new CaptureScope();
1848
- }
1849
- /** The parsed JSON value. Throws if the text is not valid JSON. */
1850
- get value() {
1851
- const stripped = stripAnsiCodes(this.rawText);
1852
- const source = this.transform ? this.transform(stripped) : stripped;
1853
- try {
1854
- return JSON.parse(source);
1855
- } catch {
1856
- const preview = source.slice(0, 200);
1857
- throw new Error(`stdout is not valid JSON: ${preview}`);
1858
- }
1859
- }
1860
- };
1861
- //#endregion
1862
- //#region src/specification/facets/_common/result/table.ts
1863
- /**
1864
- * Read-only accessor for a database table after a specification run.
1865
- *
1866
- * Assertions go through `expect()` (async — they query the database):
1867
- *
1868
- * await expect(result.table('users', { database: 'db' })).toMatchRows({
1869
- * columns: ['name'],
1870
- * rows: [['Alice']],
1871
- * });
1872
- * await expect(result.table('users', { database: 'db' })).toBeEmpty();
1873
- */
1874
- var TableAccessor = class {
1875
- /** @internal Ref-capture scope shared by the current spec execution. */
1876
- captures;
1877
- /** @internal */
1878
- db;
1879
- /** The table name this accessor reads. */
1880
- name;
1881
- constructor(name, db, captures) {
1882
- this.name = name;
1883
- this.db = db;
1884
- this.captures = captures ?? new CaptureScope();
1885
- }
1886
- /** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
1887
- async query(columns) {
1888
- return await this.db.query(this.name, columns);
1889
- }
1890
- };
1891
- //#endregion
1892
- //#region src/specification/facets/website/elements.ts
2064
+ //#region src/model/elements/elements.ts
1893
2065
  const named = (kind) => (name, options) => ({
1894
2066
  kind,
1895
2067
  name,
1896
- ...options?.exact ? { exact: true } : {}
2068
+ ...options?.exact === void 0 ? {} : { exact: options.exact }
1897
2069
  });
1898
2070
  /** A button (or element with the button role), by accessible name. */
1899
2071
  const button = named("button");
@@ -1913,7 +2085,7 @@ const testId = (id) => ({
1913
2085
  const landmark = (kind) => (name, options) => ({
1914
2086
  kind,
1915
2087
  ...name === void 0 ? {} : { name },
1916
- ...options?.exact ? { exact: true } : {}
2088
+ ...options?.exact === void 0 ? {} : { exact: options.exact }
1917
2089
  });
1918
2090
  /** The `banner` landmark — the page header. */
1919
2091
  const banner = landmark("banner");
@@ -2031,7 +2203,7 @@ const valued = (element, value) => ({
2031
2203
  value
2032
2204
  });
2033
2205
  //#endregion
2034
- //#region src/specification/contracts/filters.ts
2206
+ //#region src/model/contracts/filters.ts
2035
2207
  /** Does an observed text satisfy a declared {@link TextFilter}? */
2036
2208
  function matchesText(filter, actual) {
2037
2209
  if (filter === void 0) return true;
@@ -2040,7 +2212,7 @@ function matchesText(filter, actual) {
2040
2212
  return structuralEquals(filter, actual, new CaptureScope());
2041
2213
  }
2042
2214
  //#endregion
2043
- //#region src/integrations/anthropic/anthropic.ts
2215
+ //#region src/seams/anthropic/anthropic.ts
2044
2216
  const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
2045
2217
  function matchesFilter(body, filter) {
2046
2218
  if (!matchesText(filter.model, body?.model ?? "")) return false;
@@ -2131,7 +2303,7 @@ const anthropic = {
2131
2303
  }
2132
2304
  };
2133
2305
  //#endregion
2134
- //#region src/specification/contracts/http.ts
2306
+ //#region src/model/contracts/http.ts
2135
2307
  function wrapJson(data) {
2136
2308
  return {
2137
2309
  status: 200,
@@ -2139,6 +2311,18 @@ function wrapJson(data) {
2139
2311
  };
2140
2312
  }
2141
2313
  const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
2314
+ const STREAM_CONTENT_TYPE = "application/octet-stream";
2315
+ const SSE_CONTENT_TYPE = "text/event-stream";
2316
+ /** The wire form of one event — the frame an `EventSource` parses. */
2317
+ function frameEvent(event) {
2318
+ const lines = [];
2319
+ if (event.event !== void 0) lines.push(`event: ${event.event}`);
2320
+ if (event.id !== void 0) lines.push(`id: ${event.id}`);
2321
+ if (event.retry !== void 0) lines.push(`retry: ${event.retry}`);
2322
+ const payload = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
2323
+ for (const line of payload.split("\n")) lines.push(`data: ${line}`);
2324
+ return `${lines.join("\n")}\n\n`;
2325
+ }
2142
2326
  function matchesBody(body, expected) {
2143
2327
  if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
2144
2328
  if (expected instanceof RegExp) {
@@ -2248,6 +2432,52 @@ const http = {
2248
2432
  transport: "network-error"
2249
2433
  };
2250
2434
  },
2435
+ /**
2436
+ * Response: a body that arrives in PIECES, written in order.
2437
+ *
2438
+ * A subject that reads a response as it lands — a token-by-token render, a
2439
+ * progress bar, a reconnect on a half-read body — is specified by the
2440
+ * chunks and their order, which a single serialised body cannot state.
2441
+ * Honoured by all three engines: msw's server, msw's worker, and the
2442
+ * `node:http` stub the website and mobile facets serve from.
2443
+ *
2444
+ * @example
2445
+ * http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 })
2446
+ */
2447
+ stream(chunks, init) {
2448
+ return {
2449
+ status: init?.status ?? 200,
2450
+ body: {
2451
+ chunks: [...chunks],
2452
+ contentType: init?.contentType ?? STREAM_CONTENT_TYPE,
2453
+ delayBetweenChunks: init?.delay ?? 0,
2454
+ kind: STREAM_BODY
2455
+ },
2456
+ headers: init?.headers
2457
+ };
2458
+ },
2459
+ /**
2460
+ * Response: a server-sent event stream — one chunk per event, framed the
2461
+ * way an `EventSource` reads them, served as `text/event-stream`.
2462
+ *
2463
+ * @example
2464
+ * http.sse([{ data: { token: 'Hel' } }, { data: { token: 'lo' } }, { event: 'done', data: '' }])
2465
+ */
2466
+ sse(events, init) {
2467
+ return {
2468
+ status: init?.status ?? 200,
2469
+ body: {
2470
+ chunks: events.map(frameEvent),
2471
+ contentType: SSE_CONTENT_TYPE,
2472
+ delayBetweenChunks: init?.delay ?? 0,
2473
+ kind: STREAM_BODY
2474
+ },
2475
+ headers: {
2476
+ "cache-control": "no-cache",
2477
+ ...init?.headers
2478
+ }
2479
+ };
2480
+ },
2251
2481
  /** Response: a text body, served as `text/plain` (200 by default). */
2252
2482
  text(body, init) {
2253
2483
  return {
@@ -2262,7 +2492,7 @@ const http = {
2262
2492
  }
2263
2493
  };
2264
2494
  //#endregion
2265
- //#region src/integrations/openai/openai.ts
2495
+ //#region src/seams/openai/openai.ts
2266
2496
  const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
2267
2497
  const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
2268
2498
  function matchesChatFilter(body, filter) {
@@ -2419,51 +2649,163 @@ const openai = {
2419
2649
  }
2420
2650
  };
2421
2651
  //#endregion
2422
- //#region src/vitest/mock-of.ts
2652
+ //#region src/model/doubles/mock-of.ts
2653
+ function mockOf(options) {
2654
+ return options?.deep === false ? mock() : mockDeep();
2655
+ }
2656
+ //#endregion
2657
+ //#region src/model/clock/clock.ts
2658
+ /** The instant `iso` names, or a refusal that says what an instant looks like. */
2659
+ function instantOf(method, iso) {
2660
+ const at = new Date(iso);
2661
+ 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').`);
2662
+ return at;
2663
+ }
2423
2664
  /**
2424
- * Create a deep mock proxy for a given type.
2425
- * Wraps `vitest-mock-extended`'s `mockDeep` for convenient port mocking.
2426
- */
2427
- const mockOf = mockDeep;
2665
+ * One clock per test — a second one taken inside the first is refused rather
2666
+ * than nested.
2667
+ *
2668
+ * Disposing gives the REAL clock back, which is the only thing vitest's fake
2669
+ * timers can restore: the instant, the `toFake` set and the queue a previous
2670
+ * scope held are not readable, so an inner scope that ended would silently end
2671
+ * the outer one too. A refusal says which of the two the test meant.
2672
+ */
2673
+ function take(method, options) {
2674
+ 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.`);
2675
+ vi.useFakeTimers(options);
2676
+ return { [Symbol.dispose]: () => {
2677
+ vi.useRealTimers();
2678
+ } };
2679
+ }
2680
+ const clock = {
2681
+ /**
2682
+ * Move the pinned clock forward by `ms` — and, under {@link clock.run},
2683
+ * fire every callback that came due, awaiting what each of them started.
2684
+ *
2685
+ * Only a pinned clock moves: called with no clock taken, it says so rather
2686
+ * than silently doing nothing to the real one.
2687
+ */
2688
+ async advance(ms) {
2689
+ 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).");
2690
+ await vi.advanceTimersByTimeAsync(ms);
2691
+ },
2692
+ /**
2693
+ * Pin `Date` at `iso` for the current scope. `Date` ONLY: the scheduler
2694
+ * stays real, so a subject that awaits, renders or polls keeps running.
2695
+ *
2696
+ * @example
2697
+ * using _ = clock.at('2026-03-04T09:30:00Z');
2698
+ * expect(stampedAt()).toBe('2026-03-04T09:30:00.000Z');
2699
+ */
2700
+ at(iso) {
2701
+ return take("at", {
2702
+ now: instantOf("at", iso),
2703
+ toFake: ["Date"]
2704
+ });
2705
+ },
2706
+ /**
2707
+ * Take the scheduler as well as the calendar: `setTimeout`, `setInterval`
2708
+ * and their friends queue instead of firing, and {@link clock.advance} is
2709
+ * what makes them due. For a subject whose behaviour IS elapsed time — a
2710
+ * debounce, a retry backoff, a poll — where waiting for real would be the
2711
+ * arbitrary sleep J2 forbids.
2712
+ *
2713
+ * @example
2714
+ * using _ = clock.run('2026-03-04T09:30:00Z');
2715
+ * const pending = retrying(failsTwice);
2716
+ * await clock.advance(5_000);
2717
+ * await expect(pending).resolves.toBe('ok');
2718
+ */
2719
+ run(iso) {
2720
+ return take("run", iso === void 0 ? {} : { now: instantOf("run", iso) });
2721
+ }
2722
+ };
2428
2723
  //#endregion
2429
- //#region src/vitest/mock-of-date.ts
2724
+ //#region src/model/assertions/required.ts
2430
2725
  /**
2431
- * Freeze or reset the global Date for deterministic time-dependent tests.
2432
- * Wraps the `mockdate` package.
2726
+ * The value, or a failure that says what was missing and why it mattered.
2727
+ *
2728
+ * A test reaching into a captured structure — a row, a header, the third
2729
+ * element of a list — meets `T | undefined` and has to do something about it.
2730
+ * The two things it usually does are both wrong: `!` erases the question, and
2731
+ * an `if (!x) throw new Error('missing')` re-states the same three lines in
2732
+ * every repository (the estate had two hand-written copies of exactly this).
2733
+ * `required` is that gesture with the reason attached, so the failure reads as
2734
+ * a sentence rather than as a `TypeError` two frames further down.
2735
+ *
2736
+ * @example
2737
+ * const id = required(result.response.body.id, 'the creation reply carries the new id');
2433
2738
  */
2434
- const mockOfDate = MockDatePackage;
2739
+ function required(value, why) {
2740
+ 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.`);
2741
+ return value;
2742
+ }
2743
+ //#endregion
2744
+ //#region src/model/assertions/wait-until.ts
2745
+ const DEFAULT_TIMEOUT_MS = 5e3;
2746
+ const DEFAULT_INTERVAL_MS = 50;
2747
+ /**
2748
+ * Wait until a condition holds — the sanctioned answer to "the thing I am
2749
+ * specifying is not ready yet".
2750
+ *
2751
+ * A spec that sleeps for a guessed duration is a spec that is slow on a fast
2752
+ * machine and flaky on a slow one, which is why J2 refuses `setTimeout` under
2753
+ * `specs/`. Every facet that drives something already waits on a CONDITION —
2754
+ * a served page, a banner on stdout, an element the visitor can see — and this
2755
+ * is that primitive where no facet owns the waiting: a background write to
2756
+ * land, a queue to drain, a file to appear.
2757
+ *
2758
+ * The failure names the condition and how long it was given, so a timeout is a
2759
+ * sentence rather than "expected true, got false".
2760
+ *
2761
+ * The budget is measured on `performance.now()`, not on `Date` — `clock.at()`
2762
+ * freezes the calendar, and a deadline read off a frozen calendar never
2763
+ * arrives: the poll would run until vitest killed the test with a sentence
2764
+ * about the file rather than about the condition.
2765
+ *
2766
+ * Not for a subject under `clock.run()`: there the scheduler is the test's, and
2767
+ * `clock.advance(ms)` is what makes time pass.
2768
+ *
2769
+ * @example
2770
+ * await waitUntil(() => outbox.length === 1, { why: 'the job wrote its one message' });
2771
+ */
2772
+ async function waitUntil(predicate, options = {}) {
2773
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
2774
+ const interval = options.interval ?? DEFAULT_INTERVAL_MS;
2775
+ const deadline = performance.now() + timeout;
2776
+ for (;;) {
2777
+ if (await predicate()) return;
2778
+ 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.`);
2779
+ await pause(interval);
2780
+ }
2781
+ }
2435
2782
  //#endregion
2436
2783
  //#region src/browser/index.ts
2437
2784
  registerBrowserMatchers();
2438
2785
  /**
2439
- * The five constructors, present and refusing. A component test never reaches
2786
+ * The module-scope network double, on msw's WORKER — the same contracts and
2787
+ * the same strictness the node entry publishes, served by the one engine a
2788
+ * page has. Everything new in this release that a page can use is real here.
2789
+ */
2790
+ const intercept = interceptThrough(registerWorkerContracts);
2791
+ /**
2792
+ * The six constructors, present and refusing. A component test never reaches
2440
2793
  * one: what it renders is a unit, and the assembled product behind an entry is
2441
2794
  * specified from a node project.
2442
2795
  */
2443
2796
  const specification = {
2444
2797
  api: () => refuse("specification.api()"),
2445
2798
  cli: () => refuse("specification.cli()"),
2799
+ integration: () => refuse("specification.integration()"),
2446
2800
  jobs: () => refuse("specification.jobs()"),
2447
2801
  mobile: () => refuse("specification.mobile()"),
2448
2802
  website: () => refuse("specification.website()")
2449
2803
  };
2804
+ const process = () => refuse("process()");
2805
+ const ProcessHandle = nodeOnlyClass("ProcessHandle");
2450
2806
  const postgres = () => refuse("postgres()");
2451
2807
  const redis = () => refuse("redis()");
2452
2808
  const sqlite = () => refuse("sqlite()");
2453
- const ContainerAccessor = nodeOnlyClass("ContainerAccessor");
2454
- const findContainersByLabel = () => refuse("findContainersByLabel()");
2455
- const inspectContainer = () => refuse("inspectContainer()");
2456
- const removeContainers = () => refuse("removeContainers()");
2457
- const Orchestrator = nodeOnlyClass("Orchestrator");
2458
- const BaseResult = nodeOnlyClass("BaseResult");
2459
- const CliResult = nodeOnlyClass("CliResult");
2460
- const DirectoryAccessor = nodeOnlyClass("DirectoryAccessor");
2461
- const FetchResult = nodeOnlyClass("FetchResult");
2462
- const FilesystemAccessor = nodeOnlyClass("FilesystemAccessor");
2463
- const HttpResult = nodeOnlyClass("HttpResult");
2464
- const PageResult = nodeOnlyClass("PageResult");
2465
- const ResponseAccessor = nodeOnlyClass("ResponseAccessor");
2466
- const ScreenResult = nodeOnlyClass("ScreenResult");
2467
2809
  /**
2468
2810
  * `text()` is real here: only its anchor differs. Under node the accessor
2469
2811
  * remembers the caller's directory so a golden resolves against it; in a page
@@ -2474,4 +2816,4 @@ function text(value) {
2474
2816
  return new TextAccessor(value, "text", "");
2475
2817
  }
2476
2818
  //#endregion
2477
- export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, findContainersByLabel, focused, form, heading, http, inspectContainer, link, listitem, main, match, mockOf, mockOfDate, navigation, openai, option, postgres, redis, region, removeContainers, row, search, selected, specification, sqlite, status, table, testId, text, valued, within };
2819
+ 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 };