diff --git a/--viewport b/--viewport deleted file mode 100644 index 850f8d6..0000000 Binary files a/--viewport and /dev/null differ diff --git a/.claude/skills/playwright-cli/SKILL.md b/.claude/skills/playwright-cli/SKILL.md index 2fa5d0e..6f34a18 100644 --- a/.claude/skills/playwright-cli/SKILL.md +++ b/.claude/skills/playwright-cli/SKILL.md @@ -47,6 +47,11 @@ playwright-cli upload ./document.pdf playwright-cli check e12 playwright-cli uncheck e12 playwright-cli snapshot +# search the snapshot for text or a regexp, returns matching nodes with surrounding context +playwright-cli find "Sign in" +playwright-cli find --regex "Sign (in|up)" +# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive +playwright-cli find --regex "/sign (in|up)/i" playwright-cli eval "document.title" playwright-cli eval "el => el.textContent" e5 # get element id, class, or any attribute not visible in the snapshot @@ -210,6 +215,12 @@ playwright-cli open --browser=firefox playwright-cli open --browser=webkit playwright-cli open --browser=msedge +# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit). +# Prefer this when a mobile layout is acceptable: mobile pages are usually +# lighter, so snapshots are smaller and cheaper. +playwright-cli open --mobile +playwright-cli open --device="iPhone 15" + # Use persistent profile (by default profile is in-memory) playwright-cli open --persistent # Use persistent profile with custom directory @@ -279,6 +290,11 @@ playwright-cli snapshot e34 # include each element's bounding box as [box=x,y,width,height] playwright-cli snapshot --boxes + +# search a large snapshot instead of capturing it all — returns matching nodes +# with 3 lines of context around each match (like grep -C) +playwright-cli find "Add to cart" +playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}" ``` ## Targeting elements @@ -326,13 +342,13 @@ playwright-cli kill-all ## Installation -If global `playwright-cli` command is not available, try a local version via `npx playwright-cli`: +If global `playwright-cli` command is not available, try a local version via `npx playwright cli`: ```bash -npx --no-install playwright-cli --version +npx --no-install playwright --version ``` -When local version is available, use `npx playwright-cli` in all commands. Otherwise, install `playwright-cli` as a global command: +When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command: ```bash npm install -g @playwright/cli@latest @@ -397,9 +413,8 @@ playwright-cli show --annotate * **Request mocking** [references/request-mocking.md](references/request-mocking.md) * **Running Playwright code** [references/running-code.md](references/running-code.md) * **Browser session management** [references/session-management.md](references/session-management.md) -* **Spec-driven testing (plan / generate / heal)** [references/spec-driven-testing.md](references/spec-driven-testing.md) * **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) -* **Test generation** [references/test-generation.md](references/test-generation.md) +* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md) * **Tracing** [references/tracing.md](references/tracing.md) * **Video recording** [references/video-recording.md](references/video-recording.md) * **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) diff --git a/.claude/skills/playwright-cli/references/test-generation.md b/.claude/skills/playwright-cli/references/test-generation.md index a045c55..35a8d57 100644 --- a/.claude/skills/playwright-cli/references/test-generation.md +++ b/.claude/skills/playwright-cli/references/test-generation.md @@ -1,13 +1,19 @@ -# Test Generation +# Test generation (plan → generate → heal) -Generate Playwright test code automatically as you interact with the browser. +End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently: -## How It Works +- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions. +- **Plan** — explore the app, produce a spec file describing what to test. +- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale. +- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality. -Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. -This code appears in the output and can be copied directly into your test files. +Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics. -## Example Workflow +--- + +## 0. How generation works + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files. ```bash # Start a session @@ -31,7 +37,7 @@ playwright-cli click e3 # await page.getByRole('button', { name: 'Sign In' }).click(); ``` -## Building a Test File +### Building a test file Collect the generated code into a Playwright test: @@ -50,9 +56,7 @@ test('login flow', async ({ page }) => { }); ``` -## Best Practices - -### 1. Use Semantic Locators +### Use semantic locators The generated code uses role-based locators when possible, which are more resilient: @@ -64,7 +68,7 @@ await page.getByRole('button', { name: 'Submit' }).click(); await page.locator('#submit-btn').click(); ``` -### 2. Explore Before Recording +### Explore before recording Take snapshots to understand the page structure before recording actions: @@ -75,7 +79,7 @@ playwright-cli snapshot playwright-cli click e5 ``` -### 3. Add Assertions Manually +### Add assertions manually Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers: @@ -132,3 +136,298 @@ await expect(page.getByRole('navigation')).toMatchAriaSnapshot(` - link "Profile" `); ``` + +--- + +## 1. Planning + +Goal: produce a spec file (e.g. `specs/.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file. + +### 1.1 Prerequisite: workspace + +Check the workspace has Playwright installed before anything else: + +```bash +# Either of these confirms a workspace: +test -f playwright.config.ts || test -f playwright.config.js +npx --no-install playwright --version +``` + +If there is no Playwright install, bootstrap one and let the user pick the defaults: + +```bash +npm init playwright@latest +``` + +### 1.2 Prerequisite: seed test + +A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins. + +Minimum viable seed: + +```ts +// tests/seed.spec.ts +import { test } from '@playwright/test'; + +test('seed', async ({ page }) => { + await page.goto('https://example.com/'); +}); +``` + +Preferred — push navigation into a fixture so scenario tests reuse it: + +```ts +// tests/fixtures.ts +import { test as baseTest } from '@playwright/test'; +export { expect } from '@playwright/test'; + +export const test = baseTest.extend({ + page: async ({ page }, use) => { + await page.goto('https://example.com/'); + await use(page); + }, +}); +``` + +```ts +// tests/seed.spec.ts +import { test } from './fixtures'; + +test('seed', async ({ page }) => { + // Fixture already navigates. This empty body tells agents where to start. +}); +``` + +If no seed exists, create one that at least navigates to the app. + +### 1.3 Explore the app + +Launch the app via the seed in the background and attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli +# wait for "Debugging Instructions" and the session name tw-XXXX +playwright-cli attach tw-XXXX +``` + +Resume so the seed runs, then probe the app: + +```bash +playwright-cli resume # resume so that seed test runs fully +playwright-cli snapshot # inventory of interactive elements +playwright-cli click e5 # follow a flow +playwright-cli eval "location.href" # read URL / state +playwright-cli show --annotate # ask the user to point at something +``` + +Map out: + +- Interactive surfaces (forms, buttons, lists, filters, modals). +- Primary user journeys end-to-end. +- Edge cases: empty states, validation errors, very long input, boundary values. +- Persistence: reload, local/session storage, URL fragments. +- Navigation: which controls change the URL, back/forward behaviour. + +**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there. +**Important**: Stop the background test when done exploring. + +### 1.4 Write the spec file + +Save under `specs/.plan.md`. Use this structure: + +```markdown +# Test Plan + +## Application Overview + + + +## Test Scenarios + +### 1. + +**Seed:** `tests/seed.spec.ts` + +#### 1.1. + +**File:** `tests//.spec.ts` + +**Steps:** + 1. + - expect: + - expect: + 2. + - expect: + +#### 1.2. +... + +### 2. + +**Seed:** `tests/seed.spec.ts` +... +``` + +Guidelines: + +- Each scenario is independent and starts from the seed's fresh state — never chain scenarios. +- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`). +- Cover happy path, edge cases, validation, negative flows, persistence. +- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`"). +- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation. + +--- + +## 2. Generate + +Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted. + +### 2.1 Inputs + +- **Spec file**, e.g. `specs/basic-operations.plan.md`. +- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all. +- **Seed file**, read from the `**Seed:**` line of the scenario's group. + +### 2.2 Generate one scenario + +For each target scenario, in sequence (never in parallel — scenarios share the seed session): + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli # background +playwright-cli attach tw-XXXX +# resume +``` + +**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there. + +Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected. + +Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)): + +```bash +playwright-cli snapshot # find refs +playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...) +playwright-cli press Enter +playwright-cli click e7 +``` + +For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details. + +Collect the generated code and write the test file at the path given in the spec: + +```ts +// spec: specs/basic-operations.plan.md +// seed: tests/seed.spec.ts +import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file + +test.describe('Signing in and out', () => { + test('should sign in', async ({ page }) => { + // 1. Navigate to the application + // (handled by the seed fixture) + + // 2. Type 'John Doe' into the username field + await page.getByRole('textbox', { name: 'username' }).fill('John Doe'); + + // 3. Type password + await page.getByRole('textbox', { name: 'password' }).fill('TestPassword'); + + // 4. Press Enter to submit + await page.getByRole('textbox', { name: 'password' }).press('Enter'); + + await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!'); + }); +}); +``` + +Rules: + +- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal). +- Prefix each numbered step with a `// N. ` comment before its actions. +- Use the describe group name verbatim from the spec (no `1.` ordinal). +- Import from `./fixtures` if the project has one; otherwise `@playwright/test`. +- **Important**: close the CLI session and stop the background test before moving to the next scenario. + +### 2.3 Generate multiple scenarios + +Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped. + +### 2.4 Run generated tests + +After generation, run the new tests once: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts +``` + +Any failure goes to Section 3. + +--- + +## 3. Heal + +Goal: fix failing tests, and update the spec if the app's intended behaviour changed. + +### 3.1 Find failing tests + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test +``` + +Record the list of failing `:` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile. + +### 3.2 Debug one failure + +Run the single failing test in debug mode in the background, then attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts: --debug=cli +# wait for "Debugging Instructions" and the tw-XXXX session name +playwright-cli attach tw-XXXX +``` + +The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose: + +```bash +playwright-cli snapshot # did the element change / move / rename? +playwright-cli console # app-side errors? +playwright-cli requests # failed request? wrong payload? +playwright-cli show --annotate # ask the user to point somewhere +``` + +Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs. + +Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test. + +### 3.3 Apply the fix + +Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green. + +Never skip hooks or add sleeps as a fix. Never use `networkidle`. + +### 3.4 Reconcile with the spec + +Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test. + +- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone. +- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change. +- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide: + - the scenario id (e.g. `2.3`), + - the spec lines that no longer match, + - the observed app behaviour (quote a snapshot excerpt or a concrete outcome). + +Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression). + +### 3.5 Iteration and giving up + +- Fix failures one at a time; rerun after each. +- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip. + +--- + +## Cross-references + +| For... | See | +|---|---| +| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) | +| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) | +| Managing the CLI browser session | [session-management.md](session-management.md) | diff --git a/.gitignore b/.gitignore index 40a58c3..a08a173 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,11 @@ test-results/ docs/sessions/agents/*/status.md docs/qa/daily/ artifacts/e2e/ +.codegraph +.playwright-cli +--viewport +artifacts/e2e +.playwright-mcp +.hallmark +tests +.DS_Store diff --git a/.playwright-cli/page-2026-07-10T03-00-52-443Z.yml b/.playwright-cli/page-2026-07-10T03-00-52-443Z.yml deleted file mode 100644 index e69de29..0000000 diff --git a/.storybook/main.ts b/.storybook/main.ts deleted file mode 100644 index e6a8cc7..0000000 --- a/.storybook/main.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { StorybookConfig } from "@storybook/react-vite"; - -const config: StorybookConfig = { - stories: [ - "../components/ui-kit/**/*.stories.@(ts|tsx)", - "../components/ui-kit/**/*.mdx", - "../app/**/*.mdx", - ], - addons: [ - "@storybook/addon-essentials", - "@storybook/addon-a11y", - ], - framework: { - name: "@storybook/react-vite", - options: {}, - }, - core: { - disableWhatsNew: true, - }, - typescript: { - check: false, - }, -}; - -export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx deleted file mode 100644 index aa5c64d..0000000 --- a/.storybook/preview.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { Preview } from "@storybook/react"; -import "../app/globals.css"; - -const preview: Preview = { - parameters: { - backgrounds: { - default: "light", - values: [ - { name: "light", value: "#FAFAF9" }, - { name: "dark", value: "#0A0A0A" }, - ], - }, - viewport: { - viewports: { - mobile: { - name: "Mobile (390x844)", - styles: { width: "390px", height: "844px" }, - }, - tablet: { - name: "Tablet (768x1024)", - styles: { width: "768px", height: "1024px" }, - }, - desktop: { - name: "Desktop (1440x900)", - styles: { width: "1440px", height: "900px" }, - }, - }, - }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export default preview; diff --git a/README.md b/README.md index 08e5865..7569ae8 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,68 @@ # Agent Arena -Agent Arena is a reputation arena where AI agent teams compete on real tasks, critique each other, get judged by a rubric, and leave replayable evidence that becomes Agent Passport reputation data. - -Short version: +Agent Arena is an evidence-first competition environment for AI agent teams. Three teams enter a structured Battle, publish proposals, attack and defend, receive evidence-bound scores, and leave a replayable reputation record. > Do not trust an agent because it says it can do the job. Make it prove itself. -## Current Status - -v0.4 (Mastra OSS, Postgres-backed). Sprint 0 and Sprint 1 complete. The MVP is demoable end-to-end with a deterministic engine; real Mastra + Postgres end-to-end is the next milestone. - -The durable source docs are: - -- [PRD v0.4](Agent_Arena_PRD_v0.4_Reputation_Arena_Product_Manual.md): product vision, MVP scope, long-term roadmap. -- [Project Fact Sheet](docs/CLAUDE.md): workspace layout, tech stack, package boundaries, core invariants. **Read first.** -- [Role Orchestration](docs/agents.md): who owns what, handoff protocol, sprint plan. -- [Visual Language](docs/design.md): design direction B (Linear x sports data viz), tokens, six screenshot points. -- [Test Guidelines](docs/test-guidelines.md): test pyramid, evidence format, coverage bars. -- [Migration Plan](docs/migration-v0.4.md): v0.3 (Eve) to v0.4 (Mastra) transition. -- [ADR 0001](docs/adr/0001-eve-to-mastra.md): why we replaced Eve with Mastra. -- [Archive (do not use)](docs/archive/eve-v0.3/README.md): v0.3 Eve-first docs, kept for archaeology. - -## MVP - -The MVP is `Agent Arena: Hackathon Battle`. +## Current application -User enters a messy hackathon idea. Three fixed teams compete: +The hackathon experience uses a focused Vite + React frontend and a Hono API: -- Safe Builder: feasible and stable. -- Viral Designer: memorable and screenshot-worthy. -- Infra Hacker: technically credible and future-facing. +- `/` — landing page, autoplay mini battle, trial templates, battle brief +- `/battle/demo` — live replay, result, evidence log, damage graph +- `/battles` — battle archive and dashboard +- `/agent/infra-hacker/passport` — evidence-linked Agent Passport -The Battle Engine controls the round order, event log, score calculation, champion selection, replay generation, artifact packaging, and passport snapshot. Agents generate content; code controls rules. +The UI first asks the API for persisted events. If Postgres is unavailable, the Example Battle falls back to the checked-in deterministic fixture without delaying or blocking the demo. The Battle Engine still owns round order, state transitions, scoring, and champion selection. -### Current state (end of Sprint 1) - -- 6 pages render with full content -- POST /api/battles creates real battles (idempotent) -- 5 route files hardened with rate limit + input validation -- 12 Playwright spec files covering 14 PRD §8.3 rows -- 167 unit tests pass; 76.5% global line coverage -- 8 visual baselines refreshed via agent-browser -- CI green: typecheck, lint, test, build, e2e -- Real Mastra + Postgres end-to-end pending Sprint 2 (needs OPENAI_API_KEY) - -## One-Command Start +## Start locally ```bash -./scripts/start.sh +pnpm install +pnpm dev ``` -The script installs dependencies when needed and starts the Next.js dev server. It chooses the package manager from the lockfile when one exists. +This starts the Vite frontend and Hono API together. The frontend normally opens on `http://127.0.0.1:5188`; the API listens on `http://127.0.0.1:8787`. -Run diagnostics with: +For separate terminals: ```bash -./scripts/doctor.sh +pnpm dev:web +pnpm dev:api ``` -## Development +Run repository diagnostics with `./scripts/doctor.sh`. -Common commands (run from repo root): +## Quality checks ```bash -pnpm install # install dependencies -pnpm dev # start Next.js dev server (port 3000) -pnpm test # run all unit tests (Vitest) -pnpm test:coverage # run tests with coverage report -pnpm e2e # run Playwright end-to-end journeys -pnpm build # production build -pnpm typecheck # tsc --noEmit across workspace -pnpm lint # ESLint flat config +pnpm typecheck +pnpm lint +pnpm test +pnpm build ``` -Database setup (Drizzle + Postgres): +The test command covers the preserved engine/runtime suite plus the contracts, Hono API, and Vite data layer. See [docs/hackathon-demo-runbook.md](docs/hackathon-demo-runbook.md) for the submission walkthrough and fallback checks. -```bash -cp .env.example .env.local # then fill in OPENAI_API_KEY, DATABASE_URL -pnpm db:push # apply Drizzle schema to dev DB -pnpm db:studio # Drizzle Studio GUI -``` +## Architecture boundaries + +- `apps/web` — Vite/React presentation layer +- `apps/api` — Hono HTTP adapter; event-store reads fail softly +- `packages/contracts` — shared frontend/API event contracts +- `arena` — Battle Engine and event schemas; not controlled by presentation timing +- `lib/db` — Drizzle/Postgres persistence +- `examples/fixtures` — deterministic Example Battle source data +- `agents` — Mastra runtime adapters and agent specifications + +Core invariants remain unchanged: every score cites evidence, replay and Passport rebuild from stored events, all persisted events validate, and Passport records weaknesses as well as strengths. -## Read Order For Agents +## Source documents -1. Read this README. -2. Read [AGENTS.md](AGENTS.md). -3. Read [docs/CLAUDE.md](docs/CLAUDE.md) -- workspace layout, invariants, tech stack. -4. Read [docs/agents.md](docs/agents.md) -- role ownership, handoff protocol, sprint plan. -5. Read the task-specific sibling doc in [docs](docs/). -6. Read the PRD section linked from your ticket. +- [Visual upgrade engineering specification](Agent_Arena_视觉升级_工程实施说明书.md) +- [Interactive reference prototype](agent_arena_prototype.html) +- [PRD v0.4](Agent_Arena_PRD_v0.4_Reputation_Arena_Product_Manual.md) +- [Project fact sheet](docs/CLAUDE.md) +- [Visual language](docs/design.md) +- [Test guidelines](docs/test-guidelines.md) -Do not use this README as a substitute for the deeper docs. It is a router, not the source of every contract. \ No newline at end of file +Archived Eve-era material is retained only for archaeology under `docs/archive/eve-v0.3`. diff --git a/app/agent/[id]/passport/page.test.tsx b/app/agent/[id]/passport/page.test.tsx deleted file mode 100644 index 2283dd0..0000000 --- a/app/agent/[id]/passport/page.test.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -/** - * F-1 / F-2 / F-3 fix verification for app/agent/[id]/passport/page.tsx. - * - * F-1: loadAgentPassport returns null when no real match is found - * instead of leaking demo data for arbitrary agentIds. - * F-2: fetch URL uses the agent-specific /api/agents/[id]/passport - * endpoint instead of the hardcoded /api/battles/demo. - * F-3: isChampion comparison also checks the engine agentId - * so the champion badge renders correctly. - */ - -// The page module is a client component ("use client") that uses hooks. -// We only need to verify the module loads and that the loadAgentPassport -// function correctly handles fetch responses. The pure helper logic is -// tested indirectly via the demo-bundle path that doesn't require a fetch. - -// @vitest-environment happy-dom - -describe("passport page — fix verification", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("module compiles and exports the default page component", async () => { - const mod = await import("./page"); - expect(typeof mod.ClientPassport).toBe("function"); - }); - - it("fetches from /api/agents/[id]/passport instead of /api/battles/demo (F-2)", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - battle: { - id: "btl_TEST01", - title: "Test", - winnerTeamId: "viral_designer_agent", - }, - bundle: { passports: [] }, - }), - { status: 200 }, - ), - ); - - // Render the page, then trigger the useEffect by re-rendering. - const mod = await import("./page"); - const PassportPage = mod.ClientPassport; - - const params = Promise.resolve({ id: "safe-builder" }); - const { act } = await import("@testing-library/react"); - const { createRoot } = await import("react-dom/client"); - const React = await import("react"); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(React.createElement(PassportPage, { params })); - }); - - // The fetch must target the agent-specific endpoint, not the demo battle. - const calledUrl = fetchSpy.mock.calls[0]?.[0] as string; - expect(calledUrl).toBe("/api/agents/safe-builder/passport"); - expect(calledUrl).not.toBe("/api/battles/demo"); - - root.unmount(); - container.remove(); - }); - - it("returns null when API responds OK but no passport matches (F-1 not-found branch)", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - battle: { id: "btl_TEST01", title: "Test" }, - bundle: { passports: [] }, - }), - { status: 200 }, - ), - ); - - // Verify the fetch URL uses the agent-specific endpoint (F-2) - const fetchSpy = vi.spyOn(globalThis, "fetch"); - await fetch("/api/agents/unknown-agent/passport"); - expect(fetchSpy).toHaveBeenCalledWith("/api/agents/unknown-agent/passport"); - - // The mock returns an empty passports array, which mirrors what - // loadAgentPassport receives when no match is found. Verify the - // page module's logic: if the find() returns undefined, the function - // returns null (not a demo fallback). - const mockResponse = { - battle: { id: "btl_TEST01", title: "Test" }, - bundle: { passports: [] as Array<{ agentId: string }> }, - }; - const found = mockResponse.bundle.passports.find( - (p) => p.agentId === "unknown-agent", - ); - expect(found).toBeUndefined(); - }); - - it("falls back to demo bundle when agentId is 'demo' (F-1 explicit demo flag)", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ error: "not found" }), { status: 404 }), - ); - - const mod = await import("./page"); - const PassportPage = mod.ClientPassport; - - const params = Promise.resolve({ id: "demo" }); - const { act } = await import("@testing-library/react"); - const { createRoot } = await import("react-dom/client"); - const React = await import("react"); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(React.createElement(PassportPage, { params })); - }); - - await act(async () => { - await new Promise((r) => setTimeout(r, 50)); - }); - - // Demo fallback should render the passport layout (not "not found"). - const text = container.textContent ?? ""; - expect(text).not.toContain("Passport not found"); - expect(text).toContain("Strengths"); - - root.unmount(); - container.remove(); - }); - - it("renders 'Passport not found' SectionCard when result is null (FE-1: no infinite skeleton)", async () => { - // FE-1: the not-found branch must render for any null result, - // not only when id === "not-found". - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - battle: { id: "btl_TEST01", title: "Test" }, - bundle: { passports: [] }, - }), - { status: 200 }, - ), - ); - - const mod = await import("./page"); - const PassportPage = mod.ClientPassport; - - const params = Promise.resolve({ id: "some-unknown-agent" }); - const { act } = await import("@testing-library/react"); - const { createRoot } = await import("react-dom/client"); - const React = await import("react"); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(React.createElement(PassportPage, { params })); - }); - - // Wait for the async loadAgentPassport to resolve with null. - await act(async () => { - await new Promise((r) => setTimeout(r, 50)); - }); - - const text = container.textContent ?? ""; - // The not-found SectionCard should render, not the skeleton. - expect(text).toContain("Passport not found"); - expect(container.querySelector('[data-testid="passport-skeleton"]')).toBeNull(); - - root.unmount(); - container.remove(); - }); - - /* ----- R20 Critical: race fix + path encoding ------------------ */ - - it("shows skeleton during loading (R20 race fix: no 'not found' flash)", async () => { - // R20: before the fetch resolves, the page must show the skeleton, - // NOT the "not found" branch. The old code checked `!result` on first - // paint, which flashed "not found" before the fetch completed. - // We simulate a slow fetch so we can inspect the intermediate state. - let resolveFetch: (value: Response) => void = () => {}; - vi.spyOn(globalThis, "fetch").mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - - const mod = await import("./page"); - const PassportPage = mod.ClientPassport; - - const params = Promise.resolve({ id: "some-agent" }); - const { act } = await import("@testing-library/react"); - const { createRoot } = await import("react-dom/client"); - const React = await import("react"); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(React.createElement(PassportPage, { params })); - }); - - // While the fetch is still pending, the skeleton should be visible - // and "not found" should NOT be visible. - const loadingText = container.textContent ?? ""; - expect(loadingText).not.toContain("Passport not found"); - expect(container.querySelector('[data-testid="passport-skeleton"]')).not.toBeNull(); - - // Now resolve the fetch with a not-found result. - await act(async () => { - resolveFetch( - new Response( - JSON.stringify({ - battle: { id: "btl_TEST01", title: "Test" }, - bundle: { passports: [] }, - }), - { status: 200 }, - ), - ); - // Let the microtask queue drain. - await new Promise((r) => setTimeout(r, 10)); - }); - - // After resolution with null result, the "not found" card renders. - const resolvedText = container.textContent ?? ""; - expect(resolvedText).toContain("Passport not found"); - - root.unmount(); - container.remove(); - }); - - it("rejects unsafe agentIds with path-traversal characters (R20 path encoding)", async () => { - // R20: the fetch URL must not be built from an unsanitized agentId. - // Path traversal characters (.., /, \) must be blocked before the - // fetch is even attempted, regardless of encodeURIComponent. - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - const mod = await import("./page"); - const PassportPage = mod.ClientPassport; - - // Use an agentId with path-traversal characters. - const params = Promise.resolve({ id: "../../etc/passwd" }); - const { act } = await import("@testing-library/react"); - const { createRoot } = await import("react-dom/client"); - const React = await import("react"); - - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - await act(async () => { - root.render(React.createElement(PassportPage, { params })); - }); - - // Wait for the async load to complete. - await act(async () => { - await new Promise((r) => setTimeout(r, 50)); - }); - - // The fetch must NOT have been called with the traversal path. - const calledUrls = fetchSpy.mock.calls.map((c) => c[0] as string); - for (const url of calledUrls) { - expect(url).not.toContain(".."); - } - - // The not-found card should render (validation rejected the id). - const text = container.textContent ?? ""; - expect(text).toContain("Passport not found"); - - root.unmount(); - container.remove(); - }); -}); \ No newline at end of file diff --git a/app/agent/[id]/passport/page.tsx b/app/agent/[id]/passport/page.tsx deleted file mode 100644 index 47381f1..0000000 --- a/app/agent/[id]/passport/page.tsx +++ /dev/null @@ -1,520 +0,0 @@ -"use client"; - -import "../../../print.css"; -import { use, useEffect, useState, Suspense } from "react"; -import { AppShell } from "@/components/app-shell"; -import { PassportActions } from "@/components/passport-actions"; -import { PassportMetrics, PassportSeal, SectionCard } from "@/components/arena-cards"; -import { demoBattle, winner } from "@/lib/demo-data"; -import type { Route } from "next"; -import Link from "next/link"; - -/** - * Agent Passport Snapshot — bound to the real battle API. - * - * Layout per docs/design.md §4.6 and §5.6: - * - Gold seal (top-left, champion color #D4AF37) with team initials - * - Identity strip: agentName + role + version - * - Two-column strengths | weaknesses (MUST be non-empty per PRD §12.3) - * - Evidence event links list (each link shows event id + opens event drawer) - * - Replay link + Print + Share link buttons - * - * Mandatory invariant (PRD §12.3): weaknesses column is NEVER empty. - * The passport generator guarantees this — if no accepted attacks exist, - * it falls back to the lowest scoring category. - * - * B10 fix: renders a on first paint (before data - * arrives) so the e2e test always sees SOMETHING, eliminating the - * post-B7 SSR flaky where the client component started with an empty body. - */ - -type AgentPassport = { - id: string; - agentId: string; - battleId: string; - agentName: string; - role: string; - version: string; - directoryPath: string; - contributionSummary: string; - acceptedClaims: Array<{ - claim: string; - attackId: string; - defenseId: string; - acceptedAttack: boolean; - attackerTeamId: string; - defenderTeamId: string; - }>; - rejectedClaims: Array<{ - claim: string; - attackId: string; - defenseId: string; - acceptedAttack: boolean; - attackerTeamId: string; - defenderTeamId: string; - }>; - strengths: string[]; - weaknesses: string[]; - contributionScore: number; -}; - -type BattleSummary = { - id: string; - title: string; - winnerTeamId?: string; - winnerName?: string; - winnerScore?: number; -}; - -type BundleResponse = { - battle: BattleSummary; - bundle: { - passports: AgentPassport[]; - }; -}; - -/** - * In-memory fallback — pulls from the same demo bundle that - * `/api/battles/demo` serves. This is the current data path until - * a dedicated `/api/agents/[id]/passport` endpoint is wired. - */ -function loadFromDemoBundle(agentId: string) { - const bundle = demoBattle; - const engineTeamId = agentId.replace(/-/g, "_"); - const score = bundle.scores[agentId as keyof typeof bundle.scores]; - const teamEntry = bundle.teams.find((t) => t.id === agentId); - const displayName = teamEntry?.name ?? winner.name; - const passport: AgentPassport = { - id: `passport_${bundle.id}_${agentId}`, - agentId: `${engineTeamId}_agent`, - battleId: bundle.id, - agentName: displayName, - role: teamEntry?.subtitle ?? winner.subtitle, - version: "v1", - directoryPath: `agents/${agentId}`, - contributionSummary: `${displayName} contributed ${bundle.passport.acceptedClaims.length} accepted claims and ${bundle.passport.rejectedClaims.length} rejected claims across ${bundle.events.length} events.`, - acceptedClaims: bundle.passport.acceptedClaims, - rejectedClaims: bundle.passport.rejectedClaims, - strengths: bundle.passport.strengths, - weaknesses: bundle.passport.areasToImprove, - contributionScore: score ? Math.round(score.longTermPotential * 100) : 0, - }; - const battle: BattleSummary = { - id: bundle.id, - title: bundle.title, - winnerTeamId: bundle.winnerId, - winnerName: winner.name, - winnerScore: winner.score, - }; - return { passport, battle }; -} - -async function loadAgentPassport(agentId: string): Promise<{ - passport: AgentPassport; - battle: BattleSummary; -} | null> { - // R20 fix: validate agentId against a safe pattern before using it - // in the URL. Without this, an agentId containing ".." or other path - // metacharacters could be used for path traversal even with - // encodeURIComponent. The only agentIds that should reach the API - // are lowercase alphanumeric + hyphens (e.g. "safe-builder"). - // "demo" is a special case for the explicit demo fallback. - const isValidAgentId = - /^[a-z0-9-]+$/.test(agentId) || agentId === "demo"; - if (!isValidAgentId) { - return null; - } - // F-2: fetch from the agent-specific passport endpoint instead of the - // hardcoded demo bundle URL. R20: encode agentId to prevent - // path injection (e.g. ../ traversal). - try { - const response = await fetch( - `/api/agents/${encodeURIComponent(agentId)}/passport`, - { cache: "no-store" }, - ); - if (!response.ok) { - // F-1: only fall back to demo bundle when explicitly requested. - if (agentId === "demo") { - return loadFromDemoBundle(agentId); - } - return null; - } - const data = (await response.json()) as BundleResponse; - const passport = data.bundle.passports.find( - (p) => - p.agentId === agentId || - p.agentId.replace(/_/g, "-") === agentId || - p.agentId.startsWith(agentId.replace(/-/g, "_")), - ); - if (!passport) { - // F-1: no real match → return null so the not-found branch renders. - return null; - } - return { passport, battle: data.battle }; - } catch { - // F-1: network failure → only fall back for explicit demo agentId. - if (agentId === "demo") { - return loadFromDemoBundle(agentId); - } - return null; - } -} - -/** - * PassportSkeleton — rendered on first paint while data loads. - * Mirrors the real layout shape (hero strip + two columns + evidence list) - * so the e2e test sees .passport-layout on first hit. This eliminates - * the post-B7 empty-first-paint race. - */ -function PassportSkeleton() { - return ( -
-
-
-
-
-
-
- loading… -
-
-
-
- -
- - -
- - -
- -
-
-
-
-

Strengths

-
- — -
-
-
-

Weaknesses

-
- — -
-
-
-
-
- ); -} - -export function ClientPassport({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id: paramId } = use(params); - const [id, setId] = useState(null); - const [result, setResult] = useState<{ - passport: AgentPassport; - battle: BattleSummary; - } | null>(null); - // R20 fix: track whether the async fetch has resolved so the page - // shows the skeleton during loading instead of flashing the - // "not found" branch on first paint (when result is still null - // but the fetch hasn't completed yet). - const [loaded, setLoaded] = useState(false); - - useEffect(() => { - let cancelled = false; - setId(paramId); - setLoaded(false); - loadAgentPassport(paramId).then((res) => { - if (!cancelled) { - setResult(res); - setLoaded(true); - } - }); - return () => { - cancelled = true; - }; - }, [paramId]); - - // First paint and loading state: always show skeleton (R20 fix). - // Previously the `!result` check fell through to the "not found" - // branch before the fetch resolved, causing a visible flash of - // the wrong content on every page load. - if (!loaded) { - return ( - - - - ); - } - - if (!result) { - // `id` is guaranteed non-null here: the useEffect sets it - // synchronously to paramId before the fetch resolves, and this - // branch is only reached after `loaded` is true. - const displayId = id ?? paramId; - return ( - - -

No passport snapshot exists for agent {displayId}.

-
-
- ); - } - - const { passport, battle } = result; - // F-3: compare winnerTeamId against both the raw id and the - // underscore-normalized form so the champion badge renders correctly. - // `id` is guaranteed non-null here: loaded is true, which means - // the useEffect has run and set id to paramId. - const safeId = id ?? paramId; - const engineId = safeId.replace(/-/g, "_"); - const isChampion = - battle.winnerTeamId === safeId || - battle.winnerTeamId === engineId || - battle.winnerTeamId === passport.agentId; - const shareUrl = `https://agentarena.ai/agent/${safeId}/passport`; - - const sealInitials = passport.agentName - .split(/\s+/) - .map((word) => word[0]?.toUpperCase() ?? "") - .join("") - .slice(0, 2) || "AG"; - - // PRD §12.3 invariant: weaknesses column is NEVER empty. - const strengthsList = passport.strengths.length > 0 - ? passport.strengths - : ["No clear strengths surfaced from the battle."]; - const weaknessesList = passport.weaknesses.length > 0 - ? passport.weaknesses - : ["Low-severity weaknesses detected — no critical gaps found."]; - - return ( - - {/* Print: passport-layout gets one A4 layout via print.css */} -
- - {/* IDENTITY STRIP — gold seal + name + role + version */} -
- -
-

{passport.agentName}

-

{passport.role}

-
- {passport.version} - {passport.directoryPath} - {isChampion ? ( - Champion - ) : ( - Participant - )} -
-
-
- - {/* LEFT COLUMN — contribution + evidence */} -
- -

{passport.contributionSummary}

-
-
- Battle - {battle.id} -
-
- Score - {passport.contributionScore.toFixed(1)} -
-
- Accepted - {passport.acceptedClaims.length} -
-
- Rejected - {passport.rejectedClaims.length} -
-
-
- - - - - - -

- Each claim below is backed by an event from the battle replay. Click any link to view the source event. -

-
- {passport.acceptedClaims.map((claim) => ( -
- Accepted -

{claim.claim}

- - {claim.attackId} - -
- ))} - {passport.rejectedClaims.map((claim) => ( -
- Rejected -

{claim.claim}

- - {claim.attackId} - -
- ))} -
-
-
- - {/* RIGHT COLUMN — strengths | weaknesses + actions */} -
- {/* §4.6: two-column strengths | weaknesses. MUST be non-empty. */} -
-
-

Strengths

-
- {strengthsList.map((strength) => ( - - {strength} - - ))} -
-
-
-

Weaknesses

-
- {weaknessesList.map((weakness) => ( - - {weakness} - - ))} -
-
-
- - - -
- {shareUrl} -
- {/* Print-only: show evidence URLs as plain text (PRD §7) */} -
-

Evidence URLs

-
    - {passport.acceptedClaims.map((claim) => ( -
  • - {claim.attackId} (accepted): https://agentarena.ai/battle/{battle.id}/replay?event={claim.attackId} -
  • - ))} - {passport.rejectedClaims.map((claim) => ( -
  • - {claim.attackId} (rejected): https://agentarena.ai/battle/{battle.id}/replay?event={claim.attackId} -
  • - ))} -
-
-
-
-
-
- ); -} - -/* ------------------------------------------------------------------ */ -/* Server page — wraps ClientPassport in Suspense (R30 fix) */ -/* ------------------------------------------------------------------ */ - -type PassportPageProps = { - params: Promise<{ id: string }>; -}; - -export default async function PassportPage({ params }: PassportPageProps) { - // Resolve params here so the client component receives the id. - // The Suspense boundary is required because ClientPassport - // uses React's `use(params)` hook, which suspends until the - // promise resolves. - await params; - return ( - }> - - - ); -} diff --git a/app/agent/viral-designer/passport/page.tsx b/app/agent/viral-designer/passport/page.tsx deleted file mode 100644 index 95faa30..0000000 --- a/app/agent/viral-designer/passport/page.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import "../../../print.css"; -import { AppShell } from "@/components/app-shell"; -import { PassportActions } from "@/components/passport-actions"; -import { PassportMetrics, PassportSeal, SectionCard } from "@/components/arena-cards"; -import { demoBattle, winner } from "@/lib/demo-data"; -import type { Route } from "next"; -import Link from "next/link"; - -/** - * Static passport snapshot for viral-designer (the champion). - * - * Mirrors the layout of app/agent/[id]/passport/page.tsx — gold seal, - * identity strip, two-column strengths/weaknesses, evidence links, - * and replay/print/share actions — per docs/design.md §4.6 / §5.6. - * - * This static route is what the build pre-renders for the demo. - * Next.js matches static segments before dynamic ones, so both - * pages must stay in sync. - */ -export default function ViralDesignerPassportPage() { - const team = winner; - const battle = demoBattle; - const passport = demoBattle.passport; - const shareUrl = `https://agentarena.ai/agent/viral-designer/passport`; - - const sealInitials = team.name - .split(/\s+/) - .map((word) => word[0]?.toUpperCase() ?? "") - .join("") - .slice(0, 2) || "AG"; - - const strengthsList = passport.strengths.length > 0 - ? passport.strengths - : ["No clear strengths surfaced from the battle."]; - const weaknessesList = passport.areasToImprove.length > 0 - ? passport.areasToImprove - : ["Low-severity weaknesses detected — no critical gaps found."]; - - return ( - -
- - {/* IDENTITY STRIP — gold seal + name + role + version */} -
- -
-

{team.name}

-

{team.subtitle}

-
- v1 - agents/viral-designer - Champion -
-
-
- - {/* LEFT COLUMN — contribution + evidence */} -
- -

- {team.name} contributed {passport.acceptedClaims.length} accepted claims and {passport.rejectedClaims.length} rejected claims across {battle.events.length} events. -

-
-
- Battle - {battle.id} -
-
- Score - {passport.contributionScore.toLocaleString()} -
-
- Accepted - {passport.acceptedClaims.length} -
-
- Rejected - {passport.rejectedClaims.length} -
-
-
- - - - - - -

- Each claim below is backed by an event from the battle replay. Click any link to view the source event. -

-
- {passport.acceptedClaims.map((claim) => ( -
- Accepted -

{claim.claim}

- - {claim.attackId} - -
- ))} - {passport.rejectedClaims.map((claim) => ( -
- Rejected -

{claim.claim}

- - {claim.attackId} - -
- ))} -
-
-
- - {/* RIGHT COLUMN — strengths | weaknesses + actions */} -
-
-
-

Strengths

-
- {strengthsList.map((strength) => ( - - {strength} - - ))} -
-
-
-

Weaknesses

-
- {weaknessesList.map((weakness) => ( - - {weakness} - - ))} -
-
-
- - - -
- {shareUrl} -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/app/api/battles/[id]/cancel/route.test.ts b/app/api/battles/[id]/cancel/route.test.ts deleted file mode 100644 index b58a07a..0000000 --- a/app/api/battles/[id]/cancel/route.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -/* ------------------------------------------------------------------ */ -//* Mocks */ -/* ------------------------------------------------------------------ */ - -// Mock guards so we can control the cancelCurrentBattle return value -// and track calls. The route handler delegates validation and rate -// limiting to guards — we only need to verify the handler logic here. -const mockCancelCurrentBattle = vi.fn(); -const mockValidateBattleId = vi.fn(); -const mockBadRequest = vi.fn((msg: string) => - new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "content-type": "application/json" } }), -); - -vi.mock("@/lib/api/guards", () => ({ - validateBattleId: mockValidateBattleId, - cancelCurrentBattle: mockCancelCurrentBattle, - badRequest: mockBadRequest, - withRateLimit: unknown>(handler: T): T => handler, -})); - -function makeRequest(): Request { - return new Request("http://localhost/api/battles/btl_ABCDEFGH/cancel", { - method: "POST", - }); -} - -function makeCtx() { - return { params: Promise.resolve({ id: "btl_ABCDEFGH" }) }; -} - -describe("POST /api/battles/[id]/cancel", () => { - let POST: (request: Request, ctx: { params: Promise<{ id: string }> }) => Promise; - - beforeEach(async () => { - vi.resetModules(); - mockCancelCurrentBattle.mockReset(); - mockValidateBattleId.mockReset(); - mockBadRequest.mockClear(); - const mod = await import("./route"); - POST = mod.POST as typeof POST; - }); - - it("returns 200 with cancelled=true when a battle is in-flight", async () => { - mockValidateBattleId.mockReturnValue(true); - mockCancelCurrentBattle.mockReturnValue(true); - - const response = await POST(makeRequest(), makeCtx()); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body.battleId).toBe("btl_ABCDEFGH"); - expect(body.cancelled).toBe(true); - expect(body.status).toBe("cancelling"); - }); - - it("returns 200 with cancelled=false when no battle is running", async () => { - mockValidateBattleId.mockReturnValue(true); - mockCancelCurrentBattle.mockReturnValue(false); - - const response = await POST(makeRequest(), makeCtx()); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body.cancelled).toBe(false); - // R22 fix: "not_running" (not "demo_not_cancellable") for non-demo - // battle ids; "demo_not_cancellable" only applies when id === "demo". - expect(body.status).toBe("not_running"); - }); - - it("returns 400 for an invalid battle ID", async () => { - mockValidateBattleId.mockReturnValue(false); - - const response = await POST(makeRequest(), makeCtx()); - - expect(response.status).toBe(400); - expect(mockCancelCurrentBattle).not.toHaveBeenCalled(); - }); -}); diff --git a/app/api/battles/[id]/cancel/route.ts b/app/api/battles/[id]/cancel/route.ts deleted file mode 100644 index 159de77..0000000 --- a/app/api/battles/[id]/cancel/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextResponse } from "next/server"; -import { withRateLimit, validateBattleId, badRequest, cancelCurrentBattle } from "@/lib/api/guards"; - -type CancelRouteContext = { - params: Promise<{ id: string }>; -}; - -async function cancelBattleHandler( - _request: Request, - ctx: CancelRouteContext, -): Promise { - const { id } = await ctx.params; - - if (id !== "demo" && !validateBattleId(id)) { - return badRequest("Invalid battle ID format"); - } - - const cancelled = cancelCurrentBattle(id); - - // R22 fix: only the explicit demo battle is "demo_not_cancellable" (it - // runs synchronously and finishes before cancel can reach it). Real AI - // battles use the abort controller and can be cancelled; if no - // controller is registered, return "not_running" so the client can - // distinguish a real-but-finished battle from an already-finished demo. - const status = cancelled - ? "cancelling" - : id === "demo" - ? "demo_not_cancellable" - : "not_running"; - - return NextResponse.json({ - battleId: id, - cancelled, - status, - }); -} - -export const POST = withRateLimit(cancelBattleHandler); diff --git a/app/api/battles/[id]/events/route.test.ts b/app/api/battles/[id]/events/route.test.ts deleted file mode 100644 index 50f6cd2..0000000 --- a/app/api/battles/[id]/events/route.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -/* ------------------------------------------------------------------ */ -/* Mocks */ -/* ------------------------------------------------------------------ */ - -// Mock the battle-api module so we can control runBattleFromPayload behavior. -const mockRunBattleFromPayload = vi.fn(); - -vi.mock("@/lib/battle-api", () => ({ - runBattleFromPayload: mockRunBattleFromPayload, -})); - -/* ------------------------------------------------------------------ */ -/* Tests */ -/* ------------------------------------------------------------------ */ - -describe("GET /api/battles/[id]/events", () => { - let GET: ( - request: Request, - ctx: { params: Promise<{ id: string }> }, - ) => Promise; - - beforeEach(async () => { - vi.resetModules(); - mockRunBattleFromPayload.mockReset(); - const mod = await import("./route"); - GET = mod.GET; - }); - - function makeCtx(id: string) { - return { params: Promise.resolve({ id }) }; - } - - function makeRequest(): Request { - return new Request("http://localhost/api/battles/btl_ABCDEFGH/events", { - method: "GET", - }); - } - - it("returns 400 for an invalid battle ID format", async () => { - const response = await GET(makeRequest(), makeCtx("../../etc/passwd")); - - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toMatch(/Invalid battle ID format/); - expect(mockRunBattleFromPayload).not.toHaveBeenCalled(); - }); - - it("returns 400 for a missing battle ID prefix", async () => { - const response = await GET(makeRequest(), makeCtx("ABCDEFGH")); - - expect(response.status).toBe(400); - }); - - it("returns 200 with events for a valid battle ID", async () => { - mockRunBattleFromPayload.mockReturnValue({ - events: [{ type: "proposal_created", data: {} }], - }); - - const response = await GET(makeRequest(), makeCtx("btl_ABCDEFGH")); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body.battleId).toBe("btl_ABCDEFGH"); - expect(body.events).toHaveLength(1); - }); - - it("returns 200 with empty events when runBattleFromPayload throws (R26: never 500 for valid battle ID)", async () => { - mockRunBattleFromPayload.mockImplementation(() => { - throw new Error("boom"); - }); - - // Suppress the expected console.error output - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - const response = await GET(makeRequest(), makeCtx("btl_ABCDEFGH")); - const body = await response.json(); - - // R26 fix: in-memory battles (POST returned inMemory: true) may poll - // this route. A 500 would break the create-then-poll contract. - // Return 200 with empty events instead so the client can continue. - expect(response.status).toBe(200); - expect(body.battleId).toBe("btl_ABCDEFGH"); - expect(body.events).toEqual([]); - expect(errorSpy).toHaveBeenCalled(); - errorSpy.mockRestore(); - }); -}); diff --git a/app/api/battles/[id]/events/route.ts b/app/api/battles/[id]/events/route.ts deleted file mode 100644 index 1675c7e..0000000 --- a/app/api/battles/[id]/events/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server"; -import { runBattleFromPayload } from "@/lib/battle-api"; -import { withRateLimit, validateBattleId } from "@/lib/api/guards"; - -type BattleEventsRouteContext = { - params: Promise<{ id: string }>; -}; - -async function getBattleEvents(_request: Request, { params }: BattleEventsRouteContext): Promise { - const { id } = await params; - - if (id !== "demo" && !validateBattleId(id)) { - return NextResponse.json( - { error: "Invalid battle ID format" }, - { status: 400 }, - ); - } - - try { - const bundle = runBattleFromPayload({}, id); - return NextResponse.json({ - battleId: id, - events: bundle.events, - }); - } catch (err) { - // R26 fix: never 500 for a valid-format battle ID. Even if the demo - // engine throws (shouldn't happen, but for safety), return an empty - // events array so the client contract holds. POST /api/battles may - // create in-memory battles (inMemory: true) and the client polls this - // route — a 500 here would break that flow. - console.error("[GET /api/battles/:id/events] Unexpected error:", err); - return NextResponse.json({ - battleId: id, - events: [], - }); - } -} - -export const GET = withRateLimit(getBattleEvents); diff --git a/app/api/battles/[id]/events/stream/route.ts b/app/api/battles/[id]/events/stream/route.ts deleted file mode 100644 index 68b9449..0000000 --- a/app/api/battles/[id]/events/stream/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { runBattleFromPayload } from "@/lib/battle-api"; -import { withRateLimit, validateBattleId, badRequest } from "@/lib/api/guards"; - -type BattleEventStreamRouteContext = { - params: Promise<{ id: string }>; -}; - -async function streamHandler( - _request: Request, - { params }: BattleEventStreamRouteContext, -): Promise { - const { id } = await params; - - if (id !== "demo" && !validateBattleId(id)) { - return badRequest("Invalid battle ID format"); - } - - // R22 fix: SSE protocol requires a blank line (\n\n) between events. - // Without the blank line, EventSource clients concatenate consecutive - // events into a single malformed message. - const bundle = runBattleFromPayload({}, id); - const body = bundle.events - .map((event) => `event: ${event.eventType}\ndata: ${JSON.stringify(event)}\n\n`) - .join(""); - - return new Response(body, { - headers: { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - }, - }); -} - -export const GET = withRateLimit(streamHandler); diff --git a/app/api/battles/[id]/export/route.ts b/app/api/battles/[id]/export/route.ts deleted file mode 100644 index 7fc6054..0000000 --- a/app/api/battles/[id]/export/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { buildDemoExportMarkdown } from "@/lib/export-markdown"; -import { findById as findBattleById } from "@/lib/db/repo/battle-repo"; -import { withRateLimit, validateBattleId, badRequest } from "@/lib/api/guards"; - -async function exportHandler( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -): Promise { - const { id } = await params; - - if (!validateBattleId(id)) { - return badRequest("Invalid battle ID format"); - } - - const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "-"); - - // Try to load the real battle from DB. If found, export the real battle's - // title and idea (never lie about which data we are serving). If not found, - // or if the DB is unavailable, fall back to the demo export so the route - // still works in demo mode (PRD §8.3: ENABLE_EXAMPLE_BATTLES). - try { - const row = await findBattleById(id); - if (row) { - // R24 fix: sanitize DB-sourced content so injected `---` or - // newlines in title/idea can't break the markdown structure. - // Strip `---` sequences and collapse newlines in title; strip - // newlines in idea to prevent breaking section boundaries. - const safeTitle = (row.title ?? "").replace(/---/g, "—").replace(/[\r\n]+/g, " "); - const safeIdea = (row.idea ?? "").replace(/[\r\n]+/g, " "); - const realExport = `# ${safeTitle}: Agent Arena Export\n\nBattle ID: ${row.id}\nStatus: ${row.status}\n\n## Idea\n\n${safeIdea}\n\n## Battle Detail\n\n${buildDemoExportMarkdown(id).split("---\n\n").slice(1).join("---\n\n")}`; - return new Response(realExport, { - headers: { - "content-type": "text/markdown; charset=utf-8", - "content-disposition": `attachment; filename="agent-arena-${safeId}-export.md"`, - }, - }); - } - } catch (dbErr) { - // DB unavailable — fall through to demo export below. - console.warn( - "[GET /api/battles/[id]/export] DB lookup failed, using demo export:", - dbErr, - ); - } - - // Battle not found in DB → demo export (demo data only). - return new Response(buildDemoExportMarkdown(id), { - headers: { - "content-type": "text/markdown; charset=utf-8", - "content-disposition": `attachment; filename="agent-arena-${safeId}-export.md"`, - }, - }); -} - -export const GET = withRateLimit(exportHandler); diff --git a/app/api/battles/[id]/route.ts b/app/api/battles/[id]/route.ts deleted file mode 100644 index 1425ca1..0000000 --- a/app/api/battles/[id]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextResponse } from "next/server"; -import { runBattleFromPayload, summarizeBattleBundle } from "@/lib/battle-api"; -import { withRateLimit, validateBattleId, badRequest } from "@/lib/api/guards"; - -type BattleRouteContext = { - params: Promise<{ id: string }>; -}; - -async function getBattleHandler( - _request: Request, - { params }: BattleRouteContext, -): Promise { - const { id } = await params; - - // Accept "demo" as a special-case battle id for the demo bundle. - // Well-formed ids follow btl_<8-char base32>; "demo" is the - // canonical fixture used by e2e tests and the PRD demo flow. - if (id !== "demo" && !validateBattleId(id)) { - return badRequest("Invalid battle ID format"); - } - - const bundle = runBattleFromPayload({}, id); - - return NextResponse.json({ - battle: summarizeBattleBundle(bundle), - bundle, - }); -} - -export const GET = withRateLimit(getBattleHandler); diff --git a/app/api/battles/[id]/start/route.test.ts b/app/api/battles/[id]/start/route.test.ts deleted file mode 100644 index 4981271..0000000 --- a/app/api/battles/[id]/start/route.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -/* ------------------------------------------------------------------ */ -/* Mocks */ -/* ------------------------------------------------------------------ */ - -// Use vi.hoisted to define mock references that can be used in the -// vi.mock factory (which is itself hoisted to the top of the file). -const mocks = vi.hoisted(() => ({ - mockWithRateLimit: vi.fn( unknown>(handler: T): T => handler), - mockWithGlobalConcurrency: vi.fn( unknown>(handler: T): T => handler), - mockRegisterAbortController: vi.fn(), - mockClearAbortController: vi.fn(), -})); - -vi.mock("@/lib/api/guards", () => ({ - withRateLimit: mocks.mockWithRateLimit, - withGlobalConcurrency: mocks.mockWithGlobalConcurrency, - withInputValidation: vi.fn( - unknown }, A extends unknown[]>( - _schema: S, - handler: (data: unknown, request: Request, ...args: A) => Promise, - ) => { - return async (request: Request, ...args: A): Promise => { - // Minimal inline validation for the test — parse the JSON body - // and pass it through, but with the idea field only. - const raw = (await request.json()) as { idea?: unknown }; - const data = { idea: String(raw.idea ?? "") }; - return handler(data, request, ...args); - }; - }, - ), - badRequest: (msg: string) => - new Response(JSON.stringify({ error: msg }), { - status: 400, - headers: { "content-type": "application/json" }, - }), - validateBattleId: (id: unknown) => - typeof id === "string" && /^btl_[0-9A-HJKMNP-TV-Z]{8}$/.test(id), - validateIdea: (idea: unknown) => { - if (typeof idea !== "string") return { ok: false, error: "must be string" }; - const trimmed = idea.trim(); - if (trimmed.length < 10) return { ok: false, error: "too short" }; - return { ok: true, value: trimmed }; - }, - registerAbortController: mocks.mockRegisterAbortController, - clearAbortController: mocks.mockClearAbortController, - __resetAbortControllers: vi.fn(), - cancelCurrentBattle: vi.fn(), -})); - -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -function makeRequest(idea: string): Request { - return new Request("http://localhost/api/battles/btl_ABCDEFGH/start", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ idea }), - }); -} - -const validIdea = "Build an AI agent that writes poetry for cats in space."; - -/* ------------------------------------------------------------------ */ -/* Tests */ -/* ------------------------------------------------------------------ */ - -describe("POST /api/battles/[id]/start", () => { - let POST: (request: Request, ctx: { params: Promise<{ id: string }> }) => Promise; - - beforeEach(async () => { - vi.resetModules(); - mocks.mockWithRateLimit.mockClear(); - mocks.mockWithGlobalConcurrency.mockClear(); - mocks.mockRegisterAbortController.mockClear(); - mocks.mockClearAbortController.mockClear(); - const mod = await import("./route"); - POST = mod.POST as typeof POST; - }); - - it("wraps the handler with withRateLimit and withGlobalConcurrency", async () => { - await POST(makeRequest(validIdea), { - params: Promise.resolve({ id: "btl_ABCDEFGH" }), - }); - // Both wrappers should have been called at module load time - // (they are applied when the route module is imported and the POST - // export is created). The module import in beforeEach triggers them. - expect(mocks.mockWithRateLimit).toHaveBeenCalled(); - expect(mocks.mockWithGlobalConcurrency).toHaveBeenCalled(); - }); - - it("registers an AbortController for the battle ID (critical fix: cancel wiring)", async () => { - await POST(makeRequest(validIdea), { - params: Promise.resolve({ id: "btl_ABCDEFGH" }), - }); - // registerAbortController should be called with the battle ID - // from the route params so the cancel endpoint can find it. - expect(mocks.mockRegisterAbortController).toHaveBeenCalledWith("btl_ABCDEFGH"); - }); - - it("clears the AbortController after the battle completes (no memory leak)", async () => { - await POST(makeRequest(validIdea), { - params: Promise.resolve({ id: "btl_ABCDEFGH" }), - }); - // clearAbortController should be called in the finally block - // to prevent unbounded growth of the registry. - expect(mocks.mockClearAbortController).toHaveBeenCalledWith("btl_ABCDEFGH"); - }); - - it("returns 200 with a battle bundle on success", async () => { - const response = await POST(makeRequest(validIdea), { - params: Promise.resolve({ id: "btl_ABCDEFGH" }), - }); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.battleId).toBe("btl_ABCDEFGH"); - }); -}); diff --git a/app/api/battles/[id]/start/route.ts b/app/api/battles/[id]/start/route.ts deleted file mode 100644 index 7020300..0000000 --- a/app/api/battles/[id]/start/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { runBattleFromPayload, summarizeBattleBundle } from "@/lib/battle-api"; -import { withRateLimit, withGlobalConcurrency, withInputValidation, badRequest, validateBattleId, validateIdea, registerAbortController, clearAbortController } from "@/lib/api/guards"; - -type StartBattleRouteContext = { - params: Promise<{ id: string }>; -}; - -const StartBattleSchema = z.object({ - idea: z.string(), -}); - -async function startBattleHandler( - data: z.infer, - _request: Request, - ctx: StartBattleRouteContext, -): Promise { - const { id } = await ctx.params; - - if (!validateBattleId(id)) { - return badRequest("Invalid battle ID format"); - } - - const ideaResult = validateIdea(data.idea); - if (!ideaResult.ok) { - return badRequest(ideaResult.error); - } - - // Register an AbortController for this battle so the cancel endpoint - // can signal in-flight operations. Cleanup runs in finally to prevent - // unbounded growth of the registry map. - registerAbortController(id); - - try { - const bundle = runBattleFromPayload({ idea: ideaResult.value }, id); - - return NextResponse.json({ - battleId: bundle.battle.id, - status: bundle.battle.status, - battle: summarizeBattleBundle(bundle), - bundle, - }); - } finally { - clearAbortController(id); - } -} - -export const POST = withRateLimit( - withGlobalConcurrency( - withInputValidation(StartBattleSchema, async (data, _request, ctx) => { - if (ctx && typeof ctx === "object" && "params" in ctx) { - return startBattleHandler(data, _request, ctx as StartBattleRouteContext); - } - return badRequest("Missing route context"); - }), - ), -); diff --git a/app/api/battles/[id]/status/route.test.ts b/app/api/battles/[id]/status/route.test.ts deleted file mode 100644 index fa4338b..0000000 --- a/app/api/battles/[id]/status/route.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { GET } from "./route"; - -describe("GET /api/battles/[id]/status", () => { - it("returns agent states for any battle id (MVP: static complete)", async () => { - const response = await GET( - new Request("http://localhost:3000/api/battles/demo/status"), - { params: Promise.resolve({ id: "demo" }) }, - ); - const body = await response.json(); - expect(body.battleId).toBe("demo"); - expect(body.round).toBe(6); - expect(body.totalRounds).toBe(8); - expect(body.progress).toBe(1.0); - expect(body.canCancel).toBe(false); - expect(body.agentStates["safe-builder"].state).toBe("complete"); - expect(body.agentStates["safe-builder"].score).toBe(8.4); - expect(body.agentStates["viral-designer"].state).toBe("complete"); - expect(body.agentStates["infra-hacker"].state).toBe("complete"); - }); - - it("catch block includes status field (R24: no missing-field regression)", async () => { - // R24 fix: the DB-unavailable catch block must include a `status` - // field with a default value so polling clients always see a - // consistent shape. Before this fix, the catch returned agentStates - // without `status`, breaking the live page's polling contract. - vi.resetModules(); - vi.doMock("@/lib/db/repo/battle-repo", () => ({ - findById: vi.fn().mockRejectedValue(new Error("DB down")), - recentEvents: vi.fn().mockRejectedValue(new Error("DB down")), - })); - const { GET: GETmocked } = await import("./route"); - - const response = await GETmocked( - new Request("http://localhost:3000/api/battles/btl_FAIL/status"), - { params: Promise.resolve({ id: "btl_FAIL" }) }, - ); - const body = await response.json(); - - // Must include a status field with a safe default. - expect(body.status).toBeDefined(); - expect(body.status).toBe("unknown"); - expect(body.battleId).toBe("btl_FAIL"); - expect(body.round).toBe(1); - expect(body.progress).toBe(0); - expect(body.agentStates).toBeDefined(); - expect(Object.keys(body.agentStates)).toHaveLength(3); - - vi.doUnmock("@/lib/db/repo/battle-repo"); - }); - - it("falls through to default state (not 404) when battle not found in DB (R26: in-memory battle contract)", async () => { - // R26 fix: POST /api/battles may return 201 + inMemory: true when the - // DB insert fails. The client then polls /status — but findById - // returns null because the row was never persisted. Previously this - // returned 404, breaking the create-then-poll contract. Now it - // returns the same default state as the DB-unavailable path. - vi.resetModules(); - vi.doMock("@/lib/db/repo/battle-repo", () => ({ - findById: vi.fn().mockResolvedValue(null), - recentEvents: vi.fn().mockResolvedValue([]), - })); - const { GET: GETmocked } = await import("./route"); - - const response = await GETmocked( - new Request("http://localhost:3000/api/battles/btl_INMEM/status"), - { params: Promise.resolve({ id: "btl_INMEM" }) }, - ); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body.battleId).toBe("btl_INMEM"); - expect(body.status).toBe("unknown"); - expect(body.round).toBe(1); - expect(body.progress).toBe(0); - expect(body.canCancel).toBe(true); - expect(Object.keys(body.agentStates)).toHaveLength(3); - - vi.doUnmock("@/lib/db/repo/battle-repo"); - }); -}); diff --git a/app/api/battles/[id]/status/route.ts b/app/api/battles/[id]/status/route.ts deleted file mode 100644 index 55f9378..0000000 --- a/app/api/battles/[id]/status/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -// GET /api/battles/[id]/status -// -// Returns the current round + per-agent state for the live page polling. -// The live page polls this endpoint every 2s via SWR to update the -// 5-state agent status cards + round progress bar. -// -// Stage 3: reads from the DB for real battle IDs (battle + recent -// battle_event rows). For the demo battle ID, keeps the static complete -// response for backward compatibility with the UI. - -import { NextResponse } from "next/server"; -import { findById, recentEvents } from "@/lib/db/repo/battle-repo"; - -type RouteContext = { - params: Promise<{ id: string }>; -}; - -// Round ordering for progress calculation. -const ROUND_ORDER: Record = { - briefing: 1, - team_generation: 2, - proposal_round: 3, - cross_attack_round: 4, - defense_round: 5, - judging_round: 6, - artifact_generation: 7, - replay_generation: 8, -}; - -const TOTAL_ROUNDS = 8; - -const STATIC_DEMO_STATE = { - round: 6, - progress: 1.0, - canCancel: false, - agentStates: { - "safe-builder": { state: "complete", streamedText: "", score: 8.4 }, - "viral-designer": { state: "complete", streamedText: "", score: 8.2 }, - "infra-hacker": { state: "complete", streamedText: "", score: 7.9 }, - }, -} as const; - -export async function GET(_request: Request, context: RouteContext) { - const { id } = await context.params; - - // Demo battle ID keeps static complete response. - if (id === "demo" || id === "battle-42") { - return NextResponse.json({ battleId: id, totalRounds: TOTAL_ROUNDS, ...STATIC_DEMO_STATE }); - } - - // Real battle: query DB. - // R26 fix: if the battle row is not found in the DB (which happens for - // in-memory battles created when the DB insert failed — see POST /api/battles), - // fall through to the same default response as DB-unavailable instead of - // returning 404. This preserves the create-then-poll contract: the client - // always gets a usable status response for any valid battle ID format. - try { - const battleRow = await findById(id); - if (!battleRow) { - console.warn(`[GET /api/battles/${id}/status] Battle not in DB, using default state (in-memory?)`); - return NextResponse.json({ - battleId: id, - totalRounds: TOTAL_ROUNDS, - round: 1, - progress: 0, - canCancel: true, - status: "unknown", - agentStates: { - "safe-builder": { state: "pending", streamedText: "", score: 0 }, - "viral-designer": { state: "pending", streamedText: "", score: 0 }, - "infra-hacker": { state: "pending", streamedText: "", score: 0 }, - }, - }); - } - - const events = await recentEvents(id, 50); - - // Compute current round from the most recent event's round. - const latestEvent = events[0]; - const currentRoundName = latestEvent?.round ?? "briefing"; - const currentRound = ROUND_ORDER[currentRoundName] ?? 1; - const progress = Math.min(1.0, currentRound / TOTAL_ROUNDS); - - // If battle status is completed or failed, everything is done. - const isTerminal = battleRow.status === "completed" || battleRow.status === "failed"; - - return NextResponse.json({ - battleId: id, - totalRounds: TOTAL_ROUNDS, - round: isTerminal ? TOTAL_ROUNDS : currentRound, - progress: isTerminal ? 1.0 : progress, - canCancel: !isTerminal && battleRow.status !== "idle", - agentStates: isTerminal - ? STATIC_DEMO_STATE.agentStates - : { - "safe-builder": { state: "pending", streamedText: "", score: 0 }, - "viral-designer": { state: "pending", streamedText: "", score: 0 }, - "infra-hacker": { state: "pending", streamedText: "", score: 0 }, - }, - status: battleRow.status, - }); - } catch (dbErr) { - // DB unavailable — return a minimal response so polling doesn't break. - console.warn(`[GET /api/battles/${id}/status] DB unavailable:`, dbErr); - return NextResponse.json({ - battleId: id, - totalRounds: TOTAL_ROUNDS, - round: 1, - progress: 0, - canCancel: true, - status: "unknown", - agentStates: { - "safe-builder": { state: "pending", streamedText: "", score: 0 }, - "viral-designer": { state: "pending", streamedText: "", score: 0 }, - "infra-hacker": { state: "pending", streamedText: "", score: 0 }, - }, - }); - } -} \ No newline at end of file diff --git a/app/api/battles/demo/export/route.ts b/app/api/battles/demo/export/route.ts deleted file mode 100644 index cdf9995..0000000 --- a/app/api/battles/demo/export/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { buildDemoExportMarkdown } from "@/lib/export-markdown"; - -export function GET() { - return new Response(buildDemoExportMarkdown(), { - headers: { - "content-type": "text/markdown; charset=utf-8", - "content-disposition": 'attachment; filename="agent-arena-demo-export.md"' - } - }); -} diff --git a/app/api/battles/route.test.ts b/app/api/battles/route.test.ts deleted file mode 100644 index bfc0f37..0000000 --- a/app/api/battles/route.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -/* ------------------------------------------------------------------ */ -/* Mocks */ -/* ------------------------------------------------------------------ */ - -// Mock the DB client so the route handler works in tests without a real -// Postgres connection. Tests control return values via the mock state. -const mockSelectResults: Array> = [[]]; -const mockInsert = vi.fn().mockResolvedValue(undefined); - -vi.mock("@/lib/db/client", () => ({ - getDb: () => ({ - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => { - // Pop the next preset result, or default to empty. - return Promise.resolve(mockSelectResults.shift() ?? []); - }, - }), - }), - }), - insert: () => ({ - values: mockInsert, - }), - }), -})); - -// Capture console.warn calls for assertions. -const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -function makeRequest(body: unknown): Request { - return new Request("http://localhost/api/battles", { - method: "POST", - headers: { "content-type": "application/json" }, - body: typeof body === "string" ? body : JSON.stringify(body), - }); -} - -const validIdea = "Build an AI agent that writes poetry for cats in space."; - -/* ------------------------------------------------------------------ */ -/* Tests */ -/* ------------------------------------------------------------------ */ - -describe("POST /api/battles", () => { - // POST is dynamically imported inside beforeEach so that vi.resetModules() - // clears the module-level `buckets` Map in lib/api/guards.ts. Without - // this reset, the rate limiter state leaks between tests using the - // "unknown" client key (no forwarded-for header) and depletes the bucket. - let POST: (request: Request) => Promise; - - beforeEach(async () => { - vi.resetModules(); - mockSelectResults.length = 0; - mockInsert.mockClear(); - warnSpy.mockClear(); - const mod = await import("./route"); - POST = mod.POST; - }); - - it("returns 201 with { battleId, status: 'created' } for a valid idea", async () => { - const response = await POST(makeRequest({ idea: validIdea })); - const body = await response.json(); - - expect(response.status).toBe(201); - expect(body.status).toBe("created"); - expect(body.battleId).toMatch(/^btl_[0-9A-HJKMNP-TV-Z]{8}$/); - expect(body).not.toHaveProperty("battle"); - }); - - it("generates a deterministic battle_id from the idea text", async () => { - const response = await POST(makeRequest({ idea: validIdea })); - const body = await response.json(); - - // Same idea → same battleId (PRD §8: btl_<8-char base32> is a hash). - // R20: the low 8 bits of the hash now include real entropy (length + - // index-mixed bytes + alternate FNV prime), so the suffix differs - // from the pre-R20 output but is still deterministic. - expect(body.battleId).toBe("btl_QQK7CB3D"); - }); - - it("uses 'full' as the default mode when mode is omitted", async () => { - await POST(makeRequest({ idea: validIdea })); - expect(mockInsert).toHaveBeenCalledTimes(1); - const insertArg = mockInsert.mock.calls[0][0]; - expect(insertArg.mode).toBe("full"); - }); - - it("accepts mode='quick' explicitly", async () => { - await POST(makeRequest({ idea: validIdea, mode: "quick" })); - const insertArg = mockInsert.mock.calls[0][0]; - expect(insertArg.mode).toBe("quick"); - }); - - it("returns 400 when idea is missing", async () => { - const response = await POST(makeRequest({})); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Validation failed"); - expect(body.issues.some((s: string) => s.startsWith("idea:"))).toBe(true); - }); - - it("returns 400 when idea is too short (< 10 chars)", async () => { - const response = await POST(makeRequest({ idea: "short" })); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.issues.some((s: string) => s.includes("at least 10"))).toBe(true); - }); - - it("returns 400 when idea exceeds 2000 chars", async () => { - const longIdea = "a".repeat(2001); - const response = await POST(makeRequest({ idea: longIdea })); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.issues.some((s: string) => s.includes("at most 2000"))).toBe(true); - }); - - it("returns 400 when idea is not a string", async () => { - const response = await POST(makeRequest({ idea: 12345 })); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.issues.some((s: string) => s.startsWith("idea:"))).toBe(true); - }); - - it("returns 400 when mode is not 'quick' or 'full'", async () => { - const response = await POST(makeRequest({ idea: validIdea, mode: "turbo" })); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.issues.some((s: string) => s.startsWith("mode:"))).toBe(true); - }); - - it("returns 400 for invalid JSON body", async () => { - const response = await POST(makeRequest("not json {{{")); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toBe("Invalid JSON body"); - }); - - it("returns the existing battle_id when the same idea was submitted before (idempotency)", async () => { - mockSelectResults.push([{ id: "btl_EXISTING1" }]); - - const response = await POST(makeRequest({ idea: validIdea })); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body.battleId).toBe("btl_EXISTING1"); - expect(body.status).toBe("created"); - // Idempotent path must return the same flat shape so the client - // form can always read data.battleId regardless of code path. - expect(body).not.toHaveProperty("battle"); - // Should NOT have inserted a new row. - expect(mockInsert).not.toHaveBeenCalled(); - }); - - it("persists the battle row with correct fields on create", async () => { - await POST(makeRequest({ idea: validIdea, mode: "quick" })); - - expect(mockInsert).toHaveBeenCalledTimes(1); - const insertArg = mockInsert.mock.calls[0][0]; - expect(insertArg.id).toMatch(/^btl_/); - expect(insertArg.idea).toBe(validIdea); - expect(insertArg.mode).toBe("quick"); - expect(insertArg.status).toBe("briefing"); - expect(insertArg.type).toBe("hackathon"); - expect(insertArg.title).toBe(validIdea.slice(0, 100)); - expect(insertArg.settingsJson).toEqual({ mode: "quick" }); - expect(insertArg.originalInput).toEqual({ idea: validIdea, mode: "quick" }); - }); - - /* ----- R20 Critical: battle.id is text (btl_ prefix), not UUID ----- */ - - it("inserts a btl_ text id, not a UUID (R20 schema fix)", async () => { - await POST(makeRequest({ idea: validIdea })); - const insertArg = mockInsert.mock.calls[0][0]; - - // The id must be a text string with the btl_ prefix, NOT a UUID. - // Before R20, the schema expected uuid() which rejected btl_ strings - // at the DB layer with a type error. The schema is now text("id"). - expect(typeof insertArg.id).toBe("string"); - expect(insertArg.id).toMatch(/^btl_[0-9A-HJKMNP-TV-Z]{8}$/); - // Explicitly verify it's NOT a UUID format (8-4-4-4-12 hex pattern) - expect(insertArg.id).not.toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - }); - - it("trims the idea before length validation — 10 spaces fails validation", async () => { - const response = await POST(makeRequest({ idea: " " })); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.issues.some((s: string) => s.includes("at least 10"))).toBe(true); - }); - - it("stores the trimmed idea in the DB on create", async () => { - const paddedIdea = " Build an AI agent that writes poetry for cats in space. "; - await POST(makeRequest({ idea: paddedIdea })); - const insertArg = mockInsert.mock.calls[0][0]; - expect(insertArg.idea).toBe(validIdea); - }); - - it("returns 429 when rate limit is exceeded", async () => { - // Default rate limit is 10 requests per 60s window. - // Use a unique x-forwarded-for IP so this test is not affected by - // other tests sharing the "unknown" bucket. - const ip = "10.99.0.1"; - const responses: Array<{ status: number }> = []; - for (let i = 0; i < 11; i += 1) { - const req = new Request("http://localhost/api/battles", { - method: "POST", - headers: { - "content-type": "application/json", - "x-forwarded-for": ip, - }, - body: JSON.stringify({ idea: validIdea }), - }); - const res = await POST(req); - responses.push({ status: res.status }); - } - const lastResponse = responses[responses.length - 1]; - expect(lastResponse.status).toBe(429); - }); - - it("recovers from a unique-constraint violation (TOCTOU race) and returns the existing battle id", async () => { - // Simulate: idempotency check finds nothing (select returns []), - // then insert throws a unique-violation error, - // then recovery select returns the winner's row. - mockSelectResults.length = 0; - mockInsert.mockRejectedValueOnce( - new Error("duplicate key value violates unique constraint"), - ); - // Queue: [empty (idempotency check), winner row (recovery)] - mockSelectResults.push([], [{ id: "btl_RACEFIX" }]); - - const response = await POST(makeRequest({ idea: validIdea })); - const body = await response.json(); - - expect(response.status).toBe(200); - expect(body.battleId).toBe("btl_RACEFIX"); - // Recovery path must return the flat { battleId, status } shape — - // no legacy `battle` wrapper — so the client form reads it correctly. - expect(body).not.toHaveProperty("battle"); - expect(body.status).toBe("created"); - }); - - /* ----- R22: DB write failure falls through to in-memory (201, inMemory: true) --- */ - - it("returns 201 with inMemory: true when DB insert fails for a non-unique-violation reason", async () => { - mockSelectResults.length = 0; - mockSelectResults.push([]); // idempotency check: no existing row - mockInsert.mockRejectedValueOnce(new Error("connection refused")); - - const response = await POST(makeRequest({ idea: validIdea })); - const body = await response.json(); - - expect(response.status).toBe(201); - expect(body.inMemory).toBe(true); - }); - - it("returns 201 with inMemory: true when unique-violation recovery lookup also fails", async () => { - mockSelectResults.length = 0; - mockSelectResults.push([]); // idempotency check: no existing row - mockInsert.mockRejectedValueOnce( - new Error("duplicate key value violates unique constraint"), - ); - // Recovery select will also return empty (lookup fails) - mockSelectResults.push([]); - - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - const response = await POST(makeRequest({ idea: validIdea })); - const body = await response.json(); - - expect(response.status).toBe(201); - expect(body.inMemory).toBe(true); - // Note: the in-memory fall-through path does NOT log an error (it's the - // happy path now). errorSpy is allowed to be called zero or more times. - errorSpy.mockRestore(); - }); -}); \ No newline at end of file diff --git a/app/api/battles/route.ts b/app/api/battles/route.ts deleted file mode 100644 index cb88ffa..0000000 --- a/app/api/battles/route.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { NextResponse } from "next/server"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { demoBundle } from "@/lib/demo-data"; -import { summarizeBattleBundle, makeBattleId } from "@/lib/battle-api"; -import { getDb } from "@/lib/db/client"; -import { battle } from "@/lib/db/schema"; -import { withRateLimit } from "@/lib/api/guards"; - -export function GET() { - return NextResponse.json({ - battles: [summarizeBattleBundle(demoBundle)], - }); -} - -/* ------------------------------------------------------------------ */ -/* POST /api/battles — create a new battle */ -/* ------------------------------------------------------------------ */ - -// Request body schema: { idea: string (10-2000 chars), mode?: "quick" | "full" } -const CreateBattleBodySchema = z.object({ - idea: z - .string() - .trim() - .min(10, "idea must be at least 10 characters") - .max(2000, "idea must be at most 2000 characters"), - mode: z.enum(["quick", "full"]).optional().default("full"), -}); - -async function createBattleHandler(request: Request): Promise { - // 1. Parse and validate body - let raw: unknown; - try { - raw = await request.json(); - } catch { - return NextResponse.json( - { error: "Invalid JSON body" }, - { status: 400 }, - ); - } - - const parsed = CreateBattleBodySchema.safeParse(raw); - if (!parsed.success) { - const issues = parsed.error.issues.map( - (issue) => `${issue.path.join(".")}: ${issue.message}`, - ); - return NextResponse.json( - { error: "Validation failed", issues }, - { status: 400 }, - ); - } - - const { idea, mode } = parsed.data; - - // 2. Generate deterministic battle_id from idea (PRD §8: btl_<8-char base32>) - const battleId = makeBattleId(idea); - - // 3. Idempotency: if a battle with this exact idea already exists, return it. - try { - const db = getDb(); - const existing = await db - .select({ id: battle.id }) - .from(battle) - .where(eq(battle.idea, idea)) - .limit(1); - - if (existing.length > 0) { - return NextResponse.json( - { battleId: existing[0].id, status: "created" }, - { status: 200 }, - ); - } - } catch (dbErr) { - // DB not available (tests, build time, missing DATABASE_URL). - // For Sprint 0 demo, fall through to returning the in-memory battle_id. - console.warn("[POST /api/battles] DB unavailable, skipping idempotency check:", dbErr); - } - - // 4. Persist the new battle row. - try { - const db = getDb(); - const settingsJson = { mode }; - const originalInput = { idea, mode }; - - await db.insert(battle).values({ - id: battleId, - title: idea.slice(0, 100), - idea, - type: "hackathon", - status: "briefing", - originalInput, - settingsJson, - mode, - }); - } catch (dbErr) { - // DB write failed — could be a unique-constraint violation on idea - // (TOCTOU race: concurrent POST with same idea won the insert). - // Re-fetch the winner's id and return it idempotently. - const isUniqueViolation = - dbErr instanceof Error && - /unique|duplicate/i.test(dbErr.message); - - if (isUniqueViolation) { - try { - const db = getDb(); - const winner = await db - .select({ id: battle.id }) - .from(battle) - .where(eq(battle.idea, idea)) - .limit(1); - if (winner.length > 0) { - return NextResponse.json( - { battleId: winner[0].id, status: "created" }, - { status: 200 }, - ); - } - } catch { - // fall through to 500 below - } - } - - // DB write failed for a reason other than unique-violation recovery - // (or recovery lookup failed). Fall through to in-memory mode instead - // of hard-failing with 500 — the demo flow needs to work even without - // a live Postgres (PRD §8.3: ENABLE_EXAMPLE_BATTLES). - console.warn("[POST /api/battles] DB insert failed, falling through to in-memory:", dbErr); - return NextResponse.json( - { - battleId, - status: "created", - inMemory: true, - }, - { status: 201 }, - ); - } - - // 5. Return the created battle_id. - return NextResponse.json( - { - battleId, - status: "created", - }, - { status: 201 }, - ); -} - -export const POST = withRateLimit(createBattleHandler); \ No newline at end of file diff --git a/app/battle/[id]/live/page.tsx b/app/battle/[id]/live/page.tsx deleted file mode 100644 index a447c1d..0000000 --- a/app/battle/[id]/live/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { LiveBattleClient } from "@/components/live-battle-client"; - -type LivePageProps = { - params: Promise<{ id: string }>; -}; - -export default async function LiveBattlePage({ params }: LivePageProps) { - const { id } = await params; - - return ; -} diff --git a/app/battle/[id]/replay/page.tsx b/app/battle/[id]/replay/page.tsx deleted file mode 100644 index 0ab958a..0000000 --- a/app/battle/[id]/replay/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Suspense } from "react"; -import { BattleReplayClient } from "@/components/battle-replay-client"; - -type BattleReplayPageProps = { - params: Promise<{ id: string }>; -}; - -export default async function BattleReplayPage({ params }: BattleReplayPageProps) { - const { id } = await params; - return ( - - - - ); -} \ No newline at end of file diff --git a/app/battle/[id]/result/page.tsx b/app/battle/[id]/result/page.tsx deleted file mode 100644 index d072d58..0000000 --- a/app/battle/[id]/result/page.tsx +++ /dev/null @@ -1,302 +0,0 @@ -"use client"; - -import { use, useEffect, useState, Suspense } from "react"; -import Link from "next/link"; -import { Download, Play, Trophy, FileText, ArrowRight } from "lucide-react"; -import { AppShell } from "@/components/app-shell"; -import { - fetchBattleResult, - BattleApiError, - buildExportMarkdownUrl, - buildReplayUrl, - buildPassportUrl, - type BattleResult, - type BattleScoreRow, - type BattleArtifact, -} from "@/lib/api-client"; - -/* ------------------------------------------------------------------ */ -/* Loading / Error / Empty states */ -/* ------------------------------------------------------------------ */ - -function ResultSkeleton() { - return ( -
-
-
-
-
- ); -} - -function ResultError({ message, status }: { message: string; status?: number }) { - return ( -
-

Battle result unavailable

-

- {status === 404 - ? "This battle could not be found. It may have been deleted or the ID is incorrect." - : message} -

- - Back to Home - -
- ); -} - -/* ------------------------------------------------------------------ */ -/* Champion Card */ -/* ------------------------------------------------------------------ */ - -function ChampionCard({ result }: { result: BattleResult }) { - const winnerScore = result.scores.find((s) => s.teamId === result.winnerTeamId); - const championEvidenceId = winnerScore?.evidenceEventId ?? "unknown"; - - return ( -
- -
-

Champion

-

{result.winnerName ?? "Undecided"}

-

- - {result.winnerScore?.toFixed(1) ?? "—"} - - /10 - Winner -

-

{result.idea}

-
-
-

Evidence

- - {championEvidenceId} - - {winnerScore?.winningReason && ( -

“{winnerScore.winningReason}”

- )} -
-
- ); -} - -/* ------------------------------------------------------------------ */ -/* Scoreboard with evidence tooltips */ -/* ------------------------------------------------------------------ */ - -const scoreDimensions: Array<{ key: keyof BattleScoreRow["scores"]; label: string }> = [ - { key: "novelty", label: "Novelty" }, - { key: "feasibility", label: "Feasibility" }, - { key: "demoWow", label: "Demo Wow" }, - { key: "technicalDepth", label: "Tech Depth" }, - { key: "userValue", label: "User Value" }, - { key: "longTermPotential", label: "Long-term" }, -]; - -function Scoreboard({ scores }: { scores: BattleScoreRow[] }) { - const ranked = [...scores].sort((a, b) => b.totalScore - a.totalScore); - const rankLabels: Record = { 1: "1st", 2: "2nd", 3: "3rd" }; - - return ( -
-
- Rank - Team - Total - {scoreDimensions.map((dim) => ( - - {dim.label} - - ))} -
- {ranked.map((score, index) => { - const rank = index + 1; - const isFirst = rank === 1; - const rankLabel = rankLabels[rank] ?? `${rank}th`; - return ( -
- - {rank} - - - {score.teamName} - - - {score.totalScore.toFixed(1)} - - {scoreDimensions.map((dim) => ( - - {score.scores[dim.key].toFixed(1)} -