diff --git a/public/js/staktrak.js b/public/js/staktrak.js index ad8932e8c4..20295077b8 100644 --- a/public/js/staktrak.js +++ b/public/js/staktrak.js @@ -3573,8 +3573,10 @@ var userBehaviour = (() => { }, getParentOrigin() ); + return id; } catch (error) { console.error(`[Screenshot] Error capturing for actionIndex=${actionIndex}:`, error); + return null; } } function beginReplay(actions, testCode) { @@ -3649,20 +3651,26 @@ var userBehaviour = (() => { executeNextPlaywrightAction(); }, 500); } catch (error) { - state.errors.push( - `Action ${state.currentActionIndex + 1}: ${error instanceof Error ? error.message : "Unknown error"}` + const message = error instanceof Error ? error.message : "Unknown error"; + state.errors.push(`Action ${state.currentActionIndex + 1}: ${message}`); + state.status = "error" /* ERROR */; + state.timeouts.forEach((id) => clearTimeout(id)); + state.timeouts = []; + const screenshotId = await captureScreenshot( + state.currentActionIndex, + window.location.href ); - state.currentActionIndex++; window.parent.postMessage( { type: "staktrak-playwright-replay-error", - error: error instanceof Error ? error.message : "Unknown error", - actionIndex: state.currentActionIndex - 1, - action + error: message, + actionIndex: state.currentActionIndex, + action, + fatal: true, + screenshotId }, getParentOrigin() ); - executeNextPlaywrightAction(); } } function pausePlaywrightReplay() { diff --git a/src/__tests__/unit/hooks/useStaktrakReplay.test.ts b/src/__tests__/unit/hooks/useStaktrakReplay.test.ts index 56fbd88d10..2e307c344b 100644 --- a/src/__tests__/unit/hooks/useStaktrakReplay.test.ts +++ b/src/__tests__/unit/hooks/useStaktrakReplay.test.ts @@ -621,7 +621,7 @@ describe('usePlaywrightReplay', () => { }); describe('staktrak-playwright-replay-error', () => { - test('should accumulate errors without stopping replay', async () => { + test('should accumulate non-fatal errors without stopping replay', async () => { const { result } = renderHook(() => usePlaywrightReplay(mockIframeRef)); act(() => { @@ -644,6 +644,39 @@ describe('usePlaywrightReplay', () => { }); }); + test('should halt replay on a fatal error (issue #756)', async () => { + const { result } = renderHook(() => usePlaywrightReplay(mockIframeRef)); + + // Put the hook into a playing state first. + act(() => { + TestUtils.simulateMessageEvent({ + type: 'staktrak-playwright-replay-progress', + current: 2, + total: 5, + action: 'click button', + }); + }); + + act(() => { + TestUtils.simulateMessageEvent({ + type: 'staktrak-playwright-replay-error', + error: 'Element not found', + actionIndex: 2, + action: 'click button', + fatal: true, + screenshotId: 'shot-123', + }); + }); + + await waitFor(() => { + expect(result.current.replayErrors).toHaveLength(1); + expect(result.current.isPlaywrightReplaying).toBe(false); + expect(result.current.isPlaywrightPaused).toBe(false); + expect(result.current.playwrightStatus).toBe('error'); + expect(result.current.currentAction).toBeNull(); + }); + }); + test('should log error to console', async () => { renderHook(() => usePlaywrightReplay(mockIframeRef)); diff --git a/src/app/w/[slug]/task/[...taskParams]/artifacts/browser.tsx b/src/app/w/[slug]/task/[...taskParams]/artifacts/browser.tsx index 598e45aae4..46c01e99b6 100644 --- a/src/app/w/[slug]/task/[...taskParams]/artifacts/browser.tsx +++ b/src/app/w/[slug]/task/[...taskParams]/artifacts/browser.tsx @@ -120,10 +120,29 @@ export function BrowserArtifactPanel({ stopPlaywrightReplay, replayScreenshots, replayActions, + replayErrors = [], } = usePlaywrightReplay(iframeRef, workspaceId, taskId, featureId, (message) => { showActionToast("Screenshot Error", message); }); + // Surface replay failures: when a step errors the library halts the replay + // (issue #756), so let the user know which step broke instead of failing + // silently in the console. + const lastNotifiedErrorRef = useRef(null); + useEffect(() => { + if (replayErrors.length === 0) { + lastNotifiedErrorRef.current = null; + return; + } + const latest = replayErrors[replayErrors.length - 1]; + if (lastNotifiedErrorRef.current === latest.timestamp) return; + lastNotifiedErrorRef.current = latest.timestamp; + showActionToast( + `Replay stopped at action ${latest.actionIndex + 1}`, + latest.message, + ); + }, [replayErrors, showActionToast]); + // Auto-show actions list when replay starts useEffect(() => { if (isPlaywrightReplaying && !showActions && (replayActions.length > 0 || capturedActions.length > 0)) { @@ -452,6 +471,11 @@ export function BrowserArtifactPanel({ currentActionIndex={playwrightProgress.current - 1} totalActions={playwrightProgress.total} screenshots={replayScreenshots} + failedActionIndex={ + replayErrors.length > 0 + ? replayErrors[replayErrors.length - 1].actionIndex + : -1 + } onReplayToggle={ capturedActions.length > 0 || isPlaywrightReplaying ? handleReplayToggle : undefined } diff --git a/src/components/ActionsList.tsx b/src/components/ActionsList.tsx index d1c1e14304..8d850865a9 100644 --- a/src/components/ActionsList.tsx +++ b/src/components/ActionsList.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button"; -import { X, CheckCircle2, Loader2, Camera, Play, Square } from "lucide-react"; +import { X, CheckCircle2, Loader2, Camera, Play, Square, XCircle } from "lucide-react"; import { useRef, useEffect, useState } from "react"; import { Screenshot } from "@/types/common"; import { ScreenshotModal } from "@/components/ScreenshotModal"; @@ -31,6 +31,8 @@ interface ActionsListProps { screenshots?: Screenshot[]; title?: string; onReplayToggle?: () => void; + // Index of the action the replay failed on (issue #756). -1 / undefined = none. + failedActionIndex?: number; } // Helper function to extract the most descriptive element identifier @@ -181,6 +183,7 @@ export function ActionsList({ screenshots = [], title, onReplayToggle, + failedActionIndex = -1, }: ActionsListProps) { const actionRefs = useRef<(HTMLDivElement | null)[]>([]); const scrollContainerRef = useRef(null); @@ -203,7 +206,12 @@ export function ActionsList({ }, [isReplaying, currentActionIndex]); // Get action status based on replay progress - const getActionStatus = (index: number): "pending" | "active" | "completed" => { + const getActionStatus = ( + index: number, + ): "pending" | "active" | "completed" | "error" => { + // The failed step is marked even after replay stops, so this check comes + // first and is independent of isReplaying (issue #756). + if (failedActionIndex >= 0 && index === failedActionIndex) return "error"; if (!isReplaying) return "pending"; if (index < currentActionIndex) return "completed"; if (index === currentActionIndex) return "active"; @@ -211,12 +219,16 @@ export function ActionsList({ }; // Get status icon for action - const getStatusIcon = (status: "pending" | "active" | "completed") => { + const getStatusIcon = ( + status: "pending" | "active" | "completed" | "error", + ) => { switch (status) { case "completed": return ; case "active": return ; + case "error": + return ; default: return null; } @@ -281,6 +293,7 @@ export function ActionsList({ const status = getActionStatus(index); const isActive = status === "active"; const isCompleted = status === "completed"; + const isError = status === "error"; const screenshot = getScreenshotForAction(index); const actionType = action.type; // Only waitForURL actions get screenshots (goto is skipped) @@ -296,16 +309,18 @@ export function ActionsList({ className={`flex items-center gap-2 rounded border-l-4 ${getActionBorderColor( actionType, )} p-1.5 transition-all duration-200 ${ - isActive - ? "bg-blue-100 dark:bg-blue-900/30 shadow-md ring-2 ring-blue-400 dark:ring-blue-600" - : isCompleted - ? "bg-green-50 dark:bg-green-900/20 opacity-70" - : "bg-muted/50 hover:bg-muted" + isError + ? "bg-red-100 dark:bg-red-900/30 shadow-md ring-2 ring-red-400 dark:ring-red-600" + : isActive + ? "bg-blue-100 dark:bg-blue-900/30 shadow-md ring-2 ring-blue-400 dark:ring-blue-600" + : isCompleted + ? "bg-green-50 dark:bg-green-900/20 opacity-70" + : "bg-muted/50 hover:bg-muted" }`} title={`${actionType}: ${action.url || action.locator?.text || action.locator?.primary || action.value || ""}`} data-testid={`action-item-${index}`} > - {isReplaying && getStatusIcon(status)} + {(isReplaying || isError) && getStatusIcon(status)} {hasScreenshot && (