@ultimat3/scraping 30.9.0 → 31.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/README.md +73 -8
- package/package.json +9 -6
- package/src/actionability.ts +2 -2
- package/src/adapter.ts +22 -0
- package/src/auth.ts +4 -5
- package/src/browser-dial.ts +55 -0
- package/src/cdp-arm.ts +81 -22
- package/src/cdp-connection.ts +290 -0
- package/src/cdp-error-codes.ts +28 -0
- package/src/cdp-errors.ts +179 -0
- package/src/cdp-launch-attempt.ts +234 -0
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-probe.ts +75 -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 +216 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cdp-wire-watch.ts +113 -0
- package/src/cdp.ts +46 -0
- package/src/clock.ts +4 -111
- package/src/driver-cdp.ts +3 -0
- package/src/driver-fake.ts +4 -5
- package/src/driver.ts +16 -11
- package/src/error-throws-pdf.ts +45 -0
- package/src/error-throws-session.ts +1 -1
- package/src/error-throws.ts +4 -4
- package/src/errors.ts +17 -1
- package/src/event-prompt.ts +10 -4
- package/src/events.ts +2 -3
- package/src/html-target.ts +2 -2
- package/src/http-recorded.ts +2 -2
- package/src/http.ts +2 -2
- package/src/index.ts +21 -75
- package/src/launch-args.ts +10 -1
- package/src/page-over-target.ts +2 -3
- package/src/pdf-action.ts +116 -0
- package/src/pdf-cdp-params.ts +65 -0
- package/src/pdf-fence.ts +86 -0
- package/src/pdf-render.ts +241 -0
- package/src/pdf-slots.ts +68 -0
- package/src/rate.ts +2 -2
- package/src/scrape-run.ts +3 -4
- package/src/scrape.ts +5 -5
- package/src/target.ts +1 -1
- package/src/usage.ts +2 -2
- package/src/watchdog.ts +3 -3
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// One responsibility: a Chrome DevTools Protocol connection — request framing, response
|
|
2
|
+
// correlation, the per-call deadline — over a TRANSPORT that moves whole messages: a launched
|
|
3
|
+
// Chrome's pipe (`cdp-pipe.ts`) or a remote browser's WebSocket (`cdpConnect`). It knows neither
|
|
4
|
+
// the launch (`cdp-launch.ts`) nor a page surface (testing's `cdp-e2e-page.ts`, `pdf-render.ts`).
|
|
5
|
+
|
|
6
|
+
import { assert, stringField } from '@ultimat3/core';
|
|
7
|
+
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
8
|
+
import { wireWatch } from './cdp-wire-watch';
|
|
9
|
+
|
|
10
|
+
// **No library, and that is the point rather than an economy.** `cdp-port.ts` declares a ~25-method
|
|
11
|
+
// port because `ScrapePage` is a full scraping surface, and its intended implementation is
|
|
12
|
+
// `puppeteer-core`. `E2eBrowserPage` is FIVE methods, and CDP's wire format is one JSON object with
|
|
13
|
+
// an `id` — so the whole thing an e2e driver needs is this file plus two small ones, on Bun's
|
|
14
|
+
// native `WebSocket`, with no dependency to add to a repo whose first non-negotiable is that Bun's
|
|
15
|
+
// natives replace most of them.
|
|
16
|
+
|
|
17
|
+
/** One CDP result. `unknown` because every payload here is somebody else's JSON. */
|
|
18
|
+
export interface CdpResult {
|
|
19
|
+
readonly result?: Record<string, unknown> | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CdpConnection {
|
|
23
|
+
/**
|
|
24
|
+
* Send one command. `sessionId` targets an attached page rather than the browser itself.
|
|
25
|
+
* `deadlineMs` replaces the connection's per-call deadline for THIS call — the launcher's first
|
|
26
|
+
* call is a cold start, not a call, and is the only caller that passes one.
|
|
27
|
+
*/
|
|
28
|
+
send(
|
|
29
|
+
method: string,
|
|
30
|
+
params?: Record<string, unknown>,
|
|
31
|
+
sessionId?: string,
|
|
32
|
+
deadlineMs?: number,
|
|
33
|
+
): Promise<CdpResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Wait for the next occurrence of one CDP **event**, or for the deadline. Answers `true` when the
|
|
36
|
+
* event arrived and `false` when it did not — it never throws, because every caller has a better
|
|
37
|
+
* assertion to fail on than "the event was late".
|
|
38
|
+
*
|
|
39
|
+
* It exists because a command's reply is not always the signal. `Page.navigate`'s reply is
|
|
40
|
+
* DROPPED whenever the navigation swaps the render process — measured on Chrome 150: the page
|
|
41
|
+
* loads, the server is hit, a later `Runtime.evaluate` answers, and the navigate frame never
|
|
42
|
+
* comes back at all. A driver that treated the reply as the completion signal waits out its full
|
|
43
|
+
* deadline on the most ordinary navigation there is.
|
|
44
|
+
*/
|
|
45
|
+
once(method: string, sessionId: string | undefined, timeoutMs: number): Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to every occurrence of one CDP event, until the returned function is called.
|
|
48
|
+
*
|
|
49
|
+
* `once` cannot express what this is for: a target that attaches AFTER the driver stopped
|
|
50
|
+
* listening is a service worker whose network conditions nobody set, which is an `offline()`
|
|
51
|
+
* that silently does nothing to the one thing serving the page.
|
|
52
|
+
*
|
|
53
|
+
* The listener is also handed the event's `sessionId` (`undefined` for a browser-level event), so
|
|
54
|
+
* a subscriber that owns ONE page can ignore the frames every other attached target sends.
|
|
55
|
+
*/
|
|
56
|
+
on(method: string, listener: CdpEventListener): () => void;
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One event's params, and the session it arrived on. */
|
|
61
|
+
export type CdpEventListener = (
|
|
62
|
+
params: Record<string, unknown>,
|
|
63
|
+
sessionId: string | undefined,
|
|
64
|
+
) => void;
|
|
65
|
+
|
|
66
|
+
interface Pending {
|
|
67
|
+
readonly resolve: (value: CdpResult) => void;
|
|
68
|
+
readonly reject: (reason: Error) => void;
|
|
69
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A CDP error frame: `{ error: { code, message } }`, both fields somebody else's. */
|
|
73
|
+
const errorText = (frame: Record<string, unknown>): string | undefined => {
|
|
74
|
+
const error = frame['error'];
|
|
75
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
76
|
+
const message = (error as Record<string, unknown>)['message'];
|
|
77
|
+
return typeof message === 'string' ? message : 'the browser refused the call';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a connection needs of the wire: whole messages out, whole messages in, and a close. Framing
|
|
82
|
+
* is the transport's — a WebSocket frames per message, the pipe splits on NUL — so correlation and
|
|
83
|
+
* deadlines are written once, over either.
|
|
84
|
+
*/
|
|
85
|
+
export interface CdpTransport {
|
|
86
|
+
send(text: string): void;
|
|
87
|
+
close(): void;
|
|
88
|
+
/** Installed exactly once, by the connection, before the first `send`. */
|
|
89
|
+
listen(handlers: {
|
|
90
|
+
readonly message: (text: string) => void;
|
|
91
|
+
readonly closed: (reason: string) => void;
|
|
92
|
+
}): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CdpConnectionOptions {
|
|
96
|
+
readonly endpoint: string;
|
|
97
|
+
/** Per-call deadline. A CDP call that never answers is a suite that never finishes. */
|
|
98
|
+
readonly timeoutMs: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What went wrong, in the socket's own words when it has any. Bun hands `onerror` an `ErrorEvent`
|
|
103
|
+
* whose `message` is the only place the reason lives — "Failed to connect", "Expected 101 status
|
|
104
|
+
* code" — and a refusal that drops it names the step and not the cause.
|
|
105
|
+
*/
|
|
106
|
+
const failure = (event: unknown, what: string): string => {
|
|
107
|
+
const message = stringField(event, 'message');
|
|
108
|
+
return message === undefined || message === '' ? what : `${what}: ${message}`;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A remote browser over its WebSocket url. NOT the e2e driver's wire: measured in the dummy's
|
|
113
|
+
* `offline-feed` suite on Bun 1.4.0, Bun's WebSocket client handed `onmessage` text spliced out of
|
|
114
|
+
* several frames — 64 unparseable frames in one run, one of them the reply to a `Runtime.evaluate`
|
|
115
|
+
* that then waited out its whole deadline. A launched Chrome is driven over `cdp-pipe.ts` instead.
|
|
116
|
+
*/
|
|
117
|
+
export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConnection> {
|
|
118
|
+
assert(
|
|
119
|
+
options.endpoint.startsWith('ws://') || options.endpoint.startsWith('wss://'),
|
|
120
|
+
`the CDP endpoint is ${options.endpoint === '' ? 'empty' : 'not a WebSocket url'}`,
|
|
121
|
+
'pass the `webSocketDebuggerUrl` Chrome prints on stderr, or the one /json/version answers',
|
|
122
|
+
);
|
|
123
|
+
const socket = new WebSocket(options.endpoint);
|
|
124
|
+
await new Promise<void>((resolve, reject) => {
|
|
125
|
+
// The socket is CLOSED on both failures: a handshake that timed out otherwise kept dialling
|
|
126
|
+
// in the background, holding a descriptor and a pending connection for a caller that gave up.
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
socket.close();
|
|
129
|
+
reject(new CdpTimeoutError({ method: 'connect', timeoutMs: options.timeoutMs }));
|
|
130
|
+
}, options.timeoutMs);
|
|
131
|
+
socket.onopen = (): void => {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolve();
|
|
134
|
+
};
|
|
135
|
+
// `onerror` is the handshake's only until `listen` below reassigns it: a failure BEFORE open
|
|
136
|
+
// has no pending call to abandon, and rejecting is the only way the caller hears about it.
|
|
137
|
+
socket.onerror = (event: Event): void => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
socket.close();
|
|
140
|
+
reject(
|
|
141
|
+
new CdpCallFailedError({
|
|
142
|
+
method: 'connect',
|
|
143
|
+
detail: failure(event, 'the browser refused the connection'),
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const transport: CdpTransport = {
|
|
150
|
+
send: (text) => socket.send(text),
|
|
151
|
+
close: () => socket.close(),
|
|
152
|
+
listen(handlers) {
|
|
153
|
+
socket.onmessage = (event: MessageEvent): void => {
|
|
154
|
+
handlers.message(typeof event.data === 'string' ? event.data : '');
|
|
155
|
+
};
|
|
156
|
+
socket.onclose = (): void => handlers.closed('the browser closed the CDP connection');
|
|
157
|
+
socket.onerror = (event: Event): void =>
|
|
158
|
+
handlers.closed(failure(event, 'the CDP connection failed'));
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return cdpConnectOver(transport, options.timeoutMs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A connection over any transport — correlation, deadlines and events, written once. */
|
|
165
|
+
export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpConnection {
|
|
166
|
+
const pending = new Map<number, Pending>();
|
|
167
|
+
const waiters = new Set<(method: string, sessionId: string | undefined) => void>();
|
|
168
|
+
const listeners = new Map<string, Set<CdpEventListener>>();
|
|
169
|
+
let nextId = 0;
|
|
170
|
+
let closed = false;
|
|
171
|
+
// What a call that is never answered reports instead of a bare "did not answer".
|
|
172
|
+
const watch = wireWatch();
|
|
173
|
+
|
|
174
|
+
// Every in-flight call is settled on close. Without this a suite whose browser died waits out
|
|
175
|
+
// one full deadline per call and reports a timeout, where the true fault is a dead browser.
|
|
176
|
+
const abandon = (reason: string): void => {
|
|
177
|
+
closed = true;
|
|
178
|
+
for (const [, one] of pending) {
|
|
179
|
+
clearTimeout(one.timer);
|
|
180
|
+
one.reject(new CdpCallFailedError({ method: 'the connection', detail: reason }));
|
|
181
|
+
}
|
|
182
|
+
pending.clear();
|
|
183
|
+
// A waiter is a "did this happen" question, and on a dead connection the answer is no. Its
|
|
184
|
+
// own timer settles it, so nothing is left hanging; clearing the set only stops a late frame
|
|
185
|
+
// from resolving a waiter whose connection has gone.
|
|
186
|
+
waiters.clear();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const message = (raw: string): void => {
|
|
190
|
+
let frame: Record<string, unknown>;
|
|
191
|
+
try {
|
|
192
|
+
frame = JSON.parse(raw) as Record<string, unknown>;
|
|
193
|
+
} catch {
|
|
194
|
+
// An unparseable frame is the browser's, not ours, and there is no call to fail with it:
|
|
195
|
+
// correlation is by `id`, and a frame we cannot read has none. COUNTED, though: the call
|
|
196
|
+
// whose reply it was waits out its deadline, and its timeout says frames were dropped.
|
|
197
|
+
watch.drop();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
watch.frame(frame);
|
|
201
|
+
const id = frame['id'];
|
|
202
|
+
if (typeof id !== 'number') {
|
|
203
|
+
const method = frame['method'];
|
|
204
|
+
if (typeof method !== 'string') return;
|
|
205
|
+
const on = typeof frame['sessionId'] === 'string' ? frame['sessionId'] : undefined;
|
|
206
|
+
for (const waiter of [...waiters]) waiter(method, on);
|
|
207
|
+
const subscribed = listeners.get(method);
|
|
208
|
+
if (subscribed !== undefined) {
|
|
209
|
+
const params = frame['params'];
|
|
210
|
+
const payload: Record<string, unknown> =
|
|
211
|
+
typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {};
|
|
212
|
+
// A copy, because a listener may unsubscribe itself while this loop is running.
|
|
213
|
+
for (const listener of [...subscribed]) listener(payload, on);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const one = pending.get(id);
|
|
218
|
+
if (one === undefined) return;
|
|
219
|
+
pending.delete(id);
|
|
220
|
+
clearTimeout(one.timer);
|
|
221
|
+
const failed = errorText(frame);
|
|
222
|
+
if (failed !== undefined) {
|
|
223
|
+
one.reject(new CdpCallFailedError({ method: `call ${String(id)}`, detail: failed }));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const result = frame['result'];
|
|
227
|
+
one.resolve({
|
|
228
|
+
result:
|
|
229
|
+
typeof result === 'object' && result !== null
|
|
230
|
+
? (result as Record<string, unknown>)
|
|
231
|
+
: undefined,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
transport.listen({ message, closed: abandon });
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
send(method, params = {}, sessionId, deadlineMs = timeoutMs): Promise<CdpResult> {
|
|
238
|
+
if (closed) {
|
|
239
|
+
return Promise.reject(
|
|
240
|
+
new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
nextId += 1;
|
|
244
|
+
const id = nextId;
|
|
245
|
+
return new Promise<CdpResult>((resolve, reject) => {
|
|
246
|
+
const sentAt = watch.mark(sessionId);
|
|
247
|
+
const timer = setTimeout(() => {
|
|
248
|
+
pending.delete(id);
|
|
249
|
+
const observed = watch.observe(sentAt, sessionId, !closed);
|
|
250
|
+
reject(new CdpTimeoutError({ method, timeoutMs: deadlineMs, observed }));
|
|
251
|
+
}, deadlineMs);
|
|
252
|
+
pending.set(id, { resolve, reject, timer });
|
|
253
|
+
transport.send(
|
|
254
|
+
JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
once(method, sessionId, timeoutMs): Promise<boolean> {
|
|
259
|
+
if (closed) return Promise.resolve(false);
|
|
260
|
+
return new Promise<boolean>((resolve) => {
|
|
261
|
+
const waiter = (seen: string, on: string | undefined): void => {
|
|
262
|
+
if (seen !== method) return;
|
|
263
|
+
if (sessionId !== undefined && on !== sessionId) return;
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
waiters.delete(waiter);
|
|
266
|
+
resolve(true);
|
|
267
|
+
};
|
|
268
|
+
const timer = setTimeout(() => {
|
|
269
|
+
waiters.delete(waiter);
|
|
270
|
+
resolve(false);
|
|
271
|
+
}, timeoutMs);
|
|
272
|
+
waiters.add(waiter);
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
on(method, listener): () => void {
|
|
276
|
+
const subscribed = listeners.get(method) ?? new Set();
|
|
277
|
+
subscribed.add(listener);
|
|
278
|
+
listeners.set(method, subscribed);
|
|
279
|
+
return () => {
|
|
280
|
+
subscribed.delete(listener);
|
|
281
|
+
if (subscribed.size === 0) listeners.delete(method);
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
close(): void {
|
|
285
|
+
listeners.clear();
|
|
286
|
+
abandon('the driver closed the CDP connection');
|
|
287
|
+
transport.close();
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The registry for the raw-CDP browser's four `X_CDP_*` codes — the launcher, the connection and
|
|
2
|
+
// the calls over it. Owned here since 31.0.0, when the launcher moved from `@ultimat3/testing` to
|
|
3
|
+
// its runtime home; `@ultimat3/testing` re-reads these titles and registers only its own `X_E2E_*`.
|
|
4
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
// Four and not one, because the four repairs differ: install a browser, read the browser's own
|
|
7
|
+
// stderr, look at the page, raise a deadline.
|
|
8
|
+
export const CDP_ERROR_CODES = [
|
|
9
|
+
'X_CDP_BROWSER_MISSING',
|
|
10
|
+
'X_CDP_LAUNCH_FAILED',
|
|
11
|
+
'X_CDP_CALL_FAILED',
|
|
12
|
+
'X_CDP_TIMEOUT',
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type CdpErrorCode = (typeof CDP_ERROR_CODES)[number];
|
|
16
|
+
|
|
17
|
+
export const CDP_ERROR_TITLES = Object.freeze<Record<CdpErrorCode, string>>({
|
|
18
|
+
X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
|
|
19
|
+
X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
|
|
20
|
+
X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
|
|
21
|
+
X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Owned here and borrowed by nobody, so unconditional: a second package claiming one must fail as
|
|
25
|
+
// X_ERROR_CODE_DUPLICATE rather than quietly keep whichever title registered first.
|
|
26
|
+
registerErrorCodes(
|
|
27
|
+
Object.fromEntries(Object.entries(CDP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
28
|
+
);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// One constructor per way the raw-CDP browser refuses — the e2e driver's, `x shot`'s and `pdf()`'s.
|
|
2
|
+
// Every cause quotes a value that came out of a BROWSER or off a spawned process's stderr, so
|
|
3
|
+
// every one is rendered rather than interpolated.
|
|
4
|
+
|
|
5
|
+
import { isFixShellSafe, renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
6
|
+
// Bare: the titles these constructors' codes render with are registered there.
|
|
7
|
+
import './cdp-error-codes';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* No browser to drive. This is the one an author meets first, so its fix names the two ways out:
|
|
11
|
+
* point the driver at a binary, or accept that this machine cannot run the check.
|
|
12
|
+
*/
|
|
13
|
+
export class CdpBrowserMissingError extends UltimateError {
|
|
14
|
+
constructor(input: { readonly tried: readonly string[] }) {
|
|
15
|
+
super({
|
|
16
|
+
code: 'X_CDP_BROWSER_MISSING',
|
|
17
|
+
cause: `no Chrome or Chromium executable was found — tried ${renderCauseValue(input.tried)}`,
|
|
18
|
+
fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome or Chromium binary (GitHub-hosted ubuntu runners ship this one); unset, a browser-backed e2e suite skips',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
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
|
+
*/
|
|
70
|
+
export class CdpLaunchFailedError extends UltimateError {
|
|
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"';
|
|
78
|
+
super({
|
|
79
|
+
code: 'X_CDP_LAUNCH_FAILED',
|
|
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 },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A CDP call answered with an error frame, or the connection died under it. */
|
|
88
|
+
export class CdpCallFailedError extends UltimateError {
|
|
89
|
+
constructor(input: { readonly method: string; readonly detail: string }) {
|
|
90
|
+
super({
|
|
91
|
+
code: 'X_CDP_CALL_FAILED',
|
|
92
|
+
cause: `the browser refused ${renderCauseValue(input.method)}: ${renderCauseValue(input.detail)}`,
|
|
93
|
+
fix: 'await page.evaluate(() => document.body.innerHTML) # what the page had; a call that refuses the same way means the browser itself is gone, so read the launch above it',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
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
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
|
|
147
|
+
* WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
|
|
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.
|
|
153
|
+
*/
|
|
154
|
+
export class CdpTimeoutError extends UltimateError {
|
|
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);
|
|
163
|
+
super({
|
|
164
|
+
code: 'X_CDP_TIMEOUT',
|
|
165
|
+
cause:
|
|
166
|
+
observed === undefined || reading === undefined
|
|
167
|
+
? base
|
|
168
|
+
: `${base} — ${READING[reading](observed)}`,
|
|
169
|
+
fix:
|
|
170
|
+
reading === undefined || reading === 'no-answer'
|
|
171
|
+
? 'installE2eDriver({ page, baseUrl, timeoutMs: 60_000 }) # a larger deadline — or find the request the page still waits 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 } }),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|