From 54c9f6a21551f2f7fa1c6b5e32759896aa69205e Mon Sep 17 00:00:00 2001 From: burrows99 Date: Fri, 19 Jun 2026 10:20:01 +0100 Subject: [PATCH] feat(chrome): ChromeLauncher owns Chrome lifecycle + authenticated-profile mode Consolidate Chrome responsibility that was scattered across DynamicCommand, CdpDriver and Recorder behind ChromeLauncher and a new ChromeSession bridge, add a persistent-profile launch so a real logged-in session can be traced, and switch the journey to always open a fresh tab in the target window instead of reusing an existing one. - ChromeSession (new): the bridge between a held browser (launched or attached) and the CDP transport. Holds the port, knows whether it OWNS the process (so teardown is real for a throwaway and a no-op for an attached window), and exposes Chrome target discovery (pageTargets/openBlankTab). The raw websocket connect stays in CdpDriver (shared with Node). - ChromeLauncher.acquire(spec): one entry point that decides attach vs throwaway headless vs persistent profile and returns a ChromeSession. Spawn is parametrized for headless/headed and ephemeral/persistent profiles; a user-owned profile dir is never deleted on teardown. DynamicCommand's launch-vs-attach branch collapses into a single acquire() + session.kill(). - Authenticated sessions: new --chrome-profile (headed, persistent --user-data-dir, reuses saved logins/cookies) and --headed flags, with validation. Recorder's inline /json fetch now goes through the bridge too. - No tab targeting: JourneyRunner always opens its OWN tab and drives that, so an attached real window keeps its existing tabs untouched while our tab rides the same profile (same logins). Removed the now-dead urlMatch tab matcher from the Chrome path and ChromeSession. Verified: build clean, 47/48 unit tests pass (1 Postgres skip); end-to-end a --chrome-profile run traces the React bug and the profile persists across two separate CLI invocations; an attach run opens one fresh tab and leaves all pre-existing tabs intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/trace/SKILL.md | 2 +- src/cli/Cli.ts | 27 ++++++--- src/cli/CommandInputs.ts | 2 + src/cli/commands/DynamicCommand.ts | 19 ++++-- src/engine/ChromeLauncher.ts | 94 ++++++++++++++++++++++++------ src/engine/ChromeSession.ts | 31 ++++++++++ src/engine/JourneyRunner.ts | 46 +++++++-------- src/engine/Recorder.ts | 5 +- src/engine/Tracer.ts | 9 ++- src/transport/CdpDriver.ts | 3 +- 10 files changed, 175 insertions(+), 63 deletions(-) create mode 100644 src/engine/ChromeSession.ts diff --git a/skills/trace/SKILL.md b/skills/trace/SKILL.md index 5458551..ce61363 100644 --- a/skills/trace/SKILL.md +++ b/skills/trace/SKILL.md @@ -7,7 +7,7 @@ allowed-tools: Bash(node:*), Bash(trace-cli:*), Read # trace-cli — unified execution tracer for Node & Chrome - Attaches to a running debug target, sets breakpoints, fires a trigger, prints the full execution trace in one shot. One engine, one protocol driver — **CDP** for Node and Chrome. You read the trace; you never drive the debugger by hand. -- **Chrome can auto-launch:** `--chrome ` attaches to a browser you started (a real, logged-in session); bare `--chrome` (no port) launches a throwaway headless Chrome, traces, records, and tears it down — a frontend trace needs only the app running. +- **Chrome can auto-launch:** `--chrome ` attaches to a browser you started (a real, logged-in session); bare `--chrome` (no port) launches a throwaway headless Chrome, traces, records, and tears it down — a frontend trace needs only the app running. `--chrome-profile ` launches a headed browser on a persistent profile, so a saved-login session is reused without a hand-started browser (use a copy of your profile; the dir is never deleted on teardown). - **Static analysis needs no running app:** `trace-cli graph` is a call graph (flow tree) via **LSP call hierarchy** — map what a route/function calls, and find breakpoint coordinates before a runtime trace. TS/JS bundled; other languages via `--server` (`gopls` · `pyright --stdio` · `rust-analyzer` · `clangd`, must expose `callHierarchyProvider`). The other analyses are `deps`/`complexity`/`symbols` — run `trace-cli --help`. ## Invoking (do this first) diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index 167ed30..6616268 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -32,10 +32,15 @@ const parseIntArg = (value: string) => parseInt(value, 10); const collect = (value: string, accumulator: string[]) => { accumulator.push(value); return accumulator; }; const usage = (message: string): never => { process.stderr.write(`trace-cli: ${message}\n`); process.exit(2); }; -function pickTarget(options: any): { target: DynamicTargetKind; port: number; launch: boolean } { - if (options.chrome != null) { - const launch = options.chrome === true; // bare `--chrome` (no port) → launch a throwaway headless Chrome - return { target: TargetKind.Chrome, port: launch ? 0 : parseIntArg(options.chrome), launch }; +interface PickedTarget { target: DynamicTargetKind; port: number; launch: boolean; profileDir?: string; headed?: boolean; } +function pickTarget(options: any): PickedTarget { + // A named --chrome-profile selects Chrome and implies launching it (a profile can only be grafted onto a + // browser we spawn), even without --chrome; bare --chrome (no port) launches a throwaway, a port attaches. + if (options.chrome != null || options.chromeProfile) { + const profileDir: string | undefined = options.chromeProfile || undefined; + const launch = profileDir != null || options.chrome === true; + const headed = options.headed === true || profileDir != null; // a logged-in profile is shown so you can watch/intervene + return { target: TargetKind.Chrome, port: launch ? 0 : parseIntArg(options.chrome), launch, ...(profileDir ? { profileDir } : {}), headed }; } return { target: TargetKind.Node, port: options.node === undefined || options.node === true ? DEFAULT_NODE_PORT : parseIntArg(options.node), launch: false }; } @@ -101,8 +106,12 @@ export class Cli { async #runDynamic(options: any): Promise { if (options.chrome != null && options.node != null) usage("pick one target: --node or --chrome, not both"); + if (options.chromeProfile && options.node != null) usage("--chrome-profile is a chrome option — don't combine it with --node"); + // --chrome-profile launches a browser on that profile; an explicit --chrome means attach to a running one. + if (options.chromeProfile && typeof options.chrome === "string") usage("pick one: --chrome-profile launches a logged-in browser, or --chrome attaches to a running one — not both"); + if (options.headed && !(options.chrome != null || options.chromeProfile)) usage("--headed only applies when launching Chrome (use with --chrome or --chrome-profile)"); if (options.concise && options.detailed) usage("pick one envelope verbosity: --concise or --detailed, not both"); - const { target, port, launch } = pickTarget(options); + const { target, port, launch, profileDir, headed } = pickTarget(options); const isChrome = target === TargetKind.Chrome; if (!options.breakpoint.length) usage("run needs at least one --breakpoint (file:line or file@substring)"); // Chrome trigger = an ordered UI journey; --url is shorthand for a leading `goto:`. Node trigger = a curl. @@ -112,7 +121,7 @@ export class Cli { if (!isChrome && options.step.length) usage("--step is a chrome-only trigger (node uses --curl)"); if (!isChrome && !options.curl) usage(`${target} target needs --curl`); - const input = new DynamicInput({ target, port, launch, breakpoints: options.breakpoint, exprs: options.expression, steps, curl: options.curl }); + const input = new DynamicInput({ target, port, launch, profileDir, headed, breakpoints: options.breakpoint, exprs: options.expression, steps, curl: options.curl }); const badInput = input.validate(); if (badInput.length) usage(`invalid input — ${badInput.join("; ")}`); @@ -143,12 +152,12 @@ export class Cli { let trace: Trace; try { ({ trace } = await this.#dynamic.run({ - target, port, launch, + target, port, launch, profileDir, headed, breakpoints: options.breakpoint, exprs: options.expression, steps, curl: options.curl, root: options.root, maxHits: options.maxHits, recordOut: options.output, - args: { target, ...(launch ? { launch: true } : { port }), breakpoints: options.breakpoint, ...(options.root ? { root: options.root } : {}), ...(options.maxHits ? { maxHits: options.maxHits } : {}), ...(steps.length ? { steps: steps.map(redactStep) } : {}), ...(options.curl ? { curl: options.curl } : {}) }, + args: { target, ...(launch ? { launch: true } : { port }), ...(profileDir ? { profile: profileDir } : {}), ...(headed && !profileDir ? { headed: true } : {}), breakpoints: options.breakpoint, ...(options.root ? { root: options.root } : {}), ...(options.maxHits ? { maxHits: options.maxHits } : {}), ...(steps.length ? { steps: steps.map(redactStep) } : {}), ...(options.curl ? { curl: options.curl } : {}) }, ...(emitToCollector ? { onProgress: (intermediateTrace: Trace) => emitToCollector(intermediateTrace.toJSON()) } : {}), })); } catch (error) { @@ -260,6 +269,8 @@ export class Cli { .description("breakpoints + a trigger → a full execution trace. Breakpoints are non-pausing logpoints: each hit ships its stack + in-scope locals + exprs without halting the VM, so the app runs at full speed. Node (CDP): a --curl trigger. Chrome (CDP): a scripted UI journey (--url/--step) recorded as a screen + trace-panel replay — debug and video together.") .option("--node [port]", `Node --inspect target (default; port ${DEFAULT_NODE_PORT})`) .option("--chrome [port]", "Chrome target: a running browser's --remote-debugging-port, or omit the port to launch a throwaway headless Chrome") + .option("--chrome-profile ", "Chrome: launch a (headed) browser on this persistent --user-data-dir so saved logins/cookies carry over — trace a real, authenticated session. Use a COPY of your profile (Chrome 136+ blocks remote-debugging on the default dir; one process per dir). Implies launching, so don't combine with --chrome .") + .option("--headed", "Chrome: launch the browser visibly instead of headless (applies to --chrome / --chrome-profile launch modes; implied by --chrome-profile)") .option("--breakpoint ", "breakpoint, repeatable: file:line or file@substring (non-pausing; in-scope locals are captured automatically)", collect, []) .option("--expression ", "extra expression captured at every hit, repeatable — for computed/derived values beyond the auto-captured locals (e.g. user.id, cart.length)", collect, []) .option("--root ", "project root for resolving --breakpoint file paths and source maps (default: cwd) — needed when a file@substring breakpoint or a built app's sources live outside cwd") diff --git a/src/cli/CommandInputs.ts b/src/cli/CommandInputs.ts index f30faa5..b0a46f1 100644 --- a/src/cli/CommandInputs.ts +++ b/src/cli/CommandInputs.ts @@ -48,6 +48,8 @@ export class DynamicInput { // In Chrome launch mode the port isn't known until the browser is spawned, so only range-check a real port. @ValidateIf((input) => !input.launch) @IsInt() @Min(1) @Max(MAX_PORT) port: number; @IsOptional() @IsBoolean() launch?: boolean; + @IsOptional() @IsString() @IsNotEmpty() profileDir?: string; // chrome: persistent --user-data-dir (a logged-in profile) + @IsOptional() @IsBoolean() headed?: boolean; // chrome: launch the browser visibly @IsArray() @ArrayNotEmpty() @IsString({ each: true }) breakpoints: string[]; @IsArray() @IsString({ each: true }) exprs: string[]; @IsOptional() @IsArray() @IsString({ each: true }) steps?: string[]; // chrome: the ordered UI journey diff --git a/src/cli/commands/DynamicCommand.ts b/src/cli/commands/DynamicCommand.ts index 07ac940..90318f2 100644 --- a/src/cli/commands/DynamicCommand.ts +++ b/src/cli/commands/DynamicCommand.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { Tracer, type CaptureResult, type TraceOptions } from "../../engine/Tracer.js"; import { Recorder } from "../../engine/Recorder.js"; -import { ChromeLauncher, type LaunchedChrome } from "../../engine/ChromeLauncher.js"; +import { ChromeLauncher } from "../../engine/ChromeLauncher.js"; +import { ChromeSession } from "../../engine/ChromeSession.js"; import { Renderer } from "../../engine/Renderer.js"; import { LineageAnalyzer } from "../../analysis/LineageAnalyzer.js"; import { Trace, TraceData, CurlResponse } from "../../domain/Trace.js"; @@ -23,6 +24,8 @@ export type DynamicTargetKind = TargetKind; export interface DynamicRequest extends TraceOptions { target: DynamicTargetKind; launch?: boolean; // chrome: spawn a throwaway headless Chrome instead of attaching to `port` + profileDir?: string; // chrome: launch on a persistent --user-data-dir (a real, logged-in profile) + headed?: boolean; // chrome: launch the browser visibly instead of headless recordOut?: string; // explicit output path (else a temp file) /** * Live progress sink: called with a partial Trace as soon as the run starts (0 events) and again on every @@ -63,15 +66,19 @@ export class DynamicCommand extends TraceCommand // The session exists in the collector the instant the run begins (0 events), then updates on every hit. request.onProgress?.(this.#runningTrace([], context)); - // Chrome launch mode (`--chrome` with no port): spawn a throwaway headless Chrome to BE the trace target, - // then tear it down. Attach mode (`--chrome `) uses the running browser as-is. - let launched: LaunchedChrome | undefined; + // Chrome: acquire the browser through the launcher — it decides attach (`--chrome `, used as-is), + // throwaway headless (`--chrome` no port), or a persistent logged-in profile (`--chrome-profile`), and hands + // back a session whose kill() tears down only what WE launched. Node needs none of this. + let session: ChromeSession | undefined; try { let options: TraceOptions = { ...request, sessionId, ...(request.onProgress ? { onEvent: (events) => request.onProgress!(this.#runningTrace(events, context)) } : {}), }; - if (isChrome && request.launch) { launched = await ChromeLauncher.launch(); options = { ...options, port: launched.port }; } + if (isChrome) { + session = await ChromeLauncher.acquire({ port: request.port, launch: request.launch, profileDir: request.profileDir, headed: request.headed }); + options = { ...options, port: session.port }; + } // Both targets go through the engine the same way: one method, one CaptureResult. Chrome layers on the // extra it alone supports — the screen + trace-panel recording. @@ -88,7 +95,7 @@ export class DynamicCommand extends TraceCommand request.onProgress?.(this.#abortedTrace(error, context)); throw error; } finally { - launched?.kill(); + session?.kill(); } } diff --git a/src/engine/ChromeLauncher.ts b/src/engine/ChromeLauncher.ts index e570819..893b4d3 100644 --- a/src/engine/ChromeLauncher.ts +++ b/src/engine/ChromeLauncher.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { sleep } from "../shared/sleep.js"; import { logger } from "../shared/logger.js"; import { Code } from "../shared/codes.js"; +import { ChromeSession } from "./ChromeSession.js"; const log = logger.child({ component: "chrome" }); @@ -34,39 +35,98 @@ function freePort(): Promise { }); } -/** A throwaway headless Chrome: its CDP port plus a kill() that stops the process and removes its profile. */ +/** A Chrome process we launched: its CDP port plus a kill() that stops the process and (if ours) removes its profile. */ export interface LaunchedChrome { port: number; kill(): void; } /** - * ChromeLauncher — spawn a throwaway headless Chrome on a free port with a temp profile, wait until its CDP - * endpoint answers, and hand back the port plus a kill() that also cleans the profile. One launcher, two uses: - * the live trace target (`trace run --chrome` with no port) and the recording-video renderer — so a Chrome - * trace is turnkey instead of requiring a hand-started browser. Attach mode (`--chrome `) bypasses this - * entirely, which is how you trace a real, already-open session. + * How to get a Chrome to trace against. Exactly one of three modes, picked by which fields are set: + * - attach: a `port` only → use a running browser's `--remote-debugging-port` as-is (a real, logged-in session). + * - throwaway: `launch` → spawn a headless Chrome on a fresh temp profile, traced and torn down (the default). + * - profile: `profileDir` → launch a (headed) Chrome on a persistent `--user-data-dir`, so saved logins/cookies + * carry over — the authenticated-session path. `launch` is implied. The profile dir is the caller's; we never + * delete it on teardown. + */ +export interface AcquireSpec { + port?: number; // attach target — a running --remote-debugging-port + launch?: boolean; // spawn a throwaway headless Chrome instead of attaching + profileDir?: string; // persistent --user-data-dir (a real, logged-in profile); implies a launched, headed Chrome + headed?: boolean; // launch visibly (default: headed when profileDir is set, else headless) + extraArgs?: string[]; // extra Chrome flags (e.g. the recorder's --force-device-scale-factor) + purpose?: string; // log label only — distinguishes the trace target from the recorder's render Chrome +} + +/** Internal spawn parameters, after {@link ChromeLauncher.acquire} has resolved a spec into a concrete launch. */ +interface SpawnSpec { + headless: boolean; + userDataDir?: string; // explicit, caller-owned profile; when absent a throwaway temp dir is created (and removed on kill) + extraArgs: string[]; + purpose: string; +} + +/** + * ChromeLauncher — the single owner of Chrome process lifecycle: resolve the binary, spawn a browser (throwaway + * headless on a temp profile, or headed on a persistent logged-in one), wait until its CDP endpoint answers, and + * hand back a {@link ChromeSession} that bridges it to the transport. {@link acquire} is the one entry point — it + * decides attach vs throwaway vs profile so callers never branch on launch mode themselves; the live trace target + * (`trace run --chrome`) and the recorder's video renderer both come through here. Attach mode spawns nothing — + * that's how a real, already-open session is traced. */ export class ChromeLauncher { - // `purpose` only labels the log line, so a launch is never mistaken for the attached trace target: the - // recorder spins up its own throwaway Chrome to render the trace-panel video ("video render"), which is - // distinct from the trace target Chrome that bare `--chrome` launches ("trace target"). - static async launch(extraArgs: string[] = [], opts: { purpose?: string } = {}): Promise { - const purpose = opts.purpose ?? "trace target"; + /** + * Resolve a spec into a {@link ChromeSession}: attach to a running browser, or launch one (throwaway, or on a + * persistent profile) and own its teardown. A named `profileDir` implies launching and runs headed by default, + * so saved logins are reused in a window you can see; a throwaway runs headless. + */ + static async acquire(spec: AcquireSpec): Promise { + // A persistent profile only makes sense if we launch the browser ourselves — you can't graft a profile onto + // an already-running one — so naming a profileDir implies launch, just like bare `--chrome` does. + const shouldLaunch = spec.launch || spec.profileDir != null; + if (!shouldLaunch) { + if (!spec.port) throw new Error("attach mode needs a Chrome --remote-debugging-port (pass --chrome )"); + return ChromeLauncher.attach(spec.port); + } + const headed = spec.headed ?? spec.profileDir != null; + const launched = await ChromeLauncher.#spawn({ + headless: !headed, + ...(spec.profileDir != null ? { userDataDir: spec.profileDir } : {}), + extraArgs: spec.extraArgs ?? [], + purpose: spec.purpose ?? "trace target", + }); + return new ChromeSession(launched.port, launched); + } + + /** Wrap a running browser's debug port as a non-owning session (no spawn, kill is a no-op). */ + static attach(port: number): ChromeSession { + return new ChromeSession(port, null); + } + + /** Spawn a throwaway headless Chrome (temp profile, removed on kill). The low-level handle the recorder + tests use. */ + static launch(extraArgs: string[] = [], opts: { purpose?: string } = {}): Promise { + return ChromeLauncher.#spawn({ headless: true, extraArgs, purpose: opts.purpose ?? "trace target" }); + } + + static async #spawn(spec: SpawnSpec): Promise { const binaryPath = chromeBinary(); if (!binaryPath) throw new Error("no Chrome found to launch (set CHROME_BIN, or pass --chrome to attach to a running one)"); const port = await freePort(); - const profile = mkdtempSync(join(tmpdir(), "trace-chrome-profile-")); - const cleanup = () => { try { rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ } }; + // A caller-supplied profileDir is the user's (their logins live there) — keep it. A temp profile is ours — sweep it. + const ephemeralProfile = spec.userDataDir == null; + const profile = spec.userDataDir ?? mkdtempSync(join(tmpdir(), "trace-chrome-profile-")); + const cleanup = () => { if (ephemeralProfile) { try { rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ } } }; const chromeProcess = spawn(binaryPath, [ - "--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, - "--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars", - ...extraArgs, "about:blank", + ...(spec.headless ? ["--headless=new", "--disable-gpu", "--hide-scrollbars"] : []), + `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, + "--no-first-run", "--no-default-browser-check", + ...spec.extraArgs, "about:blank", ], { stdio: "ignore" }); chromeProcess.on("error", (error) => log.error("chrome launch failed", { code: Code.CHROME, bin: binaryPath, err: String(error) })); for (let attempt = 0; attempt < 80; attempt++) { - try { await (await fetch(`http://localhost:${port}/json/version`)).json(); log.info(`launched headless chrome (${purpose})`, { port, purpose }); + try { await (await fetch(`http://localhost:${port}/json/version`)).json(); + log.info(`launched chrome (${spec.purpose})`, { port, purpose: spec.purpose, headless: spec.headless, profile: ephemeralProfile ? "throwaway" : profile }); return { port, kill() { try { chromeProcess.kill("SIGKILL"); } catch { /* ignore */ } cleanup(); } }; } catch { await sleep(100); } } diff --git a/src/engine/ChromeSession.ts b/src/engine/ChromeSession.ts new file mode 100644 index 0000000..9eaebe4 --- /dev/null +++ b/src/engine/ChromeSession.ts @@ -0,0 +1,31 @@ +import { CdpDriver } from "../transport/CdpDriver.js"; +import { TargetKind } from "../domain/Target.js"; +import type { LaunchedChrome } from "./ChromeLauncher.js"; + +/** + * ChromeSession — the bridge between a Chrome we hold (launched or attached) and the CDP transport that talks + * to it. One object per `--chrome` run: it carries the port, knows whether it OWNS the browser process — so + * teardown is real for a throwaway we launched and a no-op for an attached, user-owned window — and exposes the + * Chrome-flavoured target discovery (the `/json` page-target semantics) so callers never reach into CdpDriver's + * Chrome branch by hand. Process lifecycle (spawn/profile/kill) is {@link ChromeLauncher}'s; the raw websocket + * connect stays {@link CdpDriver}'s (shared with Node). This sits between the two — neither owns the other. + */ +export class ChromeSession { + constructor(readonly port: number, private readonly owned: LaunchedChrome | null = null) {} + + /** True when we launched this Chrome (so we tear it down); false for an attached, user-owned browser. */ + get launched(): boolean { return this.owned !== null; } + + /** The open page targets (type "page" with a websocket) — used to spot our freshly-opened tab (and popups). */ + async pageTargets(): Promise { + return (await CdpDriver.listTargets(this.port, TargetKind.Chrome)).filter((target) => target.type === "page" && target.webSocketDebuggerUrl); + } + + /** Open a fresh blank tab (for a debug Chrome that's up but tabless) and return its descriptor. */ + openBlankTab(): Promise { + return CdpDriver.createPageTarget(this.port); + } + + /** Stop the browser if WE launched it; a no-op for an attached session — the user owns that window. */ + kill(): void { this.owned?.kill(); } +} diff --git a/src/engine/JourneyRunner.ts b/src/engine/JourneyRunner.ts index 2a50232..2614565 100644 --- a/src/engine/JourneyRunner.ts +++ b/src/engine/JourneyRunner.ts @@ -2,7 +2,7 @@ import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; import { CdpDriver, log } from "../transport/CdpDriver.js"; import { Cdp } from "../transport/cdp.js"; -import { TargetKind } from "../domain/Target.js"; +import { ChromeSession } from "./ChromeSession.js"; import { Screencaster } from "./Screencaster.js"; import { PageActions } from "./PageActions.js"; import { TabTracer } from "./TabTracer.js"; @@ -36,14 +36,14 @@ export interface TracedHit { ev: TraceEvent; t: number; } export interface TraceConfig { bps: ResolvedBreakpoint[]; root?: string; exprs: string[]; frames: number; maxHits: number; onEvent?: (events: TraceEvent[]) => void; } /** - * JourneyRunner — orchestrates a scripted UI journey across one or more page targets: discover (or open) a - * tab, drive each step, follow any spawned tab and re-point the screencast at it, and — when given + * JourneyRunner — orchestrates a scripted UI journey across one or more page targets: open a fresh tab in the + * target window, drive each step, follow any spawned tab and re-point the screencast at it, and — when given * breakpoints — attach a {@link TabTracer} per tab so the recorder can lay a live trace panel beside the * screen. Responsibilities are split: tab plumbing lives here, DOM input in {@link PageActions}, breakpoint * capture in {@link TabTracer}. Vendor-neutral: URLs, selectors and breakpoints all come from input. */ export class JourneyRunner { - #port: number; + #chrome: ChromeSession; #screencaster: Screencaster; #trace?: TraceConfig; #current!: CdpDriver; @@ -55,7 +55,7 @@ export class JourneyRunner { readonly traced: TracedHit[] = []; finalUrl?: string; - constructor(port: number, screencaster: Screencaster, trace?: TraceConfig) { this.#port = port; this.#screencaster = screencaster; this.#trace = trace; } + constructor(chrome: ChromeSession, screencaster: Screencaster, trace?: TraceConfig) { this.#chrome = chrome; this.#screencaster = screencaster; this.#trace = trace; } /** Parse a `--step` string into a {@link Step}. Vocabulary is validated upstream by `StepInput`, not here. */ static parseStep(rawStep: string): Step { @@ -76,7 +76,7 @@ export class JourneyRunner { } async #pages(): Promise { - return (await CdpDriver.listTargets(this.#port, TargetKind.Chrome)).filter((target) => target.type === "page" && target.webSocketDebuggerUrl); + return this.#chrome.pageTargets(); } /** Connect to a target, enable its domains, and (when tracing) attach a TabTracer. */ @@ -109,26 +109,26 @@ export class JourneyRunner { } /** - * Attach to an existing page (or one matching `urlMatch`) and begin recording it. `bindBeforeFirstRun` arms - * THE ONE PAUSE (see {@link TabTracer}) so breakpoints bind *before* the upcoming navigation's scripts run — - * set it when the journey opens with a `goto`, so first-run / on-mount code (e.g. a SPA computing a value - * during initial render) is caught instead of missed. Left false for an attach-then-click flow, where the - * tab is already live and its handlers fire later on a click. + * Open a FRESH tab in the target window and begin recording it. We never reuse or hijack an existing tab — + * tab targeting is deliberately gone — so an attached, real session keeps its own tabs untouched while our + * tab rides the same profile (hence the same logins). The journey's first `goto:` step navigates this blank + * tab. `bindBeforeFirstRun` arms THE ONE PAUSE (see {@link TabTracer}) so breakpoints bind *before* that + * navigation's scripts run — set it when the journey opens with a `goto`, so first-run / on-mount code (e.g. + * a SPA computing a value during initial render) is caught instead of missed. */ - async start(urlMatch?: string, bindBeforeFirstRun = false): Promise { - let pages = await this.#pages(); - if (!pages.length) { - // The debug Chrome is up but tabless (a prior run / the impersonation popup closed its tabs). Open a - // blank tab so attach is robust to tab state — the first `goto:` step navigates it. This removes the - // most common "no page target on :PORT" failure, which forced a manual re-seed of a tab before each run. - log(`no page target on :${this.#port} — opening a blank tab to attach`); - await CdpDriver.createPageTarget(this.#port).catch((error) => log(`could not open a tab on :${this.#port}: ${error?.message || error}`)); + async start(bindBeforeFirstRun = false): Promise { + // Everything already open is "known"; only the tab WE open (and any future popup) should count as new. + for (const page of await this.#pages()) this.#known.add(page.id); + // Open our own tab and attach to it. /json/new returns the tab descriptor (incl. its websocket), so the + // common case needs no re-list; some Chrome builds return a thin body, so fall back to spotting our new tab. + const opened = await this.#chrome.openBlankTab().catch((error) => { log(`could not open a tab on :${this.#chrome.port}: ${error?.message || error}`); return null; }); + let target = opened?.webSocketDebuggerUrl ? opened : null; + if (!target) { await sleep(300); - pages = await this.#pages(); - if (!pages.length) throw new Error(`no page target on :${this.#port} and could not open one — is the debug Chrome up?`); + target = (await this.#pages()).find((page) => !this.#known.has(page.id)) ?? null; } - const target = (urlMatch && pages.find((page) => (page.url || "").includes(urlMatch))) || pages[0]; - for (const page of pages) this.#known.add(page.id); // everything open now is "known"; only future tabs count as new + if (!target?.webSocketDebuggerUrl) throw new Error(`could not open a tab on :${this.#chrome.port} — is the debug Chrome up?`); + this.#known.add(target.id); const driver = await this.#connect(target, { trace: true, bindBeforeFirstRun }); this.#startTime = Date.now(); await this.#switchTo(driver); diff --git a/src/engine/Recorder.ts b/src/engine/Recorder.ts index 9cd7e5a..f5387c3 100644 --- a/src/engine/Recorder.ts +++ b/src/engine/Recorder.ts @@ -204,11 +204,10 @@ export class Recorder { if (!scenes.length) return null; const tempDir = mkdtempSync(join(tmpdir(), "trace-journey-")); - const chrome = await ChromeLauncher.launch(["--force-device-scale-factor=1"], { purpose: "video render" }); + const chrome = await ChromeLauncher.acquire({ launch: true, extraArgs: ["--force-device-scale-factor=1"], purpose: "video render" }); let driver: CdpDriver | undefined; try { - const targets = await (await fetch(`http://localhost:${chrome.port}/json`)).json() as any[]; - const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl) || targets[0]; + const page = (await chrome.pageTargets())[0]; if (!page?.webSocketDebuggerUrl) throw new Error("render Chrome exposed no page target"); driver = await CdpDriver.connect(page.webSocketDebuggerUrl); await driver.send(Cdp.Page.enable); diff --git a/src/engine/Tracer.ts b/src/engine/Tracer.ts index 8b9bfd6..c3eeb63 100644 --- a/src/engine/Tracer.ts +++ b/src/engine/Tracer.ts @@ -9,6 +9,7 @@ import { BINDING_NAME, HELPER_SOURCE, LogpointCapturer } from "./Logpoint.js"; import { CurlTrigger, type CurlResult } from "./CurlTrigger.js"; import { Screencaster } from "./Screencaster.js"; import { CAPTURE_VIEWPORT } from "./Recorder.js"; +import { ChromeLauncher } from "./ChromeLauncher.js"; import { JourneyRunner, type StepResult, type TraceConfig } from "./JourneyRunner.js"; import { TraceEvent } from "../domain/TraceEvent.js"; import { Breakpoint } from "../domain/Breakpoint.js"; @@ -155,18 +156,20 @@ export class Tracer { * driver; the binding/capture primitives are the same as Node. */ async traceChrome(options: TraceOptions): Promise { - const { port = DEFAULT_CHROME_PORT, steps = [], breakpoints = [], root, exprs = [], frames = 6, maxHits = 100, urlMatch } = options; + const { port = DEFAULT_CHROME_PORT, steps = [], breakpoints = [], root, exprs = [], frames = 6, maxHits = 100 } = options; const parsedSteps = steps.map((step) => JourneyRunner.parseStep(step)); const screencaster = new Screencaster(CAPTURE_VIEWPORT); // portrait-ish: fills the replay's left pane, no letterbox const config: TraceConfig = { bps: BreakpointResolver.resolveAll(breakpoints, root), root, exprs, frames, maxHits, onEvent: options.onEvent }; - const runner = new JourneyRunner(port, screencaster, config); + // Wrap the running browser (DynamicCommand already launched/attached it) as a session so target discovery + // goes through the bridge, not raw CdpDriver calls. + const runner = new JourneyRunner(ChromeLauncher.attach(port), screencaster, config); let stepResults: StepResult[] = []; let fatal: string | undefined; try { // Arm THE ONE PAUSE (bind-before-first-run, see TabTracer) only when the journey opens by navigating a // fresh tab — a leading `goto` — so on-mount code is caught. Attach-then-click flows don't need it. const bindBeforeFirstRun = parsedSteps[0]?.action === "goto"; - await runner.start(urlMatch, bindBeforeFirstRun); + await runner.start(bindBeforeFirstRun); stepResults = await runner.run(parsedSteps); } catch (error: any) { fatal = String(error?.message ?? error); diff --git a/src/transport/CdpDriver.ts b/src/transport/CdpDriver.ts index 5e938fa..389e67d 100644 --- a/src/transport/CdpDriver.ts +++ b/src/transport/CdpDriver.ts @@ -78,8 +78,7 @@ export class CdpDriver implements ProtocolDriver { ): Promise { const { kind = TargetKind.Node, urlMatch, titleMatch } = options; const targets = await CdpDriver.listTargets(port, kind); - let candidates = Array.isArray(targets) ? targets : []; - if (kind === TargetKind.Chrome) candidates = candidates.filter((candidate) => candidate.type === "page" && candidate.webSocketDebuggerUrl); + const candidates = Array.isArray(targets) ? targets : []; let target: any; if (urlMatch) target = candidates.find((candidate) => (candidate.url || "").includes(urlMatch)); if (!target && titleMatch) target = candidates.find((candidate) => (candidate.title || "").includes(titleMatch));