Skip to content
Merged
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
180 changes: 180 additions & 0 deletions apps/web/e2e/sprint-47-trace-run-stability.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { expect, test } from "@playwright/test";

test.describe("Sprint 47 — Trace Run Stability", () => {
test.setTimeout(60_000);

async function loadTraceExample(
page: import("@playwright/test").Page,
exampleId: string,
) {
await page.goto("/#/load");
const card = page.getByTestId(`example-card-${exampleId}`);
await expect(card).toBeVisible({ timeout: 15000 });
await card.click();
await expect(page.getByTestId("debugger-page")).toBeVisible({
timeout: 15000,
});
}

async function openSettings(page: import("@playwright/test").Page) {
await page.getByTestId("drawer-tab-settings").click();
await expect(page.getByTestId("settings-tab")).toBeVisible();
}

async function setAutoContinuePolicy(
page: import("@playwright/test").Page,
policy: "always_continue" | "continue_when_trace_matches" | "never",
) {
await openSettings(page);
await page.getByTestId(`auto-continue-radio-${policy}`).click();
await expect(
page.getByTestId(`auto-continue-radio-${policy}`),
).toBeChecked();
}

test("trace-001: run with never auto-continue does not crash", async ({
page,
}) => {
// Collect console errors and page crashes
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));

await loadTraceExample(page, "trace-001");

// Set instruction stepping + never auto-continue
await openSettings(page);
await page.getByTestId("stepping-radio-instruction").click();
await expect(page.getByTestId("stepping-radio-instruction")).toBeChecked();
await page.getByTestId("auto-continue-radio-never").click();
await expect(page.getByTestId("auto-continue-radio-never")).toBeChecked();

// Click Run — should stop at the first host call
await page.getByTestId("run-button").click();

// Wait for the PVM to reach host call state
await expect(page.getByTestId("pvm-status-typeberry")).toHaveText(
"Host Call",
{ timeout: 15000 },
);

// App must not have crashed — debugger page still visible, no error boundary
await expect(page.getByTestId("debugger-page")).toBeVisible();
await expect(page.getByTestId("error-boundary")).not.toBeVisible();

// No unhandled JS errors
expect(errors).toEqual([]);
});

test("io-trace: run with always_continue completes without crash", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));

await loadTraceExample(page, "io-trace");
await setAutoContinuePolicy(page, "always_continue");

// Click Run — should auto-continue through all host calls
await page.getByTestId("run-button").click();

// Wait for execution to finish or at least run for a while
const completeBadge = page.getByTestId("execution-complete-badge");
const pauseBtn = page.getByTestId("pause-button");

// Either execution completes or we wait and pause
try {
await completeBadge.waitFor({ state: "visible", timeout: 15000 });
} catch {
// If not complete yet, pause and verify app is still alive
if (await pauseBtn.isVisible()) {
await pauseBtn.click({ force: true });
}
}

// App must still be functional
await expect(page.getByTestId("debugger-page")).toBeVisible();
await expect(page.getByTestId("error-boundary")).not.toBeVisible();
await expect(page.getByTestId("pvm-status-typeberry")).toBeVisible();

expect(errors).toEqual([]);
});

test("trace-001: run with continue_when_trace_matches completes without crash", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));

await loadTraceExample(page, "trace-001");
await setAutoContinuePolicy(page, "continue_when_trace_matches");

await page.getByTestId("run-button").click();

// With trace matching, it should auto-continue through matching host calls
const completeBadge = page.getByTestId("execution-complete-badge");
const pauseBtn = page.getByTestId("pause-button");

try {
await completeBadge.waitFor({ state: "visible", timeout: 20000 });
} catch {
if (await pauseBtn.isVisible()) {
await pauseBtn.click({ force: true });
}
}

// If there were JS errors, log them for debugging
if (errors.length > 0) {
console.log("Page errors captured:", errors);
}

// App must still be functional
await expect(page.getByTestId("debugger-page")).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId("error-boundary")).not.toBeVisible();

expect(errors).toEqual([]);
});

test("pending changes only visible when paused at host call", async ({
page,
}) => {
await loadTraceExample(page, "trace-001");
await setAutoContinuePolicy(page, "never");

// Run to first host call
await page.getByTestId("run-button").click();
await expect(page.getByTestId("pvm-status-typeberry")).toHaveText(
"Host Call",
{ timeout: 15000 },
);

// Pending changes should be visible (paused at host call with trace proposal)
// Wait for the 300ms debounce
await page.waitForTimeout(400);
const pending = page.getByTestId("pending-changes");
// Pending may or may not exist depending on whether the proposal has data,
// but if it does exist it must only exist while paused at a host call
const isPendingVisible = await pending.isVisible().catch(() => false);

if (isPendingVisible) {
// Verify it shows register or gas changes
const hasRegisters = await page
.getByTestId("pending-register-writes")
.isVisible()
.catch(() => false);
const hasGas = await page
.getByTestId("pending-gas-change")
.isVisible()
.catch(() => false);
expect(hasRegisters || hasGas).toBe(true);
}

// Resume by stepping — pending changes should disappear
await page.getByTestId("next-button").click();

// After stepping, lifecycle is no longer paused_host_call
// so pending changes must not be visible
await expect(pending).not.toBeVisible({ timeout: 5000 });
});
});
17 changes: 10 additions & 7 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppsSidebar, Content, Header } from "@fluffylabs/shared-ui";
import { initManifest } from "@pvmdbg/content";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { DebuggerSettingsProvider } from "./context/debugger-settings";
import { OrchestratorProvider } from "./context/orchestrator";
import { useOrchestrator } from "./hooks/useOrchestrator";
Expand Down Expand Up @@ -97,13 +98,15 @@ export default function App() {
<div className="app-body">
<AppsSidebar activeLink="debugger" enableDarkModeToggle />
<Content className="app-content">
<RestoreGate>
<Routes>
<Route path="/" element={<DebuggerPage />} />
<Route path="/load" element={<LoadPage />} />
<Route path="*" element={<Navigate to="/load" replace />} />
</Routes>
</RestoreGate>
<ErrorBoundary>
<RestoreGate>
<Routes>
<Route path="/" element={<DebuggerPage />} />
<Route path="/load" element={<LoadPage />} />
<Route path="*" element={<Navigate to="/load" replace />} />
</Routes>
</RestoreGate>
</ErrorBoundary>
</Content>
</div>
</div>
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Component, type ErrorInfo, type ReactNode } from "react";

interface Props {
children: ReactNode;
}

interface State {
error: Error | null;
}

/**
* Catches uncaught render errors and displays a recovery UI instead of
* crashing the entire app to a blank page.
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };

static getDerivedStateFromError(error: Error): State {
return { error };
}

componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught:", error, info.componentStack);
}

render() {
if (this.state.error) {
return (
<div
data-testid="error-boundary"
className="flex flex-col items-center justify-center h-full gap-4 p-8"
>
<h2 className="text-lg font-semibold text-destructive">
Something went wrong
</h2>
<pre className="max-w-xl overflow-auto rounded bg-muted p-4 text-xs text-foreground">
{this.state.error.message}
</pre>
<button
type="button"
onClick={() => {
this.setState({ error: null });
window.location.hash = "#/load";
window.location.reload();
}}
className="cursor-pointer rounded bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Reload
</button>
</div>
);
}
return this.props.children;
}
}
3 changes: 3 additions & 0 deletions apps/web/src/components/debugger/BottomDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface BottomDrawerProps {
storageTable: UseStorageTable;
pendingChanges: UsePendingChanges;
snapshotVersion: number;
isRunning: boolean;
}

export function BottomDrawer({
Expand All @@ -51,12 +52,14 @@ export function BottomDrawer({
storageTable,
pendingChanges,
snapshotVersion,
isRunning,
}: BottomDrawerProps) {
const { activeTab, height, setActiveTab, setHeight } = useDrawer();
const { activeHostCall } = useHostCallState(
hostCallInfo,
selectedPvmId,
snapshots,
isRunning,
);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/debugger/RegistersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export function RegistersPanel({
/>
))}
</div>
{pendingChanges.pending && (
{lifecycle === "paused_host_call" && pendingChanges.pending && (
<PendingChanges pending={pendingChanges.pending} />
)}
</div>
Expand Down
Loading
Loading