Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions public/js/staktrak.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
35 changes: 34 additions & 1 deletion src/__tests__/unit/hooks/useStaktrakReplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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));

Expand Down
24 changes: 24 additions & 0 deletions src/app/w/[slug]/task/[...taskParams]/artifacts/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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)) {
Expand Down Expand Up @@ -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
}
Expand Down
33 changes: 24 additions & 9 deletions src/components/ActionsList.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -181,6 +183,7 @@ export function ActionsList({
screenshots = [],
title,
onReplayToggle,
failedActionIndex = -1,
}: ActionsListProps) {
const actionRefs = useRef<(HTMLDivElement | null)[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
Expand All @@ -203,20 +206,29 @@ 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";
return "pending";
};

// Get status icon for action
const getStatusIcon = (status: "pending" | "active" | "completed") => {
const getStatusIcon = (
status: "pending" | "active" | "completed" | "error",
) => {
switch (status) {
case "completed":
return <CheckCircle2 className="h-3 w-3 text-green-500 flex-shrink-0" />;
case "active":
return <Loader2 className="h-3 w-3 text-blue-500 animate-spin flex-shrink-0" />;
case "error":
return <XCircle className="h-3 w-3 text-red-500 flex-shrink-0" />;
default:
return null;
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 && (
<button
onClick={() => setSelectedScreenshot(screenshot)}
Expand Down
17 changes: 16 additions & 1 deletion src/hooks/useStaktrakReplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,23 @@ export function usePlaywrightReplay(
},
]);

// Don't stop replay on error, just log it
console.warn("Playwright replay error:", errorMsg);

// A fatal error means the library halted the replay at this step
// (issue #756). Tear down the replaying state — mirroring the
// `replay-stopped` cleanup — so the UI reflects that it stopped, and
// surface the failure to the caller.
if (data.fatal) {
setIsPlaywrightReplaying(false);
setIsPlaywrightPaused(false);
setPlaywrightStatus("error");
setCurrentAction(null);

const errContainer = document.querySelector(".iframe-container");
if (errContainer) {
errContainer.classList.remove("playwright-replaying");
}
}
break;

case "staktrak-playwright-replay-paused":
Expand Down
Loading