import { describe, expect, it, vi } from "vitest"; import { mergeLocalMacros, parseTerminalMacros, sanitizeTerminalMacros, runTerminalMacro, type MacroStep, type TerminalMacro, } from "../../../src/frontend/macros/terminal-macros"; function macro(steps: MacroStep[]): TerminalMacro { return { id: "macro-1", name: "Test", steps, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; } describe("terminal macro runner", () => { it("runs send, delay, regex wait, condition, and repeat steps", async () => { vi.useFakeTimers(); const sent: string[] = []; let listener = (_data: string) => {}; const run = runTerminalMacro( macro([ { id: "send", type: "send", text: "status", pressEnter: true }, { id: "wait", type: "wait", pattern: "READY\\s+42", timeoutMs: 1000, onTimeout: "stop", }, { id: "if", type: "if", pattern: "READY", then: [ { id: "repeat", type: "repeat", count: 2, steps: [ { id: "ok", type: "send", text: "ok", pressEnter: false }, ], }, ], else: [], }, ]), { send: (data) => sent.push(data), subscribe: (next) => { listener = next; return () => {}; }, }, ); await vi.advanceTimersByTimeAsync(1); listener("READY 42"); await run; expect(sent).toEqual(["status\r", "ok", "ok"]); vi.useRealTimers(); }); it("stops when a required regex times out", async () => { vi.useFakeTimers(); const run = runTerminalMacro( macro([ { id: "wait", type: "wait", pattern: "never", timeoutMs: 100, onTimeout: "stop", }, ]), { send: () => {}, subscribe: () => () => {} }, ); const rejection = expect(run).rejects.toThrow("Timed out"); await vi.advanceTimersByTimeAsync(101); await rejection; vi.useRealTimers(); }); it("sanitizes persisted macro bounds and drops unknown steps", () => { const parsed = parseTerminalMacros( JSON.stringify([ { id: "m", name: "Macro", steps: [ { id: "d", type: "delay", milliseconds: 999_999 }, { id: "r", type: "repeat", count: 1000, steps: [] }, { id: "bad", type: "execute-javascript", code: "nope" }, ], }, ]), ); expect(parsed[0].steps).toEqual([ { id: "d", type: "delay", milliseconds: 300_000 }, { id: "r", type: "repeat", count: 100, steps: [] }, ]); }); it("matches literal text without regex escaping when isRegex is false", async () => { vi.useFakeTimers(); const sent: string[] = []; let listener = (_data: string) => {}; const run = runTerminalMacro( macro([ { id: "wait", type: "wait", pattern: "progress (1/2)", isRegex: false, timeoutMs: 1000, onTimeout: "stop", }, { id: "send", type: "send", text: "done", pressEnter: false }, ]), { send: (data) => sent.push(data), subscribe: (next) => { listener = next; return () => {}; }, }, ); await vi.advanceTimersByTimeAsync(1); listener("progress (1/2)"); await run; expect(sent).toEqual(["done"]); vi.useRealTimers(); }); it("falls back to literal matching when a regex is invalid", async () => { vi.useFakeTimers(); const sent: string[] = []; let listener = (_data: string) => {}; const run = runTerminalMacro( macro([ { id: "wait", type: "wait", pattern: "oops(", isRegex: true, timeoutMs: 1000, onTimeout: "stop", }, { id: "send", type: "send", text: "ok", pressEnter: false }, ]), { send: (data) => sent.push(data), subscribe: (next) => { listener = next; return () => {}; }, }, ); await vi.advanceTimersByTimeAsync(1); listener("oops("); await run; expect(sent).toEqual(["ok"]); vi.useRealTimers(); }); it("branches on literal if-step text", async () => { const sent: string[] = []; await runTerminalMacro( macro([ { id: "if", type: "if", pattern: "a.b", isRegex: false, then: [{ id: "y", type: "send", text: "yes", pressEnter: false }], else: [{ id: "n", type: "send", text: "no", pressEnter: false }], }, ]), { send: (data) => sent.push(data), subscribe: () => () => {} }, ); // "a.b" as a literal must not match "axb" expect(sent).toEqual(["no"]); }); }); describe("mergeLocalMacros", () => { function storage(value: string | null) { const store = new Map(); if (value !== null) store.set("terminalMacros", value); return { store, getItem: (key: string) => store.get(key) ?? null, removeItem: (key: string) => void store.delete(key), }; } const saved = (id: string): TerminalMacro => ({ ...macro([]), id }); it("returns null when the browser kept nothing", () => { expect(mergeLocalMacros([saved("a")], storage(null))).toBeNull(); }); it("adds the browser's macros the saved list does not have", () => { const local = storage( JSON.stringify([ { id: "a", name: "Dupe", steps: [] }, { id: "b", name: "Local", steps: [] }, ]), ); const merged = mergeLocalMacros([saved("a")], local); expect(merged?.macros.map((m) => m.id)).toEqual(["a", "b"]); expect(local.store.has("terminalMacros")).toBe(true); merged?.clear(); expect(local.store.has("terminalMacros")).toBe(false); }); it("clears the key when everything is already saved", () => { const local = storage(JSON.stringify([{ id: "a", name: "A", steps: [] }])); expect(mergeLocalMacros([saved("a")], local)).toBeNull(); expect(local.store.has("terminalMacros")).toBe(false); }); it("reads a stored array as well as its JSON", () => { expect( sanitizeTerminalMacros([{ id: "x", name: "X", steps: [] }]).map( (m) => m.id, ), ).toEqual(["x"]); }); });