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));