diff --git a/docs/roadmaps/03_WEB_ROADMAP.md b/docs/roadmaps/03_WEB_ROADMAP.md
index 1b543d5..7c7c3a5 100644
--- a/docs/roadmaps/03_WEB_ROADMAP.md
+++ b/docs/roadmaps/03_WEB_ROADMAP.md
@@ -58,7 +58,7 @@ Stack: Next.js 16 + React 19 + TypeScript
- [ ] Performance pass
- [ ] Accessibility pass
- [ ] Token validation pass
-- [ ] Production build passes
+- [x] Production build passes
- [ ] UX polish pass
# High-Collision Areas
@@ -108,6 +108,13 @@ Stack: Next.js 16 + React 19 + TypeScript
- stronger focus-visible treatment for interactive controls
- clearer active-nav semantics via `aria-current`
- keyboard/accessibility guidance surfaced in navigation/settings
+ - Added the first web UI test harness and coverage for:
+ - shared `AppShell` skip-link/main landmark behavior
+ - shared `Sidebar` primary-nav and active-page semantics
+ - Verified `web/` with:
+ - `npm test`
+ - `npm run typecheck`
+ - `npm run build`
# Open Questions
@@ -139,8 +146,6 @@ Stack: Next.js 16 + React 19 + TypeScript
- Extraction-backed task review flows
- Backend-backed agent activity timeline, approval queue, proposal diff review, and audit filtering
- Remaining dense/accessibility polish and performance hardening
-- Remaining verification:
- - run `npm test`
# Done When
diff --git a/web/src/components/AppShell.test.tsx b/web/src/components/AppShell.test.tsx
new file mode 100644
index 0000000..dee4345
--- /dev/null
+++ b/web/src/components/AppShell.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { AppShell } from "./AppShell";
+
+vi.mock("@/components/Sidebar", () => ({
+ Sidebar: () =>
Sidebar
,
+}));
+
+describe("AppShell", () => {
+ it("renders skip link, page title, actions, and main content landmark", () => {
+ render(
+ Run}>
+
+
+ );
+
+ expect(screen.getByRole("link", { name: "Skip to main content" })).toHaveAttribute("href", "#main-content");
+ expect(screen.getByRole("heading", { level: 1, name: "Search" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Run" })).toBeInTheDocument();
+ expect(screen.getByRole("main")).toHaveAttribute("id", "main-content");
+ expect(screen.getByText("Results")).toBeInTheDocument();
+ });
+});
diff --git a/web/src/components/Sidebar.test.tsx b/web/src/components/Sidebar.test.tsx
new file mode 100644
index 0000000..2ad1c2a
--- /dev/null
+++ b/web/src/components/Sidebar.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { Sidebar } from "./Sidebar";
+
+const usePathnameMock = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => usePathnameMock(),
+}));
+
+vi.mock("next/link", () => ({
+ default: ({ href, children, ...props }: React.ComponentProps<"a"> & { href: string }) => (
+
+ {children}
+
+ ),
+}));
+
+describe("Sidebar", () => {
+ it("marks the active navigation item and exposes primary navigation landmarks", () => {
+ usePathnameMock.mockReturnValue("/search");
+
+ render();
+
+ expect(screen.getByLabelText("Primary")).toBeInTheDocument();
+ expect(screen.getByRole("navigation", { name: "Primary navigation" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Search" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByText("Keyboard flow")).toBeInTheDocument();
+ });
+});
diff --git a/web/src/test/setupTests.ts b/web/src/test/setupTests.ts
new file mode 100644
index 0000000..f149f27
--- /dev/null
+++ b/web/src/test/setupTests.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
new file mode 100644
index 0000000..af1831d
--- /dev/null
+++ b/web/vitest.config.ts
@@ -0,0 +1,15 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/test/setupTests.ts"],
+ globals: true,
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});