@ultimat3/testing 23.0.0 → 25.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 +20 -20
- package/README.md +95 -13
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +47 -11
- package/src/cdp-connection.ts +41 -10
- package/src/cdp-e2e-session.ts +130 -13
- package/src/cdp-errors.ts +129 -8
- package/src/cdp-launch-attempt.ts +224 -0
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-profile.ts +106 -0
- package/src/cdp-launch-reap.ts +68 -0
- package/src/cdp-launch-wire.ts +171 -0
- package/src/cdp-launch.ts +92 -140
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +35 -6
- package/src/e2e-app.ts +17 -1
- package/src/e2e-errors.ts +14 -3
- package/src/e2e-locale.ts +12 -21
- package/src/e2e-page.ts +12 -26
- package/src/e2e-run.ts +22 -12
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +58 -17
- package/src/errors.ts +34 -66
- package/src/factories.ts +4 -6
- package/src/file-boundary.ts +12 -7
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +50 -98
- package/src/fixture-jobs.ts +16 -12
- package/src/fixture-mail.ts +3 -3
- package/src/fixture-network.ts +1 -1
- package/src/fixture-statements.ts +1 -1
- package/src/fixture-subscribe.ts +3 -3
- package/src/framework-fixtures.ts +13 -13
- package/src/free-port.ts +46 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +29 -10
- package/src/island-dom.ts +57 -13
- package/src/island-globals.ts +73 -0
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +105 -0
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +6 -4
- package/src/preload.ts +2 -8
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +7 -4
- package/src/registry-snapshot.ts +41 -2
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +50 -5
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +8 -5
- package/src/test-types.ts +6 -2
package/src/cdp-browser.ts
CHANGED
|
@@ -13,11 +13,52 @@ import type { E2eSession } from './cdp-e2e-session';
|
|
|
13
13
|
import { cdpE2eSession } from './cdp-e2e-session';
|
|
14
14
|
import { CdpBrowserMissingError } from './cdp-errors';
|
|
15
15
|
import type { LaunchedBrowser } from './cdp-launch';
|
|
16
|
-
import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
|
|
16
|
+
import { CHROME_CANDIDATES, findChrome, LAUNCH_BUDGET_MS, launchChrome } from './cdp-launch';
|
|
17
|
+
import { LAUNCHED_CLOSE_MS } from './cdp-launch-attempt';
|
|
17
18
|
|
|
18
19
|
/** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
|
|
19
20
|
export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Sequential deadlines `cdpE2eSession` spends before it answers — `Target.setDiscoverTargets`,
|
|
24
|
+
* then `Target.setAutoAttach` — and `newTab()` spends per tab: `Target.createTarget`, then the
|
|
25
|
+
* attach that publishes it. Counts of calls in `cdp-e2e-session.ts`, held to it by
|
|
26
|
+
* `cdp-browser.test.ts` over a fake connection.
|
|
27
|
+
*/
|
|
28
|
+
export const SESSION_SETUP_DEADLINES = 2;
|
|
29
|
+
export const TAB_OPEN_DEADLINES = 2;
|
|
30
|
+
|
|
31
|
+
/** The longest one more `session.newTab()` is designed to take, at the default per-call deadline. */
|
|
32
|
+
export const E2E_TAB_OPEN_MS = TAB_OPEN_DEADLINES * DEFAULT_CDP_TIMEOUT_MS;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The longest `openE2eBrowser()` is DESIGNED to take, at the default deadline: the whole launch
|
|
36
|
+
* budget — both starts and their reaps — then the session's setup and the first tab. THE deadline
|
|
37
|
+
* for a `beforeAll` or `afterAll` that opens or closes a browser: one below it is killed by Bun
|
|
38
|
+
* before the relaunch, and the failure that names the slow step never surfaces. Derived, never
|
|
39
|
+
* restated: `e2e-browser-hooks.test.ts` refuses a browser hook whose deadline does not start here.
|
|
40
|
+
*/
|
|
41
|
+
export const E2E_BROWSER_OPEN_MS =
|
|
42
|
+
LAUNCH_BUDGET_MS + SESSION_SETUP_DEADLINES * DEFAULT_CDP_TIMEOUT_MS + E2E_TAB_OPEN_MS;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The longest `E2eBrowser.close()` is DESIGNED to take — the launched browser's close, every step
|
|
46
|
+
* bounded (`LAUNCHED_CLOSE_MS`). THE deadline for a hook that closes a browser it already holds: a
|
|
47
|
+
* hook left at Bun's 5 s default is killed mid-close, and when it is the run's last the process
|
|
48
|
+
* exits with the profile half-removed — the `x-e2e-chrome-*` directories found leaked in `/tmp`.
|
|
49
|
+
*/
|
|
50
|
+
export const E2E_BROWSER_CLOSE_MS = LAUNCHED_CLOSE_MS;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Sequential deadlines one `tab.goto()` spends: the load event raced against `Page.navigate`'s
|
|
54
|
+
* reply (one deadline, two waiters), then the two reads of the document (`cdp-e2e-page.ts`). Held
|
|
55
|
+
* to the method by `cdp-browser.test.ts` over a fake connection, as the tab's count is.
|
|
56
|
+
*/
|
|
57
|
+
export const GOTO_DEADLINES = 3;
|
|
58
|
+
|
|
59
|
+
/** The longest one `tab.goto()` is designed to take, at the default per-call deadline. */
|
|
60
|
+
export const E2E_GOTO_MS = GOTO_DEADLINES * DEFAULT_CDP_TIMEOUT_MS;
|
|
61
|
+
|
|
21
62
|
export interface E2eBrowser {
|
|
22
63
|
/** The first tab — what `installE2eDriver({ page })` drives. */
|
|
23
64
|
readonly page: E2eTab;
|
|
@@ -27,13 +68,12 @@ export interface E2eBrowser {
|
|
|
27
68
|
* drives, on the same launch as `page` — one harness, never a second one beside the driver.
|
|
28
69
|
*/
|
|
29
70
|
readonly session: E2eSession;
|
|
30
|
-
/** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
|
|
31
|
-
close(): void;
|
|
32
71
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
72
|
+
* THE close, idempotent, awaited: the CDP connection, then the process, its process group and its
|
|
73
|
+
* profile (bounded — `CLOSE_GRACE_MS` per step). Unawaited, a process that exits next leaves a
|
|
74
|
+
* Chrome child and a profile directory behind.
|
|
35
75
|
*/
|
|
36
|
-
|
|
76
|
+
close(): Promise<void>;
|
|
37
77
|
}
|
|
38
78
|
|
|
39
79
|
export interface OpenE2eBrowserOptions {
|
|
@@ -60,10 +100,6 @@ const compose = (launched: LaunchedBrowser, session: E2eSession, page: E2eTab):
|
|
|
60
100
|
// the process out from under an open connection makes every in-flight call report "the browser
|
|
61
101
|
// closed the CDP connection", which is true and useless.
|
|
62
102
|
close: () => launched.close(),
|
|
63
|
-
closed: async () => {
|
|
64
|
-
if (launched.closed === undefined) launched.close();
|
|
65
|
-
else await launched.closed();
|
|
66
|
-
},
|
|
67
103
|
});
|
|
68
104
|
|
|
69
105
|
/**
|
|
@@ -107,7 +143,7 @@ async function openLaunched(
|
|
|
107
143
|
});
|
|
108
144
|
return compose(launched, session, await session.newTab());
|
|
109
145
|
} catch (error) {
|
|
110
|
-
launched.close();
|
|
146
|
+
await launched.close();
|
|
111
147
|
throw error;
|
|
112
148
|
}
|
|
113
149
|
}
|
package/src/cdp-connection.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
// small ones, on Bun's native `WebSocket`, with no dependency to add to a repo whose first
|
|
12
12
|
// non-negotiable is that Bun's natives replace most of them.
|
|
13
13
|
|
|
14
|
-
import { assert } from '@ultimat3/core';
|
|
14
|
+
import { assert, stringField } 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
|
|
@@ -89,6 +99,16 @@ export interface CdpConnectionOptions {
|
|
|
89
99
|
readonly timeoutMs: number;
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
/**
|
|
103
|
+
* What went wrong, in the socket's own words when it has any. Bun hands `onerror` an `ErrorEvent`
|
|
104
|
+
* whose `message` is the only place the reason lives — "Failed to connect", "Expected 101 status
|
|
105
|
+
* code" — and a refusal that drops it names the step and not the cause.
|
|
106
|
+
*/
|
|
107
|
+
const failure = (event: unknown, what: string): string => {
|
|
108
|
+
const message = stringField(event, 'message');
|
|
109
|
+
return message === undefined || message === '' ? what : `${what}: ${message}`;
|
|
110
|
+
};
|
|
111
|
+
|
|
92
112
|
/**
|
|
93
113
|
* A remote browser over its WebSocket url. NOT the e2e driver's wire: measured in the dummy's
|
|
94
114
|
* `offline-feed` suite on Bun 1.4.0, Bun's WebSocket client handed `onmessage` text spliced out of
|
|
@@ -115,11 +135,14 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
|
|
|
115
135
|
};
|
|
116
136
|
// `onerror` is the handshake's only until `listen` below reassigns it: a failure BEFORE open
|
|
117
137
|
// has no pending call to abandon, and rejecting is the only way the caller hears about it.
|
|
118
|
-
socket.onerror = (): void => {
|
|
138
|
+
socket.onerror = (event: Event): void => {
|
|
119
139
|
clearTimeout(timer);
|
|
120
140
|
socket.close();
|
|
121
141
|
reject(
|
|
122
|
-
new CdpCallFailedError({
|
|
142
|
+
new CdpCallFailedError({
|
|
143
|
+
method: 'connect',
|
|
144
|
+
detail: failure(event, 'the browser refused the connection'),
|
|
145
|
+
}),
|
|
123
146
|
);
|
|
124
147
|
};
|
|
125
148
|
});
|
|
@@ -132,7 +155,8 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
|
|
|
132
155
|
handlers.message(typeof event.data === 'string' ? event.data : '');
|
|
133
156
|
};
|
|
134
157
|
socket.onclose = (): void => handlers.closed('the browser closed the CDP connection');
|
|
135
|
-
socket.onerror = (): void =>
|
|
158
|
+
socket.onerror = (event: Event): void =>
|
|
159
|
+
handlers.closed(failure(event, 'the CDP connection failed'));
|
|
136
160
|
},
|
|
137
161
|
};
|
|
138
162
|
return cdpConnectOver(transport, options.timeoutMs);
|
|
@@ -145,6 +169,8 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
145
169
|
const listeners = new Map<string, Set<CdpEventListener>>();
|
|
146
170
|
let nextId = 0;
|
|
147
171
|
let closed = false;
|
|
172
|
+
// What a call that is never answered reports instead of a bare "did not answer".
|
|
173
|
+
const watch = wireWatch();
|
|
148
174
|
|
|
149
175
|
// Every in-flight call is settled on close. Without this a suite whose browser died waits out
|
|
150
176
|
// one full deadline per call and reports a timeout, where the true fault is a dead browser.
|
|
@@ -167,9 +193,12 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
167
193
|
frame = JSON.parse(raw) as Record<string, unknown>;
|
|
168
194
|
} catch {
|
|
169
195
|
// 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.
|
|
196
|
+
// correlation is by `id`, and a frame we cannot read has none. COUNTED, though: the call
|
|
197
|
+
// whose reply it was waits out its deadline, and its timeout says frames were dropped.
|
|
198
|
+
watch.drop();
|
|
171
199
|
return;
|
|
172
200
|
}
|
|
201
|
+
watch.frame(frame);
|
|
173
202
|
const id = frame['id'];
|
|
174
203
|
if (typeof id !== 'number') {
|
|
175
204
|
const method = frame['method'];
|
|
@@ -206,7 +235,7 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
206
235
|
transport.listen({ message, closed: abandon });
|
|
207
236
|
|
|
208
237
|
return {
|
|
209
|
-
send(method, params = {}, sessionId): Promise<CdpResult> {
|
|
238
|
+
send(method, params = {}, sessionId, deadlineMs = timeoutMs): Promise<CdpResult> {
|
|
210
239
|
if (closed) {
|
|
211
240
|
return Promise.reject(
|
|
212
241
|
new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }),
|
|
@@ -215,10 +244,12 @@ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpC
|
|
|
215
244
|
nextId += 1;
|
|
216
245
|
const id = nextId;
|
|
217
246
|
return new Promise<CdpResult>((resolve, reject) => {
|
|
247
|
+
const sentAt = watch.mark(sessionId);
|
|
218
248
|
const timer = setTimeout(() => {
|
|
219
249
|
pending.delete(id);
|
|
220
|
-
|
|
221
|
-
|
|
250
|
+
const observed = watch.observe(sentAt, sessionId, !closed);
|
|
251
|
+
reject(new CdpTimeoutError({ method, timeoutMs: deadlineMs, observed }));
|
|
252
|
+
}, deadlineMs);
|
|
222
253
|
pending.set(id, { resolve, reject, timer });
|
|
223
254
|
transport.send(
|
|
224
255
|
JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
|
package/src/cdp-e2e-session.ts
CHANGED
|
@@ -35,11 +35,25 @@ export interface CdpE2eSessionOptions {
|
|
|
35
35
|
readonly acceptLanguage?: string | undefined;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/** What a page reports to its own code — the state a test's next `fetch` runs under. */
|
|
39
|
+
const ON_LINE = 'navigator.onLine';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Answers `true` at once when the page already reads `online`, else on the `online`/`offline`
|
|
43
|
+
* event that flips it. The read and the `addEventListener` run in ONE task, so the event cannot
|
|
44
|
+
* land between them. `false` only when the event came and the state still disagrees: ask again.
|
|
45
|
+
*/
|
|
46
|
+
const readsOnLine = (online: boolean): string => {
|
|
47
|
+
const reads = `${ON_LINE} === ${String(online)}`;
|
|
48
|
+
const flip = online ? 'online' : 'offline';
|
|
49
|
+
return `${reads} || new Promise((resolve) => addEventListener('${flip}', () => resolve(${reads}), { once: true }))`;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const member = (from: unknown, key: string): unknown =>
|
|
53
|
+
typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
|
|
39
54
|
|
|
40
55
|
const field = (from: unknown, key: string): string | undefined => {
|
|
41
|
-
const value =
|
|
42
|
-
typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
|
|
56
|
+
const value = member(from, key);
|
|
43
57
|
return typeof value === 'string' ? value : undefined;
|
|
44
58
|
};
|
|
45
59
|
|
|
@@ -55,6 +69,8 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
|
|
|
55
69
|
const send = connection.send.bind(connection);
|
|
56
70
|
const sessions = new Set<string>();
|
|
57
71
|
const pages = new Map<string, string>(); // targetId → sessionId
|
|
72
|
+
// targetId → the `newTab()` waiting for that tab to be published.
|
|
73
|
+
const published = new Map<string, (session: string) => void>();
|
|
58
74
|
const scripts: string[] = [];
|
|
59
75
|
const sockets: string[] = [];
|
|
60
76
|
const requests: string[] = [];
|
|
@@ -137,6 +153,7 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
|
|
|
137
153
|
const targetId = field(info, 'targetId');
|
|
138
154
|
if (type === 'page' && targetId !== undefined && sessions.has(session)) {
|
|
139
155
|
pages.set(targetId, session);
|
|
156
|
+
published.get(targetId)?.(session);
|
|
140
157
|
}
|
|
141
158
|
})();
|
|
142
159
|
});
|
|
@@ -147,6 +164,86 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
|
|
|
147
164
|
flatten: true,
|
|
148
165
|
});
|
|
149
166
|
|
|
167
|
+
/** Settles `true` on `event` from `session` and `false` at `deadline`, whichever is first. */
|
|
168
|
+
const nextEvent = (event: string, session: string, deadline: number) => {
|
|
169
|
+
let heard: () => void = () => undefined;
|
|
170
|
+
const arrived = new Promise<void>((resolve) => {
|
|
171
|
+
heard = resolve;
|
|
172
|
+
});
|
|
173
|
+
const off = connection.on(event, (_params, on) => {
|
|
174
|
+
if (on === session) heard();
|
|
175
|
+
});
|
|
176
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
177
|
+
return {
|
|
178
|
+
/** Subscribed when created, so an event that lands before this is awaited still counts. */
|
|
179
|
+
wait: (): Promise<boolean> =>
|
|
180
|
+
Promise.race([
|
|
181
|
+
arrived.then(() => true),
|
|
182
|
+
new Promise<boolean>((resolve) => {
|
|
183
|
+
timer = setTimeout(() => resolve(false), Math.max(0, deadline - performance.now()));
|
|
184
|
+
}),
|
|
185
|
+
]),
|
|
186
|
+
close: (): void => {
|
|
187
|
+
off();
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether one page came to read `navigator.onLine === online` before `deadline`. The page itself
|
|
195
|
+
* says when — the evaluated promise resolves on the event that flips it, `awaitPromise` holds the
|
|
196
|
+
* reply until then — so nothing is polled and nothing sleeps. A navigation destroys the context
|
|
197
|
+
* the promise lived in and refuses the call; the page is asked again in the document that
|
|
198
|
+
* replaces it, once Chrome announces that document's context.
|
|
199
|
+
*/
|
|
200
|
+
const pageReads = async (session: string, online: boolean, deadline: number) => {
|
|
201
|
+
for (;;) {
|
|
202
|
+
const remaining = deadline - performance.now();
|
|
203
|
+
if (remaining <= 0) return false;
|
|
204
|
+
const context = nextEvent('Runtime.executionContextCreated', session, deadline);
|
|
205
|
+
try {
|
|
206
|
+
const answer = await send(
|
|
207
|
+
'Runtime.evaluate',
|
|
208
|
+
{ expression: readsOnLine(online), returnByValue: true, awaitPromise: true },
|
|
209
|
+
session,
|
|
210
|
+
remaining,
|
|
211
|
+
);
|
|
212
|
+
const value = member(member(answer.result, 'result'), 'value');
|
|
213
|
+
if (value === true) return true;
|
|
214
|
+
// `false`: the event fired and the state disagrees, so the next ask awaits the next one.
|
|
215
|
+
// Anything else is a page that could not run the read — the same as a refusal, below.
|
|
216
|
+
if (value !== false && !(await context.wait())) return false;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error instanceof CdpTimeoutError) return false;
|
|
219
|
+
if (!(await context.wait())) return false;
|
|
220
|
+
} finally {
|
|
221
|
+
context.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// #572. `offline()` SENDS the condition, and the renderer applies it when it gets to it: a `fetch`
|
|
227
|
+
// on the caller's next line raced that on a slow runner and went through under a switch the test
|
|
228
|
+
// had already thrown. So the switch is thrown when every page READS the state, and not before —
|
|
229
|
+
// never a fixed sleep, which is either too long everywhere or too short on the runner that
|
|
230
|
+
// matters. Bounded by the load budget, because a page mid-navigation answers only once it lands;
|
|
231
|
+
// a page that never reads it is a timeout naming the page, never a resolve the test trusts.
|
|
232
|
+
const confirmed = async (online: boolean): Promise<void> => {
|
|
233
|
+
const deadline = performance.now() + options.loadTimeoutMs;
|
|
234
|
+
const open = [...pageSessions].filter((session) => sessions.has(session));
|
|
235
|
+
const answers = await Promise.all(
|
|
236
|
+
open.map(async (session) => [session, await pageReads(session, online, deadline)] as const),
|
|
237
|
+
);
|
|
238
|
+
const waiting = answers.filter(([, reads]) => !reads).map(([session]) => session);
|
|
239
|
+
if (waiting.length > 0) {
|
|
240
|
+
throw new CdpTimeoutError({
|
|
241
|
+
method: `${ON_LINE} === ${String(online)} in page session ${waiting.join(', ')}`,
|
|
242
|
+
timeoutMs: options.loadTimeoutMs,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
150
247
|
const offline = async (enabled: boolean): Promise<void> => {
|
|
151
248
|
cut = enabled;
|
|
152
249
|
// Sequential, and a session that has gone away is dropped rather than taking the rest down.
|
|
@@ -155,21 +252,41 @@ export async function cdpE2eSession(options: CdpE2eSessionOptions): Promise<E2eS
|
|
|
155
252
|
}
|
|
156
253
|
// And `navigator.onLine` from a new document's first script (`cdp-offline-script.ts`) — the
|
|
157
254
|
// network condition alone reaches a reloaded page only after its scripts have run.
|
|
255
|
+
// A page that is still THERE and refuses is kept and reported once every page has been asked:
|
|
256
|
+
// dropping it meant every later `offline()` skipped that tab in silence. Only a session the
|
|
257
|
+
// loop above just found gone is dropped — a closed tab refuses every call.
|
|
258
|
+
const refused: unknown[] = [];
|
|
158
259
|
for (const session of [...pageSessions]) {
|
|
260
|
+
if (!sessions.has(session)) {
|
|
261
|
+
pageSessions.delete(session);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
159
264
|
const toggled = enabled ? onLineScripts.add(session) : onLineScripts.remove(session);
|
|
160
|
-
await toggled.catch(() =>
|
|
265
|
+
await toggled.catch((error: unknown) => refused.push(error));
|
|
161
266
|
}
|
|
267
|
+
if (refused.length > 0) throw refused[0];
|
|
268
|
+
await confirmed(!enabled);
|
|
162
269
|
};
|
|
163
270
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
271
|
+
/** The tab's session once it is published — told by the attach itself, never polled for. */
|
|
272
|
+
const attached = (targetId: string): Promise<string> => {
|
|
273
|
+
const session = pages.get(targetId);
|
|
274
|
+
if (session !== undefined) return Promise.resolve(session);
|
|
275
|
+
return new Promise<string>((resolve, reject) => {
|
|
276
|
+
const timer = setTimeout(() => {
|
|
277
|
+
published.delete(targetId);
|
|
278
|
+
reject(
|
|
279
|
+
new CdpTimeoutError({
|
|
280
|
+
method: `Target.attachedToTarget for tab ${targetId}`,
|
|
281
|
+
timeoutMs: options.loadTimeoutMs,
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
}, options.loadTimeoutMs);
|
|
285
|
+
published.set(targetId, (ready) => {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
published.delete(targetId);
|
|
288
|
+
resolve(ready);
|
|
289
|
+
});
|
|
173
290
|
});
|
|
174
291
|
};
|
|
175
292
|
|
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,66 @@ 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
|
+
/**
|
|
26
|
+
* `deadline`: alive and silent for `waitedMs`. `closed`: its wire ended, or its dial was
|
|
27
|
+
* refused, before any answer. `spawn`: the binary could not be started at all — it never ran.
|
|
28
|
+
*/
|
|
29
|
+
readonly why: 'deadline' | 'closed' | 'spawn';
|
|
30
|
+
readonly waitedMs: number;
|
|
31
|
+
/** The code it exited with on its own, or `null` when the launcher had to kill it. */
|
|
32
|
+
readonly exitCode: number | null;
|
|
33
|
+
/** The last lines it wrote to stderr; empty when it wrote none. */
|
|
34
|
+
readonly stderr: string;
|
|
35
|
+
/**
|
|
36
|
+
* What the LAUNCHER saw end the attempt, rendered: the refused dial and its endpoint, the call
|
|
37
|
+
* that timed out, the spawn's own error. Absent on a record that predates it.
|
|
38
|
+
*/
|
|
39
|
+
readonly detail?: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HAND_FLAGS =
|
|
43
|
+
'--headless=new --no-sandbox --disable-dev-shm-usage --disable-gpu --remote-debugging-port=0 about:blank';
|
|
44
|
+
|
|
45
|
+
const endedText = (attempt: CdpLaunchAttempt): string => {
|
|
46
|
+
const after = `after ${String(attempt.waitedMs)}ms`;
|
|
47
|
+
if (attempt.why === 'spawn') return 'could not be started';
|
|
48
|
+
if (attempt.why === 'deadline')
|
|
49
|
+
return `no answer inside ${String(attempt.waitedMs)}ms, so it was killed`;
|
|
50
|
+
return attempt.exitCode === null
|
|
51
|
+
? `its DevTools connection ended ${after} without an answer, and it was killed`
|
|
52
|
+
: `exited with code ${String(attempt.exitCode)} ${after} without answering`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const attemptText = (attempt: CdpLaunchAttempt, index: number): string => {
|
|
56
|
+
const saw =
|
|
57
|
+
attempt.detail === undefined || attempt.detail === ''
|
|
58
|
+
? ''
|
|
59
|
+
: `: ${renderCauseValue(attempt.detail)}`;
|
|
60
|
+
const said = attempt.stderr === '' ? 'it printed nothing' : renderCauseValue(attempt.stderr);
|
|
61
|
+
return `launch ${String(index + 1)}: ${endedText(attempt)}${saw}; stderr: ${said}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The binary ran and never answered a DevTools call — a crash, a bad flag, a sandbox refusal, or a
|
|
66
|
+
* machine too loaded to start it inside the launch deadline. Every attempt is in the cause with its
|
|
67
|
+
* own stderr, because Chrome's start-up noise reads the same in a healthy browser and a dead one:
|
|
68
|
+
* what tells them apart is whether it EXITED or was still starting when the deadline passed.
|
|
69
|
+
*/
|
|
24
70
|
export class CdpLaunchFailedError extends UltimateError {
|
|
25
|
-
constructor(input: {
|
|
71
|
+
constructor(input: {
|
|
72
|
+
readonly executable: string;
|
|
73
|
+
readonly attempts: readonly CdpLaunchAttempt[];
|
|
74
|
+
}) {
|
|
75
|
+
// Run by hand, a healthy Chrome prints `DevTools listening on ws://…` and a broken one prints
|
|
76
|
+
// why. A path that is not shell-inert is the operator's own CHROME_PATH — every candidate is.
|
|
77
|
+
const binary = isFixShellSafe(input.executable) ? input.executable : '"$CHROME_PATH"';
|
|
26
78
|
super({
|
|
27
79
|
code: 'X_CDP_LAUNCH_FAILED',
|
|
28
|
-
cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: ${
|
|
29
|
-
fix:
|
|
80
|
+
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(' · ')}`,
|
|
81
|
+
fix: `${binary} ${HAND_FLAGS}`,
|
|
82
|
+
meta: { executable: input.executable, attempts: input.attempts },
|
|
30
83
|
});
|
|
31
84
|
}
|
|
32
85
|
}
|
|
@@ -42,17 +95,85 @@ export class CdpCallFailedError extends UltimateError {
|
|
|
42
95
|
}
|
|
43
96
|
}
|
|
44
97
|
|
|
98
|
+
/** How a call's target went away: its session detached, its target was destroyed, or it crashed. */
|
|
99
|
+
export type CdpTargetGone = 'detached' | 'destroyed' | 'crashed';
|
|
100
|
+
|
|
101
|
+
/** What the wire did between a call being sent and its deadline (`cdp-wire-watch.ts`). */
|
|
102
|
+
export interface CdpTimeoutObservation {
|
|
103
|
+
/** The session the call was sent on; `null` for a browser-level call. */
|
|
104
|
+
readonly sessionId: string | null;
|
|
105
|
+
/** Frames that arrived and parsed after the call was sent — replies to other calls, and events. */
|
|
106
|
+
readonly framesArrived: number;
|
|
107
|
+
/** The last of those, oldest first, bounded: `reply <id>`, or `<method> @<session>`. */
|
|
108
|
+
readonly lastFrames: readonly string[];
|
|
109
|
+
/** Frames that arrived after the call and could not be parsed. One of them may be its reply. */
|
|
110
|
+
readonly framesDropped: number;
|
|
111
|
+
readonly transportOpen: boolean;
|
|
112
|
+
/** `detached`, `destroyed` or `crashed` when the call's target went away; `null` while it is there. */
|
|
113
|
+
readonly targetGone: CdpTargetGone | null;
|
|
114
|
+
/** Main-frame navigations on the call's session since it was sent. */
|
|
115
|
+
readonly navigations: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The one-word verdict a reader — or a script over `--json` — branches on. */
|
|
119
|
+
export type CdpTimeoutReading = 'target-gone' | 'lost-in-transport' | 'no-answer';
|
|
120
|
+
|
|
121
|
+
export const readTimeout = (seen: CdpTimeoutObservation): CdpTimeoutReading =>
|
|
122
|
+
seen.targetGone !== null
|
|
123
|
+
? 'target-gone'
|
|
124
|
+
: seen.framesDropped > 0
|
|
125
|
+
? 'lost-in-transport'
|
|
126
|
+
: 'no-answer';
|
|
127
|
+
|
|
128
|
+
const READING: Record<CdpTimeoutReading, (seen: CdpTimeoutObservation) => string> = {
|
|
129
|
+
'target-gone': (seen) =>
|
|
130
|
+
`its target is gone (${String(seen.targetGone)}), so nothing was left to answer`,
|
|
131
|
+
'lost-in-transport': (seen) =>
|
|
132
|
+
`${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)`,
|
|
133
|
+
'no-answer': (seen) => {
|
|
134
|
+
if (seen.framesArrived === 0) {
|
|
135
|
+
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`;
|
|
136
|
+
}
|
|
137
|
+
const moved =
|
|
138
|
+
seen.navigations === 0
|
|
139
|
+
? ''
|
|
140
|
+
: `; the page navigated ${String(seen.navigations)} time(s) since the call`;
|
|
141
|
+
return `the target did not answer: ${String(seen.framesArrived)} other frame(s) arrived intact after the call, the last ${renderCauseValue(seen.lastFrames)}${moved}`;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
45
145
|
/**
|
|
46
146
|
* A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
|
|
47
147
|
* WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
|
|
48
148
|
* `Runtime.evaluate` is an expression that never settles.
|
|
149
|
+
*
|
|
150
|
+
* `observed` is what the connection saw meanwhile, and it is what tells three failures apart that
|
|
151
|
+
* all read "did not answer": the target went away, the reply was dropped on the wire, or the target
|
|
152
|
+
* was there and silent. A deadline the connection did not keep (`waitFor`, the handshake) has none.
|
|
49
153
|
*/
|
|
50
154
|
export class CdpTimeoutError extends UltimateError {
|
|
51
|
-
constructor(input: {
|
|
155
|
+
constructor(input: {
|
|
156
|
+
readonly method: string;
|
|
157
|
+
readonly timeoutMs: number;
|
|
158
|
+
readonly observed?: CdpTimeoutObservation | undefined;
|
|
159
|
+
}) {
|
|
160
|
+
const { observed } = input;
|
|
161
|
+
const base = `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`;
|
|
162
|
+
const reading = observed === undefined ? undefined : readTimeout(observed);
|
|
52
163
|
super({
|
|
53
164
|
code: 'X_CDP_TIMEOUT',
|
|
54
|
-
cause:
|
|
55
|
-
|
|
165
|
+
cause:
|
|
166
|
+
observed === undefined || reading === undefined
|
|
167
|
+
? base
|
|
168
|
+
: `${base} — ${READING[reading](observed)}`,
|
|
169
|
+
fix:
|
|
170
|
+
reading === undefined || reading === 'no-answer'
|
|
171
|
+
? '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'
|
|
172
|
+
: // `--json`: the rerun's `meta.reading` and `meta.framesDropped` are only printed there.
|
|
173
|
+
'x test e2e --json',
|
|
174
|
+
...(observed === undefined
|
|
175
|
+
? {}
|
|
176
|
+
: { meta: { method: input.method, timeoutMs: input.timeoutMs, reading, ...observed } }),
|
|
56
177
|
});
|
|
57
178
|
}
|
|
58
179
|
}
|