// One responsibility: one TAB over a raw CDP session — navigate, reload, evaluate, click, wait. The // session (`cdp-e2e-session.ts`) attaches it and owns everything browser-wide: the network // condition, the init scripts, the socket and request log. Launching is `cdp-launch.ts` and the // wire is `cdp-connection.ts`. // // FIVE methods, which is the whole reason this exists next to `@ultimat3/scraping` rather than // through it: `ScrapePage` is a full scraping surface whose intended implementation is // `puppeteer-core`, and an e2e driver needs none of it. import type { CdpConnection } from './cdp-connection'; import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors'; import type { E2eBrowserPage } from './e2e-page'; /** What the page threw, when `Runtime.evaluate` answered with an exception rather than a value. */ const thrownIn = (result: Record | undefined): string | undefined => { const thrown = result?.['exceptionDetails']; if (typeof thrown !== 'object' || thrown === null) return undefined; const text = (thrown as Record)['text']; return typeof text === 'string' ? text : 'the expression threw in the page'; }; /** `Runtime.evaluate`'s answer, unwrapped. Every field here is somebody else's JSON. */ const evaluated = (result: Record | undefined): unknown => { const threw = thrownIn(result); if (threw !== undefined) { throw new CdpCallFailedError({ method: 'Runtime.evaluate', detail: threw }); } const remote = result?.['result']; if (typeof remote !== 'object' || remote === null) return undefined; return (remote as Record)['value']; }; /** * What Chrome answers a `Runtime.evaluate` that was in flight when the document committed (#774): * the call is refused at the protocol level, not thrown in the page. The same first text is also * what a CLOSED tab answers, which is why `waitFor` asks for the target before it polls again. */ const NAVIGATED_AWAY = [ 'Inspected target navigated or closed', 'Execution context was destroyed', 'Cannot find context with specified id', ] as const; const navigatedAway = (error: unknown): error is CdpCallFailedError => error instanceof CdpCallFailedError && NAVIGATED_AWAY.some((text) => error.cause.includes(text)); /** One tab: the port the driver drives, plus what a multi-tab acceptance suite asks of one. */ export interface E2eTab extends E2eBrowserPage { readonly targetId: string; /** Browser-wide, like the switch it models: every tab AND every worker goes with it. */ offline(enabled: boolean): Promise; /** * THIS tab's scripting, off or back on (`Emulation.setScriptExecutionDisabled`): the page's own * scripts stop. What proves a page works with no JS — a link that only a script could follow is * a page that fails for every visitor whose JS did not load. Takes effect from the next document. * Off, `evaluate` still answers a SYNCHRONOUS expression and `click` still clicks, but no page * callback runs — so `goto`, which waits on the page's `load` listener, never settles: navigate * with `evaluate('location.href = …')` and poll with `waitFor`. */ scripting(enabled: boolean): Promise; /** * A REAL click: the pointer moved to the element's centre, pressed and released through * `Input.dispatchMouseEvent`, so the browser hit-tests it as it would a person's — and anything * painted over the element (a view transition's overlay, a modal) receives it instead. `click()` * dispatches straight at the element and can never see that. */ pointerClick(selector: string): Promise; /** Reload and wait for the load, at the url the tab already had. */ reload(): Promise; /** Poll `expression` in the page until it is truthy, or refuse naming `what`. */ waitFor(expression: string, what: string, timeoutMs?: number): Promise; /** The IndexedDB databases this tab's origin holds, by name, sorted. */ indexedDbNames(): Promise; close(): Promise; } export interface CdpE2eTabOptions { readonly connection: CdpConnection; /** The attached tab's flattened session — every page call carries it. */ readonly sessionId: string; readonly targetId: string; /** * How long a navigation's load event may take. Distinct from the connection's per-call deadline: * `Page.navigate` ANSWERS as soon as the navigation is committed, so the wait for the load event * is a second budget and is the one an app makes long. */ readonly loadTimeoutMs: number; /** The session's browser-wide switch, which `offline()` forwards to. */ readonly offline: (enabled: boolean) => Promise; } const POLL_MS = 100; /** A tab over a session the caller has already attached and enabled (`cdp-e2e-session.ts`). */ export function cdpE2eTab(options: CdpE2eTabOptions): E2eTab { const send = options.connection.send.bind(options.connection); const { sessionId } = options; // `url()` is SYNCHRONOUS on the port, and CDP has no synchronous read — so the last committed // url is tracked here. Seeded with the tab's own starting url rather than '' so a `reload()` // before any `goto()` navigates somewhere real. let current = 'about:blank'; const evaluateRaw = (expression: string) => send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId); const evaluate = async (expression: string): Promise => evaluated((await evaluateRaw(expression)).result); // A poll that THROWS is a poll that does not hold yet, not a refusal: a click that navigates // leaves the next poll reading a document whose `` is not parsed, and // `document.body.textContent` throws there once and holds a poll later. The last throw is kept // and named at the deadline, so an expression that can never evaluate still says why. // // A poll the browser REFUSED because the document committed under it is the same "not yet" // (#774) — but only while the tab is still there: a closed tab answers with the same text, and // still refuses at once. Any other refusal (a dead connection) refuses at once too. const tabOpen = (): Promise => send('Target.getTargetInfo', { targetId: options.targetId }).then( () => true, () => false, ); const poll = async (expression: string): Promise | 'navigated'> => { try { return (await evaluateRaw(`Boolean(${expression})`)).result ?? {}; } catch (error) { if (navigatedAway(error) && (await tabOpen())) return 'navigated'; throw error; } }; const waitFor = async (expression: string, what: string, timeoutMs = options.loadTimeoutMs) => { // Only the PAGE's throw is swallowed: a connection that died still refuses at once. let threw: string | undefined; for (let waited = 0; waited < timeoutMs; waited += POLL_MS) { const answer = await poll(expression); if (answer === 'navigated') { threw = 'the document committed while the poll was in flight'; await Bun.sleep(POLL_MS); continue; } threw = thrownIn(answer); if (threw === undefined && evaluated(answer) === true) return; await Bun.sleep(POLL_MS); } const last = threw === undefined ? '' : `, and its last poll threw: ${threw}`; throw new CdpTimeoutError({ method: `waitFor(${what})${last}`, timeoutMs }); }; const tab: E2eTab = { targetId: options.targetId, url: () => current, async goto(url: string): Promise { // **The load EVENT is the signal, not the reply.** Chrome drops `Page.navigate`'s own reply // whenever the navigation swaps the render process — measured on Chrome 150 against a local // server: the page loads, the server is hit, a later `Runtime.evaluate` answers `document // .title` from the new document, and the navigate frame never comes back at all. Awaiting // the reply therefore waited out the full deadline on the most ordinary navigation there is, // `about:blank` → `http://localhost:/`. So the waiter is registered BEFORE the send, // and the reply is raced against it rather than depended on. const loaded = options.connection.once( 'Page.loadEventFired', sessionId, options.loadTimeoutMs, ); // A dropped reply is expected, so its rejection is answered rather than thrown: what the // reply is still worth reading for is `errorText`, which is the ONLY place a refused // navigation is named — an unreachable host loads no page and fires no load event. const answered = send('Page.navigate', { url }, sessionId).then( (answer) => { const failed = answer.result?.['errorText']; return typeof failed === 'string' && failed !== '' ? failed : undefined; }, () => undefined, ); const failed = await Promise.race([loaded.then(() => undefined), answered]); if (failed !== undefined) { throw new CdpCallFailedError({ method: `Page.navigate to ${url}`, detail: failed }); } current = url; // The load event may already have fired before the waiter was registered on a same-process // navigation, and `answered` can win the race on one too. So the document is asked directly: // a `readyState` that is already `complete` resolves at once, and the deadline resolves // rather than throwing — a slow page is the app's business, and the assertion after this is // what should fail. HALF the budget, because the budget is also the connection's per-call // deadline: a page timer of the full budget raced that deadline and lost, reporting a page // that never fired `load` as "Runtime.evaluate did not answer" instead. await evaluate(`(() => new Promise((resolve) => { if (document.readyState === 'complete') { resolve(true); return; } const done = () => resolve(true); addEventListener('load', done, { once: true }); setTimeout(done, ${String(Math.floor(options.loadTimeoutMs / 2))}); }))()`); // The app may have redirected, so the committed url is re-read rather than assumed. const settled = await evaluate('location.href'); if (typeof settled === 'string' && settled !== '') current = settled; return undefined; }, evaluate, async click(selector: string): Promise { // In-page rather than a synthesised `Input.dispatchMouseEvent`: the port takes a SELECTOR, // and turning one into coordinates means a box model read, a scroll and a hit test — three // more CDP calls, each with its own way to be wrong about an element the page can click. const clicked = await evaluate( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`, ); if (clicked !== true) { throw new CdpCallFailedError({ method: `click(${selector})`, detail: 'no element in the page matches that selector', }); } }, async pointerClick(selector: string): Promise { const at = await evaluate( `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return null; const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })()`, ); if (!Array.isArray(at) || typeof at[0] !== 'number' || typeof at[1] !== 'number') { throw new CdpCallFailedError({ method: `pointerClick(${selector})`, detail: 'no element in the page matches that selector', }); } const [x, y] = at; for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased'] as const) { const pressed = type !== 'mouseMoved'; await send( 'Input.dispatchMouseEvent', { type, x, y, button: pressed ? 'left' : 'none', clickCount: pressed ? 1 : 0 }, sessionId, ); } }, offline: (enabled: boolean) => options.offline(enabled), async scripting(enabled: boolean): Promise { await send('Emulation.setScriptExecutionDisabled', { value: !enabled }, sessionId); }, async reload(): Promise { const at = current; // Reload is a navigation to the url the tab already has — the one wait the port proves. await tab.goto(at); }, waitFor, async indexedDbNames(): Promise { const names = await evaluate( '(async () => (await indexedDB.databases()).map((db) => db.name ?? "").sort())()', ); return Array.isArray(names) ? names.filter((name): name is string => typeof name === 'string') : []; }, async close(): Promise { await send('Target.closeTarget', { targetId: options.targetId }); }, }; return tab; }