From dcb79fc62bdd4d9ec0cbdb2c97d3eb2b2dacdf22 Mon Sep 17 00:00:00 2001 From: amankumarsingh77 Date: Tue, 28 Jul 2026 22:41:18 +0530 Subject: refactor: merge nine files whose reason to exist was gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/ goes from 84 files to 75 at the same line count — every merge below had exactly one consumer. cdp/event-queue.ts and cdp/pending-requests.ts become daemon/queue.ts. I split these in the daemon-internals commit on the grounds that they held two unrelated concerns, but the transport commit had already deleted the second transport, so both halves had one consumer and the split bought nothing. They also sat in cdp/ while only daemon/ used them. cdp/transport.ts held only the CdpTransport interface after the second transport went; five of its six consumers import it type-only, so it moves to cdp/types.ts. cdp/evaluate.ts moves into cdp/session.ts rather than into its lone consumer isolated-tab.ts — it reaches session.call directly, so under src/domains/ it would trip the raw-cdp-call rule. util/keycodes.ts and util/time.ts fold into their only callers (keyboard.ts, page.ts); util/bytes.ts into util/truncate.ts, which already owns output-size formatting; readpage/capture.ts and search/extract.ts into the tools beside them; profile/picker.ts into profile/command.ts. Kept deliberately: mutex.ts and domains/target.ts each have their own test suite, and folding target.ts into click.ts would make a tool file export a helper. sharp-shim.ts is an isolation boundary carrying two allowlisted casts. result.ts, guards.ts, js-template.ts and paths.ts are small but have 44, 12, 8 and 3 consumers. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ARCHITECTURE.md | 9 +-- src/cdp/evaluate.ts | 30 -------- src/cdp/event-queue.ts | 38 ---------- src/cdp/pending-requests.ts | 47 ------------- src/cdp/session.ts | 33 ++++++++- src/cdp/transport.ts | 18 ----- src/cdp/types.ts | 17 +++++ src/client.ts | 2 +- src/daemon/queue.ts | 85 +++++++++++++++++++++++ src/daemon/transport.ts | 5 +- src/domains/isolated-tab.ts | 2 +- src/domains/js.ts | 2 +- src/domains/keyboard.ts | 10 ++- src/domains/network.ts | 2 +- src/domains/page.ts | 18 ++++- src/domains/readpage/capture.ts | 34 --------- src/domains/readpage/read-page.ts | 36 +++++++++- src/domains/search/extract.ts | 28 -------- src/domains/search/web-search.ts | 30 +++++++- src/profile/command.ts | 50 +++++++++++++- src/profile/picker.ts | 50 -------------- src/util/bytes.ts | 5 -- src/util/keycodes.ts | 8 --- src/util/time.ts | 16 ----- src/util/truncate.ts | 6 ++ test/cdp/evaluate-test.ts | 97 -------------------------- test/cdp/event-queue-test.ts | 137 ------------------------------------- test/cdp/session-call-test.ts | 2 +- test/cdp/session-evaluate-test.ts | 97 ++++++++++++++++++++++++++ test/daemon/queue-test.ts | 136 ++++++++++++++++++++++++++++++++++++ test/domains/fake-client.ts | 2 +- test/manual/window-binding-test.ts | 2 +- 32 files changed, 523 insertions(+), 531 deletions(-) delete mode 100644 src/cdp/evaluate.ts delete mode 100644 src/cdp/event-queue.ts delete mode 100644 src/cdp/pending-requests.ts delete mode 100644 src/cdp/transport.ts create mode 100644 src/daemon/queue.ts delete mode 100644 src/domains/readpage/capture.ts delete mode 100644 src/domains/search/extract.ts delete mode 100644 src/profile/picker.ts delete mode 100644 src/util/bytes.ts delete mode 100644 src/util/keycodes.ts delete mode 100644 src/util/time.ts delete mode 100644 test/cdp/evaluate-test.ts delete mode 100644 test/cdp/event-queue-test.ts create mode 100644 test/cdp/session-evaluate-test.ts create mode 100644 test/daemon/queue-test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 450d680..92ea16f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,6 +36,7 @@ cdp/session.ts typed call/callOnTarget/callBrowser, event routing, console + network buffers | daemon/transport.ts -> daemon/* out-of-process daemon owning the Chrome socket + (CdpTransport is declared in cdp/types.ts) | Chrome (CDP over WebSocket) ``` @@ -46,7 +47,7 @@ Side stacks: |---|---|---| | `profile/*` | decides *which* Chrome and which profile to drive | `index.ts`, `setup.ts`, `client.ts` — never a domain | | `schemas/*` | shared typebox arg fragments (`Coords`, `MouseButton`) and `parseJson` | anything | -| `util/*` | leaves — they import nothing from this repo except each other (`result`, `guards`, `mutex`, `time`, `keycodes`, `truncate`, `paths`, `js-template`, `sharp-shim`) | anything | +| `util/*` | leaves — they import nothing from this repo except each other (`result`, `guards`, `mutex`, `truncate`, `paths`, `js-template`, `sharp-shim`, `debug`) | anything | `src/util/tool.ts` sits in `util/` for locality but is not a leaf — it is the tool runtime layer in the diagram above and imports `client` and `daemon/spawn`. @@ -54,10 +55,10 @@ in the diagram above and imports `client` and `daemon/spawn`. ### The import rule for domains A `domains/*` file may import from `schemas/`, `util/`, other `domains/`, the `BrowserClient` -type, and the *typed, session-level* parts of `cdp/` (`cdp/commands` types, `cdp/evaluate`, -`cdp/target-factory`, the buffers, the `CdpSession` type). +type, and the *typed, session-level* parts of `cdp/` (`cdp/commands` types, `cdp/target-factory`, +the buffers, and `cdp/session` for the `CdpSession` type and `evaluateJson`). -**It must never import from `daemon/`, `cdp/transport`, or `profile/`.** A domain that reaches +**It must never import from `daemon/` or `profile/`.** A domain that reaches the raw transport has escaped the validated boundary in section 2; a domain that reaches `profile/` has coupled a tool to Chrome-launch policy. diff --git a/src/cdp/evaluate.ts b/src/cdp/evaluate.ts deleted file mode 100644 index 733a11b..0000000 --- a/src/cdp/evaluate.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type Result, err, ok } from "../util/result"; -import { type CdpError, cdpError } from "./errors"; -import type { CdpSession } from "./session"; - -export const evaluateJson = async ( - session: CdpSession, - expression: string, - check: (v: unknown) => v is T, - opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined }, -): Promise> => { - const params = { - expression, - returnByValue: true, - awaitPromise: true, - }; - const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}; - const r = opts?.sessionId !== undefined - ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts) - : await session.call("Runtime.evaluate", params, callOpts); - if (!r.success) return r; - const { result, exceptionDetails } = r.data; - if (exceptionDetails !== undefined) { - return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate")); - } - const value = result.value; - if (!check(value)) { - return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate")); - } - return ok(value); -}; diff --git a/src/cdp/event-queue.ts b/src/cdp/event-queue.ts deleted file mode 100644 index 08024b0..0000000 --- a/src/cdp/event-queue.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CdpEvent } from "./types"; - -export type EventQueue = { - readonly push: (e: CdpEvent) => void; - readonly end: () => void; - readonly iter: AsyncIterable; -}; - -export const makeEventQueue = (): EventQueue => { - const buf: CdpEvent[] = []; - const waiters: Array<(v: IteratorResult) => void> = []; - let ended = false; - return { - push(e) { - if (ended) return; - const w = waiters.shift(); - if (w) w({ value: e, done: false }); - else buf.push(e); - }, - end() { - ended = true; - for (const w of waiters.splice(0)) w({ value: undefined, done: true }); - }, - iter: { - [Symbol.asyncIterator](): AsyncIterator { - return { - next: (): Promise> => - new Promise((resolve) => { - const next = buf.shift(); - if (next) resolve({ value: next, done: false }); - else if (ended) resolve({ value: undefined, done: true }); - else waiters.push(resolve); - }), - }; - }, - }, - }; -}; diff --git a/src/cdp/pending-requests.ts b/src/cdp/pending-requests.ts deleted file mode 100644 index 03b19cd..0000000 --- a/src/cdp/pending-requests.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type Result, err } from "../util/result"; -import { type CdpError, cdpError } from "./errors"; - -export type CdpResult = Result; - -export type Pending = { - readonly resolve: (v: Result) => void; - readonly timer: ReturnType; - readonly method: string; -}; - -export const rejectAllPending = (pending: Map, reason: string): void => { - for (const [, p] of pending) { - clearTimeout(p.timer); - p.resolve(err(cdpError("transport_closed", reason, p.method))); - } - pending.clear(); -}; - -export const sendWithTimeout = ( - pending: Map, - id: number, - method: string, - timeoutMs: number, - timeoutLabel: string, - send: () => void, -): Promise => - new Promise((resolve) => { - const timer = setTimeout(() => { - pending.delete(id); - resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method))); - }, timeoutMs); - pending.set(id, { resolve, timer, method }); - try { - send(); - } catch (e) { - clearTimeout(timer); - pending.delete(id); - resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method))); - } - }); - -export const makeOnClose = (closeListeners: Set<() => void>) => - (cb: () => void): (() => void) => { - closeListeners.add(cb); - return () => closeListeners.delete(cb); - }; diff --git a/src/cdp/session.ts b/src/cdp/session.ts index 0eb83fa..5adf7e0 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -1,8 +1,8 @@ -import { map, type Result, ok } from "../util/result"; -import type { CdpError } from "./errors"; +import { map, type Result, err, ok } from "../util/result"; +import { type CdpError, cdpError } from "./errors"; import type { DialogInfo } from "./types"; import type { OwnershipRegistry } from "./ownership"; -import type { CdpTransport } from "./transport"; +import type { CdpTransport } from "./types"; import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands"; import { decodeEvent } from "./events"; import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer"; @@ -374,3 +374,30 @@ export const createCdpSession = ( }; return session; }; + +export const evaluateJson = async ( + session: CdpSession, + expression: string, + check: (v: unknown) => v is T, + opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined }, +): Promise> => { + const params = { + expression, + returnByValue: true, + awaitPromise: true, + }; + const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}; + const r = opts?.sessionId !== undefined + ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts) + : await session.call("Runtime.evaluate", params, callOpts); + if (!r.success) return r; + const { result, exceptionDetails } = r.data; + if (exceptionDetails !== undefined) { + return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate")); + } + const value = result.value; + if (!check(value)) { + return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate")); + } + return ok(value); +}; diff --git a/src/cdp/transport.ts b/src/cdp/transport.ts deleted file mode 100644 index 4036ff7..0000000 --- a/src/cdp/transport.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Result } from "../util/result"; -import type { CdpError } from "./errors"; -import type { CdpEvent } from "./types"; - -export const DEFAULT_TIMEOUT_MS = 15_000; - -export type CdpTransport = { - connect(url: string, opts?: { timeoutMs?: number }): Promise>; - close(): Promise; - request( - method: string, - params: Record, - opts?: { sessionId?: string | null; timeoutMs?: number }, - ): Promise>; - events(): AsyncIterable; - state(): "open" | "closed" | "connecting"; - onClose(cb: () => void): () => void; -}; diff --git a/src/cdp/types.ts b/src/cdp/types.ts index 984b930..6119b69 100644 --- a/src/cdp/types.ts +++ b/src/cdp/types.ts @@ -1,4 +1,6 @@ +import type { Result } from "../util/result"; import { isRecord } from "../util/guards"; +import type { CdpError } from "./errors"; export type CdpEvent = { readonly method: string; @@ -63,3 +65,18 @@ export const isCdpRawMessage = (v: unknown): v is CdpRawMessage => { } return true; }; + +export const DEFAULT_TIMEOUT_MS = 15_000; + +export type CdpTransport = { + connect(url: string, opts?: { timeoutMs?: number }): Promise>; + close(): Promise; + request( + method: string, + params: Record, + opts?: { sessionId?: string | null; timeoutMs?: number }, + ): Promise>; + events(): AsyncIterable; + state(): "open" | "closed" | "connecting"; + onClose(cb: () => void): () => void; +}; diff --git a/src/client.ts b/src/client.ts index f1d2cb7..6a5419a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,7 +6,7 @@ import { safeJs } from "./util/js-template"; import { type Mutex, createMutex } from "./util/mutex"; import { discoverEndpoint } from "./cdp/discovery"; import { type CdpError, cdpError } from "./cdp/errors"; -import type { CdpTransport } from "./cdp/transport"; +import type { CdpTransport } from "./cdp/types"; import { type CdpSession, createCdpSession } from "./cdp/session"; import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership"; import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory"; diff --git a/src/daemon/queue.ts b/src/daemon/queue.ts new file mode 100644 index 0000000..a19efd0 --- /dev/null +++ b/src/daemon/queue.ts @@ -0,0 +1,85 @@ +import { type Result, err } from "../util/result"; +import { type CdpError, cdpError } from "../cdp/errors"; +import type { CdpEvent } from "../cdp/types"; + +export type EventQueue = { + readonly push: (e: CdpEvent) => void; + readonly end: () => void; + readonly iter: AsyncIterable; +}; + +export const makeEventQueue = (): EventQueue => { + const buf: CdpEvent[] = []; + const waiters: Array<(v: IteratorResult) => void> = []; + let ended = false; + return { + push(e) { + if (ended) return; + const w = waiters.shift(); + if (w) w({ value: e, done: false }); + else buf.push(e); + }, + end() { + ended = true; + for (const w of waiters.splice(0)) w({ value: undefined, done: true }); + }, + iter: { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => + new Promise((resolve) => { + const next = buf.shift(); + if (next) resolve({ value: next, done: false }); + else if (ended) resolve({ value: undefined, done: true }); + else waiters.push(resolve); + }), + }; + }, + }, + }; +}; + +export type CdpResult = Result; + +export type Pending = { + readonly resolve: (v: Result) => void; + readonly timer: ReturnType; + readonly method: string; +}; + +export const rejectAllPending = (pending: Map, reason: string): void => { + for (const [, p] of pending) { + clearTimeout(p.timer); + p.resolve(err(cdpError("transport_closed", reason, p.method))); + } + pending.clear(); +}; + +export const sendWithTimeout = ( + pending: Map, + id: number, + method: string, + timeoutMs: number, + timeoutLabel: string, + send: () => void, +): Promise => + new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(id); + resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method))); + }, timeoutMs); + pending.set(id, { resolve, timer, method }); + try { + send(); + } catch (e) { + clearTimeout(timer); + pending.delete(id); + resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method))); + } + }); + +export const makeOnClose = (closeListeners: Set<() => void>) => + (cb: () => void): (() => void) => { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + }; diff --git a/src/daemon/transport.ts b/src/daemon/transport.ts index 4e75215..3c42336 100644 --- a/src/daemon/transport.ts +++ b/src/daemon/transport.ts @@ -2,10 +2,9 @@ import { createConnection, type Socket } from "node:net"; import { createInterface } from "node:readline"; import { type Result, err, ok } from "../util/result"; import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors"; -import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/transport"; +import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/types"; import type { CdpEvent } from "../cdp/types"; -import { makeEventQueue } from "../cdp/event-queue"; -import { type Pending, makeOnClose, rejectAllPending, sendWithTimeout } from "../cdp/pending-requests"; +import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./queue"; import { DAEMON_SOCKET_PATH, type WireRequest, diff --git a/src/domains/isolated-tab.ts b/src/domains/isolated-tab.ts index 103c9cc..b76b101 100644 --- a/src/domains/isolated-tab.ts +++ b/src/domains/isolated-tab.ts @@ -2,7 +2,7 @@ import type { BrowserClient } from "../client"; import { type Result, err, ok } from "../util/result"; import { safeJs } from "../util/js-template"; import { ensureHarnessWindow, openHarnessTab } from "../cdp/target-factory"; -import { evaluateJson } from "../cdp/evaluate"; +import { evaluateJson } from "../cdp/session"; import type { ToolErr } from "../util/tool"; import { cdpCallBrowser, cdpCallOnTarget, cdpErrToToolErr, evalJs } from "./cdp-call"; diff --git a/src/domains/js.ts b/src/domains/js.ts index d2588d0..6ba21cd 100644 --- a/src/domains/js.ts +++ b/src/domains/js.ts @@ -6,7 +6,7 @@ import { Type } from "typebox"; import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; -import { formatBytes } from "../util/bytes"; +import { formatBytes } from "../util/truncate"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { evalJs } from "./cdp-call"; import { applyTruncation } from "../util/truncate"; diff --git a/src/domains/keyboard.ts b/src/domains/keyboard.ts index 50bee62..17b56e7 100644 --- a/src/domains/keyboard.ts +++ b/src/domains/keyboard.ts @@ -1,11 +1,19 @@ import { Type } from "typebox"; import { safeJs } from "../util/js-template"; -import { virtualKeyCode } from "../util/keycodes"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { cdpCall, evalJs } from "./cdp-call"; import { resolveRefToObjectId } from "./ref-resolve"; +const VIRTUAL_KEY_CODES: Readonly> = { + Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32, + ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, + Home: 36, End: 35, PageUp: 33, PageDown: 34, +}; + +const virtualKeyCode = (key: string): number => + VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0); + const TypeArgs = Type.Object({ text: Type.String({ description: "Text to type" }), }); diff --git a/src/domains/network.ts b/src/domains/network.ts index aeb1aa6..a34dde3 100644 --- a/src/domains/network.ts +++ b/src/domains/network.ts @@ -2,7 +2,7 @@ import { Type } from "typebox"; import { Markdown, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent"; import { type Result, err, ok } from "../util/result"; -import { formatBytes } from "../util/bytes"; +import { formatBytes } from "../util/truncate"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { cdpCall } from "./cdp-call"; import { applyTruncation } from "../util/truncate"; diff --git a/src/domains/page.ts b/src/domains/page.ts index c202ef1..909963f 100644 --- a/src/domains/page.ts +++ b/src/domains/page.ts @@ -2,9 +2,25 @@ import { Type } from "typebox"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { evalJs } from "./cdp-call"; -import { sleep } from "../util/time"; import { safeJs } from "../util/js-template"; +const sleep = (ms: number, signal?: AbortSignal): Promise => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + const onAbort = (): void => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("aborted")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + export const pageInfoTool = defineBrowserTool({ name: "browser_page_info", label: "Browser Page Info", diff --git a/src/domains/readpage/capture.ts b/src/domains/readpage/capture.ts deleted file mode 100644 index b0b7ae0..0000000 --- a/src/domains/readpage/capture.ts +++ /dev/null @@ -1,34 +0,0 @@ -const BODY_TEXT_LIMIT = 20_000; - -export const buildPageCaptureExpr = (): string => ` - (() => { - const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']); - const inBoilerplate = (el) => { - for (let n = el; n; n = n.parentElement) { - if (BOILERPLATE.has(n.tagName)) return true; - const role = n.getAttribute && n.getAttribute('role'); - if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true; - } - return false; - }; - const kindOf = (tag) => { - if (/^H[1-6]$/.test(tag)) return 'heading'; - if (tag === 'LI') return 'listitem'; - if (tag === 'BLOCKQUOTE') return 'blockquote'; - if (tag === 'P') return 'paragraph'; - return 'other'; - }; - const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote'; - const root = document.querySelector('article, main, [role=main]') || document.body; - const blocks = []; - for (const el of root.querySelectorAll(selector)) { - const text = (el.innerText || '').replace(/\\s+/g, ' ').trim(); - if (!text) continue; - let linkTextLength = 0; - for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length; - blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) }); - } - const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT}); - return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText }); - })() -`; diff --git a/src/domains/readpage/read-page.ts b/src/domains/readpage/read-page.ts index 9d46c7e..fadee38 100644 --- a/src/domains/readpage/read-page.ts +++ b/src/domains/readpage/read-page.ts @@ -15,7 +15,6 @@ import { waitForIsolatedLoad, } from "../isolated-tab"; import { renderExpandableText } from "../render"; -import { buildPageCaptureExpr } from "./capture"; import { extractReadable, type PageCapture, type ReadablePage } from "./readability"; const LOAD_TIMEOUT_MS = 15_000; @@ -49,6 +48,41 @@ const PageCaptureSchema = Type.Object({ const pageCaptureValidator = Compile(PageCaptureSchema); +const BODY_TEXT_LIMIT = 20_000; + +const buildPageCaptureExpr = (): string => ` + (() => { + const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']); + const inBoilerplate = (el) => { + for (let n = el; n; n = n.parentElement) { + if (BOILERPLATE.has(n.tagName)) return true; + const role = n.getAttribute && n.getAttribute('role'); + if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true; + } + return false; + }; + const kindOf = (tag) => { + if (/^H[1-6]$/.test(tag)) return 'heading'; + if (tag === 'LI') return 'listitem'; + if (tag === 'BLOCKQUOTE') return 'blockquote'; + if (tag === 'P') return 'paragraph'; + return 'other'; + }; + const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote'; + const root = document.querySelector('article, main, [role=main]') || document.body; + const blocks = []; + for (const el of root.querySelectorAll(selector)) { + const text = (el.innerText || '').replace(/\\s+/g, ' ').trim(); + if (!text) continue; + let linkTextLength = 0; + for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length; + blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) }); + } + const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT}); + return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText }); + })() +`; + const toPageCapture = (raw: unknown): PageCapture | undefined => { if (typeof raw === "string") { const parsed = parseJson(raw, pageCaptureValidator); diff --git a/src/domains/search/extract.ts b/src/domains/search/extract.ts deleted file mode 100644 index 4d9e892..0000000 --- a/src/domains/search/extract.ts +++ /dev/null @@ -1,28 +0,0 @@ -const PAGE_TEXT_LIMIT = 4000; - -export const buildSerpExtractionExpr = (): string => ` - (() => { - const main = document.querySelector('#search, #rso, #main') || document.body; - const headings = Array.from(main.querySelectorAll('a h3')); - const seen = new Set(); - const anchors = []; - for (const h3 of headings) { - const a = h3.closest('a[href]'); - if (!a) continue; - const href = a.href; - if (!href || seen.has(href)) continue; - seen.add(href); - const container = - a.closest('div.g') || - a.closest('div[data-hveid]') || - a.parentElement?.parentElement || - a.parentElement; - const containerText = container ? container.innerText || '' : ''; - const heading = h3.innerText || h3.textContent || ''; - const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500); - anchors.push({ href, heading, snippet }); - } - const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT}); - return JSON.stringify({ anchors, pageText }); - })() -`; diff --git a/src/domains/search/web-search.ts b/src/domains/search/web-search.ts index 9327f15..553135c 100644 --- a/src/domains/search/web-search.ts +++ b/src/domains/search/web-search.ts @@ -13,7 +13,6 @@ import { waitForIsolatedLoad, } from "../isolated-tab"; import { renderExpandableText } from "../render"; -import { buildSerpExtractionExpr } from "./extract"; import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp"; const LOAD_TIMEOUT_MS = 15_000; @@ -25,6 +24,35 @@ const WebSearchArgs = Type.Object({ ), }); +const PAGE_TEXT_LIMIT = 4000; + +const buildSerpExtractionExpr = (): string => ` + (() => { + const main = document.querySelector('#search, #rso, #main') || document.body; + const headings = Array.from(main.querySelectorAll('a h3')); + const seen = new Set(); + const anchors = []; + for (const h3 of headings) { + const a = h3.closest('a[href]'); + if (!a) continue; + const href = a.href; + if (!href || seen.has(href)) continue; + seen.add(href); + const container = + a.closest('div.g') || + a.closest('div[data-hveid]') || + a.parentElement?.parentElement || + a.parentElement; + const containerText = container ? container.innerText || '' : ''; + const heading = h3.innerText || h3.textContent || ''; + const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500); + anchors.push({ href, heading, snippet }); + } + const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT}); + return JSON.stringify({ anchors, pageText }); + })() +`; + const buildSerpUrl = (query: string, limit: number): string => `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`; diff --git a/src/profile/command.ts b/src/profile/command.ts index 0703ae8..270b42f 100644 --- a/src/profile/command.ts +++ b/src/profile/command.ts @@ -4,8 +4,54 @@ import { discoverEndpoint } from "../cdp/discovery"; import { type Result, err, ok } from "../util/result"; import { type BrowserProfile, listProfiles } from "./list"; import { browserNameForUserDataDir, userDataDirCandidates } from "./paths"; -import { pinFor, promptForProfile } from "./picker"; -import { clearPin, readPin, writePin } from "./store"; +import { clearPin, type ProfilePin, readPin, writePin } from "./store"; + +// `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing". + +const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —"; + +type ProfileChoice = + | { readonly kind: "profile"; readonly profile: BrowserProfile } + | { readonly kind: "clear" } + | { readonly kind: "cancelled" }; + +const disambiguate = (profiles: ReadonlyArray): ReadonlyArray => { + const counts = new Map(); + for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1); + return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label)); +}; + +const promptForProfile = async ( + ctx: ExtensionContext, + profiles: ReadonlyArray, + currentPin: ProfilePin | null, + title = "Select browser profile", +): Promise => { + if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" }; + + const labels = disambiguate(profiles); + const rows = labels.map((label, i) => { + const isPinned = currentPin?.profileDir === profiles[i]?.dir; + return isPinned ? `✓ ${label}` : ` ${label}`; + }); + const options = [...rows, CLEAR_SELECTION_LABEL]; + + const answer = await ctx.ui.select(title, options); + if (answer === undefined) return { kind: "cancelled" }; + if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" }; + + const index = options.indexOf(answer); + const profile = index >= 0 ? profiles[index] : undefined; + if (!profile) return { kind: "cancelled" }; + return { kind: "profile", profile }; +}; + +const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({ + userDataDir, + profileDir: profile.dir, + label: profile.label, + savedAt: new Date().toISOString(), +}); const resolveUserDataDir = async (client: BrowserClient): Promise> => { const connected = client.userDataDir(); diff --git a/src/profile/picker.ts b/src/profile/picker.ts deleted file mode 100644 index ede43a6..0000000 --- a/src/profile/picker.ts +++ /dev/null @@ -1,50 +0,0 @@ -// `ctx.ui.select` resolves to undefined both on Escape and in non-interactive modes, so "no answer" must mean "change nothing". - -import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; -import type { BrowserProfile } from "./list"; -import type { ProfilePin } from "./store"; - -const CLEAR_SELECTION_LABEL = "— Clear selection (use whichever window is focused) —"; - -export type ProfileChoice = - | { readonly kind: "profile"; readonly profile: BrowserProfile } - | { readonly kind: "clear" } - | { readonly kind: "cancelled" }; - -const disambiguate = (profiles: ReadonlyArray): ReadonlyArray => { - const counts = new Map(); - for (const p of profiles) counts.set(p.label, (counts.get(p.label) ?? 0) + 1); - return profiles.map((p) => ((counts.get(p.label) ?? 0) > 1 ? `${p.label} [${p.dir}]` : p.label)); -}; - -export const promptForProfile = async ( - ctx: ExtensionContext, - profiles: ReadonlyArray, - currentPin: ProfilePin | null, - title = "Select browser profile", -): Promise => { - if (!ctx.hasUI || profiles.length === 0) return { kind: "cancelled" }; - - const labels = disambiguate(profiles); - const rows = labels.map((label, i) => { - const isPinned = currentPin?.profileDir === profiles[i]?.dir; - return isPinned ? `✓ ${label}` : ` ${label}`; - }); - const options = [...rows, CLEAR_SELECTION_LABEL]; - - const answer = await ctx.ui.select(title, options); - if (answer === undefined) return { kind: "cancelled" }; - if (answer === CLEAR_SELECTION_LABEL) return { kind: "clear" }; - - const index = options.indexOf(answer); - const profile = index >= 0 ? profiles[index] : undefined; - if (!profile) return { kind: "cancelled" }; - return { kind: "profile", profile }; -}; - -export const pinFor = (userDataDir: string, profile: BrowserProfile): ProfilePin => ({ - userDataDir, - profileDir: profile.dir, - label: profile.label, - savedAt: new Date().toISOString(), -}); diff --git a/src/util/bytes.ts b/src/util/bytes.ts deleted file mode 100644 index 43ad49c..0000000 --- a/src/util/bytes.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const formatBytes = (n: number): string => { - if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`; - if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${n} B`; -}; diff --git a/src/util/keycodes.ts b/src/util/keycodes.ts deleted file mode 100644 index 68be059..0000000 --- a/src/util/keycodes.ts +++ /dev/null @@ -1,8 +0,0 @@ -const VIRTUAL_KEY_CODES: Readonly> = { - Enter: 13, Tab: 9, Backspace: 8, Escape: 27, Delete: 46, " ": 32, - ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, - Home: 36, End: 35, PageUp: 33, PageDown: 34, -}; - -export const virtualKeyCode = (key: string): number => - VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0); diff --git a/src/util/time.ts b/src/util/time.ts deleted file mode 100644 index ae751f8..0000000 --- a/src/util/time.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const sleep = (ms: number, signal?: AbortSignal): Promise => - new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(signal.reason ?? new Error("aborted")); - return; - } - const onAbort = (): void => { - clearTimeout(timer); - reject(signal?.reason ?? new Error("aborted")); - }; - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal?.addEventListener("abort", onAbort, { once: true }); - }); diff --git a/src/util/truncate.ts b/src/util/truncate.ts index 2bf0600..7a1fa02 100644 --- a/src/util/truncate.ts +++ b/src/util/truncate.ts @@ -33,3 +33,9 @@ export const applyTruncation = async (output: string, prefix: string): Promise { + if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`; + if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${n} B`; +}; diff --git a/test/cdp/evaluate-test.ts b/test/cdp/evaluate-test.ts deleted file mode 100644 index cffd513..0000000 --- a/test/cdp/evaluate-test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { evaluateJson } from "../../src/cdp/evaluate"; -import type { CdpSession } from "../../src/cdp/session"; -import { err, ok } from "../../src/util/result"; - -const isNumberArray = (v: unknown): v is ReadonlyArray => - Array.isArray(v) && v.every((x) => typeof x === "number"); - -const fakeSession = (payload: unknown): CdpSession => - ({ - call: async () => ok(payload), - }) as unknown as CdpSession; - -type Call = { method: string; args: unknown[] }; - -const recordingSession = (result: unknown): { session: CdpSession; calls: Call[] } => { - const calls: Call[] = []; - const session = { - call: async (...args: unknown[]) => { - calls.push({ method: "call", args }); - return result; - }, - callOnTarget: async (...args: unknown[]) => { - calls.push({ method: "callOnTarget", args }); - return result; - }, - } as unknown as CdpSession; - return { session, calls }; -}; - -describe("evaluateJson", () => { - test("unwraps result.value and applies the guard", async () => { - const s = fakeSession({ result: { type: "object", value: [1, 2, 3] } }); - const r = await evaluateJson(s, "[1,2,3]", isNumberArray); - assert.equal(r.success, true); - if (r.success) assert.deepEqual(r.data, [1, 2, 3]); - }); - - test("surfaces a page exception as an error", async () => { - const s = fakeSession({ - result: { type: "undefined" }, - exceptionDetails: { text: "ReferenceError: x is not defined" }, - }); - const r = await evaluateJson(s, "x", isNumberArray); - assert.equal(r.success, false); - if (!r.success) { - assert.equal(r.error.kind, "remote_error"); - assert.match(r.error.message, /ReferenceError/); - } - }); - - test("rejects a value that fails the guard", async () => { - const s = fakeSession({ result: { type: "string", value: "nope" } }); - const r = await evaluateJson(s, "'nope'", isNumberArray); - assert.equal(r.success, false); - if (!r.success) assert.equal(r.error.kind, "invalid_response"); - }); - - test("dispatches via callOnTarget when opts.sessionId is set", async () => { - const { session, calls } = recordingSession(ok({ result: { type: "object", value: [4, 5] } })); - const r = await evaluateJson(session, "[4,5]", isNumberArray, { sessionId: "sess-1" }); - assert.equal(r.success, true); - if (r.success) assert.deepEqual(r.data, [4, 5]); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.method, "callOnTarget"); - const [, , sessionIdArg] = calls[0]?.args ?? []; - assert.equal(sessionIdArg, "sess-1"); - }); - - test("dispatches via call when no opts are given", async () => { - const { session, calls } = recordingSession(ok({ result: { type: "object", value: [1] } })); - const r = await evaluateJson(session, "[1]", isNumberArray); - assert.equal(r.success, true); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.method, "call"); - assert.equal(calls.some((c) => c.method === "callOnTarget"), false); - }); - - test("passes timeoutMs through to the transport options", async () => { - const { session, calls } = recordingSession(ok({ result: { type: "object", value: [1] } })); - await evaluateJson(session, "[1]", isNumberArray, { timeoutMs: 5000 }); - const [, , optsArg] = calls[0]?.args ?? []; - assert.deepEqual(optsArg, { timeoutMs: 5000 }); - }); - - test("passes through a transport failure unchanged", async () => { - const failure = err({ kind: "timeout" as const, message: "timed out" }); - const { session } = recordingSession(failure); - const r = await evaluateJson(session, "[1]", isNumberArray); - assert.equal(r.success, false); - if (!r.success) { - assert.equal(r.error.kind, "timeout"); - assert.equal(r.error.message, "timed out"); - } - }); -}); diff --git a/test/cdp/event-queue-test.ts b/test/cdp/event-queue-test.ts deleted file mode 100644 index 771ce8e..0000000 --- a/test/cdp/event-queue-test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { makeEventQueue } from "../../src/cdp/event-queue"; -import { rejectAllPending, sendWithTimeout, type Pending } from "../../src/cdp/pending-requests"; - -describe("event queue iteration", () => { - test("yields pushed events in order", async () => { - const q = makeEventQueue(); - q.push({ method: "Page.loadEventFired", params: {} }); - q.push({ method: "Page.frameNavigated", params: {} }); - const seen: string[] = []; - for await (const ev of q.iter) { - seen.push(ev.method); - if (seen.length === 2) break; - } - assert.deepEqual(seen, ["Page.loadEventFired", "Page.frameNavigated"]); - }); - - test("ending the queue terminates the iteration after draining the buffer", async () => { - const q = makeEventQueue(); - q.push({ method: "Page.loadEventFired", params: {} }); - q.end(); - const seen: string[] = []; - for await (const ev of q.iter) seen.push(ev.method); - assert.deepEqual(seen, ["Page.loadEventFired"]); - }); - - test("a waiter pending at end() is released", async () => { - const q = makeEventQueue(); - const done = (async () => { - const seen: string[] = []; - for await (const ev of q.iter) seen.push(ev.method); - return seen; - })(); - await Promise.resolve(); - q.end(); - assert.deepEqual(await done, []); - }); - - test("push after end() is dropped", async () => { - const q = makeEventQueue(); - q.end(); - q.push({ method: "Page.loadEventFired", params: {} }); - const seen: string[] = []; - for await (const ev of q.iter) seen.push(ev.method); - assert.deepEqual(seen, []); - }); - - test("a waiter is handed the next pushed event directly", async () => { - const q = makeEventQueue(); - const it = q.iter[Symbol.asyncIterator](); - const pending = it.next(); - await Promise.resolve(); - q.push({ method: "Runtime.consoleAPICalled", params: { a: 1 } }); - const r = await pending; - assert.equal(r.done, false); - assert.deepEqual(r.value, { method: "Runtime.consoleAPICalled", params: { a: 1 } }); - }); - - test("the terminal result reports done with an undefined value", async () => { - const q = makeEventQueue(); - q.end(); - const it = q.iter[Symbol.asyncIterator](); - const r = await it.next(); - assert.equal(r.done, true); - assert.equal(r.value, undefined); - }); - - test("sessionId is preserved on yielded events", async () => { - const q = makeEventQueue(); - q.push({ method: "Page.loadEventFired", params: {}, sessionId: "s1" }); - q.end(); - const seen: Array = []; - for await (const ev of q.iter) seen.push(ev.sessionId); - assert.deepEqual(seen, ["s1"]); - }); -}); - -describe("pending request bookkeeping", () => { - test("rejectAllPending settles every entry with transport_closed and clears the map", () => { - const pending = new Map(); - const settled: string[] = []; - for (const id of [1, 2]) { - pending.set(id, { - resolve: (r) => settled.push(r.success ? "ok" : r.error.kind), - timer: setTimeout(() => {}, 10_000), - method: `M.m${id}`, - }); - } - rejectAllPending(pending, "gone"); - assert.deepEqual(settled, ["transport_closed", "transport_closed"]); - assert.equal(pending.size, 0); - }); - - test("sendWithTimeout resolves transport_closed when send throws synchronously", async () => { - const pending = new Map(); - const r = await sendWithTimeout(pending, 7, "M.m", 10_000, "Test", () => { - throw new Error("socket gone"); - }); - assert.equal(r.success, false); - if (!r.success) { - assert.equal(r.error.kind, "transport_closed"); - assert.equal(r.error.message, "socket gone"); - assert.equal(r.error.method, "M.m"); - } - assert.equal(pending.size, 0); - }); - - test("sendWithTimeout registers a pending entry the caller can resolve", async () => { - const pending = new Map(); - let sent = false; - const p = sendWithTimeout(pending, 9, "M.m", 10_000, "Test", () => { - sent = true; - }); - assert.equal(sent, true); - const entry = pending.get(9); - assert.notEqual(entry, undefined); - if (entry) { - clearTimeout(entry.timer); - entry.resolve({ success: true, data: { hello: "world" } }); - } - const r = await p; - assert.equal(r.success, true); - if (r.success) assert.deepEqual(r.data, { hello: "world" }); - }); - - test("sendWithTimeout resolves a timeout error and drops the pending entry", async () => { - const pending = new Map(); - const r = await sendWithTimeout(pending, 11, "M.slow", 1, "Test", () => {}); - assert.equal(r.success, false); - if (!r.success) { - assert.equal(r.error.kind, "timeout"); - assert.match(r.error.message, /Test timeout after 1ms: M\.slow/); - } - assert.equal(pending.size, 0); - }); -}); diff --git a/test/cdp/session-call-test.ts b/test/cdp/session-call-test.ts index 6a14e17..ef9a725 100644 --- a/test/cdp/session-call-test.ts +++ b/test/cdp/session-call-test.ts @@ -2,7 +2,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { decodeResult } from "../../src/cdp/commands"; import { createCdpSession } from "../../src/cdp/session"; -import type { CdpTransport } from "../../src/cdp/transport"; +import type { CdpTransport } from "../../src/cdp/types"; import type { CdpEvent } from "../../src/cdp/types"; import { type Result, ok, err } from "../../src/util/result"; import { type CdpError, cdpError } from "../../src/cdp/errors"; diff --git a/test/cdp/session-evaluate-test.ts b/test/cdp/session-evaluate-test.ts new file mode 100644 index 0000000..c03a4cb --- /dev/null +++ b/test/cdp/session-evaluate-test.ts @@ -0,0 +1,97 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { evaluateJson } from "../../src/cdp/session"; +import type { CdpSession } from "../../src/cdp/session"; +import { err, ok } from "../../src/util/result"; + +const isNumberArray = (v: unknown): v is ReadonlyArray => + Array.isArray(v) && v.every((x) => typeof x === "number"); + +const fakeSession = (payload: unknown): CdpSession => + ({ + call: async () => ok(payload), + }) as unknown as CdpSession; + +type Call = { method: string; args: unknown[] }; + +const recordingSession = (result: unknown): { session: CdpSession; calls: Call[] } => { + const calls: Call[] = []; + const session = { + call: async (...args: unknown[]) => { + calls.push({ method: "call", args }); + return result; + }, + callOnTarget: async (...args: unknown[]) => { + calls.push({ method: "callOnTarget", args }); + return result; + }, + } as unknown as CdpSession; + return { session, calls }; +}; + +describe("evaluateJson", () => { + test("unwraps result.value and applies the guard", async () => { + const s = fakeSession({ result: { type: "object", value: [1, 2, 3] } }); + const r = await evaluateJson(s, "[1,2,3]", isNumberArray); + assert.equal(r.success, true); + if (r.success) assert.deepEqual(r.data, [1, 2, 3]); + }); + + test("surfaces a page exception as an error", async () => { + const s = fakeSession({ + result: { type: "undefined" }, + exceptionDetails: { text: "ReferenceError: x is not defined" }, + }); + const r = await evaluateJson(s, "x", isNumberArray); + assert.equal(r.success, false); + if (!r.success) { + assert.equal(r.error.kind, "remote_error"); + assert.match(r.error.message, /ReferenceError/); + } + }); + + test("rejects a value that fails the guard", async () => { + const s = fakeSession({ result: { type: "string", value: "nope" } }); + const r = await evaluateJson(s, "'nope'", isNumberArray); + assert.equal(r.success, false); + if (!r.success) assert.equal(r.error.kind, "invalid_response"); + }); + + test("dispatches via callOnTarget when opts.sessionId is set", async () => { + const { session, calls } = recordingSession(ok({ result: { type: "object", value: [4, 5] } })); + const r = await evaluateJson(session, "[4,5]", isNumberArray, { sessionId: "sess-1" }); + assert.equal(r.success, true); + if (r.success) assert.deepEqual(r.data, [4, 5]); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.method, "callOnTarget"); + const [, , sessionIdArg] = calls[0]?.args ?? []; + assert.equal(sessionIdArg, "sess-1"); + }); + + test("dispatches via call when no opts are given", async () => { + const { session, calls } = recordingSession(ok({ result: { type: "object", value: [1] } })); + const r = await evaluateJson(session, "[1]", isNumberArray); + assert.equal(r.success, true); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.method, "call"); + assert.equal(calls.some((c) => c.method === "callOnTarget"), false); + }); + + test("passes timeoutMs through to the transport options", async () => { + const { session, calls } = recordingSession(ok({ result: { type: "object", value: [1] } })); + await evaluateJson(session, "[1]", isNumberArray, { timeoutMs: 5000 }); + const [, , optsArg] = calls[0]?.args ?? []; + assert.deepEqual(optsArg, { timeoutMs: 5000 }); + }); + + test("passes through a transport failure unchanged", async () => { + const failure = err({ kind: "timeout" as const, message: "timed out" }); + const { session } = recordingSession(failure); + const r = await evaluateJson(session, "[1]", isNumberArray); + assert.equal(r.success, false); + if (!r.success) { + assert.equal(r.error.kind, "timeout"); + assert.equal(r.error.message, "timed out"); + } + }); +}); diff --git a/test/daemon/queue-test.ts b/test/daemon/queue-test.ts new file mode 100644 index 0000000..6ea95f8 --- /dev/null +++ b/test/daemon/queue-test.ts @@ -0,0 +1,136 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { makeEventQueue, rejectAllPending, sendWithTimeout, type Pending } from "../../src/daemon/queue"; + +describe("event queue iteration", () => { + test("yields pushed events in order", async () => { + const q = makeEventQueue(); + q.push({ method: "Page.loadEventFired", params: {} }); + q.push({ method: "Page.frameNavigated", params: {} }); + const seen: string[] = []; + for await (const ev of q.iter) { + seen.push(ev.method); + if (seen.length === 2) break; + } + assert.deepEqual(seen, ["Page.loadEventFired", "Page.frameNavigated"]); + }); + + test("ending the queue terminates the iteration after draining the buffer", async () => { + const q = makeEventQueue(); + q.push({ method: "Page.loadEventFired", params: {} }); + q.end(); + const seen: string[] = []; + for await (const ev of q.iter) seen.push(ev.method); + assert.deepEqual(seen, ["Page.loadEventFired"]); + }); + + test("a waiter pending at end() is released", async () => { + const q = makeEventQueue(); + const done = (async () => { + const seen: string[] = []; + for await (const ev of q.iter) seen.push(ev.method); + return seen; + })(); + await Promise.resolve(); + q.end(); + assert.deepEqual(await done, []); + }); + + test("push after end() is dropped", async () => { + const q = makeEventQueue(); + q.end(); + q.push({ method: "Page.loadEventFired", params: {} }); + const seen: string[] = []; + for await (const ev of q.iter) seen.push(ev.method); + assert.deepEqual(seen, []); + }); + + test("a waiter is handed the next pushed event directly", async () => { + const q = makeEventQueue(); + const it = q.iter[Symbol.asyncIterator](); + const pending = it.next(); + await Promise.resolve(); + q.push({ method: "Runtime.consoleAPICalled", params: { a: 1 } }); + const r = await pending; + assert.equal(r.done, false); + assert.deepEqual(r.value, { method: "Runtime.consoleAPICalled", params: { a: 1 } }); + }); + + test("the terminal result reports done with an undefined value", async () => { + const q = makeEventQueue(); + q.end(); + const it = q.iter[Symbol.asyncIterator](); + const r = await it.next(); + assert.equal(r.done, true); + assert.equal(r.value, undefined); + }); + + test("sessionId is preserved on yielded events", async () => { + const q = makeEventQueue(); + q.push({ method: "Page.loadEventFired", params: {}, sessionId: "s1" }); + q.end(); + const seen: Array = []; + for await (const ev of q.iter) seen.push(ev.sessionId); + assert.deepEqual(seen, ["s1"]); + }); +}); + +describe("pending request bookkeeping", () => { + test("rejectAllPending settles every entry with transport_closed and clears the map", () => { + const pending = new Map(); + const settled: string[] = []; + for (const id of [1, 2]) { + pending.set(id, { + resolve: (r) => settled.push(r.success ? "ok" : r.error.kind), + timer: setTimeout(() => {}, 10_000), + method: `M.m${id}`, + }); + } + rejectAllPending(pending, "gone"); + assert.deepEqual(settled, ["transport_closed", "transport_closed"]); + assert.equal(pending.size, 0); + }); + + test("sendWithTimeout resolves transport_closed when send throws synchronously", async () => { + const pending = new Map(); + const r = await sendWithTimeout(pending, 7, "M.m", 10_000, "Test", () => { + throw new Error("socket gone"); + }); + assert.equal(r.success, false); + if (!r.success) { + assert.equal(r.error.kind, "transport_closed"); + assert.equal(r.error.message, "socket gone"); + assert.equal(r.error.method, "M.m"); + } + assert.equal(pending.size, 0); + }); + + test("sendWithTimeout registers a pending entry the caller can resolve", async () => { + const pending = new Map(); + let sent = false; + const p = sendWithTimeout(pending, 9, "M.m", 10_000, "Test", () => { + sent = true; + }); + assert.equal(sent, true); + const entry = pending.get(9); + assert.notEqual(entry, undefined); + if (entry) { + clearTimeout(entry.timer); + entry.resolve({ success: true, data: { hello: "world" } }); + } + const r = await p; + assert.equal(r.success, true); + if (r.success) assert.deepEqual(r.data, { hello: "world" }); + }); + + test("sendWithTimeout resolves a timeout error and drops the pending entry", async () => { + const pending = new Map(); + const r = await sendWithTimeout(pending, 11, "M.slow", 1, "Test", () => {}); + assert.equal(r.success, false); + if (!r.success) { + assert.equal(r.error.kind, "timeout"); + assert.match(r.error.message, /Test timeout after 1ms: M\.slow/); + } + assert.equal(pending.size, 0); + }); +}); diff --git a/test/domains/fake-client.ts b/test/domains/fake-client.ts index 6bf2e54..f8a0c81 100644 --- a/test/domains/fake-client.ts +++ b/test/domains/fake-client.ts @@ -1,7 +1,7 @@ import { createCdpSession, type CdpSession } from "../../src/cdp/session"; import { createOwnershipRegistry, type OwnershipRegistry } from "../../src/cdp/ownership"; import { type CdpError, cdpError } from "../../src/cdp/errors"; -import type { CdpTransport } from "../../src/cdp/transport"; +import type { CdpTransport } from "../../src/cdp/types"; import type { CdpEvent } from "../../src/cdp/types"; import type { BrowserClient } from "../../src/client"; import { createMutex } from "../../src/util/mutex"; diff --git a/test/manual/window-binding-test.ts b/test/manual/window-binding-test.ts index cad526a..d040614 100644 --- a/test/manual/window-binding-test.ts +++ b/test/manual/window-binding-test.ts @@ -1,6 +1,6 @@ import { createBrowserClient } from "../../src/client"; import { createOwnershipRegistry } from "../../src/cdp/ownership"; -import type { CdpTransport } from "../../src/cdp/transport"; +import type { CdpTransport } from "../../src/cdp/types"; import type { CdpEvent } from "../../src/cdp/types"; import { ok, type Result } from "../../src/util/result"; import type { CdpError } from "../../src/cdp/errors"; -- cgit v1.2.3