Skip to content

Commit b4b01e6

Browse files
burrows99claude
andauthored
feat(chrome): ChromeLauncher owns Chrome lifecycle + authenticated-profile mode (#10)
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 <dir> (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) <noreply@anthropic.com>
1 parent f335f18 commit b4b01e6

10 files changed

Lines changed: 175 additions & 63 deletions

File tree

skills/trace/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ allowed-tools: Bash(node:*), Bash(trace-cli:*), Read
77
# trace-cli — unified execution tracer for Node & Chrome
88

99
- 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.
10-
- **Chrome can auto-launch:** `--chrome <port>` 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.
10+
- **Chrome can auto-launch:** `--chrome <port>` 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 <dir>` 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).
1111
- **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`.
1212

1313
## Invoking (do this first)

src/cli/Cli.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@ const parseIntArg = (value: string) => parseInt(value, 10);
3232
const collect = (value: string, accumulator: string[]) => { accumulator.push(value); return accumulator; };
3333
const usage = (message: string): never => { process.stderr.write(`trace-cli: ${message}\n`); process.exit(2); };
3434

35-
function pickTarget(options: any): { target: DynamicTargetKind; port: number; launch: boolean } {
36-
if (options.chrome != null) {
37-
const launch = options.chrome === true; // bare `--chrome` (no port) → launch a throwaway headless Chrome
38-
return { target: TargetKind.Chrome, port: launch ? 0 : parseIntArg(options.chrome), launch };
35+
interface PickedTarget { target: DynamicTargetKind; port: number; launch: boolean; profileDir?: string; headed?: boolean; }
36+
function pickTarget(options: any): PickedTarget {
37+
// A named --chrome-profile selects Chrome and implies launching it (a profile can only be grafted onto a
38+
// browser we spawn), even without --chrome; bare --chrome (no port) launches a throwaway, a port attaches.
39+
if (options.chrome != null || options.chromeProfile) {
40+
const profileDir: string | undefined = options.chromeProfile || undefined;
41+
const launch = profileDir != null || options.chrome === true;
42+
const headed = options.headed === true || profileDir != null; // a logged-in profile is shown so you can watch/intervene
43+
return { target: TargetKind.Chrome, port: launch ? 0 : parseIntArg(options.chrome), launch, ...(profileDir ? { profileDir } : {}), headed };
3944
}
4045
return { target: TargetKind.Node, port: options.node === undefined || options.node === true ? DEFAULT_NODE_PORT : parseIntArg(options.node), launch: false };
4146
}
@@ -101,8 +106,12 @@ export class Cli {
101106

102107
async #runDynamic(options: any): Promise<void> {
103108
if (options.chrome != null && options.node != null) usage("pick one target: --node or --chrome, not both");
109+
if (options.chromeProfile && options.node != null) usage("--chrome-profile is a chrome option — don't combine it with --node");
110+
// --chrome-profile launches a browser on that profile; an explicit --chrome <port> means attach to a running one.
111+
if (options.chromeProfile && typeof options.chrome === "string") usage("pick one: --chrome-profile launches a logged-in browser, or --chrome <port> attaches to a running one — not both");
112+
if (options.headed && !(options.chrome != null || options.chromeProfile)) usage("--headed only applies when launching Chrome (use with --chrome or --chrome-profile)");
104113
if (options.concise && options.detailed) usage("pick one envelope verbosity: --concise or --detailed, not both");
105-
const { target, port, launch } = pickTarget(options);
114+
const { target, port, launch, profileDir, headed } = pickTarget(options);
106115
const isChrome = target === TargetKind.Chrome;
107116
if (!options.breakpoint.length) usage("run needs at least one --breakpoint (file:line or file@substring)");
108117
// Chrome trigger = an ordered UI journey; --url is shorthand for a leading `goto:`. Node trigger = a curl.
@@ -112,7 +121,7 @@ export class Cli {
112121
if (!isChrome && options.step.length) usage("--step is a chrome-only trigger (node uses --curl)");
113122
if (!isChrome && !options.curl) usage(`${target} target needs --curl`);
114123

115-
const input = new DynamicInput({ target, port, launch, breakpoints: options.breakpoint, exprs: options.expression, steps, curl: options.curl });
124+
const input = new DynamicInput({ target, port, launch, profileDir, headed, breakpoints: options.breakpoint, exprs: options.expression, steps, curl: options.curl });
116125
const badInput = input.validate();
117126
if (badInput.length) usage(`invalid input — ${badInput.join("; ")}`);
118127

@@ -143,12 +152,12 @@ export class Cli {
143152
let trace: Trace;
144153
try {
145154
({ trace } = await this.#dynamic.run({
146-
target, port, launch,
155+
target, port, launch, profileDir, headed,
147156
breakpoints: options.breakpoint, exprs: options.expression,
148157
steps, curl: options.curl,
149158
root: options.root, maxHits: options.maxHits,
150159
recordOut: options.output,
151-
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 } : {}) },
160+
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 } : {}) },
152161
...(emitToCollector ? { onProgress: (intermediateTrace: Trace) => emitToCollector(intermediateTrace.toJSON()) } : {}),
153162
}));
154163
} catch (error) {
@@ -260,6 +269,8 @@ export class Cli {
260269
.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.")
261270
.option("--node [port]", `Node --inspect target (default; port ${DEFAULT_NODE_PORT})`)
262271
.option("--chrome [port]", "Chrome target: a running browser's --remote-debugging-port, or omit the port to launch a throwaway headless Chrome")
272+
.option("--chrome-profile <dir>", "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 <port>.")
273+
.option("--headed", "Chrome: launch the browser visibly instead of headless (applies to --chrome / --chrome-profile launch modes; implied by --chrome-profile)")
263274
.option("--breakpoint <file:line>", "breakpoint, repeatable: file:line or file@substring (non-pausing; in-scope locals are captured automatically)", collect, [])
264275
.option("--expression <js>", "extra expression captured at every hit, repeatable — for computed/derived values beyond the auto-captured locals (e.g. user.id, cart.length)", collect, [])
265276
.option("--root <dir>", "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")

src/cli/CommandInputs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export class DynamicInput {
4848
// In Chrome launch mode the port isn't known until the browser is spawned, so only range-check a real port.
4949
@ValidateIf((input) => !input.launch) @IsInt() @Min(1) @Max(MAX_PORT) port: number;
5050
@IsOptional() @IsBoolean() launch?: boolean;
51+
@IsOptional() @IsString() @IsNotEmpty() profileDir?: string; // chrome: persistent --user-data-dir (a logged-in profile)
52+
@IsOptional() @IsBoolean() headed?: boolean; // chrome: launch the browser visibly
5153
@IsArray() @ArrayNotEmpty() @IsString({ each: true }) breakpoints: string[];
5254
@IsArray() @IsString({ each: true }) exprs: string[];
5355
@IsOptional() @IsArray() @IsString({ each: true }) steps?: string[]; // chrome: the ordered UI journey

src/cli/commands/DynamicCommand.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { join } from "node:path";
44

55
import { Tracer, type CaptureResult, type TraceOptions } from "../../engine/Tracer.js";
66
import { Recorder } from "../../engine/Recorder.js";
7-
import { ChromeLauncher, type LaunchedChrome } from "../../engine/ChromeLauncher.js";
7+
import { ChromeLauncher } from "../../engine/ChromeLauncher.js";
8+
import { ChromeSession } from "../../engine/ChromeSession.js";
89
import { Renderer } from "../../engine/Renderer.js";
910
import { LineageAnalyzer } from "../../analysis/LineageAnalyzer.js";
1011
import { Trace, TraceData, CurlResponse } from "../../domain/Trace.js";
@@ -23,6 +24,8 @@ export type DynamicTargetKind = TargetKind;
2324
export interface DynamicRequest extends TraceOptions {
2425
target: DynamicTargetKind;
2526
launch?: boolean; // chrome: spawn a throwaway headless Chrome instead of attaching to `port`
27+
profileDir?: string; // chrome: launch on a persistent --user-data-dir (a real, logged-in profile)
28+
headed?: boolean; // chrome: launch the browser visibly instead of headless
2629
recordOut?: string; // explicit output path (else a temp file)
2730
/**
2831
* 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<DynamicRequest, DynamicResult>
6366
// The session exists in the collector the instant the run begins (0 events), then updates on every hit.
6467
request.onProgress?.(this.#runningTrace([], context));
6568

66-
// Chrome launch mode (`--chrome` with no port): spawn a throwaway headless Chrome to BE the trace target,
67-
// then tear it down. Attach mode (`--chrome <port>`) uses the running browser as-is.
68-
let launched: LaunchedChrome | undefined;
69+
// Chrome: acquire the browser through the launcher — it decides attach (`--chrome <port>`, used as-is),
70+
// throwaway headless (`--chrome` no port), or a persistent logged-in profile (`--chrome-profile`), and hands
71+
// back a session whose kill() tears down only what WE launched. Node needs none of this.
72+
let session: ChromeSession | undefined;
6973
try {
7074
let options: TraceOptions = {
7175
...request, sessionId,
7276
...(request.onProgress ? { onEvent: (events) => request.onProgress!(this.#runningTrace(events, context)) } : {}),
7377
};
74-
if (isChrome && request.launch) { launched = await ChromeLauncher.launch(); options = { ...options, port: launched.port }; }
78+
if (isChrome) {
79+
session = await ChromeLauncher.acquire({ port: request.port, launch: request.launch, profileDir: request.profileDir, headed: request.headed });
80+
options = { ...options, port: session.port };
81+
}
7582

7683
// Both targets go through the engine the same way: one method, one CaptureResult. Chrome layers on the
7784
// extra it alone supports — the screen + trace-panel recording.
@@ -88,7 +95,7 @@ export class DynamicCommand extends TraceCommand<DynamicRequest, DynamicResult>
8895
request.onProgress?.(this.#abortedTrace(error, context));
8996
throw error;
9097
} finally {
91-
launched?.kill();
98+
session?.kill();
9299
}
93100
}
94101

src/engine/ChromeLauncher.ts

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { join } from "node:path";
77
import { sleep } from "../shared/sleep.js";
88
import { logger } from "../shared/logger.js";
99
import { Code } from "../shared/codes.js";
10+
import { ChromeSession } from "./ChromeSession.js";
1011

1112
const log = logger.child({ component: "chrome" });
1213

@@ -34,39 +35,98 @@ function freePort(): Promise<number> {
3435
});
3536
}
3637

37-
/** A throwaway headless Chrome: its CDP port plus a kill() that stops the process and removes its profile. */
38+
/** A Chrome process we launched: its CDP port plus a kill() that stops the process and (if ours) removes its profile. */
3839
export interface LaunchedChrome {
3940
port: number;
4041
kill(): void;
4142
}
4243

4344
/**
44-
* ChromeLauncher — spawn a throwaway headless Chrome on a free port with a temp profile, wait until its CDP
45-
* endpoint answers, and hand back the port plus a kill() that also cleans the profile. One launcher, two uses:
46-
* the live trace target (`trace run --chrome` with no port) and the recording-video renderer — so a Chrome
47-
* trace is turnkey instead of requiring a hand-started browser. Attach mode (`--chrome <port>`) bypasses this
48-
* entirely, which is how you trace a real, already-open session.
45+
* How to get a Chrome to trace against. Exactly one of three modes, picked by which fields are set:
46+
* - attach: a `port` only → use a running browser's `--remote-debugging-port` as-is (a real, logged-in session).
47+
* - throwaway: `launch` → spawn a headless Chrome on a fresh temp profile, traced and torn down (the default).
48+
* - profile: `profileDir` → launch a (headed) Chrome on a persistent `--user-data-dir`, so saved logins/cookies
49+
* carry over — the authenticated-session path. `launch` is implied. The profile dir is the caller's; we never
50+
* delete it on teardown.
51+
*/
52+
export interface AcquireSpec {
53+
port?: number; // attach target — a running --remote-debugging-port
54+
launch?: boolean; // spawn a throwaway headless Chrome instead of attaching
55+
profileDir?: string; // persistent --user-data-dir (a real, logged-in profile); implies a launched, headed Chrome
56+
headed?: boolean; // launch visibly (default: headed when profileDir is set, else headless)
57+
extraArgs?: string[]; // extra Chrome flags (e.g. the recorder's --force-device-scale-factor)
58+
purpose?: string; // log label only — distinguishes the trace target from the recorder's render Chrome
59+
}
60+
61+
/** Internal spawn parameters, after {@link ChromeLauncher.acquire} has resolved a spec into a concrete launch. */
62+
interface SpawnSpec {
63+
headless: boolean;
64+
userDataDir?: string; // explicit, caller-owned profile; when absent a throwaway temp dir is created (and removed on kill)
65+
extraArgs: string[];
66+
purpose: string;
67+
}
68+
69+
/**
70+
* ChromeLauncher — the single owner of Chrome process lifecycle: resolve the binary, spawn a browser (throwaway
71+
* headless on a temp profile, or headed on a persistent logged-in one), wait until its CDP endpoint answers, and
72+
* hand back a {@link ChromeSession} that bridges it to the transport. {@link acquire} is the one entry point — it
73+
* decides attach vs throwaway vs profile so callers never branch on launch mode themselves; the live trace target
74+
* (`trace run --chrome`) and the recorder's video renderer both come through here. Attach mode spawns nothing —
75+
* that's how a real, already-open session is traced.
4976
*/
5077
export class ChromeLauncher {
51-
// `purpose` only labels the log line, so a launch is never mistaken for the attached trace target: the
52-
// recorder spins up its own throwaway Chrome to render the trace-panel video ("video render"), which is
53-
// distinct from the trace target Chrome that bare `--chrome` launches ("trace target").
54-
static async launch(extraArgs: string[] = [], opts: { purpose?: string } = {}): Promise<LaunchedChrome> {
55-
const purpose = opts.purpose ?? "trace target";
78+
/**
79+
* Resolve a spec into a {@link ChromeSession}: attach to a running browser, or launch one (throwaway, or on a
80+
* persistent profile) and own its teardown. A named `profileDir` implies launching and runs headed by default,
81+
* so saved logins are reused in a window you can see; a throwaway runs headless.
82+
*/
83+
static async acquire(spec: AcquireSpec): Promise<ChromeSession> {
84+
// A persistent profile only makes sense if we launch the browser ourselves — you can't graft a profile onto
85+
// an already-running one — so naming a profileDir implies launch, just like bare `--chrome` does.
86+
const shouldLaunch = spec.launch || spec.profileDir != null;
87+
if (!shouldLaunch) {
88+
if (!spec.port) throw new Error("attach mode needs a Chrome --remote-debugging-port (pass --chrome <port>)");
89+
return ChromeLauncher.attach(spec.port);
90+
}
91+
const headed = spec.headed ?? spec.profileDir != null;
92+
const launched = await ChromeLauncher.#spawn({
93+
headless: !headed,
94+
...(spec.profileDir != null ? { userDataDir: spec.profileDir } : {}),
95+
extraArgs: spec.extraArgs ?? [],
96+
purpose: spec.purpose ?? "trace target",
97+
});
98+
return new ChromeSession(launched.port, launched);
99+
}
100+
101+
/** Wrap a running browser's debug port as a non-owning session (no spawn, kill is a no-op). */
102+
static attach(port: number): ChromeSession {
103+
return new ChromeSession(port, null);
104+
}
105+
106+
/** Spawn a throwaway headless Chrome (temp profile, removed on kill). The low-level handle the recorder + tests use. */
107+
static launch(extraArgs: string[] = [], opts: { purpose?: string } = {}): Promise<LaunchedChrome> {
108+
return ChromeLauncher.#spawn({ headless: true, extraArgs, purpose: opts.purpose ?? "trace target" });
109+
}
110+
111+
static async #spawn(spec: SpawnSpec): Promise<LaunchedChrome> {
56112
const binaryPath = chromeBinary();
57113
if (!binaryPath) throw new Error("no Chrome found to launch (set CHROME_BIN, or pass --chrome <port> to attach to a running one)");
58114
const port = await freePort();
59-
const profile = mkdtempSync(join(tmpdir(), "trace-chrome-profile-"));
60-
const cleanup = () => { try { rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ } };
115+
// A caller-supplied profileDir is the user's (their logins live there) — keep it. A temp profile is ours — sweep it.
116+
const ephemeralProfile = spec.userDataDir == null;
117+
const profile = spec.userDataDir ?? mkdtempSync(join(tmpdir(), "trace-chrome-profile-"));
118+
const cleanup = () => { if (ephemeralProfile) { try { rmSync(profile, { recursive: true, force: true }); } catch { /* ignore */ } } };
61119
const chromeProcess = spawn(binaryPath, [
62-
"--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
63-
"--no-first-run", "--no-default-browser-check", "--disable-gpu", "--hide-scrollbars",
64-
...extraArgs, "about:blank",
120+
...(spec.headless ? ["--headless=new", "--disable-gpu", "--hide-scrollbars"] : []),
121+
`--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
122+
"--no-first-run", "--no-default-browser-check",
123+
...spec.extraArgs, "about:blank",
65124
], { stdio: "ignore" });
66125
chromeProcess.on("error", (error) => log.error("chrome launch failed", { code: Code.CHROME, bin: binaryPath, err: String(error) }));
67126

68127
for (let attempt = 0; attempt < 80; attempt++) {
69-
try { await (await fetch(`http://localhost:${port}/json/version`)).json(); log.info(`launched headless chrome (${purpose})`, { port, purpose });
128+
try { await (await fetch(`http://localhost:${port}/json/version`)).json();
129+
log.info(`launched chrome (${spec.purpose})`, { port, purpose: spec.purpose, headless: spec.headless, profile: ephemeralProfile ? "throwaway" : profile });
70130
return { port, kill() { try { chromeProcess.kill("SIGKILL"); } catch { /* ignore */ } cleanup(); } };
71131
} catch { await sleep(100); }
72132
}

0 commit comments

Comments
 (0)