Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/trace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 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).
- **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)
Expand Down
27 changes: 19 additions & 8 deletions src/cli/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -101,8 +106,12 @@ export class Cli {

async #runDynamic(options: any): Promise<void> {
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 <port> 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 <port> 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)");
Comment on lines 108 to +112
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.
Expand All @@ -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("; ")}`);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <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>.")
.option("--headed", "Chrome: launch the browser visibly instead of headless (applies to --chrome / --chrome-profile launch modes; implied by --chrome-profile)")
.option("--breakpoint <file:line>", "breakpoint, repeatable: file:line or file@substring (non-pausing; in-scope locals are captured automatically)", collect, [])
.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, [])
.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")
Expand Down
2 changes: 2 additions & 0 deletions src/cli/CommandInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions src/cli/commands/DynamicCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -63,15 +66,19 @@ export class DynamicCommand extends TraceCommand<DynamicRequest, DynamicResult>
// 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 <port>`) uses the running browser as-is.
let launched: LaunchedChrome | undefined;
// Chrome: acquire the browser through the launcher — it decides attach (`--chrome <port>`, 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.
Expand All @@ -88,7 +95,7 @@ export class DynamicCommand extends TraceCommand<DynamicRequest, DynamicResult>
request.onProgress?.(this.#abortedTrace(error, context));
throw error;
} finally {
launched?.kill();
session?.kill();
}
}

Expand Down
94 changes: 77 additions & 17 deletions src/engine/ChromeLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down Expand Up @@ -34,39 +35,98 @@ function freePort(): Promise<number> {
});
}

/** 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 <port>`) 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<LaunchedChrome> {
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<ChromeSession> {
// 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 <port>)");
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<LaunchedChrome> {
return ChromeLauncher.#spawn({ headless: true, extraArgs, purpose: opts.purpose ?? "trace target" });
}

static async #spawn(spec: SpawnSpec): Promise<LaunchedChrome> {
const binaryPath = chromeBinary();
if (!binaryPath) throw new Error("no Chrome found to launch (set CHROME_BIN, or pass --chrome <port> 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); }
}
Expand Down
Loading