Skip to content

Commit bd89b17

Browse files
CoderCococlaude
andauthored
test(web): Playwright e2e harness with auth-gate and dashboard specs (#76)
Closes #74 ## Summary Adds a Playwright e2e harness for `@gsd/web` that runs against the production build (`vite build` + `vite preview`) with every `/api/*` call stubbed at the network layer. The Nest server is never started — this is the **stubs-only** tier (#74); the full-stack tier with a real Nest server lands separately in #75. - New scripts: `npm run app:test:e2e` (root) → `playwright test` in `@gsd/web`. - New CI job (`.github/workflows/e2e.yml`) installs the Chromium browser with system deps, runs the suite on every PR, and uploads `playwright-report/` (traces + videos) as an artifact on failure. - Co-located fixtures and stubs under `app/packages/web/e2e/fixtures/`; specs under `e2e/specs/`. - A custom `authedPage` Playwright fixture pre-seeds `localStorage.apiToken` so most specs start authenticated; auth-gate specs use the base `test` to exercise the 401-trigger path. - A catch-all `page.route('**/api/**')` returns 404 for any endpoint not explicitly stubbed so missing stubs surface as fast test failures rather than hangs. ## Spec coverage delivered with this PR **Auth gate** (`auth-gate.spec.ts`): - `should show token modal when API returns 401` - `should load dashboard when valid token is already stored` - `should save token and show dashboard after reload` **Dashboard** (`dashboard.spec.ts`): - `should render a game card for a stopped game` / `for a running game with IP` - `should render multiple game cards` - `should show empty-state message when no games are configured` - `should fire POST /api/start/:game when Start is clicked` - `should disable Start button for a running game` - Sidebar nav specs for `/logs`, `/discord`, `/settings` Per-surface specs (Costs panel, Stop confirmation, polling indicator, etc.) follow with their respective UI issues (#60#68) — the harness is now in place to plug them in. ## Acceptance criteria from #74 - [x] `npm run app:test:e2e` runs the suite locally and in CI against the production build. - [x] CI fails the PR when a spec breaks; traces + videos uploaded as artifacts on failure. - [x] At least the auth-gate and dashboard Start specs land with this issue. - [x] `docs/docs/components/management-app.md` gains a \"Running e2e tests\" section. - [x] No real AWS / Discord calls — every external request is stubbed. - [x] `npm run app:test` continues to pass; e2e suite runs as a separate command. - [x] `CLAUDE.md`'s \"Code & Test Conventions\" section now describes the two-tier strategy (#74 stubs vs #75 real Nest) and when to add specs to each tier. ## Implementation notes - Chose **Playwright** over Vitest browser mode (per the issue's open question) for the trace viewer, parallel projects, and first-class CI artifacts story. Vitest unit tests remain on Vitest — only browser-driven specs use Playwright. - The webServer command is `npm run build && npm run preview`; Playwright polls until `http://localhost:4173` responds. `reuseExistingServer` is true locally so re-runs are fast if `vite preview` is already running. - Cleanup: added `app/packages/web/test-results/`, `playwright-report/`, and `playwright/.cache/` to `.gitignore` so local artifacts don't get committed. ## Test plan - [ ] CI: e2e workflow runs and all 12 specs pass (Chromium, Ubuntu). - [ ] Local: `sudo npx playwright install-deps chromium` once, then `npm run app:test:e2e` from repo root passes. - [ ] `npm run app:test` still passes (the unit-test suite is untouched). - [ ] Reviewer can open `app/packages/web/playwright-report/index.html` after a local run to inspect traces/videos. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 65b7ab6 commit bd89b17

13 files changed

Lines changed: 595 additions & 83 deletions

File tree

.github/workflows/e2e.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: E2E Tests
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
e2e:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v5
16+
17+
- uses: actions/setup-node@v5
18+
with:
19+
node-version: '24'
20+
cache: npm
21+
cache-dependency-path: package-lock.json
22+
23+
- run: npm ci
24+
25+
- name: Install Playwright browsers
26+
run: npx playwright install chromium --with-deps
27+
working-directory: app/packages/web
28+
29+
- run: npm run app:test:e2e
30+
31+
- uses: actions/upload-artifact@v4
32+
if: failure()
33+
with:
34+
name: playwright-report
35+
path: app/packages/web/playwright-report/
36+
retention-days: 30

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,9 @@ Thumbs.db
3434
# be committed (contains AWS ARNs/account IDs).
3535
app/packages/server/src/generated/tfstate.ts
3636

37+
# Playwright artifacts (traces, videos, screenshots) and HTML report.
38+
app/packages/web/test-results/
39+
app/packages/web/playwright-report/
40+
app/packages/web/playwright/.cache/
41+
3742
.make

CLAUDE.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,29 @@ Any time you add or remove Terraform variables, update **all four** of these in
154154

155155
## Code & Test Conventions
156156

157-
- **Test names**: every `it(...)` case must read as a natural-language sentence starting with "should" — e.g. `it('should return null when state file is missing')`, not `it('returns null...')`.
157+
- **Test names**: every `it(...)` / `test(...)` case must read as a natural-language sentence starting with "should" — e.g. `it('should return null when state file is missing')`, not `it('returns null...')`.
158158
- **TSDoc comments**: document non-trivial functions, helpers, and notable constants/variables with TSDoc (`/** ... */`) so their intent is clear later. This applies to test-file helpers (stub factories, fixtures) as well as production code.
159159
- **Typing in tests**: avoid `as unknown as SomeType` casts. Prefer `vi.mocked(fn)` for mocked modules and `Partial<T>` + a single `as T` for service-shaped stubs.
160160
- **No raw `process.env` in business logic**: wrap environment access behind a service method so tests can stub it via `vi.spyOn` instead of mutating `process.env` (which is flaky and leaks across tests).
161161

162+
### Two-tier browser testing strategy
163+
164+
The test suite has two complementary tiers:
165+
166+
| Tier | Command | What runs | When to add specs |
167+
|------|---------|-----------|-------------------|
168+
| **Unit / integration** | `npm run app:test` | Vitest — server-side logic, hooks, helpers. No browser, no real network. AWS SDK mocked via `aws-sdk-client-mock`. | Pure logic, hook behaviour, server controllers. |
169+
| **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.
172+
173+
**Playwright conventions:**
174+
- Specs live under `app/packages/web/e2e/specs/`.
175+
- Shared stub helpers and fixtures are in `app/packages/web/e2e/fixtures/`.
176+
- Import `{ test, expect, stubApis }` from `../fixtures/index.js` for authenticated specs; use `@playwright/test` directly for auth-gate specs.
177+
- Use the `authedPage` fixture (token pre-seeded in localStorage) for any spec that is not testing the auth flow itself.
178+
- Stubs must cover every `/api/*` endpoint the page hits, or the catch-all returns 404 and the test will surface the missing stub quickly.
179+
162180
## Git & Branch Workflow
163181

164182
`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`.

app/eslint.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export default tseslint.config(
4949
},
5050
{
5151
files: ['packages/web/**/*.{ts,tsx}'],
52+
ignores: ['packages/web/e2e/**'],
5253
...react.configs.flat.recommended,
5354
languageOptions: {
5455
...react.configs.flat.recommended.languageOptions,
@@ -62,16 +63,18 @@ export default tseslint.config(
6263
},
6364
{
6465
files: ['packages/web/**/*.{ts,tsx}'],
66+
ignores: ['packages/web/e2e/**'],
6567
...react.configs.flat['jsx-runtime'],
6668
},
6769
{
6870
files: ['packages/web/**/*.{ts,tsx}'],
71+
ignores: ['packages/web/e2e/**'],
6972
plugins: { 'react-hooks': reactHooks },
7073
rules: reactHooks.configs.recommended.rules,
7174
},
7275
{
73-
// TypeScript already enforces prop types; disable the runtime-only rule.
7476
files: ['packages/web/**/*.{ts,tsx}'],
77+
ignores: ['packages/web/e2e/**'],
7578
rules: { 'react/prop-types': 'off' },
7679
},
7780
{
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig } from '../../src/api.js';
2+
3+
/** Stub response for `GET /api/env`. */
4+
export const ENV_DATA: EnvInfo = {
5+
region: 'us-east-1',
6+
domain: 'example.com',
7+
environment: 'dev',
8+
};
9+
10+
/** A single stopped game — use as the default single-game fixture. */
11+
export const STOPPED_GAME: GameStatus = {
12+
game: 'minecraft',
13+
state: 'stopped',
14+
};
15+
16+
/** A single running game with a public IP. */
17+
export const RUNNING_GAME: GameStatus = {
18+
game: 'minecraft',
19+
state: 'running',
20+
publicIp: '1.2.3.4',
21+
hostname: 'minecraft.example.com',
22+
taskArn: 'arn:aws:ecs:us-east-1:123:task/minecraft/abc',
23+
};
24+
25+
/** Two-game fixture covering stopped + running states. */
26+
export const MULTI_GAME_STATUSES: GameStatus[] = [
27+
STOPPED_GAME,
28+
{ game: 'valheim', state: 'running', publicIp: '5.6.7.8' },
29+
];
30+
31+
/** Stub response for `GET /api/config` (the watchdog tuning panel). */
32+
export const WATCHDOG_CONFIG: WatchdogConfig = {
33+
watchdog_interval_minutes: 15,
34+
watchdog_idle_checks: 4,
35+
watchdog_min_packets: 100,
36+
};
37+
38+
/** Stub response for `GET /api/costs/estimate`. */
39+
export const COST_DATA: CostEstimates = {
40+
games: {
41+
minecraft: {
42+
vcpu: 1,
43+
memoryGb: 2,
44+
costPerHour: 0.08,
45+
costPerDay24h: 1.92,
46+
costPerMonth4hpd: 9.6,
47+
},
48+
},
49+
totalPerHourIfAllOn: 0.08,
50+
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { test as base, type Page } from '@playwright/test';
2+
import type { GameStatus, CostEstimates, EnvInfo, ActionResult, WatchdogConfig } from '../../src/api.js';
3+
import { ENV_DATA, STOPPED_GAME, COST_DATA, WATCHDOG_CONFIG } from './game-data.js';
4+
5+
export type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig };
6+
export { ENV_DATA, STOPPED_GAME, RUNNING_GAME, MULTI_GAME_STATUSES, COST_DATA, WATCHDOG_CONFIG } from './game-data.js';
7+
8+
/** Per-spec overrides for the default `/api/*` stubs registered by `stubApis`. */
9+
export interface StubOptions {
10+
/** Game statuses returned by `GET /api/status`. Defaults to `[STOPPED_GAME]`. */
11+
statuses?: GameStatus[];
12+
/** Cost estimates returned by `GET /api/costs/estimate`. */
13+
costs?: CostEstimates;
14+
/** Env info returned by `GET /api/env`. */
15+
env?: EnvInfo;
16+
/** Watchdog config returned by `GET /api/config`. */
17+
config?: WatchdogConfig;
18+
/** Override for `POST /api/start/:game` response. */
19+
startResult?: ActionResult;
20+
}
21+
22+
/**
23+
* Registers Playwright route intercepts for all `/api/*` endpoints used by the
24+
* dashboard. Call before `page.goto()` in each spec that needs a running UI.
25+
*
26+
* Playwright matches routes in REVERSE registration order (last-registered
27+
* wins), so we register the catch-all FIRST and the specific stubs after —
28+
* that way `/api/status` hits the specific handler, while `/api/anything-else`
29+
* falls through to the catch-all 404 so missing stubs surface as fast failures
30+
* instead of hangs.
31+
*/
32+
export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void> {
33+
const statuses = opts.statuses ?? [STOPPED_GAME];
34+
const costs = opts.costs ?? COST_DATA;
35+
const env = opts.env ?? ENV_DATA;
36+
const config = opts.config ?? WATCHDOG_CONFIG;
37+
const startResult: ActionResult = opts.startResult ?? { success: true, message: 'Started' };
38+
39+
await page.route('**/api/**', (route) =>
40+
route.fulfill({ status: 404, json: { error: 'not stubbed' } })
41+
);
42+
43+
await page.route('**/api/env', (route) => route.fulfill({ json: env }));
44+
45+
await page.route('**/api/status', (route) => route.fulfill({ json: statuses }));
46+
47+
await page.route('**/api/status/*', (route) => {
48+
const game = new URL(route.request().url()).pathname.split('/').pop()!;
49+
const s = statuses.find((x) => x.game === game) ?? statuses[0];
50+
return route.fulfill({ json: s });
51+
});
52+
53+
await page.route('**/api/costs/estimate', (route) => route.fulfill({ json: costs }));
54+
55+
await page.route('**/api/config', (route) => {
56+
if (route.request().method() === 'POST') {
57+
return route.fulfill({ json: { success: true, config } });
58+
}
59+
return route.fulfill({ json: config });
60+
});
61+
62+
await page.route('**/api/start/*', (route) => route.fulfill({ json: startResult }));
63+
64+
await page.route('**/api/stop/*', (route) =>
65+
route.fulfill({ json: { success: true, message: 'Stopped' } as ActionResult })
66+
);
67+
}
68+
69+
type E2EFixtures = {
70+
/**
71+
* A page with `apiToken` pre-seeded in localStorage so every navigation
72+
* starts authenticated. Use this in all specs except auth-gate tests.
73+
*/
74+
authedPage: Page;
75+
};
76+
77+
export const test = base.extend<E2EFixtures>({
78+
authedPage: async ({ page }, use) => {
79+
await page.addInitScript(() => {
80+
localStorage.setItem('apiToken', 'test-token');
81+
});
82+
await use(page);
83+
},
84+
});
85+
86+
export { expect } from '@playwright/test';
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { test, expect } from '@playwright/test';
2+
import { stubApis, ENV_DATA, COST_DATA } from '../fixtures/index.js';
3+
4+
/**
5+
* Auth-gate specs use the base Playwright `test` (no pre-seeded token) so
6+
* they can verify the 401 → modal and token-save → reload flows in isolation.
7+
*/
8+
9+
test.describe('auth gate', () => {
10+
test('should show token modal when API returns 401', async ({ page }) => {
11+
await page.route('**/api/**', (route) =>
12+
route.fulfill({ status: 401, body: 'Unauthorized' })
13+
);
14+
await page.goto('/');
15+
await expect(page.getByRole('heading', { name: 'API token required' })).toBeVisible();
16+
});
17+
18+
test('should load dashboard when valid token is already stored', async ({ page }) => {
19+
await page.addInitScript(() => {
20+
localStorage.setItem('apiToken', 'test-token');
21+
});
22+
await stubApis(page, { statuses: [] });
23+
await page.goto('/');
24+
// Top-bar heading is the dashboard shell; modal should not appear.
25+
await expect(page.getByRole('heading', { name: 'Game Server Manager' })).toBeVisible();
26+
await expect(page.getByRole('heading', { name: 'API token required' })).not.toBeVisible();
27+
});
28+
29+
test('should save token and show dashboard after reload', async ({ page }) => {
30+
// Playwright matches routes in REVERSE registration order, so register the
31+
// catch-all 404 FIRST and the specific 401/200 handlers after — otherwise
32+
// the catch-all takes precedence and the modal never triggers.
33+
await page.route('**/api/**', (route) =>
34+
route.fulfill({ status: 404, json: { error: 'not stubbed' } })
35+
);
36+
// Return 401 for unauthenticated requests, 200 once the token is present.
37+
await page.route('**/api/env', async (route) => {
38+
const auth = route.request().headers()['authorization'] ?? '';
39+
if (auth.startsWith('Bearer ')) {
40+
await route.fulfill({ json: ENV_DATA });
41+
} else {
42+
await route.fulfill({ status: 401, body: 'Unauthorized' });
43+
}
44+
});
45+
await page.route('**/api/status', async (route) => {
46+
const auth = route.request().headers()['authorization'] ?? '';
47+
if (auth.startsWith('Bearer ')) {
48+
await route.fulfill({ json: [] });
49+
} else {
50+
await route.fulfill({ status: 401, body: 'Unauthorized' });
51+
}
52+
});
53+
await page.route('**/api/costs/estimate', async (route) => {
54+
const auth = route.request().headers()['authorization'] ?? '';
55+
if (auth.startsWith('Bearer ')) {
56+
await route.fulfill({ json: COST_DATA });
57+
} else {
58+
await route.fulfill({ status: 401, body: 'Unauthorized' });
59+
}
60+
});
61+
62+
await page.goto('/');
63+
await expect(page.getByRole('heading', { name: 'API token required' })).toBeVisible();
64+
65+
await page.getByPlaceholder('API token').fill('my-test-token');
66+
await page.getByRole('button', { name: 'Save & reload' }).click();
67+
68+
// After reload the stored token is sent; the modal must be gone.
69+
await expect(page.getByRole('heading', { name: 'Game Server Manager' })).toBeVisible();
70+
await expect(page.getByRole('heading', { name: 'API token required' })).not.toBeVisible();
71+
});
72+
});

0 commit comments

Comments
 (0)