@ultimat3/testing 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CLAUDE.md +37 -44
  2. package/README.md +297 -13
  3. package/package.json +16 -14
  4. package/src/auth-request.ts +54 -0
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser.ts +5 -10
  7. package/src/cdp-connection.ts +23 -6
  8. package/src/cdp-e2e-session.ts +10 -1
  9. package/src/cdp-errors.ts +113 -8
  10. package/src/cdp-launch-attempt.ts +192 -0
  11. package/src/cdp-launch.ts +49 -122
  12. package/src/cdp-offline-script.ts +33 -3
  13. package/src/cdp-wire-watch.ts +113 -0
  14. package/src/determinism.ts +53 -4
  15. package/src/e2e-app.ts +17 -1
  16. package/src/e2e-driver.ts +11 -2
  17. package/src/e2e-run.ts +7 -2
  18. package/src/e2e-spawn.ts +4 -8
  19. package/src/errors.ts +16 -1
  20. package/src/factories.ts +4 -6
  21. package/src/failure-context.ts +42 -0
  22. package/src/file-boundary.ts +12 -7
  23. package/src/fixture-island.ts +18 -45
  24. package/src/fixture-jobs.ts +117 -28
  25. package/src/fixtures.ts +9 -4
  26. package/src/free-port.ts +46 -0
  27. package/src/frozen-scheduler.ts +49 -0
  28. package/src/harness.ts +2 -1
  29. package/src/index.ts +41 -1
  30. package/src/island-dom.ts +8 -4
  31. package/src/island-html-entities.ts +27 -0
  32. package/src/island-scratch.ts +41 -0
  33. package/src/island-state-errors.ts +19 -0
  34. package/src/island-state-mount.ts +129 -0
  35. package/src/job-driver-checks.ts +177 -0
  36. package/src/job-driver-conformance.ts +21 -0
  37. package/src/live-node.ts +2 -0
  38. package/src/local-tx.ts +87 -0
  39. package/src/matcher-row.ts +93 -0
  40. package/src/matcher-surface.ts +7 -0
  41. package/src/matchers.ts +6 -0
  42. package/src/per-test-reset.ts +43 -0
  43. package/src/preload.ts +14 -7
  44. package/src/quiet-logs.ts +56 -0
  45. package/src/registry-leak-guard.ts +3 -1
  46. package/src/registry-snapshot.ts +22 -1
  47. package/src/render-view.ts +118 -0
  48. package/src/sealed-network.ts +5 -1
  49. package/src/template-db.ts +6 -3
  50. package/src/test-seal-key.ts +51 -0
  51. package/src/test-types.ts +53 -30
  52. package/src/e2e-dom-fixture.ts +0 -122
package/src/test-types.ts CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { test } from 'bun:test';
6
6
  import { TestEvalThresholdError } from './errors';
7
+ import type { FixtureBody } from './fixtures';
8
+ import { runWithFixtures } from './fixtures';
7
9
 
8
10
  export const TEST_TYPES = ['unit', 'contract', 'live', 'job', 'e2e', 'eval'] as const;
9
11
 
@@ -13,27 +15,39 @@ export const SEPARATOR = ' · ';
13
15
 
14
16
  export const testName = (type: TestType, name: string): string => `${type}${SEPARATOR}${name}`;
15
17
 
16
- export type TestBody = () => void | Promise<void>;
18
+ /**
19
+ * What a typed test may say about itself beyond its body. One key today: a deliberately heavy
20
+ * test (48 PDF renders) used to have to drop to `test(testName('unit', …), body, 30_000)` to
21
+ * outlive Bun's 5 s default, which is the one-way rule broken for a number (#589).
22
+ */
23
+ export interface TestOptions {
24
+ /** This test's own deadline in milliseconds, handed to `bun:test`. Absent is Bun's default. */
25
+ readonly timeoutMs?: number;
26
+ }
27
+
28
+ /**
29
+ * One typed registrar. The body is `test`'s own (`FixtureBody`): it is handed the fixture bag and
30
+ * only what it destructures is built, so `unitTest('…', async ({ runJobs }) => …)` is a job body
31
+ * under the `unit` step with no `describe(testName('unit', …))` around a bare `test`. A body that
32
+ * destructures nothing builds nothing — the wrapper costs it one resolved promise.
33
+ */
34
+ const typed =
35
+ (type: TestType) =>
36
+ (name: string, body: FixtureBody, options?: TestOptions): void => {
37
+ test(testName(type, name), () => runWithFixtures(body), options?.timeoutMs);
38
+ };
17
39
 
18
40
  /** Pure logic: no database, no clock, no network. The cheapest test that can fail for real. */
19
- export const unitTest = (name: string, body: TestBody): void => {
20
- test(testName('unit', name), body);
21
- };
41
+ export const unitTest = typed('unit');
22
42
 
23
43
  /** The published surface: OpenAPI diff against the committed spec, MCP exposure, error codes. */
24
- export const contractTest = (name: string, body: TestBody): void => {
25
- test(testName('contract', name), body);
26
- };
44
+ export const contractTest = typed('contract');
27
45
 
28
46
  /** Live queries: assert exactly what each subscriber receives, snapshot then incremental patch. */
29
- export const liveTest = (name: string, body: TestBody): void => {
30
- test(testName('live', name), body);
31
- };
47
+ export const liveTest = typed('live');
32
48
 
33
49
  /** Jobs: step sequence, retries, idempotency. Never wall-clock sleeps — advance the frozen clock. */
34
- export const jobTest = (name: string, body: TestBody): void => {
35
- test(testName('job', name), body);
36
- };
50
+ export const jobTest = typed('job');
37
51
 
38
52
  /** One element selection, resolved when it is used rather than when it is built. */
39
53
  export interface LocatorLike {
@@ -90,7 +104,10 @@ export interface E2eFixtures {
90
104
 
91
105
  export type E2eBody = (fixtures: E2eFixtures) => Promise<void>;
92
106
 
93
- let e2eDriver: ((name: string, body: E2eBody) => void) | undefined;
107
+ /** What registers one e2e test with a browser behind it. `options` is the test's own (`timeoutMs`). */
108
+ export type E2eDriver = (name: string, body: E2eBody, options?: TestOptions) => void;
109
+
110
+ let e2eDriver: E2eDriver | undefined;
94
111
 
95
112
  /**
96
113
  * Register the browser-backed driver. Without one, `e2eTest` skips loudly — the skipped test's own
@@ -105,7 +122,7 @@ let e2eDriver: ((name: string, body: E2eBody) => void) | undefined;
105
122
  * `As of 2026-08` there are zero registered drivers, so every `e2eTest` in the tree is a skip; the
106
123
  * framework's own `e2e` suites use plain `bun:test`.
107
124
  */
108
- export function useE2eDriver(driver: (name: string, body: E2eBody) => void): void {
125
+ export function useE2eDriver(driver: E2eDriver): void {
109
126
  e2eDriver = driver;
110
127
  }
111
128
 
@@ -123,7 +140,7 @@ export const resetE2eDriver = (): void => {
123
140
  e2eDriver = undefined;
124
141
  };
125
142
 
126
- export const e2eTest = (name: string, body: E2eBody): void => {
143
+ export const e2eTest = (name: string, body: E2eBody, options?: TestOptions): void => {
127
144
  if (e2eDriver === undefined) {
128
145
  test.skip(
129
146
  testName('e2e', `${name} (no browser driver: x build --target static && x e2e)`),
@@ -131,7 +148,7 @@ export const e2eTest = (name: string, body: E2eBody): void => {
131
148
  );
132
149
  return;
133
150
  }
134
- e2eDriver(testName('e2e', name), body);
151
+ e2eDriver(testName('e2e', name), body, options);
135
152
  };
136
153
 
137
154
  export interface EvalCase<TInput, TOutput> {
@@ -141,7 +158,7 @@ export interface EvalCase<TInput, TOutput> {
141
158
  score(output: TOutput): number | Promise<number>;
142
159
  }
143
160
 
144
- export interface EvalOptions<TInput, TOutput> {
161
+ export interface EvalOptions<TInput, TOutput> extends TestOptions {
145
162
  readonly threshold: number;
146
163
  readonly cases: readonly EvalCase<TInput, TOutput>[];
147
164
  run(input: TInput): Promise<TOutput>;
@@ -155,18 +172,24 @@ export function evalTest<TInput, TOutput>(
155
172
  name: string,
156
173
  options: EvalOptions<TInput, TOutput>,
157
174
  ): void {
158
- test(testName('eval', name), async () => {
159
- const scores: { readonly name: string; readonly score: number }[] = [];
160
- for (const testCase of options.cases) {
161
- const output = await options.run(testCase.input);
162
- scores.push({ name: testCase.name, score: await testCase.score(output) });
163
- }
164
- const failures = scores.filter((entry) => entry.score < options.threshold);
165
- if (failures.length > 0) {
166
- const detail = failures.map((entry) => `${entry.name}=${entry.score.toFixed(2)}`).join(', ');
167
- throw new TestEvalThresholdError({ name, threshold: options.threshold, detail });
168
- }
169
- });
175
+ test(
176
+ testName('eval', name),
177
+ async () => {
178
+ const scores: { readonly name: string; readonly score: number }[] = [];
179
+ for (const testCase of options.cases) {
180
+ const output = await options.run(testCase.input);
181
+ scores.push({ name: testCase.name, score: await testCase.score(output) });
182
+ }
183
+ const failures = scores.filter((entry) => entry.score < options.threshold);
184
+ if (failures.length > 0) {
185
+ const detail = failures
186
+ .map((entry) => `${entry.name}=${entry.score.toFixed(2)}`)
187
+ .join(', ');
188
+ throw new TestEvalThresholdError({ name, threshold: options.threshold, detail });
189
+ }
190
+ },
191
+ options.timeoutMs,
192
+ );
170
193
  }
171
194
 
172
195
  export interface OpenApiOperationLike {
@@ -1,122 +0,0 @@
1
- // A document small enough to hold in a test and real enough to RUN the driver's own in-page
2
- // expressions. Without it every claim about `getByRole` and `getByText` would be an assertion
3
- // about a string, and a string that never executes cannot be wrong about a page.
4
- //
5
- // Its own file, the pattern `dev-roles-fixture.ts` and `policy-fixture.ts` already set here.
6
-
7
- /** One element. `attrs` is data, so every read of it goes through `Object.hasOwn` below. */
8
- export interface FakeE2eElement {
9
- readonly tag: string;
10
- readonly attrs: Readonly<Record<string, string>>;
11
- readonly text?: string;
12
- readonly children?: readonly FakeE2eElement[];
13
- /** What `getComputedStyle` answers. Absent is the browser's own default — visible. */
14
- readonly style?: { display?: string; visibility?: string; opacity?: string };
15
- }
16
-
17
- interface Node {
18
- tagName: string;
19
- textContent: string;
20
- readonly attributes: Record<string, string>;
21
- readonly descendants: Node[];
22
- readonly style: { display: string; visibility: string; opacity: string };
23
- getAttribute(name: string): string | null;
24
- setAttribute(name: string, value: string): void;
25
- removeAttribute(name: string): void;
26
- contains(other: Node): boolean;
27
- }
28
-
29
- const textOf = (element: FakeE2eElement): string =>
30
- [element.text ?? '', ...(element.children ?? []).map(textOf)]
31
- .join(' ')
32
- .replace(/\s+/g, ' ')
33
- .trim();
34
-
35
- function build(element: FakeE2eElement): Node {
36
- const attributes: Record<string, string> = { ...element.attrs };
37
- const children = (element.children ?? []).map(build);
38
- const descendants = children.flatMap((child) => [child, ...child.descendants]);
39
- const node: Node = {
40
- tagName: element.tag.toUpperCase(),
41
- textContent: textOf(element),
42
- attributes,
43
- descendants,
44
- style: {
45
- display: element.style?.display ?? 'block',
46
- visibility: element.style?.visibility ?? 'visible',
47
- opacity: element.style?.opacity ?? '1',
48
- },
49
- getAttribute: (name) => (Object.hasOwn(attributes, name) ? (attributes[name] as string) : null),
50
- setAttribute: (name, value) => {
51
- attributes[name] = value;
52
- },
53
- removeAttribute: (name) => {
54
- delete attributes[name];
55
- },
56
- contains: (other) => descendants.includes(other),
57
- };
58
- return node;
59
- }
60
-
61
- /**
62
- * `tag`, `*`, `[attr]`, `[attr="value"]`, a trailing `:not([attr])` and any combination — every
63
- * shape this driver emits (`input:not([type])` is the textbox role's untyped input).
64
- */
65
- const SIMPLE = /^([a-zA-Z0-9*]*)((?:\[[^\]]*\])*)(?::not\(\[([^\]=]+)\]\))?$/;
66
-
67
- function matchesSimple(node: Node, selector: string): boolean {
68
- const parsed = SIMPLE.exec(selector.trim());
69
- if (parsed === null) return false;
70
- const tag = parsed[1] ?? '';
71
- if (tag !== '' && tag !== '*' && tag.toUpperCase() !== node.tagName) return false;
72
- const absent = parsed[3]?.trim();
73
- if (absent !== undefined && node.getAttribute(absent) !== null) return false;
74
- for (const clause of (parsed[2] ?? '').matchAll(/\[([^\]=]+)(?:=("[^"]*"|[^\]]*))?\]/g)) {
75
- const name = (clause[1] ?? '').trim();
76
- const held = node.getAttribute(name);
77
- if (held === null) return false;
78
- const raw = clause[2];
79
- if (raw !== undefined && held !== raw.replace(/^"|"$/g, '')) return false;
80
- }
81
- return true;
82
- }
83
-
84
- const matches = (node: Node, selector: string): boolean =>
85
- selector.split(',').some((part) => part.trim() !== '' && matchesSimple(node, part));
86
-
87
- /**
88
- * The globals a driver expression names, bound to one tree. `getComputedStyle` and `document` are
89
- * handed in as arguments rather than assigned to `globalThis`: a test that installed a fake
90
- * `document` on the process would leak it into every later file in the run.
91
- */
92
- export function fakeE2eDocument(root: FakeE2eElement): Readonly<Record<string, unknown>> {
93
- const rootNode = build(root);
94
- const all = [rootNode, ...rootNode.descendants];
95
- return {
96
- document: {
97
- querySelectorAll: (selector: string): Node[] => all.filter((node) => matches(node, selector)),
98
- querySelector: (selector: string): Node | null =>
99
- all.find((node) => matches(node, selector)) ?? null,
100
- getElementById: (id: string): Node | null =>
101
- all.find((node) => node.getAttribute('id') === id) ?? null,
102
- },
103
- getComputedStyle: (node: Node) => node.style,
104
- };
105
- }
106
-
107
- /**
108
- * Run an expression the driver built, in THIS process, against a stubbed global scope.
109
- *
110
- * `new Function` and not `eval`: the body evaluates with no access to this module's scope, so a
111
- * name the expression does not receive as a parameter is genuinely free — which is exactly the
112
- * `ReferenceError` a captured closure produces in a real browser, and the thing the evaluate
113
- * wrapper has to be proved against.
114
- */
115
- export async function runInFakePage(
116
- expression: string,
117
- globals: Readonly<Record<string, unknown>> = {},
118
- ): Promise<unknown> {
119
- const names = Object.keys(globals);
120
- const body = new Function(...names, `return (${expression});`) as (...args: unknown[]) => unknown;
121
- return await body(...names.map((name) => globals[name]));
122
- }