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..d00888529 --- /dev/null +++ b/packages/cli/src/commands/macro/delete.ts @@ -0,0 +1,39 @@ +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"]; + + static override args = { + name: Args.string({ + description: "Macro name to delete.", + required: true, + }), + }; + + async run(): Promise { + const { args } = await this.parse(MacroDelete); + const file = macroFilePath(args.name); + + try { + await fs.unlink(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + fail(`Macro "${args.name}" not found.`); + } + throw error; + } + + 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..f87b1589d 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 { tryAppendMacroStepIfRecording } 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, ); + 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 new file mode 100644 index 000000000..cc62d9532 --- /dev/null +++ b/packages/cli/src/lib/macro/recording.ts @@ -0,0 +1,95 @@ +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; +} + +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/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..859273ed0 --- /dev/null +++ b/packages/cli/tests/macro.test.ts @@ -0,0 +1,140 @@ +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, + tryAppendMacroStepIfRecording, +} 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(); + }); + + 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(); + }); +});