@ultimat3/testing 29.3.2 → 30.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 +56 -63
- package/README.md +281 -6
- package/package.json +27 -16
- package/src/ax-node.ts +96 -0
- package/src/cassette-errors.ts +91 -0
- package/src/cassette-format.ts +175 -0
- package/src/cassette-record.ts +120 -0
- package/src/cassette-secret-scan.ts +77 -0
- package/src/cassette.ts +166 -0
- package/src/cdp-browser.ts +5 -3
- package/src/cdp-e2e-page.ts +91 -3
- package/src/cdp-e2e-session.ts +18 -1
- package/src/define-exercise.ts +82 -0
- package/src/drill-budgets.ts +54 -0
- package/src/drill-errors.ts +45 -0
- package/src/drill-fleet.ts +251 -0
- package/src/drill-jobs.ts +79 -0
- package/src/drill-load.ts +166 -0
- package/src/drill-probe.ts +68 -0
- package/src/drill-run.ts +144 -0
- package/src/drill-services.ts +70 -0
- package/src/drill-sync.ts +108 -0
- package/src/drill-verdict.ts +256 -0
- package/src/drill-writes.ts +123 -0
- package/src/drills.ts +109 -0
- package/src/e2e-a11y-run.ts +189 -0
- package/src/e2e-a11y-tabs.ts +113 -0
- package/src/e2e-a11y.ts +153 -0
- package/src/e2e-driver.ts +46 -10
- package/src/e2e-page.ts +28 -3
- package/src/e2e-run.ts +1 -0
- package/src/e2e-spawn.ts +3 -2
- package/src/errors.ts +43 -0
- package/src/exercise-actor.ts +124 -0
- package/src/exercise-crash.ts +46 -0
- package/src/exercise-input.ts +182 -0
- package/src/exercise-override-input.ts +34 -0
- package/src/exercise-report.ts +282 -0
- package/src/exercise-request.ts +38 -0
- package/src/exercise-sweeps.ts +100 -0
- package/src/exercise.ts +448 -0
- package/src/factories.ts +10 -7
- package/src/factory-registry.ts +49 -15
- package/src/fake-identity-provider.ts +191 -0
- package/src/fault-proxy.ts +342 -0
- package/src/file-boundary.ts +4 -1
- package/src/fixture-jobs.ts +3 -1
- package/src/fixture-statements.ts +4 -1
- package/src/free-port.ts +59 -0
- package/src/index.ts +148 -3
- package/src/island-dom.ts +5 -0
- package/src/isolated-plugins.ts +1 -1
- package/src/job-crash-proof.ts +249 -0
- package/src/job-sim-driver.ts +164 -0
- package/src/job-sim-effects.ts +142 -0
- package/src/job-sim-errors.ts +72 -0
- package/src/job-sim-schedule.ts +154 -0
- package/src/job-sim-world.ts +198 -0
- package/src/job-simulate.ts +179 -0
- package/src/mock-route.ts +81 -0
- package/src/module-mock-boundary.ts +85 -0
- package/src/module-mock-error.ts +28 -0
- package/src/plan-oracle.ts +164 -0
- package/src/plan-sweep-fix.ts +123 -0
- package/src/plan-sweep-report.ts +84 -0
- package/src/plan-sweep.ts +148 -0
- package/src/preload.ts +24 -4
- package/src/registry-snapshot.ts +1 -1
- package/src/repro-stand-in.ts +67 -0
- package/src/repro.ts +158 -0
- package/src/request-log.ts +64 -0
- package/src/reusable-database.ts +1 -1
- package/src/scale-sweep-graph.ts +82 -0
- package/src/scale-sweep-run.ts +134 -0
- package/src/scale-sweep.ts +74 -0
- package/src/sealed-network.ts +63 -46
- package/src/seed-graph-error.ts +50 -0
- package/src/seed-graph-order.ts +98 -0
- package/src/seed-graph-pass.ts +68 -0
- package/src/seed-graph-plan.ts +81 -0
- package/src/seed-graph-row.ts +134 -0
- package/src/seed-graph-shape.ts +104 -0
- package/src/seed-graph-values.ts +135 -0
- package/src/seed-graph.ts +250 -0
- package/src/seed-writer.ts +180 -0
- package/src/subject-proof-error.ts +23 -0
- package/src/subject-proof.ts +180 -0
- package/src/template-db-roles.ts +51 -0
- package/src/template-db.ts +8 -2
- package/src/tenant-canary.ts +120 -0
- package/src/tenant-proof-report.ts +62 -0
- package/src/tenant-proof.ts +177 -0
- package/src/test-types.ts +12 -3
package/src/cassette.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// `cassette()` — a vendor's recorded answers, replayed through `mockFetch` with the network still
|
|
2
|
+
// sealed, and recorded (opt-in, never under CI) with the written bytes checked for secrets. Replay
|
|
3
|
+
// adds no match path: each recorded URL is one `mockFetch` route, answered in call order.
|
|
4
|
+
|
|
5
|
+
// why: `cassette()` is synchronous so `await using` can hold it, so the file is read synchronously;
|
|
6
|
+
// Bun has no sync read or atomic rename of its own.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
// why: the cassette directory is joined from the caller's `import.meta.dir`; Bun has no path join.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { assert } from '@ultimat3/core';
|
|
11
|
+
import type { UnusedEntry } from './cassette-errors';
|
|
12
|
+
import {
|
|
13
|
+
CassetteMissingError,
|
|
14
|
+
CassetteRecordRefusedError,
|
|
15
|
+
CassetteUnusedError,
|
|
16
|
+
} from './cassette-errors';
|
|
17
|
+
import type { CassetteEntry } from './cassette-format';
|
|
18
|
+
import { decodeBody, parseCassette, urlMatch } from './cassette-format';
|
|
19
|
+
import { startRecording } from './cassette-record';
|
|
20
|
+
import { NetworkSealedError } from './errors';
|
|
21
|
+
import { allowHost, captureNetwork, mockFetch, restoreCapturedNetwork } from './sealed-network';
|
|
22
|
+
|
|
23
|
+
export interface CassetteOptions {
|
|
24
|
+
/** The vendor hosts a recording may reach (`api.stripe.com`). Recording needs at least one. */
|
|
25
|
+
readonly record?: { readonly hosts: readonly string[] };
|
|
26
|
+
/**
|
|
27
|
+
* The app's rule, after the built-in masking: applied to each URL and text body before the scan.
|
|
28
|
+
* Replace with `'[redacted]'` — in a URL that marker matches any one segment on replay.
|
|
29
|
+
*/
|
|
30
|
+
readonly redact?: (text: string) => string;
|
|
31
|
+
/** Values that must never be written, beside every credential-named env var's. */
|
|
32
|
+
readonly secrets?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `await using tape = cassette(...)`: the dispose is where a recording is written. */
|
|
36
|
+
export interface Cassette extends AsyncDisposable {
|
|
37
|
+
readonly mode: 'replay' | 'record';
|
|
38
|
+
readonly file: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The switch: `ULTIMATE_CASSETTE=record` records; unset (or `replay`) replays. */
|
|
42
|
+
export const CASSETTE_ENV = 'ULTIMATE_CASSETTE';
|
|
43
|
+
|
|
44
|
+
export function cassette(dir: string, name: string, options: CassetteOptions = {}): Cassette {
|
|
45
|
+
const file = join(dir, '__cassettes__', `${name}.json`);
|
|
46
|
+
const before = captureNetwork();
|
|
47
|
+
const mode = modeOf(Bun.env[CASSETTE_ENV]);
|
|
48
|
+
if (mode === 'record') return recording(dir, name, file, options, before.allowed);
|
|
49
|
+
return replaying(dir, name, file);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function modeOf(value: string | undefined): Cassette['mode'] {
|
|
53
|
+
if (value === undefined || value === '' || value === 'replay') return 'replay';
|
|
54
|
+
assert(
|
|
55
|
+
value === 'record',
|
|
56
|
+
`${CASSETTE_ENV} is ${JSON.stringify(value)}; it is 'record', 'replay' or unset`,
|
|
57
|
+
`${CASSETTE_ENV}=record bun test <test file> # or unset it to replay`,
|
|
58
|
+
);
|
|
59
|
+
return 'record';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function recording(
|
|
63
|
+
dir: string,
|
|
64
|
+
name: string,
|
|
65
|
+
file: string,
|
|
66
|
+
options: CassetteOptions,
|
|
67
|
+
allowedBefore: readonly string[],
|
|
68
|
+
): Cassette {
|
|
69
|
+
// Before any dial: a gate must never reach a vendor, whatever a developer exported.
|
|
70
|
+
const ci = Bun.env['CI'];
|
|
71
|
+
if (ci !== undefined && ci !== '' && ci !== 'false' && ci !== '0') {
|
|
72
|
+
throw new CassetteRecordRefusedError({ reason: 'ci', name });
|
|
73
|
+
}
|
|
74
|
+
const hosts = options.record?.hosts ?? [];
|
|
75
|
+
if (hosts.length === 0) throw new CassetteRecordRefusedError({ reason: 'no-hosts', name });
|
|
76
|
+
for (const host of hosts) allowHost(host);
|
|
77
|
+
const tape = startRecording({
|
|
78
|
+
name,
|
|
79
|
+
dir,
|
|
80
|
+
file,
|
|
81
|
+
hosts: new Set(hosts),
|
|
82
|
+
redact: options.redact,
|
|
83
|
+
secrets: options.secrets ?? [],
|
|
84
|
+
env: Bun.env,
|
|
85
|
+
write: (text) => {
|
|
86
|
+
mkdirSync(join(dir, '__cassettes__'), { recursive: true });
|
|
87
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
88
|
+
writeFileSync(temp, text);
|
|
89
|
+
renameSync(temp, file);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
mode: 'record',
|
|
94
|
+
file,
|
|
95
|
+
async [Symbol.asyncDispose]() {
|
|
96
|
+
// The unsealing ends with the recording, whatever `finish` decides.
|
|
97
|
+
const now = captureNetwork();
|
|
98
|
+
const added = hosts.filter((host) => !allowedBefore.includes(host));
|
|
99
|
+
restoreCapturedNetwork({ ...now, allowed: now.allowed.filter((h) => !added.includes(h)) });
|
|
100
|
+
await tape.finish();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Queue {
|
|
106
|
+
readonly entries: CassetteEntry[];
|
|
107
|
+
readonly used: boolean[];
|
|
108
|
+
next: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function replaying(dir: string, name: string, file: string): Cassette {
|
|
112
|
+
if (!existsSync(file)) throw new CassetteMissingError({ file, dir, name });
|
|
113
|
+
const { entries } = parseCassette(readFileSync(file, 'utf8'), file);
|
|
114
|
+
// URL first, then verb: one route per URL, so a masked query's pattern is registered once.
|
|
115
|
+
const byUrl = new Map<string, Map<string, Queue>>();
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const verbs = byUrl.get(entry.url) ?? new Map<string, Queue>();
|
|
118
|
+
byUrl.set(entry.url, verbs);
|
|
119
|
+
const queue = verbs.get(entry.method) ?? { entries: [], used: [], next: 0 };
|
|
120
|
+
verbs.set(entry.method, queue);
|
|
121
|
+
queue.entries.push(entry);
|
|
122
|
+
queue.used.push(false);
|
|
123
|
+
}
|
|
124
|
+
const before = captureNetwork().mocks.length;
|
|
125
|
+
for (const [url, verbs] of byUrl) mockFetch(urlMatch(url), (request) => answer(verbs, request));
|
|
126
|
+
// `mockFetch` puts each new route FIRST: the cassette's are the head of the list.
|
|
127
|
+
const registered = captureNetwork().mocks;
|
|
128
|
+
const routes = registered.slice(0, registered.length - before);
|
|
129
|
+
return {
|
|
130
|
+
mode: 'replay',
|
|
131
|
+
file,
|
|
132
|
+
async [Symbol.asyncDispose]() {
|
|
133
|
+
const now = captureNetwork();
|
|
134
|
+
restoreCapturedNetwork({ ...now, mocks: now.mocks.filter((r) => !routes.includes(r)) });
|
|
135
|
+
const unused: UnusedEntry[] = [];
|
|
136
|
+
for (const [url, verbs] of byUrl) {
|
|
137
|
+
for (const [method, queue] of verbs) {
|
|
138
|
+
queue.used.forEach((used, index) => {
|
|
139
|
+
if (!used) unused.push({ method, url, index });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (unused.length > 0) throw new CassetteUnusedError({ file, dir, unused });
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The next recorded answer for this verb; past the end the last repeats, as `mockFetch`'s do. */
|
|
149
|
+
function answer(verbs: ReadonlyMap<string, Queue>, request: Request): Response {
|
|
150
|
+
const queue = verbs.get(request.method.toUpperCase());
|
|
151
|
+
if (queue === undefined) {
|
|
152
|
+
throw new NetworkSealedError({ url: request.url, method: request.method, allowed: [] });
|
|
153
|
+
}
|
|
154
|
+
const index = Math.min(queue.next, queue.entries.length - 1);
|
|
155
|
+
queue.next += 1;
|
|
156
|
+
queue.used[index] = true;
|
|
157
|
+
const entry = queue.entries[index];
|
|
158
|
+
if (entry === undefined) {
|
|
159
|
+
throw new NetworkSealedError({ url: request.url, method: request.method, allowed: [] });
|
|
160
|
+
}
|
|
161
|
+
const empty = [101, 204, 205, 304].includes(entry.status);
|
|
162
|
+
return new Response(empty ? null : decodeBody(entry), {
|
|
163
|
+
status: entry.status,
|
|
164
|
+
headers: entry.headers,
|
|
165
|
+
});
|
|
166
|
+
}
|
package/src/cdp-browser.ts
CHANGED
|
@@ -51,10 +51,12 @@ export const E2E_BROWSER_CLOSE_MS = LAUNCHED_CLOSE_MS;
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Sequential deadlines one `tab.goto()` spends: the load event raced against `Page.navigate`'s
|
|
54
|
-
* reply (one deadline, two waiters),
|
|
55
|
-
*
|
|
54
|
+
* reply (one deadline, two waiters), the two reads of the document (`cdp-e2e-page.ts`), then the
|
|
55
|
+
* accessibility audit's two reads — the probe and the tree (`e2e-a11y-tabs.ts`). A finding's own
|
|
56
|
+
* description is three more calls, spent on a test that is already red. Held to the method by
|
|
57
|
+
* `cdp-browser.test.ts` over a fake connection, as the tab's count is.
|
|
56
58
|
*/
|
|
57
|
-
export const GOTO_DEADLINES =
|
|
59
|
+
export const GOTO_DEADLINES = 5;
|
|
58
60
|
|
|
59
61
|
/** The longest one `tab.goto()` is designed to take, at the default per-call deadline. */
|
|
60
62
|
export const E2E_GOTO_MS = GOTO_DEADLINES * DEFAULT_CDP_TIMEOUT_MS;
|
package/src/cdp-e2e-page.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// through it: `ScrapePage` is a full scraping surface whose intended implementation is
|
|
8
8
|
// `puppeteer-core`, and an e2e driver needs none of it.
|
|
9
9
|
|
|
10
|
+
import type { AxTreeNode } from './ax-node';
|
|
11
|
+
import { axRecord, toAxTreeNodes } from './ax-node';
|
|
10
12
|
import type { CdpConnection } from './cdp-connection';
|
|
11
13
|
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
12
|
-
import type { E2eBrowserPage } from './e2e-page';
|
|
14
|
+
import type { AxTreeRequest, E2eBrowserPage } from './e2e-page';
|
|
13
15
|
|
|
14
16
|
/** What the page threw, when `Runtime.evaluate` answered with an exception rather than a value. */
|
|
15
17
|
const thrownIn = (result: Record<string, unknown> | undefined): string | undefined => {
|
|
@@ -39,9 +41,12 @@ const NAVIGATED_AWAY = [
|
|
|
39
41
|
'Inspected target navigated or closed',
|
|
40
42
|
'Execution context was destroyed',
|
|
41
43
|
'Cannot find context with specified id',
|
|
44
|
+
// A tree node's DOM id from the document that was just replaced (`DOM.resolveNode`).
|
|
45
|
+
'No node with given id found',
|
|
42
46
|
] as const;
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
/** A call refused because the document it addressed is gone: "not this document", not a failure. */
|
|
49
|
+
export const navigatedAway = (error: unknown): error is CdpCallFailedError =>
|
|
45
50
|
error instanceof CdpCallFailedError && NAVIGATED_AWAY.some((text) => error.cause.includes(text));
|
|
46
51
|
|
|
47
52
|
/** One tab: the port the driver drives, plus what a multi-tab acceptance suite asks of one. */
|
|
@@ -65,6 +70,8 @@ export interface E2eTab extends E2eBrowserPage {
|
|
|
65
70
|
* dispatches straight at the element and can never see that.
|
|
66
71
|
*/
|
|
67
72
|
pointerClick(selector: string): Promise<void>;
|
|
73
|
+
/** Required on a tab: the audit of every `e2eTest` reads it (`e2e-a11y-run.ts`). */
|
|
74
|
+
accessibilityTree(request: AxTreeRequest): Promise<readonly AxTreeNode[]>;
|
|
68
75
|
/** Reload and wait for the load, at the url the tab already had. */
|
|
69
76
|
reload(): Promise<void>;
|
|
70
77
|
/** Poll `expression` in the page until it is truthy, or refuse naming `what`. */
|
|
@@ -87,13 +94,41 @@ export interface CdpE2eTabOptions {
|
|
|
87
94
|
readonly loadTimeoutMs: number;
|
|
88
95
|
/** The session's browser-wide switch, which `offline()` forwards to. */
|
|
89
96
|
readonly offline: (enabled: boolean) => Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* The raw-tab accessibility audit (`e2e-a11y-tabs.ts`), which the session wires: `navigated`
|
|
99
|
+
* after every settled `goto`/`reload`, `used` on every call the tab sends, `closed` once
|
|
100
|
+
* `close()` is answered. Absent, a tab is a
|
|
101
|
+
* bare driver — what this file's own unit tests build.
|
|
102
|
+
*/
|
|
103
|
+
readonly audit?: { navigated(): Promise<void>; used(): void; closed(): void } | undefined;
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
const POLL_MS = 100;
|
|
93
107
|
|
|
108
|
+
/** The one place the framework reads a whole document's accessibility tree. */
|
|
109
|
+
export const FULL_AX_TREE = 'Accessibility.getFullAXTree';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run ON the resolved node: a document answers for its `<html>`, opening tag only — its whole
|
|
113
|
+
* markup starts with the head, which names nothing. Bounded as `e2e-a11y.ts` says.
|
|
114
|
+
*/
|
|
115
|
+
const DESCRIBE_NODE = `function (selectors) {
|
|
116
|
+
const el = this.nodeType === 9 ? this.documentElement : this;
|
|
117
|
+
if (!el || typeof el.outerHTML !== 'string') return JSON.stringify({ html: '', matches: [] });
|
|
118
|
+
const html = el === document.documentElement ? el.outerHTML.slice(0, el.outerHTML.indexOf('>') + 1) : el.outerHTML;
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
html: html.slice(0, 120),
|
|
121
|
+
matches: selectors.filter((s) => { try { return el.matches(s); } catch { return false; } }),
|
|
122
|
+
});
|
|
123
|
+
}`;
|
|
124
|
+
|
|
94
125
|
/** A tab over a session the caller has already attached and enabled (`cdp-e2e-session.ts`). */
|
|
95
126
|
export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
|
|
96
|
-
const
|
|
127
|
+
const sendRaw = options.connection.send.bind(options.connection);
|
|
128
|
+
const send: CdpConnection['send'] = (...args) => {
|
|
129
|
+
options.audit?.used();
|
|
130
|
+
return sendRaw(...args);
|
|
131
|
+
};
|
|
97
132
|
const { sessionId } = options;
|
|
98
133
|
|
|
99
134
|
// `url()` is SYNCHRONOUS on the port, and CDP has no synchronous read — so the last committed
|
|
@@ -145,6 +180,44 @@ export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
|
|
|
145
180
|
throw new CdpTimeoutError({ method: `waitFor(${what})${last}`, timeoutMs });
|
|
146
181
|
};
|
|
147
182
|
|
|
183
|
+
// One node described: resolved to a page object, asked in the page, released with its group.
|
|
184
|
+
const describeNode = async (
|
|
185
|
+
backendNodeId: number,
|
|
186
|
+
selectors: readonly string[],
|
|
187
|
+
): Promise<Pick<AxTreeNode, 'html' | 'matches'>> => {
|
|
188
|
+
const objectGroup = 'x-e2e-a11y';
|
|
189
|
+
const resolved = await send('DOM.resolveNode', { backendNodeId, objectGroup }, sessionId);
|
|
190
|
+
const objectId = axRecord(resolved.result?.['object'])?.['objectId'];
|
|
191
|
+
if (typeof objectId !== 'string') return {};
|
|
192
|
+
try {
|
|
193
|
+
const raw = evaluated(
|
|
194
|
+
(
|
|
195
|
+
await send(
|
|
196
|
+
'Runtime.callFunctionOn',
|
|
197
|
+
{
|
|
198
|
+
objectId,
|
|
199
|
+
functionDeclaration: DESCRIBE_NODE,
|
|
200
|
+
arguments: [{ value: selectors }],
|
|
201
|
+
returnByValue: true,
|
|
202
|
+
},
|
|
203
|
+
sessionId,
|
|
204
|
+
)
|
|
205
|
+
).result,
|
|
206
|
+
);
|
|
207
|
+
const answer = axRecord(typeof raw === 'string' ? JSON.parse(raw) : raw);
|
|
208
|
+
const html = answer?.['html'];
|
|
209
|
+
const matches = answer?.['matches'];
|
|
210
|
+
return {
|
|
211
|
+
...(typeof html === 'string' ? { html } : {}),
|
|
212
|
+
matches: Array.isArray(matches)
|
|
213
|
+
? matches.filter((m): m is string => typeof m === 'string')
|
|
214
|
+
: [],
|
|
215
|
+
};
|
|
216
|
+
} finally {
|
|
217
|
+
await send('Runtime.releaseObjectGroup', { objectGroup }, sessionId).catch(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
148
221
|
const tab: E2eTab = {
|
|
149
222
|
targetId: options.targetId,
|
|
150
223
|
url: () => current,
|
|
@@ -192,6 +265,8 @@ export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
|
|
|
192
265
|
// The app may have redirected, so the committed url is re-read rather than assumed.
|
|
193
266
|
const settled = await evaluate('location.href');
|
|
194
267
|
if (typeof settled === 'string' && settled !== '') current = settled;
|
|
268
|
+
// The document as it settled, before anything replaces it: what a person would land on.
|
|
269
|
+
await options.audit?.navigated();
|
|
195
270
|
return undefined;
|
|
196
271
|
},
|
|
197
272
|
evaluate,
|
|
@@ -230,6 +305,18 @@ export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
|
|
|
230
305
|
}
|
|
231
306
|
},
|
|
232
307
|
offline: (enabled: boolean) => options.offline(enabled),
|
|
308
|
+
async accessibilityTree(request: AxTreeRequest): Promise<readonly AxTreeNode[]> {
|
|
309
|
+
const nodes = toAxTreeNodes((await send(FULL_AX_TREE, {}, sessionId)).result);
|
|
310
|
+
const out: AxTreeNode[] = [];
|
|
311
|
+
for (const node of nodes) {
|
|
312
|
+
const described =
|
|
313
|
+
node.backendNodeId !== undefined && request.describe(node)
|
|
314
|
+
? await describeNode(node.backendNodeId, request.selectors)
|
|
315
|
+
: {};
|
|
316
|
+
out.push({ ...node, ...described });
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
},
|
|
233
320
|
async scripting(enabled: boolean): Promise<void> {
|
|
234
321
|
await send('Emulation.setScriptExecutionDisabled', { value: !enabled }, sessionId);
|
|
235
322
|
},
|
|
@@ -249,6 +336,7 @@ export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab {
|
|
|
249
336
|
},
|
|
250
337
|
async close(): Promise<void> {
|
|
251
338
|
await send('Target.closeTarget', { targetId: options.targetId });
|
|
339
|
+
options.audit?.closed();
|
|
252
340
|
},
|
|
253
341
|
};
|
|
254
342
|
return tab;
|
package/src/cdp-e2e-session.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { cdpE2eTab } from './cdp-e2e-page';
|
|
|
9
9
|
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
10
10
|
import { isNetworkProbe, networkAgrees } from './cdp-network-probe';
|
|
11
11
|
import { offlineScripts } from './cdp-offline-script';
|
|
12
|
+
import type { AuditedTab } from './e2e-a11y-tabs';
|
|
13
|
+
import { auditTab } from './e2e-a11y-tabs';
|
|
12
14
|
|
|
13
15
|
export interface E2eSession {
|
|
14
16
|
/** A new tab in the same profile — same cookies, same origin storage, same SharedWorker. */
|
|
@@ -311,13 +313,28 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
|
|
|
311
313
|
});
|
|
312
314
|
}
|
|
313
315
|
const sessionId = await attached(targetId);
|
|
314
|
-
|
|
316
|
+
// Every tab is audited: built here, so no suite can hold one the audit does not see. The
|
|
317
|
+
// tab is alive while the browser still knows its target — a closed one has no page to read.
|
|
318
|
+
let audit: AuditedTab | undefined;
|
|
319
|
+
const tab = cdpE2eTab({
|
|
315
320
|
connection,
|
|
316
321
|
sessionId,
|
|
317
322
|
targetId,
|
|
318
323
|
loadTimeoutMs: options.loadTimeoutMs,
|
|
319
324
|
offline,
|
|
325
|
+
audit: {
|
|
326
|
+
navigated: () => audit?.navigated() ?? Promise.resolve(),
|
|
327
|
+
used: () => audit?.used(),
|
|
328
|
+
closed: () => audit?.closed(),
|
|
329
|
+
},
|
|
320
330
|
});
|
|
331
|
+
audit = auditTab(tab, () =>
|
|
332
|
+
send('Target.getTargetInfo', { targetId }).then(
|
|
333
|
+
() => true,
|
|
334
|
+
() => false,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
return tab;
|
|
321
338
|
},
|
|
322
339
|
async addInitScript(source: string): Promise<void> {
|
|
323
340
|
scripts.push(source);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `defineExercise({ publishPost: { input, actor }, sendInvoice: { off: 'why' } })`: an app's
|
|
2
|
+
// overrides for the exercise pass, one entry per primitive. Each value IS 39's `.contract({
|
|
3
|
+
// exercise })` option, re-exported rather than restated, so one shape answers both questions.
|
|
4
|
+
// A blank `off` is refused here, at declaration; an unknown name when the pass runs.
|
|
5
|
+
|
|
6
|
+
import type { ExerciseOptions } from '@ultimat3/action';
|
|
7
|
+
import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/** 39's option type, exactly: `{ actor, input?, runs? }` or `{ off: '<reason>' }`. */
|
|
10
|
+
export type ExerciseOverride = ExerciseOptions;
|
|
11
|
+
|
|
12
|
+
/** What `defineExercise` returns: the overrides, by primitive name. Read by `runExercisePass`. */
|
|
13
|
+
export interface ExerciseOverrides {
|
|
14
|
+
readonly kind: 'exercise-overrides';
|
|
15
|
+
readonly entries: ReadonlyMap<string, ExerciseOverride>;
|
|
16
|
+
/** The entity an actor is a row of, by name: its id is bound to a seeded row. */
|
|
17
|
+
readonly actorEntity?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What the whole pass needs to know of the app, beside the per-primitive entries. */
|
|
21
|
+
export interface ExerciseAppOptions {
|
|
22
|
+
/**
|
|
23
|
+
* The entity whose rows ARE the app's actors (`members`, `users`): each actor's id becomes a
|
|
24
|
+
* seeded row's key in its own tenant, so a handler writing `ctx.actor.id` into a foreign key
|
|
25
|
+
* writes a row that exists. Omitted, an actor's id is its role's name.
|
|
26
|
+
*/
|
|
27
|
+
readonly actor?: { readonly $name: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `X_CONTRACT_EXERCISE_UNDECLARED`, 39's code for the same silence: an `off` with no reason. */
|
|
31
|
+
const blankReason = (name: string): UltimateError =>
|
|
32
|
+
new UltimateError({
|
|
33
|
+
code: 'X_CONTRACT_EXERCISE_UNDECLARED',
|
|
34
|
+
cause: `defineExercise() turns ${renderCauseValue(name)} off with a blank reason, so the skip is silent`,
|
|
35
|
+
fix: `defineExercise({ ${renderFixLiteral(name, '<name>')}: { off: '<why this primitive cannot run in the exercise pass>' } }) # in apps/web/exercise.ts`,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export function defineExercise(
|
|
39
|
+
overrides: Readonly<Record<string, ExerciseOverride>>,
|
|
40
|
+
app: ExerciseAppOptions = {},
|
|
41
|
+
): ExerciseOverrides {
|
|
42
|
+
const entries = new Map<string, ExerciseOverride>();
|
|
43
|
+
// `Object.entries`, never `for in`: an inherited key is not a declaration.
|
|
44
|
+
for (const [name, override] of Object.entries(overrides)) {
|
|
45
|
+
if ('off' in override && override.off.trim().length === 0) throw blankReason(name);
|
|
46
|
+
entries.set(name, override);
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze({
|
|
49
|
+
kind: 'exercise-overrides',
|
|
50
|
+
entries,
|
|
51
|
+
...(app.actor === undefined ? {} : { actorEntity: app.actor.$name }),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every override names a registered action, query or job — checked when the pass runs, because
|
|
57
|
+
* that is when the registries are full. `X_DECLARATION_UNKNOWN`, the code an unknown name answers
|
|
58
|
+
* everywhere else; the cause lists what IS registered so the typo is visible.
|
|
59
|
+
*/
|
|
60
|
+
export function assertOverridesKnown(
|
|
61
|
+
overrides: ExerciseOverrides | undefined,
|
|
62
|
+
known: ReadonlySet<string>,
|
|
63
|
+
): void {
|
|
64
|
+
const unknown = [...(overrides?.entries.keys() ?? [])].filter((name) => !known.has(name)).sort();
|
|
65
|
+
if (unknown.length === 0) return;
|
|
66
|
+
throw unknownDeclaration(unknown, known, 'defineExercise() overrides');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One unknown-name refusal for an override and for `only`, so both read the same. */
|
|
70
|
+
export function unknownDeclaration(
|
|
71
|
+
names: readonly string[],
|
|
72
|
+
known: ReadonlySet<string>,
|
|
73
|
+
where: string,
|
|
74
|
+
): UltimateError {
|
|
75
|
+
const listed = [...known].sort();
|
|
76
|
+
return new UltimateError({
|
|
77
|
+
code: 'X_DECLARATION_UNKNOWN',
|
|
78
|
+
cause: `${where} name ${names.map((name) => renderCauseValue(name)).join(', ')}, which no registered action, query or job is called (registered: ${listed.length === 0 ? 'none' : renderCauseValue(listed.join(', '))})`,
|
|
79
|
+
fix: 'x actions list --json # or x queries list --json / x jobs list --json; rename or remove the entry in apps/web/exercise.ts',
|
|
80
|
+
meta: { unknown: names, registered: listed },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A drill's thresholds, derived from what the app declares — its request deadline, its drain and
|
|
2
|
+
// its readiness mode — never typed per drill. `--budget-ms` replaces the deadline alone, and the
|
|
3
|
+
// verdict prints that it did.
|
|
4
|
+
|
|
5
|
+
import { finiteCount, MAX_TIMER_MS } from '@ultimat3/core';
|
|
6
|
+
import type { DrillBudgets } from './drill-verdict';
|
|
7
|
+
|
|
8
|
+
/** The facts of an app's config a drill reads. */
|
|
9
|
+
export interface DrillConfig {
|
|
10
|
+
/** `configureHttp({ requestTimeoutMs })`; `0` is "no deadline", which a drill cannot judge. */
|
|
11
|
+
readonly requestTimeoutMs: number;
|
|
12
|
+
readonly drainDeadlineMs: number;
|
|
13
|
+
readonly readinessGraceMs: number;
|
|
14
|
+
readonly readiness: 'dependencies' | 'process';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `@ultimat3/http`'s own default request deadline, the one a drill measures against with none set. */
|
|
18
|
+
export const DRILL_DEFAULT_DEADLINE_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One bounded delay: what a degraded dependency may cost one request. The redis tier's per-command
|
|
22
|
+
* deadline is 150 ms and a paused probe answered under 1 s (`29-data-redis-outage`).
|
|
23
|
+
*/
|
|
24
|
+
export const DRILL_PROMPT_MS = 1_000;
|
|
25
|
+
|
|
26
|
+
export interface DrillTiming extends DrillBudgets {
|
|
27
|
+
/** How long each fault phase is held: past one deadline, so a blocked request meets it. */
|
|
28
|
+
readonly phaseMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function drillBudgets(config: DrillConfig, overrideMs?: number): DrillTiming {
|
|
32
|
+
const declared = finiteCount('drillBudgets', 'requestTimeoutMs', config.requestTimeoutMs);
|
|
33
|
+
const deadlineMs =
|
|
34
|
+
overrideMs === undefined
|
|
35
|
+
? declared === 0
|
|
36
|
+
? DRILL_DEFAULT_DEADLINE_MS
|
|
37
|
+
: declared
|
|
38
|
+
: finiteCount('x drill', '--budget-ms', overrideMs, 1);
|
|
39
|
+
const drainMs =
|
|
40
|
+
finiteCount('drillBudgets', 'readinessGraceMs', config.readinessGraceMs) +
|
|
41
|
+
finiteCount('drillBudgets', 'drainDeadlineMs', config.drainDeadlineMs);
|
|
42
|
+
return {
|
|
43
|
+
deadlineMs: Math.min(deadlineMs, MAX_TIMER_MS),
|
|
44
|
+
promptMs: DRILL_PROMPT_MS,
|
|
45
|
+
// Recovery and readiness are each given one deadline: a pool that replaces its sockets does
|
|
46
|
+
// so on the next statement, and a statement is bounded by the request it serves.
|
|
47
|
+
recoveryMs: deadlineMs,
|
|
48
|
+
readinessMs: deadlineMs,
|
|
49
|
+
drainMs,
|
|
50
|
+
readiness: config.readiness,
|
|
51
|
+
override: overrideMs,
|
|
52
|
+
phaseMs: deadlineMs + DRILL_PROMPT_MS,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The two ways a fault drill ends red. Declared in `errors.ts` with this package's codes, thrown
|
|
2
|
+
// from here: a drill whose service is missing is REFUSED, never skipped — a green drill that ran
|
|
3
|
+
// nothing is the vacuous pass — and a drill whose property failed names the property and evidence.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { DrillFailure } from './drill-verdict';
|
|
7
|
+
|
|
8
|
+
const DRILL_PLACEHOLDER = '<the drill the cause names>';
|
|
9
|
+
|
|
10
|
+
export type DrillServiceGap =
|
|
11
|
+
/** The service is not running where the drill can reach it: its `TEST_*` URL is unset. */
|
|
12
|
+
| { readonly kind: 'unset'; readonly variable: string }
|
|
13
|
+
/** The app declares no use of the service, so faulting it would prove nothing. */
|
|
14
|
+
| { readonly kind: 'undeclared'; readonly declaration: string };
|
|
15
|
+
|
|
16
|
+
export class DrillServiceMissingError extends UltimateError {
|
|
17
|
+
constructor(input: { readonly drill: string; readonly gap: DrillServiceGap }) {
|
|
18
|
+
const { gap } = input;
|
|
19
|
+
super({
|
|
20
|
+
code: 'X_DRILL_SERVICE_MISSING',
|
|
21
|
+
cause:
|
|
22
|
+
gap.kind === 'unset'
|
|
23
|
+
? `x drill ${renderCauseValue(input.drill)} needs ${gap.variable}, and it is unset: a drill with no service to fault is refused, never passed`
|
|
24
|
+
: `x drill ${renderCauseValue(input.drill)} faults a service this app does not use (${gap.declaration}), so it would prove nothing`,
|
|
25
|
+
fix:
|
|
26
|
+
gap.kind === 'unset'
|
|
27
|
+
? 'docker compose -f docker/docker-compose.test.yml up -d --wait # then: set -a; . docker/test-services.env; set +a'
|
|
28
|
+
: `x drill --list --json # pick a drill this app declares the service for, or declare it in app.config.ts: ${gap.declaration}`,
|
|
29
|
+
meta: { drill: input.drill, gap: gap.kind },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One failed property, as a thrown error or as the cause and fix of a CLI finding. */
|
|
35
|
+
export class DrillFailedError extends UltimateError {
|
|
36
|
+
constructor(input: { readonly drill: string; readonly failure: DrillFailure }) {
|
|
37
|
+
const { failure } = input;
|
|
38
|
+
super({
|
|
39
|
+
code: 'X_DRILL_FAILED',
|
|
40
|
+
cause: `x drill ${renderCauseValue(input.drill)}: ${failure.property} did not hold — expected ${failure.expected}; observed ${renderCauseValue(failure.observed)}`,
|
|
41
|
+
fix: `x drill ${renderFixShellArg(input.drill, DRILL_PLACEHOLDER)} --json # read ${failure.property} and its evidence, then explain the code that evidence names: x errors explain <CODE>`,
|
|
42
|
+
meta: { drill: input.drill, property: failure.property },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|