# Ticket 0.3 — Test Infrastructure (Vitest + Playwright + Mocks)

**Phase**: 0 — Project Scaffolding
**Priority**: Critical (blocking)
**Depends on**: 0.1, 0.2

## Description

Set up the complete test infrastructure so that every subsequent ticket can follow TDD — writing tests *before* implementation. This includes:

- **Vitest** — for unit tests and fixture tests (server-side logic)
- **Playwright** — for E2E browser tests (frontend UI)
- **Mock OpenCode server** — deterministic testing without a real AI backend
- **Mock WebSocket client** — for fixture tests
- **Sample SSE event fixtures** — canned event sequences for deterministic flows
- **Test helpers** — setup/teardown, assertions, wait utilities

Without this ticket, the "Testable Components" tables in every ticket are aspirational rather than actionable.

### Why Phase 0, Not Phase 6

The original plan placed integration testing in ticket 6.1 (Phase 6 — Testing & Polish). This was too late for a TDD workflow. Every ticket from 1.1 onwards has unit, fixture, and E2E tests in its acceptance criteria. The test tooling must exist *before* we write the first line of production code.

Ticket 6.1 now focuses on **integration test scenarios** (the actual test cases), while this ticket provides the **infrastructure** to run them.

### Three Test Types

| Type | Runner | Purpose | Where |
|------|--------|---------|-------|
| **Unit** | Vitest | Test a single function/class in isolation with mocked deps | `test/unit/**/*.test.ts` |
| **Fixture** | Vitest | Test multiple components using mock server + canned events | `test/fixture/**/*.test.ts` |
| **E2E** | Playwright | Test UI in real browsers across 5 viewports (mobile, tablet, desktop) | `test/e2e/**/*.spec.ts` |

## Acceptance Criteria

### AC1: Vitest is configured and runs

```gherkin
Given the project has been set up (tickets 0.1 + 0.2)
When I run `pnpm test`
Then Vitest discovers test files in test/unit/**/*.test.ts and test/fixture/**/*.test.ts
And it completes with 0 failures (no tests exist yet, but the runner works)
And it supports ES modules natively (no CJS transform needed)
And it resolves TypeScript imports without additional configuration
```

### AC2: Vitest config file exists

```gherkin
Given vitest.config.ts exists in the project root
When I inspect it
Then it includes:
  - test.include: ["test/unit/**/*.test.ts", "test/fixture/**/*.test.ts"]
  - test.testTimeout: 10000 (generous for fixture tests)
  - test.hookTimeout: 10000
  - resolve.alias for src/ paths (if needed)
And package.json has scripts:
  - "test": "vitest run"
  - "test:unit": "vitest run test/unit"
  - "test:fixture": "vitest run test/fixture"
  - "test:watch": "vitest"
  - "test:coverage": "vitest run --coverage"
  - "test:e2e": "playwright test"
  - "test:e2e:ui": "playwright test --ui"
  - "test:all": "vitest run && playwright test"
```

### AC3: Playwright is configured for E2E

```gherkin
Given playwright.config.ts exists in the project root
When I inspect it
Then it includes:
  - testDir: "test/e2e"
  - projects (5 viewports covering mobile, tablet, and desktop):
    - { name: "iphone-15", use: { ...devices["iPhone 15"] } }           # iOS mobile (WebKit)
    - { name: "iphone-17", use: { ...devices["iPhone 17"] } }           # iOS mobile (WebKit)
    - { name: "pixel-7", use: { ...devices["Pixel 7"] } }               # Android mobile (Chromium)
    - { name: "ipad-pro", use: { ...devices["iPad Pro 11"] } }          # Tablet (WebKit)
    - { name: "desktop", use: { viewport: { width: 1440, height: 900 } } }  # Desktop (Chromium)
  - webServer: starts the relay + mock OpenCode server before tests
  - retries: 2 on CI, 0 locally
  - reporter: ["html"]
And Playwright browsers are installed (chromium + webkit)
```

### AC4: Mock OpenCode server scaffold exists

```gherkin
Given test/fixtures/mock-opencode-server.ts exists
When I import and instantiate it
Then it can:
  - Start an HTTP server on a random available port
  - Serve canned responses for all key endpoints:
    | Endpoint                         | Response                |
    | GET /global/health               | { healthy: true, version: "mock" } |
    | GET /session/                    | Array of mock sessions  |
    | POST /session/                   | New mock session        |
    | GET /session/:id                 | Mock session object     |
    | GET /session/:id/message         | Array of mock messages  |
    | POST /session/:id/prompt_async   | 204 (triggers SSE events) |
    | POST /permission/:id/reply       | 200                     |
    | POST /question/:id/reply         | 200                     |
    | GET /permission/                 | Array of pending permissions |
    | GET /question/                   | Array of pending questions |
    | GET /agent/                      | Array of mock agents    |
    | GET /provider/                   | Mock provider list      |
    | GET /event                       | SSE stream              |
  - Emit SSE events programmatically (push events into the stream)
  - Track which endpoints were called and with what arguments
  - Shut down cleanly after each test
And the mock server starts/stops in < 100ms
```

### AC5: Mock WebSocket client scaffold exists

```gherkin
Given test/fixtures/mock-ws-client.ts exists
When I import and instantiate it
Then it can:
  - Connect to the relay's WebSocket endpoint
  - Send typed messages (message, permission_response, ask_user_response, etc.)
  - Collect received messages in an ordered array
  - Wait for a specific message type with a timeout
  - Disconnect cleanly
And multiple clients can connect simultaneously for multi-client tests
```

### AC6: Sample SSE event fixtures exist

```gherkin
Given test/fixtures/sample-events.ts exists
When I import it
Then it exports canned event sequences for common flows:
  | Fixture                  | Events                                                    |
  | simpleTextResponse       | session.status(busy) → part.updated(text) → part.delta(text) × N → message.updated → session.status(idle) |
  | toolCallResponse         | session.status(busy) → part.updated(tool,pending) → part.updated(tool,running) → part.updated(tool,completed) → session.status(idle) |
  | thinkingThenText         | session.status(busy) → part.updated(reasoning) → part.delta(reasoning) × N → part.updated(text) → part.delta(text) × N → session.status(idle) |
  | permissionRequired       | session.status(busy) → permission.asked → (waits for reply) → permission.replied → session.status(idle) |
  | questionAsked            | session.status(busy) → question.asked → (waits for reply) → question.replied → session.status(idle) |
  | errorResponse            | session.status(busy) → part.updated(tool,error) → session.status(idle) |
And each fixture is an array of { type, properties } objects matching OpenCode's SSE format
And each fixture has correct sessionID, messageID, and partID cross-references
```

### AC7: Test helpers exist

```gherkin
Given test/helpers/setup.ts exists
When I import it
Then it exports:
  - createTestRelay(mockServerPort) → starts a relay instance pointing at the mock
  - teardownTestRelay(relay) → shuts down the relay cleanly
  - waitForMessage(client, type, timeoutMs?) → Promise that resolves when the client receives a message of the given type
  - waitForMessages(client, count, timeoutMs?) → Promise that resolves when N messages are received
  - assertMessageSequence(received, expected) → asserts messages arrived in order with correct types

Given test/helpers/assertions.ts exists
When I import it
Then it exports custom Vitest assertions:
  - expect(msg).toBeRelayMessage(type) → checks { type } field
  - expect(msgs).toContainMessageSequence(types[]) → checks ordered subset
  - expect(mockServer).toHaveReceivedRequest(method, path) → checks call log
```

### AC8: Fixture smoke test proves server-side infrastructure works

```gherkin
Given all fixtures and helpers are in place
When I run `pnpm test:fixture`
Then a smoke test in test/fixture/smoke.test.ts passes:
  1. Mock OpenCode server starts
  2. Relay starts and connects to mock
  3. Mock WebSocket client connects to relay
  4. Client sends a message
  5. Mock server receives the prompt request
  6. Mock server emits canned SSE events
  7. Client receives translated messages
  8. Everything shuts down cleanly
And the smoke test completes in < 5 seconds
```

### AC9: E2E smoke test proves browser infrastructure works

```gherkin
Given Playwright is configured and the relay + mock server can start
When I run `pnpm test:e2e`
Then a smoke test in test/e2e/smoke.spec.ts passes on all 5 viewports (iPhone 15, iPhone 17, Pixel 7, iPad Pro 11, Desktop):
  1. The relay starts with mock OpenCode server
  2. Playwright opens the relay URL in the configured browser/viewport
  3. The page loads without errors
  4. The chat input is visible and focusable
  5. The browser is closed and relay shuts down cleanly
And the E2E smoke test completes in < 30 seconds (across all viewports)
```

## Testable Components

| Component | Test Type | What to Assert |
|-----------|-----------|----------------|
| `vitest.config.ts` | Unit | Correct include patterns, timeouts, aliases |
| `playwright.config.ts` | Unit | Correct testDir, 5 viewports (iPhone 15, iPhone 17, Pixel 7, iPad Pro 11, Desktop), webServer config |
| `MockOpenCodeServer` | Unit | Starts, serves endpoints, emits SSE, tracks calls, stops |
| `MockWSClient` | Unit | Connects, sends, receives, waits, disconnects |
| `sampleEvents` | Unit | All fixtures have valid shape, cross-referenced IDs |
| `setup helpers` | Unit | createTestRelay/teardown work, waitForMessage resolves |
| `custom assertions` | Unit | Each matcher works for positive and negative cases |
| Fixture smoke test | Fixture | Full server-side round-trip works |
| E2E smoke test | E2E | Browser loads relay page, chat input visible on all 5 viewports |

## Test Infrastructure Layout

```
test/
├── fixtures/
│   ├── mock-opencode-server.ts   # Mock HTTP + SSE server
│   ├── mock-ws-client.ts         # Mock WebSocket client
│   └── sample-events.ts          # Canned SSE event sequences
├── helpers/
│   ├── setup.ts                  # Start/stop relay + mock per test
│   └── assertions.ts             # Custom Vitest assertion helpers
├── unit/                         # Unit tests (populated by later tickets)
├── fixture/                      # Fixture tests using mock server + events
│   └── smoke.test.ts             # Proves fixture infrastructure works
├── e2e/
│   └── smoke.spec.ts             # Proves E2E infrastructure works
vitest.config.ts                  # Vitest configuration (unit + fixture)
playwright.config.ts              # Playwright configuration (E2E)
```

## Files to Create

- `vitest.config.ts`
- `playwright.config.ts`
- `test/fixtures/mock-opencode-server.ts`
- `test/fixtures/mock-ws-client.ts`
- `test/fixtures/sample-events.ts`
- `test/helpers/setup.ts`
- `test/helpers/assertions.ts`
- `test/fixture/smoke.test.ts`
- `test/e2e/smoke.spec.ts`

## Notes

- Mock server should use Node.js built-in `http` module (no framework dependency for tests)
- SSE streaming in the mock can use `res.write()` with `text/event-stream` content type
- Mock WS client wraps the `ws` library (already a project dependency)
- All fixtures are TypeScript with proper type annotations matching OpenCode's event shapes
- Playwright runs on **5 viewports**: iPhone 15 (WebKit), iPhone 17 (WebKit), Pixel 7 (Chromium), iPad Pro 11 (WebKit), and Desktop 1440×900 (Chromium) — mobile-first but verified across all form factors
- The smoke tests are intentionally simple — comprehensive scenarios are in ticket 6.1
- Test execution target: unit + fixture tests should stay under **30 seconds**, E2E under **60 seconds**
- Every subsequent ticket should add its tests to `test/unit/`, `test/fixture/`, or `test/e2e/` using this infrastructure
