diff --git a/bridge/injector.ts b/bridge/injector.ts index f903885..b025fd2 100644 --- a/bridge/injector.ts +++ b/bridge/injector.ts @@ -107,30 +107,44 @@ export async function injectExtensionIfNeeded({ session_id, }; }; + const discoverReadyServiceWorker = async (session_timeout_ms = 2_000) => { + const target_infos = TargetCommands["Target.getTargets"].result.parse(await send("Target.getTargets")).targetInfos; + if (trust_matched_service_worker) { + const trusted_target = target_infos.find((candidate) => serviceWorkerTargetMatches(candidate)) as + | TargetInfo + | undefined; + if (trusted_target) { + const probed = await probeTarget(trusted_target, session_timeout_ms); + if (probed) return { source: "trusted" as const, ...probed }; + } + } + for (const candidate of target_infos) { + if (candidate.type !== "service_worker") continue; + if (!candidate.url.startsWith("chrome-extension://")) continue; + try { + const probed = await probeTarget(candidate as TargetInfo, session_timeout_ms); + if (probed) return { source: "discovered" as const, ...probed }; + } catch { + continue; + } + } + return null; + }; + const waitForReadyServiceWorker = async (timeout_ms: number) => { + const deadline = Date.now() + timeout_ms; + while (Date.now() < deadline) { + const discovered = await discoverReadyServiceWorker(Math.min(2_000, Math.max(100, deadline - Date.now()))); + if (discovered) return discovered; + await sleep(100); + } + return null; + }; // 1. Discover an existing CDPMod service worker from the current CDP target // snapshot. If no already-ready worker is visible, move on to the explicit // injection path instead of waiting on a guessed preinstalled-extension budget. - const target_infos = TargetCommands["Target.getTargets"].result.parse(await send("Target.getTargets")).targetInfos; - if (trust_matched_service_worker) { - const trusted_target = target_infos.find((candidate) => serviceWorkerTargetMatches(candidate)) as - | TargetInfo - | undefined; - if (trusted_target) { - const probed = await probeTarget(trusted_target, 2_000); - if (probed) return { source: "trusted", ...probed }; - } - } - for (const candidate of target_infos) { - if (candidate.type !== "service_worker") continue; - if (!candidate.url.startsWith("chrome-extension://")) continue; - try { - const probed = await probeTarget(candidate as TargetInfo, 2_000); - if (probed) return { source: "discovered", ...probed }; - } catch { - continue; - } - } + const discovered = await discoverReadyServiceWorker(); + if (discovered) return discovered; if (require_service_worker_target) { throw new Error( `Required CDPMod service worker target was not visible in the current CDP target snapshot ` + @@ -150,28 +164,8 @@ export async function injectExtensionIfNeeded({ const load_error = error instanceof Error ? error : new Error(String(error)); if (/Method not available|Method.*not.*found|wasn't found/i.test(load_error.message)) { load_unpacked_unavailable_error = load_error; - const target_infos = TargetCommands["Target.getTargets"].result.parse( - await send("Target.getTargets"), - ).targetInfos; - if (trust_matched_service_worker) { - const trusted_target = target_infos.find((candidate) => serviceWorkerTargetMatches(candidate)) as - | TargetInfo - | undefined; - if (trusted_target) { - const probed = await probeTarget(trusted_target, 2_000); - if (probed) return { source: "trusted", ...probed }; - } - } - for (const candidate of target_infos) { - if (candidate.type !== "service_worker") continue; - if (!candidate.url.startsWith("chrome-extension://")) continue; - try { - const probed = await probeTarget(candidate as TargetInfo, 2_000); - if (probed) return { source: "discovered", ...probed }; - } catch { - continue; - } - } + const discovered = await discoverReadyServiceWorker(); + if (discovered) return discovered; } else { throw new Error( `Extensions.loadUnpacked failed for ${extension_path}: ${load_error.message}\n` + @@ -217,6 +211,9 @@ export async function injectExtensionIfNeeded({ // 4. Chrome's new chrome://inspect auto-connect flow exposes CDP without // exposing Extensions.loadUnpacked. In that case, inject the same server into // every currently running extension service worker and keep the best session. + const late_discovered = await waitForReadyServiceWorker(5_000); + if (late_discovered) return late_discovered; + const borrowed: { target_id: string; url: string; diff --git a/bridge/launcher.ts b/bridge/launcher.ts index 8f1672c..39cefd4 100644 --- a/bridge/launcher.ts +++ b/bridge/launcher.ts @@ -27,6 +27,7 @@ const CANDIDATE_PATHS = [ const DEFAULT_FLAGS = [ "--enable-unsafe-extension-debugging", + "--enable-extensions", "--remote-allow-origins=*", "--no-first-run", "--no-default-browser-check", diff --git a/client/js/CDPModClient.ts b/client/js/CDPModClient.ts index 98ec02e..13729a7 100644 --- a/client/js/CDPModClient.ts +++ b/client/js/CDPModClient.ts @@ -14,6 +14,7 @@ // oxlint-disable typescript-eslint/no-unsafe-declaration-merging -- alias members are assigned by connect(). import type { z } from "zod"; +import { ReplayPageRegistry } from "./_ReplayPageRegistry.js"; import type { CdpAliases } from "../../types/aliases.js"; import { bindingNameFor, @@ -49,6 +50,44 @@ import { normalizeCDPModName, normalizeCDPModPayloadSchema, } from "../../types/cdpmod.js"; +import { + ModBindPageParamsSchema, + ModElementSchema, + ModOpenPageParamsSchema, + ModPageSchema, + ModWaitForPageParamsSchema, + type ModClickResult, + type ModElement, + type ModFillResult, + type ModFrameHop, + type ModHoverResult, + type ModNavigationResult, + type ModOpenPageParams, + type ModPage, + type ModPageEvaluateParams, + type ModPageEvaluateResult, + type ModPageExpectation, + type ModPageGoBackParams, + type ModPageGoForwardParams, + type ModPageGotoParams, + type ModPageReloadParams, + type ModPageScreenshotParams, + type ModPageScreenshotResult, + type ModPageWaitForLoadStateParams, + type ModPageWaitForLoadStateResult, + type ModPageWaitForSelectorParams, + type ModPageWaitForSelectorResult, + type ModPageWaitForTimeoutParams, + type ModPageWaitForTimeoutResult, + type ModPressResult, + type PageTargetInfo, + type ModQueryElementResult, + type ModScrollResult, + type ModSelector, + type ModTextResult, + type ModTypeResult, + type ModWaitForPageParams, +} from "../../types/replayable.js"; const DEFAULT_LIVE_CDP_URL = "http://127.0.0.1:9222"; @@ -57,7 +96,7 @@ type PendingCommand = { resolve: (value: ProtocolResult) => void; reject: (error: Error) => void; }; -type ClientOptions = { +export type CDPModClientOptions = { cdp_url?: string | null; extension_path?: string; routes?: CDPModRoutes; @@ -80,10 +119,201 @@ type ClientOptions = { handleCommand: (method: string, params?: ProtocolParams, cdpSessionId?: string | null) => Promise; } | null; }; -type CDPModEventNameInput = string | symbol | (z.ZodType & CDPModNamedValue); -type CDPModClientCustomCommandParams = Omit & { +export type CDPModEventNameInput = string | symbol | (z.ZodType & CDPModNamedValue); +export type CDPModClientCustomCommandParams = Omit & { expression?: string | null; }; +export type ModFrameOptions = "IFRAME" | "FRAME" | { assertNodeName?: "IFRAME" | "FRAME" }; +export type ModWaitForPageOptions = Omit & { + opener?: ModPage | ModPageHandle; +}; +export type ModPageGotoOptions = Partial>; +export type ModPageReloadOptions = Partial>; +export type ModPageGoBackOptions = Partial>; +export type ModPageGoForwardOptions = Partial>; +export type ModPageScreenshotOptions = Partial>; +export type ModPageEvaluateOptions = Partial>; +export type ModPageWaitForSelectorOptions = Partial>; +export type ModInputScrollOptions = { + selector?: ModSelector; + deltaX?: number; + deltaY: number; +}; + +export class ModPageHandle { + readonly object = "mod.page"; + readonly id: string; + + #client: CDPModClient; + #frames: ModFrameHop[]; + + constructor(client: CDPModClient, page: ModPage, frames: ModFrameHop[] = []) { + this.#client = client; + this.id = page.id; + this.#frames = frames; + } + + get ref(): ModPage { + return { object: "mod.page", id: this.id }; + } + + get frames(): readonly ModFrameHop[] { + return this.#frames; + } + + toJSON(): ModPage { + return this.ref; + } + + frame(owner: ModSelector, options: ModFrameOptions = "IFRAME"): ModPageHandle { + const assertNodeName = typeof options === "string" ? options : (options.assertNodeName ?? "IFRAME"); + return new ModPageHandle(this.#client, this.ref, [...this.#frames, { owner, assertNodeName }]); + } + + async send(method: string, params: Record = {}) { + if ( + method === "Mod.DOM.elementText" || + method === "Mod.Input.clickElement" || + method === "Mod.Input.typeElement" || + method === "Mod.Input.hoverElement" || + method === "Mod.Input.fillElement" || + method === "Mod.Input.pressElement" || + method === "Mod.Input.scrollElement" + ) { + return this.#client.send(method, params); + } + if (method.startsWith("Mod.DOM.") || method.startsWith("Mod.Input.")) { + return this.#client.send(method, { + ...params, + page: params.page ?? this.ref, + frames: params.frames ?? this.#frames, + }); + } + if (method === "Mod.Page.evaluate" || method === "Mod.Page.waitForSelector") { + return this.#client.send(method, { + ...params, + page: params.page ?? this.ref, + frames: params.frames ?? this.#frames, + }); + } + if (method.startsWith("Mod.Page.")) { + return this.#client.send(method, { + ...params, + page: params.page ?? this.ref, + }); + } + return this.#client.send(method, params); + } + + async goto(url: string, options: ModPageGotoOptions = {}): Promise { + return (await this.send("Mod.Page.goto", { ...options, url })) as ModNavigationResult; + } + + async reload(options: ModPageReloadOptions = {}): Promise { + return (await this.send("Mod.Page.reload", options)) as ModNavigationResult; + } + + async goBack(options: ModPageGoBackOptions = {}): Promise { + return (await this.send("Mod.Page.goBack", options)) as ModNavigationResult; + } + + async goForward(options: ModPageGoForwardOptions = {}): Promise { + return (await this.send("Mod.Page.goForward", options)) as ModNavigationResult; + } + + async waitForLoadState( + state: ModPageWaitForLoadStateParams["state"], + options: Partial> = {}, + ): Promise { + return (await this.send("Mod.Page.waitForLoadState", { ...options, state })) as ModPageWaitForLoadStateResult; + } + + async waitForTimeout(ms: number): Promise { + return (await this.send("Mod.Page.waitForTimeout", { ms })) as ModPageWaitForTimeoutResult; + } + + async screenshot(options: ModPageScreenshotOptions = {}): Promise { + return (await this.send("Mod.Page.screenshot", options)) as ModPageScreenshotResult; + } + + async evaluate(expression: string, options: ModPageEvaluateOptions = {}): Promise { + return (await this.send("Mod.Page.evaluate", { ...options, expression })) as ModPageEvaluateResult; + } + + async waitForSelector( + selector: ModSelector, + options: ModPageWaitForSelectorOptions = {}, + ): Promise { + const result = (await this.send("Mod.Page.waitForSelector", { + ...options, + selector, + })) as ModPageWaitForSelectorResult; + if (result.element) result.element = ModElementSchema.parse(result.element); + return result; + } + + async query(selector: ModSelector, options: { id?: string } = {}): Promise { + const result = (await this.send("Mod.DOM.queryElement", { ...options, selector })) as ModQueryElementResult; + return ModElementSchema.parse(result.element); + } + + async text(selector: ModSelector): Promise { + const result = (await this.send("Mod.DOM.text", { selector })) as ModTextResult; + return result.text; + } + + async click(selector: ModSelector): Promise { + return (await this.send("Mod.Input.click", { selector })) as ModClickResult; + } + + async type(selector: ModSelector, text: string): Promise { + return (await this.send("Mod.Input.type", { selector, text })) as ModTypeResult; + } + + async hover(selector: ModSelector): Promise { + return (await this.send("Mod.Input.hover", { selector })) as ModHoverResult; + } + + async fill(selector: ModSelector, value: string): Promise { + return (await this.send("Mod.Input.fill", { selector, value })) as ModFillResult; + } + + async press(key: string): Promise { + return (await this.send("Mod.Input.press", { key })) as ModPressResult; + } + + async scroll(options: ModInputScrollOptions): Promise { + return (await this.send("Mod.Input.scroll", options)) as ModScrollResult; + } + + async waitForPage(params: Omit): Promise { + const result = (await this.#client.send("Mod.Page.waitFor", { + ...params, + opener: this.ref, + })) as { page: unknown }; + return new ModPageHandle(this.#client, ModPageSchema.parse(result.page)); + } +} + +export class CDPModReplayNamespace { + constructor(private readonly client: CDPModClient) {} + + async openPage(params: ModOpenPageParams): Promise { + const result = (await this.client.send("Mod.Page.open", params)) as { page: unknown }; + return new ModPageHandle(this.client, ModPageSchema.parse(result.page)); + } + + async waitForPage(params: ModWaitForPageOptions): Promise { + const opener = params.opener instanceof ModPageHandle ? params.opener.ref : params.opener; + const result = (await this.client.send("Mod.Page.waitFor", { ...params, opener })) as { page: unknown }; + return new ModPageHandle(this.client, ModPageSchema.parse(result.page)); + } + + page(page: ModPage | ModPageHandle): ModPageHandle { + if (page instanceof ModPageHandle) return page; + return new ModPageHandle(this.client, ModPageSchema.parse(page)); + } +} export type CDPModCommandSpec = { params: Params; @@ -218,6 +448,17 @@ function runtimeModuleUrl(relative_path: string) { return new URL(relative_path, import.meta.url).href; } +function pageTargetMatchesExpectation(target: PageTargetInfo, expected: ModPageExpectation | undefined): boolean { + if (!expected) return true; + if (expected.url && target.url !== expected.url) return false; + if (expected.urlIncludes && !target.url?.includes(expected.urlIncludes)) return false; + return true; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function hasCommandExpression( command: CDPModClientCustomCommandParams, ): command is CDPModClientCustomCommandParams & { expression: string } { @@ -240,7 +481,7 @@ export class CDPModClient extends CDPModEventEmitter { require_service_worker_target: boolean; service_worker_ready_expression: string | null; ws: WebSocket | null; - self: ClientOptions["self"]; + self: CDPModClientOptions["self"]; next_id: number; pending: Map; ext_session_id: string | null; @@ -258,6 +499,8 @@ export class CDPModClient extends CDPModEventEmitter { event_wait_cleanups: Set<() => void>; auto_target_sessions: Map; auto_session_targets: Map>; + private readonly replay_pages: ReplayPageRegistry; + refs: CDPModReplayNamespace; _prepared_extension: { path: string; close: () => Promise } | null; _cdp: { send: (method: string, params?: ProtocolParams, sessionId?: string | null) => Promise; @@ -282,7 +525,7 @@ export class CDPModClient extends CDPModEventEmitter { service_worker_ready_expression = null, launch_options = {}, self = null, - }: ClientOptions = {}) { + }: CDPModClientOptions = {}) { super(); this.cdp_url = cdp_url; this.extension_path = extension_path; @@ -318,6 +561,8 @@ export class CDPModClient extends CDPModEventEmitter { this.event_wait_cleanups = new Set(); this.auto_target_sessions = new Map(); this.auto_session_targets = new Map(); + this.replay_pages = new ReplayPageRegistry(); + this.refs = new CDPModReplayNamespace(this); this._prepared_extension = null; this._launched = null; @@ -388,6 +633,7 @@ export class CDPModClient extends CDPModEventEmitter { }), this._sendFrame("Target.setDiscoverTargets", { discover: true }), ]); + await this._refreshTargetInfos().catch(() => {}); const service_worker_url_suffixes = await this._serviceWorkerUrlSuffixes(); const trust_service_worker_target = @@ -446,6 +692,9 @@ export class CDPModClient extends CDPModEventEmitter { } async send(method: string, params: unknown = {}) { + if (method === "Mod.Page.open") return this._openModPage(params); + if (method === "Mod.Page.waitFor") return this._waitForModPage(params); + const started_at = Date.now(); const command_params = this.command_params_schemas.get(method)?.parse(params ?? {}) ?? params ?? {}; const command = wrapCommandIfNeeded(method, command_params as ProtocolParams, { @@ -464,6 +713,116 @@ export class CDPModClient extends CDPModEventEmitter { return this.command_result_schemas.get(method)?.parse(result) ?? result; } + async _openModPage(raw_params: unknown) { + const params = ModOpenPageParamsSchema.parse(raw_params ?? {}); + const page = this._createModPage(params.id); + const { targetId } = (await this._sendFrame("Target.createTarget", { url: params.url })) as { targetId?: string }; + if (!targetId) throw new Error("Target.createTarget returned no targetId."); + + await this._waitForPageTarget( + (target) => target.targetId === targetId && (!params.url || target.url === params.url), + `Timed out waiting for page target ${targetId} to navigate to ${params.url}.`, + ); + await this._bindModPage(page, targetId); + return { page }; + } + + async _waitForModPage(raw_params: unknown) { + const params = ModWaitForPageParamsSchema.parse(raw_params ?? {}); + const page = this._createModPage(params.id); + const timeout_ms = params.timeoutMs ?? 10_000; + const started_at = Date.now(); + await this._refreshTargetInfos().catch(() => {}); + const baseline = new Set(this._pageTargetInfos().map((target) => target.targetId)); + const opener_target_id = params.opener ? this.replay_pages.targetIdForPage(params.opener) : null; + if (params.opener && !opener_target_id) { + throw new Error(`Unknown opener ModPage id "${params.opener.id}".`); + } + + while (Date.now() - started_at < timeout_ms) { + await this._refreshTargetInfos().catch(() => {}); + const scoped_targets = this.replay_pages.unboundPageTargetInfos(baseline, opener_target_id); + await Promise.all(scoped_targets.filter((target) => !target.url).map((target) => this._resumeTarget(target))); + if (scoped_targets.some((target) => !target.url)) await this._refreshTargetInfos().catch(() => {}); + const candidates = scoped_targets + .map((target) => this.replay_pages.targetInfo(target.targetId) ?? target) + .filter((target) => pageTargetMatchesExpectation(target, params.expected)); + if (candidates.length === 1) { + await this._bindModPage(page, candidates[0].targetId); + return { page }; + } + if (candidates.length > 1) { + throw new Error(`Mod.Page.waitFor expected exactly one new page, found ${candidates.length}.`); + } + await sleep(100); + } + + throw new Error(`Mod.Page.waitFor timed out after ${timeout_ms}ms.`); + } + + _createModPage(id?: string): ModPage { + return this.replay_pages.createPage(id); + } + + async _bindModPage(page: ModPage, target_id: string) { + const params = ModBindPageParamsSchema.parse({ page, targetId: target_id }); + const result = await this.send("Mod.Page.bind", params); + const bound_page = ModPageSchema.parse((result as { page?: unknown }).page); + this.replay_pages.bindPage(bound_page, target_id); + return bound_page; + } + + async _waitForPageTarget(predicate: (target: PageTargetInfo) => boolean, message: string, timeout_ms = 10_000) { + const deadline = Date.now() + timeout_ms; + while (Date.now() < deadline) { + await this._refreshTargetInfos().catch(() => {}); + const target = this._pageTargetInfos().find(predicate); + if (target) return target; + await sleep(100); + } + throw new Error(message); + } + + async _refreshTargetInfos() { + const result = (await this._sendFrame("Target.getTargets")) as { targetInfos?: unknown[] }; + for (const target_info of result.targetInfos || []) this._upsertTargetInfo(target_info); + } + + _pageTargetInfos(): PageTargetInfo[] { + return this.replay_pages.pageTargetInfos(); + } + + _upsertTargetInfo(value: unknown) { + this.replay_pages.upsertTargetInfo(value); + } + + _removeTargetInfo(target_id: string) { + this.replay_pages.removeTarget(target_id); + } + + async _resumeTarget(target: PageTargetInfo) { + if (!this.replay_pages.takeResumeAttempt(target.targetId)) return; + let session_id = this.auto_target_sessions.get(target.targetId) ?? null; + let attached_here = false; + try { + if (!session_id) { + const attached = (await this._sendFrame("Target.attachToTarget", { + targetId: target.targetId, + flatten: true, + })) as { sessionId?: string }; + session_id = attached.sessionId ?? null; + attached_here = Boolean(session_id); + } + if (!session_id) return; + await this._sendFrame("Runtime.runIfWaitingForDebugger", {}, session_id).catch(() => {}); + await this._sendFrame("Page.enable", {}, session_id).catch(() => {}); + } finally { + if (attached_here && session_id) { + await this._sendFrame("Target.detachFromTarget", { sessionId: session_id }).catch(() => {}); + } + } + } + async _hydrateCdpAliases() { if (!this.hydrate_aliases || this.cdp_aliases_hydrated) return; const { createCdpAliases } = (await import( @@ -749,10 +1108,19 @@ export class CDPModClient extends CDPModEventEmitter { ? (params.targetInfo as Record) : null; const target_id = typeof target_info?.targetId === "string" ? target_info.targetId : null; + this._upsertTargetInfo(target_info); if (session_id && target_id) { this.auto_target_sessions.set(target_id, session_id); this.auto_session_targets.set(session_id, target_info as Record); + void this._sendFrame("Runtime.runIfWaitingForDebugger", {}, session_id).catch(() => {}); } + } else if (event.method === "Target.targetCreated" || event.method === "Target.targetInfoChanged") { + const params = (event.params || {}) as Record; + this._upsertTargetInfo(params.targetInfo); + } else if (event.method === "Target.targetDestroyed") { + const params = (event.params || {}) as Record; + const target_id = typeof params.targetId === "string" ? params.targetId : null; + if (target_id) this._removeTargetInfo(target_id); } else if (event.method === "Target.detachedFromTarget") { const params = (event.params || {}) as Record; const session_id = typeof params.sessionId === "string" ? params.sessionId : null; diff --git a/client/js/_ReplayPageRegistry.ts b/client/js/_ReplayPageRegistry.ts new file mode 100644 index 0000000..acb4ea9 --- /dev/null +++ b/client/js/_ReplayPageRegistry.ts @@ -0,0 +1,80 @@ +import { PageTargetInfoSchema, type ModPage, type PageTargetInfo } from "../../types/replayable.js"; + +/** + * @internal + * + * Private CDPModClient registry for replayable ModPage binding state. + * + * This records client-observed TargetInfo snapshots, ModPage <-> targetId + * bindings, and one-shot target resume attempts so replayable references can be + * resolved. It is not authoritative operational browser truth. + */ +export class ReplayPageRegistry { + private readonly target_infos = new Map(); + private readonly mod_page_targets = new Map(); + private readonly mod_target_pages = new Map(); + private readonly resumed_target_ids = new Set(); + private next_mod_page_id = 1; + + createPage(id?: string): ModPage { + let page_id = id; + while (!page_id) { + const candidate = `page_${this.next_mod_page_id++}`; + if (!this.mod_page_targets.has(candidate)) page_id = candidate; + } + if (this.mod_page_targets.has(page_id)) throw new Error(`ModPage id "${page_id}" is already bound.`); + return { object: "mod.page", id: page_id }; + } + + bindPage(page: ModPage, target_id: string) { + this.mod_page_targets.set(page.id, target_id); + this.mod_target_pages.set(target_id, page.id); + } + + targetIdForPage(page: ModPage): string | null { + return this.mod_page_targets.get(page.id) ?? null; + } + + targetInfo(target_id: string): PageTargetInfo | null { + return this.target_infos.get(target_id) ?? null; + } + + pageTargetInfos(): PageTargetInfo[] { + return [...this.target_infos.values()].filter((target) => target.type === "page"); + } + + unboundPageTargetInfos(baseline_target_ids: ReadonlySet, opener_target_id: string | null): PageTargetInfo[] { + return this.pageTargetInfos().filter((target) => { + if (this.mod_target_pages.has(target.targetId)) return false; + if (baseline_target_ids.has(target.targetId)) return false; + if (opener_target_id) { + if (target.openerId !== opener_target_id) return false; + if (target.canAccessOpener === false) return false; + } + return true; + }); + } + + upsertTargetInfo(value: unknown): PageTargetInfo | null { + const parsed = PageTargetInfoSchema.safeParse(value); + if (!parsed.success) return null; + const target = parsed.data; + const next = PageTargetInfoSchema.parse({ ...this.target_infos.get(target.targetId), ...target }); + this.target_infos.set(next.targetId, next); + return next; + } + + removeTarget(target_id: string) { + this.target_infos.delete(target_id); + const page_id = this.mod_target_pages.get(target_id); + if (page_id) this.mod_page_targets.delete(page_id); + this.mod_target_pages.delete(target_id); + this.resumed_target_ids.delete(target_id); + } + + takeResumeAttempt(target_id: string): boolean { + if (this.resumed_target_ids.has(target_id)) return false; + this.resumed_target_ids.add(target_id); + return true; + } +} diff --git a/examples/replay-hacker-news.js b/examples/replay-hacker-news.js new file mode 100644 index 0000000..e167bdb --- /dev/null +++ b/examples/replay-hacker-news.js @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { CDPModClient } from "cdpmod"; +import { chromium } from "playwright"; +import { launchChrome } from "../dist/bridge/launcher.js"; + +const HN_URL = "https://news.ycombinator.com/"; +const STORY_RANKS = [1, 2, 3]; +const EXTENSION_PATH = fileURLToPath(new URL("../dist/extension/", import.meta.url)); +const SERVICE_WORKER_PATH = fileURLToPath(new URL("../dist/extension/service_worker.js", import.meta.url)); + +const xpath = (value) => ({ kind: "xpath", xpath: value }); + +const HN_STORY_LIST = "/html[1]/body[1]/center[1]/table[1]/tbody[1]/tr[3]/td[1]/table[1]/tbody[1]"; +const HN_ITEM_PAGE = "/html[1]/body[1]/center[1]/table[1]/tbody[1]/tr[3]/td[1]"; +const TOP_COMMENT = xpath(`${HN_ITEM_PAGE}/table[2]/tbody[1]/tr[1]/td[1]/table[1]/tbody[1]/tr[1]/td[3]/div[2]/div[1]`); + +function storyRow(rank) { + return 1 + (rank - 1) * 3; +} + +function storyTitle(rank) { + return xpath(`${HN_STORY_LIST}/tr[${storyRow(rank)}]/td[3]/span[1]/a[1]`); +} + +function storyCommentsLink(rank) { + return xpath(`${HN_STORY_LIST}/tr[${storyRow(rank) + 1}]/td[2]/span[1]/a[3]`); +} + +const REPLAY_PLAN = STORY_RANKS.map((rank) => ({ + rank, + title: storyTitle(rank), + comments: storyCommentsLink(rank), + topComment: TOP_COMMENT, +})); + +function normalizeText(value) { + return value.replace(/\s+/g, " ").trim(); +} + +function preview(value, max = 96) { + const normalized = normalizeText(value); + return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized; +} + +function launchOptions() { + return { + executable_path: chromium.executablePath(), + headless: true, + sandbox: process.platform !== "linux", + extra_args: [`--disable-extensions-except=${EXTENSION_PATH}`, `--load-extension=${EXTENSION_PATH}`], + }; +} + +function clientOptions(cdpUrl) { + return { + cdp_url: cdpUrl, + extension_path: EXTENSION_PATH, + routes: { + "Mod.*": "service_worker", + "*.*": "direct_cdp", + }, + server: null, + }; +} + +async function withReplayClient(fn) { + if (!existsSync(SERVICE_WORKER_PATH)) { + throw new Error(`Built extension not found at ${SERVICE_WORKER_PATH}. Run pnpm run build first.`); + } + + const chrome = await launchChrome(launchOptions()); + const cdp = new CDPModClient(clientOptions(chrome.cdpUrl)); + try { + await cdp.connect(); + assert.equal(typeof cdp.cdp_url, "string"); + await cdp.send("Mod.configure", { + loopback_cdp_url: cdp.cdp_url, + routes: { "*.*": "loopback_cdp" }, + }); + return await fn(cdp); + } finally { + await cdp.close().catch(() => {}); + await chrome.close().catch(() => {}); + } +} + +async function waitForText(page, selector, label) { + const deadline = Date.now() + 10_000; + let lastError = null; + + while (Date.now() < deadline) { + try { + const text = normalizeText(await page.text(selector)); + if (text) return text; + } catch (error) { + lastError = error; + } + await sleep(250); + } + + const reason = lastError instanceof Error ? lastError.message : "selector returned empty text"; + throw new Error(`Timed out waiting for ${label}: ${reason}`); +} + +async function waitForPageUrl(page, expectedUrlPart, label) { + const deadline = Date.now() + 10_000; + let lastUrl = ""; + let lastError = null; + + while (Date.now() < deadline) { + try { + const result = await page.send("Mod.DOM.resolveContext"); + lastUrl = typeof result.pageUrl === "string" ? result.pageUrl : ""; + if (lastUrl.includes(expectedUrlPart)) return lastUrl; + } catch (error) { + lastError = error; + } + await sleep(250); + } + + const reason = lastError instanceof Error ? lastError.message : `last URL was "${lastUrl}"`; + throw new Error(`Timed out waiting for ${label}: ${reason}`); +} + +async function runReplayPlan(cdp, label) { + const rows = []; + + for (const step of REPLAY_PLAN) { + const { rank } = step; + const page = await cdp.refs.openPage({ id: `${label}-hn-${rank}`, url: HN_URL }); + + const title = await waitForText(page, step.title, `story ${rank} title`); + const comments = await waitForText(page, step.comments, `story ${rank} comments link`); + assert.match( + comments, + /\b\d+\s+comments?\b/i, + `HN story ${rank} does not have comments right now; comments link text was "${comments}".`, + ); + + await page.click(step.comments); + await waitForPageUrl(page, "item?id=", `story ${rank} comments page`); + const topComment = await waitForText(page, step.topComment, `story ${rank} top comment`); + + rows.push({ rank, title, topComment }); + console.log(`[${label}] #${rank} ${preview(title)}`); + console.log(`[${label}] ${preview(topComment)}`); + } + + return rows; +} + +const firstPass = await withReplayClient((cdp) => runReplayPlan(cdp, "record")); +const secondPass = await withReplayClient((cdp) => runReplayPlan(cdp, "replay")); + +assert.deepEqual(secondPass, firstPass); +console.log(`Replay matched ${firstPass.length} Hacker News top comments in a fresh browser session.`); diff --git a/extension/CDPModServer.ts b/extension/CDPModServer.ts index dc39298..421b76c 100644 --- a/extension/CDPModServer.ts +++ b/extension/CDPModServer.ts @@ -19,6 +19,7 @@ import type { ProtocolPayload, ProtocolResult, } from "../types/cdpmod.js"; +import { registerReplayableBuiltins } from "./replayableBuiltins.js"; type MiddlewarePhase = "request" | "response" | "event"; type CDPModGlobalScope = typeof globalThis & @@ -413,6 +414,22 @@ export function installCDPModServer(globalScope: CDPModGlobalScope = globalThis return { name, registered: true }; }, + /** + * Internal built-in command registration. + * + * This intentionally bypasses the custom command Domain.method restriction + * because CDPMod-owned namespaces such as Mod.DOM.queryElement are grouped + * more narrowly than public custom commands. Do not use this for user + * supplied commands. + */ + addBuiltinCommand({ name, paramsSchema = null, resultSchema = null, handler }: CDPModCustomCommandRegistration) { + name = normalizeCDPModName(name); + if (!/^[^.]+(?:\.[^.]+)+$/.test(name)) throw new Error("name must be in Dotted.command form."); + if (typeof handler !== "function") throw new Error(`Built-in command ${name} was registered without a handler.`); + commandHandlers.set(name, { name, handler, paramsSchema, resultSchema, expression: null }); + return { name, registered: true }; + }, + addCustomEvent({ name, bindingName, eventSchema = null }: CDPModCustomEventRegistration) { name = normalizeCDPModName(name); if (!/^[^.]+\.[^.]+$/.test(name)) throw new Error("name must be in Domain.event form."); @@ -817,6 +834,8 @@ export function installCDPModServer(globalScope: CDPModGlobalScope = globalThis handler: async (params: ProtocolParams = {}) => CDPModServer.addMiddleware(params as CDPModMiddlewareRegistration), }); + if (typeof registerReplayableBuiltins === "function") registerReplayableBuiltins(CDPModServer, globalScope); + const chromeApi = globalScope.chrome; try { chromeApi?.runtime?.onStartup?.addListener(startOffscreenKeepAlive); diff --git a/extension/replayableBuiltins.ts b/extension/replayableBuiltins.ts new file mode 100644 index 0000000..64876e6 --- /dev/null +++ b/extension/replayableBuiltins.ts @@ -0,0 +1,1802 @@ +import type { + ModBindPageParams, + ModClickElementParams, + ModClickParams, + ModElement, + ModFillElementParams, + ModFillParams, + ModElementTextParams, + ModFrameHop, + ModHoverElementParams, + ModHoverParams, + ModLoadState, + ModNavigationResult, + ModOpenPageParams, + ModPageEvaluateParams, + ModPageGoBackParams, + ModPageGoForwardParams, + ModPageGotoParams, + ModPageReloadParams, + ModPageScreenshotParams, + ModPageWaitForLoadStateParams, + ModPageWaitForSelectorParams, + ModPageWaitForTimeoutParams, + ModPressElementParams, + ModPressParams, + ModQueryElementParams, + ModResolveContextParams, + ModScrollElementParams, + ModScrollParams, + ModSelector, + ModSelectorTargetParams, + ModTextParams, + ModTypeElementParams, + ModTypeParams, + ModWaitForPageParams, +} from "../types/replayable.js"; + +type CdpMessage = { + id?: number; + method?: string; + params?: Record; + result?: Record; + error?: { message?: string }; +}; + +type CdpCall = ( + method: string, + params?: Record, + sessionId?: string | null, + timeoutMs?: number, +) => Promise; + +type TargetInfoLike = { + targetId: string; + type: string; + url?: string; + title?: string; + openerId?: string; + canAccessOpener?: boolean; + openerFrameId?: string; + parentFrameId?: string; + browserContextId?: string; +}; + +type ReplayOnlyTargetRecord = { + targetId: string; + createdSeq: number; + destroyedSeq?: number; + latest: TargetInfoLike; + urlHistory: Array<{ seq: number; url: string }>; +}; + +type DomNode = { + nodeId?: number; + parentId?: number; + backendNodeId?: number; + nodeType?: number; + nodeName?: string; + localName?: string; + nodeValue?: string; + shadowRootType?: string; + attributes?: string[]; + children?: DomNode[]; + shadowRoots?: DomNode[]; + contentDocument?: DomNode; + frameId?: string; +}; + +type ResolvedContext = { + call: CdpCall; + targetInfo: TargetInfoLike; + pageSessionId: string; + sessionId: string; + root: DomNode; + frameDepth: number; + cleanupSessionIds: string[]; +}; + +const MOUSE_MOVE = "mouseMoved"; +const MOUSE_DOWN = "mousePressed"; +const MOUSE_UP = "mouseReleased"; +const DEFAULT_WAIT_TIMEOUT_MS = 10_000; +const POPUP_OPENING_MOUSE_UP_TIMEOUT_MS = 1_000; + +const replayOnlyTargetJournals = new Map(); + +function getReplayOnlyTargetJournal(loopbackCdpUrl: string): ReplayOnlyTargetJournal { + let journal = replayOnlyTargetJournals.get(loopbackCdpUrl); + if (!journal) { + journal = new ReplayOnlyTargetJournal(loopbackCdpUrl); + replayOnlyTargetJournals.set(loopbackCdpUrl, journal); + } + return journal; +} + +function parseOpenPageParams(raw: ModOpenPageParams): ModOpenPageParams { + const params = record(raw, "Mod.Page.open"); + return { + id: optionalString(params.id, "Mod.Page.open id"), + url: requiredString(params.url, "Mod.Page.open url"), + }; +} + +function parseBindPageParams(raw: ModBindPageParams): ModBindPageParams { + const params = record(raw, "Mod.Page.bind"); + return { + page: requiredPage(params.page, "Mod.Page.bind page"), + targetId: requiredString(params.targetId, "Mod.Page.bind targetId"), + }; +} + +function parseWaitForPageParams(raw: ModWaitForPageParams): ModWaitForPageParams { + const params = record(raw, "Mod.Page.waitFor"); + return { + id: optionalString(params.id, "Mod.Page.waitFor id"), + opener: params.opener === undefined ? undefined : requiredPage(params.opener, "Mod.Page.waitFor opener"), + expected: optionalPageExpectation(params.expected, "Mod.Page.waitFor expected"), + timeoutMs: positiveInt(params.timeoutMs, 10_000, "Mod.Page.waitFor timeoutMs"), + }; +} + +function parsePageGotoParams(raw: ModPageGotoParams): ModPageGotoParams { + const params = record(raw, "Mod.Page.goto"); + return { + page: requiredPage(params.page, "Mod.Page.goto page"), + url: requiredString(params.url, "Mod.Page.goto url"), + waitUntil: optionalLoadState(params.waitUntil, "Mod.Page.goto waitUntil"), + timeoutMs: nonnegativeInt(params.timeoutMs, 30_000, "Mod.Page.goto timeoutMs"), + }; +} + +function parsePageReloadParams(raw: ModPageReloadParams): ModPageReloadParams { + const params = record(raw, "Mod.Page.reload"); + return { + page: requiredPage(params.page, "Mod.Page.reload page"), + waitUntil: optionalLoadState(params.waitUntil, "Mod.Page.reload waitUntil"), + timeoutMs: nonnegativeInt(params.timeoutMs, 30_000, "Mod.Page.reload timeoutMs"), + ignoreCache: typeof params.ignoreCache === "boolean" ? params.ignoreCache : undefined, + }; +} + +function parsePageGoBackParams(raw: ModPageGoBackParams): ModPageGoBackParams { + const params = record(raw, "Mod.Page.goBack"); + return { + page: requiredPage(params.page, "Mod.Page.goBack page"), + waitUntil: optionalLoadState(params.waitUntil, "Mod.Page.goBack waitUntil"), + timeoutMs: nonnegativeInt(params.timeoutMs, 30_000, "Mod.Page.goBack timeoutMs"), + }; +} + +function parsePageGoForwardParams(raw: ModPageGoForwardParams): ModPageGoForwardParams { + const params = record(raw, "Mod.Page.goForward"); + return { + page: requiredPage(params.page, "Mod.Page.goForward page"), + waitUntil: optionalLoadState(params.waitUntil, "Mod.Page.goForward waitUntil"), + timeoutMs: nonnegativeInt(params.timeoutMs, 30_000, "Mod.Page.goForward timeoutMs"), + }; +} + +function parsePageWaitForLoadStateParams(raw: ModPageWaitForLoadStateParams): ModPageWaitForLoadStateParams { + const params = record(raw, "Mod.Page.waitForLoadState"); + const state = optionalLoadState(params.state, "Mod.Page.waitForLoadState state"); + if (!state) throw new Error("Mod.Page.waitForLoadState state must be a load state."); + return { + page: requiredPage(params.page, "Mod.Page.waitForLoadState page"), + state, + timeoutMs: nonnegativeInt(params.timeoutMs, 30_000, "Mod.Page.waitForLoadState timeoutMs"), + }; +} + +function parsePageWaitForTimeoutParams(raw: ModPageWaitForTimeoutParams): ModPageWaitForTimeoutParams { + const params = record(raw, "Mod.Page.waitForTimeout"); + return { + page: requiredPage(params.page, "Mod.Page.waitForTimeout page"), + ms: nonnegativeInt(params.ms, undefined, "Mod.Page.waitForTimeout ms"), + }; +} + +function parsePageScreenshotParams(raw: ModPageScreenshotParams): ModPageScreenshotParams { + const params = record(raw, "Mod.Page.screenshot"); + const type = params.type === undefined ? "png" : requiredScreenshotType(params.type, "Mod.Page.screenshot type"); + if (params.quality !== undefined && type !== "jpeg" && type !== "webp") { + throw new Error("Mod.Page.screenshot quality is only supported for jpeg or webp."); + } + if (params.clip !== undefined && params.fullPage === true) { + throw new Error("Mod.Page.screenshot clip cannot be used together with fullPage."); + } + return { + page: requiredPage(params.page, "Mod.Page.screenshot page"), + fullPage: typeof params.fullPage === "boolean" ? params.fullPage : undefined, + clip: params.clip === undefined ? undefined : requiredClip(params.clip, "Mod.Page.screenshot clip"), + type, + quality: + params.quality === undefined ? undefined : boundedInt(params.quality, 0, 100, "Mod.Page.screenshot quality"), + timeoutMs: nonnegativeInt(params.timeoutMs, 30_000, "Mod.Page.screenshot timeoutMs"), + }; +} + +function parsePageEvaluateParams(raw: ModPageEvaluateParams): ModPageEvaluateParams { + const params = record(raw, "Mod.Page.evaluate"); + return { + page: requiredPage(params.page, "Mod.Page.evaluate page"), + frames: requiredFrames(params.frames, "Mod.Page.evaluate frames"), + expression: requiredString(params.expression, "Mod.Page.evaluate expression"), + arg: params.arg, + awaitPromise: typeof params.awaitPromise === "boolean" ? params.awaitPromise : true, + timeoutMs: + params.timeoutMs === undefined + ? undefined + : nonnegativeInt(params.timeoutMs, undefined, "Mod.Page.evaluate timeoutMs"), + }; +} + +function parsePageWaitForSelectorParams(raw: ModPageWaitForSelectorParams): ModPageWaitForSelectorParams { + const params = record(raw, "Mod.Page.waitForSelector"); + return { + id: optionalString(params.id, "Mod.Page.waitForSelector id"), + page: requiredPage(params.page, "Mod.Page.waitForSelector page"), + frames: requiredFrames(params.frames, "Mod.Page.waitForSelector frames"), + selector: requiredSelector(params.selector, "Mod.Page.waitForSelector selector"), + state: optionalSelectorState(params.state, "Mod.Page.waitForSelector state") ?? "visible", + timeoutMs: nonnegativeInt(params.timeoutMs, 30_000, "Mod.Page.waitForSelector timeoutMs"), + }; +} + +function parseQueryElementParams(raw: ModQueryElementParams): ModQueryElementParams { + const params = record(raw, "Mod.DOM.queryElement"); + return { + id: optionalString(params.id, "Mod.DOM.queryElement id"), + ...parseSelectorTargetParams(params, "Mod.DOM.queryElement"), + }; +} + +function parseTextParams(raw: ModTextParams): ModTextParams { + return parseSelectorTargetParams(raw, "Mod.DOM.text"); +} + +function parseClickParams(raw: ModClickParams): ModClickParams { + return parseSelectorTargetParams(raw, "Mod.Input.click"); +} + +function parseTypeParams(raw: ModTypeParams): ModTypeParams { + const params = record(raw, "Mod.Input.type"); + return { + ...parseSelectorTargetParams(params, "Mod.Input.type"), + text: requiredString(params.text, "Mod.Input.type text"), + }; +} + +function parseHoverParams(raw: ModHoverParams): ModHoverParams { + return parseSelectorTargetParams(raw, "Mod.Input.hover"); +} + +function parseFillParams(raw: ModFillParams): ModFillParams { + const params = record(raw, "Mod.Input.fill"); + return { + ...parseSelectorTargetParams(params, "Mod.Input.fill"), + value: requiredString(params.value, "Mod.Input.fill value"), + }; +} + +function parsePressParams(raw: ModPressParams): ModPressParams { + const params = record(raw, "Mod.Input.press"); + return { + page: requiredPage(params.page, "Mod.Input.press page"), + frames: requiredFrames(params.frames, "Mod.Input.press frames"), + key: requiredString(params.key, "Mod.Input.press key"), + }; +} + +function parseScrollParams(raw: ModScrollParams): ModScrollParams { + const params = record(raw, "Mod.Input.scroll"); + return { + page: requiredPage(params.page, "Mod.Input.scroll page"), + frames: requiredFrames(params.frames, "Mod.Input.scroll frames"), + selector: + params.selector === undefined ? undefined : requiredSelector(params.selector, "Mod.Input.scroll selector"), + deltaX: typeof params.deltaX === "number" ? params.deltaX : 0, + deltaY: typeof params.deltaY === "number" ? params.deltaY : 0, + }; +} + +function parseSelectorTargetParams(raw: unknown, label: string): ModSelectorTargetParams { + const params = record(raw, label); + return { + page: requiredPage(params.page, `${label} page`), + frames: requiredFrames(params.frames, `${label} frames`), + selector: requiredSelector(params.selector, `${label} selector`), + }; +} + +function parseResolveContextParams(raw: ModResolveContextParams): ModResolveContextParams { + const params = record(raw, "Mod.DOM.resolveContext"); + return { + page: requiredPage(params.page, "Mod.DOM.resolveContext page"), + frames: requiredFrames(params.frames, "Mod.DOM.resolveContext frames"), + }; +} + +function parseElementTextParams(raw: ModElementTextParams): ModElementTextParams { + return { element: requiredElement(record(raw, "Mod.DOM.elementText").element, "Mod.DOM.elementText element") }; +} + +function parseClickElementParams(raw: ModClickElementParams): ModClickElementParams { + return { element: requiredElement(record(raw, "Mod.Input.clickElement").element, "Mod.Input.clickElement element") }; +} + +function parseTypeElementParams(raw: ModTypeElementParams): ModTypeElementParams { + const params = record(raw, "Mod.Input.typeElement"); + return { + element: requiredElement(params.element, "Mod.Input.typeElement element"), + text: requiredString(params.text, "Mod.Input.typeElement text"), + }; +} + +function parseHoverElementParams(raw: ModHoverElementParams): ModHoverElementParams { + return { element: requiredElement(record(raw, "Mod.Input.hoverElement").element, "Mod.Input.hoverElement element") }; +} + +function parseFillElementParams(raw: ModFillElementParams): ModFillElementParams { + const params = record(raw, "Mod.Input.fillElement"); + return { + element: requiredElement(params.element, "Mod.Input.fillElement element"), + value: requiredString(params.value, "Mod.Input.fillElement value"), + }; +} + +function parsePressElementParams(raw: ModPressElementParams): ModPressElementParams { + const params = record(raw, "Mod.Input.pressElement"); + return { + element: requiredElement(params.element, "Mod.Input.pressElement element"), + key: requiredString(params.key, "Mod.Input.pressElement key"), + }; +} + +function parseScrollElementParams(raw: ModScrollElementParams): ModScrollElementParams { + const params = record(raw, "Mod.Input.scrollElement"); + return { + element: requiredElement(params.element, "Mod.Input.scrollElement element"), + deltaX: typeof params.deltaX === "number" ? params.deltaX : 0, + deltaY: typeof params.deltaY === "number" ? params.deltaY : 0, + }; +} + +function requiredElement(value: unknown, label: string): ModElement { + const obj = record(value, label); + return { + object: "mod.element", + id: typeof obj.id === "string" ? obj.id : undefined, + page: requiredPage(obj.page, `${label}.page`), + frames: requiredFrames(obj.frames, `${label}.frames`), + selector: requiredSelector(obj.selector, `${label}.selector`), + fingerprint: typeof obj.fingerprint === "object" && obj.fingerprint !== null ? (obj.fingerprint as any) : undefined, + }; +} + +function requiredPage(value: unknown, label: string): ModElement["page"] { + const obj = record(value, label); + if (obj.object !== "mod.page" || typeof obj.id !== "string" || !obj.id) { + throw new Error(`${label} must be a ModPage.`); + } + return { object: "mod.page", id: obj.id }; +} + +function requiredFrames(value: unknown, label: string): ModFrameHop[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${label} must be an array.`); + return value.map((hop, index) => { + const obj = record(hop, `${label}[${index}]`); + return { + owner: requiredSelector(obj.owner, `${label}[${index}].owner`), + assertNodeName: obj.assertNodeName === "FRAME" || obj.assertNodeName === "IFRAME" ? obj.assertNodeName : "IFRAME", + }; + }); +} + +function requiredSelector(value: unknown, label: string): ModSelector { + const obj = record(value, label); + if (obj.kind === "xpath" && typeof obj.xpath === "string") return { kind: "xpath", xpath: obj.xpath }; + if (obj.kind === "css" && typeof obj.selector === "string") return { kind: "css", selector: obj.selector }; + if (obj.kind === "role" && typeof obj.role === "string") { + return { + kind: "role", + role: obj.role, + name: typeof obj.name === "string" ? obj.name : undefined, + exact: typeof obj.exact === "boolean" ? obj.exact : true, + }; + } + if (obj.kind === "text" && typeof obj.text === "string") { + return { + kind: "text", + text: obj.text, + exact: typeof obj.exact === "boolean" ? obj.exact : true, + }; + } + throw new Error(`${label} must be a ModSelector.`); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`); + return value as Record; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value) throw new Error(`${label} must be a non-empty string.`); + return value; +} + +function optionalString(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined; + return requiredString(value, label); +} + +function positiveInt(value: unknown, defaultValue: number | undefined, label: string): number { + const parsed = value === undefined ? defaultValue : value; + if (!Number.isInteger(parsed) || Number(parsed) <= 0) throw new Error(`${label} must be a positive integer.`); + return Number(parsed); +} + +function nonnegativeInt(value: unknown, defaultValue: number | undefined, label: string): number { + const parsed = value === undefined ? defaultValue : value; + if (!Number.isInteger(parsed) || Number(parsed) < 0) throw new Error(`${label} must be a non-negative integer.`); + return Number(parsed); +} + +function boundedInt(value: unknown, min: number, max: number, label: string): number { + if (!Number.isInteger(value) || Number(value) < min || Number(value) > max) { + throw new Error(`${label} must be an integer from ${min} to ${max}.`); + } + return Number(value); +} + +function optionalLoadState(value: unknown, label: string): ModLoadState | undefined { + if (value === undefined) return undefined; + if (value === "load" || value === "domcontentloaded" || value === "networkidle") return value; + throw new Error(`${label} must be load, domcontentloaded, or networkidle.`); +} + +function optionalSelectorState(value: unknown, label: string): ModPageWaitForSelectorParams["state"] | undefined { + if (value === undefined) return undefined; + if (value === "attached" || value === "detached" || value === "visible" || value === "hidden") return value; + throw new Error(`${label} must be attached, detached, visible, or hidden.`); +} + +function requiredScreenshotType(value: unknown, label: string): ModPageScreenshotParams["type"] { + if (value === "png" || value === "jpeg" || value === "webp") return value; + throw new Error(`${label} must be png, jpeg, or webp.`); +} + +function optionalPageExpectation(value: unknown, label: string): ModWaitForPageParams["expected"] { + if (value === undefined) return undefined; + const obj = record(value, label); + return { + url: optionalString(obj.url, `${label}.url`), + urlIncludes: optionalString(obj.urlIncludes, `${label}.urlIncludes`), + }; +} + +function requiredClip(value: unknown, label: string): NonNullable { + const obj = record(value, label); + return { + x: requiredNumber(obj.x, `${label}.x`), + y: requiredNumber(obj.y, `${label}.y`), + width: positiveNumber(obj.width, `${label}.width`), + height: positiveNumber(obj.height, `${label}.height`), + scale: obj.scale === undefined ? undefined : positiveNumber(obj.scale, `${label}.scale`), + }; +} + +function requiredNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a finite number.`); + return value; +} + +function positiveNumber(value: unknown, label: string): number { + const parsed = requiredNumber(value, label); + if (parsed <= 0) throw new Error(`${label} must be positive.`); + return parsed; +} + +/** + * Private replayability-only target journal. + * + * This is intentionally not exported and intentionally does not expose sessions, + * DOM nodes, frame ownership, or attach helpers. It records only the provenance + * needed to bind replayable ModPage IDs to live Chrome targets. + * + * Do not use this as operational truth. Every CDPMod command must still ask CDP + * for live targets/DOM/frame state before acting; this journal may only bind a + * replayable page ID to the live target that Chrome reports right now. + */ +class ReplayOnlyTargetJournal { + private ws: WebSocket | null = null; + private startPromise: Promise | null = null; + private nextId = 1; + private seq = 0; + private readonly pending = new Map< + number, + { resolve: (value: any) => void; reject: (error: Error) => void; method: string } + >(); + private readonly records = new Map(); + private readonly modPages = new Map(); + + constructor(private readonly loopbackCdpUrl: string) {} + + async ensureStarted(): Promise { + if (this.ws && this.ws.readyState === WebSocket.OPEN) return; + if (!this.startPromise) { + this.startPromise = this.start().finally(() => { + this.startPromise = null; + }); + } + await this.startPromise; + } + + nextSeqSnapshot(): number { + return this.seq; + } + + createPageRef(id?: string) { + const pageId = id || `page_${this.modPages.size + 1}`; + return { object: "mod.page" as const, id: pageId }; + } + + bindModPage(pageId: string, targetId: string): void { + const existing = this.modPages.get(pageId); + if (existing && existing.targetId !== targetId) { + throw new Error(`ModPage id "${pageId}" is already bound to a different live target.`); + } + this.modPages.set(pageId, { targetId, boundSeq: ++this.seq }); + } + + targetForModPage(pageId: string, liveTargets: TargetInfoLike[]): TargetInfoLike { + const binding = this.modPages.get(pageId); + if (!binding) { + throw new Error(`Unknown ModPage id "${pageId}". Pages must be created or bound by CDPMod before use.`); + } + const target = liveTargets.find( + (candidate) => candidate.targetId === binding.targetId && candidate.type === "page", + ); + if (!target) throw new Error(`ModPage id "${pageId}" no longer has a live page target.`); + return target; + } + + isModPageBoundToTarget(targetId: string): boolean { + return [...this.modPages.values()].some((binding) => binding.targetId === targetId); + } + + createdAfter(targetId: string, seq: number): boolean { + return (this.records.get(targetId)?.createdSeq ?? Number.MAX_SAFE_INTEGER) > seq; + } + + private async start(): Promise { + this.ws = await openCDPSocket(this.loopbackCdpUrl); + this.ws.addEventListener("message", (event) => this.onMessage(event)); + this.ws.addEventListener("close", () => this.onClose()); + this.ws.addEventListener("error", () => this.onClose()); + + await this.send("Target.setDiscoverTargets", { discover: true }); + const snapshot = await this.send("Target.getTargets"); + for (const targetInfo of snapshot.targetInfos || []) this.upsertTarget(targetInfo); + } + + private onMessage(event: MessageEvent): void { + const data = typeof event.data === "string" ? event.data : String(event.data); + const msg = JSON.parse(data) as CdpMessage; + if (typeof msg.id === "number") { + const pending = this.pending.get(msg.id); + if (!pending) return; + this.pending.delete(msg.id); + if (msg.error) pending.reject(new Error(msg.error.message || `${pending.method} failed`)); + else pending.resolve(msg.result || {}); + return; + } + + if (msg.method === "Target.targetCreated" || msg.method === "Target.targetInfoChanged") { + this.upsertTarget((msg.params as { targetInfo?: TargetInfoLike } | undefined)?.targetInfo); + } else if (msg.method === "Target.targetDestroyed") { + const targetId = (msg.params as { targetId?: string } | undefined)?.targetId; + if (targetId) this.markDestroyed(targetId); + } + } + + private onClose(): void { + for (const [id, pending] of this.pending.entries()) { + pending.reject(new Error(`${pending.method} failed because the replay-only target journal socket closed.`)); + this.pending.delete(id); + } + this.ws = null; + } + + private send(method: string, params: Record = {}): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("Replay-only target journal CDP socket is not open.")); + } + const id = this.nextId++; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject, method }); + }); + } + + private upsertTarget(targetInfo: TargetInfoLike | undefined): void { + if (!targetInfo?.targetId) return; + const existing = this.records.get(targetInfo.targetId); + const record = + existing ?? + ({ + targetId: targetInfo.targetId, + createdSeq: ++this.seq, + latest: targetInfo, + urlHistory: [], + } satisfies ReplayOnlyTargetRecord); + + record.latest = { ...record.latest, ...targetInfo }; + record.destroyedSeq = undefined; + + const url = String(targetInfo.url ?? ""); + const lastUrl = record.urlHistory.at(-1)?.url; + if (url && url !== lastUrl) record.urlHistory.push({ seq: ++this.seq, url }); + this.records.set(record.targetId, record); + } + + private markDestroyed(targetId: string): void { + const record = this.records.get(targetId); + if (!record) return; + record.destroyedSeq = ++this.seq; + } +} + +export function registerReplayableBuiltins(CDPModServer: any, _globalScope: typeof globalThis = globalThis) { + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.open", + handler: async (rawParams: ModOpenPageParams = {} as ModOpenPageParams) => { + const params = parseOpenPageParams(rawParams); + return withCdp(CDPModServer, async ({ call, journal }) => { + const page = journal.createPageRef(params.id); + const { targetId } = await call("Target.createTarget", { url: params.url }); + await waitForPageTarget(call, targetId, params.url); + journal.bindModPage(page.id, targetId); + return { page }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.bind", + handler: async (rawParams: ModBindPageParams = {} as ModBindPageParams) => { + const params = parseBindPageParams(rawParams); + return withCdp(CDPModServer, async ({ call, journal }) => { + const target = (await livePageTargets(call)).find((candidate) => candidate.targetId === params.targetId); + if (!target) throw new Error(`Mod.Page.bind target ${params.targetId} is not a live page target.`); + journal.bindModPage(params.page.id, params.targetId); + return { page: params.page }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.waitFor", + handler: async (rawParams: ModWaitForPageParams = {} as ModWaitForPageParams) => { + const params = parseWaitForPageParams(rawParams); + return withCdp(CDPModServer, async ({ call, journal }) => { + const deadline = Date.now() + params.timeoutMs; + const startSeq = journal.nextSeqSnapshot(); + const baseline = new Set((await livePageTargets(call)).map((target) => target.targetId)); + const page = journal.createPageRef(params.id); + + while (Date.now() < deadline) { + const targets = await livePageTargets(call); + const openerTarget = params.opener ? journal.targetForModPage(params.opener.id, targets) : null; + const candidates = targets.filter((target) => { + if (journal.isModPageBoundToTarget(target.targetId)) return false; + if (baseline.has(target.targetId) && !journal.createdAfter(target.targetId, startSeq)) return false; + if (openerTarget) { + if (target.openerId !== openerTarget.targetId) return false; + if (target.canAccessOpener === false) return false; + } + return pageMatchesExpectation(target, params.expected); + }); + if (candidates.length === 1) { + journal.bindModPage(page.id, candidates[0].targetId); + return { page }; + } + if (candidates.length > 1) { + throw new Error(`Mod.Page.waitFor expected exactly one new page, found ${candidates.length}.`); + } + await sleep(100); + } + throw new Error(`Mod.Page.waitFor timed out after ${params.timeoutMs}ms.`); + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.goto", + handler: async (rawParams: ModPageGotoParams = {} as ModPageGotoParams) => { + const params = parsePageGotoParams(rawParams); + assertSupportedLoadState(params.waitUntil); + return withPageSession(CDPModServer, params.page, async ({ call, pageSessionId }) => { + await call("Page.enable", {}, pageSessionId).catch(() => {}); + const navigation = await call("Page.navigate", { url: params.url }, pageSessionId); + if (typeof navigation.errorText === "string" && navigation.errorText.length > 0) { + throw new Error(`Mod.Page.goto failed: ${navigation.errorText}`); + } + if (params.waitUntil) await waitForLoadState(call, pageSessionId, params.waitUntil, params.timeoutMs); + return navigationResult(params.page, await currentPageUrl(call, pageSessionId)); + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.reload", + handler: async (rawParams: ModPageReloadParams = {} as ModPageReloadParams) => { + const params = parsePageReloadParams(rawParams); + assertSupportedLoadState(params.waitUntil); + return withPageSession(CDPModServer, params.page, async ({ call, pageSessionId }) => { + await call("Page.enable", {}, pageSessionId).catch(() => {}); + await call("Page.reload", { ignoreCache: params.ignoreCache ?? false }, pageSessionId); + if (params.waitUntil) await waitForLoadState(call, pageSessionId, params.waitUntil, params.timeoutMs); + return navigationResult(params.page, await currentPageUrl(call, pageSessionId)); + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.goBack", + handler: async (rawParams: ModPageGoBackParams = {} as ModPageGoBackParams) => { + const params = parsePageGoBackParams(rawParams); + assertSupportedLoadState(params.waitUntil); + return withPageSession(CDPModServer, params.page, async ({ call, pageSessionId }) => { + const entry = await adjacentHistoryEntry(call, pageSessionId, -1, "Mod.Page.goBack"); + await call("Page.navigateToHistoryEntry", { entryId: entry.id }, pageSessionId); + if (params.waitUntil) await waitForLoadState(call, pageSessionId, params.waitUntil, params.timeoutMs); + return navigationResult(params.page, await currentPageUrl(call, pageSessionId, entry.url)); + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.goForward", + handler: async (rawParams: ModPageGoForwardParams = {} as ModPageGoForwardParams) => { + const params = parsePageGoForwardParams(rawParams); + assertSupportedLoadState(params.waitUntil); + return withPageSession(CDPModServer, params.page, async ({ call, pageSessionId }) => { + const entry = await adjacentHistoryEntry(call, pageSessionId, 1, "Mod.Page.goForward"); + await call("Page.navigateToHistoryEntry", { entryId: entry.id }, pageSessionId); + if (params.waitUntil) await waitForLoadState(call, pageSessionId, params.waitUntil, params.timeoutMs); + return navigationResult(params.page, await currentPageUrl(call, pageSessionId, entry.url)); + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.waitForLoadState", + handler: async (rawParams: ModPageWaitForLoadStateParams = {} as ModPageWaitForLoadStateParams) => { + const params = parsePageWaitForLoadStateParams(rawParams); + assertSupportedLoadState(params.state); + return withPageSession(CDPModServer, params.page, async ({ call, pageSessionId }) => { + await waitForLoadState(call, pageSessionId, params.state, params.timeoutMs); + return { page: params.page, state: params.state }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.waitForTimeout", + handler: async (rawParams: ModPageWaitForTimeoutParams = {} as ModPageWaitForTimeoutParams) => { + const params = parsePageWaitForTimeoutParams(rawParams); + await sleep(params.ms); + return { page: params.page, ms: params.ms }; + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.screenshot", + handler: async (rawParams: ModPageScreenshotParams = {} as ModPageScreenshotParams) => { + const params = parsePageScreenshotParams(rawParams); + return withPageSession(CDPModServer, params.page, async ({ call, pageSessionId }) => { + const result = await call( + "Page.captureScreenshot", + { + format: params.type, + captureBeyondViewport: params.fullPage === true, + ...(params.quality === undefined ? {} : { quality: params.quality }), + ...(params.clip === undefined ? {} : { clip: params.clip }), + }, + pageSessionId, + params.timeoutMs, + ); + if (typeof result.data !== "string") throw new Error("Page.captureScreenshot returned no data."); + return { page: params.page, base64: result.data, mimeType: mimeTypeForScreenshot(params.type) }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.evaluate", + handler: async (rawParams: ModPageEvaluateParams = {} as ModPageEvaluateParams) => { + const params = parsePageEvaluateParams(rawParams); + return withResolvedContext(CDPModServer, params, async ({ call, sessionId, root }) => ({ + value: await evaluateInDocumentContext(call, sessionId, root, params.expression, params.arg, { + awaitPromise: params.awaitPromise, + timeoutMs: params.timeoutMs, + }), + })); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Page.waitForSelector", + handler: async (rawParams: ModPageWaitForSelectorParams = {} as ModPageWaitForSelectorParams) => { + const params = parsePageWaitForSelectorParams(rawParams); + const deadline = Date.now() + params.timeoutMs; + let lastError: unknown = null; + while (Date.now() <= deadline) { + try { + const match = await selectorState(CDPModServer, params); + if (match.matched) { + return { + page: params.page, + matched: true as const, + ...(match.node ? { element: elementFromSelectorTarget(params, match.node, params.id) } : {}), + }; + } + } catch (error) { + lastError = error; + } + await sleep(100); + } + if (lastError instanceof Error) throw lastError; + throw new Error(`Mod.Page.waitForSelector timed out after ${params.timeoutMs}ms.`); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.DOM.queryElement", + handler: async (rawParams: ModQueryElementParams = {} as ModQueryElementParams) => { + const params = parseQueryElementParams(rawParams); + return withResolvedContext(CDPModServer, params, async ({ root }) => { + const node = resolveSelectorStrict(root, params.selector, "element"); + return { element: elementFromSelectorTarget(params, node, params.id) }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.DOM.resolveContext", + handler: async (rawParams: ModResolveContextParams = {} as ModResolveContextParams) => { + const params = parseResolveContextParams(rawParams); + return withResolvedContext(CDPModServer, params, async (resolved) => ({ + found: true, + page: params.page, + pageUrl: resolved.targetInfo.url ?? "", + frameDepth: resolved.frameDepth, + })); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.DOM.text", + handler: async (rawParams: ModTextParams = {} as ModTextParams) => { + const params = parseTextParams(rawParams); + const element = elementFromSelectorTarget(params); + return withResolvedElement(CDPModServer, element, async ({ node }) => ({ + text: collectText(node).trim(), + element, + })); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.click", + handler: async (rawParams: ModClickParams = {} as ModClickParams) => { + const params = parseClickParams(rawParams); + const element = elementFromSelectorTarget(params); + return withResolvedElement(CDPModServer, element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const { x, y } = await centerPoint(call, sessionId, node); + await call("Input.dispatchMouseEvent", { type: MOUSE_MOVE, x, y }, sessionId); + await call("Input.dispatchMouseEvent", { type: MOUSE_DOWN, x, y, button: "left", clickCount: 1 }, sessionId); + await dispatchMouseUp(call, sessionId, x, y); + return { clicked: true, element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.type", + handler: async (rawParams: ModTypeParams = {} as ModTypeParams) => { + const params = parseTypeParams(rawParams); + const element = elementFromSelectorTarget(params); + return withResolvedElement(CDPModServer, element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + await call("DOM.focus", { backendNodeId: node.backendNodeId }, sessionId); + await call("Input.insertText", { text: params.text }, sessionId); + return { typed: true, element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.hover", + handler: async (rawParams: ModHoverParams = {} as ModHoverParams) => { + const params = parseHoverParams(rawParams); + const element = elementFromSelectorTarget(params); + return withResolvedElement(CDPModServer, element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const { x, y } = await centerPoint(call, sessionId, node); + await call("Input.dispatchMouseEvent", { type: MOUSE_MOVE, x, y }, sessionId); + return { hovered: true, element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.fill", + handler: async (rawParams: ModFillParams = {} as ModFillParams) => { + const params = parseFillParams(rawParams); + const element = elementFromSelectorTarget(params); + return withResolvedElement(CDPModServer, element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const value = await fillElementValue(call, sessionId, node, params.value); + return { filled: true, value, element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.press", + handler: async (rawParams: ModPressParams = {} as ModPressParams) => { + const params = parsePressParams(rawParams); + return withResolvedContext(CDPModServer, params, async ({ call, pageSessionId, sessionId }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + await dispatchKeyPress(call, sessionId, params.key); + return { pressed: true, key: params.key }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.scroll", + handler: async (rawParams: ModScrollParams = {} as ModScrollParams) => { + const params = parseScrollParams(rawParams); + if (params.selector) { + const element = elementFromSelectorTarget({ ...params, selector: params.selector }); + return withResolvedElement(CDPModServer, element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const { x, y } = await centerPoint(call, sessionId, node); + await synthesizeScroll(call, sessionId, x, y, params.deltaX, params.deltaY); + return { scrolled: true, page: params.page, element }; + }); + } + + return withResolvedContext(CDPModServer, params, async ({ call, pageSessionId, sessionId }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const { x, y } = await viewportCenter(call, sessionId); + await synthesizeScroll(call, sessionId, x, y, params.deltaX, params.deltaY); + return { scrolled: true, page: params.page }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.DOM.elementText", + handler: async (rawParams: ModElementTextParams = {} as ModElementTextParams) => { + const params = parseElementTextParams(rawParams); + return withResolvedElement(CDPModServer, params.element, async ({ node }) => ({ + text: collectText(node).trim(), + element: params.element, + })); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.clickElement", + handler: async (rawParams: ModClickElementParams = {} as ModClickElementParams) => { + const params = parseClickElementParams(rawParams); + return withResolvedElement(CDPModServer, params.element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const { x, y } = await centerPoint(call, sessionId, node); + await call("Input.dispatchMouseEvent", { type: MOUSE_MOVE, x, y }, sessionId); + await call("Input.dispatchMouseEvent", { type: MOUSE_DOWN, x, y, button: "left", clickCount: 1 }, sessionId); + await dispatchMouseUp(call, sessionId, x, y); + return { clicked: true, element: params.element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.hoverElement", + handler: async (rawParams: ModHoverElementParams = {} as ModHoverElementParams) => { + const params = parseHoverElementParams(rawParams); + return withResolvedElement(CDPModServer, params.element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const { x, y } = await centerPoint(call, sessionId, node); + await call("Input.dispatchMouseEvent", { type: MOUSE_MOVE, x, y }, sessionId); + return { hovered: true, element: params.element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.fillElement", + handler: async (rawParams: ModFillElementParams = {} as ModFillElementParams) => { + const params = parseFillElementParams(rawParams); + return withResolvedElement(CDPModServer, params.element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const value = await fillElementValue(call, sessionId, node, params.value); + return { filled: true, value, element: params.element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.pressElement", + handler: async (rawParams: ModPressElementParams = {} as ModPressElementParams) => { + const params = parsePressElementParams(rawParams); + return withResolvedElement(CDPModServer, params.element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + await call("DOM.focus", { backendNodeId: node.backendNodeId }, sessionId); + await dispatchKeyPress(call, sessionId, params.key); + return { pressed: true, key: params.key }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.scrollElement", + handler: async (rawParams: ModScrollElementParams = {} as ModScrollElementParams) => { + const params = parseScrollElementParams(rawParams); + return withResolvedElement(CDPModServer, params.element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + const { x, y } = await centerPoint(call, sessionId, node); + await synthesizeScroll(call, sessionId, x, y, params.deltaX, params.deltaY); + return { scrolled: true, page: params.element.page, element: params.element }; + }); + }, + }); + + CDPModServer.addBuiltinCommand({ + name: "Mod.Input.typeElement", + handler: async (rawParams: ModTypeElementParams = {} as ModTypeElementParams) => { + const params = parseTypeElementParams(rawParams); + return withResolvedElement(CDPModServer, params.element, async ({ call, pageSessionId, sessionId, node }) => { + await call("Page.bringToFront", {}, pageSessionId).catch(() => {}); + await call("DOM.focus", { backendNodeId: node.backendNodeId }, sessionId); + await call("Input.insertText", { text: params.text }, sessionId); + return { typed: true, element: params.element }; + }); + }, + }); +} + +async function withCdp( + server: any, + fn: (ctx: { call: CdpCall; journal: ReplayOnlyTargetJournal }) => Promise, +): Promise { + if (!server.loopback_cdp_url) throw new Error("CDPMod replayable built-ins require loopback_cdp_url."); + + const journal = getReplayOnlyTargetJournal(server.loopback_cdp_url); + await journal.ensureStarted(); + + const ws = await openCDPSocket(server.loopback_cdp_url); + const call = createCdpCall(ws); + try { + return await fn({ call, journal }); + } finally { + ws.close(); + } +} + +function elementFromSelectorTarget(target: ModSelectorTargetParams, node?: DomNode, id?: string): ModElement { + return { + object: "mod.element", + id, + page: target.page, + frames: target.frames || [], + selector: target.selector, + fingerprint: node + ? { + nodeName: nodeName(node), + text: collectText(node).trim().slice(0, 200), + } + : undefined, + }; +} + +async function withResolvedElement( + server: any, + element: ModElement, + fn: (resolved: ResolvedContext & { node: DomNode }) => Promise, +) { + return withResolvedContext(server, element, async (resolved) => { + const node = resolveSelectorStrict(resolved.root, element.selector, "element"); + if (!node.backendNodeId) throw new Error(`Resolved element has no backendNodeId.`); + return fn({ ...resolved, node }); + }); +} + +async function withPageSession( + server: any, + page: ModElement["page"], + fn: (resolved: { + call: CdpCall; + targetInfo: TargetInfoLike; + pageSessionId: string; + cleanupSessionIds: string[]; + }) => Promise, +) { + return withCdp(server, async ({ call, journal }) => { + const cleanupSessionIds: string[] = []; + try { + const targetInfo = journal.targetForModPage(page.id, await livePageTargets(call)); + const { sessionId: pageSessionId } = await call("Target.attachToTarget", { + targetId: targetInfo.targetId, + flatten: true, + }); + cleanupSessionIds.push(pageSessionId); + return await fn({ call, targetInfo, pageSessionId, cleanupSessionIds }); + } finally { + for (const sessionId of cleanupSessionIds.reverse()) { + await call("Target.detachFromTarget", { sessionId }).catch(() => {}); + } + } + }); +} + +async function withResolvedContext( + server: any, + context: { page: ModElement["page"]; frames?: ModFrameHop[] }, + fn: (resolved: ResolvedContext) => Promise, +) { + return withCdp(server, async ({ call, journal }) => { + const cleanupSessionIds: string[] = []; + try { + const targetInfo = journal.targetForModPage(context.page.id, await livePageTargets(call)); + const { sessionId: pageSessionId } = await call("Target.attachToTarget", { + targetId: targetInfo.targetId, + flatten: true, + }); + cleanupSessionIds.push(pageSessionId); + + let sessionId = pageSessionId; + let root = await documentRoot(call, sessionId); + + for (const hop of context.frames || []) { + const frame = await resolveFrameHop(call, sessionId, root, hop); + sessionId = frame.sessionId; + if (frame.cleanupSessionId) cleanupSessionIds.push(frame.cleanupSessionId); + root = frame.root; + } + + return await fn({ + call, + targetInfo, + pageSessionId, + sessionId, + root, + frameDepth: context.frames?.length ?? 0, + cleanupSessionIds, + }); + } finally { + for (const sessionId of cleanupSessionIds.reverse()) { + await call("Target.detachFromTarget", { sessionId }).catch(() => {}); + } + } + }); +} + +async function resolveFrameHop(call: CdpCall, sessionId: string, root: DomNode, hop: ModFrameHop) { + const owner = resolveSelectorStrict(root, hop.owner, "frame owner"); + if (!owner.backendNodeId) throw new Error(`Resolved frame owner has no backendNodeId.`); + const actualName = (owner.nodeName || owner.localName || "").toUpperCase(); + if (actualName !== hop.assertNodeName) { + throw new Error(`Frame owner resolved to ${actualName}, expected ${hop.assertNodeName}.`); + } + + if (owner.contentDocument) { + return { sessionId, root: owner.contentDocument, cleanupSessionId: null }; + } + + const frameId = owner.frameId || (await frameIdForOwner(call, sessionId, owner.backendNodeId)); + if (!frameId) throw new Error(`Could not map frame owner to a frameId.`); + + const iframeTarget = await waitForFrameTarget(call, frameId); + const { sessionId: childSessionId } = await call("Target.attachToTarget", { + targetId: iframeTarget.targetId, + flatten: true, + }); + return { + sessionId: childSessionId, + root: await documentRoot(call, childSessionId), + cleanupSessionId: childSessionId, + }; +} + +async function waitForFrameTarget(call: CdpCall, frameId: string): Promise { + const deadline = Date.now() + DEFAULT_WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + const targets = await call("Target.getTargets"); + const iframeTarget = (targets.targetInfos || []).find( + (target: TargetInfoLike) => + target.targetId === frameId || target.parentFrameId === frameId || target.openerFrameId === frameId, + ); + if (iframeTarget?.targetId) return iframeTarget; + await sleep(100); + } + throw new Error(`Frame ${frameId} did not expose an attachable target. Same-process contentDocument was absent.`); +} + +async function frameIdForOwner(call: CdpCall, sessionId: string, ownerBackendNodeId: number) { + const tree = await call("Page.getFrameTree", {}, sessionId); + for (const frame of flattenFrameTree(tree.frameTree)) { + if (!frame?.id) continue; + try { + const owner = await call("DOM.getFrameOwner", { frameId: frame.id }, sessionId); + if (owner.backendNodeId === ownerBackendNodeId) return frame.id; + } catch {} + } + return null; +} + +function flattenFrameTree(tree: any): any[] { + if (!tree?.frame) return []; + return [tree.frame, ...(tree.childFrames || []).flatMap(flattenFrameTree)]; +} + +async function livePageTargets(call: CdpCall): Promise { + const targets = await call("Target.getTargets"); + return (targets.targetInfos || []).filter((target: TargetInfoLike) => target.type === "page"); +} + +async function waitForPageTarget(call: CdpCall, targetId: string, url: string): Promise { + const deadline = Date.now() + DEFAULT_WAIT_TIMEOUT_MS; + while (Date.now() < deadline) { + const target = (await livePageTargets(call)).find((candidate) => candidate.targetId === targetId); + if (target && (!url || target.url === url)) return target; + await sleep(100); + } + throw new Error(`Timed out waiting for page target ${targetId} to navigate to ${url}.`); +} + +function pageMatchesExpectation(target: TargetInfoLike, expected: ModWaitForPageParams["expected"]): boolean { + if (!expected) return true; + if (expected.url && target.url !== expected.url) return false; + if (expected.urlIncludes && !target.url?.includes(expected.urlIncludes)) return false; + return true; +} + +function navigationResult(page: ModElement["page"], url: string): ModNavigationResult { + return { page, url, response: null }; +} + +function assertSupportedLoadState(state: ModLoadState | undefined): void { + if (state === "networkidle") { + throw new Error( + "Mod.Page networkidle waits are not implemented because replayable request/lifecycle state needs an explicit design.", + ); + } +} + +async function adjacentHistoryEntry(call: CdpCall, sessionId: string, offset: -1 | 1, label: string) { + const history = await call("Page.getNavigationHistory", {}, sessionId); + const currentIndex = typeof history.currentIndex === "number" ? history.currentIndex : -1; + const entries = Array.isArray(history.entries) ? history.entries : []; + const entry = entries[currentIndex + offset]; + if (!entry || typeof entry.id !== "number") { + throw new Error(`${label} cannot navigate without a ${offset < 0 ? "previous" : "next"} history entry.`); + } + return { + id: entry.id as number, + url: typeof entry.url === "string" ? entry.url : undefined, + }; +} + +async function waitForLoadState( + call: CdpCall, + sessionId: string, + state: ModLoadState, + timeoutMs: number, +): Promise { + assertSupportedLoadState(state); + const deadline = Date.now() + timeoutMs; + do { + const readyState = await documentReadyState(call, sessionId); + if (state === "domcontentloaded" && (readyState === "interactive" || readyState === "complete")) return; + if (state === "load" && readyState === "complete") return; + await sleep(100); + } while (Date.now() <= deadline); + throw new Error(`Mod.Page.waitForLoadState timed out after ${timeoutMs}ms waiting for ${state}.`); +} + +async function documentReadyState(call: CdpCall, sessionId: string): Promise { + const result = await call("Runtime.evaluate", { expression: "document.readyState", returnByValue: true }, sessionId); + const value = result.result?.value; + return typeof value === "string" ? value : ""; +} + +async function currentPageUrl(call: CdpCall, sessionId: string, fallback = "about:blank"): Promise { + try { + const result = await call("Runtime.evaluate", { expression: "location.href", returnByValue: true }, sessionId); + return typeof result.result?.value === "string" ? result.result.value : fallback; + } catch { + return fallback; + } +} + +function mimeTypeForScreenshot(type: ModPageScreenshotParams["type"]) { + if (type === "jpeg") return "image/jpeg" as const; + if (type === "webp") return "image/webp" as const; + return "image/png" as const; +} + +async function documentRoot(call: CdpCall, sessionId: string) { + const { root } = await call("DOM.getDocument", { depth: -1, pierce: true }, sessionId); + if (!root) throw new Error("DOM.getDocument returned no root."); + return root as DomNode; +} + +async function selectorState( + server: any, + params: ModPageWaitForSelectorParams, +): Promise<{ matched: boolean; node?: DomNode }> { + return withResolvedContext(server, params, async ({ call, sessionId, root }) => { + const matches = selectNodes(root, params.selector); + if (matches.length > 1) { + throw new Error(`Strict selector ${describeSelector(params.selector)} resolved to ${matches.length} nodes.`); + } + const node = matches[0] ?? null; + if (params.state === "attached") return node ? { matched: true, node } : { matched: false }; + if (params.state === "detached") return { matched: node == null }; + if (!node) return { matched: params.state === "hidden" }; + const visible = await isNodeVisible(call, sessionId, node); + if (params.state === "visible") return visible ? { matched: true, node } : { matched: false }; + return { matched: !visible }; + }); +} + +async function isNodeVisible(call: CdpCall, sessionId: string, node: DomNode): Promise { + if (!node.backendNodeId) return false; + try { + const quads = await call("DOM.getContentQuads", { backendNodeId: node.backendNodeId }, sessionId); + return Array.isArray(quads.quads) && quads.quads.some((quad: unknown) => Array.isArray(quad) && quad.length >= 8); + } catch { + return false; + } +} + +function resolveSelectorStrict(root: DomNode, selector: ModSelector, label: string): DomNode { + const matches = selectNodes(root, selector); + if (matches.length !== 1) { + throw new Error(`Strict ${label} selector ${describeSelector(selector)} resolved to ${matches.length} nodes.`); + } + return matches[0]; +} + +function selectNodes(root: DomNode, selector: ModSelector): DomNode[] { + if (selector.kind === "xpath") return selectXPath(root, selector.xpath); + if (selector.kind === "css") return selectCss(root, selector.selector); + if (selector.kind === "role") return selectRole(root, selector); + return selectText(root, selector); +} + +function selectXPath(root: DomNode, xpath: string): DomNode[] { + if (xpath.startsWith("//")) { + const step = parseXPathStep(xpath.slice(2), xpath); + return descendants(root).filter((node) => matchesStep(node, step)); + } + + const parts = xpath + .split("/") + .filter(Boolean) + .map((part) => parseXPathStep(part, xpath)); + + let current: DomNode[] = [root]; + for (const [partIndex, part] of parts.entries()) { + const next: DomNode[] = []; + for (const node of current) { + if (partIndex === 0 && matchesStep(node, part)) { + next.push(node); + continue; + } + const matches = childNodes(node).filter((child) => matchesStep(child, part)); + if (part.index === null) next.push(...matches); + else if (matches[part.index - 1]) next.push(matches[part.index - 1]); + } + current = next; + if (current.length === 0) return []; + } + return current; +} + +function parseXPathStep(segment: string, xpath: string) { + const idMatch = /^(?\*|[A-Za-z0-9:_#-]+)?\[@id=(?["'])(?.*?)\k\](?:\[(?\d+)\])?$/.exec( + segment, + ); + if (idMatch?.groups) { + return { + name: (idMatch.groups.name || "*").toUpperCase(), + id: idMatch.groups.id, + index: idMatch.groups.index ? Number(idMatch.groups.index) : null, + }; + } + + const structuralMatch = /^(?\*|[A-Za-z0-9:_#-]+)(?:\[(?\d+)\])?$/.exec(segment); + if (!structuralMatch?.groups) throw new Error(`Unsupported XPath segment ${segment} in ${xpath}`); + return { + name: structuralMatch.groups.name.toUpperCase(), + id: null, + index: structuralMatch.groups.index ? Number(structuralMatch.groups.index) : null, + }; +} + +function matchesStep(node: DomNode, step: { name: string; id: string | null }) { + if (step.name !== "#SHADOW-ROOT" && node.nodeType !== 1) return false; + if (step.name !== "*" && nodeName(node) !== step.name) return false; + if (step.id !== null && attribute(node, "id") !== step.id) return false; + return true; +} + +function selectCss(root: DomNode, selector: string): DomNode[] { + const parts = selector.trim().split(/\s+/).filter(Boolean).map(parseSimpleCssSelector); + let current: DomNode[] = [root]; + for (const part of parts) { + current = current.flatMap((node) => descendants(node).filter((candidate) => matchesSimpleCss(candidate, part))); + } + return current; +} + +function parseSimpleCssSelector(selector: string) { + const match = + /^(?[A-Za-z][A-Za-z0-9_-]*)?(?:#(?[A-Za-z0-9_-]+))?(?:\.(?[A-Za-z0-9_-]+))?(?:\[(?[A-Za-z0-9:_-]+)(?:=(?["']?)(?[^\]"']*)\k)?\])?$/.exec( + selector, + ); + if (!match?.groups || (!match.groups.tag && !match.groups.id && !match.groups.className && !match.groups.attr)) { + throw new Error(`Unsupported CDPMod CSS selector "${selector}".`); + } + return { + tag: match.groups.tag?.toUpperCase() ?? null, + id: match.groups.id ?? null, + className: match.groups.className ?? null, + attr: match.groups.attr ?? null, + value: match.groups.value ?? null, + }; +} + +function matchesSimpleCss( + node: DomNode, + selector: { + tag: string | null; + id: string | null; + className: string | null; + attr: string | null; + value: string | null; + }, +): boolean { + if (node.nodeType !== 1) return false; + if (selector.tag && nodeName(node) !== selector.tag) return false; + if (selector.id && attribute(node, "id") !== selector.id) return false; + if (selector.className) { + const classes = (attribute(node, "class") || "").split(/\s+/).filter(Boolean); + if (!classes.includes(selector.className)) return false; + } + if (selector.attr) { + const value = attribute(node, selector.attr); + if (value === null) return false; + if (selector.value !== null && value !== selector.value) return false; + } + return true; +} + +function selectRole(root: DomNode, selector: Extract): DomNode[] { + return descendants(root).filter((node) => { + if (roleOf(node) !== selector.role) return false; + if (selector.name === undefined) return true; + return stringMatches(accessibleName(node), selector.name, selector.exact); + }); +} + +function selectText(root: DomNode, selector: Extract): DomNode[] { + const matching = descendants(root).filter((node) => { + if (node.nodeType !== 1) return false; + return stringMatches(normalizeText(collectText(node)), selector.text, selector.exact); + }); + return matching.filter( + (node) => + !childNodes(node).some( + (child) => + child.nodeType === 1 && stringMatches(normalizeText(collectText(child)), selector.text, selector.exact), + ), + ); +} + +function roleOf(node: DomNode): string | null { + const explicit = attribute(node, "role"); + if (explicit) return explicit; + const name = nodeName(node); + if (name === "BUTTON") return "button"; + if (name === "A" && attribute(node, "href")) return "link"; + if (name === "INPUT") { + const type = (attribute(node, "type") || "text").toLowerCase(); + if (["button", "submit", "reset"].includes(type)) return "button"; + return "textbox"; + } + if (/^H[1-6]$/.test(name)) return "heading"; + return null; +} + +function accessibleName(node: DomNode): string { + return normalizeText( + attribute(node, "aria-label") || attribute(node, "alt") || attribute(node, "title") || collectText(node), + ); +} + +function stringMatches(actual: string, expected: string, exact = true): boolean { + const normalizedActual = normalizeText(actual); + const normalizedExpected = normalizeText(expected); + return exact ? normalizedActual === normalizedExpected : normalizedActual.includes(normalizedExpected); +} + +function normalizeText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function describeSelector(selector: ModSelector): string { + return JSON.stringify(selector); +} + +function descendants(node: DomNode): DomNode[] { + return childNodes(node).flatMap((child) => [child, ...descendants(child)]); +} + +function childNodes(node: DomNode | null): DomNode[] { + if (!node) return []; + return [ + ...(node.children || []), + ...(node.shadowRoots || []), + ...(node.contentDocument ? [node.contentDocument] : []), + ]; +} + +function nodeName(node: DomNode) { + if (node.shadowRootType) return "#SHADOW-ROOT"; + return (node.nodeName || node.localName || "").toUpperCase(); +} + +function attribute(node: DomNode, name: string) { + const attrs = node.attributes || []; + for (let index = 0; index < attrs.length; index += 2) { + if (attrs[index] === name) return attrs[index + 1] ?? null; + } + return null; +} + +function collectText(node: DomNode): string { + if (node.nodeType === 3) return node.nodeValue || ""; + return childNodes(node).map(collectText).join(""); +} + +async function evaluateInDocumentContext( + call: CdpCall, + sessionId: string, + root: DomNode, + expression: string, + arg: unknown, + options: { awaitPromise: boolean; timeoutMs?: number }, +): Promise { + const result = await callFunctionOnNode( + call, + sessionId, + root, + `async function(arg) { + const value = (${expression}); + return typeof value === "function" ? await value.call(globalThis, arg) : await value; + }`, + [{ value: arg }], + { awaitPromise: options.awaitPromise, timeoutMs: options.timeoutMs }, + ); + throwIfRuntimeException(result, "Mod.Page.evaluate"); + return runtimeRemoteObjectValue(result); +} + +async function fillElementValue(call: CdpCall, sessionId: string, node: DomNode, value: string): Promise { + const result = await callFunctionOnNode( + call, + sessionId, + node, + `function(value) { + const element = this; + if (!element || !element.isConnected) return { status: "error", reason: "not connected", value: "" }; + const win = element.ownerDocument?.defaultView ?? globalThis; + const dispatch = () => { + element.dispatchEvent(new win.Event("input", { bubbles: true, composed: true })); + element.dispatchEvent(new win.Event("change", { bubbles: true })); + }; + element.focus?.(); + if (element instanceof win.HTMLInputElement || element instanceof win.HTMLTextAreaElement) { + const proto = element instanceof win.HTMLTextAreaElement ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set; + if (typeof setter === "function") setter.call(element, value); + else element.value = value; + dispatch(); + return { status: "done", value: String(element.value ?? "") }; + } + if (element.isContentEditable) { + element.textContent = value; + dispatch(); + return { status: "done", value: String(element.textContent ?? "") }; + } + return { status: "error", reason: "unsupported element", value: "" }; + }`, + [{ value }], + { awaitPromise: true }, + ); + throwIfRuntimeException(result, "Mod.Input.fillElement"); + const fillResult = runtimeRemoteObjectValue(result); + if (!fillResult || typeof fillResult !== "object") throw new Error("Mod.Input.fillElement returned no fill result."); + const status = (fillResult as { status?: unknown }).status; + if (status === "error") { + const reason = (fillResult as { reason?: unknown }).reason; + throw new Error(typeof reason === "string" ? reason : "Failed to fill element."); + } + const filledValue = (fillResult as { value?: unknown }).value; + return typeof filledValue === "string" ? filledValue : value; +} + +async function callFunctionOnNode( + call: CdpCall, + sessionId: string, + node: DomNode, + functionDeclaration: string, + args: Array>, + options: { awaitPromise: boolean; timeoutMs?: number }, +) { + if (!node.backendNodeId) throw new Error("Resolved node has no backendNodeId."); + const resolved = await call("DOM.resolveNode", { backendNodeId: node.backendNodeId }, sessionId); + const objectId = resolved.object?.objectId; + if (typeof objectId !== "string") throw new Error("DOM.resolveNode returned no objectId."); + try { + return await call( + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration, + arguments: args, + awaitPromise: options.awaitPromise, + returnByValue: true, + }, + sessionId, + options.timeoutMs, + ); + } finally { + await call("Runtime.releaseObject", { objectId }, sessionId).catch(() => {}); + } +} + +function throwIfRuntimeException(result: any, label: string): void { + if (!result?.exceptionDetails) return; + const exception = result.exceptionDetails.exception; + const message = + (exception && typeof exception.description === "string" ? exception.description : null) || + (typeof result.exceptionDetails.text === "string" ? result.exceptionDetails.text : null) || + `${label} threw an exception.`; + throw new Error(message); +} + +function runtimeRemoteObjectValue(result: any): unknown { + const remote = result?.result; + if (!remote || typeof remote !== "object") return undefined; + return "value" in remote ? remote.value : undefined; +} + +async function centerPoint(call: CdpCall, sessionId: string, node: DomNode) { + await call("DOM.scrollIntoViewIfNeeded", { backendNodeId: node.backendNodeId }, sessionId).catch(() => {}); + const quads = await call("DOM.getContentQuads", { backendNodeId: node.backendNodeId }, sessionId); + const quad = quads.quads?.[0]; + if (!quad || quad.length < 8) + throw new Error(`DOM.getContentQuads returned no quad for backendNodeId ${node.backendNodeId}`); + return { + x: (quad[0] + quad[2] + quad[4] + quad[6]) / 4, + y: (quad[1] + quad[3] + quad[5] + quad[7]) / 4, + }; +} + +async function viewportCenter(call: CdpCall, sessionId: string) { + const metrics = await call("Page.getLayoutMetrics", {}, sessionId); + const viewport = metrics.cssVisualViewport || metrics.visualViewport || {}; + const width = typeof viewport.clientWidth === "number" ? viewport.clientWidth : 1280; + const height = typeof viewport.clientHeight === "number" ? viewport.clientHeight : 720; + return { x: width / 2, y: height / 2 }; +} + +async function synthesizeScroll( + call: CdpCall, + sessionId: string, + x: number, + y: number, + deltaX: number, + deltaY: number, +): Promise { + await call( + "Input.synthesizeScrollGesture", + { + x, + y, + xDistance: -deltaX, + yDistance: -deltaY, + }, + sessionId, + ); +} + +async function dispatchKeyPress(call: CdpCall, sessionId: string, key: string): Promise { + await call("Input.dispatchKeyEvent", { type: "keyDown", key }, sessionId); + await call("Input.dispatchKeyEvent", { type: "keyUp", key }, sessionId); +} + +async function dispatchMouseUp(call: CdpCall, sessionId: string, x: number, y: number): Promise { + try { + await call( + "Input.dispatchMouseEvent", + { type: MOUSE_UP, x, y, button: "left", clickCount: 1 }, + sessionId, + POPUP_OPENING_MOUSE_UP_TIMEOUT_MS, + ); + } catch (error) { + if (error instanceof Error && error.message.includes("timed out")) return; + throw error; + } +} + +async function openCDPSocket(endpoint: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(endpoint); + ws.addEventListener("open", () => resolve(ws), { once: true }); + ws.addEventListener("error", reject, { once: true }); + }); +} + +function createCdpCall(ws: WebSocket): CdpCall { + let nextId = 1; + return ( + method: string, + params: Record = {}, + sessionId: string | null = null, + timeoutMs?: number, + ) => { + const id = nextId++; + const message: Record = { id, method, params }; + if (sessionId) message.sessionId = sessionId; + ws.send(JSON.stringify(message)); + return new Promise((resolve, reject) => { + let timeout: ReturnType | null = null; + const cleanup = () => { + ws.removeEventListener("message", listener); + if (timeout) clearTimeout(timeout); + timeout = null; + }; + const listener = (event: MessageEvent) => { + const data = typeof event.data === "string" ? event.data : String(event.data); + const msg = JSON.parse(data) as CdpMessage; + if (msg.id !== id) return; + cleanup(); + if (msg.error) reject(new Error(msg.error.message || `${method} failed`)); + else resolve(msg.result || {}); + }; + ws.addEventListener("message", listener); + ws.addEventListener("error", reject, { once: true }); + if (timeoutMs) { + timeout = setTimeout(() => { + cleanup(); + reject(new Error(`${method} timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + } + }); + }; +} + +async function sleep(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/extension/service_worker.ts b/extension/service_worker.ts index 35c5e88..82e1ad1 100644 --- a/extension/service_worker.ts +++ b/extension/service_worker.ts @@ -1,4 +1,6 @@ // Extension service worker entry point. Importing CDPModServer installs it on // globalThis and starts best-effort keepalive setup. -import "./CDPModServer.js"; +import { CDPModServer } from "./CDPModServer.js"; + +globalThis.CDPMod = CDPModServer; diff --git a/package.json b/package.json index 1cfbca0..c3116e6 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,28 @@ "url": "git+https://github.com/pirate/cdpmod.git" }, "type": "module", + "types": "./types/client.d.ts", "exports": { ".": { - "types": "./client/js/CDPModClient.ts", + "types": "./types/client.d.ts", "import": "./dist/client/js/CDPModClient.js" }, "./client": { - "types": "./client/js/CDPModClient.ts", + "types": "./types/client.d.ts", "import": "./dist/client/js/CDPModClient.js" }, "./extension/service_worker.js": "./dist/extension/service_worker.js", "./extension/CDPModServer.js": "./dist/extension/CDPModServer.js", "./extension/translate.js": "./dist/extension/translate.js", "./bridge/translate.js": "./dist/bridge/translate.js", + "./types/replayable.js": { + "types": "./types/replayable.ts", + "import": "./dist/types/replayable.js" + }, + "./types/cdpmod.js": { + "types": "./types/cdpmod.ts", + "import": "./dist/types/cdpmod.js" + }, "./types/*": "./dist/types/*", "./types/zod/*": "./dist/types/zod/*" }, @@ -37,8 +46,9 @@ "demo:go": "pnpm run build && (cd client/go && go run ./demo)", "demo:proxy:playwright": "pnpm run build && node dist/client/js/examples/playwright.js", "demo:proxy:puppeteer": "pnpm run build && node dist/client/js/examples/puppeteer.js", + "example:replay:hn": "pnpm run build && node examples/replay-hacker-news.js", "proxy": "pnpm run build && node dist/bridge/proxy.js", - "test": "pnpm run build && node --test \"dist/test/*.test.js\"", + "test": "pnpm run build && node --test --test-concurrency=2 \"dist/test/*.test.js\"", "prepare": "pnpm exec prek install" }, "dependencies": { diff --git a/test/replayable-path-builtins.test.ts b/test/replayable-path-builtins.test.ts new file mode 100644 index 0000000..07502d4 --- /dev/null +++ b/test/replayable-path-builtins.test.ts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import test from "node:test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AddressInfo } from "node:net"; +import { chromium } from "playwright"; + +import { launchChrome } from "../bridge/launcher.js"; +import { CDPModClient } from "../client/js/CDPModClient.js"; +import type { ModElement, ModPage } from "../types/replayable.js"; +import { + clickElement, + css, + element, + elementText, + frame, + openModPage, + queryModElement, + role, + textSelector, + typeElement, + xpath, +} from "./replayable-test-helpers.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_PATH = path.resolve(HERE, "..", "extension"); +const HOST_RULES = "MAP parent.magic-cdp.test 127.0.0.1,MAP child.magic-cdp.test 127.0.0.1"; + +type Fixture = { + parentUrl: string; + close(): Promise; + clickedCount(): number; +}; + +test( + "Mod.DOM.elementText resolves a replayable page/frame/node path through an OOPIF", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openModPage(cdp, "stripe-main", `${fixture.parentUrl}/parent.html`); + const cardNumber = stripeCardNumberPath(page); + + const result = await elementText(cdp, cardNumber); + + assert.equal(result, "Card number"); + }); + }, +); + +test( + "Mod.Input.clickElement accepts only a replayable element path and resolves live ids internally", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openModPage(cdp, "stripe-main", `${fixture.parentUrl}/parent.html`); + const payButton = stripePayButtonPath(page); + + await clickElement(cdp, payButton); + await waitFor(() => fixture.clickedCount() === 1, "expected replayable path click to reach the OOPIF button"); + }); + }, +); + +test("Mod.Input.typeElement re-resolves the frame path after the owner iframe moves", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openModPage(cdp, "stripe-main", `${fixture.parentUrl}/parent.html`); + const cardInput = stripeCardInputPath(page); + + await clickElement(cdp, moveFrameButtonPath(page)); + await typeElement(cdp, cardInput, "4242424242424242"); + }); +}); + +test("Mod.DOM.queryElement returns a replayable ModElement from a strict selector", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openModPage(cdp, "selectors-main", `${fixture.parentUrl}/parent.html`); + const button = await queryModElement(cdp, "move-button", page, [], role("button", "Move payment frame")); + + assert.equal(button.object, "mod.element"); + assert.equal(button.page.id, page.id); + assert.equal(await elementText(cdp, button), "Move payment frame"); + }); +}); + +test("ModPageHandle exposes selector-only page and frame actions", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await cdp.refs.openPage({ id: "dx-main", url: `${fixture.parentUrl}/parent.html` }); + + assert.deepEqual(JSON.parse(JSON.stringify(page)), { object: "mod.page", id: "dx-main" }); + assert.equal(await page.text(role("button", "Move payment frame")), "Move payment frame"); + + const moveResult = (await page.send("Mod.Input.click", { + selector: role("button", "Move payment frame"), + })) as { clicked?: boolean }; + assert.equal(moveResult.clicked, true); + + const stripe = page.frame(css("#payment-frame")); + assert.equal(await stripe.text(role("textbox", "Card number")), ""); + + const input = await stripe.query(role("textbox", "Card number"), { id: "card-input" }); + assert.equal(input.object, "mod.element"); + assert.equal(input.page.id, page.id); + assert.equal(input.frames.length, 1); + + await stripe.type(role("textbox", "Card number"), "4242424242424242"); + await stripe.click(role("button", "Pay")); + await waitFor(() => fixture.clickedCount() === 1, "expected selector-only page handle click to reach fixture"); + }); +}); + +test("CDPMod selectors fail when a selector does not resolve to exactly one node", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openModPage(cdp, "selectors-main", `${fixture.parentUrl}/ambiguous.html`); + + await assert.rejects( + () => queryModElement(cdp, "ambiguous-button", page, [], css("button")), + /exactly one|resolved to 2|Strict/i, + ); + await assert.rejects( + () => queryModElement(cdp, "missing-button", page, [], textSelector("missing")), + /exactly one|resolved to 0|Strict/i, + ); + }); +}); + +test("Mod.Page.waitFor ignores pages that already existed before the wait started", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const url = `${fixture.parentUrl}/parent.html`; + const { targetId } = (await cdp.Target.createTarget({ url })) as { targetId: string }; + await waitForRawTargetUrl(cdp, targetId, url); + + await assert.rejects( + () => cdp.send("Mod.Page.waitFor", { id: "preexisting-page", expected: { url }, timeoutMs: 1000 }), + /timed out|timeout/i, + ); + }); +}); + +function stripeCardNumberPath(page: ModPage): ModElement { + return element(page, stripeFrames(), xpath("/html[1]/body[1]/label[1]")); +} + +function stripeCardInputPath(page: ModPage): ModElement { + return element(page, stripeFrames(), xpath("/html[1]/body[1]/input[1]")); +} + +function stripePayButtonPath(page: ModPage): ModElement { + return element(page, stripeFrames(), xpath("/html[1]/body[1]/button[1]")); +} + +function moveFrameButtonPath(page: ModPage): ModElement { + return element(page, [], xpath("/html[1]/body[1]/button[1]")); +} + +function stripeFrames() { + return [frame(css("#payment-frame"))]; +} + +async function withFixtureAndClient( + fn: (args: { fixture: Fixture; cdp: CDPModClient }) => Promise, +): Promise { + const fixture = await startFixture(); + const chrome = await launchChrome({ + executable_path: chromium.executablePath(), + headless: true, + sandbox: process.platform !== "linux", + extra_args: [ + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + "--site-per-process", + `--host-resolver-rules=${HOST_RULES}`, + ], + }); + const cdp = new CDPModClient({ + cdp_url: chrome.cdpUrl, + routes: { + "Mod.*": "service_worker", + "*.*": "direct_cdp", + }, + server: { + loopback_cdp_url: chrome.cdpUrl, + routes: { "*.*": "loopback_cdp" }, + }, + }); + + try { + await cdp.connect(); + await fn({ fixture, cdp }); + } finally { + await cdp.close().catch(() => {}); + await chrome.close(); + await fixture.close(); + } +} + +async function startFixture(): Promise { + let clicked = 0; + const childServer = http.createServer((req, res) => { + if (req.method === "POST" && req.url === "/clicked") { + clicked += 1; + res.writeHead(204).end(); + return; + } + if (req.url === "/stripe.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + + + `); + return; + } + res.writeHead(404).end(); + }); + const childPort = await listen(childServer); + + const parentServer = http.createServer((req, res) => { + if (req.url === "/parent.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + +
+
+ +
+
+ + + `); + return; + } + if (req.url === "/ambiguous.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + `); + return; + } + if (req.url === "/opens-popup.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + `); + return; + } + if (req.url === "/popup.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(`

Popup checkout

`); + return; + } + res.writeHead(404).end(); + }); + const parentPort = await listen(parentServer); + + return { + parentUrl: `http://parent.magic-cdp.test:${parentPort}`, + close: async () => { + await Promise.all([closeServer(parentServer), closeServer(childServer)]); + }, + clickedCount: () => clicked, + }; +} + +function fixtureOrigin(req: http.IncomingMessage): string { + const host = req.headers.host; + assert.equal(typeof host, "string", "fixture request should include host header"); + return `http://${host}`; +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", resolve); + server.once("error", reject); + }); + return (server.address() as AddressInfo).port; +} + +async function closeServer(server: http.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +async function waitForRawTargetUrl(cdp: CDPModClient, targetId: string, href: string): Promise { + await waitFor(async () => { + const targets = (await cdp.Target.getTargets()) as { targetInfos?: { targetId?: string; url?: string }[] }; + return targets.targetInfos?.some((target) => target.targetId === targetId && target.url === href); + }, `expected raw target ${targetId} to navigate to ${href}`); +} + +async function waitFor(predicate: () => boolean | Promise, message: string): Promise { + const deadline = Date.now() + 10_000; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`${message}${lastError instanceof Error ? `: ${lastError.message}` : ""}`); +} diff --git a/test/replayable-test-helpers.ts b/test/replayable-test-helpers.ts new file mode 100644 index 0000000..0ad702c --- /dev/null +++ b/test/replayable-test-helpers.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; + +import { CDPModClient } from "../client/js/CDPModClient.js"; +import type { ModElement, ModFrameHop, ModPage, ModSelector } from "../types/replayable.js"; +import { ModElementSchema, ModPageSchema } from "../types/replayable.js"; + +export function xpath(value: string): ModSelector { + return { kind: "xpath", xpath: value }; +} + +export function css(value: string): ModSelector { + return { kind: "css", selector: value }; +} + +export function role(role: string, name?: string, exact = true): ModSelector { + return { kind: "role", role, name, exact }; +} + +export function textSelector(text: string, exact = true): ModSelector { + return { kind: "text", text, exact }; +} + +export function frame(owner: ModSelector, assertNodeName: "IFRAME" | "FRAME" = "IFRAME"): ModFrameHop { + return { owner, assertNodeName }; +} + +export function element(page: ModPage, frames: ModFrameHop[], selector: ModSelector, id?: string): ModElement { + return ModElementSchema.parse({ + object: "mod.element", + id, + page, + frames, + selector, + }); +} + +export async function openModPage(cdp: CDPModClient, id: string, url: string): Promise { + const result = (await cdp.send("Mod.Page.open", { id, url })) as { page: unknown }; + return ModPageSchema.parse(result.page); +} + +export async function waitForModPage( + cdp: CDPModClient, + id: string, + params: { opener?: ModPage; expected?: { url?: string; urlIncludes?: string }; timeoutMs?: number }, +): Promise { + const result = (await cdp.send("Mod.Page.waitFor", { id, ...params })) as { page: unknown }; + return ModPageSchema.parse(result.page); +} + +export async function queryModElement( + cdp: CDPModClient, + id: string, + page: ModPage, + frames: ModFrameHop[], + selector: ModSelector, +): Promise { + const result = (await cdp.send("Mod.DOM.queryElement", { id, page, frames, selector })) as { element: unknown }; + return ModElementSchema.parse(result.element); +} + +export async function elementText(cdp: CDPModClient, modElement: ModElement): Promise { + return ((await cdp.send("Mod.DOM.elementText", { element: modElement })) as { text: string }).text; +} + +export async function clickElement(cdp: CDPModClient, modElement: ModElement): Promise { + const result = (await cdp.send("Mod.Input.clickElement", { element: modElement })) as { clicked?: boolean }; + assert.equal(result.clicked, true); +} + +export async function typeElement(cdp: CDPModClient, modElement: ModElement, value: string): Promise { + const result = (await cdp.send("Mod.Input.typeElement", { element: modElement, text: value })) as { typed?: boolean }; + assert.equal(result.typed, true); +} diff --git a/test/routed-default-overrides.test.ts b/test/routed-default-overrides.test.ts index 88e7db4..5ddd091 100644 --- a/test/routed-default-overrides.test.ts +++ b/test/routed-default-overrides.test.ts @@ -3,6 +3,8 @@ import test from "node:test"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + import { launchChrome } from "../bridge/launcher.js"; import { CDPModClient } from "../client/js/CDPModClient.js"; import { commands, events } from "../types/zod.js"; @@ -114,9 +116,10 @@ async (payload, next) => { test("service-worker routed standard CDP commands and events can be transformed", { timeout: 45_000 }, async () => { const chrome = await launchChrome({ - headless: process.platform === "linux", + executable_path: chromium.executablePath(), + headless: true, sandbox: process.platform !== "linux", - extra_args: [`--load-extension=${EXTENSION_PATH}`], + extra_args: [`--disable-extensions-except=${EXTENSION_PATH}`, `--load-extension=${EXTENSION_PATH}`], }); const cdp = new CDPModClient({ cdp_url: chrome.cdpUrl, diff --git a/test/upstream-playwright-frame-owner.test.ts b/test/upstream-playwright-frame-owner.test.ts new file mode 100644 index 0000000..0bfcc6f --- /dev/null +++ b/test/upstream-playwright-frame-owner.test.ts @@ -0,0 +1,359 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +import { launchChrome } from "../bridge/launcher.js"; +import { CDPModClient } from "../client/js/CDPModClient.js"; +import type { ModElement, ModFrameHop, ModPage } from "../types/replayable.js"; +import { element, elementText, frame, openModPage, typeElement, xpath } from "./replayable-test-helpers.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_PATH = path.resolve(HERE, "..", "extension"); +const HOST_RULES = "MAP parent.magic-cdp.test 127.0.0.1,MAP child.magic-cdp.test 127.0.0.1"; + +type Fixture = { + parentUrl: string; + childUrl: string; + close(): Promise; + clickedCount(): number; +}; + +test( + "Playwright contentFrame/frameElement: owner iframe path resolves same-process frame content", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "simple", `${fixture.parentUrl}/simple.html`); + + const result = await elementText(cdp, elementPath(page, simpleFrame(), "/html[1]/body[1]/h1[1]")); + + assert.equal(result, "Hello iframe"); + }); + }, +); + +test( + "Playwright ownerFrame: element path resolves the frame that owns a child element", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "simple", `${fixture.parentUrl}/simple.html`); + + await typeElement( + cdp, + elementPath(page, simpleFrame(), "/html[1]/body[1]/input[1]"), + "typed through owner frame", + ); + const value = await elementText(cdp, elementPath(page, simpleFrame(), "/html[1]/body[1]/output[1]")); + + assert.equal(value, "typed through owner frame"); + }); + }, +); + +test( + "Playwright frameElement: nested owner iframe path resolves nested frame content", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "nested", `${fixture.parentUrl}/nested.html`); + + const result = await elementText(cdp, elementPath(page, nestedFrame(), "/html[1]/body[1]/button[1]")); + + assert.equal(result, "Hello nested iframe"); + }); + }, +); + +test( + "Playwright contentFrame: owner iframe path resolves cross-process iframe content", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "oopif", `${fixture.parentUrl}/oopif.html`); + + const result = (await cdp.send("Mod.Input.clickElement", { + element: elementPath(page, oopifFrame(), "/html[1]/body[1]/button[1]"), + })) as { clicked?: boolean }; + + assert.equal(result.clicked, true); + await waitFor(() => fixture.clickedCount() === 1, "expected click inside cross-process iframe to reach fixture"); + }); + }, +); + +test("Playwright frameElement: detached owner iframe rejects replayable frame paths", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "detached", `${fixture.parentUrl}/detached.html`); + await cdp.send("Mod.Input.clickElement", { + element: elementPath(page, [], "/html[1]/body[1]/button[1]"), + }); + + await assert.rejects( + () => + cdp.send("Mod.DOM.elementText", { + element: elementPath(page, [frame(xpath("/html[1]/body[1]/iframe[1]"))], "/html[1]/body[1]/h1[1]"), + }), + /iframe|frame|owner|detached|not found/i, + ); + }); +}); + +test( + "Playwright frameLocator: non-frame owner elements fail the frame hop assertion", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "non-frame-owner", `${fixture.parentUrl}/non-frame-owner.html`); + + await assert.rejects( + () => + cdp.send("Mod.DOM.elementText", { + element: elementPath(page, [frame(xpath("/html[1]/body[1]/div[1]"))], "/html[1]/body[1]/button[1]"), + }), + /iframe|frame|expected|node/i, + ); + }); + }, +); + +test( + "Playwright frameElement: iframe in shadow DOM remains addressable as a frame owner", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "shadow", `${fixture.parentUrl}/shadow.html`); + + const result = await elementText( + cdp, + elementPath( + page, + [frame(xpath("/html[1]/body[1]/div[1]/#shadow-root[1]/iframe[1]"))], + "/html[1]/body[1]/h1[1]", + ), + ); + + assert.equal(result, "Hello shadow iframe"); + }); + }, +); + +test("Playwright frameLocator: ambiguous iframe owner path fails strict resolution", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await openPage(cdp, "ambiguous", `${fixture.parentUrl}/ambiguous.html`); + + await assert.rejects( + () => + cdp.send("Mod.DOM.elementText", { + element: elementPath(page, [frame(xpath("//iframe"))], "/html[1]/body[1]/button[1]"), + }), + /strict|ambiguous|multiple|resolved to 3|3 elements/i, + ); + }); +}); + +function simpleFrame(): ModFrameHop[] { + return [frame(xpath("/html[1]/body[1]/iframe[1]"))]; +} + +function nestedFrame(): ModFrameHop[] { + return [frame(xpath("/html[1]/body[1]/iframe[1]")), frame(xpath("/html[1]/body[1]/div[1]/iframe[1]"))]; +} + +function oopifFrame(): ModFrameHop[] { + return [frame(xpath("/html[1]/body[1]/iframe[1]"))]; +} + +function elementPath(page: ModPage, frames: ModFrameHop[], xpathValue: string): ModElement { + return element(page, frames, xpath(xpathValue)); +} + +async function withFixtureAndClient( + fn: (args: { fixture: Fixture; cdp: CDPModClient }) => Promise, +): Promise { + const fixture = await startFixture(); + const chrome = await launchChrome({ + executable_path: chromium.executablePath(), + headless: true, + sandbox: process.platform !== "linux", + extra_args: [ + "--enable-unsafe-extension-debugging", + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + "--site-per-process", + `--host-resolver-rules=${HOST_RULES}`, + "--remote-allow-origins=*", + ], + }); + const cdp = new CDPModClient({ + cdp_url: chrome.cdpUrl, + routes: { + "Mod.*": "service_worker", + "*.*": "direct_cdp", + }, + server: { + loopback_cdp_url: chrome.cdpUrl, + routes: { "*.*": "loopback_cdp" }, + }, + }); + + try { + await cdp.connect(); + await fn({ fixture, cdp }); + } finally { + await cdp.close().catch(() => {}); + await chrome.close(); + await fixture.close(); + } +} + +async function openPage(cdp: CDPModClient, id: string, url: string): Promise { + return openModPage(cdp, id, url); +} + +async function startFixture(): Promise { + let clicked = 0; + const childServer = http.createServer((req, res) => { + if (req.method === "POST" && req.url === "/clicked") { + clicked += 1; + res.writeHead(204).end(); + return; + } + if (req.url === "/oopif-child.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + `); + return; + } + res.writeHead(404).end(); + }); + const childPort = await listen(childServer); + const childUrl = `http://child.magic-cdp.test:${childPort}`; + + const parentServer = http.createServer((req, res) => { + if (req.url === "/simple.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + `); + return; + } + if (req.url === "/nested.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + `); + return; + } + if (req.url === "/oopif.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + `); + return; + } + if (req.url === "/detached.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + `); + return; + } + if (req.url === "/non-frame-owner.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + +
+ + `); + return; + } + if (req.url === "/shadow.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + +
+ + + `); + return; + } + if (req.url === "/ambiguous.html") { + res.writeHead(200, { "content-type": "text/html" }); + res.end(` + + + + + + + `); + return; + } + res.writeHead(404).end(); + }); + const parentPort = await listen(parentServer); + + return { + parentUrl: `http://parent.magic-cdp.test:${parentPort}`, + childUrl, + close: async () => { + await Promise.all([closeServer(parentServer), closeServer(childServer)]); + }, + clickedCount: () => clicked, + }; +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", resolve); + server.once("error", reject); + }); + return (server.address() as AddressInfo).port; +} + +async function closeServer(server: http.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +async function waitFor(predicate: () => boolean | Promise, message: string): Promise { + const deadline = Date.now() + 10_000; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`${message}${lastError instanceof Error ? `: ${lastError.message}` : ""}`); +} diff --git a/test/upstream-playwright-popup.test.ts b/test/upstream-playwright-popup.test.ts new file mode 100644 index 0000000..b1d43b3 --- /dev/null +++ b/test/upstream-playwright-popup.test.ts @@ -0,0 +1,386 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +import { launchChrome } from "../bridge/launcher.js"; +import { CDPModClient } from "../client/js/CDPModClient.js"; +import type { ModElement, ModPage } from "../types/replayable.js"; +import { + clickElement, + element as modElement, + elementText, + openModPage, + waitForModPage, + xpath, +} from "./replayable-test-helpers.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_PATH = path.resolve(HERE, "..", "extension"); + +type Fixture = { + origin: string; + close(): Promise; +}; + +test("Playwright popup: window.open creates a bindable ModPage", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await openModPage(cdp, "window-open", `${fixture.origin}/window-open.html`); + const popupPromise = waitForModPage(cdp, "window-open-popup", { + opener, + expected: { url: `${fixture.origin}/window-open-popup.html` }, + }); + + await sleep(100); + await click(cdp, element(opener, "/html[1]/body[1]/button[1]")); + const popup = await popupPromise; + + assert.equal(await text(cdp, element(popup, "/html[1]/body[1]/h1[1]")), "window.open popup"); + }); +}); + +test( + "Playwright popup: target=_blank rel=opener is bindable through its opener ModPage", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await openModPage(cdp, "target-blank-opener", `${fixture.origin}/target-blank-opener.html`); + const popupPromise = waitForModPage(cdp, "target-blank-opener-popup", { + opener, + expected: { url: `${fixture.origin}/target-blank-opener-popup.html` }, + }); + + await sleep(100); + await click(cdp, element(opener, "//*[@id='blank-opener']")); + const popup = await popupPromise; + + assert.equal(await text(cdp, element(popup, "/html[1]/body[1]/h1[1]")), "target blank opener"); + }); + }, +); + +test("Playwright popup: noopener target is not bindable through its opener ModPage", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await openModPage(cdp, "target-blank-noopener", `${fixture.origin}/target-blank-noopener.html`); + const popupUrl = `${fixture.origin}/target-blank-noopener-popup.html`; + const openerScopedRejection = assert.rejects( + waitForModPage(cdp, "target-blank-noopener-popup-via-opener", { + opener, + expected: { url: popupUrl }, + timeoutMs: 1000, + }), + /timed out|timeout/i, + ); + const popupWait = waitForModPage(cdp, "target-blank-noopener-popup", { expected: { url: popupUrl } }); + + await sleep(100); + await click(cdp, element(opener, "//*[@id='blank-noopener']")); + const popupTarget = await waitForTargetUrl(cdp, popupUrl); + const popup = await popupWait; + + assert.equal(popupTarget.canAccessOpener, false); + await openerScopedRejection; + assert.equal(await text(cdp, element(popup, "/html[1]/body[1]/h1[1]")), "target blank noopener"); + }); +}); + +test("Playwright popup: about:blank popup remains bound after navigation", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await openModPage(cdp, "about-blank-opener", `${fixture.origin}/about-blank-then-navigate.html`); + const popupPromise = waitForModPage(cdp, "about-blank-popup", { opener }); + + await sleep(100); + await click(cdp, element(opener, "/html[1]/body[1]/button[1]")); + const popup = await popupPromise; + + await waitFor( + async () => (await text(cdp, element(popup, "/html[1]/body[1]/h1[1]"))) === "initial blank popup", + "expected bound popup page to resolve the initial about:blank popup document", + ); + + await waitForTargetUrl(cdp, `${fixture.origin}/navigated-popup.html`); + assert.equal(await text(cdp, element(popup, "/html[1]/body[1]/h1[1]")), "navigated popup"); + }); +}); + +test( + "Playwright popup: multiple popups from one opener bind to explicit ModPage ids", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await openModPage(cdp, "multiple-popups", `${fixture.origin}/multiple-popups.html`); + const firstPromise = waitForModPage(cdp, "first-popup", { + opener, + expected: { url: `${fixture.origin}/first-popup.html` }, + }); + + await sleep(100); + await click(cdp, element(opener, "//*[@id='first']")); + const firstPopup = await firstPromise; + + const secondPromise = waitForModPage(cdp, "second-popup", { + opener, + expected: { url: `${fixture.origin}/second-popup.html` }, + }); + + await sleep(100); + await click(cdp, element(opener, "//*[@id='second']")); + const secondPopup = await secondPromise; + + assert.equal(await text(cdp, element(firstPopup, "/html[1]/body[1]/h1[1]")), "first popup"); + assert.equal(await text(cdp, element(secondPopup, "/html[1]/body[1]/h1[1]")), "second popup"); + }); + }, +); + +test("Playwright popup: popup close invalidates the bound ModPage", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await openModPage(cdp, "close-popup-opener", `${fixture.origin}/close-popup.html`); + const popupPromise = waitForModPage(cdp, "close-popup", { + opener, + expected: { url: `${fixture.origin}/closable-popup.html` }, + }); + + await sleep(100); + await click(cdp, element(opener, "//*[@id='open-close-popup']")); + const popup = await popupPromise; + const target = await waitForTargetUrl(cdp, `${fixture.origin}/closable-popup.html`); + + assert.equal(await text(cdp, element(popup, "/html[1]/body[1]/h1[1]")), "closable popup"); + await click(cdp, element(popup, "//*[@id='close-me']")); + + await waitFor(async () => !(await targetExists(cdp, target.targetId)), "expected closed popup target to disappear"); + await assert.rejects(() => text(cdp, element(popup, "/html[1]/body[1]/h1[1]"))); + }); +}); + +test("Playwright popup: bound popup follows URL changes", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await openModPage(cdp, "url-change-opener", `${fixture.origin}/url-change-opener.html`); + const popupPromise = waitForModPage(cdp, "changing-popup", { + opener, + expected: { url: `${fixture.origin}/changing-popup.html` }, + }); + + await sleep(100); + await click(cdp, element(opener, "//*[@id='open-changing-popup']")); + const popup = await popupPromise; + + assert.equal(await text(cdp, element(popup, "/html[1]/body[1]/h1[1]")), "before url change"); + await click(cdp, element(popup, "//*[@id='navigate']")); + await waitForTargetUrl(cdp, `${fixture.origin}/changed-popup.html`); + + assert.equal(await text(cdp, element(popup, "/html[1]/body[1]/h1[1]")), "after url change"); + }); +}); + +function element(page: ModPage, xpathValue: string): ModElement { + return modElement(page, [], xpath(xpathValue)); +} + +async function text(cdp: CDPModClient, elementPath: ModElement): Promise { + return elementText(cdp, elementPath); +} + +async function click(cdp: CDPModClient, elementPath: ModElement): Promise { + await clickElement(cdp, elementPath); +} + +type PageTargetInfo = { + targetId: string; + type: string; + url: string; + openerId?: string; + canAccessOpener: boolean; +}; + +async function waitForTargetUrl(cdp: CDPModClient, url: string): Promise { + let target: PageTargetInfo | undefined; + await waitFor(async () => { + target = (await pageTargets(cdp)).find((candidate) => candidate.url === url); + return Boolean(target); + }, `expected page target for ${url}`); + return target!; +} + +async function targetExists(cdp: CDPModClient, targetId: string): Promise { + return (await pageTargets(cdp)).some((target) => target.targetId === targetId); +} + +async function pageTargets(cdp: CDPModClient): Promise { + const result = (await cdp.Target.getTargets()) as { targetInfos?: PageTargetInfo[] }; + return result.targetInfos?.filter((target) => target.type === "page") ?? []; +} + +async function withFixtureAndClient( + fn: (args: { fixture: Fixture; cdp: CDPModClient }) => Promise, +): Promise { + const fixture = await startFixture(); + const chrome = await launchChrome({ + executable_path: chromium.executablePath(), + headless: true, + sandbox: process.platform !== "linux", + extra_args: [`--disable-extensions-except=${EXTENSION_PATH}`, `--load-extension=${EXTENSION_PATH}`], + }); + const cdp = new CDPModClient({ + cdp_url: chrome.cdpUrl, + routes: { + "Mod.*": "service_worker", + "*.*": "direct_cdp", + }, + server: { + loopback_cdp_url: chrome.cdpUrl, + routes: { "*.*": "loopback_cdp" }, + }, + }); + + try { + await cdp.connect(); + await fn({ fixture, cdp }); + } finally { + await cdp.close().catch(() => {}); + await chrome.close(); + await fixture.close(); + } +} + +async function startFixture(): Promise { + let origin = ""; + const server = http.createServer((req, res) => { + if (req.url === "/window-open.html") { + html(res, ``); + return; + } + if (req.url === "/window-open-popup.html") { + html(res, "

window.open popup

"); + return; + } + if (req.url === "/target-blank-opener.html") { + html( + res, + `open`, + ); + return; + } + if (req.url === "/target-blank-opener-popup.html") { + html(res, "

target blank opener

"); + return; + } + if (req.url === "/target-blank-noopener.html") { + html( + res, + `open`, + ); + return; + } + if (req.url === "/target-blank-noopener-popup.html") { + html(res, "

target blank noopener

"); + return; + } + if (req.url === "/about-blank-then-navigate.html") { + html( + res, + ``, + ); + return; + } + if (req.url === "/navigated-popup.html") { + html(res, "

navigated popup

"); + return; + } + if (req.url === "/multiple-popups.html") { + html( + res, + ` + `, + ); + return; + } + if (req.url === "/first-popup.html") { + html(res, "

first popup

"); + return; + } + if (req.url === "/second-popup.html") { + html(res, "

second popup

"); + return; + } + if (req.url === "/close-popup.html") { + html(res, ``); + return; + } + if (req.url === "/closable-popup.html") { + html(res, `

closable popup

`); + return; + } + if (req.url === "/url-change-opener.html") { + html( + res, + ``, + ); + return; + } + if (req.url === "/changing-popup.html") { + html( + res, + `

before url change

`, + ); + return; + } + if (req.url === "/changed-popup.html") { + html(res, "

after url change

"); + return; + } + res.writeHead(404).end(); + }); + const port = await listen(server); + origin = `http://127.0.0.1:${port}`; + return { + origin, + close: async () => { + await closeServer(server); + }, + }; +} + +function html(res: http.ServerResponse, body: string): void { + res.writeHead(200, { "content-type": "text/html" }); + res.end(`${body}`); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", resolve); + server.once("error", reject); + }); + return (server.address() as AddressInfo).port; +} + +async function closeServer(server: http.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +async function waitFor(predicate: () => boolean | Promise, message: string): Promise { + const deadline = Date.now() + 10_000; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await sleep(100); + } + throw new Error(`${message}${lastError instanceof Error ? `: ${lastError.message}` : ""}`); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/test/upstream-puppeteer-frame-target.test.ts b/test/upstream-puppeteer-frame-target.test.ts new file mode 100644 index 0000000..26c0a2e --- /dev/null +++ b/test/upstream-puppeteer-frame-target.test.ts @@ -0,0 +1,489 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +import { launchChrome } from "../bridge/launcher.js"; +import { CDPModClient } from "../client/js/CDPModClient.js"; +import type { ModElement, ModFrameHop, ModPage } from "../types/replayable.js"; +import { + clickElement, + element, + elementText as readElementText, + frame, + openModPage, + waitForModPage, + xpath, +} from "./replayable-test-helpers.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_PATH = path.resolve(HERE, "..", "extension"); + +type Fixture = { + origin: string; + close(): Promise; +}; + +type TargetInfo = { + targetId: string; + type: string; + url: string; + openerId?: string; + canAccessOpener?: boolean; +}; + +test("upstream Frame Management: resolves nested frame owner chains and parent/child relationships", async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await createPage(cdp, "nested-frames", `${fixture.origin}/nested-frames.html`); + + assert.equal(await elementText(cdp, nestedFrameLabel(page, "uno")), "uno"); + assert.equal(await elementText(cdp, nestedFrameLabel(page, "dos")), "dos"); + assert.equal(await elementText(cdp, nestedFrameLabel(page, "aframe")), "aframe"); + + const resolved = (await cdp.send("Mod.DOM.resolveContext", { + page, + frames: nestedFrames("uno"), + })) as { found?: boolean; page?: ModPage }; + assert.equal(resolved.found, true); + assert.deepEqual(resolved.page, page); + }); +}); + +test("upstream Frame Management: replays dynamic attach, navigation, detach, and re-attach paths", async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await createPage(cdp, "dynamic", `${fixture.origin}/dynamic.html`); + const frameLabel = dynamicFrameLabel(page); + + await clickElement(cdp, pageButton(page, 1)); + assert.equal(await elementText(cdp, frameLabel), "attached"); + + await clickElement(cdp, pageButton(page, 2)); + assert.equal(await elementText(cdp, frameLabel), "navigated"); + + await clickElement(cdp, pageButton(page, 3)); + await assertRejectsMagicPath(cdp, frameLabel, "detached iframe owner should not resolve"); + + await clickElement(cdp, pageButton(page, 4)); + assert.equal(await elementText(cdp, frameLabel), "reattached"); + }); +}); + +test("upstream Frame Management: child frame paths stop resolving after main-frame navigation", async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await createPage(cdp, "main-navigation", `${fixture.origin}/nested-frames.html`); + const targetId = (await waitForTargetUrl(cdp, undefined, `${fixture.origin}/nested-frames.html`)).targetId; + assert.equal(await elementText(cdp, nestedFrameLabel(page, "uno")), "uno"); + + const sessionId = await attachToTarget(cdp, targetId); + try { + await cdp._sendFrame("Page.navigate", { url: `${fixture.origin}/empty.html` }, sessionId); + await waitForTargetUrl(cdp, targetId, `${fixture.origin}/empty.html`); + } finally { + await cdp.Target.detachFromTarget({ sessionId }).catch(() => {}); + } + + await assertRejectsMagicPath(cdp, nestedFrameLabel(page, "uno"), "old child frame path should be detached"); + assert.equal(await elementText(cdp, pageHeading(page)), "Empty page"); + }); +}); + +test("upstream Frame Management: resolves FRAME owners in framesets", async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const page = await createPage(cdp, "frameset", `${fixture.origin}/frameset.html`); + + assert.equal(await elementText(cdp, framesetLabel(page, "left")), "left"); + assert.equal(await elementText(cdp, framesetLabel(page, "inner-right")), "inner-right"); + }); +}); + +test("upstream Frame Management: reports iframe inside shadow DOM in the CDP frame tree", async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + await createPage(cdp, "shadow", `${fixture.origin}/shadow.html`); + const targetId = (await waitForTargetUrl(cdp, undefined, `${fixture.origin}/shadow.html`)).targetId; + const sessionId = await attachToTarget(cdp, targetId); + try { + await cdp._sendFrame("Page.enable", {}, sessionId); + const tree = (await cdp._sendFrame("Page.getFrameTree", {}, sessionId)) as { frameTree: unknown }; + const urls = frameTreeUrls(tree.frameTree); + assert.ok(urls.includes(`${fixture.origin}/frame.html?label=shadow`), "expected shadow iframe in frame tree"); + } finally { + await cdp.Target.detachFromTarget({ sessionId }).catch(() => {}); + } + }); +}); + +test("upstream Page/Target: resolves popup opener paths and distinguishes noopener targets", async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const opener = await createPage(cdp, "popup-controls", `${fixture.origin}/popup-controls.html`); + const openerTargetId = (await waitForTargetUrl(cdp, undefined, `${fixture.origin}/popup-controls.html`)).targetId; + + const openerPopup = `${fixture.origin}/popup.html?kind=opener`; + const openerPopupPromise = waitForModPage(cdp, "popup-opener", { + opener, + expected: { url: openerPopup }, + }); + await sleep(100); + await clickElement(cdp, pageButton(opener, 1)); + const openerPopupPage = await openerPopupPromise; + assert.equal(await elementText(cdp, popupHeading(openerPopupPage)), "Popup opener"); + + const openerTarget = await waitForTarget(cdp, (target) => target.url === openerPopup); + assert.equal(openerTarget.openerId, openerTargetId); + + const noopenerPopup = `${fixture.origin}/popup.html?kind=noopener`; + const noopenerPopupPromise = waitForModPage(cdp, "popup-noopener", { + expected: { url: noopenerPopup }, + }); + await sleep(100); + await clickElement(cdp, pageButton(opener, 2)); + const noopenerPopupPage = await noopenerPopupPromise; + const noopenerTarget = await waitForTarget(cdp, (target) => target.url === noopenerPopup); + assert.equal(await elementText(cdp, popupHeading(noopenerPopupPage)), "Popup noopener"); + assert.equal(noopenerTarget.canAccessOpener, false); + }); +}); + +test("upstream Target: observes target URL changes and resolves multiple page targets by URL", async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const changingPage = await createPage(cdp, "changing", `${fixture.origin}/empty.html`); + const changingTargetId = (await waitForTargetUrl(cdp, undefined, `${fixture.origin}/empty.html`)).targetId; + const sessionId = await attachToTarget(cdp, changingTargetId); + try { + await cdp._sendFrame("Page.navigate", { url: `${fixture.origin}/target-a.html` }, sessionId); + await waitForTargetUrl(cdp, changingTargetId, `${fixture.origin}/target-a.html`); + assert.equal(await elementText(cdp, pageHeading(changingPage)), "Target A"); + + await cdp._sendFrame("Page.navigate", { url: `${fixture.origin}/target-b.html` }, sessionId); + await waitForTargetUrl(cdp, changingTargetId, `${fixture.origin}/target-b.html`); + assert.equal(await elementText(cdp, pageHeading(changingPage)), "Target B"); + } finally { + await cdp.Target.detachFromTarget({ sessionId }).catch(() => {}); + } + + const multiOne = await createPage(cdp, "multi-one", `${fixture.origin}/multi-one.html`); + const multiTwo = await createPage(cdp, "multi-two", `${fixture.origin}/multi-two.html`); + assert.equal(await elementText(cdp, pageHeading(multiOne)), "Multi One"); + assert.equal(await elementText(cdp, pageHeading(multiTwo)), "Multi Two"); + + const targets = await pageTargets(cdp); + assert.ok(targets.some((target) => target.url === `${fixture.origin}/multi-one.html`)); + assert.ok(targets.some((target) => target.url === `${fixture.origin}/multi-two.html`)); + }); +}); + +function nestedFrameLabel(page: ModPage, label: "uno" | "dos" | "aframe"): ModElement { + return elementPath(page, nestedFrames(label), "/html[1]/body[1]/main[1]/h1[1]"); +} + +function nestedFrames(label: "uno" | "dos" | "aframe"): ModFrameHop[] { + if (label === "aframe") { + return [frame(xpath("/html[1]/body[1]/iframe[2]"))]; + } + return [ + frame(xpath("/html[1]/body[1]/iframe[1]")), + frame(xpath(label === "uno" ? "/html[1]/body[1]/iframe[1]" : "/html[1]/body[1]/iframe[2]")), + ]; +} + +function dynamicFrameLabel(page: ModPage): ModElement { + return elementPath(page, [frame(xpath("/html[1]/body[1]/section[1]/iframe[1]"))], "/html[1]/body[1]/main[1]/h1[1]"); +} + +function framesetLabel(page: ModPage, label: "left" | "inner-right"): ModElement { + const frames = + label === "left" + ? [frame(xpath("/html[1]/frameset[1]/frame[1]"), "FRAME")] + : [ + frame(xpath("/html[1]/frameset[1]/frame[2]"), "FRAME"), + frame(xpath("/html[1]/frameset[1]/frame[2]"), "FRAME"), + ]; + return elementPath(page, frames, "/html[1]/body[1]/main[1]/h1[1]"); +} + +function pageHeading(page: ModPage): ModElement { + return elementPath(page, [], "/html[1]/body[1]/main[1]/h1[1]"); +} + +function pageButton(page: ModPage, buttonNumber: number): ModElement { + return elementPath(page, [], `/html[1]/body[1]/button[${buttonNumber}]`); +} + +function popupHeading(page: ModPage): ModElement { + return elementPath(page, [], "/html[1]/body[1]/main[1]/h1[1]"); +} + +function elementPath(page: ModPage, frames: ModFrameHop[], xpathValue: string): ModElement { + return element(page, frames, xpath(xpathValue)); +} + +async function withFixtureAndClient( + fn: (args: { fixture: Fixture; cdp: CDPModClient }) => Promise, +): Promise { + const fixture = await startFixture(); + const chrome = await launchChrome({ + executable_path: chromium.executablePath(), + headless: true, + sandbox: process.platform !== "linux", + extra_args: [`--disable-extensions-except=${EXTENSION_PATH}`, `--load-extension=${EXTENSION_PATH}`], + }); + const cdp = new CDPModClient({ + cdp_url: chrome.cdpUrl, + routes: { + "Mod.*": "service_worker", + "*.*": "direct_cdp", + }, + server: { + loopback_cdp_url: chrome.cdpUrl, + routes: { "*.*": "loopback_cdp" }, + }, + }); + + try { + await cdp.connect(); + await fn({ fixture, cdp }); + } finally { + await cdp.close().catch(() => {}); + await chrome.close(); + await fixture.close(); + } +} + +async function createPage(cdp: CDPModClient, id: string, url: string): Promise { + return openModPage(cdp, id, url); +} + +async function attachToTarget(cdp: CDPModClient, targetId: string): Promise { + const { sessionId } = (await cdp.Target.attachToTarget({ targetId, flatten: true })) as { sessionId: string }; + return sessionId; +} + +async function elementText(cdp: CDPModClient, element: ModElement): Promise { + return waitFor( + async () => { + const result = await readElementText(cdp, element); + return typeof result === "string" ? result : false; + }, + `expected Mod.DOM.elementText to resolve ${JSON.stringify(element)}`, + ); +} + +async function assertRejectsMagicPath(cdp: CDPModClient, element: ModElement, message: string): Promise { + await assert.rejects(() => cdp.send("Mod.DOM.elementText", { element }), undefined, message); +} + +async function pageTargets(cdp: CDPModClient): Promise { + const result = (await cdp.Target.getTargets()) as { targetInfos?: TargetInfo[] }; + return (result.targetInfos ?? []).filter((target) => target.type === "page"); +} + +async function waitForTargetUrl(cdp: CDPModClient, targetId: string | undefined, url: string): Promise { + return waitForTarget(cdp, (target) => (targetId ? target.targetId === targetId : true) && target.url === url); +} + +async function waitForTarget(cdp: CDPModClient, predicate: (target: TargetInfo) => boolean): Promise { + const target = await waitFor(async () => (await pageTargets(cdp)).find(predicate), "expected matching target"); + assert.ok(target); + return target; +} + +function frameTreeUrls(frameTree: unknown): string[] { + const urls: string[] = []; + const visit = (node: any) => { + if (typeof node?.frame?.url === "string") urls.push(node.frame.url); + for (const child of node?.childFrames ?? []) visit(child); + }; + visit(frameTree); + return urls; +} + +async function startFixture(): Promise { + let origin = ""; + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", origin || "http://127.0.0.1"); + res.setHeader("content-type", "text/html"); + + if (url.pathname === "/nested-frames.html") { + res.end(html` + + + + + `); + return; + } + if (url.pathname === "/two-frames.html") { + res.end(html` + + + + + `); + return; + } + if (url.pathname === "/frame.html") { + res.end(page(url.searchParams.get("label") ?? "frame")); + return; + } + if (url.pathname === "/dynamic.html") { + res.end(html` + + + + + +
+ + + `); + return; + } + if (url.pathname === "/dynamic-frame.html") { + res.end(page(url.searchParams.get("label") ?? "dynamic")); + return; + } + if (url.pathname === "/frameset.html") { + res.end(html` + + + + + `); + return; + } + if (url.pathname === "/inner-frameset.html") { + res.end(html` + + + + + `); + return; + } + if (url.pathname === "/shadow.html") { + res.end(html` + +
+ + + `); + return; + } + if (url.pathname === "/popup-controls.html") { + res.end(html` + + + + + `); + return; + } + if (url.pathname === "/popup.html") { + res.end(page(`Popup ${url.searchParams.get("kind") ?? "unknown"}`)); + return; + } + if (url.pathname === "/empty.html") { + res.end(page("Empty page")); + return; + } + if (url.pathname === "/target-a.html") { + res.end(page("Target A")); + return; + } + if (url.pathname === "/target-b.html") { + res.end(page("Target B")); + return; + } + if (url.pathname === "/multi-one.html") { + res.end(page("Multi One")); + return; + } + if (url.pathname === "/multi-two.html") { + res.end(page("Multi Two")); + return; + } + + res.writeHead(404).end(page("Not found")); + }); + const port = await listen(server); + origin = `http://127.0.0.1:${port}`; + return { + origin, + close: async () => { + await closeServer(server); + }, + }; +} + +function page(heading: string): string { + return html` + +

${escapeHtml(heading)}

+ + `; +} + +function html(strings: TemplateStringsArray, ...values: string[]): string { + let result = ""; + for (let index = 0; index < strings.length; index += 1) result += strings[index] + (values[index] ?? ""); + return `${result}`; +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", resolve); + server.once("error", reject); + }); + return (server.address() as AddressInfo).port; +} + +async function closeServer(server: http.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +async function waitFor( + predicate: () => T | undefined | false | Promise, + message: string, +): Promise { + const deadline = Date.now() + 10_000; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + const result = await predicate(); + if (result) return result; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`${message}${lastError instanceof Error ? `: ${lastError.message}` : ""}`); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/test/upstream-puppeteer-oopif.test.ts b/test/upstream-puppeteer-oopif.test.ts new file mode 100644 index 0000000..b65c1d3 --- /dev/null +++ b/test/upstream-puppeteer-oopif.test.ts @@ -0,0 +1,394 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +import { launchChrome } from "../bridge/launcher.js"; +import { CDPModClient } from "../client/js/CDPModClient.js"; +import type { ModElement, ModFrameHop, ModPage } from "../types/replayable.js"; +import { + clickElement, + element, + elementText, + frame, + openModPage, + typeElement, + xpath, +} from "./replayable-test-helpers.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXTENSION_PATH = path.resolve(HERE, "..", "extension"); +const HOST_RULES = [ + "MAP parent.magic-cdp.test 127.0.0.1", + "MAP child.magic-cdp.test 127.0.0.1", + "MAP grandchild.magic-cdp.test 127.0.0.1", +].join(","); + +type Fixture = { + parentUrl: string; + childUrl: string; + grandchildUrl: string; + close(): Promise; +}; + +test( + "Puppeteer OOPIF: cross-site iframe under --site-per-process resolves like a normal iframe", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const pageUrl = `${fixture.parentUrl}/mixed-iframes.html`; + const page = await openPage(cdp, "mixed-iframes", pageUrl); + + const normal = await elementText(cdp, modElement(page, [frameById("normal-frame")], "/html[1]/body[1]/h1[1]")); + const oopif = await elementText(cdp, modElement(page, [frameById("oopif-frame")], "/html[1]/body[1]/h1[1]")); + const resolved = (await cdp.send("Mod.DOM.resolveContext", { + page, + frames: [frameById("oopif-frame")], + })) as { found?: boolean }; + + assert.equal(normal, "same-site iframe"); + assert.equal(oopif, "cross-site iframe"); + assert.equal(resolved.found, true); + }); + }, +); + +test("Puppeteer OOPIF: iframe can transition from normal iframe to OOPIF", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const pageUrl = `${fixture.parentUrl}/transition.html`; + const page = await openPage(cdp, "transition", pageUrl); + const body = modElement(page, [frameById("transition-frame")], "/html[1]/body[1]"); + + assert.equal(await elementText(cdp, body), "normal initial"); + await click(cdp, modElement(page, [], "/html[1]/body[1]/button[1]")); + + await waitFor( + async () => (await elementText(cdp, body)) === "oopif navigated", + "expected iframe path to re-resolve after same-site to cross-site navigation", + ); + }); +}); + +test("Puppeteer OOPIF: OOPIF can transition back to a normal iframe", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const pageUrl = `${fixture.parentUrl}/transition-back.html`; + const page = await openPage(cdp, "transition-back", pageUrl); + const body = modElement(page, [frameById("transition-frame")], "/html[1]/body[1]"); + + assert.equal(await elementText(cdp, body), "oopif initial"); + await click(cdp, modElement(page, [], "/html[1]/body[1]/button[1]")); + + await waitFor( + async () => (await elementText(cdp, body)) === "normal navigated", + "expected iframe path to re-resolve after cross-site to same-site navigation", + ); + }); +}); + +test( + "Puppeteer OOPIF: nested OOPIF paths resolve through multiple cross-site owners", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const pageUrl = `${fixture.parentUrl}/nested-oopif.html`; + const page = await openPage(cdp, "nested-oopif", pageUrl); + + const text = await elementText( + cdp, + modElement(page, [frameById("outer-oopif"), frameById("inner-oopif")], "/html[1]/body[1]/h1[1]"), + ); + + assert.equal(text, "nested grandchild oopif"); + }); + }, +); + +test("Puppeteer OOPIF: a normal frame inside an OOPIF is addressable", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const pageUrl = `${fixture.parentUrl}/oopif-with-inner-frame.html`; + const page = await openPage(cdp, "oopif-with-inner-frame", pageUrl); + + const text = await elementText( + cdp, + modElement(page, [frameById("oopif-frame"), frameById("inner-normal-frame")], "/html[1]/body[1]/h1[1]"), + ); + + assert.equal(text, "inner frame inside oopif"); + }); +}); + +test( + "Puppeteer OOPIF: detached OOPIF owner disappears without breaking parent paths", + { timeout: 45_000 }, + async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const pageUrl = `${fixture.parentUrl}/detached-oopif.html`; + const page = await openPage(cdp, "detached-oopif", pageUrl); + const oopifHeading = modElement(page, [frameById("detached-frame")], "/html[1]/body[1]/h1[1]"); + const count = modElement(page, [], "/html[1]/body[1]/p[1]"); + + assert.equal(await elementText(cdp, oopifHeading), "detachable oopif"); + await click(cdp, modElement(page, [], "/html[1]/body[1]/button[1]")); + + await waitFor(async () => (await elementText(cdp, count)) === "frames: 0", "expected OOPIF owner to detach"); + }); + }, +); + +test("Puppeteer OOPIF: click and type interactions work inside an OOPIF", { timeout: 45_000 }, async () => { + await withFixtureAndClient(async ({ fixture, cdp }) => { + const pageUrl = `${fixture.parentUrl}/interactive-oopif.html`; + const page = await openPage(cdp, "interactive-oopif", pageUrl); + const frame = [frameById("interactive-frame")]; + + await clickElement(cdp, modElement(page, frame, "/html[1]/body[1]/button[1]")); + await typeElement(cdp, modElement(page, frame, "/html[1]/body[1]/input[1]"), "typed through oopif"); + + await waitFor( + async () => + (await elementText(cdp, modElement(page, frame, "/html[1]/body[1]/p[1]"))) === "clicked typed through oopif", + "expected click and type to mutate OOPIF content", + ); + }); +}); + +function modElement(page: ModPage, frames: ModFrameHop[], xpathValue: string): ModElement { + return element(page, frames, xpath(xpathValue)); +} + +function frameById(id: string): ModFrameHop { + return frame(xpath(`//*[@id=${xpathStringLiteral(id)}]`)); +} + +async function click(cdp: CDPModClient, modElement: ModElement): Promise { + await clickElement(cdp, modElement); +} + +async function openPage(cdp: CDPModClient, id: string, url: string): Promise { + return openModPage(cdp, id, url); +} + +async function withFixtureAndClient( + fn: (args: { fixture: Fixture; cdp: CDPModClient }) => Promise, +): Promise { + const fixture = await startFixture(); + const chrome = await launchChrome({ + executable_path: chromium.executablePath(), + headless: true, + sandbox: process.platform !== "linux", + extra_args: [ + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + "--site-per-process", + `--host-resolver-rules=${HOST_RULES}`, + ], + }); + const cdp = new CDPModClient({ + cdp_url: chrome.cdpUrl, + routes: { + "Mod.*": "service_worker", + "*.*": "direct_cdp", + }, + server: { + loopback_cdp_url: chrome.cdpUrl, + routes: { "*.*": "loopback_cdp" }, + }, + }); + + try { + await cdp.connect(); + await fn({ fixture, cdp }); + } finally { + await cdp.close().catch(() => {}); + await chrome.close(); + await fixture.close(); + } +} + +async function startFixture(): Promise { + let parentUrl = ""; + let childUrl = ""; + let grandchildUrl = ""; + + const parentServer = http.createServer((req, res) => { + if (req.url === "/empty.html") { + html(res, "

same-site iframe

"); + return; + } + if (req.url === "/normal-navigated.html") { + html(res, "normal navigated"); + return; + } + if (req.url === "/mixed-iframes.html") { + html( + res, + ` + `, + ); + return; + } + if (req.url === "/transition.html") { + html( + res, + ` + `, + ); + return; + } + if (req.url === "/normal-initial.html") { + html(res, "normal initial"); + return; + } + if (req.url === "/transition-back.html") { + html( + res, + ` + `, + ); + return; + } + if (req.url === "/nested-oopif.html") { + html(res, ``); + return; + } + if (req.url === "/oopif-with-inner-frame.html") { + html(res, ``); + return; + } + if (req.url === "/detached-oopif.html") { + html( + res, + ` +

frames: 1

+ + `, + ); + return; + } + if (req.url === "/interactive-oopif.html") { + html(res, ``); + return; + } + notFound(res); + }); + const parentPort = await listen(parentServer); + parentUrl = `http://parent.magic-cdp.test:${parentPort}`; + + const childServer = http.createServer((req, res) => { + if (req.url === "/oopif.html") { + html(res, "

cross-site iframe

"); + return; + } + if (req.url === "/initial.html") { + html(res, "oopif initial"); + return; + } + if (req.url === "/navigated.html") { + html(res, "oopif navigated"); + return; + } + if (req.url === "/outer-oopif.html") { + html(res, ``); + return; + } + if (req.url === "/with-inner-frame.html") { + html(res, ``); + return; + } + if (req.url === "/inner-frame.html") { + html(res, "

inner frame inside oopif

"); + return; + } + if (req.url === "/detachable.html") { + html(res, "

detachable oopif

"); + return; + } + if (req.url === "/interactive.html") { + html( + res, + ` + +

idle

+ `, + ); + return; + } + notFound(res); + }); + const childPort = await listen(childServer); + childUrl = `http://child.magic-cdp.test:${childPort}`; + + const grandchildServer = http.createServer((req, res) => { + if (req.url === "/nested.html") { + html(res, "

nested grandchild oopif

"); + return; + } + notFound(res); + }); + const grandchildPort = await listen(grandchildServer); + grandchildUrl = `http://grandchild.magic-cdp.test:${grandchildPort}`; + + return { + parentUrl, + childUrl, + grandchildUrl, + close: async () => { + await Promise.all([closeServer(parentServer), closeServer(childServer), closeServer(grandchildServer)]); + }, + }; +} + +function html(res: http.ServerResponse, body: string): void { + res.writeHead(200, { "content-type": "text/html" }); + res.end(`${body}`); +} + +function notFound(res: http.ServerResponse): void { + res.writeHead(404).end(); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", resolve); + server.once("error", reject); + }); + return (server.address() as AddressInfo).port; +} + +async function closeServer(server: http.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +async function waitFor(predicate: () => boolean | Promise, message: string): Promise { + const deadline = Date.now() + 10_000; + let lastError: unknown = null; + while (Date.now() < deadline) { + try { + if (await predicate()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`${message}${lastError instanceof Error ? `: ${lastError.message}` : ""}`); +} + +function xpathStringLiteral(value: string): string { + if (!value.includes("'")) return `'${value}'`; + if (!value.includes('"')) return `"${value}"`; + return `concat('${value.replaceAll("'", `', "'", '`)}')`; +} diff --git a/types/cdpmod.ts b/types/cdpmod.ts index 15c23f8..496df95 100644 --- a/types/cdpmod.ts +++ b/types/cdpmod.ts @@ -2,6 +2,31 @@ import { z } from "zod"; +import { + ModBindPageParamsSchema, + ModBindPageResultSchema, + ModClickElementParamsSchema, + ModClickElementResultSchema, + ModClickParamsSchema, + ModClickResultSchema, + ModElementTextParamsSchema, + ModElementTextResultSchema, + ModOpenPageParamsSchema, + ModOpenPageResultSchema, + ModQueryElementParamsSchema, + ModQueryElementResultSchema, + ModResolveContextParamsSchema, + ModResolveContextResultSchema, + ModTextParamsSchema, + ModTextResultSchema, + ModTypeElementParamsSchema, + ModTypeElementResultSchema, + ModTypeParamsSchema, + ModTypeResultSchema, + ModWaitForPageParamsSchema, + ModWaitForPageResultSchema, +} from "./replayable.js"; + const isZodType = (value: unknown): value is z.ZodType => value != null && typeof value === "object" && typeof (value as z.ZodType).parse === "function"; @@ -163,12 +188,34 @@ export const CDPModCommandParamsSchema = z.union([ CDPModAddMiddlewareParamsSchema, CDPModConfigureParamsSchema, CDPModPingParamsSchema, + ModOpenPageParamsSchema, + ModBindPageParamsSchema, + ModWaitForPageParamsSchema, + ModQueryElementParamsSchema, + ModResolveContextParamsSchema, + ModTextParamsSchema, + ModClickParamsSchema, + ModTypeParamsSchema, + ModElementTextParamsSchema, + ModClickElementParamsSchema, + ModTypeElementParamsSchema, CDPModCustomPayloadSchema, ]); export type CDPModCommandParams = z.infer; export const CDPModCommandResultSchema = z.union([ z.object({ ok: z.boolean() }).passthrough(), + ModOpenPageResultSchema, + ModBindPageResultSchema, + ModWaitForPageResultSchema, + ModQueryElementResultSchema, + ModResolveContextResultSchema, + ModTextResultSchema, + ModClickResultSchema, + ModTypeResultSchema, + ModElementTextResultSchema, + ModClickElementResultSchema, + ModTypeElementResultSchema, CDPModCustomPayloadSchema, ]); export type CDPModCommandResult = z.infer; @@ -403,6 +450,17 @@ export const Mod = { AddMiddlewareParams: CDPModAddMiddlewareParamsSchema, ConfigureParams: CDPModConfigureParamsSchema, PingParams: CDPModPingParamsSchema, + PageOpenParams: ModOpenPageParamsSchema, + PageBindParams: ModBindPageParamsSchema, + PageWaitForParams: ModWaitForPageParamsSchema, + DOMQueryElementParams: ModQueryElementParamsSchema, + DOMResolveContextParams: ModResolveContextParamsSchema, + DOMTextParams: ModTextParamsSchema, + InputClickParams: ModClickParamsSchema, + InputTypeParams: ModTypeParamsSchema, + DOMElementTextParams: ModElementTextParamsSchema, + InputClickElementParams: ModClickElementParamsSchema, + InputTypeElementParams: ModTypeElementParamsSchema, PongEvent: CDPModPongEventSchema, PingLatency: CDPModPingLatencySchema, CommandParams: CDPModCommandParamsSchema, @@ -413,6 +471,17 @@ export const Mod = { AddMiddlewareResponse: CDPModAddMiddlewareResponseSchema, ConfigureResponse: CDPModConfigureResponseSchema, PingResponse: CDPModPingResponseSchema, + PageOpenResponse: ModOpenPageResultSchema, + PageBindResponse: ModBindPageResultSchema, + PageWaitForResponse: ModWaitForPageResultSchema, + DOMQueryElementResponse: ModQueryElementResultSchema, + DOMResolveContextResponse: ModResolveContextResultSchema, + DOMTextResponse: ModTextResultSchema, + InputClickResponse: ModClickResultSchema, + InputTypeResponse: ModTypeResultSchema, + DOMElementTextResponse: ModElementTextResultSchema, + InputClickElementResponse: ModClickElementResultSchema, + InputTypeElementResponse: ModTypeElementResultSchema, BindingPayload: CDPModBindingPayloadSchema, CustomCommandRegistration: CDPModCustomCommandRegistrationSchema, CustomEventRegistration: CDPModCustomEventRegistrationSchema, diff --git a/types/client.d.ts b/types/client.d.ts new file mode 100644 index 0000000..a1454f5 --- /dev/null +++ b/types/client.d.ts @@ -0,0 +1,205 @@ +import type { z } from "zod"; + +import type { + CDPModAddCustomCommandParams, + CDPModAddCustomEventObjectParams, + CDPModAddMiddlewareParams, + CDPModConfigureParams, + CDPModNamedValue, + CDPModPingLatency, + CDPModPongEvent, + CDPModRoutes, + ProtocolParams, + ProtocolPayload, + ProtocolResult, + TranslatedCommand, +} from "./cdpmod.js"; +import type { + ModClickResult, + ModElement, + ModFillResult, + ModFrameHop, + ModHoverResult, + ModNavigationResult, + ModOpenPageParams, + ModPage, + ModPageEvaluateParams, + ModPageEvaluateResult, + ModPageGoBackParams, + ModPageGoForwardParams, + ModPageGotoParams, + ModPageReloadParams, + ModPageScreenshotParams, + ModPageScreenshotResult, + ModPageWaitForLoadStateParams, + ModPageWaitForLoadStateResult, + ModPageWaitForSelectorParams, + ModPageWaitForSelectorResult, + ModPageWaitForTimeoutResult, + ModPressResult, + ModScrollResult, + ModSelector, + ModTypeResult, + ModWaitForPageParams, +} from "./replayable.js"; + +export type CDPModEventNameInput = string | symbol | (z.ZodType & CDPModNamedValue); +export type CDPModClientCustomCommandParams = Omit & { + expression?: string | null; +}; +export type ModFrameOptions = "IFRAME" | "FRAME" | { assertNodeName?: "IFRAME" | "FRAME" }; +export type ModWaitForPageOptions = Omit & { + opener?: ModPage | ModPageHandle; +}; +export type ModPageGotoOptions = Omit; +export type ModPageReloadOptions = Omit; +export type ModPageGoBackOptions = Omit; +export type ModPageGoForwardOptions = Omit; +export type ModPageScreenshotOptions = Omit; +export type ModPageEvaluateOptions = Omit; +export type ModPageWaitForSelectorOptions = Omit; +export type ModInputScrollOptions = { + selector?: ModSelector; + deltaX?: number; + deltaY: number; +}; + +export type CDPModClientOptions = { + cdp_url?: string | null; + extension_path?: string; + routes?: CDPModRoutes; + server?: CDPModConfigureParams | null; + custom_commands?: CDPModClientCustomCommandParams[]; + custom_events?: CDPModAddCustomEventObjectParams[]; + custom_middlewares?: CDPModAddMiddlewareParams[]; + hydrate_aliases?: boolean; + service_worker_url_includes?: string[]; + service_worker_url_suffixes?: string[] | null; + trust_service_worker_target?: boolean; + require_service_worker_target?: boolean; + service_worker_ready_expression?: string | null; + launch_options?: Record; + self?: { + addEventListener?: ( + listener: (event: string, data: ProtocolPayload, cdpSessionId: string | null) => void, + ) => unknown; + configure?: (params: CDPModConfigureParams) => Promise; + handleCommand: (method: string, params?: ProtocolParams, cdpSessionId?: string | null) => Promise; + } | null; +}; + +export class ModPageHandle { + readonly object: "mod.page"; + readonly id: string; + + constructor(client: CDPModClient, page: ModPage, frames?: ModFrameHop[]); + + get ref(): ModPage; + get frames(): readonly ModFrameHop[]; + + toJSON(): ModPage; + frame(owner: ModSelector, options?: ModFrameOptions): ModPageHandle; + send(method: string, params?: Record): Promise; + goto(url: string, options?: ModPageGotoOptions): Promise; + reload(options?: ModPageReloadOptions): Promise; + goBack(options?: ModPageGoBackOptions): Promise; + goForward(options?: ModPageGoForwardOptions): Promise; + waitForLoadState( + state: ModPageWaitForLoadStateParams["state"], + options?: Omit, + ): Promise; + waitForTimeout(ms: number): Promise; + screenshot(options?: ModPageScreenshotOptions): Promise; + evaluate(expression: string, options?: ModPageEvaluateOptions): Promise; + waitForSelector( + selector: ModSelector, + options?: ModPageWaitForSelectorOptions, + ): Promise; + query(selector: ModSelector, options?: { id?: string }): Promise; + text(selector: ModSelector): Promise; + click(selector: ModSelector): Promise; + type(selector: ModSelector, text: string): Promise; + hover(selector: ModSelector): Promise; + fill(selector: ModSelector, value: string): Promise; + press(key: string): Promise; + scroll(options: ModInputScrollOptions): Promise; + waitForPage(params: Omit): Promise; +} + +export class CDPModReplayNamespace { + constructor(client: CDPModClient); + + openPage(params: ModOpenPageParams): Promise; + waitForPage(params: ModWaitForPageOptions): Promise; + page(page: ModPage | ModPageHandle): ModPageHandle; +} + +export type CDPModCommandSpec = { + params: Params; + result: Result; +}; +export type CDPModCommandMap = Record; +type MethodName = TName extends `${string}.${infer TMethod}` ? TMethod : never; +type DomainName = TName extends `${infer TDomain}.${string}` ? TDomain : never; +type CommandsForDomain = { + [TName in keyof TCommands as TName extends `${TDomain}.${string}` + ? MethodName> + : never]: undefined extends TCommands[TName]["params"] + ? (params?: TCommands[TName]["params"]) => Promise + : (params: TCommands[TName]["params"]) => Promise; +}; +export type CDPModClientInstance> = CDPModClient & { + [TDomain in DomainName>]: CommandsForDomain; +}; + +export class CDPModClient { + cdp_url: string | null; + extension_path: string; + routes: CDPModRoutes; + server: CDPModConfigureParams | null; + launch_options: Record; + custom_commands: CDPModClientCustomCommandParams[]; + custom_events: CDPModAddCustomEventObjectParams[]; + custom_middlewares: CDPModAddMiddlewareParams[]; + hydrate_aliases: boolean; + service_worker_url_includes: string[]; + service_worker_url_suffixes: string[] | null; + trust_service_worker_target: boolean; + require_service_worker_target: boolean; + service_worker_ready_expression: string | null; + ws: unknown | null; + self: CDPModClientOptions["self"]; + ext_session_id: string | null; + ext_target_id: string | null; + extension_id: string | null; + latency: CDPModPingLatency | null; + connect_timing: Record | null; + last_command_timing: Record | null; + last_raw_timing: Record | null; + refs: CDPModReplayNamespace; + _cdp: { + send: (method: string, params?: ProtocolParams, sessionId?: string | null) => Promise; + on: (eventName: string | symbol, listener: (...args: unknown[]) => void) => CDPModClient; + once: (eventName: string | symbol, listener: (...args: unknown[]) => void) => CDPModClient; + }; + + constructor(options?: CDPModClientOptions); + + connect(): Promise; + send(method: string, params?: unknown): Promise; + close(): Promise; + on(eventName: CDPModEventNameInput, listener: (...args: unknown[]) => void): this; + once(eventName: CDPModEventNameInput, listener: (...args: unknown[]) => void): this; + off(eventName: CDPModEventNameInput, listener: (...args: unknown[]) => void): this; + _waitForEvent( + eventName: CDPModEventNameInput, + options?: { timeout_ms?: number }, + ): { promise: Promise; cancel: () => void }; + _sendRaw(command: TranslatedCommand): Promise; + _sendFrame( + method: string, + params?: ProtocolParams, + sessionId?: string | null, + options?: { record_raw_timing?: boolean }, + ): Promise; +} diff --git a/types/replayable.ts b/types/replayable.ts new file mode 100644 index 0000000..ab3747f --- /dev/null +++ b/types/replayable.ts @@ -0,0 +1,482 @@ +import { z } from "zod"; + +export const ModIdSchema = z.string().min(1); +export type ModId = z.infer; + +export const ModPageSchema = z + .object({ + object: z.literal("mod.page"), + id: ModIdSchema, + }) + .strict(); +export type ModPage = z.infer; + +export const PageTargetInfoSchema = z + .object({ + targetId: z.string(), + type: z.string(), + url: z.string().optional(), + title: z.string().optional(), + openerId: z.string().optional(), + canAccessOpener: z.boolean().optional(), + }) + .passthrough(); +export type PageTargetInfo = z.infer; + +export const ModSelectorSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("xpath"), + xpath: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("css"), + selector: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("role"), + role: z.string().min(1), + name: z.string().optional(), + exact: z.boolean().default(true), + }) + .strict(), + z + .object({ + kind: z.literal("text"), + text: z.string().min(1), + exact: z.boolean().default(true), + }) + .strict(), +]); +export type ModSelector = z.infer; + +export const ModFrameHopSchema = z + .object({ + owner: ModSelectorSchema, + assertNodeName: z.enum(["IFRAME", "FRAME"]).default("IFRAME"), + }) + .strict(); +export type ModFrameHop = z.infer; + +export const ModElementSchema = z + .object({ + object: z.literal("mod.element"), + id: ModIdSchema.optional(), + page: ModPageSchema, + frames: z.array(ModFrameHopSchema).default([]), + selector: ModSelectorSchema, + fingerprint: z + .object({ + nodeName: z.string().optional(), + text: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict(); +export type ModElement = z.infer; + +export const ModOpenPageParamsSchema = z + .object({ + id: ModIdSchema.optional(), + url: z.string().url(), + }) + .strict(); +export type ModOpenPageParams = z.infer; +export const ModOpenPageResultSchema = z.object({ page: ModPageSchema }).strict(); +export type ModOpenPageResult = z.infer; + +export const ModBindPageParamsSchema = z + .object({ + page: ModPageSchema, + targetId: ModIdSchema, + }) + .strict(); +export type ModBindPageParams = z.infer; +export const ModBindPageResultSchema = z.object({ page: ModPageSchema }).strict(); +export type ModBindPageResult = z.infer; + +export const ModPageExpectationSchema = z + .object({ + url: z.string().url().optional(), + urlIncludes: z.string().min(1).optional(), + }) + .strict(); +export type ModPageExpectation = z.infer; + +export const ModWaitForPageParamsSchema = z + .object({ + id: ModIdSchema.optional(), + opener: ModPageSchema.optional(), + expected: ModPageExpectationSchema.optional(), + timeoutMs: z.number().int().positive().default(10_000), + }) + .strict(); +export type ModWaitForPageParams = z.infer; +export const ModWaitForPageResultSchema = z.object({ page: ModPageSchema }).strict(); +export type ModWaitForPageResult = z.infer; + +export const ModLoadStateSchema = z.enum(["load", "domcontentloaded", "networkidle"]); +export type ModLoadState = z.infer; + +export const ModWaitForSelectorStateSchema = z.enum(["attached", "detached", "visible", "hidden"]); +export type ModWaitForSelectorState = z.infer; + +export const ModNavigationResultSchema = z + .object({ + page: ModPageSchema, + url: z.string(), + response: z.null(), + }) + .strict(); +export type ModNavigationResult = z.infer; + +export const ModPageGotoParamsSchema = z + .object({ + page: ModPageSchema, + url: z.string().url(), + waitUntil: ModLoadStateSchema.optional(), + timeoutMs: z.number().int().nonnegative().default(30_000), + }) + .strict(); +export type ModPageGotoParams = z.infer; +export const ModPageGotoResultSchema = ModNavigationResultSchema; +export type ModPageGotoResult = z.infer; + +export const ModPageReloadParamsSchema = z + .object({ + page: ModPageSchema, + waitUntil: ModLoadStateSchema.optional(), + timeoutMs: z.number().int().nonnegative().default(30_000), + ignoreCache: z.boolean().optional(), + }) + .strict(); +export type ModPageReloadParams = z.infer; +export const ModPageReloadResultSchema = ModNavigationResultSchema; +export type ModPageReloadResult = z.infer; + +export const ModPageGoBackParamsSchema = z + .object({ + page: ModPageSchema, + waitUntil: ModLoadStateSchema.optional(), + timeoutMs: z.number().int().nonnegative().default(30_000), + }) + .strict(); +export type ModPageGoBackParams = z.infer; +export const ModPageGoBackResultSchema = ModNavigationResultSchema; +export type ModPageGoBackResult = z.infer; + +export const ModPageGoForwardParamsSchema = ModPageGoBackParamsSchema; +export type ModPageGoForwardParams = z.infer; +export const ModPageGoForwardResultSchema = ModNavigationResultSchema; +export type ModPageGoForwardResult = z.infer; + +export const ModPageWaitForLoadStateParamsSchema = z + .object({ + page: ModPageSchema, + state: ModLoadStateSchema, + timeoutMs: z.number().int().nonnegative().default(30_000), + }) + .strict(); +export type ModPageWaitForLoadStateParams = z.infer; +export const ModPageWaitForLoadStateResultSchema = z + .object({ + page: ModPageSchema, + state: ModLoadStateSchema, + }) + .strict(); +export type ModPageWaitForLoadStateResult = z.infer; + +export const ModPageWaitForTimeoutParamsSchema = z + .object({ + page: ModPageSchema, + ms: z.number().int().nonnegative(), + }) + .strict(); +export type ModPageWaitForTimeoutParams = z.infer; +export const ModPageWaitForTimeoutResultSchema = z + .object({ + page: ModPageSchema, + ms: z.number().int().nonnegative(), + }) + .strict(); +export type ModPageWaitForTimeoutResult = z.infer; + +export const ModScreenshotTypeSchema = z.enum(["png", "jpeg", "webp"]); +export type ModScreenshotType = z.infer; + +export const ModPageClipSchema = z + .object({ + x: z.number(), + y: z.number(), + width: z.number().positive(), + height: z.number().positive(), + scale: z.number().positive().optional(), + }) + .strict(); +export type ModPageClip = z.infer; + +export const ModPageScreenshotParamsSchema = z + .object({ + page: ModPageSchema, + fullPage: z.boolean().optional(), + clip: ModPageClipSchema.optional(), + type: ModScreenshotTypeSchema.default("png"), + quality: z.number().int().min(0).max(100).optional(), + timeoutMs: z.number().int().nonnegative().default(30_000), + }) + .strict() + .superRefine((value, ctx) => { + if (value.quality !== undefined && value.type !== "jpeg" && value.type !== "webp") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["quality"], + message: "quality is only supported when type is 'jpeg' or 'webp'", + }); + } + if (value.clip && value.fullPage) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["clip"], + message: "clip cannot be used together with fullPage", + }); + } + }); +export type ModPageScreenshotParams = z.infer; +export const ModPageScreenshotResultSchema = z + .object({ + page: ModPageSchema, + base64: z.string(), + mimeType: z.enum(["image/png", "image/jpeg", "image/webp"]), + }) + .strict(); +export type ModPageScreenshotResult = z.infer; + +export const ModPageEvaluateParamsSchema = z + .object({ + page: ModPageSchema, + frames: z.array(ModFrameHopSchema).default([]), + expression: z.string().min(1), + arg: z.unknown().optional(), + awaitPromise: z.boolean().default(true), + timeoutMs: z.number().int().nonnegative().optional(), + }) + .strict(); +export type ModPageEvaluateParams = z.infer; +export const ModPageEvaluateResultSchema = z.object({ value: z.unknown().optional() }).strict(); +export type ModPageEvaluateResult = z.infer; + +export const ModPageWaitForSelectorParamsSchema = z + .object({ + id: ModIdSchema.optional(), + page: ModPageSchema, + frames: z.array(ModFrameHopSchema).default([]), + selector: ModSelectorSchema, + state: ModWaitForSelectorStateSchema.default("visible"), + timeoutMs: z.number().int().nonnegative().default(30_000), + }) + .strict(); +export type ModPageWaitForSelectorParams = z.infer; +export const ModPageWaitForSelectorResultSchema = z + .object({ + page: ModPageSchema, + matched: z.literal(true), + element: ModElementSchema.optional(), + }) + .strict(); +export type ModPageWaitForSelectorResult = z.infer; + +export const ModQueryElementParamsSchema = z + .object({ + id: ModIdSchema.optional(), + page: ModPageSchema, + frames: z.array(ModFrameHopSchema).default([]), + selector: ModSelectorSchema, + }) + .strict(); +export type ModQueryElementParams = z.infer; +export const ModQueryElementResultSchema = z.object({ element: ModElementSchema }).strict(); +export type ModQueryElementResult = z.infer; + +export const ModSelectorTargetParamsSchema = z + .object({ + page: ModPageSchema, + frames: z.array(ModFrameHopSchema).default([]), + selector: ModSelectorSchema, + }) + .strict(); +export type ModSelectorTargetParams = z.infer; + +export const ModResolveContextParamsSchema = z + .object({ + page: ModPageSchema, + frames: z.array(ModFrameHopSchema).default([]), + }) + .strict(); +export type ModResolveContextParams = z.infer; +export const ModResolveContextResultSchema = z + .object({ + found: z.literal(true), + page: ModPageSchema, + pageUrl: z.string(), + frameDepth: z.number().int().nonnegative(), + }) + .strict(); +export type ModResolveContextResult = z.infer; + +export const ModTextParamsSchema = ModSelectorTargetParamsSchema; +export type ModTextParams = z.infer; +export const ModTextResultSchema = z + .object({ + text: z.string(), + element: ModElementSchema, + }) + .strict(); +export type ModTextResult = z.infer; + +export const ModClickParamsSchema = ModSelectorTargetParamsSchema; +export type ModClickParams = z.infer; +export const ModClickResultSchema = z + .object({ + clicked: z.literal(true), + element: ModElementSchema, + }) + .strict(); +export type ModClickResult = z.infer; + +export const ModTypeParamsSchema = ModSelectorTargetParamsSchema.extend({ + text: z.string(), +}); +export type ModTypeParams = z.infer; +export const ModTypeResultSchema = z + .object({ + typed: z.literal(true), + element: ModElementSchema, + }) + .strict(); +export type ModTypeResult = z.infer; + +export const ModElementTextParamsSchema = z.object({ element: ModElementSchema }).strict(); +export type ModElementTextParams = z.infer; +export const ModElementTextResultSchema = z + .object({ + text: z.string(), + element: ModElementSchema, + }) + .strict(); +export type ModElementTextResult = z.infer; + +export const ModClickElementParamsSchema = z.object({ element: ModElementSchema }).strict(); +export type ModClickElementParams = z.infer; +export const ModClickElementResultSchema = z + .object({ + clicked: z.literal(true), + element: ModElementSchema, + }) + .strict(); +export type ModClickElementResult = z.infer; + +export const ModTypeElementParamsSchema = z + .object({ + element: ModElementSchema, + text: z.string(), + }) + .strict(); +export type ModTypeElementParams = z.infer; +export const ModTypeElementResultSchema = z + .object({ + typed: z.literal(true), + element: ModElementSchema, + }) + .strict(); +export type ModTypeElementResult = z.infer; + +export const ModHoverParamsSchema = ModSelectorTargetParamsSchema; +export type ModHoverParams = z.infer; +export const ModHoverResultSchema = z + .object({ + hovered: z.literal(true), + element: ModElementSchema, + }) + .strict(); +export type ModHoverResult = z.infer; + +export const ModHoverElementParamsSchema = z.object({ element: ModElementSchema }).strict(); +export type ModHoverElementParams = z.infer; +export const ModHoverElementResultSchema = ModHoverResultSchema; +export type ModHoverElementResult = z.infer; + +export const ModFillParamsSchema = ModSelectorTargetParamsSchema.extend({ + value: z.string(), +}); +export type ModFillParams = z.infer; +export const ModFillResultSchema = z + .object({ + filled: z.literal(true), + value: z.string(), + element: ModElementSchema, + }) + .strict(); +export type ModFillResult = z.infer; + +export const ModFillElementParamsSchema = z + .object({ + element: ModElementSchema, + value: z.string(), + }) + .strict(); +export type ModFillElementParams = z.infer; +export const ModFillElementResultSchema = ModFillResultSchema; +export type ModFillElementResult = z.infer; + +export const ModPressParamsSchema = ModResolveContextParamsSchema.extend({ + key: z.string().min(1), +}); +export type ModPressParams = z.infer; +export const ModPressResultSchema = z + .object({ + pressed: z.literal(true), + key: z.string(), + }) + .strict(); +export type ModPressResult = z.infer; + +export const ModPressElementParamsSchema = z + .object({ + element: ModElementSchema, + key: z.string().min(1), + }) + .strict(); +export type ModPressElementParams = z.infer; +export const ModPressElementResultSchema = ModPressResultSchema; +export type ModPressElementResult = z.infer; + +export const ModScrollParamsSchema = ModResolveContextParamsSchema.extend({ + selector: ModSelectorSchema.optional(), + deltaX: z.number().default(0), + deltaY: z.number(), +}); +export type ModScrollParams = z.infer; +export const ModScrollResultSchema = z + .object({ + scrolled: z.literal(true), + page: ModPageSchema, + element: ModElementSchema.optional(), + }) + .strict(); +export type ModScrollResult = z.infer; + +export const ModScrollElementParamsSchema = z + .object({ + element: ModElementSchema, + deltaX: z.number().default(0), + deltaY: z.number(), + }) + .strict(); +export type ModScrollElementParams = z.infer; +export const ModScrollElementResultSchema = ModScrollResultSchema; +export type ModScrollElementResult = z.infer;