Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit 4330880

Browse files
z23ccclaude
andcommitted
feat: Wave 6 — bun test, responsive layout, a11y, error boundary
- Bun test infrastructure: 10 tests across 3 files (api, ws, CommandPalette) - Responsive: sidebar collapses at ≤1024px, bottom tabs at ≤768px - A11y: ARIA landmarks, aria-live on HUD, 44px touch targets - WebSocket connection indicator (green/red/pulsing) - ErrorBoundary wrapping App routes - Replay: DAG execution playback with timeline and speed control 292 Rust tests pass, 10 frontend tests pass, build 516KB. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 35fa851 commit 4330880

14 files changed

Lines changed: 485 additions & 35 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ frontend/dist/
2525
# OS
2626
.DS_Store
2727
.gstack/
28+
flowctl/node_modules/
29+
flowctl/bun.lock

frontend/bun.lock

Lines changed: 76 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/bunfig.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[test]
2+
preload = ["./src/test-setup.ts"]

frontend/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "vite",
88
"build": "tsc --noEmit && vite build",
99
"preview": "vite preview",
10-
"typecheck": "tsc --noEmit"
10+
"typecheck": "tsc --noEmit",
11+
"test": "bun test"
1112
},
1213
"keywords": [],
1314
"author": "",
@@ -24,10 +25,16 @@
2425
"swr": "^2.4.1"
2526
},
2627
"devDependencies": {
28+
"@happy-dom/global-registrator": "^20.8.9",
2729
"@tailwindcss/vite": "^4.2.2",
30+
"@testing-library/jest-dom": "^6.9.1",
31+
"@testing-library/react": "^16.3.2",
32+
"@testing-library/user-event": "^14.6.1",
33+
"@types/bun": "^1.3.11",
2834
"@types/react": "^19.2.14",
2935
"@types/react-dom": "^19.2.3",
3036
"@vitejs/plugin-react": "^6.0.1",
37+
"happy-dom": "^20.8.9",
3138
"tailwindcss": "^4.2.2",
3239
"typescript": "^6.0.2",
3340
"vite": "^8.0.3"

frontend/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Replay from "./pages/Replay";
1212
import CommandPalette from "./components/CommandPalette";
1313
import TaskSidebar from "./components/TaskSidebar";
1414
import { TaskSidebarProvider } from "./components/TaskSidebarContext";
15+
import ErrorBoundary from "./components/ErrorBoundary";
1516
import { startToastBridge } from "./lib/toast-bridge";
1617

1718
export default function App() {
@@ -35,6 +36,7 @@ export default function App() {
3536
/>
3637
<CommandPalette />
3738
<TaskSidebar />
39+
<ErrorBoundary>
3840
<Routes>
3941
<Route element={<Layout />}>
4042
<Route path="/" element={<Dashboard />} />
@@ -46,6 +48,7 @@ export default function App() {
4648
<Route path="/replay/:id" element={<Replay />} />
4749
</Route>
4850
</Routes>
51+
</ErrorBoundary>
4952
</TaskSidebarProvider>
5053
);
5154
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect, mock, beforeEach } from "bun:test";
2+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3+
import { MemoryRouter } from "react-router-dom";
4+
import CommandPalette from "../components/CommandPalette";
5+
6+
// Mock SWR to return empty tasks
7+
mock.module("swr", () => ({
8+
default: () => ({ data: { count: 0, tasks: [] } }),
9+
}));
10+
11+
function renderPalette() {
12+
return render(
13+
<MemoryRouter>
14+
<CommandPalette />
15+
</MemoryRouter>,
16+
);
17+
}
18+
19+
describe("CommandPalette", () => {
20+
beforeEach(() => {
21+
cleanup();
22+
});
23+
24+
it("renders when Cmd+K is pressed", () => {
25+
renderPalette();
26+
expect(
27+
screen.queryByPlaceholderText("Type a command..."),
28+
).not.toBeInTheDocument();
29+
30+
fireEvent.keyDown(document, { key: "k", metaKey: true });
31+
expect(
32+
screen.getByPlaceholderText("Type a command..."),
33+
).toBeInTheDocument();
34+
});
35+
36+
it("closes on Escape", () => {
37+
renderPalette();
38+
39+
fireEvent.keyDown(document, { key: "k", metaKey: true });
40+
expect(
41+
screen.getByPlaceholderText("Type a command..."),
42+
).toBeInTheDocument();
43+
44+
const input = screen.getByPlaceholderText("Type a command...");
45+
fireEvent.keyDown(input, { key: "Escape" });
46+
expect(
47+
screen.queryByPlaceholderText("Type a command..."),
48+
).not.toBeInTheDocument();
49+
});
50+
51+
it("filters results when typing", () => {
52+
renderPalette();
53+
fireEvent.keyDown(document, { key: "k", metaKey: true });
54+
55+
expect(screen.getByText("go Dashboard")).toBeInTheDocument();
56+
expect(screen.getByText("go Agents")).toBeInTheDocument();
57+
58+
fireEvent.change(screen.getByPlaceholderText("Type a command..."), {
59+
target: { value: "dash" },
60+
});
61+
62+
expect(screen.getByText("go Dashboard")).toBeInTheDocument();
63+
expect(screen.queryByText("go Agents")).not.toBeInTheDocument();
64+
});
65+
});

frontend/src/__tests__/api.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
2+
import { ApiRequestError, apiFetch, apiPost } from "../lib/api";
3+
4+
describe("ApiRequestError", () => {
5+
it("parses server error JSON into status and message", () => {
6+
const err = new ApiRequestError(422, "Validation failed");
7+
expect(err.status).toBe(422);
8+
expect(err.serverMessage).toBe("Validation failed");
9+
expect(err.name).toBe("ApiRequestError");
10+
expect(err.message).toBe("Validation failed");
11+
});
12+
});
13+
14+
describe("apiFetch", () => {
15+
const originalFetch = globalThis.fetch;
16+
17+
afterEach(() => {
18+
globalThis.fetch = originalFetch;
19+
});
20+
21+
it("throws ApiRequestError with server error message on non-ok response", async () => {
22+
globalThis.fetch = mock(() =>
23+
Promise.resolve({
24+
ok: false,
25+
status: 400,
26+
statusText: "Bad Request",
27+
json: () => Promise.resolve({ error: "Invalid task ID" }),
28+
}),
29+
) as unknown as typeof fetch;
30+
31+
try {
32+
await apiFetch("/tasks");
33+
throw new Error("should have thrown");
34+
} catch (err) {
35+
expect(err).toBeInstanceOf(ApiRequestError);
36+
expect((err as ApiRequestError).status).toBe(400);
37+
expect((err as ApiRequestError).serverMessage).toBe("Invalid task ID");
38+
}
39+
});
40+
41+
it("falls back to statusText when response body is not JSON", async () => {
42+
globalThis.fetch = mock(() =>
43+
Promise.resolve({
44+
ok: false,
45+
status: 500,
46+
statusText: "Internal Server Error",
47+
json: () => Promise.reject(new SyntaxError("not json")),
48+
}),
49+
) as unknown as typeof fetch;
50+
51+
try {
52+
await apiFetch("/tasks");
53+
throw new Error("should have thrown");
54+
} catch (err) {
55+
expect((err as ApiRequestError).serverMessage).toBe(
56+
"Internal Server Error",
57+
);
58+
}
59+
});
60+
61+
it("returns parsed JSON on success", async () => {
62+
globalThis.fetch = mock(() =>
63+
Promise.resolve({
64+
ok: true,
65+
json: () => Promise.resolve({ count: 3, tasks: [] }),
66+
}),
67+
) as unknown as typeof fetch;
68+
69+
const result = await apiFetch<{ count: number }>("/tasks");
70+
expect(result.count).toBe(3);
71+
});
72+
});
73+
74+
describe("apiPost", () => {
75+
const originalFetch = globalThis.fetch;
76+
77+
afterEach(() => {
78+
globalThis.fetch = originalFetch;
79+
});
80+
81+
it("sends POST with JSON-stringified body", async () => {
82+
const mockFn = mock(() =>
83+
Promise.resolve({
84+
ok: true,
85+
json: () => Promise.resolve({ id: "task-1" }),
86+
}),
87+
);
88+
globalThis.fetch = mockFn as unknown as typeof fetch;
89+
90+
await apiPost("/tasks/task-1/start", { force: true });
91+
92+
expect(mockFn).toHaveBeenCalledTimes(1);
93+
const call = mockFn.mock.calls[0] as unknown as [string, RequestInit];
94+
const [url, opts] = call;
95+
expect(url).toBe("/api/v1/tasks/task-1/start");
96+
expect(opts.method).toBe("POST");
97+
expect(opts.body).toBe(JSON.stringify({ force: true }));
98+
});
99+
});

frontend/src/__tests__/ws.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, beforeEach } from "bun:test";
2+
import type { ConnectionState } from "../lib/ws";
3+
4+
const instances: Array<{
5+
onopen: (() => void) | null;
6+
onclose: (() => void) | null;
7+
onmessage: ((e: { data: string }) => void) | null;
8+
onerror: (() => void) | null;
9+
closed: boolean;
10+
close: () => void;
11+
simulateOpen: () => void;
12+
simulateClose: () => void;
13+
}> = [];
14+
15+
class MockWebSocket {
16+
onopen: (() => void) | null = null;
17+
onclose: (() => void) | null = null;
18+
onmessage: ((e: { data: string }) => void) | null = null;
19+
onerror: (() => void) | null = null;
20+
closed = false;
21+
22+
constructor(_url: string) {
23+
instances.push(this);
24+
}
25+
26+
close() {
27+
this.closed = true;
28+
}
29+
30+
simulateOpen() {
31+
this.onopen?.();
32+
}
33+
34+
simulateClose() {
35+
this.onclose?.();
36+
}
37+
}
38+
39+
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
40+
41+
import { connectEvents } from "../lib/ws";
42+
43+
describe("WebSocket connection", () => {
44+
beforeEach(() => {
45+
instances.length = 0;
46+
});
47+
48+
it("tracks connection state changes", () => {
49+
const states: ConnectionState[] = [];
50+
const conn = connectEvents();
51+
conn.onConnectionChange((s) => states.push(s));
52+
53+
const ws = instances[0];
54+
ws.simulateOpen();
55+
ws.simulateClose();
56+
57+
expect(states).toEqual(["connected", "reconnecting"]);
58+
conn.close();
59+
});
60+
61+
it("starts in disconnected state and connects", () => {
62+
const conn = connectEvents();
63+
// Initial WebSocket created
64+
expect(instances).toHaveLength(1);
65+
66+
// Simulate open
67+
instances[0].simulateOpen();
68+
expect(conn.state).toBe("connected");
69+
70+
conn.close();
71+
});
72+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Component, type ReactNode } from "react";
2+
3+
interface Props {
4+
children: ReactNode;
5+
}
6+
7+
interface State {
8+
hasError: boolean;
9+
}
10+
11+
export default class ErrorBoundary extends Component<Props, State> {
12+
state: State = { hasError: false };
13+
14+
static getDerivedStateFromError(): State {
15+
return { hasError: true };
16+
}
17+
18+
componentDidCatch(error: Error, info: React.ErrorInfo) {
19+
console.error("ErrorBoundary caught:", error, info.componentStack);
20+
}
21+
22+
render() {
23+
if (this.state.hasError) {
24+
return (
25+
<div className="flex flex-col items-center justify-center h-screen gap-4 text-text-primary">
26+
<h1 className="text-xl font-semibold">Something went wrong</h1>
27+
<p className="text-sm text-text-muted">
28+
An unexpected error occurred.
29+
</p>
30+
<button
31+
className="px-4 py-2 rounded-md bg-accent text-white text-sm hover:opacity-90 min-h-[44px] min-w-[44px]"
32+
onClick={() => this.setState({ hasError: false })}
33+
>
34+
Try again
35+
</button>
36+
</div>
37+
);
38+
}
39+
return this.props.children;
40+
}
41+
}

frontend/src/components/HUD.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export default function HUD() {
6060
}, []);
6161

6262
return (
63-
<div className="h-8 flex items-center px-4 md:px-6 border-b border-border bg-bg-secondary/30 text-xs font-mono shrink-0 gap-4">
63+
<div aria-live="polite" className="h-8 flex items-center px-4 md:px-6 border-b border-border bg-bg-secondary/30 text-xs font-mono shrink-0 gap-4">
6464
<span className="text-text-secondary">
6565
<span className="text-success font-medium">{counts.done}</span>
6666
<span className="text-text-muted">/{counts.total}</span>

0 commit comments

Comments
 (0)