@jterrazz/test 15.2.0 → 15.3.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.
- package/README.md +38 -29
- package/dist/artifacts.js +41 -4
- package/dist/browser/index.js +1364 -1045
- package/dist/checker.js +461 -26
- package/dist/index.d.ts +648 -133
- package/dist/index.js +726 -39
- package/dist/intercept.js +9 -1
- package/dist/oxlint.cjs +47 -14
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +47 -14
- package/dist/playwright.adapter.js +1 -0
- package/dist/{queue.js → stream.js} +60 -1
- package/dist/vitest.d.ts +38 -8
- package/dist/vitest.js +59 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -1,29 +1,146 @@
|
|
|
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";
|
|
3
|
+
import { mock, mockDeep } from "vitest-mock-extended";
|
|
4
4
|
import MockDatePackage from "mockdate";
|
|
5
|
-
//#region src/specification/
|
|
5
|
+
//#region src/specification/contracts/contract.ts
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Declare one contract. Identity function — its value is the enforced shape
|
|
8
|
+
* and the naming convention:
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* framework resolves BY PATH, never imported, never executed — and it carries a
|
|
13
|
-
* LEADING UNDERSCORE. A spec's own folder never does.
|
|
10
|
+
* @example
|
|
11
|
+
* // specs/api/reports/contracts/openai/classify-article.ts
|
|
12
|
+
* import { defineContract, openai } from '@jterrazz/test';
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* root beside `package.json`, outside any row of specs.
|
|
14
|
+
* export default defineContract({
|
|
15
|
+
* request: openai.responses({ user: PROMPT, tools: ['classify'] }),
|
|
16
|
+
* response: openai.reply({ categories: ['TECH'] }),
|
|
17
|
+
* });
|
|
20
18
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* // Dynamic — the response is computed from the observed request:
|
|
20
|
+
* export default defineContract({
|
|
21
|
+
* request: http.post('https://api.example.com/echo'),
|
|
22
|
+
* response: (request) => http.json({ received: request.body }),
|
|
23
|
+
* });
|
|
24
24
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
function defineContract(contract) {
|
|
26
|
+
return contract;
|
|
27
|
+
}
|
|
28
|
+
/** Is this a {@link Contracts} composite (rather than a contract or a list)? */
|
|
29
|
+
function isContracts(value) {
|
|
30
|
+
return typeof value === "object" && value !== null && Array.isArray(value.contracts) && typeof value.with === "function";
|
|
31
|
+
}
|
|
32
|
+
/** Is this a single {@link Contract} (rather than a bare request half)? */
|
|
33
|
+
function isContract(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && "request" in value && "response" in value;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The route a contract claims — method + the DECLARED url source. Two
|
|
38
|
+
* contracts share a route when they would be written on the same line of a
|
|
39
|
+
* declaration: same method, same url pattern (`.source` for a RegExp).
|
|
40
|
+
*/
|
|
41
|
+
function routeKeyOf(contract) {
|
|
42
|
+
const { method, url } = contract.request;
|
|
43
|
+
return url instanceof RegExp ? `${method} re:${url.source}` : `${method} str:${url}`;
|
|
44
|
+
}
|
|
45
|
+
/** Human-readable route of a contract — used in every failure message. */
|
|
46
|
+
function describeRoute(contract) {
|
|
47
|
+
const { method, url } = contract.request;
|
|
48
|
+
return `${method === "*" ? "ANY" : method} ${url instanceof RegExp ? String(url) : url}`;
|
|
49
|
+
}
|
|
50
|
+
/** Flatten contracts, lists, and composites into one ordered list. */
|
|
51
|
+
function flatten(items) {
|
|
52
|
+
const flat = [];
|
|
53
|
+
for (const item of items) if (Array.isArray(item)) flat.push(...flatten(item));
|
|
54
|
+
else if (isContracts(item)) flat.push(...item.contracts);
|
|
55
|
+
else flat.push(item);
|
|
56
|
+
return flat;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Compose contracts into the artifact a test imports — it's contracts all the
|
|
60
|
+
* way down: a composite may extend contracts, lists, and other composites,
|
|
61
|
+
* recursively, order preserved.
|
|
62
|
+
*
|
|
63
|
+
* `.with(...)` derives a scenario: contracts whose route the overrides claim
|
|
64
|
+
* are removed from the base, and the overrides are prepended. Under
|
|
65
|
+
* first-match selection a more specific override (`/articles/gone-1`) also
|
|
66
|
+
* wins over a generic base route (`/articles/{{uuid}}`) it does not replace.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* // contracts/newsroom.contracts.ts
|
|
70
|
+
* export default defineContracts(events, articleById);
|
|
71
|
+
* export const withArticleGone = (id: string) =>
|
|
72
|
+
* newsroom.with(articleGone(id));
|
|
73
|
+
*/
|
|
74
|
+
function defineContracts(...items) {
|
|
75
|
+
const contracts = Object.freeze(flatten(items));
|
|
76
|
+
return {
|
|
77
|
+
contracts,
|
|
78
|
+
with(...overrides) {
|
|
79
|
+
const added = flatten(overrides);
|
|
80
|
+
const claimed = new Set(added.map(routeKeyOf));
|
|
81
|
+
const kept = contracts.filter((contract) => !claimed.has(routeKeyOf(contract)));
|
|
82
|
+
return defineContracts([...added, ...kept]);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Normalize any accepted input into a flat contract list. */
|
|
87
|
+
function contractsOf(input) {
|
|
88
|
+
return flatten([input]);
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/integrations/msw/scope.ts
|
|
92
|
+
/**
|
|
93
|
+
* The network double in MODULE scope — the same contracts, the same queue and
|
|
94
|
+
* the same strictness as a chain's `.intercept()`, for the one kind of test
|
|
95
|
+
* that has no chain to hang them on.
|
|
96
|
+
*
|
|
97
|
+
* A module test whose subject reaches the network had exactly two ways out
|
|
98
|
+
* before this: `vi.stubGlobal('fetch')`, which replaces the platform with a
|
|
99
|
+
* hand-written stand-in, or `msw/node` imported directly, which F6 refuses a
|
|
100
|
+
* test and F8 refuses a consumer to declare. Both answer "what did the outside
|
|
101
|
+
* world reply" in a dialect nothing else in the repository speaks.
|
|
102
|
+
*
|
|
103
|
+
* The scope is an `AsyncDisposable`, so the handlers come off at the end of
|
|
104
|
+
* the block however the block ends — and the strict violation (an outgoing
|
|
105
|
+
* request no contract accepted) is raised THERE, which is what makes the
|
|
106
|
+
* double a guard rather than a convenience.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* The contracts a call declared, whichever of the two forms it used. Exported
|
|
110
|
+
* for its own module test: the bare-request refusal is a runtime guard the
|
|
111
|
+
* compiler already states, so no typed call site can reach it.
|
|
112
|
+
*
|
|
113
|
+
* @internal
|
|
114
|
+
*/
|
|
115
|
+
function declared(requestOrContracts, maybeResponse) {
|
|
116
|
+
if (Array.isArray(requestOrContracts) || isContracts(requestOrContracts) || isContract(requestOrContracts)) return contractsOf(requestOrContracts);
|
|
117
|
+
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).");
|
|
118
|
+
return [{
|
|
119
|
+
request: requestOrContracts,
|
|
120
|
+
response: maybeResponse
|
|
121
|
+
}];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Build the module-scope double on one engine. The node entry hands it msw's
|
|
125
|
+
* server, the page's entry hands it msw's worker: one shape, two transports,
|
|
126
|
+
* exactly as a chain's `.intercept()` already has it.
|
|
127
|
+
*
|
|
128
|
+
* @internal
|
|
129
|
+
*/
|
|
130
|
+
function interceptThrough(register) {
|
|
131
|
+
async function intercept(requestOrContracts, maybeResponse) {
|
|
132
|
+
const contracts = declared(requestOrContracts, maybeResponse);
|
|
133
|
+
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.");
|
|
134
|
+
const registration = await register(contracts);
|
|
135
|
+
return { [Symbol.asyncDispose]: async () => {
|
|
136
|
+
const violation = registration.violation();
|
|
137
|
+
registration.cleanup();
|
|
138
|
+
await Promise.resolve();
|
|
139
|
+
if (violation) throw violation;
|
|
140
|
+
} };
|
|
141
|
+
}
|
|
142
|
+
return intercept;
|
|
143
|
+
}
|
|
27
144
|
//#endregion
|
|
28
145
|
//#region src/specification/matching/match.ts
|
|
29
146
|
/** Every kind usable as a `{{token}}` in fixture files (all but ref/regex/includes). */
|
|
@@ -411,1152 +528,1156 @@ function textEquals(expected, actual, scope) {
|
|
|
411
528
|
return placeholderStringMatches(expected, actual, scope);
|
|
412
529
|
}
|
|
413
530
|
//#endregion
|
|
414
|
-
//#region src/specification/
|
|
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
|
-
}
|
|
531
|
+
//#region src/specification/contracts/queue.ts
|
|
439
532
|
/**
|
|
440
|
-
*
|
|
533
|
+
* The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
|
|
534
|
+
* Both engines consume it: the MSW integration on api/jobs chains, and the
|
|
535
|
+
* declared stub backend on website/mobile chains.
|
|
441
536
|
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* actual cause.
|
|
537
|
+
* Selection: the FIRST contract in declaration order that matches the observed
|
|
538
|
+
* request and is not exhausted. A contract with no `times` is unlimited (a
|
|
539
|
+
* re-render or a retry replays it); `times: n` is spent after n serves, so an
|
|
540
|
+
* ordered sequence is written as finite contracts before an unlimited tail:
|
|
541
|
+
* `[error500 ×3, ok]`.
|
|
448
542
|
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*/
|
|
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.
|
|
543
|
+
* Strictness (CONVENTIONS D7): when nothing matches — including "everything
|
|
544
|
+
* that matches is exhausted" — the engine answers 501, records the request,
|
|
545
|
+
* and the chain fails with {@link ContractQueue.unmatchedError}. A chain that
|
|
546
|
+
* declared zero contracts is not guarded at all (unchanged boundary).
|
|
476
547
|
*/
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
548
|
+
/** Base used to parse origin-relative observed URLs (`/events?x=1`). */
|
|
549
|
+
const RELATIVE_BASE = "http://contract.invalid";
|
|
550
|
+
/** Split a declared url into its base (origin+path, or path) and query halves.
|
|
551
|
+
* Never through `new URL()`, which would percent-encode `{{token}}` braces. */
|
|
552
|
+
function splitDeclared(url) {
|
|
553
|
+
const separator = url.indexOf("?");
|
|
554
|
+
if (separator === -1) return {
|
|
555
|
+
base: url,
|
|
556
|
+
query: new URLSearchParams()
|
|
482
557
|
};
|
|
483
558
|
return {
|
|
484
|
-
|
|
485
|
-
|
|
559
|
+
base: url.slice(0, separator),
|
|
560
|
+
query: new URLSearchParams(url.slice(separator + 1))
|
|
486
561
|
};
|
|
487
562
|
}
|
|
488
|
-
/**
|
|
489
|
-
function
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
};
|
|
563
|
+
/** Decode percent-escapes, falling back to the raw text on malformed input. */
|
|
564
|
+
function safeDecode(text) {
|
|
565
|
+
try {
|
|
566
|
+
return decodeURIComponent(text);
|
|
567
|
+
} catch {
|
|
568
|
+
return text;
|
|
569
|
+
}
|
|
496
570
|
}
|
|
497
|
-
/**
|
|
498
|
-
function
|
|
499
|
-
|
|
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
|
-
};
|
|
571
|
+
/** Token-aware comparison of one declared value against an observed one. */
|
|
572
|
+
function valueMatches(declared, observed) {
|
|
573
|
+
return textEquals(declared, observed, new CaptureScope());
|
|
505
574
|
}
|
|
506
575
|
/**
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
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.
|
|
576
|
+
* Does an observed URL satisfy a declared one? A RegExp tests the full URL; a
|
|
577
|
+
* PATH FORM (`/articles/{{uuid}}`) ignores the origin; an absolute string
|
|
578
|
+
* compares origin+pathname. In both string forms `{{token}}` segments compare
|
|
579
|
+
* structurally and declared query params are a SUBSET of the observed ones.
|
|
514
580
|
*/
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
};
|
|
532
|
-
}
|
|
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
|
-
};
|
|
581
|
+
function urlMatches(declared, observed) {
|
|
582
|
+
if (declared instanceof RegExp) return declared.test(observed);
|
|
583
|
+
let url;
|
|
584
|
+
try {
|
|
585
|
+
url = new URL(observed, RELATIVE_BASE);
|
|
586
|
+
} catch {
|
|
587
|
+
return false;
|
|
539
588
|
}
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
589
|
+
const { base, query } = splitDeclared(declared);
|
|
590
|
+
const observedBase = base.startsWith("/") ? url.pathname : `${url.origin}${url.pathname}`;
|
|
591
|
+
const decodedBase = base.startsWith("/") ? safeDecode(url.pathname) : `${url.origin}${safeDecode(url.pathname)}`;
|
|
592
|
+
if (!valueMatches(base, observedBase) && !valueMatches(base, decodedBase)) return false;
|
|
593
|
+
for (const key of new Set(query.keys())) {
|
|
594
|
+
const observedValues = url.searchParams.getAll(key);
|
|
595
|
+
if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
545
598
|
}
|
|
546
|
-
//#endregion
|
|
547
|
-
//#region src/specification/facets/_common/result/grep.ts
|
|
548
599
|
/**
|
|
549
|
-
*
|
|
550
|
-
*
|
|
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")
|
|
600
|
+
* Does an observed request satisfy a contract? Method (`*` matches any), URL,
|
|
601
|
+
* then the contract's own `match` predicate (headers/query/body filters).
|
|
556
602
|
*/
|
|
557
|
-
function
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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, "");
|
|
603
|
+
function contractMatches(contract, request) {
|
|
604
|
+
const { match, method, url } = contract.request;
|
|
605
|
+
if (method !== "*" && method.toUpperCase() !== request.method.toUpperCase()) return false;
|
|
606
|
+
if (!urlMatches(url, request.url)) return false;
|
|
607
|
+
return match ? match(request) : true;
|
|
566
608
|
}
|
|
567
609
|
/**
|
|
568
|
-
*
|
|
569
|
-
*
|
|
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).
|
|
610
|
+
* The URL pattern an out-of-process router (MSW) must register so the request
|
|
611
|
+
* REACHES the queue. Deliberately no narrower than {@link contractMatches} —
|
|
612
|
+
* the predicate above stays the authority on what actually matches.
|
|
589
613
|
*/
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
this.
|
|
614
|
+
function routePatternOf(declared) {
|
|
615
|
+
if (declared instanceof RegExp) return declared;
|
|
616
|
+
const { base } = splitDeclared(declared);
|
|
617
|
+
if (base.startsWith("/")) return new RegExp(String.raw`^https?:\/\/[^/?#]+${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
|
|
618
|
+
if (hasPlaceholders(base)) return new RegExp(String.raw`^${placeholderPatternSource(base)}(?:\?[^#]*)?(?:#.*)?$`, "u");
|
|
619
|
+
return base;
|
|
620
|
+
}
|
|
621
|
+
/** How a declared contract reads in a failure message: spent, served, or silent. */
|
|
622
|
+
function stateOf({ contract, served }) {
|
|
623
|
+
const { times } = contract;
|
|
624
|
+
if (times !== void 0 && served >= times) return ` (exhausted after ${times})`;
|
|
625
|
+
return served === 0 ? "" : ` (served ${served} time(s))`;
|
|
626
|
+
}
|
|
627
|
+
var ContractQueue = class {
|
|
628
|
+
entries;
|
|
629
|
+
constructor(contracts) {
|
|
630
|
+
this.entries = contracts.map((contract) => ({
|
|
631
|
+
contract,
|
|
632
|
+
served: 0
|
|
633
|
+
}));
|
|
607
634
|
}
|
|
608
|
-
/**
|
|
609
|
-
get
|
|
610
|
-
|
|
611
|
-
return this.transform ? this.transform(stripped) : stripped;
|
|
635
|
+
/** How many contracts the chain declared. */
|
|
636
|
+
get size() {
|
|
637
|
+
return this.entries.length;
|
|
612
638
|
}
|
|
613
639
|
/**
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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.
|
|
640
|
+
* Routes to register with an out-of-process router, deduped by pattern —
|
|
641
|
+
* two contracts on the same URL share one handler and one queue.
|
|
619
642
|
*/
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
643
|
+
get routes() {
|
|
644
|
+
const routes = /* @__PURE__ */ new Map();
|
|
645
|
+
for (const { contract } of this.entries) {
|
|
646
|
+
const url = routePatternOf(contract.request.url);
|
|
647
|
+
const key = String(url);
|
|
648
|
+
const route = routes.get(key) ?? {
|
|
649
|
+
methods: [],
|
|
650
|
+
url
|
|
651
|
+
};
|
|
652
|
+
if (!route.methods.includes(contract.request.method)) route.methods.push(contract.request.method);
|
|
653
|
+
routes.set(key, route);
|
|
654
|
+
}
|
|
655
|
+
return [...routes.values()];
|
|
625
656
|
}
|
|
626
|
-
|
|
627
|
-
|
|
657
|
+
/** The declared routes, in order — the enumeration a 501 body carries. */
|
|
658
|
+
declaredRoutes() {
|
|
659
|
+
return this.entries.map(({ contract }) => describeRoute(contract));
|
|
628
660
|
}
|
|
629
|
-
|
|
630
|
-
|
|
661
|
+
/**
|
|
662
|
+
* Serve the first contract in declaration order that matches and is not
|
|
663
|
+
* exhausted, or null when nothing matches (including "everything that
|
|
664
|
+
* matches is spent") — the strict-violation signal.
|
|
665
|
+
*/
|
|
666
|
+
take(request) {
|
|
667
|
+
for (const entry of this.entries) {
|
|
668
|
+
const { times } = entry.contract;
|
|
669
|
+
if (times !== void 0 && entry.served >= times) continue;
|
|
670
|
+
if (!contractMatches(entry.contract, request)) continue;
|
|
671
|
+
entry.served += 1;
|
|
672
|
+
return entry.contract;
|
|
673
|
+
}
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The chain-end failure for contracts declared `required: true` that were
|
|
678
|
+
* never requested (or not exactly `times` times), or null when every
|
|
679
|
+
* requirement held.
|
|
680
|
+
*/
|
|
681
|
+
requiredError() {
|
|
682
|
+
const unmet = [];
|
|
683
|
+
for (const { contract, served } of this.entries) {
|
|
684
|
+
if (!contract.required) continue;
|
|
685
|
+
const { times } = contract;
|
|
686
|
+
if (times === void 0) {
|
|
687
|
+
if (served === 0) unmet.push(` - ${describeRoute(contract)} — declared and required but never requested`);
|
|
688
|
+
} else if (served !== times) unmet.push(` - ${describeRoute(contract)} — declared required with times: ${times} but was requested ${served} time(s)`);
|
|
689
|
+
}
|
|
690
|
+
if (unmet.length === 0) return null;
|
|
691
|
+
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.`);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The strict failure for a request that matched no contract (CONVENTIONS
|
|
695
|
+
* D7): method + URL of the offending request, plus every declared contract
|
|
696
|
+
* and how often it was served.
|
|
697
|
+
*/
|
|
698
|
+
unmatchedError(method, url) {
|
|
699
|
+
const declared = this.entries.length === 0 ? " (no contracts declared)" : this.entries.map((entry) => ` - ${describeRoute(entry.contract)}${stateOf(entry)}`).join("\n");
|
|
700
|
+
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).`);
|
|
631
701
|
}
|
|
632
702
|
};
|
|
633
703
|
//#endregion
|
|
634
|
-
//#region src/specification/
|
|
704
|
+
//#region src/specification/timing/pause.ts
|
|
635
705
|
/**
|
|
636
|
-
*
|
|
706
|
+
* One answer to "wait" — the only `setTimeout` the model owns.
|
|
637
707
|
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
708
|
+
* Two things in the framework genuinely have to wait: the spacing between the
|
|
709
|
+
* pieces of a declared stream, and the poll behind `waitUntil`. Everything
|
|
710
|
+
* else that waits is a spec doing it by hand, which is what J2 refuses. Stated
|
|
711
|
+
* once so there is one place to look when time stops behaving.
|
|
712
|
+
*/
|
|
713
|
+
async function pause(ms) {
|
|
714
|
+
await new Promise((resolve) => {
|
|
715
|
+
setTimeout(resolve, ms);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/specification/contracts/stream.ts
|
|
720
|
+
/**
|
|
721
|
+
* A declared stream, made real — ONE serialisation for all three engines.
|
|
645
722
|
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
};
|
|
723
|
+
* msw's server and msw's worker take a `ReadableStream` body verbatim
|
|
724
|
+
* (https://mswjs.io/docs/http/mocking-responses/streaming) and the `node:http`
|
|
725
|
+
* stub pipes one into its response, so what "arriving in pieces" MEANS —
|
|
726
|
+
* the order, the spacing, the close after the last chunk — is stated here and
|
|
727
|
+
* nowhere else. A second copy of it would be a second answer to "what does the
|
|
728
|
+
* subject read", and the facets would drift.
|
|
729
|
+
*/
|
|
730
|
+
/**
|
|
731
|
+
* The pieces of `body`, in order, `delayBetweenChunks` milliseconds apart,
|
|
732
|
+
* closing after the last one.
|
|
733
|
+
*/
|
|
734
|
+
function toReadableStream(body) {
|
|
735
|
+
const encoder = new TextEncoder();
|
|
736
|
+
let index = 0;
|
|
737
|
+
return new ReadableStream({ async pull(controller) {
|
|
738
|
+
const chunk = body.chunks[index];
|
|
739
|
+
if (chunk === void 0) {
|
|
740
|
+
controller.close();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (body.delayBetweenChunks > 0 && index > 0) await pause(body.delayBetweenChunks);
|
|
744
|
+
index += 1;
|
|
745
|
+
controller.enqueue(encoder.encode(chunk));
|
|
746
|
+
} });
|
|
747
|
+
}
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region src/specification/contracts/types.ts
|
|
686
750
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* the
|
|
751
|
+
* The marker that tells a STREAMED body from a value that happens to be an
|
|
752
|
+
* object. A body reaches three different engines (msw's server, msw's worker,
|
|
753
|
+
* the `node:http` stub) and each of them serialises "an object" as JSON, so a
|
|
754
|
+
* stream declared as a plain value arrives as `{}`. The tag is what each
|
|
755
|
+
* engine recognises before it reaches for `JSON.stringify`.
|
|
690
756
|
*/
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
757
|
+
const STREAM_BODY = "@jterrazz/test:stream";
|
|
758
|
+
/** Is this body a declared stream rather than a value to serialise? */
|
|
759
|
+
function isStreamBody(body) {
|
|
760
|
+
return typeof body === "object" && body !== null && "kind" in body && body.kind === "@jterrazz/test:stream";
|
|
694
761
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
762
|
+
//#endregion
|
|
763
|
+
//#region src/integrations/msw/handlers.ts
|
|
764
|
+
/**
|
|
765
|
+
* A contract answers the request that was made, never a copy of an earlier one.
|
|
766
|
+
*
|
|
767
|
+
* In a page the reply travels through a real service worker and a real HTTP
|
|
768
|
+
* cache, so a second render of the same component can be served the FIRST
|
|
769
|
+
* test's answer and never reach the queue at all — a contract that was
|
|
770
|
+
* exhausted, or replaced, silently keeps answering. Stated as a default, so a
|
|
771
|
+
* spec whose subject IS caching can still say otherwise.
|
|
772
|
+
*/
|
|
773
|
+
const NEVER_CACHED = { "cache-control": "no-store" };
|
|
774
|
+
/** Turn a contract response into the msw reply, body kind by body kind. */
|
|
775
|
+
function toMswResponse(msw, response) {
|
|
776
|
+
const { body, headers, status = 200 } = response;
|
|
777
|
+
if (response.transport === "network-error") return msw.HttpResponse.error();
|
|
778
|
+
if (body === null || body === void 0) return new msw.HttpResponse(null, {
|
|
779
|
+
headers: {
|
|
780
|
+
...NEVER_CACHED,
|
|
781
|
+
...headers
|
|
782
|
+
},
|
|
783
|
+
status
|
|
784
|
+
});
|
|
785
|
+
if (isStreamBody(body)) return new msw.HttpResponse(toReadableStream(body), {
|
|
786
|
+
headers: {
|
|
787
|
+
...NEVER_CACHED,
|
|
788
|
+
"content-type": body.contentType,
|
|
789
|
+
...headers
|
|
790
|
+
},
|
|
791
|
+
status
|
|
792
|
+
});
|
|
793
|
+
if (typeof body === "string") return new msw.HttpResponse(body, {
|
|
794
|
+
headers: {
|
|
795
|
+
...NEVER_CACHED,
|
|
796
|
+
"content-type": "text/plain; charset=utf-8",
|
|
797
|
+
...headers
|
|
798
|
+
},
|
|
799
|
+
status
|
|
800
|
+
});
|
|
801
|
+
return msw.HttpResponse.json(body, {
|
|
802
|
+
headers: {
|
|
803
|
+
...NEVER_CACHED,
|
|
804
|
+
...headers
|
|
805
|
+
},
|
|
806
|
+
status
|
|
807
|
+
});
|
|
707
808
|
}
|
|
708
|
-
|
|
709
|
-
|
|
809
|
+
/**
|
|
810
|
+
* The handlers one chain's contracts become, plus the trailing catch-all that
|
|
811
|
+
* records any request no contract accepted (CONVENTIONS D7). The caller decides
|
|
812
|
+
* what to do with the recorded violation — the api chain rethrows it, the
|
|
813
|
+
* component chain fails the render. An EMPTY contract list is still a guard:
|
|
814
|
+
* the catch-all alone says "this subject has no network".
|
|
815
|
+
*
|
|
816
|
+
* `bypass` names the traffic that is not the subject's: under node there is
|
|
817
|
+
* none, in a page it is everything the runner fetches to BE a page.
|
|
818
|
+
*/
|
|
819
|
+
function buildContractHandlers(msw, contracts, bypass) {
|
|
820
|
+
const queue = new ContractQueue(contracts);
|
|
821
|
+
let violation = null;
|
|
822
|
+
const recordViolation = (method, url) => {
|
|
823
|
+
violation ??= queue.unmatchedError(method, url);
|
|
824
|
+
return msw.HttpResponse.json({ error: `@jterrazz/test strict contracts: unmatched request ${method} ${url}` }, {
|
|
825
|
+
headers: NEVER_CACHED,
|
|
826
|
+
status: 501
|
|
827
|
+
});
|
|
828
|
+
};
|
|
829
|
+
const handlers = [];
|
|
830
|
+
for (const route of queue.routes) for (const method of route.methods) {
|
|
831
|
+
const handlerFn = method === "*" ? msw.http.all : msw.http[method.toLowerCase()];
|
|
832
|
+
if (!handlerFn) continue;
|
|
833
|
+
handlers.push(handlerFn(route.url, async ({ request }) => {
|
|
834
|
+
const observed = await observe(request);
|
|
835
|
+
const contract = queue.take(observed);
|
|
836
|
+
if (!contract) return recordViolation(request.method, request.url);
|
|
837
|
+
const response = typeof contract.response === "function" ? contract.response(observed) : contract.response;
|
|
838
|
+
if (response.delay) await new Promise((resolve) => setTimeout(resolve, response.delay));
|
|
839
|
+
return toMswResponse(msw, response);
|
|
840
|
+
}));
|
|
841
|
+
}
|
|
842
|
+
handlers.push(msw.http.all("*", ({ request }) => bypass?.(request.url) === true ? msw.passthrough() : recordViolation(request.method, request.url)));
|
|
843
|
+
return {
|
|
844
|
+
handlers,
|
|
845
|
+
violation: () => violation ?? queue.requiredError()
|
|
846
|
+
};
|
|
710
847
|
}
|
|
711
|
-
/**
|
|
712
|
-
function
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
848
|
+
/** The request as the queue matches it — body parsed when it is JSON. */
|
|
849
|
+
async function observe(request) {
|
|
850
|
+
let body = null;
|
|
851
|
+
const rawText = await request.clone().text();
|
|
852
|
+
if (rawText) try {
|
|
853
|
+
body = JSON.parse(rawText);
|
|
854
|
+
} catch {
|
|
855
|
+
body = rawText;
|
|
717
856
|
}
|
|
718
|
-
|
|
857
|
+
const headers = {};
|
|
858
|
+
request.headers.forEach((value, key) => {
|
|
859
|
+
headers[key.toLowerCase()] = value;
|
|
860
|
+
});
|
|
861
|
+
return {
|
|
862
|
+
body,
|
|
863
|
+
headers,
|
|
864
|
+
method: request.method.toUpperCase(),
|
|
865
|
+
url: request.url
|
|
866
|
+
};
|
|
719
867
|
}
|
|
868
|
+
//#endregion
|
|
869
|
+
//#region src/integrations/msw/worker.ts
|
|
720
870
|
/**
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
* the count it would leave is stated so a still-ambiguous suggestion never
|
|
724
|
-
* reads as a fix.
|
|
871
|
+
* What the runner itself fetches. Everything else a page asks for is the
|
|
872
|
+
* subject's own traffic and answers to its contracts (D7).
|
|
725
873
|
*/
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
874
|
+
const RUNNER_PREFIXES = [
|
|
875
|
+
"/.artifacts/",
|
|
876
|
+
"/@fs/",
|
|
877
|
+
"/@id/",
|
|
878
|
+
"/@vite",
|
|
879
|
+
"/node_modules/",
|
|
880
|
+
"/__vitest"
|
|
881
|
+
];
|
|
882
|
+
let workerInstance = null;
|
|
883
|
+
/**
|
|
884
|
+
* Is this the runner's own traffic rather than the subject's?
|
|
885
|
+
*
|
|
886
|
+
* In a page EVERY module is an HTTP request: the test file, the seam, the
|
|
887
|
+
* dependency cache Vite optimised, the runner's own channel. None of them is
|
|
888
|
+
* the component's network, and none of them can be declared by a contract, so
|
|
889
|
+
* they are recognised by PREFIX — never by extension, because msw bypasses a
|
|
890
|
+
* "common asset" path before `onUnhandledRequest` ever runs and an extension
|
|
891
|
+
* test would let a real `/api/posts.json` through on the same ground.
|
|
892
|
+
*/
|
|
893
|
+
function isRunnerRequest(url) {
|
|
894
|
+
const { pathname } = new URL(url);
|
|
895
|
+
return RUNNER_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
743
896
|
}
|
|
744
897
|
/**
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
898
|
+
* Start the worker once per page. The script is served at `/mockServiceWorker.js`
|
|
899
|
+
* by the plugin `component()` registers, out of the package's OWN `msw`
|
|
900
|
+
* install — so no consumer ever runs `msw init` and no consumer declares `msw`.
|
|
748
901
|
*/
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
};
|
|
902
|
+
async function ensureContractWorker() {
|
|
903
|
+
if (workerInstance !== null) return;
|
|
904
|
+
const { setupWorker } = await import("msw/browser");
|
|
905
|
+
workerInstance = setupWorker();
|
|
906
|
+
await workerInstance.start({
|
|
907
|
+
onUnhandledRequest: (request, print) => {
|
|
908
|
+
if (!isRunnerRequest(request.url)) print.error();
|
|
909
|
+
},
|
|
910
|
+
quiet: true,
|
|
911
|
+
serviceWorker: { url: "/mockServiceWorker.js" }
|
|
912
|
+
});
|
|
913
|
+
}
|
|
752
914
|
/**
|
|
753
|
-
*
|
|
915
|
+
* Register one render's contracts on the page's worker.
|
|
754
916
|
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
917
|
+
* The worker starts even when the list is empty: on this facet D7 is TOTAL —
|
|
918
|
+
* a component that was given no contract has no network, and a fetch it makes
|
|
919
|
+
* anyway is named by {@link ContractRegistration.violation} rather than
|
|
920
|
+
* answered by whatever the dev server happens to have at that path.
|
|
758
921
|
*/
|
|
759
|
-
function
|
|
760
|
-
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
"",
|
|
774
|
-
"Docs: docs/14-website.md#designating-exactly-one-element (CONVENTIONS W3)"
|
|
775
|
-
].join("\n");
|
|
922
|
+
async function registerWorkerContracts(contracts) {
|
|
923
|
+
await ensureContractWorker();
|
|
924
|
+
const { handlers, violation } = buildContractHandlers(await import("msw"), contracts, isRunnerRequest);
|
|
925
|
+
workerInstance.use(...handlers);
|
|
926
|
+
return {
|
|
927
|
+
cleanup: () => {
|
|
928
|
+
workerInstance.resetHandlers();
|
|
929
|
+
},
|
|
930
|
+
violation
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/** Drop every handler the page still carries — the per-test reset. */
|
|
934
|
+
function resetWorkerContracts() {
|
|
935
|
+
if (workerInstance !== null) workerInstance.resetHandlers();
|
|
776
936
|
}
|
|
777
937
|
//#endregion
|
|
778
|
-
//#region src/
|
|
938
|
+
//#region src/specification/facets/_common/ground.ts
|
|
779
939
|
/**
|
|
780
|
-
* The
|
|
781
|
-
* vocabulary.
|
|
940
|
+
* The GROUND a row of specs stands on — the naming law of a spec tree.
|
|
782
941
|
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
787
|
-
*
|
|
942
|
+
* In a row of specs, a MEMBER is a spec: `<aspect>.test.ts`, `<case>.spec.yaml`,
|
|
943
|
+
* `<name>.specification.ts`, and the facet/domain folders that hold them.
|
|
944
|
+
* Everything else in that row is what the members STAND ON — inert material the
|
|
945
|
+
* framework resolves BY PATH, never imported, never executed — and it carries a
|
|
946
|
+
* LEADING UNDERSCORE. A spec's own folder never does.
|
|
788
947
|
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
948
|
+
* Four names, and only four: `_fixtures/` (file state), `_expected/` (goldens),
|
|
949
|
+
* `_requests/` (inputs), `_seeds/` (database state). `contracts/` is NOT ground —
|
|
950
|
+
* a contract is TypeScript a spec imports, behaviour composed with the spec's own,
|
|
951
|
+
* so it stays a member. `docker/` is not ground either: it sits at the PROJECT
|
|
952
|
+
* root beside `package.json`, outside any row of specs.
|
|
953
|
+
*
|
|
954
|
+
* This module is the single home of those names. Every resolver, matcher, lint
|
|
955
|
+
* rule and checker pass reads them from here, so the runtime and the static
|
|
956
|
+
* channels cannot disagree about where a spec's ground lives.
|
|
793
957
|
*/
|
|
794
|
-
/**
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
958
|
+
/** Goldens: every expected fixture, flat, extension included. */
|
|
959
|
+
const GROUND_EXPECTED = "_expected";
|
|
960
|
+
//#endregion
|
|
961
|
+
//#region src/specification/facets/_common/reporter.ts
|
|
962
|
+
const GREEN = "\x1B[32m";
|
|
963
|
+
const RED = "\x1B[31m";
|
|
964
|
+
const RESET = "\x1B[0m";
|
|
965
|
+
const strictEquals = (expected, actual) => expected === actual;
|
|
966
|
+
function renderLines(expectedLines, actualLines, equals) {
|
|
967
|
+
const rendered = [];
|
|
968
|
+
let marked = 0;
|
|
969
|
+
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
970
|
+
for (let i = 0; i < maxLines; i++) {
|
|
971
|
+
const exp = expectedLines[i];
|
|
972
|
+
const act = actualLines[i];
|
|
973
|
+
if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
|
|
974
|
+
rendered.push(` ${exp}`);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
marked++;
|
|
978
|
+
if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
|
|
979
|
+
if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
|
|
980
|
+
}
|
|
981
|
+
return {
|
|
982
|
+
marked,
|
|
983
|
+
rendered
|
|
984
|
+
};
|
|
985
|
+
}
|
|
798
986
|
/**
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
*
|
|
987
|
+
* Render an expected/received diff over two texts.
|
|
988
|
+
*
|
|
989
|
+
* `equals` is how a line is judged EQUAL. Callers that compare through the
|
|
990
|
+
* `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
|
|
991
|
+
* matched is shown as equal — with its token, not as a `-`/`+` pair against
|
|
992
|
+
* the concrete URL. Marking a line the comparison ACCEPTED buries the one it
|
|
993
|
+
* rejected: the reader then has to work out which of the differences is the
|
|
994
|
+
* actual cause.
|
|
995
|
+
*
|
|
996
|
+
* Safety net: if the token-aware pass marks nothing at all, the whole diff
|
|
997
|
+
* would render as a clean block against a failure that really happened (a
|
|
998
|
+
* `#ref` captured inconsistently across lines, say). The strict rendering is
|
|
999
|
+
* used instead, so a diff always shows the reader something.
|
|
804
1000
|
*/
|
|
805
|
-
function
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
});
|
|
1001
|
+
function formatStdoutDiff(file, expected, actual, options) {
|
|
1002
|
+
const lines = [];
|
|
1003
|
+
lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
|
|
1004
|
+
const expectedLines = expected.split("\n");
|
|
1005
|
+
const actualLines = actual.split("\n");
|
|
1006
|
+
const equals = options?.equals ?? strictEquals;
|
|
1007
|
+
const first = renderLines(expectedLines, actualLines, equals);
|
|
1008
|
+
const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
|
|
1009
|
+
return [...lines, ...rendered].join("\n");
|
|
833
1010
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
return element;
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/specification/facets/_common/result/text-assertions.ts
|
|
1013
|
+
/** The fixture name carries its extension — the name IS the file. */
|
|
1014
|
+
function requireExtension(name, subject) {
|
|
1015
|
+
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").`);
|
|
840
1016
|
}
|
|
841
1017
|
/**
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
1018
|
+
* Compare a captured stream against the CONTENT of its golden. Text snapshots
|
|
1019
|
+
* share the unified `{{token}}` grammar (CONVENTIONS D4), and the diff judges
|
|
1020
|
+
* each line through the SAME grammar the comparison used — so a token line
|
|
1021
|
+
* that matched is shown as equal instead of competing with the real mismatch
|
|
1022
|
+
* for the reader's attention.
|
|
846
1023
|
*/
|
|
847
|
-
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1024
|
+
function compareStreamText(accessor, name, expected) {
|
|
1025
|
+
const actual = accessor.comparableText;
|
|
1026
|
+
if (textEquals(expected, actual, accessor.captures)) return {
|
|
1027
|
+
message: () => `expected ${accessor.streamName} not to match _expected/${name}`,
|
|
1028
|
+
pass: true
|
|
1029
|
+
};
|
|
1030
|
+
return {
|
|
1031
|
+
message: () => formatStdoutDiff(name, expected, actual, { equals: (expectedLine, actualLine) => textEquals(expectedLine, actualLine, new CaptureScope(accessor.captures.workdir)) }),
|
|
1032
|
+
pass: false
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/** `expect(stream).toBeEmpty()` — the stream you usually want silent. */
|
|
1036
|
+
function textIsEmpty(accessor) {
|
|
1037
|
+
const content = accessor.comparableText;
|
|
1038
|
+
const pass = content === "";
|
|
1039
|
+
return {
|
|
1040
|
+
message: () => pass ? `expected ${accessor.streamName} not to be empty` : `Expected ${accessor.streamName} to be empty, but it contains:\n${content}`,
|
|
1041
|
+
pass
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/** `expect(stream).toContain('…')` — the scalpel, never the default (D11). */
|
|
1045
|
+
function textContains(accessor, expected) {
|
|
1046
|
+
const actual = accessor.comparableText;
|
|
1047
|
+
const pass = actual.includes(expected);
|
|
1048
|
+
return {
|
|
1049
|
+
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)}`,
|
|
1050
|
+
pass
|
|
1051
|
+
};
|
|
862
1052
|
}
|
|
863
1053
|
/**
|
|
864
|
-
*
|
|
1054
|
+
* `toMatch` and `toContain` on a subject that is NOT an accessor.
|
|
865
1055
|
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
1056
|
+
* Both matchers are overridden globally, for every subject in the project, so
|
|
1057
|
+
* whatever the framework does not own has to keep meaning what vitest's own
|
|
1058
|
+
* matcher meant. Stated once here because the two builds register two different
|
|
1059
|
+
* `expect.extend` calls: a copy in the page is how `expect(['ab']).toContain('b')`
|
|
1060
|
+
* ends up passing on one side of the seam and failing on the other.
|
|
870
1061
|
*/
|
|
871
|
-
|
|
872
|
-
|
|
1062
|
+
/** vitest's `toMatch` for a plain string: a substring, or a regular expression. */
|
|
1063
|
+
function nativeMatch(received, expected) {
|
|
1064
|
+
if (typeof received !== "string") throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
|
|
1065
|
+
const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
|
|
1066
|
+
return {
|
|
1067
|
+
message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
|
|
1068
|
+
pass
|
|
1069
|
+
};
|
|
873
1070
|
}
|
|
874
|
-
/**
|
|
875
|
-
function
|
|
876
|
-
|
|
1071
|
+
/** vitest's `toContain` for a plain string (substring) or an iterable (membership). */
|
|
1072
|
+
function nativeContain(received, expected) {
|
|
1073
|
+
if (typeof received === "string") {
|
|
1074
|
+
const pass = received.includes(String(expected));
|
|
1075
|
+
return {
|
|
1076
|
+
message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
|
|
1077
|
+
pass
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
if (isIterable(received)) {
|
|
1081
|
+
const pass = [...received].includes(expected);
|
|
1082
|
+
return {
|
|
1083
|
+
message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
|
|
1084
|
+
pass
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
|
|
1088
|
+
}
|
|
1089
|
+
/** Does this value hand out its members? A Set and an array both do; a DOM node does not. */
|
|
1090
|
+
function isIterable(value) {
|
|
1091
|
+
return value !== null && value !== void 0 && typeof value[Symbol.iterator] === "function";
|
|
877
1092
|
}
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/specification/facets/_common/result/grep.ts
|
|
878
1095
|
/**
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
1096
|
+
* Extract text blocks from output that contain a pattern.
|
|
1097
|
+
* Splits by blank lines (how linter/compiler output is structured),
|
|
1098
|
+
* returns only blocks matching the pattern.
|
|
1099
|
+
*
|
|
1100
|
+
* @example
|
|
1101
|
+
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
1102
|
+
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
882
1103
|
*/
|
|
883
|
-
function
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1104
|
+
function grep(output, pattern) {
|
|
1105
|
+
return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
|
|
1106
|
+
}
|
|
1107
|
+
//#endregion
|
|
1108
|
+
//#region src/specification/facets/_common/result/text.ts
|
|
1109
|
+
const ANSI_RE = /\u001B\[[0-9;]*m/gu;
|
|
1110
|
+
/** Strip ANSI escape sequences from a string. */
|
|
1111
|
+
function stripAnsiCodes(value) {
|
|
1112
|
+
return value.replace(ANSI_RE, "");
|
|
890
1113
|
}
|
|
891
1114
|
/**
|
|
892
|
-
*
|
|
1115
|
+
* Read-only accessor for a captured text handle — THE universal one: stdout,
|
|
1116
|
+
* stderr, container logs, and file text all surface as a `TextAccessor`.
|
|
893
1117
|
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
* `
|
|
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".
|
|
1118
|
+
* Backed by a primitive string, exposed via {@link text}, but also provides
|
|
1119
|
+
* `toString()` / `valueOf()` for string coercion, so common patterns like
|
|
1120
|
+
* `String(result.stdout)` and template-literal interpolation still work.
|
|
899
1121
|
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
1122
|
+
* ANSI escape sequences are stripped by default before every comparison
|
|
1123
|
+
* (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
|
|
1124
|
+
* an escape hatch for application noise not covered by the `{{token}}`
|
|
1125
|
+
* grammar.
|
|
1126
|
+
*
|
|
1127
|
+
* Text operations are **closed** over the type: `.grep(pattern)` returns a
|
|
1128
|
+
* `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
|
|
1129
|
+
* context and the `transform`, so results are chainable
|
|
1130
|
+
* (`result.stdout.grep(a).grep(b)`) and snapshot-able
|
|
1131
|
+
* (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
|
|
1132
|
+
*
|
|
1133
|
+
* Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
|
|
1134
|
+
* and `expect(result.stdout).toMatch('name.txt')` (resolved against
|
|
1135
|
+
* `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
|
|
902
1136
|
*/
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
});
|
|
949
|
-
},
|
|
950
|
-
press: async (key) => {
|
|
951
|
-
await userEvent.keyboard(keystroke(key));
|
|
952
|
-
},
|
|
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
|
-
});
|
|
982
|
-
},
|
|
983
|
-
select: async (element, option) => {
|
|
984
|
-
await act(element, async (locator) => {
|
|
985
|
-
await locator.selectOptions(option);
|
|
986
|
-
});
|
|
987
|
-
},
|
|
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);
|
|
1002
|
-
}
|
|
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 });
|
|
1137
|
+
var TextAccessor = class TextAccessor {
|
|
1138
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1139
|
+
captures;
|
|
1140
|
+
/** @internal Stream label used in failure messages ("stdout" / "stderr"). */
|
|
1141
|
+
streamName;
|
|
1142
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
1143
|
+
testDir;
|
|
1144
|
+
/** The raw captured text (never transformed, ANSI preserved). */
|
|
1145
|
+
text;
|
|
1146
|
+
/** @internal Normaliser applied before comparisons, never to fixtures. */
|
|
1147
|
+
transform;
|
|
1148
|
+
constructor(value, streamName, testDir, options = {}) {
|
|
1149
|
+
this.text = value;
|
|
1150
|
+
this.streamName = streamName;
|
|
1151
|
+
this.testDir = testDir;
|
|
1152
|
+
this.transform = options.transform;
|
|
1153
|
+
this.captures = options.captures ?? new CaptureScope();
|
|
1010
1154
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
unmount: rendered.unmount
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
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);
|
|
1027
|
-
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
|
-
}
|
|
1037
|
-
};
|
|
1038
|
-
}
|
|
1039
|
-
//#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;
|
|
1044
|
-
}
|
|
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
|
-
};
|
|
1155
|
+
/** @internal The text as compared by matchers — ANSI stripped, transform applied. */
|
|
1156
|
+
get comparableText() {
|
|
1157
|
+
const stripped = stripAnsiCodes(this.text);
|
|
1158
|
+
return this.transform ? this.transform(stripped) : stripped;
|
|
1061
1159
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1160
|
+
/**
|
|
1161
|
+
* Keep only the blank-line-separated blocks of the text that contain
|
|
1162
|
+
* `pattern` (how linter/compiler output is structured), returned as a new
|
|
1163
|
+
* `TextAccessor` — the same subject type, so the result is chainable and
|
|
1164
|
+
* snapshot-able. The scalpel for probing large tool outputs; snapshot the
|
|
1165
|
+
* whole surface by default, reach for `.grep()` for targeted checks.
|
|
1166
|
+
*/
|
|
1167
|
+
grep(pattern) {
|
|
1168
|
+
return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
|
|
1169
|
+
captures: this.captures,
|
|
1170
|
+
transform: this.transform
|
|
1171
|
+
});
|
|
1069
1172
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
return await matchGolden(received, expected, options?.frozen === true);
|
|
1173
|
+
toString() {
|
|
1174
|
+
return this.text;
|
|
1175
|
+
}
|
|
1176
|
+
valueOf() {
|
|
1177
|
+
return this.text;
|
|
1076
1178
|
}
|
|
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
|
-
/**
|
|
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.
|
|
1092
|
-
*/
|
|
1093
|
-
function registerBrowserMatchers() {
|
|
1094
|
-
if (registered) return;
|
|
1095
|
-
registered = true;
|
|
1096
|
-
expect.extend({
|
|
1097
|
-
toBeEmpty,
|
|
1098
|
-
toContain,
|
|
1099
|
-
toMatch
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
//#endregion
|
|
1103
|
-
//#region src/browser/node-only.ts
|
|
1104
|
-
/**
|
|
1105
|
-
* The refusals the browser build ships in place of what only node can do.
|
|
1106
|
-
*
|
|
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.
|
|
1117
|
-
*/
|
|
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.`);
|
|
1120
|
-
}
|
|
1121
|
-
/**
|
|
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.
|
|
1126
|
-
*
|
|
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.
|
|
1130
|
-
*/
|
|
1131
|
-
const nodeOnlyClass = (name) => function NodeOnly() {
|
|
1132
|
-
return refuse(name);
|
|
1133
1179
|
};
|
|
1134
1180
|
//#endregion
|
|
1135
|
-
//#region src/specification/
|
|
1181
|
+
//#region src/specification/facets/website/ambiguity.ts
|
|
1136
1182
|
/**
|
|
1137
|
-
*
|
|
1138
|
-
* and the naming convention:
|
|
1139
|
-
*
|
|
1140
|
-
* @example
|
|
1141
|
-
* // specs/api/reports/contracts/openai/classify-article.ts
|
|
1142
|
-
* import { defineContract, openai } from '@jterrazz/test';
|
|
1183
|
+
* The ambiguity refusal — CONVENTIONS W3.
|
|
1143
1184
|
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1185
|
+
* A visit descriptor must designate exactly one element. Acting on "the
|
|
1186
|
+
* first match" is the failure mode this module exists to prevent: the spec
|
|
1187
|
+
* keeps passing while the visitor clicks something else, and nothing ever
|
|
1188
|
+
* reports it. So when a descriptor matches several elements the framework
|
|
1189
|
+
* refuses, and the refusal has to carry everything needed to fix it without
|
|
1190
|
+
* opening a browser — the descriptor in the caller's own vocabulary, every
|
|
1191
|
+
* candidate, and the concrete rewrites that would resolve it.
|
|
1148
1192
|
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
* request: http.post('https://api.example.com/echo'),
|
|
1152
|
-
* response: (request) => http.json({ received: request.body }),
|
|
1153
|
-
* });
|
|
1193
|
+
* Pure string building: it takes captured data and returns a message, so the
|
|
1194
|
+
* wording is unit-testable and the browser integration stays a thin adapter.
|
|
1154
1195
|
*/
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1196
|
+
/** `kind` → the constructor that builds it, so errors echo the caller's source. */
|
|
1197
|
+
const CONSTRUCTORS = {
|
|
1198
|
+
banner: "banner",
|
|
1199
|
+
button: "button",
|
|
1200
|
+
complementary: "complementary",
|
|
1201
|
+
contentinfo: "contentinfo",
|
|
1202
|
+
dialog: "dialog",
|
|
1203
|
+
field: "field",
|
|
1204
|
+
form: "form",
|
|
1205
|
+
heading: "heading",
|
|
1206
|
+
link: "link",
|
|
1207
|
+
listitem: "listitem",
|
|
1208
|
+
main: "main",
|
|
1209
|
+
navigation: "navigation",
|
|
1210
|
+
option: "option",
|
|
1211
|
+
region: "region",
|
|
1212
|
+
row: "row",
|
|
1213
|
+
search: "search",
|
|
1214
|
+
status: "status",
|
|
1215
|
+
table: "table",
|
|
1216
|
+
testId: "testId",
|
|
1217
|
+
text: "content"
|
|
1218
|
+
};
|
|
1219
|
+
/** The landmark a context string maps back to, for a copy-pasteable suggestion. */
|
|
1220
|
+
const CONTEXT_LANDMARKS = {
|
|
1221
|
+
aside: "complementary()",
|
|
1222
|
+
banner: "banner()",
|
|
1223
|
+
complementary: "complementary()",
|
|
1224
|
+
contentinfo: "contentinfo()",
|
|
1225
|
+
footer: "contentinfo()",
|
|
1226
|
+
form: "form()",
|
|
1227
|
+
header: "banner()",
|
|
1228
|
+
main: "main()",
|
|
1229
|
+
nav: "navigation()",
|
|
1230
|
+
navigation: "navigation()",
|
|
1231
|
+
search: "search()"
|
|
1232
|
+
};
|
|
1166
1233
|
/**
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1234
|
+
* Render a descriptor as the source that would build it — `link('Articles')`,
|
|
1235
|
+
* `within(navigation(), link('Articles', { exact: true }))`. The error speaks
|
|
1236
|
+
* the vocabulary the author wrote, never playwright's.
|
|
1170
1237
|
*/
|
|
1171
|
-
function
|
|
1172
|
-
const
|
|
1173
|
-
return
|
|
1174
|
-
}
|
|
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}`;
|
|
1179
|
-
}
|
|
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;
|
|
1238
|
+
function formatElement(element) {
|
|
1239
|
+
const bare = formatBare(element);
|
|
1240
|
+
return element.scope ? `within(${formatElement(element.scope)}, ${bare})` : bare;
|
|
1187
1241
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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));
|
|
1203
|
-
*/
|
|
1204
|
-
function defineContracts(...items) {
|
|
1205
|
-
const contracts = Object.freeze(flatten(items));
|
|
1206
|
-
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
|
-
}
|
|
1214
|
-
};
|
|
1242
|
+
function formatBare(element) {
|
|
1243
|
+
const args = [];
|
|
1244
|
+
if (element.name !== void 0) args.push(JSON.stringify(element.name));
|
|
1245
|
+
if (element.exact) args.push("{ exact: true }");
|
|
1246
|
+
return `${CONSTRUCTORS[element.kind]}(${args.join(", ")})`;
|
|
1215
1247
|
}
|
|
1216
|
-
/**
|
|
1217
|
-
function
|
|
1218
|
-
|
|
1248
|
+
/** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
|
|
1249
|
+
function formatMatch(match, index) {
|
|
1250
|
+
const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
|
|
1251
|
+
const named = match.accessibleName ? ` named ${JSON.stringify(match.accessibleName)}` : "";
|
|
1252
|
+
const context = match.context ? ` in <${match.context}>` : "";
|
|
1253
|
+
return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${named}${context}`;
|
|
1219
1254
|
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
/**
|
|
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.
|
|
1226
|
-
*
|
|
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]`.
|
|
1232
|
-
*
|
|
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).
|
|
1237
|
-
*/
|
|
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
|
-
};
|
|
1255
|
+
function quoteDetail(match) {
|
|
1256
|
+
return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
|
|
1252
1257
|
}
|
|
1253
|
-
/**
|
|
1254
|
-
function
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1258
|
+
/** Each landmark the candidates sit in, and how many of them it holds. */
|
|
1259
|
+
function landmarkCounts(matches) {
|
|
1260
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1261
|
+
for (const match of matches) {
|
|
1262
|
+
const landmark = match.context === void 0 ? void 0 : CONTEXT_LANDMARKS[match.context];
|
|
1263
|
+
if (landmark !== void 0) counts.set(landmark, (counts.get(landmark) ?? 0) + 1);
|
|
1259
1264
|
}
|
|
1260
|
-
|
|
1261
|
-
/** Token-aware comparison of one declared value against an observed one. */
|
|
1262
|
-
function valueMatches(declared, observed) {
|
|
1263
|
-
return textEquals(declared, observed, new CaptureScope());
|
|
1265
|
+
return [...counts].toSorted(([, a], [, b]) => a - b);
|
|
1264
1266
|
}
|
|
1265
1267
|
/**
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1268
|
+
* The rewrites worth offering, in the order they should be tried. Scoping is
|
|
1269
|
+
* always available; `exact` only when it would actually narrow the set, and
|
|
1270
|
+
* the count it would leave is stated so a still-ambiguous suggestion never
|
|
1271
|
+
* reads as a fix.
|
|
1270
1272
|
*/
|
|
1271
|
-
function
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1273
|
+
function formatFixes(element, matches) {
|
|
1274
|
+
const fixes = [];
|
|
1275
|
+
const narrowing = landmarkCounts(matches).filter(([, count]) => count < matches.length);
|
|
1276
|
+
if (!element.scope) {
|
|
1277
|
+
const [first, ...rest] = narrowing;
|
|
1278
|
+
const alsoHere = rest.length > 0 ? ` [also here: ${rest.map(([name]) => name).join(", ")}]` : "";
|
|
1279
|
+
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
1280
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
if (!query.getAll(key).every((value) => observedValues.some((actual) => valueMatches(value, actual)))) return false;
|
|
1281
|
+
if (!element.exact && element.name !== void 0) {
|
|
1282
|
+
const remaining = matches.filter((match) => match.text === element.name).length;
|
|
1283
|
+
if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
|
|
1284
|
+
...element,
|
|
1285
|
+
exact: true
|
|
1286
|
+
})} [leaves ${remaining} of ${matches.length}]`);
|
|
1286
1287
|
}
|
|
1287
|
-
|
|
1288
|
+
fixes.push("other element a heading(), button() or field() may name one thing where this does not");
|
|
1289
|
+
return fixes;
|
|
1288
1290
|
}
|
|
1289
1291
|
/**
|
|
1290
|
-
*
|
|
1291
|
-
*
|
|
1292
|
+
* The W3 refusal. A distinct class so the visit wrapper can recognize an
|
|
1293
|
+
* already-complete message and let it through instead of nesting it inside
|
|
1294
|
+
* `visit scenario failed: …`, which would print the whole thing twice.
|
|
1292
1295
|
*/
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
if (!urlMatches(url, request.url)) return false;
|
|
1297
|
-
return match ? match(request) : true;
|
|
1298
|
-
}
|
|
1296
|
+
var AmbiguousElementError = class extends Error {
|
|
1297
|
+
name = "AmbiguousElementError";
|
|
1298
|
+
};
|
|
1299
1299
|
/**
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1300
|
+
* Build the refusal thrown when a descriptor matches more than one element.
|
|
1301
|
+
*
|
|
1302
|
+
* @param options.element The descriptor as the caller wrote it.
|
|
1303
|
+
* @param options.matches Every candidate, in DOM order (already truncated).
|
|
1304
|
+
* @param options.url The page the visitor was on when it happened.
|
|
1303
1305
|
*/
|
|
1304
|
-
function
|
|
1305
|
-
|
|
1306
|
-
const
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1306
|
+
function describeAmbiguity(options) {
|
|
1307
|
+
const { element, matches, url } = options;
|
|
1308
|
+
const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
|
|
1309
|
+
return [
|
|
1310
|
+
`Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on ${url}.`,
|
|
1311
|
+
"",
|
|
1312
|
+
"A spec must designate exactly one element. Acting on the first match would let",
|
|
1313
|
+
"this test keep passing while the visitor interacts with something else.",
|
|
1314
|
+
"",
|
|
1315
|
+
"Matched:",
|
|
1316
|
+
...matches.map((match, index) => formatMatch(match, index)),
|
|
1317
|
+
"",
|
|
1318
|
+
"Disambiguate with one of:",
|
|
1319
|
+
...fixes,
|
|
1320
|
+
"",
|
|
1321
|
+
"Docs: docs/14-website.md#designating-exactly-one-element (CONVENTIONS W3)"
|
|
1322
|
+
].join("\n");
|
|
1316
1323
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
return
|
|
1324
|
+
//#endregion
|
|
1325
|
+
//#region src/integrations/vitest-browser/vitest-browser.adapter.ts
|
|
1326
|
+
/**
|
|
1327
|
+
* The browser-mode adapter — the component facet's half of the one element
|
|
1328
|
+
* vocabulary.
|
|
1329
|
+
*
|
|
1330
|
+
* Its translation table is the playwright adapter's, descriptor for
|
|
1331
|
+
* descriptor: a `button('Save')` means `getByRole('button', { name })` whether
|
|
1332
|
+
* the surface is a served page or a mounted component, and the W3 refusal
|
|
1333
|
+
* enumerates candidates in the same words. Two adapters, one dialect — which
|
|
1334
|
+
* is the whole reason a page spec and a component spec read alike.
|
|
1335
|
+
*
|
|
1336
|
+
* What differs is where the code runs. Playwright drives a page from the
|
|
1337
|
+
* outside and reads it through `evaluate`; here the test IS in the page, so a
|
|
1338
|
+
* candidate is described from the live element and the ARIA tree is the one
|
|
1339
|
+
* thing that has to go back out (`server.commands`, the provider's own frame).
|
|
1340
|
+
*/
|
|
1341
|
+
/** How many candidates the ambiguity error enumerates before truncating. */
|
|
1342
|
+
const MAX_REPORTED_MATCHES = 10;
|
|
1343
|
+
/** The landmark ancestors the ambiguity evidence names. */
|
|
1344
|
+
const LANDMARKS = "nav, header, footer, main, aside, section, form, [role]";
|
|
1345
|
+
/**
|
|
1346
|
+
* Translate a user-facing descriptor into a Browser Mode locator, resolving
|
|
1347
|
+
* `scope` outside-in so `within(navigation(), link('X'))` searches the nav
|
|
1348
|
+
* subtree. No `.first()` anywhere: designating exactly one element is
|
|
1349
|
+
* CONVENTIONS W3, and taking the first match is the silent wrong-element bug
|
|
1350
|
+
* the rule exists to prevent.
|
|
1351
|
+
*/
|
|
1352
|
+
function locate(root, element) {
|
|
1353
|
+
const scope = element.scope ? locate(root, element.scope) : root;
|
|
1354
|
+
const exact = element.exact ?? false;
|
|
1355
|
+
const name = element.name ?? "";
|
|
1356
|
+
if (element.kind === "field") return scope.getByLabelText(name, { exact });
|
|
1357
|
+
if (element.kind === "testId") return scope.getByTestId(name);
|
|
1358
|
+
if (element.kind === "text") return scope.getByText(name, { exact });
|
|
1359
|
+
return scope.getByRole(element.kind, element.name === void 0 ? { exact } : {
|
|
1360
|
+
exact,
|
|
1361
|
+
name: element.name
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
/** Describe the live candidates — the evidence the W3 refusal enumerates. */
|
|
1365
|
+
function candidates(locator) {
|
|
1366
|
+
return locator.elements().slice(0, MAX_REPORTED_MATCHES).map((node) => {
|
|
1367
|
+
const landmark = node.parentElement?.closest(LANDMARKS);
|
|
1368
|
+
const context = landmark?.getAttribute("role") ?? landmark?.tagName.toLowerCase();
|
|
1369
|
+
const detail = node.getAttribute("href") ?? node.getAttribute("name");
|
|
1370
|
+
const text = (node.textContent ?? "").replaceAll(/\s+/gu, " ").trim().slice(0, 80);
|
|
1371
|
+
const label = node.getAttribute("aria-label")?.trim();
|
|
1372
|
+
return {
|
|
1373
|
+
accessibleName: label !== void 0 && label !== text ? label : void 0,
|
|
1374
|
+
context: context ?? void 0,
|
|
1375
|
+
detail: detail ?? void 0,
|
|
1376
|
+
tag: node.tagName.toLowerCase(),
|
|
1377
|
+
text
|
|
1378
|
+
};
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
/** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
|
|
1382
|
+
function ambiguousLevel(element) {
|
|
1383
|
+
const chain = [];
|
|
1384
|
+
for (let current = element; current; current = current.scope) chain.unshift(current);
|
|
1385
|
+
for (const level of chain.slice(0, -1)) if (locate(page, level).elements().length > 1) return level;
|
|
1386
|
+
return element;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Run one visitor action, turning "more than one match" into the W3 refusal.
|
|
1390
|
+
* The ambiguous LEVEL is identified first: a scope matching several landmarks
|
|
1391
|
+
* is the real fault, and naming the target would send the author to fix the
|
|
1392
|
+
* wrong descriptor.
|
|
1393
|
+
*/
|
|
1394
|
+
async function act(element, action) {
|
|
1395
|
+
const locator = locate(page, element);
|
|
1396
|
+
try {
|
|
1397
|
+
return await action(locator);
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
if (locator.elements().length <= 1) throw error;
|
|
1400
|
+
const culprit = ambiguousLevel(element);
|
|
1401
|
+
const matches = candidates(locate(page, culprit));
|
|
1402
|
+
const url = globalThis.location.href;
|
|
1403
|
+
throw new AmbiguousElementError(describeAmbiguity({
|
|
1404
|
+
element: culprit,
|
|
1405
|
+
matches,
|
|
1406
|
+
url
|
|
1407
|
+
}));
|
|
1391
1408
|
}
|
|
1392
|
-
}
|
|
1393
|
-
//#endregion
|
|
1394
|
-
//#region src/integrations/msw/handlers.ts
|
|
1409
|
+
}
|
|
1395
1410
|
/**
|
|
1396
|
-
*
|
|
1411
|
+
* Is this descriptor answered by PRESENCE rather than by visibility?
|
|
1397
1412
|
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
* spec whose subject IS caching can still say otherwise.
|
|
1413
|
+
* The options of a collapsed `<select>` are in the document and in the
|
|
1414
|
+
* accessibility tree, and not one of them has a box on the screen — asking
|
|
1415
|
+
* whether an option is visible would answer "no" for every select a visitor
|
|
1416
|
+
* never opened.
|
|
1403
1417
|
*/
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
});
|
|
1418
|
+
function byPresence(element) {
|
|
1419
|
+
return element.kind === "option";
|
|
1420
|
+
}
|
|
1421
|
+
/** A control's live value — the property, never the attribute. */
|
|
1422
|
+
function valueOf(node) {
|
|
1423
|
+
return node instanceof HTMLInputElement || node instanceof HTMLSelectElement || node instanceof HTMLTextAreaElement ? node.value : void 0;
|
|
1431
1424
|
}
|
|
1432
1425
|
/**
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
|
|
1437
|
-
|
|
1426
|
+
* Is the element gone — or, with a state modifier, no longer in that state? A
|
|
1427
|
+
* node that left the document and one that is still there but not rendered are
|
|
1428
|
+
* the same answer to a spec.
|
|
1429
|
+
*/
|
|
1430
|
+
function isGone(locator, element) {
|
|
1431
|
+
const node = locator.query();
|
|
1432
|
+
if (element.focused === true) return node !== document.activeElement;
|
|
1433
|
+
if (element.disabled !== void 0) return node === null || node.matches(":disabled") !== element.disabled;
|
|
1434
|
+
if (element.selected !== void 0) return node === null || node.matches(":checked") !== element.selected;
|
|
1435
|
+
if (element.value !== void 0) return node === null || valueOf(node) !== element.value;
|
|
1436
|
+
return node === null || (byPresence(element) ? false : !node.checkVisibility());
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* How long `gone()` waits — what is LEFT of the running test's budget.
|
|
1438
1440
|
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
+
* Every other verb already gets it: a locator action and `expect.element`
|
|
1442
|
+
* default their timeout to the remaining budget, and only a bare
|
|
1443
|
+
* `expect.poll` falls back to vitest's one second. That second made `gone()`
|
|
1444
|
+
* the one verb that could not outlast a slow answer — a button taken off the
|
|
1445
|
+
* screen when a two-second request returns was "still on the screen".
|
|
1446
|
+
*
|
|
1447
|
+
* The 100ms margin is vitest's own: the poll has to give up just BEFORE the
|
|
1448
|
+
* test does, or the failure is a bare timeout naming no element.
|
|
1441
1449
|
*/
|
|
1442
|
-
function
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1450
|
+
function remainingBudget() {
|
|
1451
|
+
const current = TestRunner.getCurrentTest();
|
|
1452
|
+
const started = current?.result?.startTime;
|
|
1453
|
+
if (current === void 0 || started === void 0) return;
|
|
1454
|
+
return Math.max(current.timeout - (Date.now() - started) - 100, 0);
|
|
1455
|
+
}
|
|
1456
|
+
/** `expect.poll` over that budget — the retry the matchers get for free. */
|
|
1457
|
+
function polling(probe, message) {
|
|
1458
|
+
const timeout = remainingBudget();
|
|
1459
|
+
return expect.poll(probe, {
|
|
1460
|
+
message,
|
|
1461
|
+
...timeout === void 0 ? {} : { timeout }
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
/** A key name is a key; a single character is typed as itself. */
|
|
1465
|
+
function keystroke(key) {
|
|
1466
|
+
return key.length > 1 ? `{${key}}` : key;
|
|
1467
|
+
}
|
|
1468
|
+
/** The verbs a rendered surface shares with a page, plus the two a parent owns. */
|
|
1469
|
+
function componentVerbs(surface) {
|
|
1470
|
+
return {
|
|
1471
|
+
check: async (element) => {
|
|
1472
|
+
await act(element, async (locator) => {
|
|
1473
|
+
const node = locator.element();
|
|
1474
|
+
if (!(node instanceof HTMLInputElement) || !node.checked) await locator.click();
|
|
1475
|
+
});
|
|
1476
|
+
},
|
|
1477
|
+
click: async (element) => {
|
|
1478
|
+
await act(element, async (locator) => {
|
|
1479
|
+
await locator.click();
|
|
1480
|
+
});
|
|
1481
|
+
},
|
|
1482
|
+
fill: async (element, value) => {
|
|
1483
|
+
await act(element, async (locator) => {
|
|
1484
|
+
await locator.fill(value);
|
|
1485
|
+
});
|
|
1486
|
+
},
|
|
1487
|
+
gone: async (element) => {
|
|
1488
|
+
await act(element, async (locator) => {
|
|
1489
|
+
await polling(() => isGone(locator, element), `${formatElement(element)} is still on the screen`).toBeTruthy();
|
|
1490
|
+
});
|
|
1491
|
+
},
|
|
1492
|
+
hover: async (element) => {
|
|
1493
|
+
await act(element, async (locator) => {
|
|
1494
|
+
await locator.hover();
|
|
1495
|
+
});
|
|
1496
|
+
},
|
|
1497
|
+
press: async (key) => {
|
|
1498
|
+
await userEvent.keyboard(keystroke(key));
|
|
1499
|
+
},
|
|
1500
|
+
rerender: surface.rerender,
|
|
1501
|
+
see: async (element) => {
|
|
1502
|
+
await act(element, async (locator) => {
|
|
1503
|
+
if (element.focused === true) {
|
|
1504
|
+
await expect.element(locator).toHaveFocus();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (element.disabled === true) {
|
|
1508
|
+
await expect.element(locator).toBeDisabled();
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (element.disabled === false) {
|
|
1512
|
+
await expect.element(locator).toBeEnabled();
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (element.selected !== void 0) {
|
|
1516
|
+
await polling(() => locator.query()?.matches(":checked") === true, `${formatElement(element)} is not the selected one`).toBe(element.selected);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
if (element.value !== void 0) {
|
|
1520
|
+
await expect.element(locator).toHaveValue(element.value);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (byPresence(element)) {
|
|
1524
|
+
await expect.element(locator).toBeInTheDocument();
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
await expect.element(locator).toBeVisible();
|
|
1528
|
+
});
|
|
1529
|
+
},
|
|
1530
|
+
select: async (element, option) => {
|
|
1531
|
+
await act(element, async (locator) => {
|
|
1532
|
+
await locator.selectOptions(option);
|
|
1533
|
+
});
|
|
1534
|
+
},
|
|
1535
|
+
unmount: surface.unmount
|
|
1451
1536
|
};
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1537
|
+
}
|
|
1538
|
+
/** The ARIA snapshot of the rendered `<body>` — the golden a tree is asserted as. */
|
|
1539
|
+
async function ariaTree() {
|
|
1540
|
+
return await commands.ariaTree();
|
|
1541
|
+
}
|
|
1542
|
+
/** A golden's content, read server-side from `_expected/` beside the test. */
|
|
1543
|
+
async function readGolden(name) {
|
|
1544
|
+
return await commands.goldenRead(name);
|
|
1545
|
+
}
|
|
1546
|
+
/** Write a golden server-side, and say where — update mode's half. */
|
|
1547
|
+
async function writeGolden(name, content) {
|
|
1548
|
+
return await commands.goldenWrite(name, content);
|
|
1549
|
+
}
|
|
1550
|
+
/** Mount a React subject through the optional `vitest-browser-react` adapter. */
|
|
1551
|
+
async function mountReact(ui) {
|
|
1552
|
+
let render;
|
|
1553
|
+
try {
|
|
1554
|
+
({render} = await import("vitest-browser-react"));
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
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
1557
|
}
|
|
1465
|
-
|
|
1558
|
+
const rendered = await render(ui);
|
|
1466
1559
|
return {
|
|
1467
|
-
|
|
1468
|
-
|
|
1560
|
+
container: rendered.container,
|
|
1561
|
+
rerender: rendered.rerender,
|
|
1562
|
+
unmount: rendered.unmount
|
|
1469
1563
|
};
|
|
1470
1564
|
}
|
|
1471
|
-
/**
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const headers = {};
|
|
1481
|
-
request.headers.forEach((value, key) => {
|
|
1482
|
-
headers[key.toLowerCase()] = value;
|
|
1483
|
-
});
|
|
1565
|
+
/**
|
|
1566
|
+
* Mount a vanilla DOM subject: the function is handed a fresh container and
|
|
1567
|
+
* may return its own teardown — the shape a DOM library states one in. There
|
|
1568
|
+
* is no react here, and no react is loaded.
|
|
1569
|
+
*/
|
|
1570
|
+
function mountDom(mount) {
|
|
1571
|
+
const container = document.createElement("div");
|
|
1572
|
+
document.body.append(container);
|
|
1573
|
+
const teardown = mount(container);
|
|
1484
1574
|
return {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1575
|
+
container,
|
|
1576
|
+
rerender: () => {
|
|
1577
|
+
throw new Error("rerender() replaces a React tree; a DOM function is re-rendered by calling it again in a new render().");
|
|
1578
|
+
},
|
|
1579
|
+
unmount: async () => {
|
|
1580
|
+
teardown?.();
|
|
1581
|
+
container.remove();
|
|
1582
|
+
await Promise.resolve();
|
|
1583
|
+
}
|
|
1489
1584
|
};
|
|
1490
1585
|
}
|
|
1491
1586
|
//#endregion
|
|
1492
|
-
//#region src/integrations/
|
|
1587
|
+
//#region src/integrations/vitest-browser/golden.ts
|
|
1588
|
+
/** Update mode crosses the seam as a provided value, never as an env read. */
|
|
1589
|
+
function updating() {
|
|
1590
|
+
return inject("update") === true;
|
|
1591
|
+
}
|
|
1592
|
+
async function matchGolden(accessor, name, frozen) {
|
|
1593
|
+
requireExtension(name, "stream");
|
|
1594
|
+
const actual = accessor.comparableText;
|
|
1595
|
+
const stored = await readGolden(name);
|
|
1596
|
+
if (stored === null) {
|
|
1597
|
+
if (updating() && !frozen) {
|
|
1598
|
+
const path = await writeGolden(name, actual);
|
|
1599
|
+
return {
|
|
1600
|
+
message: () => `wrote ${path}`,
|
|
1601
|
+
pass: true
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
return {
|
|
1605
|
+
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.`,
|
|
1606
|
+
pass: false
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
const comparison = compareStreamText(accessor, name, stored);
|
|
1610
|
+
if (!comparison.pass && updating() && !frozen) {
|
|
1611
|
+
await writeGolden(name, actual);
|
|
1612
|
+
return {
|
|
1613
|
+
message: () => `updated ${GROUND_EXPECTED}/${name}`,
|
|
1614
|
+
pass: true
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
return comparison;
|
|
1618
|
+
}
|
|
1619
|
+
async function toMatch(received, expected, options) {
|
|
1620
|
+
if (received instanceof TextAccessor) {
|
|
1621
|
+
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/).");
|
|
1622
|
+
return await matchGolden(received, expected, options?.frozen === true);
|
|
1623
|
+
}
|
|
1624
|
+
return nativeMatch(received, expected);
|
|
1625
|
+
}
|
|
1626
|
+
function toContain(received, expected) {
|
|
1627
|
+
if (received instanceof TextAccessor) return textContains(received, String(expected));
|
|
1628
|
+
return nativeContain(received, expected);
|
|
1629
|
+
}
|
|
1630
|
+
function toBeEmpty(received) {
|
|
1631
|
+
if (!(received instanceof TextAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected a text accessor.");
|
|
1632
|
+
return textIsEmpty(received);
|
|
1633
|
+
}
|
|
1634
|
+
let registered = false;
|
|
1493
1635
|
/**
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
1636
|
+
* Register the page's matchers with vitest's `expect`. Idempotent, and called
|
|
1637
|
+
* from the browser entry itself: a component test imports `@jterrazz/test` to
|
|
1638
|
+
* get `component`, and that import IS the registration.
|
|
1496
1639
|
*/
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1640
|
+
function registerBrowserMatchers() {
|
|
1641
|
+
if (registered) return;
|
|
1642
|
+
registered = true;
|
|
1643
|
+
expect.extend({
|
|
1644
|
+
toBeEmpty,
|
|
1645
|
+
toContain,
|
|
1646
|
+
toMatch
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
//#endregion
|
|
1650
|
+
//#region src/browser/node-only.ts
|
|
1506
1651
|
/**
|
|
1507
|
-
*
|
|
1652
|
+
* The refusals the browser build ships in place of what only node can do.
|
|
1508
1653
|
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
* test would let a real `/api/posts.json` through on the same ground.
|
|
1654
|
+
* `@jterrazz/test` has ONE specifier and two runtimes, so the page gets the
|
|
1655
|
+
* same NAMES as the server: a component test that reaches for
|
|
1656
|
+
* `specification.api()` or `postgres()` fails on the line that reached, saying
|
|
1657
|
+
* where that name runs — not on an unresolved import three layers down, and
|
|
1658
|
+
* not silently on `undefined`.
|
|
1515
1659
|
*/
|
|
1516
|
-
function isRunnerRequest(url) {
|
|
1517
|
-
const { pathname } = new URL(url);
|
|
1518
|
-
return RUNNER_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
1519
|
-
}
|
|
1520
1660
|
/**
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1661
|
+
* The refusal, in one line, whatever the name. Returns `never`, so a stub is
|
|
1662
|
+
* an ordinary arrow function the page's entry annotates with the node
|
|
1663
|
+
* signature — no cast, and no way for the two surfaces to disagree.
|
|
1524
1664
|
*/
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
});
|
|
1665
|
+
function refuse(name) {
|
|
1666
|
+
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
1667
|
}
|
|
1537
1668
|
/**
|
|
1538
|
-
*
|
|
1669
|
+
* A node-only CLASS, present under the same name. A class has a shape a stub
|
|
1670
|
+
* cannot honour — every instance member of the real one — so this is the one
|
|
1671
|
+
* place the page's entry states a type it does not structurally satisfy; what
|
|
1672
|
+
* the value can actually do is throw, which is the whole of the contract.
|
|
1539
1673
|
*
|
|
1540
|
-
* The
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
* answered by whatever the dev server happens to have at that path.
|
|
1674
|
+
* The stub is a constructible FUNCTION, never an arrow: `new` on an arrow
|
|
1675
|
+
* throws "is not a constructor" before the refusal can run, so the page would
|
|
1676
|
+
* lose the one thing the stub exists to say — which name it reached for.
|
|
1544
1677
|
*/
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
-
}
|
|
1678
|
+
const nodeOnlyClass = (name) => function NodeOnly() {
|
|
1679
|
+
return refuse(name);
|
|
1680
|
+
};
|
|
1560
1681
|
//#endregion
|
|
1561
1682
|
//#region src/integrations/vitest-browser/page-runtime.ts
|
|
1562
1683
|
/**
|
|
@@ -1846,7 +1967,13 @@ var JsonAccessor = class {
|
|
|
1846
1967
|
this.transform = transform;
|
|
1847
1968
|
this.captures = captures ?? new CaptureScope();
|
|
1848
1969
|
}
|
|
1849
|
-
/**
|
|
1970
|
+
/**
|
|
1971
|
+
* The parsed JSON value. Throws if the text is not valid JSON.
|
|
1972
|
+
*
|
|
1973
|
+
* `Value` is `unknown` unless the subject that produced this accessor
|
|
1974
|
+
* knows better — `.call<T>()` does, and hands its T straight through so a
|
|
1975
|
+
* one-field reading needs no cast in the test.
|
|
1976
|
+
*/
|
|
1850
1977
|
get value() {
|
|
1851
1978
|
const stripped = stripAnsiCodes(this.rawText);
|
|
1852
1979
|
const source = this.transform ? this.transform(stripped) : stripped;
|
|
@@ -2139,6 +2266,18 @@ function wrapJson(data) {
|
|
|
2139
2266
|
};
|
|
2140
2267
|
}
|
|
2141
2268
|
const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
2269
|
+
const STREAM_CONTENT_TYPE = "application/octet-stream";
|
|
2270
|
+
const SSE_CONTENT_TYPE = "text/event-stream";
|
|
2271
|
+
/** The wire form of one event — the frame an `EventSource` parses. */
|
|
2272
|
+
function frameEvent(event) {
|
|
2273
|
+
const lines = [];
|
|
2274
|
+
if (event.event !== void 0) lines.push(`event: ${event.event}`);
|
|
2275
|
+
if (event.id !== void 0) lines.push(`id: ${event.id}`);
|
|
2276
|
+
if (event.retry !== void 0) lines.push(`retry: ${event.retry}`);
|
|
2277
|
+
const payload = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
|
|
2278
|
+
for (const line of payload.split("\n")) lines.push(`data: ${line}`);
|
|
2279
|
+
return `${lines.join("\n")}\n\n`;
|
|
2280
|
+
}
|
|
2142
2281
|
function matchesBody(body, expected) {
|
|
2143
2282
|
if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
|
|
2144
2283
|
if (expected instanceof RegExp) {
|
|
@@ -2248,6 +2387,52 @@ const http = {
|
|
|
2248
2387
|
transport: "network-error"
|
|
2249
2388
|
};
|
|
2250
2389
|
},
|
|
2390
|
+
/**
|
|
2391
|
+
* Response: a body that arrives in PIECES, written in order.
|
|
2392
|
+
*
|
|
2393
|
+
* A subject that reads a response as it lands — a token-by-token render, a
|
|
2394
|
+
* progress bar, a reconnect on a half-read body — is specified by the
|
|
2395
|
+
* chunks and their order, which a single serialised body cannot state.
|
|
2396
|
+
* Honoured by all three engines: msw's server, msw's worker, and the
|
|
2397
|
+
* `node:http` stub the website and mobile facets serve from.
|
|
2398
|
+
*
|
|
2399
|
+
* @example
|
|
2400
|
+
* http.stream(['Hel', 'lo, ', 'world'], { contentType: 'text/plain', delay: 10 })
|
|
2401
|
+
*/
|
|
2402
|
+
stream(chunks, init) {
|
|
2403
|
+
return {
|
|
2404
|
+
status: init?.status ?? 200,
|
|
2405
|
+
body: {
|
|
2406
|
+
chunks: [...chunks],
|
|
2407
|
+
contentType: init?.contentType ?? STREAM_CONTENT_TYPE,
|
|
2408
|
+
delayBetweenChunks: init?.delay ?? 0,
|
|
2409
|
+
kind: STREAM_BODY
|
|
2410
|
+
},
|
|
2411
|
+
headers: init?.headers
|
|
2412
|
+
};
|
|
2413
|
+
},
|
|
2414
|
+
/**
|
|
2415
|
+
* Response: a server-sent event stream — one chunk per event, framed the
|
|
2416
|
+
* way an `EventSource` reads them, served as `text/event-stream`.
|
|
2417
|
+
*
|
|
2418
|
+
* @example
|
|
2419
|
+
* http.sse([{ data: { token: 'Hel' } }, { data: { token: 'lo' } }, { event: 'done', data: '' }])
|
|
2420
|
+
*/
|
|
2421
|
+
sse(events, init) {
|
|
2422
|
+
return {
|
|
2423
|
+
status: init?.status ?? 200,
|
|
2424
|
+
body: {
|
|
2425
|
+
chunks: events.map(frameEvent),
|
|
2426
|
+
contentType: SSE_CONTENT_TYPE,
|
|
2427
|
+
delayBetweenChunks: init?.delay ?? 0,
|
|
2428
|
+
kind: STREAM_BODY
|
|
2429
|
+
},
|
|
2430
|
+
headers: {
|
|
2431
|
+
"cache-control": "no-cache",
|
|
2432
|
+
...init?.headers
|
|
2433
|
+
}
|
|
2434
|
+
};
|
|
2435
|
+
},
|
|
2251
2436
|
/** Response: a text body, served as `text/plain` (200 by default). */
|
|
2252
2437
|
text(body, init) {
|
|
2253
2438
|
return {
|
|
@@ -2420,11 +2605,9 @@ const openai = {
|
|
|
2420
2605
|
};
|
|
2421
2606
|
//#endregion
|
|
2422
2607
|
//#region src/vitest/mock-of.ts
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
*/
|
|
2427
|
-
const mockOf = mockDeep;
|
|
2608
|
+
function mockOf(options) {
|
|
2609
|
+
return options?.deep === false ? mock() : mockDeep();
|
|
2610
|
+
}
|
|
2428
2611
|
//#endregion
|
|
2429
2612
|
//#region src/vitest/mock-of-date.ts
|
|
2430
2613
|
/**
|
|
@@ -2433,20 +2616,155 @@ const mockOf = mockDeep;
|
|
|
2433
2616
|
*/
|
|
2434
2617
|
const mockOfDate = MockDatePackage;
|
|
2435
2618
|
//#endregion
|
|
2619
|
+
//#region src/vitest/clock.ts
|
|
2620
|
+
/** The instant `iso` names, or a refusal that says what an instant looks like. */
|
|
2621
|
+
function instantOf(method, iso) {
|
|
2622
|
+
const at = new Date(iso);
|
|
2623
|
+
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').`);
|
|
2624
|
+
return at;
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* One clock per test — a second one taken inside the first is refused rather
|
|
2628
|
+
* than nested.
|
|
2629
|
+
*
|
|
2630
|
+
* Disposing gives the REAL clock back, which is the only thing vitest's fake
|
|
2631
|
+
* timers can restore: the instant, the `toFake` set and the queue a previous
|
|
2632
|
+
* scope held are not readable, so an inner scope that ended would silently end
|
|
2633
|
+
* the outer one too. A refusal says which of the two the test meant.
|
|
2634
|
+
*/
|
|
2635
|
+
function take(method, options) {
|
|
2636
|
+
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.`);
|
|
2637
|
+
vi.useFakeTimers(options);
|
|
2638
|
+
return { [Symbol.dispose]: () => {
|
|
2639
|
+
vi.useRealTimers();
|
|
2640
|
+
} };
|
|
2641
|
+
}
|
|
2642
|
+
const clock = {
|
|
2643
|
+
/**
|
|
2644
|
+
* Move the pinned clock forward by `ms` — and, under {@link clock.run},
|
|
2645
|
+
* fire every callback that came due, awaiting what each of them started.
|
|
2646
|
+
*
|
|
2647
|
+
* Only a pinned clock moves: called with no clock taken, it says so rather
|
|
2648
|
+
* than silently doing nothing to the real one.
|
|
2649
|
+
*/
|
|
2650
|
+
async advance(ms) {
|
|
2651
|
+
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).");
|
|
2652
|
+
await vi.advanceTimersByTimeAsync(ms);
|
|
2653
|
+
},
|
|
2654
|
+
/**
|
|
2655
|
+
* Pin `Date` at `iso` for the current scope. `Date` ONLY: the scheduler
|
|
2656
|
+
* stays real, so a subject that awaits, renders or polls keeps running.
|
|
2657
|
+
*
|
|
2658
|
+
* @example
|
|
2659
|
+
* using _ = clock.at('2026-03-04T09:30:00Z');
|
|
2660
|
+
* expect(stampedAt()).toBe('2026-03-04T09:30:00.000Z');
|
|
2661
|
+
*/
|
|
2662
|
+
at(iso) {
|
|
2663
|
+
return take("at", {
|
|
2664
|
+
now: instantOf("at", iso),
|
|
2665
|
+
toFake: ["Date"]
|
|
2666
|
+
});
|
|
2667
|
+
},
|
|
2668
|
+
/**
|
|
2669
|
+
* Take the scheduler as well as the calendar: `setTimeout`, `setInterval`
|
|
2670
|
+
* and their friends queue instead of firing, and {@link clock.advance} is
|
|
2671
|
+
* what makes them due. For a subject whose behaviour IS elapsed time — a
|
|
2672
|
+
* debounce, a retry backoff, a poll — where waiting for real would be the
|
|
2673
|
+
* arbitrary sleep J2 forbids.
|
|
2674
|
+
*
|
|
2675
|
+
* @example
|
|
2676
|
+
* using _ = clock.run('2026-03-04T09:30:00Z');
|
|
2677
|
+
* const pending = retrying(failsTwice);
|
|
2678
|
+
* await clock.advance(5_000);
|
|
2679
|
+
* await expect(pending).resolves.toBe('ok');
|
|
2680
|
+
*/
|
|
2681
|
+
run(iso) {
|
|
2682
|
+
return take("run", iso === void 0 ? {} : { now: instantOf("run", iso) });
|
|
2683
|
+
}
|
|
2684
|
+
};
|
|
2685
|
+
//#endregion
|
|
2686
|
+
//#region src/specification/assertions/required.ts
|
|
2687
|
+
/**
|
|
2688
|
+
* The value, or a failure that says what was missing and why it mattered.
|
|
2689
|
+
*
|
|
2690
|
+
* A test reaching into a captured structure — a row, a header, the third
|
|
2691
|
+
* element of a list — meets `T | undefined` and has to do something about it.
|
|
2692
|
+
* The two things it usually does are both wrong: `!` erases the question, and
|
|
2693
|
+
* an `if (!x) throw new Error('missing')` re-states the same three lines in
|
|
2694
|
+
* every repository (the estate had two hand-written copies of exactly this).
|
|
2695
|
+
* `required` is that gesture with the reason attached, so the failure reads as
|
|
2696
|
+
* a sentence rather than as a `TypeError` two frames further down.
|
|
2697
|
+
*
|
|
2698
|
+
* @example
|
|
2699
|
+
* const id = required(result.response.body.id, 'the creation reply carries the new id');
|
|
2700
|
+
*/
|
|
2701
|
+
function required(value, why) {
|
|
2702
|
+
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.`);
|
|
2703
|
+
return value;
|
|
2704
|
+
}
|
|
2705
|
+
//#endregion
|
|
2706
|
+
//#region src/specification/assertions/wait-until.ts
|
|
2707
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
2708
|
+
const DEFAULT_INTERVAL_MS = 50;
|
|
2709
|
+
/**
|
|
2710
|
+
* Wait until a condition holds — the sanctioned answer to "the thing I am
|
|
2711
|
+
* specifying is not ready yet".
|
|
2712
|
+
*
|
|
2713
|
+
* A spec that sleeps for a guessed duration is a spec that is slow on a fast
|
|
2714
|
+
* machine and flaky on a slow one, which is why J2 refuses `setTimeout` under
|
|
2715
|
+
* `specs/`. Every facet that drives something already waits on a CONDITION —
|
|
2716
|
+
* a served page, a banner on stdout, an element the visitor can see — and this
|
|
2717
|
+
* is that primitive where no facet owns the waiting: a background write to
|
|
2718
|
+
* land, a queue to drain, a file to appear.
|
|
2719
|
+
*
|
|
2720
|
+
* The failure names the condition and how long it was given, so a timeout is a
|
|
2721
|
+
* sentence rather than "expected true, got false".
|
|
2722
|
+
*
|
|
2723
|
+
* The budget is measured on `performance.now()`, not on `Date` — `clock.at()`
|
|
2724
|
+
* freezes the calendar, and a deadline read off a frozen calendar never
|
|
2725
|
+
* arrives: the poll would run until vitest killed the test with a sentence
|
|
2726
|
+
* about the file rather than about the condition.
|
|
2727
|
+
*
|
|
2728
|
+
* Not for a subject under `clock.run()`: there the scheduler is the test's, and
|
|
2729
|
+
* `clock.advance(ms)` is what makes time pass.
|
|
2730
|
+
*
|
|
2731
|
+
* @example
|
|
2732
|
+
* await waitUntil(() => outbox.length === 1, { why: 'the job wrote its one message' });
|
|
2733
|
+
*/
|
|
2734
|
+
async function waitUntil(predicate, options = {}) {
|
|
2735
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
2736
|
+
const interval = options.interval ?? DEFAULT_INTERVAL_MS;
|
|
2737
|
+
const deadline = performance.now() + timeout;
|
|
2738
|
+
for (;;) {
|
|
2739
|
+
if (await predicate()) return;
|
|
2740
|
+
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.`);
|
|
2741
|
+
await pause(interval);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
//#endregion
|
|
2436
2745
|
//#region src/browser/index.ts
|
|
2437
2746
|
registerBrowserMatchers();
|
|
2438
2747
|
/**
|
|
2439
|
-
* The
|
|
2748
|
+
* The module-scope network double, on msw's WORKER — the same contracts and
|
|
2749
|
+
* the same strictness the node entry publishes, served by the one engine a
|
|
2750
|
+
* page has. Everything new in this release that a page can use is real here.
|
|
2751
|
+
*/
|
|
2752
|
+
const intercept = interceptThrough(registerWorkerContracts);
|
|
2753
|
+
/**
|
|
2754
|
+
* The six constructors, present and refusing. A component test never reaches
|
|
2440
2755
|
* one: what it renders is a unit, and the assembled product behind an entry is
|
|
2441
2756
|
* specified from a node project.
|
|
2442
2757
|
*/
|
|
2443
2758
|
const specification = {
|
|
2444
2759
|
api: () => refuse("specification.api()"),
|
|
2445
2760
|
cli: () => refuse("specification.cli()"),
|
|
2761
|
+
integration: () => refuse("specification.integration()"),
|
|
2446
2762
|
jobs: () => refuse("specification.jobs()"),
|
|
2447
2763
|
mobile: () => refuse("specification.mobile()"),
|
|
2448
2764
|
website: () => refuse("specification.website()")
|
|
2449
2765
|
};
|
|
2766
|
+
const process = () => refuse("process()");
|
|
2767
|
+
const ProcessHandle = nodeOnlyClass("ProcessHandle");
|
|
2450
2768
|
const postgres = () => refuse("postgres()");
|
|
2451
2769
|
const redis = () => refuse("redis()");
|
|
2452
2770
|
const sqlite = () => refuse("sqlite()");
|
|
@@ -2456,6 +2774,7 @@ const inspectContainer = () => refuse("inspectContainer()");
|
|
|
2456
2774
|
const removeContainers = () => refuse("removeContainers()");
|
|
2457
2775
|
const Orchestrator = nodeOnlyClass("Orchestrator");
|
|
2458
2776
|
const BaseResult = nodeOnlyClass("BaseResult");
|
|
2777
|
+
const CallResult = nodeOnlyClass("CallResult");
|
|
2459
2778
|
const CliResult = nodeOnlyClass("CliResult");
|
|
2460
2779
|
const DirectoryAccessor = nodeOnlyClass("DirectoryAccessor");
|
|
2461
2780
|
const FetchResult = nodeOnlyClass("FetchResult");
|
|
@@ -2474,4 +2793,4 @@ function text(value) {
|
|
|
2474
2793
|
return new TextAccessor(value, "text", "");
|
|
2475
2794
|
}
|
|
2476
2795
|
//#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 };
|
|
2796
|
+
export { BaseResult, CallResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ProcessHandle, ResponseAccessor, ScreenResult, TableAccessor, TextAccessor, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, findContainersByLabel, focused, form, heading, http, inspectContainer, intercept, link, listitem, main, match, mockOf, mockOfDate, navigation, openai, option, postgres, process, redis, region, removeContainers, required, row, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };
|