@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.
- package/CLAUDE.md +37 -44
- package/README.md +297 -13
- package/package.json +16 -14
- package/src/auth-request.ts +54 -0
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser.ts +5 -10
- package/src/cdp-connection.ts +23 -6
- package/src/cdp-e2e-session.ts +10 -1
- package/src/cdp-errors.ts +113 -8
- package/src/cdp-launch-attempt.ts +192 -0
- package/src/cdp-launch.ts +49 -122
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/determinism.ts +53 -4
- package/src/e2e-app.ts +17 -1
- package/src/e2e-driver.ts +11 -2
- package/src/e2e-run.ts +7 -2
- package/src/e2e-spawn.ts +4 -8
- package/src/errors.ts +16 -1
- package/src/factories.ts +4 -6
- package/src/failure-context.ts +42 -0
- package/src/file-boundary.ts +12 -7
- package/src/fixture-island.ts +18 -45
- package/src/fixture-jobs.ts +117 -28
- package/src/fixtures.ts +9 -4
- package/src/free-port.ts +46 -0
- package/src/frozen-scheduler.ts +49 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +41 -1
- package/src/island-dom.ts +8 -4
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +41 -0
- package/src/island-state-errors.ts +19 -0
- package/src/island-state-mount.ts +129 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +2 -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 +14 -7
- package/src/quiet-logs.ts +56 -0
- package/src/registry-leak-guard.ts +3 -1
- package/src/registry-snapshot.ts +22 -1
- package/src/render-view.ts +118 -0
- package/src/sealed-network.ts +5 -1
- package/src/template-db.ts +6 -3
- package/src/test-seal-key.ts +51 -0
- package/src/test-types.ts +53 -30
- package/src/e2e-dom-fixture.ts +0 -122
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"src",
|
|
25
25
|
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*-fixture.ts",
|
|
26
27
|
"CLAUDE.md",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE"
|
|
@@ -35,18 +36,19 @@
|
|
|
35
36
|
"test": "bun test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@ultimat3/action": "
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/db": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
46
|
-
"@ultimat3/
|
|
47
|
-
"@ultimat3/
|
|
48
|
-
"@ultimat3/
|
|
49
|
-
"@ultimat3/
|
|
50
|
-
"@ultimat3/
|
|
39
|
+
"@ultimat3/action": "24.0.0",
|
|
40
|
+
"@ultimat3/cache": "24.0.0",
|
|
41
|
+
"@ultimat3/core": "24.0.0",
|
|
42
|
+
"@ultimat3/db": "24.0.0",
|
|
43
|
+
"@ultimat3/entity": "24.0.0",
|
|
44
|
+
"@ultimat3/http": "24.0.0",
|
|
45
|
+
"@ultimat3/i18n": "24.0.0",
|
|
46
|
+
"@ultimat3/jobs": "24.0.0",
|
|
47
|
+
"@ultimat3/mail": "24.0.0",
|
|
48
|
+
"@ultimat3/policy": "24.0.0",
|
|
49
|
+
"@ultimat3/query": "24.0.0",
|
|
50
|
+
"@ultimat3/realtime": "24.0.0",
|
|
51
|
+
"@ultimat3/render": "24.0.0",
|
|
52
|
+
"@ultimat3/time": "24.0.0"
|
|
51
53
|
}
|
|
52
54
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// `authRequest()` — the two arguments `hooks.authenticate` is handed, built the way the pipeline
|
|
2
|
+
// builds them, so a test of an app's authenticator calls it with no server and no cast.
|
|
3
|
+
// `@ultimat3/http` is imported INSIDE the function: this module is on the `.` barrel, which a
|
|
4
|
+
// tier-0 test imports for `expect` alone.
|
|
5
|
+
|
|
6
|
+
import type { Authenticator } from '@ultimat3/http';
|
|
7
|
+
|
|
8
|
+
/** What an authenticator reads off a request: where it came in, and the credentials it carries. */
|
|
9
|
+
export interface AuthRequestInit {
|
|
10
|
+
/** The URL the request names. `https://example.test/` when absent. */
|
|
11
|
+
readonly url?: string;
|
|
12
|
+
/** `GET` when absent. */
|
|
13
|
+
readonly method?: string;
|
|
14
|
+
/** Any header. `cookies` and `bearer` below win over the same header spelled here. */
|
|
15
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
16
|
+
/** Cookies by name, sent as the ONE `cookie` header a browser sends, values percent-encoded. */
|
|
17
|
+
readonly cookies?: Readonly<Record<string, string>>;
|
|
18
|
+
/** `Authorization: Bearer <token>`. */
|
|
19
|
+
readonly bearer?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_URL = 'https://example.test/';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The authenticator's own parameter list — `await authenticate(...(await authRequest({ cookies })))`
|
|
26
|
+
* — as a real `UltimateRequest` over a real `RequestContext`. Typed off `Authenticator`, so a
|
|
27
|
+
* third parameter on the hook is a compile error here rather than an `undefined` in a test.
|
|
28
|
+
*
|
|
29
|
+
* The context is the one the `auth` stage sees: the actor is still anonymous, because resolving
|
|
30
|
+
* it is the function under test.
|
|
31
|
+
*/
|
|
32
|
+
export async function authRequest(init: AuthRequestInit = {}): Promise<Parameters<Authenticator>> {
|
|
33
|
+
const http = await import('@ultimat3/http');
|
|
34
|
+
const url = new URL(init.url ?? DEFAULT_URL);
|
|
35
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
36
|
+
const headers = new Headers(init.headers);
|
|
37
|
+
const cookies = Object.entries(init.cookies ?? {});
|
|
38
|
+
if (cookies.length > 0) {
|
|
39
|
+
headers.set(
|
|
40
|
+
'cookie',
|
|
41
|
+
cookies.map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; '),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (init.bearer !== undefined) headers.set('authorization', `Bearer ${init.bearer}`);
|
|
45
|
+
const ctx = http.createRequestContext({
|
|
46
|
+
url,
|
|
47
|
+
method,
|
|
48
|
+
role: 'web',
|
|
49
|
+
// One process, one test: the limiter's scope has no default, and nothing here spends a bucket.
|
|
50
|
+
config: http.defineHttpConfig({ rateLimit: { scope: 'process' } }),
|
|
51
|
+
requestHeaders: headers,
|
|
52
|
+
});
|
|
53
|
+
return [new http.UltimateRequest(new Request(url, { method, headers }), ctx), ctx];
|
|
54
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The `BudgetStore` contract of `@ultimat3/ai` as shared examples, for a store of your own (Redis,
|
|
2
|
+
// SQL): `behavesLike(budgetStoreConformance, () => store)`. The race is the point — `take` is the
|
|
3
|
+
// one atomic step, and a store that awaits between its read and its write overspends under it.
|
|
4
|
+
// Structural, so this package takes no dependency on `ai`: a `BudgetStore` is assignable.
|
|
5
|
+
|
|
6
|
+
import { expect, test } from 'bun:test';
|
|
7
|
+
import type { SharedExamples } from './shared-examples';
|
|
8
|
+
import { sharedExamples } from './shared-examples';
|
|
9
|
+
|
|
10
|
+
type Maybe<T> = T | Promise<T>;
|
|
11
|
+
|
|
12
|
+
/** The four members of `@ultimat3/ai`'s `BudgetStore`, structurally. */
|
|
13
|
+
export interface BudgetStoreLike {
|
|
14
|
+
spent(key: string): Maybe<number>;
|
|
15
|
+
add(key: string, tokens: number): Maybe<void>;
|
|
16
|
+
take(key: string, tokens: number, limit: number): Maybe<{ readonly taken: boolean }>;
|
|
17
|
+
reset(key?: string): Maybe<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BudgetStoreCheck {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
run(store: BudgetStoreLike): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Concurrent takers, and the ceiling only some of them fit under. */
|
|
26
|
+
const BUDGET_RACE_TAKERS = 24;
|
|
27
|
+
const RACE_LIMIT = 5;
|
|
28
|
+
|
|
29
|
+
let minted = 0;
|
|
30
|
+
const keyOf = (): string => {
|
|
31
|
+
minted += 1;
|
|
32
|
+
return `conformance:${process.pid.toString(36)}:${minted.toString(36)}`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const BUDGET_STORE_CHECKS: readonly BudgetStoreCheck[] = [
|
|
36
|
+
{
|
|
37
|
+
name: `${BUDGET_RACE_TAKERS} concurrent takes against one key never overspend the limit`,
|
|
38
|
+
async run(store) {
|
|
39
|
+
const key = keyOf();
|
|
40
|
+
const takes = Array.from({ length: BUDGET_RACE_TAKERS }, async () =>
|
|
41
|
+
store.take(key, 1, RACE_LIMIT),
|
|
42
|
+
);
|
|
43
|
+
const taken = (await Promise.all(takes)).filter((each) => each.taken).length;
|
|
44
|
+
expect([taken, await store.spent(key)]).toEqual([RACE_LIMIT, RACE_LIMIT]);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'a refused take spends nothing, and a take that fits exactly is taken',
|
|
49
|
+
async run(store) {
|
|
50
|
+
const key = keyOf();
|
|
51
|
+
expect((await store.take(key, 3, 4)).taken).toBe(true);
|
|
52
|
+
expect((await store.take(key, 2, 4)).taken).toBe(false);
|
|
53
|
+
expect(await store.spent(key)).toBe(3);
|
|
54
|
+
expect((await store.take(key, 1, 4)).taken).toBe(true);
|
|
55
|
+
expect(await store.spent(key)).toBe(4);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'add takes a negative credit, and reset(key) clears that key alone',
|
|
60
|
+
async run(store) {
|
|
61
|
+
const [key, other] = [keyOf(), keyOf()];
|
|
62
|
+
await store.add(key, 5);
|
|
63
|
+
await store.add(key, -2);
|
|
64
|
+
await store.add(other, 1);
|
|
65
|
+
expect(await store.spent(key)).toBe(3);
|
|
66
|
+
await store.reset(key);
|
|
67
|
+
expect([await store.spent(key), await store.spent(other)]).toEqual([0, 1]);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const budgetStoreConformance: SharedExamples<Maybe<BudgetStoreLike>> = sharedExamples(
|
|
73
|
+
'a budget store',
|
|
74
|
+
(subject) => {
|
|
75
|
+
for (const check of BUDGET_STORE_CHECKS) {
|
|
76
|
+
test(check.name, async () => {
|
|
77
|
+
await check.run(await subject());
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
);
|
package/src/cdp-browser.ts
CHANGED
|
@@ -27,13 +27,12 @@ export interface E2eBrowser {
|
|
|
27
27
|
* drives, on the same launch as `page` — one harness, never a second one beside the driver.
|
|
28
28
|
*/
|
|
29
29
|
readonly session: E2eSession;
|
|
30
|
-
/** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
|
|
31
|
-
close(): void;
|
|
32
30
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* THE close, idempotent, awaited: the CDP connection, then the process, its process group and its
|
|
32
|
+
* profile (bounded — `CLOSE_GRACE_MS` per step). Unawaited, a process that exits next leaves a
|
|
33
|
+
* Chrome child and a profile directory behind.
|
|
35
34
|
*/
|
|
36
|
-
|
|
35
|
+
close(): Promise<void>;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
export interface OpenE2eBrowserOptions {
|
|
@@ -60,10 +59,6 @@ const compose = (launched: LaunchedBrowser, session: E2eSession, page: E2eTab):
|
|
|
60
59
|
// the process out from under an open connection makes every in-flight call report "the browser
|
|
61
60
|
// closed the CDP connection", which is true and useless.
|
|
62
61
|
close: () => launched.close(),
|
|
63
|
-
closed: async () => {
|
|
64
|
-
if (launched.closed === undefined) launched.close();
|
|
65
|
-
else await launched.closed();
|
|
66
|
-
},
|
|
67
62
|
});
|
|
68
63
|
|
|
69
64
|
/**
|
|
@@ -107,7 +102,7 @@ async function openLaunched(
|
|
|
107
102
|
});
|
|
108
103
|
return compose(launched, session, await session.newTab());
|
|
109
104
|
} catch (error) {
|
|
110
|
-
launched.close();
|
|
105
|
+
await launched.close();
|
|
111
106
|
throw error;
|
|
112
107
|
}
|
|
113
108
|
}
|
package/src/cdp-connection.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { assert } from '@ultimat3/core';
|
|
15
15
|
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
16
|
+
import { wireWatch } from './cdp-wire-watch';
|
|
16
17
|
|
|
17
18
|
/** One CDP result. `unknown` because every payload here is somebody else's JSON. */
|
|
18
19
|
export interface CdpResult {
|
|
@@ -20,8 +21,17 @@ export interface CdpResult {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface CdpConnection {
|
|
23
|
-
/**
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Send one command. `sessionId` targets an attached page rather than the browser itself.
|
|
26
|
+
* `deadlineMs` replaces the connection's per-call deadline for THIS call — the launcher's first
|
|
27
|
+
* call is a cold start, not a call, and is the only caller that passes one.
|
|
28
|
+
*/
|
|
29
|
+
send(
|
|
30
|
+
method: string,
|
|
31
|
+
params?: Record<string, unknown>,
|
|
32
|
+
sessionId?: string,
|
|
33
|
+
deadlineMs?: number,
|
|
34
|
+
): Promise<CdpResult>;
|
|
25
35
|
/**
|
|
26
36
|
* Wait for the next occurrence of one CDP **event**, or for the deadline. Answers `true` when the
|
|
27
37
|
* event arrived and `false` when it did not — it never throws, because every caller has a better
|
|
@@ -145,6 +155,8 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
145
155
|
const listeners = new Map<string, Set<CdpEventListener>>();
|
|
146
156
|
let nextId = 0;
|
|
147
157
|
let closed = false;
|
|
158
|
+
// What a call that is never answered reports instead of a bare "did not answer".
|
|
159
|
+
const watch = wireWatch();
|
|
148
160
|
|
|
149
161
|
// Every in-flight call is settled on close. Without this a suite whose browser died waits out
|
|
150
162
|
// one full deadline per call and reports a timeout, where the true fault is a dead browser.
|
|
@@ -167,9 +179,12 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
167
179
|
frame = JSON.parse(raw) as Record<string, unknown>;
|
|
168
180
|
} catch {
|
|
169
181
|
// An unparseable frame is the browser's, not ours, and there is no call to fail with it:
|
|
170
|
-
// correlation is by `id`, and a frame we cannot read has none.
|
|
182
|
+
// correlation is by `id`, and a frame we cannot read has none. COUNTED, though: the call
|
|
183
|
+
// whose reply it was waits out its deadline, and its timeout says frames were dropped.
|
|
184
|
+
watch.drop();
|
|
171
185
|
return;
|
|
172
186
|
}
|
|
187
|
+
watch.frame(frame);
|
|
173
188
|
const id = frame['id'];
|
|
174
189
|
if (typeof id !== 'number') {
|
|
175
190
|
const method = frame['method'];
|
|
@@ -206,7 +221,7 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
206
221
|
transport.listen({ message, closed: abandon });
|
|
207
222
|
|
|
208
223
|
return {
|
|
209
|
-
send(method, params = {}, sessionId): Promise<CdpResult> {
|
|
224
|
+
send(method, params = {}, sessionId, deadlineMs = timeoutMs): Promise<CdpResult> {
|
|
210
225
|
if (closed) {
|
|
211
226
|
return Promise.reject(
|
|
212
227
|
new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }),
|
|
@@ -215,10 +230,12 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
215
230
|
nextId += 1;
|
|
216
231
|
const id = nextId;
|
|
217
232
|
return new Promise<CdpResult>((resolve, reject) => {
|
|
233
|
+
const sentAt = watch.mark(sessionId);
|
|
218
234
|
const timer = setTimeout(() => {
|
|
219
235
|
pending.delete(id);
|
|
220
|
-
|
|
221
|
-
|
|
236
|
+
const observed = watch.observe(sentAt, sessionId, !closed);
|
|
237
|
+
reject(new CdpTimeoutError({ method, timeoutMs: deadlineMs, observed }));
|
|
238
|
+
}, deadlineMs);
|
|
222
239
|
pending.set(id, { resolve, reject, timer });
|
|
223
240
|
transport.send(
|
|
224
241
|
JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
|
package/src/cdp-e2e-session.ts
CHANGED
|
@@ -155,10 +155,19 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
|
|
|
155
155
|
}
|
|
156
156
|
// And `navigator.onLine` from a new document's first script (`cdp-offline-script.ts`) — the
|
|
157
157
|
// network condition alone reaches a reloaded page only after its scripts have run.
|
|
158
|
+
// A page that is still THERE and refuses is kept and reported once every page has been asked:
|
|
159
|
+
// dropping it meant every later `offline()` skipped that tab in silence. Only a session the
|
|
160
|
+
// loop above just found gone is dropped — a closed tab refuses every call.
|
|
161
|
+
const refused: unknown[] = [];
|
|
158
162
|
for (const session of [...pageSessions]) {
|
|
163
|
+
if (!sessions.has(session)) {
|
|
164
|
+
pageSessions.delete(session);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
159
167
|
const toggled = enabled ? onLineScripts.add(session) : onLineScripts.remove(session);
|
|
160
|
-
await toggled.catch(() =>
|
|
168
|
+
await toggled.catch((error: unknown) => refused.push(error));
|
|
161
169
|
}
|
|
170
|
+
if (refused.length > 0) throw refused[0];
|
|
162
171
|
};
|
|
163
172
|
|
|
164
173
|
const attached = async (targetId: string): Promise<string> => {
|
package/src/cdp-errors.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// out of a BROWSER or off a spawned process's stderr, so every one is rendered rather than
|
|
3
3
|
// interpolated — the rule `e2e-errors.ts` already states.
|
|
4
4
|
|
|
5
|
-
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { isFixShellSafe, renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
6
6
|
// Bare: the titles these constructors' codes render with are registered there.
|
|
7
7
|
import './e2e-error-codes';
|
|
8
8
|
|
|
@@ -20,13 +20,50 @@ export class CdpBrowserMissingError extends UltimateError {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
23
|
+
/** One start of the browser that ended without an answer — what `cdp-launch-attempt.ts` reports. */
|
|
24
|
+
export interface CdpLaunchAttempt {
|
|
25
|
+
/** `deadline`: alive and silent for `waitedMs`. `closed`: its pipe ended before any answer. */
|
|
26
|
+
readonly why: 'deadline' | 'closed';
|
|
27
|
+
readonly waitedMs: number;
|
|
28
|
+
/** The code it exited with on its own, or `null` when the launcher had to kill it. */
|
|
29
|
+
readonly exitCode: number | null;
|
|
30
|
+
/** The last lines it wrote to stderr; empty when it wrote none. */
|
|
31
|
+
readonly stderr: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HAND_FLAGS =
|
|
35
|
+
'--headless=new --no-sandbox --disable-dev-shm-usage --disable-gpu --remote-debugging-port=0 about:blank';
|
|
36
|
+
|
|
37
|
+
const attemptText = (attempt: CdpLaunchAttempt, index: number): string => {
|
|
38
|
+
const ended =
|
|
39
|
+
attempt.why === 'deadline'
|
|
40
|
+
? `no answer inside ${String(attempt.waitedMs)}ms, so it was killed`
|
|
41
|
+
: attempt.exitCode === null
|
|
42
|
+
? `closed its DevTools pipe after ${String(attempt.waitedMs)}ms without answering, and was killed`
|
|
43
|
+
: `exited with code ${String(attempt.exitCode)} after ${String(attempt.waitedMs)}ms without answering`;
|
|
44
|
+
const said = attempt.stderr === '' ? 'it printed nothing' : renderCauseValue(attempt.stderr);
|
|
45
|
+
return `launch ${String(index + 1)}: ${ended}; stderr: ${said}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The binary ran and never answered a DevTools call — a crash, a bad flag, a sandbox refusal, or a
|
|
50
|
+
* machine too loaded to start it inside the launch deadline. Every attempt is in the cause with its
|
|
51
|
+
* own stderr, because Chrome's start-up noise reads the same in a healthy browser and a dead one:
|
|
52
|
+
* what tells them apart is whether it EXITED or was still starting when the deadline passed.
|
|
53
|
+
*/
|
|
24
54
|
export class CdpLaunchFailedError extends UltimateError {
|
|
25
|
-
constructor(input: {
|
|
55
|
+
constructor(input: {
|
|
56
|
+
readonly executable: string;
|
|
57
|
+
readonly attempts: readonly CdpLaunchAttempt[];
|
|
58
|
+
}) {
|
|
59
|
+
// Run by hand, a healthy Chrome prints `DevTools listening on ws://…` and a broken one prints
|
|
60
|
+
// why. A path that is not shell-inert is the operator's own CHROME_PATH — every candidate is.
|
|
61
|
+
const binary = isFixShellSafe(input.executable) ? input.executable : '"$CHROME_PATH"';
|
|
26
62
|
super({
|
|
27
63
|
code: 'X_CDP_LAUNCH_FAILED',
|
|
28
|
-
cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: ${
|
|
29
|
-
fix:
|
|
64
|
+
cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: it answered no DevTools call in ${String(input.attempts.length)} ${input.attempts.length === 1 ? 'launch' : 'launches, each on its own fresh profile'} — ${input.attempts.map(attemptText).join(' · ')}`,
|
|
65
|
+
fix: `${binary} ${HAND_FLAGS}`,
|
|
66
|
+
meta: { executable: input.executable, attempts: input.attempts },
|
|
30
67
|
});
|
|
31
68
|
}
|
|
32
69
|
}
|
|
@@ -42,17 +79,85 @@ export class CdpCallFailedError extends UltimateError {
|
|
|
42
79
|
}
|
|
43
80
|
}
|
|
44
81
|
|
|
82
|
+
/** How a call's target went away: its session detached, its target was destroyed, or it crashed. */
|
|
83
|
+
export type CdpTargetGone = 'detached' | 'destroyed' | 'crashed';
|
|
84
|
+
|
|
85
|
+
/** What the wire did between a call being sent and its deadline (`cdp-wire-watch.ts`). */
|
|
86
|
+
export interface CdpTimeoutObservation {
|
|
87
|
+
/** The session the call was sent on; `null` for a browser-level call. */
|
|
88
|
+
readonly sessionId: string | null;
|
|
89
|
+
/** Frames that arrived and parsed after the call was sent — replies to other calls, and events. */
|
|
90
|
+
readonly framesArrived: number;
|
|
91
|
+
/** The last of those, oldest first, bounded: `reply <id>`, or `<method> @<session>`. */
|
|
92
|
+
readonly lastFrames: readonly string[];
|
|
93
|
+
/** Frames that arrived after the call and could not be parsed. One of them may be its reply. */
|
|
94
|
+
readonly framesDropped: number;
|
|
95
|
+
readonly transportOpen: boolean;
|
|
96
|
+
/** `detached`, `destroyed` or `crashed` when the call's target went away; `null` while it is there. */
|
|
97
|
+
readonly targetGone: CdpTargetGone | null;
|
|
98
|
+
/** Main-frame navigations on the call's session since it was sent. */
|
|
99
|
+
readonly navigations: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The one-word verdict a reader — or a script over `--json` — branches on. */
|
|
103
|
+
export type CdpTimeoutReading = 'target-gone' | 'lost-in-transport' | 'no-answer';
|
|
104
|
+
|
|
105
|
+
export const readTimeout = (seen: CdpTimeoutObservation): CdpTimeoutReading =>
|
|
106
|
+
seen.targetGone !== null
|
|
107
|
+
? 'target-gone'
|
|
108
|
+
: seen.framesDropped > 0
|
|
109
|
+
? 'lost-in-transport'
|
|
110
|
+
: 'no-answer';
|
|
111
|
+
|
|
112
|
+
const READING: Record<CdpTimeoutReading, (seen: CdpTimeoutObservation) => string> = {
|
|
113
|
+
'target-gone': (seen) =>
|
|
114
|
+
`its target is gone (${String(seen.targetGone)}), so nothing was left to answer`,
|
|
115
|
+
'lost-in-transport': (seen) =>
|
|
116
|
+
`${String(seen.framesDropped)} frame(s) arrived unparseable after it was sent and were dropped, so its reply may have been lost in transport (${String(seen.framesArrived)} other frame(s) arrived intact)`,
|
|
117
|
+
'no-answer': (seen) => {
|
|
118
|
+
if (seen.framesArrived === 0) {
|
|
119
|
+
return `nothing at all arrived after the call was sent, on a transport that is ${seen.transportOpen ? 'still open' : 'closed'} — the whole browser went silent`;
|
|
120
|
+
}
|
|
121
|
+
const moved =
|
|
122
|
+
seen.navigations === 0
|
|
123
|
+
? ''
|
|
124
|
+
: `; the page navigated ${String(seen.navigations)} time(s) since the call`;
|
|
125
|
+
return `the target did not answer: ${String(seen.framesArrived)} other frame(s) arrived intact after the call, the last ${renderCauseValue(seen.lastFrames)}${moved}`;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
45
129
|
/**
|
|
46
130
|
* A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
|
|
47
131
|
* WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
|
|
48
132
|
* `Runtime.evaluate` is an expression that never settles.
|
|
133
|
+
*
|
|
134
|
+
* `observed` is what the connection saw meanwhile, and it is what tells three failures apart that
|
|
135
|
+
* all read "did not answer": the target went away, the reply was dropped on the wire, or the target
|
|
136
|
+
* was there and silent. A deadline the connection did not keep (`waitFor`, the handshake) has none.
|
|
49
137
|
*/
|
|
50
138
|
export class CdpTimeoutError extends UltimateError {
|
|
51
|
-
constructor(input: {
|
|
139
|
+
constructor(input: {
|
|
140
|
+
readonly method: string;
|
|
141
|
+
readonly timeoutMs: number;
|
|
142
|
+
readonly observed?: CdpTimeoutObservation | undefined;
|
|
143
|
+
}) {
|
|
144
|
+
const { observed } = input;
|
|
145
|
+
const base = `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`;
|
|
146
|
+
const reading = observed === undefined ? undefined : readTimeout(observed);
|
|
52
147
|
super({
|
|
53
148
|
code: 'X_CDP_TIMEOUT',
|
|
54
|
-
cause:
|
|
55
|
-
|
|
149
|
+
cause:
|
|
150
|
+
observed === undefined || reading === undefined
|
|
151
|
+
? base
|
|
152
|
+
: `${base} — ${READING[reading](observed)}`,
|
|
153
|
+
fix:
|
|
154
|
+
reading === undefined || reading === 'no-answer'
|
|
155
|
+
? 'raise timeoutMs on installE2eDriver({ timeoutMs }), or find the request the page is still waiting on — a navigation that never settles is an app that never finishes its response'
|
|
156
|
+
: // `--json`: the rerun's `meta.reading` and `meta.framesDropped` are only printed there.
|
|
157
|
+
'x test e2e --json',
|
|
158
|
+
...(observed === undefined
|
|
159
|
+
? {}
|
|
160
|
+
: { meta: { method: input.method, timeoutMs: input.timeoutMs, reading, ...observed } }),
|
|
56
161
|
});
|
|
57
162
|
}
|
|
58
163
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// One responsibility: ONE start of a Chrome process — spawn it on a throwaway profile, wait for its
|
|
2
|
+
// first DevTools answer, and on no answer reap it (killed, awaited, profile removed) and say why.
|
|
3
|
+
// Which binary, which flags and how many starts is `cdp-launch.ts`.
|
|
4
|
+
|
|
5
|
+
// why: Bun exposes no recursive-remove and no temp-root primitive, so the throwaway profile
|
|
6
|
+
// directory this launcher must create and delete needs both.
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
// why: Bun exposes no path-join primitive.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { CdpConnection } from './cdp-connection';
|
|
13
|
+
import { cdpConnectOver } from './cdp-connection';
|
|
14
|
+
import type { CdpLaunchAttempt } from './cdp-errors';
|
|
15
|
+
import { CdpTimeoutError } from './cdp-errors';
|
|
16
|
+
import { pipeTransport } from './cdp-pipe';
|
|
17
|
+
|
|
18
|
+
export interface LaunchedBrowser {
|
|
19
|
+
/** The browser's own CDP connection, over its debugging pipe. Already answering. */
|
|
20
|
+
readonly connection: CdpConnection;
|
|
21
|
+
/**
|
|
22
|
+
* THE close, idempotent, and awaited by every caller: the connection closed, SIGTERM, SIGKILL
|
|
23
|
+
* after `CLOSE_GRACE_MS`, then the browser's whole process group killed and the profile removed —
|
|
24
|
+
* bounded at three graces. A promise because a Chrome still shutting down competes with the next
|
|
25
|
+
* launch on a 4-CPU runner, and its children re-create a profile removed before they are gone.
|
|
26
|
+
*/
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LaunchAttemptOptions {
|
|
31
|
+
readonly executable: string;
|
|
32
|
+
/** What the process is started with, given the profile directory this attempt made for it. */
|
|
33
|
+
readonly flags: (profileDir: string) => readonly string[];
|
|
34
|
+
/** Every call's deadline AFTER the first. */
|
|
35
|
+
readonly timeoutMs: number;
|
|
36
|
+
/** The first call's deadline: how long a cold start may take. */
|
|
37
|
+
readonly launchTimeoutMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type LaunchAttemptResult =
|
|
41
|
+
| { readonly ok: true; readonly browser: LaunchedBrowser }
|
|
42
|
+
/** `reaped: false` is a process that outlived SIGKILL's grace — never start another beside it. */
|
|
43
|
+
| { readonly ok: false; readonly failure: CdpLaunchAttempt; readonly reaped: boolean };
|
|
44
|
+
|
|
45
|
+
const STDERR_TAIL_CHARS = 4_000;
|
|
46
|
+
/** How much of the tail one attempt hands a cause: the last lines, each cut to a readable width. */
|
|
47
|
+
const TAIL_LINES = 3;
|
|
48
|
+
const TAIL_LINE_CHARS = 160;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read stderr to its end for the life of the process, keeping only a bounded tail. A pipe nobody
|
|
52
|
+
* reads fills, and Chrome's next stderr write then blocks the thread making it — a browser that
|
|
53
|
+
* stops answering mid-run for a reason no log shows. The tail is the launch-failure diagnostics:
|
|
54
|
+
* a missing library, a sandbox refusal and a bad flag are all named there and nowhere else.
|
|
55
|
+
*/
|
|
56
|
+
function stderrTail(stream: ReadableStream<Uint8Array>): {
|
|
57
|
+
readonly lastLines: () => string;
|
|
58
|
+
/** Settles once the stream has ended — every byte the process wrote has been read. */
|
|
59
|
+
readonly drained: Promise<void>;
|
|
60
|
+
} {
|
|
61
|
+
let text = '';
|
|
62
|
+
const drained = (async () => {
|
|
63
|
+
const decoder = new TextDecoder();
|
|
64
|
+
for await (const chunk of stream) {
|
|
65
|
+
text = (text + decoder.decode(chunk, { stream: true })).slice(-STDERR_TAIL_CHARS);
|
|
66
|
+
}
|
|
67
|
+
})().catch(() => undefined);
|
|
68
|
+
const lastLines = (): string =>
|
|
69
|
+
text
|
|
70
|
+
.trim()
|
|
71
|
+
.split('\n')
|
|
72
|
+
.slice(-TAIL_LINES)
|
|
73
|
+
.map((line) => line.slice(0, TAIL_LINE_CHARS))
|
|
74
|
+
.join(' | ');
|
|
75
|
+
return { lastLines, drained };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How long a browser that CLOSED ITS PIPE gets to finish dying, and any browser's stderr to finish
|
|
80
|
+
* draining, before the tail is read. The pipe ending and the stderr reader reaching the last line
|
|
81
|
+
* are two unordered events; read at the first, the reason a browser died was reported as "printed
|
|
82
|
+
* nothing". Bounded, because a WEDGED browser neither exits nor closes stderr.
|
|
83
|
+
*/
|
|
84
|
+
const FAILURE_DRAIN_MS = 1_000;
|
|
85
|
+
|
|
86
|
+
/** How long a closed Chrome gets to exit on SIGTERM before it is killed outright. */
|
|
87
|
+
export const CLOSE_GRACE_MS = 5_000;
|
|
88
|
+
|
|
89
|
+
const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
|
|
90
|
+
Promise.race([work, Bun.sleep(ms)]);
|
|
91
|
+
|
|
92
|
+
const GROUP_POLL_MS = 10;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Kill whatever is left of the browser's process group and wait until the group is EMPTY — the
|
|
96
|
+
* condition, asked of the kernel, not a pause: signal 0 to a group with no member throws. Bounded
|
|
97
|
+
* by `CLOSE_GRACE_MS` for a container whose init never reaps a zombie.
|
|
98
|
+
*/
|
|
99
|
+
async function reapGroup(pgid: number): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-pgid, 'SIGKILL');
|
|
102
|
+
for (let looks = CLOSE_GRACE_MS / GROUP_POLL_MS; looks > 0; looks -= 1) {
|
|
103
|
+
await Bun.sleep(GROUP_POLL_MS);
|
|
104
|
+
process.kill(-pgid, 0);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// ESRCH: nothing is left in the group, which is the answer being waited for.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Start the browser once and answer when it has answered one CDP call. With a pipe there is no
|
|
113
|
+
* "DevTools listening" line to wait for — the first reply IS the readiness signal.
|
|
114
|
+
*/
|
|
115
|
+
export async function launchAttempt(options: LaunchAttemptOptions): Promise<LaunchAttemptResult> {
|
|
116
|
+
const profileDir = mkdtempSync(join(tmpdir(), 'x-e2e-chrome-'));
|
|
117
|
+
const removeProfile = (): void => {
|
|
118
|
+
try {
|
|
119
|
+
rmSync(profileDir, { recursive: true, force: true });
|
|
120
|
+
} catch {
|
|
121
|
+
// A throwaway directory that will not go is litter, never the launch's or the close's verdict.
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const began = performance.now();
|
|
125
|
+
const child = Bun.spawn([options.executable, ...options.flags(profileDir)], {
|
|
126
|
+
// Chrome's fd 3 is where it READS commands and fd 4 where it WRITES replies and events.
|
|
127
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
128
|
+
// Its own process GROUP, so the zygote, GPU and network processes can be reaped with it
|
|
129
|
+
// (`reapGroup`). It still ends with this process: Chrome exits when its fd 3 closes.
|
|
130
|
+
detached: true,
|
|
131
|
+
});
|
|
132
|
+
const tail = stderrTail(child.stderr as ReadableStream<Uint8Array>);
|
|
133
|
+
const [, , , toBrowser, fromBrowser] = child.stdio as unknown as readonly number[];
|
|
134
|
+
const sink = Bun.file(toBrowser ?? -1).writer();
|
|
135
|
+
const connection = cdpConnectOver(
|
|
136
|
+
pipeTransport({
|
|
137
|
+
write: (bytes) => {
|
|
138
|
+
sink.write(bytes);
|
|
139
|
+
void sink.flush();
|
|
140
|
+
},
|
|
141
|
+
read: Bun.file(fromBrowser ?? -1).stream(),
|
|
142
|
+
end: () => {
|
|
143
|
+
void Promise.resolve(sink.end()).catch(() => undefined);
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
options.timeoutMs,
|
|
147
|
+
);
|
|
148
|
+
let exiting: Promise<void> | undefined;
|
|
149
|
+
const close = (): Promise<void> => {
|
|
150
|
+
exiting ??= (async () => {
|
|
151
|
+
connection.close();
|
|
152
|
+
child.kill();
|
|
153
|
+
removeProfile();
|
|
154
|
+
const exited =
|
|
155
|
+
(await within(
|
|
156
|
+
CLOSE_GRACE_MS,
|
|
157
|
+
child.exited.then(() => true),
|
|
158
|
+
)) === true;
|
|
159
|
+
if (!exited) {
|
|
160
|
+
child.kill('SIGKILL');
|
|
161
|
+
await within(CLOSE_GRACE_MS, child.exited);
|
|
162
|
+
}
|
|
163
|
+
await reapGroup(child.pid);
|
|
164
|
+
// Again, now that nothing is writing: Chrome's network process outlives the browser process
|
|
165
|
+
// and flushes into the profile, re-creating a directory removed a moment before. Measured on
|
|
166
|
+
// Chrome 150, 30 launches each: left behind 6 and 12 times without the group reap, 0 with.
|
|
167
|
+
removeProfile();
|
|
168
|
+
})();
|
|
169
|
+
return exiting;
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
await connection.send('Browser.getVersion', {}, undefined, options.launchTimeoutMs);
|
|
173
|
+
return { ok: true, browser: { connection, close } };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const why = error instanceof CdpTimeoutError ? 'deadline' : 'closed';
|
|
176
|
+
// The deadline itself when that is what ended it: the number the reader can raise.
|
|
177
|
+
const waitedMs =
|
|
178
|
+
why === 'deadline' ? options.launchTimeoutMs : Math.round(performance.now() - began);
|
|
179
|
+
// Only a browser that hung up may be on its way out with the reason still unwritten. One that
|
|
180
|
+
// was silent for the whole deadline has had its time.
|
|
181
|
+
if (why === 'closed') await within(FAILURE_DRAIN_MS, child.exited);
|
|
182
|
+
// Read BEFORE the kill: after it, an exit code is ours and says nothing about the browser.
|
|
183
|
+
const exitCode = child.exitCode;
|
|
184
|
+
await close();
|
|
185
|
+
await within(FAILURE_DRAIN_MS, tail.drained);
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
failure: { why, waitedMs, exitCode, stderr: tail.lastLines() },
|
|
189
|
+
reaped: child.exitCode !== null || child.signalCode !== null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|