From 2ac5fcf1442afa300aed31806351a5564f78f63b Mon Sep 17 00:00:00 2001 From: Oluwaferanmi Oyelude Date: Wed, 17 Jun 2026 00:04:19 -0400 Subject: [PATCH 1/2] feat(cli): add browse macro record and replay commands Let users capture successful driver commands into reusable macros and replay them in a session. Co-authored-by: Cursor --- .changeset/browse-macro-command.md | 5 + packages/cli/package.json | 3 + packages/cli/src/commands/macro/delete.ts | 46 ++++++++ packages/cli/src/commands/macro/list.ts | 22 ++++ packages/cli/src/commands/macro/record.ts | 32 ++++++ packages/cli/src/commands/macro/run.ts | 55 +++++++++ packages/cli/src/commands/macro/show.ts | 23 ++++ packages/cli/src/commands/macro/stop.ts | 19 +++ packages/cli/src/lib/driver/command-cli.ts | 10 +- packages/cli/src/lib/macro/recording.ts | 84 ++++++++++++++ packages/cli/src/lib/macro/replay.ts | 44 +++++++ packages/cli/src/lib/macro/store.ts | 109 ++++++++++++++++++ packages/cli/src/lib/macro/types.ts | 18 +++ packages/cli/tests/macro.test.ts | 127 +++++++++++++++++++++ 14 files changed, 595 insertions(+), 2 deletions(-) create mode 100644 .changeset/browse-macro-command.md create mode 100644 packages/cli/src/commands/macro/delete.ts create mode 100644 packages/cli/src/commands/macro/list.ts create mode 100644 packages/cli/src/commands/macro/record.ts create mode 100644 packages/cli/src/commands/macro/run.ts create mode 100644 packages/cli/src/commands/macro/show.ts create mode 100644 packages/cli/src/commands/macro/stop.ts create mode 100644 packages/cli/src/lib/macro/recording.ts create mode 100644 packages/cli/src/lib/macro/replay.ts create mode 100644 packages/cli/src/lib/macro/store.ts create mode 100644 packages/cli/src/lib/macro/types.ts create mode 100644 packages/cli/tests/macro.test.ts diff --git a/.changeset/browse-macro-command.md b/.changeset/browse-macro-command.md new file mode 100644 index 000000000..9a9d84ce2 --- /dev/null +++ b/.changeset/browse-macro-command.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse macro` commands to record and replay driver command sequences. diff --git a/packages/cli/package.json b/packages/cli/package.json index ac9521666..17982631d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -58,6 +58,9 @@ "mouse": { "description": "Send raw mouse coordinate input." }, + "macro": { + "description": "Record and replay browse driver command sequences." + }, "network": { "description": "Capture browser network traffic for the active session." }, diff --git a/packages/cli/src/commands/macro/delete.ts b/packages/cli/src/commands/macro/delete.ts new file mode 100644 index 000000000..9253d3bf7 --- /dev/null +++ b/packages/cli/src/commands/macro/delete.ts @@ -0,0 +1,46 @@ +import { Args, Flags } from "@oclif/core"; +import { promises as fs } from "node:fs"; + +import { BrowseCommand } from "../../base.js"; +import { macroFilePath } from "../../lib/macro/store.js"; +import { outputJson } from "../../lib/output.js"; + +export default class MacroDelete extends BrowseCommand { + static override description = "Delete a saved browse macro."; + + static override examples = [ + "browse macro delete login-flow", + "browse macro delete login-flow --force", + ]; + + static override args = { + name: Args.string({ + description: "Macro name to delete.", + required: true, + }), + }; + + static override flags = { + force: Flags.boolean({ + default: false, + description: "Delete without confirmation.", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(MacroDelete); + const file = macroFilePath(args.name); + + if (!flags.force) { + throw new Error( + `Refusing to delete macro "${args.name}" without --force.`, + ); + } + + await fs.unlink(file); + outputJson({ + deleted: true, + name: args.name, + }); + } +} diff --git a/packages/cli/src/commands/macro/list.ts b/packages/cli/src/commands/macro/list.ts new file mode 100644 index 000000000..c14f5bd10 --- /dev/null +++ b/packages/cli/src/commands/macro/list.ts @@ -0,0 +1,22 @@ +import { BrowseCommand } from "../../base.js"; +import { listMacroNames } from "../../lib/macro/store.js"; +import { getActiveRecordingName } from "../../lib/macro/recording.js"; +import { outputJson } from "../../lib/output.js"; + +export default class MacroList extends BrowseCommand { + static override description = "List saved browse macros."; + + static override examples = ["browse macro list"]; + + async run(): Promise { + const [macros, recording] = await Promise.all([ + listMacroNames(), + getActiveRecordingName(), + ]); + + outputJson({ + macros, + recording, + }); + } +} diff --git a/packages/cli/src/commands/macro/record.ts b/packages/cli/src/commands/macro/record.ts new file mode 100644 index 000000000..33685f2ea --- /dev/null +++ b/packages/cli/src/commands/macro/record.ts @@ -0,0 +1,32 @@ +import { Args } from "@oclif/core"; + +import { BrowseCommand } from "../../base.js"; +import { startMacroRecording } from "../../lib/macro/recording.js"; +import { outputJson } from "../../lib/output.js"; + +export default class MacroRecord extends BrowseCommand { + static override description = + "Start recording browse driver commands into a named macro."; + + static override examples = [ + "browse macro record login-flow", + "browse macro record checkout --session research", + ]; + + static override args = { + name: Args.string({ + description: "Macro name to create.", + required: true, + }), + }; + + async run(): Promise { + const { args } = await this.parse(MacroRecord); + await startMacroRecording(args.name); + outputJson({ + message: `Recording macro "${args.name}". Run browse commands, then browse macro stop.`, + name: args.name, + recording: true, + }); + } +} diff --git a/packages/cli/src/commands/macro/run.ts b/packages/cli/src/commands/macro/run.ts new file mode 100644 index 000000000..dc726cd63 --- /dev/null +++ b/packages/cli/src/commands/macro/run.ts @@ -0,0 +1,55 @@ +import { Args, Flags } from "@oclif/core"; + +import { BrowseCommand } from "../../base.js"; +import { + driverCommandFlags, + resolveTargetForCommand, + type DriverFlags, +} from "../../lib/driver/command-cli.js"; +import { sessionName } from "../../lib/driver/flags.js"; +import { replayMacro } from "../../lib/macro/replay.js"; +import { outputJson } from "../../lib/output.js"; + +export default class MacroRun extends BrowseCommand { + static override description = + "Replay a saved macro in the active browse driver session."; + + static override examples = [ + "browse macro run login-flow", + "browse macro run checkout --session research --delay 250", + ]; + + static override args = { + name: Args.string({ + description: "Macro name to replay.", + required: true, + }), + }; + + static override flags = { + ...driverCommandFlags, + delay: Flags.integer({ + default: 0, + description: "Delay in milliseconds between macro steps.", + helpValue: "", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(MacroRun); + const session = sessionName(flags.session); + const target = await resolveTargetForCommand(session, flags as DriverFlags); + const { macro, results } = await replayMacro({ + delayMs: flags.delay, + name: args.name, + session, + target, + }); + + outputJson({ + name: macro.name, + results, + steps: macro.steps.length, + }); + } +} diff --git a/packages/cli/src/commands/macro/show.ts b/packages/cli/src/commands/macro/show.ts new file mode 100644 index 000000000..7c4649817 --- /dev/null +++ b/packages/cli/src/commands/macro/show.ts @@ -0,0 +1,23 @@ +import { Args } from "@oclif/core"; + +import { BrowseCommand } from "../../base.js"; +import { loadMacro } from "../../lib/macro/store.js"; +import { outputJson } from "../../lib/output.js"; + +export default class MacroShow extends BrowseCommand { + static override description = "Show the steps in a saved browse macro."; + + static override examples = ["browse macro show login-flow"]; + + static override args = { + name: Args.string({ + description: "Macro name to inspect.", + required: true, + }), + }; + + async run(): Promise { + const { args } = await this.parse(MacroShow); + outputJson(await loadMacro(args.name)); + } +} diff --git a/packages/cli/src/commands/macro/stop.ts b/packages/cli/src/commands/macro/stop.ts new file mode 100644 index 000000000..a01aeb229 --- /dev/null +++ b/packages/cli/src/commands/macro/stop.ts @@ -0,0 +1,19 @@ +import { BrowseCommand } from "../../base.js"; +import { stopMacroRecording } from "../../lib/macro/recording.js"; +import { outputJson } from "../../lib/output.js"; + +export default class MacroStop extends BrowseCommand { + static override description = "Stop the active macro recording and save it."; + + static override examples = ["browse macro stop"]; + + async run(): Promise { + const macro = await stopMacroRecording(); + outputJson({ + createdAt: macro.createdAt, + message: `Saved macro "${macro.name}" with ${macro.steps.length} step(s).`, + name: macro.name, + steps: macro.steps.length, + }); + } +} diff --git a/packages/cli/src/lib/driver/command-cli.ts b/packages/cli/src/lib/driver/command-cli.ts index 709d6a9e9..5088676cd 100644 --- a/packages/cli/src/lib/driver/command-cli.ts +++ b/packages/cli/src/lib/driver/command-cli.ts @@ -22,6 +22,7 @@ import { type DriverModeFlags, } from "./mode.js"; import type { ConnectionTarget } from "./types.js"; +import { appendMacroStepIfRecording } from "../macro/recording.js"; import { outputJson } from "../output.js"; import { runDriverCommandWithTarget } from "./runtime.js"; @@ -70,9 +71,14 @@ export async function runDriverCommandFromFlags( ): Promise { const session = sessionName(flags.session); const target = await resolveTargetForCommand(session, flags); - outputJson( - await runDriverCommandWithTarget(session, target, command, params), + const result = await runDriverCommandWithTarget( + session, + target, + command, + params, ); + await appendMacroStepIfRecording(command, params); + outputJson(result); } export async function resolveTargetForCommand( diff --git a/packages/cli/src/lib/macro/recording.ts b/packages/cli/src/lib/macro/recording.ts new file mode 100644 index 000000000..3a6e491e4 --- /dev/null +++ b/packages/cli/src/lib/macro/recording.ts @@ -0,0 +1,84 @@ +import type { DriverCommandName } from "../driver/commands/types.js"; +import { + clearRecordingState, + loadMacro, + readRecordingState, + saveMacro, + writeRecordingState, +} from "./store.js"; +import type { BrowseMacro, MacroStep } from "./types.js"; + +const NON_RECORDABLE_COMMANDS = new Set([ + "cursor", + "refs", + "snapshot", + "tab.list", +]); + +export async function startMacroRecording(name: string): Promise { + const active = await readRecordingState(); + if (active) { + throw new Error( + `Already recording macro "${active.name}". Run browse macro stop first.`, + ); + } + + try { + await loadMacro(name); + throw new Error( + `Macro "${name}" already exists. Choose a different name or delete the existing macro first.`, + ); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("not found")) { + throw error; + } + } + + await writeRecordingState({ + name, + startedAt: new Date().toISOString(), + steps: [], + }); +} + +export async function stopMacroRecording(): Promise { + const active = await readRecordingState(); + if (!active) { + throw new Error( + "No macro recording in progress. Run browse macro record first.", + ); + } + + const macro: BrowseMacro = { + createdAt: active.startedAt, + name: active.name, + steps: active.steps, + }; + + await saveMacro(macro); + await clearRecordingState(); + return macro; +} + +export async function appendMacroStepIfRecording( + command: DriverCommandName, + params: unknown, +): Promise { + if (NON_RECORDABLE_COMMANDS.has(command)) { + return; + } + + const active = await readRecordingState(); + if (!active) { + return; + } + + const step: MacroStep = { command, params }; + active.steps.push(step); + await writeRecordingState(active); +} + +export async function getActiveRecordingName(): Promise { + const active = await readRecordingState(); + return active?.name ?? null; +} diff --git a/packages/cli/src/lib/macro/replay.ts b/packages/cli/src/lib/macro/replay.ts new file mode 100644 index 000000000..ee648db72 --- /dev/null +++ b/packages/cli/src/lib/macro/replay.ts @@ -0,0 +1,44 @@ +import type { DriverCommandName } from "../driver/commands/types.js"; +import { runDriverCommandWithTarget } from "../driver/runtime.js"; +import type { ConnectionTarget } from "../driver/types.js"; +import { loadMacro } from "./store.js"; +import type { BrowseMacro } from "./types.js"; + +export interface ReplayMacroOptions { + delayMs: number; + name: string; + session: string; + target: ConnectionTarget; +} + +export interface ReplayMacroResult { + macro: BrowseMacro; + results: unknown[]; +} + +export async function replayMacro( + options: ReplayMacroOptions, +): Promise { + const macro = await loadMacro(options.name); + const results: unknown[] = []; + + for (const step of macro.steps) { + const result = await runDriverCommandWithTarget( + options.session, + options.target, + step.command as DriverCommandName, + step.params, + ); + results.push(result); + + if (options.delayMs > 0) { + await sleep(options.delayMs); + } + } + + return { macro, results }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/cli/src/lib/macro/store.ts b/packages/cli/src/lib/macro/store.ts new file mode 100644 index 000000000..993711af1 --- /dev/null +++ b/packages/cli/src/lib/macro/store.ts @@ -0,0 +1,109 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + ensurePrivateDir, + PRIVATE_FILE_MODE, + runtimeDir, + writePrivateFile, +} from "../driver/daemon/paths.js"; +import type { BrowseMacro, MacroRecordingState } from "./types.js"; + +const MACRO_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +export function macrosDir(): string { + return ( + process.env.BROWSE_MACRO_DIR ?? path.join(os.homedir(), ".browse", "macros") + ); +} + +export function recordingStatePath(): string { + return path.join(runtimeDir(), "macro-recording.json"); +} + +export function assertValidMacroName(name: string): void { + if (!MACRO_NAME_RE.test(name)) { + throw new Error( + `Invalid macro name "${name}". Use 1-64 characters: letters, numbers, ".", "_", or "-".`, + ); + } +} + +export function macroFilePath(name: string): string { + assertValidMacroName(name); + return path.join(macrosDir(), `${name}.json`); +} + +export async function ensureMacrosDir(): Promise { + const dir = macrosDir(); + await ensurePrivateDir(dir); + return dir; +} + +export async function loadMacro(name: string): Promise { + const file = macroFilePath(name); + let raw: string; + try { + raw = await fs.readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Macro "${name}" not found.`); + } + throw error; + } + + const parsed = JSON.parse(raw) as BrowseMacro; + if (!parsed.name || !Array.isArray(parsed.steps)) { + throw new Error(`Macro "${name}" is invalid or corrupted.`); + } + + return parsed; +} + +export async function saveMacro(macro: BrowseMacro): Promise { + await ensureMacrosDir(); + const file = macroFilePath(macro.name); + await writePrivateFile(file, `${JSON.stringify(macro, null, 2)}\n`); + return file; +} + +export async function listMacroNames(): Promise { + await ensureMacrosDir(); + const entries = await fs.readdir(macrosDir(), { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => entry.name.slice(0, -".json".length)) + .sort((a, b) => a.localeCompare(b)); +} + +export async function readRecordingState(): Promise { + try { + const raw = await fs.readFile(recordingStatePath(), "utf8"); + const parsed = JSON.parse(raw) as MacroRecordingState; + if (!parsed.name || !Array.isArray(parsed.steps)) { + return null; + } + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +export async function writeRecordingState( + state: MacroRecordingState, +): Promise { + await ensurePrivateDir(runtimeDir()); + await writePrivateFile(recordingStatePath(), `${JSON.stringify(state)}\n`); +} + +export async function clearRecordingState(): Promise { + await fs.unlink(recordingStatePath()).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + }); +} diff --git a/packages/cli/src/lib/macro/types.ts b/packages/cli/src/lib/macro/types.ts new file mode 100644 index 000000000..d844e8d13 --- /dev/null +++ b/packages/cli/src/lib/macro/types.ts @@ -0,0 +1,18 @@ +import type { DriverCommandName } from "../driver/commands/types.js"; + +export interface MacroStep { + command: DriverCommandName; + params?: unknown; +} + +export interface BrowseMacro { + createdAt: string; + name: string; + steps: MacroStep[]; +} + +export interface MacroRecordingState { + name: string; + startedAt: string; + steps: MacroStep[]; +} diff --git a/packages/cli/tests/macro.test.ts b/packages/cli/tests/macro.test.ts new file mode 100644 index 000000000..46725ac94 --- /dev/null +++ b/packages/cli/tests/macro.test.ts @@ -0,0 +1,127 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + appendMacroStepIfRecording, + startMacroRecording, + stopMacroRecording, +} from "../src/lib/macro/recording.js"; +import { replayMacro } from "../src/lib/macro/replay.js"; +import { + listMacroNames, + loadMacro, + macrosDir, + recordingStatePath, +} from "../src/lib/macro/store.js"; + +const tempDirs: string[] = []; + +async function useTempMacroDirs(): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "browse-macro-test-")); + tempDirs.push(base); + process.env.BROWSE_MACRO_DIR = path.join(base, "macros"); + process.env.BROWSE_DAEMON_DIR = path.join(base, "runtime"); +} + +async function cleanupTempDirs(): Promise { + delete process.env.BROWSE_MACRO_DIR; + delete process.env.BROWSE_DAEMON_DIR; + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { force: true, recursive: true })), + ); +} + +vi.mock("../src/lib/driver/runtime.js", () => ({ + runDriverCommandWithTarget: vi.fn(async () => ({ ok: true })), +})); + +import { runDriverCommandWithTarget } from "../src/lib/driver/runtime.js"; + +describe("macro store and recording", () => { + beforeEach(async () => { + await useTempMacroDirs(); + }); + + afterEach(async () => { + await cleanupTempDirs(); + vi.mocked(runDriverCommandWithTarget).mockClear(); + }); + + it("records successful driver commands while recording is active", async () => { + await startMacroRecording("login-flow"); + await appendMacroStepIfRecording("open", { + url: "https://example.com", + }); + await appendMacroStepIfRecording("click", { selector: "@0-1" }); + + const macro = await stopMacroRecording(); + expect(macro.name).toBe("login-flow"); + expect(macro.steps).toEqual([ + { command: "open", params: { url: "https://example.com" } }, + { command: "click", params: { selector: "@0-1" } }, + ]); + + const loaded = await loadMacro("login-flow"); + expect(loaded.steps).toHaveLength(2); + expect(await listMacroNames()).toEqual(["login-flow"]); + await expect(fs.readFile(recordingStatePath(), "utf8")).rejects.toThrow(); + }); + + it("skips non-recordable commands", async () => { + await startMacroRecording("inspect-only"); + await appendMacroStepIfRecording("snapshot", {}); + await appendMacroStepIfRecording("refs", {}); + + const macro = await stopMacroRecording(); + expect(macro.steps).toEqual([]); + }); + + it("replays macro steps through the driver runtime", async () => { + await startMacroRecording("checkout"); + await appendMacroStepIfRecording("fill", { + selector: "@0-2", + value: "test@example.com", + }); + await appendMacroStepIfRecording("click", { selector: "@0-3" }); + await stopMacroRecording(); + + const { macro, results } = await replayMacro({ + delayMs: 0, + name: "checkout", + session: "default", + target: { kind: "managed-local" }, + }); + + expect(macro.steps).toHaveLength(2); + expect(results).toHaveLength(2); + expect(runDriverCommandWithTarget).toHaveBeenCalledTimes(2); + expect(runDriverCommandWithTarget).toHaveBeenNthCalledWith( + 1, + "default", + { kind: "managed-local" }, + "fill", + { selector: "@0-2", value: "test@example.com" }, + ); + }); + + it("rejects duplicate macro names when starting a recording", async () => { + await startMacroRecording("login-flow"); + await stopMacroRecording(); + + await expect(startMacroRecording("login-flow")).rejects.toThrow( + "already exists", + ); + }); + + it("stores macros in the configured directory", async () => { + await startMacroRecording("paths"); + await stopMacroRecording(); + + const file = path.join(macrosDir(), "paths.json"); + await expect(fs.access(file)).resolves.toBeUndefined(); + }); +}); From 6aa73372608c3e0735a13a54b52bdb19d5050206 Mon Sep 17 00:00:00 2001 From: Oluwaferanmi Oyelude Date: Wed, 17 Jun 2026 00:19:05 -0400 Subject: [PATCH 2/2] fix(cli): address macro PR review feedback Emit driver results before best-effort recording, remove misleading --force on delete, and use fail() for not-found errors. Co-authored-by: Cursor --- packages/cli/src/commands/macro/delete.ts | 29 ++++++++-------------- packages/cli/src/lib/driver/command-cli.ts | 4 +-- packages/cli/src/lib/macro/recording.ts | 11 ++++++++ packages/cli/tests/macro.test.ts | 13 ++++++++++ 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/macro/delete.ts b/packages/cli/src/commands/macro/delete.ts index 9253d3bf7..d00888529 100644 --- a/packages/cli/src/commands/macro/delete.ts +++ b/packages/cli/src/commands/macro/delete.ts @@ -1,17 +1,15 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import { promises as fs } from "node:fs"; import { BrowseCommand } from "../../base.js"; +import { fail } from "../../lib/errors.js"; import { macroFilePath } from "../../lib/macro/store.js"; import { outputJson } from "../../lib/output.js"; export default class MacroDelete extends BrowseCommand { static override description = "Delete a saved browse macro."; - static override examples = [ - "browse macro delete login-flow", - "browse macro delete login-flow --force", - ]; + static override examples = ["browse macro delete login-flow"]; static override args = { name: Args.string({ @@ -20,24 +18,19 @@ export default class MacroDelete extends BrowseCommand { }), }; - static override flags = { - force: Flags.boolean({ - default: false, - description: "Delete without confirmation.", - }), - }; - async run(): Promise { - const { args, flags } = await this.parse(MacroDelete); + const { args } = await this.parse(MacroDelete); const file = macroFilePath(args.name); - if (!flags.force) { - throw new Error( - `Refusing to delete macro "${args.name}" without --force.`, - ); + try { + await fs.unlink(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + fail(`Macro "${args.name}" not found.`); + } + throw error; } - await fs.unlink(file); outputJson({ deleted: true, name: args.name, diff --git a/packages/cli/src/lib/driver/command-cli.ts b/packages/cli/src/lib/driver/command-cli.ts index 5088676cd..f87b1589d 100644 --- a/packages/cli/src/lib/driver/command-cli.ts +++ b/packages/cli/src/lib/driver/command-cli.ts @@ -22,7 +22,7 @@ import { type DriverModeFlags, } from "./mode.js"; import type { ConnectionTarget } from "./types.js"; -import { appendMacroStepIfRecording } from "../macro/recording.js"; +import { tryAppendMacroStepIfRecording } from "../macro/recording.js"; import { outputJson } from "../output.js"; import { runDriverCommandWithTarget } from "./runtime.js"; @@ -77,8 +77,8 @@ export async function runDriverCommandFromFlags( command, params, ); - await appendMacroStepIfRecording(command, params); outputJson(result); + await tryAppendMacroStepIfRecording(command, params); } export async function resolveTargetForCommand( diff --git a/packages/cli/src/lib/macro/recording.ts b/packages/cli/src/lib/macro/recording.ts index 3a6e491e4..cc62d9532 100644 --- a/packages/cli/src/lib/macro/recording.ts +++ b/packages/cli/src/lib/macro/recording.ts @@ -82,3 +82,14 @@ export async function getActiveRecordingName(): Promise { const active = await readRecordingState(); return active?.name ?? null; } + +export async function tryAppendMacroStepIfRecording( + command: DriverCommandName, + params: unknown, +): Promise { + try { + await appendMacroStepIfRecording(command, params); + } catch { + // Best-effort recording must not mask successful driver commands. + } +} diff --git a/packages/cli/tests/macro.test.ts b/packages/cli/tests/macro.test.ts index 46725ac94..859273ed0 100644 --- a/packages/cli/tests/macro.test.ts +++ b/packages/cli/tests/macro.test.ts @@ -7,6 +7,7 @@ import { appendMacroStepIfRecording, startMacroRecording, stopMacroRecording, + tryAppendMacroStepIfRecording, } from "../src/lib/macro/recording.js"; import { replayMacro } from "../src/lib/macro/replay.js"; import { @@ -124,4 +125,16 @@ describe("macro store and recording", () => { const file = path.join(macrosDir(), "paths.json"); await expect(fs.access(file)).resolves.toBeUndefined(); }); + + it("swallows recording persistence errors in best-effort mode", async () => { + await startMacroRecording("fragile"); + await fs.rm(process.env.BROWSE_DAEMON_DIR!, { + force: true, + recursive: true, + }); + + await expect( + tryAppendMacroStepIfRecording("click", { selector: "@0-1" }), + ).resolves.toBeUndefined(); + }); });