import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { Page } from "puppeteer-core"; import type { BrowserToolInput, SavedWorkflow, WorkflowStep, WorkflowSummary } from "./types.ts"; export function sanitizeWorkflowSegment(value: string | undefined, fallback: string): string { const base = (value ?? fallback).trim() || fallback; return base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || fallback; } export function workflowRoot(artifactRoot: string): string { return join(dirname(artifactRoot), "workflows"); } export function workflowJsonPath(root: string, workflowId: string): string { return join(root, `${sanitizeWorkflowSegment(workflowId, "workflow")}.json`); } export function workflowScriptPath(root: string, workflowId: string): string { return join(root, `${sanitizeWorkflowSegment(workflowId, "workflow")}.js`); } export function createWorkflowId(name: string): string { return `${sanitizeWorkflowSegment(name.toLowerCase(), "workflow")}-${new Date().toISOString().replace(/[:.]/g, "-")}`; } export async function listWorkflows(root: string): Promise { let entries: string[] = []; try { entries = await readdir(root); } catch { return []; } const workflows = await Promise.all( entries .filter((entry) => entry.endsWith(".json")) .map(async (entry): Promise => { try { const workflow = await readWorkflowByPath(join(root, entry)); return summarizeWorkflow(workflow); } catch { return undefined; } }), ); return workflows .filter((workflow): workflow is WorkflowSummary => Boolean(workflow)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } export async function readWorkflow(root: string, workflowIdOrName: string): Promise { const directPath = workflowJsonPath(root, workflowIdOrName); try { return await readWorkflowByPath(directPath); } catch { // Fall through to name lookup. } const workflows = await listWorkflows(root); const match = workflows.find((workflow) => workflow.id === workflowIdOrName || workflow.name === workflowIdOrName); if (!match) throw new Error(`Unknown workflow: ${workflowIdOrName}`); return readWorkflowByPath(workflowJsonPath(root, match.id)); } export async function readWorkflowByPath(path: string): Promise { const parsed = JSON.parse(await readFile(path, "utf8")) as SavedWorkflow; if (!parsed || parsed.schemaVersion !== 1 || !parsed.id || !Array.isArray(parsed.steps)) { throw new Error(`Invalid workflow file: ${path}`); } return parsed; } export async function writeWorkflow(root: string, workflow: SavedWorkflow): Promise<{ jsonPath: string; scriptPath: string; script: string }> { workflow.updatedAt = new Date().toISOString(); const jsonPath = workflowJsonPath(root, workflow.id); const scriptPath = workflowScriptPath(root, workflow.id); const script = generatePuppeteerScript(workflow); await mkdir(root, { recursive: true }); await writeFile(jsonPath, `${JSON.stringify(workflow, null, 2)}\n`, "utf8"); await writeFile(scriptPath, script, "utf8"); return { jsonPath, scriptPath, script }; } export async function renameWorkflow(root: string, workflowIdOrName: string, nextName: string): Promise { const workflow = await readWorkflow(root, workflowIdOrName); const oldId = workflow.id; workflow.name = nextName.trim() || workflow.name; workflow.id = createWorkflowId(workflow.name); workflow.updatedAt = new Date().toISOString(); await writeWorkflow(root, workflow); await rm(workflowJsonPath(root, oldId), { force: true }); await rm(workflowScriptPath(root, oldId), { force: true }); return workflow; } export async function deleteWorkflow(root: string, workflowIdOrName: string): Promise { const workflow = await readWorkflow(root, workflowIdOrName); await rm(workflowJsonPath(root, workflow.id), { force: true }); await rm(workflowScriptPath(root, workflow.id), { force: true }); return workflow; } export function summarizeWorkflow(workflow: SavedWorkflow): WorkflowSummary { return { id: workflow.id, name: workflow.name, createdAt: workflow.createdAt, updatedAt: workflow.updatedAt, stepCount: workflow.steps.length, startUrl: workflow.steps.find((step) => step.type === "navigate")?.url ?? null, }; } export function generatePuppeteerScript(workflow: SavedWorkflow): string { const lines = [ "import puppeteer from \"puppeteer-core\";", "", `// Workflow: ${escapeComment(workflow.name)}`, `// Generated by pi-puppeteer from ${workflow.id}.json`, "const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;", "if (!executablePath) console.warn(\"Set PUPPETEER_EXECUTABLE_PATH when running this with puppeteer-core.\");", "const browser = await puppeteer.launch({ headless: false, ...(executablePath ? { executablePath } : {}) });", "const page = await browser.newPage();", "", "async function firstSelector(selectors) {", "\tfor (const selector of selectors.flat()) {", "\t\tif (await page.$(selector)) return selector;", "\t}", "\tthrow new Error(`No selector matched: ${selectors.flat().join(\", \")}`);", "}", "", ]; for (const step of workflow.steps) { lines.push(...stepToPuppeteerLines(step), ""); } lines.push("// await browser.close();", ""); return `${lines.join("\n")}`; } export function generateBrowserToolCalls(workflow: SavedWorkflow): BrowserToolInput[] { return workflow.steps.map((step) => { switch (step.type) { case "navigate": return { action: "navigate", url: step.url }; case "click": return { action: "click", selector: firstStoredSelector(step) }; case "change": return { action: "type", selector: firstStoredSelector(step), text: step.value, replace: true }; case "keyDown": return { action: "press", key: step.key }; case "scroll": return { action: "scroll", scrollX: step.x, scrollY: step.y }; case "waitForElement": return { action: "wait_for", selector: firstStoredSelector(step) }; default: return { action: "inspect" }; } }); } export function generateBrowserToolScript(workflow: SavedWorkflow): string { return generateBrowserToolCalls(workflow).map((call) => JSON.stringify(call)).join("\n"); } function stepToPuppeteerLines(step: WorkflowStep): string[] { switch (step.type) { case "navigate": return [`await page.goto(${JSON.stringify(step.url)}, { waitUntil: "domcontentloaded" });`]; case "click": return [ `await page.click(await firstSelector(${JSON.stringify(step.selectors)}));`, ]; case "change": return [ `{`, `\tconst selector = await firstSelector(${JSON.stringify(step.selectors)});`, `\tawait page.click(selector, { clickCount: 3 });`, `\tawait page.keyboard.press("Backspace");`, `\tawait page.type(selector, ${JSON.stringify(step.value)});`, `}`, ]; case "keyDown": return [`await page.keyboard.press(${JSON.stringify(step.key)});`]; case "scroll": return [`await page.evaluate(({ x, y }) => window.scrollTo(x, y), ${JSON.stringify({ x: step.x, y: step.y })});`]; case "submit": return [ `{`, `\tconst selector = await firstSelector(${JSON.stringify(step.selectors)});`, `\tawait page.$eval(selector, (form) => (form instanceof HTMLFormElement ? form.requestSubmit() : form.closest("form")?.requestSubmit()));`, `}`, ]; case "waitForElement": return [`await page.waitForSelector(await firstSelector(${JSON.stringify(step.selectors)}));`]; } } function firstStoredSelector(step: Extract): string { return step.selectors.flat()[0] ?? "body"; } function escapeComment(value: string): string { return value.replace(/\*\//g, "*\\/").replace(/\r?\n/g, " "); } export async function findFirstSelector(page: Page, selectors: string[][], timeoutMs: number): Promise { const flattened = selectors.flat().filter(Boolean); let lastError: unknown; for (const selector of flattened) { try { await page.waitForSelector(selector, { timeout: Math.max(250, Math.min(timeoutMs, 1500)) }); return selector; } catch (error) { lastError = error; } } throw new Error(`No workflow selector matched (${flattened.join(", ")}): ${(lastError as Error | undefined)?.message ?? "not found"}`); } export function injectedWorkflowRecorder(functionName: string): void { const windowWithRecorder = window as unknown as Window & { __piPuppeteerWorkflowRecorderInstalled?: Record; __piPuppeteerWorkflowLastScroll?: number; [key: string]: unknown; }; windowWithRecorder.__piPuppeteerWorkflowRecorderInstalled ??= {}; if (windowWithRecorder.__piPuppeteerWorkflowRecorderInstalled[functionName]) return; windowWithRecorder.__piPuppeteerWorkflowRecorderInstalled[functionName] = true; let lastClickAt = 0; const send = (payload: Record) => { const callback = windowWithRecorder[functionName]; if (typeof callback !== "function") return; void (callback as (payload: Record) => Promise)({ ...payload, url: location.href, timestamp: Date.now(), }); }; const cssEscape = (value: string) => { const css = (window as Window & { CSS?: { escape?: (text: string) => string } }).CSS; return css?.escape ? css.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); }; const selectorFor = (target: EventTarget | null): string[][] => { if (!(target instanceof Element)) return [["body"]]; const element = target; const selectors: string[] = []; const testId = element.getAttribute("data-testid") ?? element.getAttribute("data-test") ?? element.getAttribute("data-cy"); if (testId) selectors.push(`[data-testid=\"${cssEscape(testId)}\"]`, `[data-test=\"${cssEscape(testId)}\"]`, `[data-cy=\"${cssEscape(testId)}\"]`); const aria = element.getAttribute("aria-label"); if (aria) selectors.push(`[aria-label=\"${cssEscape(aria)}\"]`); const name = element.getAttribute("name"); if (name) selectors.push(`${element.tagName.toLowerCase()}[name=\"${cssEscape(name)}\"]`); const id = element.id; if (id) selectors.push(`#${cssEscape(id)}`); const type = element.getAttribute("type"); if (type) selectors.push(`${element.tagName.toLowerCase()}[type=\"${cssEscape(type)}\"]`); selectors.push(nthSelector(element)); return selectors.map((selector) => [selector]); }; const nthSelector = (element: Element): string => { const parts: string[] = []; let current: Element | null = element; while (current && current !== document.documentElement) { const tag = current.tagName.toLowerCase(); const siblings = Array.from(current.parentElement?.children ?? []).filter((child) => child.tagName === current!.tagName); const nth = siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(current) + 1})` : ""; parts.unshift(`${tag}${nth}`); current = current.parentElement; } return parts.length ? parts.join(" > ") : "body"; }; const isRecordableValueTarget = (target: EventTarget | null): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement => { if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) return false; if (target instanceof HTMLInputElement && ["hidden", "button", "submit", "reset", "image", "file"].includes(target.type)) return false; return true; }; const redactValue = (target: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): string => { if (target instanceof HTMLInputElement && target.type === "password") return ""; return target.value; }; document.addEventListener("click", (event) => { if (!event.isTrusted) return; lastClickAt = Date.now(); send({ type: "click", selectors: selectorFor(event.target), button: event.button }); }, true); document.addEventListener("input", (event) => { if (!event.isTrusted || !isRecordableValueTarget(event.target)) return; send({ type: "change", selectors: selectorFor(event.target), value: redactValue(event.target) }); }, true); document.addEventListener("change", (event) => { if (!event.isTrusted || !isRecordableValueTarget(event.target)) return; send({ type: "change", selectors: selectorFor(event.target), value: redactValue(event.target) }); }, true); document.addEventListener("submit", (event) => { if (!event.isTrusted || Date.now() - lastClickAt < 500) return; send({ type: "submit", selectors: selectorFor(event.target) }); }, true); document.addEventListener("keydown", (event) => { if (!event.isTrusted || event.key.length === 1) return; send({ type: "keyDown", key: event.key }); }, true); window.addEventListener("scroll", () => { const now = Date.now(); if (now - (windowWithRecorder.__piPuppeteerWorkflowLastScroll ?? 0) < 300) return; windowWithRecorder.__piPuppeteerWorkflowLastScroll = now; send({ type: "scroll", x: window.scrollX, y: window.scrollY }); }, { capture: true, passive: true }); }