@ultimat3/testing 22.14.0 → 23.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.
- package/CLAUDE.md +29 -36
- package/README.md +247 -8
- package/package.json +16 -14
- package/src/auth-request.ts +54 -0
- package/src/determinism.ts +20 -0
- package/src/e2e-app.ts +14 -1
- package/src/e2e-driver.ts +11 -2
- package/src/e2e-run.ts +5 -0
- package/src/e2e-spawn.ts +3 -0
- package/src/errors.ts +2 -0
- package/src/failure-context.ts +42 -0
- package/src/fixture-jobs.ts +110 -25
- package/src/fixtures.ts +9 -4
- package/src/frozen-scheduler.ts +49 -0
- package/src/index.ts +29 -1
- package/src/island-state-errors.ts +19 -0
- package/src/island-state-mount.ts +129 -0
- package/src/local-tx.ts +87 -0
- package/src/matcher-row.ts +93 -0
- package/src/matcher-surface.ts +7 -0
- package/src/matchers.ts +6 -0
- package/src/per-test-reset.ts +43 -0
- package/src/preload.ts +13 -0
- package/src/quiet-logs.ts +56 -0
- package/src/render-view.ts +118 -0
- package/src/test-seal-key.ts +51 -0
- package/src/test-types.ts +53 -30
- 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
|
-
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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 = (
|
|
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
|
-
|
|
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:
|
|
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(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
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 {
|
package/src/e2e-dom-fixture.ts
DELETED
|
@@ -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
|
-
}
|