diff --git a/CHANGELOG.md b/CHANGELOG.md index ac83996..7d4dfb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,13 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht - Semver tag helper script (`scripts/release-version.sh`) with `patch`, `minor`, `major` bump modes. - Workflow file export schema metadata (`schema`, `version`) with backward-compatible import checks. - Workflow builder controls for undo/redo and explicit edge disconnect. +- Web recorder stop endpoint (`/api/recorders/web/stop`) and recorder navigation event capture. +- Recorder draft review panel with reorder/edit/skip controls before inserting recorded steps. ### Changed - CI now includes browser smoke validation (`Web E2E Smoke`). - Web editor keyboard shortcuts now include undo/redo and selection-aware delete behavior. +- Web recorder now follows capture -> review -> insert flow instead of immediate node injection. ## [1.0.7] - 2026-02-13 diff --git a/README.md b/README.md index 527beb5..2bda5de 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It combines a drag-and-drop workflow studio, resilient execution, AI-assisted au - Visual workflow builder (React Flow) - Core editor UX: undo/redo, duplicate, edge disconnect, auto-layout, and JSON import/export - Web automation (Playwright) and desktop automation (agent service) -- Recorder flows for web and desktop action capture +- Recorder flows for web and desktop action capture with review-before-insert draft editing - Autopilot workflow generation from natural-language prompts - AI nodes: `transform_llm`, `document_understanding`, `clipboard_ai_transfer` - Integrations (`http_api`, `postgresql`, `mysql`, `mongodb`, `google_sheets`, `airtable`, `s3`) @@ -53,6 +53,7 @@ Use these guided demos to evaluate the platform quickly: - `docs/DEMOS.md#demo-2-orchestrator-unattended-queue` - `docs/DEMOS.md#demo-3-document-understanding-and-clipboard-ai` - `docs/DEMOS.md#demo-4-workflow-builder-mvp-controls` +- `docs/DEMOS.md#demo-5-recorder-draft-review-and-insert` ## Contributor Onboarding New contributors should start here: diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6c30cdc..e2a50c1 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -8,7 +8,7 @@ import { WebSocketServer } from "ws"; import { PrismaClient } from "@prisma/client"; import { z } from "zod"; import { authMiddleware, getAuthContext, requirePermission, signToken, verifyLogin } from "./lib/auth.js"; -import { startWebRecorder, attachRecorderWs } from "./lib/recorder.js"; +import { startWebRecorder, stopWebRecorder, attachRecorderWs } from "./lib/recorder.js"; import { getActiveRunCount, startRun, waitForActiveRuns } from "./lib/runner.js"; import { startDesktopRecording, stopDesktopRecording } from "./lib/agent.js"; import { @@ -2176,6 +2176,21 @@ app.post("/api/recorders/web/start", canWriteWorkflows, async (req, res) => { res.json(session); }); +app.post("/api/recorders/web/stop", canWriteWorkflows, async (req, res) => { + const schema = z.object({ sessionId: z.string().min(1) }); + const parsed = schema.safeParse(req.body || {}); + if (!parsed.success) { + res.status(400).json({ error: "Invalid payload" }); + return; + } + const result = await stopWebRecorder(parsed.data.sessionId); + if (!result) { + res.status(404).json({ error: "Recorder session not found" }); + return; + } + res.json(result); +}); + app.post("/api/recorders/desktop/start", canWriteWorkflows, async (req, res) => { const schema = z.object({ label: z.string().optional() }); const parsed = schema.safeParse(req.body || {}); diff --git a/apps/server/src/lib/recorder.test.ts b/apps/server/src/lib/recorder.test.ts index d201bf2..54b2beb 100644 --- a/apps/server/src/lib/recorder.test.ts +++ b/apps/server/src/lib/recorder.test.ts @@ -5,13 +5,23 @@ import { attachRecorderWs, startWebRecorder, stopWebRecorder } from "./recorder. class FakePage { binding: ((source: unknown, payload: any) => void) | null = null; gotoUrl: string | null = null; + listeners = new Map void>>(); async addInitScript(_fn: unknown) {} async exposeBinding(_name: string, fn: (source: unknown, payload: any) => void) { this.binding = fn; } + on(event: string, handler: (payload: any) => void) { + const list = this.listeners.get(event) || []; + list.push(handler); + this.listeners.set(event, list); + } async goto(url: string) { this.gotoUrl = url; } + emit(event: string, payload: any) { + const list = this.listeners.get(event) || []; + list.forEach((handler) => handler(payload)); + } } class FakeContext { @@ -69,12 +79,21 @@ test("web recorder broadcasts captured events to websocket clients", async () => assert.equal(JSON.parse(sent[0]).type, "recorder:ready"); browser.context.page.binding?.({}, { type: "click", selector: "#submit" }); + browser.context.page.emit("framenavigated", { + parentFrame: () => null, + url: () => "https://example.com/next" + }); assert.equal(sent.length >= 2, true); assert.equal(JSON.parse(sent[1]).type, "recorder:event"); + assert.equal(sent.length >= 3, true); + const navEvent = JSON.parse(sent[2]); + assert.equal(navEvent.type, "recorder:event"); + assert.equal(navEvent.payload.type, "navigate"); const stopped = await stopWebRecorder("session-1"); - assert.equal(stopped?.events?.length, 1); + assert.equal(stopped?.events?.length, 2); assert.equal((stopped?.events?.[0] as any).selector, "#submit"); + assert.equal((stopped?.events?.[1] as any).type, "navigate"); assert.equal(browser.closed, true); }); diff --git a/apps/server/src/lib/recorder.ts b/apps/server/src/lib/recorder.ts index 951775a..44b2efd 100644 --- a/apps/server/src/lib/recorder.ts +++ b/apps/server/src/lib/recorder.ts @@ -24,6 +24,15 @@ type RecorderDeps = { const sessions = new Map(); const sessionsByPage = new Map(); +function emitSessionEvent(session: RecorderSession, payload: Record) { + session.events.push(payload); + session.clients.forEach((client) => { + if (client.readyState === 1) { + client.send(JSON.stringify({ type: "recorder:event", payload })); + } + }); +} + export async function startWebRecorder( { startUrl }: { startUrl?: string }, deps: RecorderDeps = {} @@ -39,80 +48,6 @@ export async function startWebRecorder( }); const context = await browser.newContext(); const page = await context.newPage(); - - await page.addInitScript(() => { - const buildSelector = (el: Element | null): string => { - if (!el) return ""; - if ((el as HTMLElement).id) return `#${(el as HTMLElement).id}`; - const parts: string[] = []; - let current: Element | null = el; - while (current && current.nodeType === 1 && parts.length < 5) { - const tag = current.tagName.toLowerCase(); - const className = (current as HTMLElement).className - .split(" ") - .filter(Boolean) - .slice(0, 2) - .join("."); - const index = current.parentElement - ? Array.from(current.parentElement.children).indexOf(current) + 1 - : 1; - const selector = className ? `${tag}.${className}:nth-child(${index})` : `${tag}:nth-child(${index})`; - parts.unshift(selector); - current = current.parentElement; - } - return parts.join(" > "); - }; - - const send = (payload: any) => { - // @ts-expect-error injected binding - window.rpaRecordEvent(payload); - }; - - document.addEventListener("click", (event) => { - const target = event.target as Element; - send({ - type: "click", - selector: buildSelector(target), - text: (target as HTMLElement)?.innerText?.slice(0, 120) - }); - }, true); - - document.addEventListener("input", (event) => { - const target = event.target as HTMLInputElement; - if (!target) return; - send({ - type: "fill", - selector: buildSelector(target), - value: target.value - }); - }, true); - - document.addEventListener("change", (event) => { - const target = event.target as HTMLInputElement; - if (!target) return; - send({ - type: "change", - selector: buildSelector(target), - value: target.value - }); - }, true); - }); - - await page.exposeBinding("rpaRecordEvent", (_source, payload) => { - const session = sessionsByPage.get(page); - if (!session) return; - session.events.push(payload); - session.clients.forEach((client) => { - if (client.readyState === 1) { - client.send(JSON.stringify({ type: "recorder:event", payload })); - } - }); - }); - - if (startUrl) { - await page.goto(startUrl); - } - const id = makeId(); const session: RecorderSession = { id, @@ -124,7 +59,103 @@ export async function startWebRecorder( sessions.set(id, session); sessionsByPage.set(page, session); - return { sessionId: id, wsUrl: `/ws?type=recorder&sessionId=${id}` }; + try { + await page.addInitScript(() => { + const buildSelector = (el: Element | null): string => { + if (!el) return ""; + if ((el as HTMLElement).id) return `#${(el as HTMLElement).id}`; + const parts: string[] = []; + let current: Element | null = el; + while (current && current.nodeType === 1 && parts.length < 5) { + const tag = current.tagName.toLowerCase(); + const className = (current as HTMLElement).className + .split(" ") + .filter(Boolean) + .slice(0, 2) + .join("."); + const index = current.parentElement + ? Array.from(current.parentElement.children).indexOf(current) + 1 + : 1; + const selector = className ? `${tag}.${className}:nth-child(${index})` : `${tag}:nth-child(${index})`; + parts.unshift(selector); + current = current.parentElement; + } + return parts.join(" > "); + }; + + const send = (payload: any) => { + // @ts-expect-error injected binding + window.rpaRecordEvent(payload); + }; + + document.addEventListener( + "click", + (event) => { + const target = event.target as Element; + send({ + type: "click", + selector: buildSelector(target), + text: (target as HTMLElement)?.innerText?.slice(0, 120) + }); + }, + true + ); + + document.addEventListener( + "input", + (event) => { + const target = event.target as HTMLInputElement; + if (!target) return; + send({ + type: "fill", + selector: buildSelector(target), + value: target.value + }); + }, + true + ); + + document.addEventListener( + "change", + (event) => { + const target = event.target as HTMLInputElement; + if (!target) return; + send({ + type: "change", + selector: buildSelector(target), + value: target.value + }); + }, + true + ); + }); + + await page.exposeBinding("rpaRecordEvent", (_source, payload) => { + const currentSession = sessionsByPage.get(page); + if (!currentSession) return; + emitSessionEvent(currentSession, payload); + }); + + page.on("framenavigated", (frame: any) => { + const currentSession = sessionsByPage.get(page); + if (!currentSession) return; + if (typeof frame?.parentFrame === "function" && frame.parentFrame()) return; + const url = typeof frame?.url === "function" ? String(frame.url() || "") : ""; + if (!url) return; + emitSessionEvent(currentSession, { type: "navigate", url }); + }); + + if (startUrl) { + await page.goto(startUrl); + } + + return { sessionId: id, wsUrl: `/ws?type=recorder&sessionId=${id}` }; + } catch (error) { + sessions.delete(id); + sessionsByPage.delete(page); + await browser.close(); + throw error; + } } export function attachRecorderWs(wss: WebSocketServer) { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f0c093e..cace68f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -74,6 +74,7 @@ import { previewSchedule as fetchSchedulePreview, startDesktopRecorder, startRecorder, + stopRecorder, startRun, stopDesktopRecorder, syncOrchestratorJob, @@ -162,6 +163,19 @@ type RunArtifact = { error?: string; }; +type RecorderPayloadType = "navigate" | "click" | "fill" | "change"; +type RecorderDraftNodeType = "playwright_navigate" | "playwright_click" | "playwright_fill" | "skip"; +type RecorderDraftEvent = { + id: string; + capturedAt: string; + type: RecorderPayloadType; + nodeType: RecorderDraftNodeType; + url: string; + selector: string; + value: string; + text: string; +}; + type WorkflowPresence = { clientId: string; workflowId: string; @@ -309,6 +323,27 @@ function formatBytes(value: number | null | undefined) { return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } +function mapRecorderPayloadToDraftEvent(payload: Record): RecorderDraftEvent | null { + const type = String(payload?.type || "").trim() as RecorderPayloadType; + if (!type || !["navigate", "click", "fill", "change"].includes(type)) { + return null; + } + + const nodeType: RecorderDraftNodeType = + type === "navigate" ? "playwright_navigate" : type === "click" ? "playwright_click" : "playwright_fill"; + const now = new Date().toISOString(); + return { + id: `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + capturedAt: now, + type, + nodeType, + url: String(payload?.url || ""), + selector: String(payload?.selector || ""), + value: String(payload?.value || ""), + text: String(payload?.text || "") + }; +} + export default function App() { const [token, setToken] = useState(localStorage.getItem("token")); const [loginTotpCode, setLoginTotpCode] = useState(""); @@ -320,6 +355,11 @@ export default function App() { const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [status, setStatus] = useState(""); const [desktopRecording, setDesktopRecording] = useState(false); + const [webRecording, setWebRecording] = useState(false); + const [webRecorderReady, setWebRecorderReady] = useState(false); + const [webRecorderSessionId, setWebRecorderSessionId] = useState(null); + const [webRecorderStartUrl, setWebRecorderStartUrl] = useState("https://example.com"); + const [webRecorderDraftEvents, setWebRecorderDraftEvents] = useState([]); const [runs, setRuns] = useState([]); const [activeRun, setActiveRun] = useState(null); const [runDiff, setRunDiff] = useState(null); @@ -414,6 +454,8 @@ export default function App() { const editorHistoryRef = useRef([]); const editorHistoryIndexRef = useRef(-1); const historyHydratingRef = useRef(false); + const webRecorderSocketRef = useRef(null); + const webRecorderSessionIdRef = useRef(webRecorderSessionId); useEffect(() => { nodesRef.current = nodes; @@ -423,6 +465,20 @@ export default function App() { edgesRef.current = edges; }, [edges]); + useEffect(() => { + webRecorderSessionIdRef.current = webRecorderSessionId; + }, [webRecorderSessionId]); + + useEffect(() => { + return () => { + const socket = webRecorderSocketRef.current; + if (socket && socket.readyState === WebSocket.OPEN) { + socket.close(); + } + webRecorderSocketRef.current = null; + }; + }, []); + const resetEditorHistory = (snapshotNodes: Node[], snapshotEdges: Edge[]) => { const nodesClone = cloneNodes(snapshotNodes); const edgesClone = cloneEdges(snapshotEdges); @@ -1224,6 +1280,11 @@ export default function App() { }; const handleLogout = () => { + const activeWebRecorderSession = webRecorderSessionIdRef.current; + if (activeWebRecorderSession) { + void stopRecorder(activeWebRecorderSession).catch(() => undefined); + } + closeWebRecorderSocket(); clearToken(); setToken(null); setActiveWorkflow(null); @@ -1284,6 +1345,11 @@ export default function App() { setIsDirty(false); setLastAutoSaveAt(null); setStatus(""); + setWebRecording(false); + setWebRecorderReady(false); + setWebRecorderSessionId(null); + setWebRecorderDraftEvents([]); + setWebRecorderStartUrl("https://example.com"); }; const handleSave = async (options?: { silent?: boolean; autosave?: boolean }) => { @@ -1676,57 +1742,245 @@ export default function App() { return validationErrors; }; + const closeWebRecorderSocket = () => { + const existing = webRecorderSocketRef.current; + if (!existing) return; + try { + existing.close(); + } catch { + // ignore close errors + } + webRecorderSocketRef.current = null; + }; + + const appendWebRecorderDraftEvent = (event: RecorderDraftEvent) => { + setWebRecorderDraftEvents((current) => { + if (event.nodeType === "playwright_fill" && current.length) { + const last = current[current.length - 1]; + if (last.nodeType === "playwright_fill" && last.selector === event.selector) { + const next = [...current]; + next[next.length - 1] = { + ...last, + value: event.value, + capturedAt: event.capturedAt + }; + return next; + } + } + return [...current, event].slice(-160); + }); + }; + const handleStartRecorder = async () => { - setFeedback("Starting recorder...", "info"); - const session = await startRecorder(); + if (webRecording) return; + const rawStartUrl = webRecorderStartUrl.trim(); + let startUrl: string | undefined; + if (rawStartUrl) { + try { + startUrl = new URL(rawStartUrl).toString(); + } catch { + throw new Error("Recorder start URL must be a valid absolute URL."); + } + } + + setFeedback("Starting web recorder...", "info"); + const session = await startRecorder(startUrl); + setWebRecorderSessionId(session.sessionId); + setWebRecorderReady(false); + setWebRecording(true); + const ws = new WebSocket(`${API_URL.replace("http", "ws")}${session.wsUrl}`); - let isReady = false; - let capturedEvents = 0; + webRecorderSocketRef.current = ws; + const readyTimeout = window.setTimeout(() => { - if (isReady) return; + if (webRecorderSocketRef.current !== ws) return; setFeedback("Recorder did not connect in time. Check browser/Playwright availability.", "error"); - ws.close(); - }, 6000); + closeWebRecorderSocket(); + setWebRecording(false); + setWebRecorderReady(false); + setWebRecorderSessionId(null); + }, 7000); ws.onmessage = (event) => { - const message = JSON.parse(event.data); + const message = JSON.parse(String(event.data || "{}")); if (message.type === "recorder:ready") { - isReady = true; window.clearTimeout(readyTimeout); - setFeedback("Recorder connected. Perform actions in the recording browser.", "success"); + setWebRecorderReady(true); + setFeedback("Web recorder connected. Perform actions in the recorder browser.", "success"); return; } if (message.type === "recorder:event") { - capturedEvents += 1; - const payload = message.payload; - if (payload.type === "click") { - handleAddNode("playwright_click", { selector: payload.selector, retryCount: 2, timeoutMs: 15000 }); - } - if (payload.type === "fill") { - handleAddNode("playwright_fill", { - selector: payload.selector, - value: payload.value, - retryCount: 2, - timeoutMs: 15000 - }); - } + const mapped = mapRecorderPayloadToDraftEvent( + message?.payload && typeof message.payload === "object" ? (message.payload as Record) : {} + ); + if (!mapped) return; + appendWebRecorderDraftEvent(mapped); } }; - ws.onopen = () => setFeedback("Recorder socket opened. Waiting for recorder ready signal...", "info"); + ws.onopen = () => { + setFeedback("Recorder socket opened. Waiting for recorder ready signal...", "info"); + }; + ws.onerror = () => { window.clearTimeout(readyTimeout); - setFeedback("Recorder error. Unable to stream events.", "error"); + setFeedback("Recorder socket error. Stop and restart capture.", "error"); }; + ws.onclose = () => { window.clearTimeout(readyTimeout); - if (!isReady) return; - if (!capturedEvents) { - setFeedback("Recorder session ended with no captured actions.", "info"); + if (webRecorderSocketRef.current === ws) { + webRecorderSocketRef.current = null; } + setWebRecorderReady(false); + }; + }; + + const mergeRecordedSessionEvents = (events: Array>) => { + if (!events.length) return; + if (webRecorderDraftEvents.length) return; + const mapped = events + .map((payload) => mapRecorderPayloadToDraftEvent(payload)) + .filter((entry): entry is RecorderDraftEvent => Boolean(entry)); + if (!mapped.length) return; + setWebRecorderDraftEvents(mapped.slice(-160)); + }; + + const handleStopRecorder = async () => { + const sessionId = webRecorderSessionIdRef.current; + closeWebRecorderSocket(); + setWebRecorderReady(false); + setWebRecording(false); + setWebRecorderSessionId(null); + if (!sessionId) { + setFeedback("Web recorder stopped.", "info"); + return; + } + const result = await stopRecorder(sessionId); + const events = Array.isArray(result?.events) ? (result.events as Array>) : []; + mergeRecordedSessionEvents(events); + setFeedback(`Recorder stopped. Captured ${events.length} action(s). Review and insert when ready.`, "success"); + }; + + const handleToggleRecorder = async () => { + if (webRecording) { + await handleStopRecorder(); + return; + } + await handleStartRecorder(); + }; + + const handleRemoveRecordedEvent = (id: string) => { + setWebRecorderDraftEvents((current) => current.filter((entry) => entry.id !== id)); + }; + + const handleMoveRecordedEvent = (id: string, direction: -1 | 1) => { + setWebRecorderDraftEvents((current) => { + const index = current.findIndex((entry) => entry.id === id); + if (index < 0) return current; + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= current.length) return current; + const next = [...current]; + const [item] = next.splice(index, 1); + next.splice(nextIndex, 0, item); + return next; + }); + }; + + const handleUpdateRecordedEvent = (id: string, patch: Partial) => { + setWebRecorderDraftEvents((current) => + current.map((entry) => (entry.id === id ? { ...entry, ...patch, id: entry.id } : entry)) + ); + }; + + const buildRecordedNodeData = (event: RecorderDraftEvent, index: number): Record => { + if (event.nodeType === "playwright_navigate") { + return { + label: index === 0 ? "Navigate" : `Navigate ${index + 1}`, + type: "playwright_navigate", + url: event.url || webRecorderStartUrl || "https://example.com", + waitUntil: "domcontentloaded", + retryCount: 1, + timeoutMs: 30000 + }; + } + if (event.nodeType === "playwright_click") { + return { + label: index === 0 ? "Click" : `Click ${index + 1}`, + type: "playwright_click", + selector: event.selector, + retryCount: 2, + timeoutMs: 15000 + }; + } + return { + label: index === 0 ? "Fill" : `Fill ${index + 1}`, + type: "playwright_fill", + selector: event.selector, + value: event.value, + retryCount: 2, + timeoutMs: 15000 }; }; + const handleInsertRecordedSequence = () => { + const actionable = webRecorderDraftEvents.filter((entry) => entry.nodeType !== "skip"); + if (!actionable.length) { + setFeedback("Recorder draft is empty. Capture actions first.", "error"); + return; + } + + const existingNodes = nodesRef.current; + const anchorNode = selectedNode || existingNodes[existingNodes.length - 1] || null; + const basePosition = anchorNode + ? { + x: (anchorNode.position?.x || 80) + 250, + y: anchorNode.position?.y || 80 + } + : findNextNodePosition(existingNodes); + + const timestamp = Date.now(); + const newNodes: Node[] = actionable.map((event, index) => ({ + id: `${event.nodeType}-rec-${timestamp}-${index}`, + type: "action", + position: { + x: basePosition.x + index * 250, + y: basePosition.y + }, + data: buildRecordedNodeData(event, index) + })); + + if (!newNodes.length) { + setFeedback("No actionable recorder events to insert.", "error"); + return; + } + + const newEdges: Edge[] = []; + if (anchorNode) { + newEdges.push({ + id: `e-${anchorNode.id}-${newNodes[0].id}`, + source: anchorNode.id, + target: newNodes[0].id + }); + } + for (let index = 1; index < newNodes.length; index += 1) { + const prev = newNodes[index - 1]; + const current = newNodes[index]; + newEdges.push({ + id: `e-${prev.id}-${current.id}`, + source: prev.id, + target: current.id + }); + } + + setNodes((current) => [...current, ...newNodes]); + setEdges((current) => [...current, ...newEdges]); + setSelectedNode(newNodes[newNodes.length - 1]); + setSelectedEdgeId(null); + setWebRecorderDraftEvents([]); + setFeedback(`Inserted ${newNodes.length} recorded step(s) into the workflow.`, "success"); + }; + const handleDesktopRecordStart = async () => { setFeedback("Starting desktop recorder...", "info"); await startDesktopRecorder("session"); @@ -3073,7 +3327,8 @@ export default function App() { onQuickAddFirstNode={handleQuickAddFirstNode} onQuickAddNode={handleQuickAddNode} isActionLoading={isActionLoading} - onRecordWeb={() => withActionLoading("record-web", handleStartRecorder)} + onRecordWeb={() => withActionLoading("record-web", handleToggleRecorder)} + webRecording={webRecording} onRecordDesktop={() => withActionLoading("record-desktop", () => (desktopRecording ? handleDesktopRecordStop() : handleDesktopRecordStart())) } @@ -3097,6 +3352,106 @@ export default function App() { lastAutoSaveAt={lastAutoSaveAt} /> + {webRecording || webRecorderDraftEvents.length ? ( +
+
+ Web Recorder Draft + + {webRecording + ? webRecorderReady + ? `Recording live (${webRecorderDraftEvents.length} events captured)` + : "Connecting recorder..." + : `${webRecorderDraftEvents.length} captured events ready for review`} + +
+
+ setWebRecorderStartUrl(event.target.value)} + placeholder="Recorder start URL (optional)" + /> + + + +
+
+ {webRecorderDraftEvents.map((event, index) => ( +
+
+ #{index + 1} + {event.type} + {new Date(event.capturedAt).toLocaleTimeString()} +
+ + {event.nodeType === "playwright_navigate" ? ( + handleUpdateRecordedEvent(event.id, { url: valueEvent.target.value })} + placeholder="https://example.com/page" + /> + ) : null} + {event.nodeType === "playwright_click" || event.nodeType === "playwright_fill" ? ( + handleUpdateRecordedEvent(event.id, { selector: valueEvent.target.value })} + placeholder="CSS selector" + /> + ) : null} + {event.nodeType === "playwright_fill" ? ( + handleUpdateRecordedEvent(event.id, { value: valueEvent.target.value })} + placeholder="Input value" + /> + ) : null} +
+ + + +
+
+ ))} + {!webRecorderDraftEvents.length ? No recorded events yet. Start capture to build a draft. : null} +
+
+ ) : null} +
Local Time diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 81fde38..9c6e11c 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -582,6 +582,13 @@ export function startRecorder(startUrl?: string) { }); } +export function stopRecorder(sessionId: string) { + return request<{ sessionId: string; events: Array> }>("/api/recorders/web/stop", { + method: "POST", + body: JSON.stringify({ sessionId }) + }); +} + export function startDesktopRecorder(label?: string) { return request("/api/recorders/desktop/start", { method: "POST", diff --git a/apps/web/src/components/CanvasToolbar.test.tsx b/apps/web/src/components/CanvasToolbar.test.tsx index f787a55..846e2be 100644 --- a/apps/web/src/components/CanvasToolbar.test.tsx +++ b/apps/web/src/components/CanvasToolbar.test.tsx @@ -14,6 +14,7 @@ test("quick-add input Enter triggers add-first action", () => { onQuickAddNode={() => undefined} isActionLoading={() => false} onRecordWeb={() => undefined} + webRecording={false} onRecordDesktop={() => undefined} desktopRecording={false} onAutoLayout={() => undefined} @@ -50,6 +51,7 @@ test("toolbar shows dirty draft status", () => { onQuickAddNode={() => undefined} isActionLoading={() => false} onRecordWeb={() => undefined} + webRecording={false} onRecordDesktop={() => undefined} desktopRecording={false} onAutoLayout={() => undefined} @@ -87,6 +89,7 @@ test("toolbar triggers undo and redo actions", () => { onQuickAddNode={() => undefined} isActionLoading={() => false} onRecordWeb={() => undefined} + webRecording={false} onRecordDesktop={() => undefined} desktopRecording={false} onAutoLayout={() => undefined} @@ -113,3 +116,73 @@ test("toolbar triggers undo and redo actions", () => { expect(onUndo).toHaveBeenCalledTimes(1); expect(onRedo).toHaveBeenCalledTimes(1); }); + +test("record web button label reflects recording state", () => { + const { rerender } = render( + undefined} + quickAddInputRef={createRef()} + filteredNodeOptions={[]} + onQuickAddFirstNode={() => undefined} + onQuickAddNode={() => undefined} + isActionLoading={() => false} + onRecordWeb={() => undefined} + webRecording={false} + onRecordDesktop={() => undefined} + desktopRecording={false} + onAutoLayout={() => undefined} + onUndo={() => undefined} + onRedo={() => undefined} + canUndo={false} + canRedo={false} + onDuplicateSelectedNode={() => undefined} + canDuplicateSelectedNode={false} + onDisconnectSelectedEdge={() => undefined} + canDisconnectSelectedEdge={false} + snapToGrid={true} + onToggleSnap={() => undefined} + onSaveDraft={() => undefined} + onPublish={() => undefined} + onTestRun={() => undefined} + onRun={() => undefined} + isDirty={false} + lastAutoSaveAt={null} + /> + ); + expect(screen.getByRole("button", { name: "Record Web" })).toBeInTheDocument(); + + rerender( + undefined} + quickAddInputRef={createRef()} + filteredNodeOptions={[]} + onQuickAddFirstNode={() => undefined} + onQuickAddNode={() => undefined} + isActionLoading={() => false} + onRecordWeb={() => undefined} + webRecording={true} + onRecordDesktop={() => undefined} + desktopRecording={false} + onAutoLayout={() => undefined} + onUndo={() => undefined} + onRedo={() => undefined} + canUndo={false} + canRedo={false} + onDuplicateSelectedNode={() => undefined} + canDuplicateSelectedNode={false} + onDisconnectSelectedEdge={() => undefined} + canDisconnectSelectedEdge={false} + snapToGrid={true} + onToggleSnap={() => undefined} + onSaveDraft={() => undefined} + onPublish={() => undefined} + onTestRun={() => undefined} + onRun={() => undefined} + isDirty={false} + lastAutoSaveAt={null} + /> + ); + expect(screen.getByRole("button", { name: "Stop Web" })).toBeInTheDocument(); +}); diff --git a/apps/web/src/components/CanvasToolbar.tsx b/apps/web/src/components/CanvasToolbar.tsx index 78b9101..1d715c8 100644 --- a/apps/web/src/components/CanvasToolbar.tsx +++ b/apps/web/src/components/CanvasToolbar.tsx @@ -10,6 +10,7 @@ type CanvasToolbarProps = { onQuickAddNode: (type: string) => void; isActionLoading: (actionId: string) => boolean; onRecordWeb: () => void; + webRecording: boolean; onRecordDesktop: () => void; desktopRecording: boolean; onAutoLayout: () => void; @@ -40,6 +41,7 @@ export function CanvasToolbar({ onQuickAddNode, isActionLoading, onRecordWeb, + webRecording, onRecordDesktop, desktopRecording, onAutoLayout, @@ -114,7 +116,7 @@ export function CanvasToolbar({ className={isActionLoading("record-web") ? "is-loading" : ""} onClick={onRecordWeb} > - {isActionLoading("record-web") ? "Starting..." : "Record Web"} + {isActionLoading("record-web") ? "Working..." : webRecording ? "Stop Web" : "Record Web"}