Skip to content

Commit 03b0629

Browse files
CoderCococlaude
andcommitted
test(web): add integration specs, fixtures, and docs for issue #75
Add the Playwright-side of the full-stack integration test suite: - ServerMocks fixture class with auto-reset before/after each spec; extends Playwright base with serverMocks, authedPage, and dashboard - 6 spec files: api-token-guard (4 HTTP), config-service (3 HTTP), start-stop (2 browser), status-polling (1 browser), error-propagation (1 HTTP), can-run (skipped placeholder) - Integration spec index re-exporting the extended test/expect - Vite integration config: embed VITE_STATUS_POLL_MS=3000 so status poller fires every 3s rather than 20s during integration runs - Playwright integration config: raise Nest startup timeout 30s→120s to accommodate ESM loading latency on WSL2/DrvFs (~50s observed) - gitignore: exclude dist-integration/ build artifacts - CLAUDE.md: promote two-tier table to three-tier; add integration test conventions section; update reference docs table - docs/docs/components/integration-tests.md: full architecture doc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fac5fac commit 03b0629

14 files changed

Lines changed: 1719 additions & 5 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@ app/packages/server/src/generated/tfstate.ts
3838
app/packages/web/test-results/
3939
app/packages/web/playwright-report/
4040
app/packages/web/playwright/.cache/
41+
app/packages/web/dist-integration/
4142

4243
.make

CLAUDE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ When working in a specific area, read the relevant doc rather than relying on wh
140140
| AWS IAM deploy policy | `docs/docs/setup.md` |
141141
| Terraform variables reference | `docs/docs/components/terraform.md` |
142142
| Full setup walkthrough | `docs/docs/setup.md` |
143+
| Integration test architecture | `docs/docs/components/integration-tests.md` |
143144
| Copilot review tuning | `.github/copilot-instructions.md` |
144145
| PR creation command | `.claude/commands/pr.md` |
145146

@@ -161,14 +162,13 @@ Any time you add or remove Terraform variables, update **all four** of these in
161162

162163
### Two-tier browser testing strategy
163164

164-
The test suite has two complementary tiers:
165+
The test suite has three complementary tiers:
165166

166167
| Tier | Command | What runs | When to add specs |
167168
|------|---------|-----------|-------------------|
168169
| **Unit / integration** | `npm run app:test` | Vitest. Server-side logic, hooks, helpers run under the `node` environment; React component specs in `@gsd/web` run under `jsdom` via `environmentMatchGlobs`. No real network — AWS SDK mocked via `aws-sdk-client-mock`; the `@gsd/web` API client is stubbed via `vi.mock`. | Pure logic, hook behaviour, server controllers, **per-component React behaviour** (rendering, callbacks, internal state transitions). |
169170
| **E2E (tier 1 — #74)** | `npm run app:test:e2e` | Playwright against `vite build` + `vite preview`. Nest server never runs; every `/api/*` call is stubbed at the network layer via `page.route()`. | User-visible flows: routing, auth gate, button interactions, status-badge rendering, optimistic updates. |
170-
171-
A planned **tier 2** (#75) will add full-stack specs (real Nest + mocked AWS SDK) for scenarios that require real HTTP contract validation between the client and server. Until that lands, all browser-facing specs belong in tier 1.
171+
| **Integration (tier 2 — #75)** | `npm run app:test:integration` | Playwright against `vite build (integration config)` + `vite preview` + real Nest server on `:3002`. AWS SDK intercepted by `aws-sdk-client-mock`. Mock state pushed via `ServerMocks` fixture to `/api/test/mocks/*`. | Real HTTP contract validation, `ApiTokenGuard` behaviour, `ConfigService` tfstate parsing, start/stop flows end-to-end, error propagation from ECS through the API response. |
172172

173173
**React component unit tests (`@gsd/web`):**
174174
- Use **Vitest + jsdom + `@testing-library/react` + `@testing-library/user-event`**. `@testing-library/jest-dom` matchers (`toBeInTheDocument`, `toHaveTextContent`, etc.) are auto-loaded via `app/vitest.setup.ts`.
@@ -180,7 +180,7 @@ A planned **tier 2** (#75) will add full-stack specs (real Nest + mocked AWS SDK
180180
- Each routed page (`DashboardPage`, `CostsPage`, `DiscordPage`, `LogsPage`, `SettingsPage`) gets a co-located `*.test.tsx` that mounts the page through `renderPage()` (`app/packages/web/src/test-utils/renderPage.tsx`). The helper wraps children in `PollingProvider → GameStatusProvider → MemoryRouter` so the same provider stack the production app uses is exercised; pass `initialEntries` when the page reads `useLocation`.
181181
- Mock `../api.js` with `vi.mock` + `vi.hoisted` so the page drives off canned data instead of real fetches. Stub every method the page (and the GameStatusProvider above it) calls — at minimum `api.status` and `api.costsEstimate` — or the test will hang waiting for the polling registry to settle.
182182
- These tests are intentionally **complementary** to the e2e tier, not a replacement: the e2e specs prove the indicator + chrome render at the route level under a real Vite preview build; the unit tests pin the page's own provider wiring (e.g. that `<PollingIndicator />` resolves to "Updated …" once the mocked status poll resolves) and let us iterate on the page layout without spinning up Playwright.
183-
- Keep page-test scope tight: smoke-render each header section, exercise interactive controls that aren't already covered by a child component's unit test, and verify the polling indicator wiring. Anything that requires real HTTP belongs in the planned tier-2 specs (#75).
183+
- Keep page-test scope tight: smoke-render each header section, exercise interactive controls that aren't already covered by a child component's unit test, and verify the polling indicator wiring. Anything that requires real HTTP belongs in tier-2 integration specs (`e2e/integration-specs/`).
184184

185185
**Playwright conventions:**
186186
- Specs live under `app/packages/web/e2e/specs/`.
@@ -191,6 +191,13 @@ A planned **tier 2** (#75) will add full-stack specs (real Nest + mocked AWS SDK
191191
- Use the `authedPage` fixture (token pre-seeded in localStorage) only when a spec needs raw `Page` access (e.g. to call `stubApis` or `addInitScript`); otherwise prefer the higher-level page-object fixtures.
192192
- Stubs must cover every `/api/*` endpoint the page hits, or the catch-all returns 404 and the test will surface the missing stub quickly.
193193

194+
**Integration test conventions (tier 2):**
195+
- Specs live under `app/packages/web/e2e/integration-specs/`. Import `{ test, expect }` from `./index.js` (NOT from `@playwright/test`) — the extended `test` includes the `serverMocks`, `authedPage`, and `dashboard` fixtures.
196+
- `serverMocks` (`ServerMocks` from `e2e/fixtures/server-mocks.ts`) pushes queued responses to the test server via `POST /api/test/mocks/*`. Always include it in test parameters — it resets the MockStore before and after each spec automatically.
197+
- The test Nest server (`test-main.ts`) runs on `:3002`; the Vite integration preview (port 4174) proxies `/api` to it. Pure HTTP tests call `http://localhost:3002/api/...` directly (bypasses the proxy). Browser tests navigate to the `baseURL` (port 4174) and the proxy routes API calls.
198+
- `workers: 1` and `fullyParallel: false` in `playwright.integration.config.ts` are intentional — the shared in-process `MockStore` cannot be used concurrently.
199+
- The integration Vite build sets `VITE_STATUS_POLL_MS=3000` so pollers fire every 3 s instead of 20 s, keeping status-polling specs fast without busy-looping.
200+
194201
## Git & Branch Workflow
195202

196203
`main` is a protected branch — direct pushes are blocked. All changes go through a PR, including trivial chores (`.gitignore` entries, config tweaks). Never commit directly to `main`.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { test as base } from '@playwright/test';
2+
import type { APIRequestContext, Page } from '@playwright/test';
3+
import { DashboardPage } from '../pages/DashboardPage.js';
4+
5+
const SERVER_BASE = 'http://localhost:3002';
6+
const AUTH_HEADERS = { Authorization: 'Bearer test-token' };
7+
8+
/** Shape matching the server-side MockResponse — one queued ECS command reply. */
9+
export interface MockResponse {
10+
type: 'success' | 'error';
11+
data?: unknown;
12+
code?: string;
13+
message?: string;
14+
}
15+
16+
/**
17+
* Playwright-side helper that pushes queued responses into the test server's
18+
* MockStore via its HTTP control endpoints at `/api/test/mocks/*`. Each method
19+
* maps to one ECS command type. Construct via the `serverMocks` fixture — it
20+
* resets the store automatically before and after every test.
21+
*/
22+
export class ServerMocks {
23+
constructor(private readonly request: APIRequestContext) {}
24+
25+
private async post(path: string, body?: unknown): Promise<void> {
26+
const res = await this.request.post(`${SERVER_BASE}${path}`, {
27+
headers: AUTH_HEADERS,
28+
...(body !== undefined ? { data: body } : {}),
29+
});
30+
if (!res.ok()) throw new Error(`Mock control call failed ${res.status()} ${path}`);
31+
}
32+
33+
/** Clear all ECS command queues — called automatically before and after each test. */
34+
async reset(): Promise<void> { await this.post('/api/test/mocks/reset'); }
35+
36+
/** Queue a response for the next ListTasksCommand call. */
37+
async pushListTasks(r: MockResponse): Promise<void> { await this.post('/api/test/mocks/ecs/list-tasks', r); }
38+
39+
/** Queue a response for the next DescribeTasksCommand call. */
40+
async pushDescribeTasks(r: MockResponse): Promise<void> { await this.post('/api/test/mocks/ecs/describe-tasks', r); }
41+
42+
/** Queue a response for the next RunTaskCommand call. */
43+
async pushRunTask(r: MockResponse): Promise<void> { await this.post('/api/test/mocks/ecs/run-task', r); }
44+
45+
/** Queue a response for the next StopTaskCommand call. */
46+
async pushStopTask(r: MockResponse): Promise<void> { await this.post('/api/test/mocks/ecs/stop-task', r); }
47+
}
48+
49+
type IntegrationFixtures = {
50+
/**
51+
* Pre-seeded mock controller — resets the MockStore before and after each
52+
* test so no queued responses leak between specs.
53+
*/
54+
serverMocks: ServerMocks;
55+
/**
56+
* Page with `apiToken = 'test-token'` pre-seeded in localStorage so every
57+
* navigation to the Vite preview starts authenticated.
58+
*/
59+
authedPage: Page;
60+
/** Dashboard page object backed by `authedPage`. */
61+
dashboard: DashboardPage;
62+
};
63+
64+
export const test = base.extend<IntegrationFixtures>({
65+
serverMocks: async ({ request }, use) => {
66+
const mocks = new ServerMocks(request);
67+
await mocks.reset();
68+
await use(mocks);
69+
await mocks.reset();
70+
},
71+
72+
authedPage: async ({ page }, use) => {
73+
await page.addInitScript(() => {
74+
localStorage.setItem('apiToken', 'test-token');
75+
});
76+
await use(page);
77+
},
78+
79+
dashboard: async ({ authedPage }, use) => {
80+
await use(new DashboardPage(authedPage));
81+
},
82+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { test, expect } from './index.js';
2+
3+
/** Base URL for direct calls to the Nest test server (bypasses the Vite proxy). */
4+
const ENV_URL = 'http://localhost:3002/api/env';
5+
6+
test.describe('ApiTokenGuard', () => {
7+
test('should reject requests with no Authorization header with 401', async ({ request, serverMocks: _ }) => {
8+
void _;
9+
const resp = await request.get(ENV_URL);
10+
expect(resp.status()).toBe(401);
11+
});
12+
13+
test('should reject requests with wrong token with 401', async ({ request, serverMocks: _ }) => {
14+
void _;
15+
const resp = await request.get(ENV_URL, {
16+
headers: { Authorization: 'Bearer wrong-token' },
17+
});
18+
expect(resp.status()).toBe(401);
19+
const body = await resp.json() as { error?: string };
20+
expect(body.error).toBe('invalid bearer token');
21+
});
22+
23+
test('should allow requests with valid Authorization: Bearer header', async ({ request, serverMocks: _ }) => {
24+
void _;
25+
const resp = await request.get(ENV_URL, {
26+
headers: { Authorization: 'Bearer test-token' },
27+
});
28+
expect(resp.status()).toBe(200);
29+
const body = await resp.json() as { region: string };
30+
expect(body.region).toBe('us-east-1');
31+
});
32+
33+
test('should allow requests with valid ?token= query param (SSE fallback)', async ({ request, serverMocks: _ }) => {
34+
void _;
35+
const resp = await request.get(`${ENV_URL}?token=test-token`);
36+
expect(resp.status()).toBe(200);
37+
const body = await resp.json() as { region: string };
38+
expect(body.region).toBe('us-east-1');
39+
});
40+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { test } from './index.js';
2+
3+
/**
4+
* Placeholder for canRun() permission enforcement tests.
5+
*
6+
* TODO(#75): Add integration specs once the Discord module is wired into the
7+
* test server. Each spec should verify that a guild not in `allowedGuilds`, a
8+
* non-admin user, or a user without the required per-game action permission is
9+
* rejected with the appropriate HTTP error.
10+
*/
11+
test.skip('canRun() permission enforcement — pending Discord integration', () => {
12+
// placeholder
13+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { test, expect } from './index.js';
2+
3+
const BASE = 'http://localhost:3002';
4+
const HEADERS = { Authorization: 'Bearer test-token' };
5+
6+
/**
7+
* Verifies that ConfigService correctly reads from the synthetic tfstate fixture
8+
* (`e2e/fixtures/tfstate.fixture.json`) injected via `TF_STATE_PATH` at
9+
* test-server startup.
10+
*/
11+
test.describe('ConfigService — tfstate fixture', () => {
12+
test('should return aws_region and domain from tfstate fixture', async ({ request, serverMocks: _ }) => {
13+
void _;
14+
const resp = await request.get(`${BASE}/api/env`, { headers: HEADERS });
15+
expect(resp.status()).toBe(200);
16+
const body = await resp.json() as { region: string; domain: string; environment: string };
17+
expect(body.region).toBe('us-east-1');
18+
expect(body.domain).toBe('test.example.com');
19+
// 'PROD' is derived when domain_name is non-empty
20+
expect(body.environment).toBe('PROD');
21+
});
22+
23+
test('should return game names from tfstate fixture', async ({ request, serverMocks: _ }) => {
24+
void _;
25+
const resp = await request.get(`${BASE}/api/games`, { headers: HEADERS });
26+
expect(resp.status()).toBe(200);
27+
const body = await resp.json() as { games: string[] };
28+
expect(body.games).toEqual(['minecraft', 'valheim']);
29+
});
30+
31+
test('should return status entries for all games in tfstate fixture', async ({ request, serverMocks: _ }) => {
32+
void _;
33+
const resp = await request.get(`${BASE}/api/status`, { headers: HEADERS });
34+
expect(resp.status()).toBe(200);
35+
const statuses = await resp.json() as Array<{ game: string; state: string }>;
36+
// Default mock state — no queued ListTasks responses → empty taskArns → stopped
37+
expect(statuses.map((s) => s.game).sort()).toEqual(['minecraft', 'valheim']);
38+
statuses.forEach((s) => expect(s.state).toBe('stopped'));
39+
});
40+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { test, expect } from './index.js';
2+
3+
const BASE = 'http://localhost:3002';
4+
const HEADERS = { Authorization: 'Bearer test-token' };
5+
6+
/**
7+
* Verifies that AWS SDK errors surfaced by the mock store propagate through
8+
* the Nest server as structured `{ success: false, message }` responses rather
9+
* than crashing the server or returning an HTTP error status.
10+
*
11+
* The mock interceptor in `test-main.ts` throws an Error with the `name` field
12+
* set to the `code` value, mirroring the real AWS SDK exception shape. Nest's
13+
* `EcsService.start()` catch block converts that to the message string via
14+
* `String(err)` → `"<name>: <message>"`.
15+
*/
16+
test.describe('Error propagation', () => {
17+
test('should surface RunTask AccessDeniedException as a failed start response', async ({
18+
request,
19+
serverMocks,
20+
}) => {
21+
await serverMocks.pushRunTask({
22+
type: 'error',
23+
code: 'AccessDeniedException',
24+
message: 'User is not authorized to perform ecs:RunTask',
25+
});
26+
27+
// findRunningTask() uses the default empty-queue ListTasks response (no
28+
// existing task), so start() proceeds to RunTask where the error fires.
29+
const resp = await request.post(`${BASE}/api/start/minecraft`, { headers: HEADERS });
30+
31+
// Nest POST routes return 201 by default — the error is encoded in the body.
32+
expect(resp.status()).toBe(201);
33+
const body = await resp.json() as { success: boolean; message: string };
34+
expect(body.success).toBe(false);
35+
expect(body.message).toContain('AccessDeniedException');
36+
});
37+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Re-exports for integration specs. Import `test` and `expect` from here
3+
* rather than from `@playwright/test` directly — `test` includes the
4+
* `serverMocks`, `authedPage`, and `dashboard` fixtures that every integration
5+
* spec needs.
6+
*/
7+
export { test, type MockResponse, ServerMocks } from '../fixtures/server-mocks.js';
8+
export { expect } from '@playwright/test';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { test, expect } from './index.js';
2+
3+
/**
4+
* ECS task ARN used as the mock "running task" across start/stop tests.
5+
* The value itself doesn't matter — just needs to be a non-empty string.
6+
*/
7+
const TASK_ARN = 'arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc12345';
8+
9+
test.describe('Start / Stop game server (browser)', () => {
10+
/**
11+
* Golden path: the dashboard loads, polls the real Nest server, and renders
12+
* both games from the tfstate fixture as STOPPED (default mock behaviour —
13+
* empty ListTasks queue → taskArns [] → stopped).
14+
*/
15+
test('should display game cards from tfstate and show STOPPED status on initial load', async ({
16+
dashboard,
17+
serverMocks: _,
18+
}) => {
19+
void _;
20+
await dashboard.goto();
21+
await expect(dashboard.gameCardHeading('minecraft')).toBeVisible();
22+
await expect(dashboard.gameCardHeading('valheim')).toBeVisible();
23+
// At least one STOPPED badge must be visible (both games are stopped)
24+
await expect(dashboard.statusBadge('STOPPED').first()).toBeVisible();
25+
});
26+
27+
/**
28+
* Seeds one game as RUNNING (one ListTasks response consumed by whichever
29+
* game's status call executes first), then verifies that exactly one Stop
30+
* button is rendered and that clicking it opens the confirm dialog.
31+
*
32+
* Two games call ListTasksCommand concurrently; the first dequeues the
33+
* RUNNING ARN and the second falls through to the default (no task → stopped).
34+
* The result is always one RUNNING + one STOPPED card, with exactly one Stop
35+
* button visible.
36+
*/
37+
test('should show confirm dialog when Stop is clicked on a running game', async ({
38+
dashboard,
39+
serverMocks,
40+
}) => {
41+
await serverMocks.pushListTasks({
42+
type: 'success',
43+
data: { taskArns: [TASK_ARN] },
44+
});
45+
await serverMocks.pushDescribeTasks({
46+
type: 'success',
47+
data: { tasks: [{ taskArn: TASK_ARN, lastStatus: 'RUNNING' }] },
48+
});
49+
50+
await dashboard.goto();
51+
52+
// One game is running — its Stop button is the only one on the page
53+
await expect(dashboard.stopButton()).toBeVisible();
54+
await dashboard.stopButton().click();
55+
56+
// Confirmation dialog must appear with the game name in the heading.
57+
// Radix AlertDialog renders with role="alertdialog", not "dialog".
58+
await expect(dashboard.page.getByRole('alertdialog')).toBeVisible();
59+
await expect(dashboard.page.getByRole('heading', { name: /Stop .+\?/ })).toBeVisible();
60+
});
61+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { test, expect } from './index.js';
2+
3+
const TASK_ARN = 'arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc12345';
4+
5+
/**
6+
* Verifies that the dashboard's status poller picks up AWS state changes
7+
* without a page reload. The integration build sets VITE_STATUS_POLL_MS=3000
8+
* so the test can push new mock responses after the initial load and observe
9+
* the badge transition within a generous timeout.
10+
*/
11+
test.describe('Status polling', () => {
12+
test('should update game badge from STOPPED to RUNNING after mock state changes', async ({
13+
dashboard,
14+
serverMocks,
15+
}) => {
16+
// Navigate — initial poll fires immediately on mount (default: no tasks → stopped)
17+
await dashboard.goto();
18+
await expect(dashboard.statusBadge('STOPPED').first()).toBeVisible();
19+
20+
// Push 2 RUNNING responses — one per game in the next concurrent poll
21+
for (let i = 0; i < 2; i++) {
22+
await serverMocks.pushListTasks({
23+
type: 'success',
24+
data: { taskArns: [TASK_ARN] },
25+
});
26+
await serverMocks.pushDescribeTasks({
27+
type: 'success',
28+
data: { tasks: [{ taskArn: TASK_ARN, lastStatus: 'RUNNING' }] },
29+
});
30+
}
31+
32+
// The next poll (≤3 s) consumes the RUNNING responses and updates the badges
33+
await expect(dashboard.statusBadge('RUNNING').first()).toBeVisible({ timeout: 10_000 });
34+
});
35+
});

0 commit comments

Comments
 (0)