Skip to content

Commit bf4978c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-64-9QSsY
# Conflicts: # app/packages/web/e2e/specs/auth-gate.spec.ts
2 parents 8c1382e + bd51068 commit bf4978c

32 files changed

Lines changed: 5027 additions & 966 deletions

CLAUDE.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,16 +165,24 @@ The test suite has two complementary tiers:
165165

166166
| Tier | Command | What runs | When to add specs |
167167
|------|---------|-----------|-------------------|
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. |
168+
| **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 networkAWS 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). |
169169
| **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. |
170170

171171
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.
172172

173+
**React component unit tests (`@gsd/web`):**
174+
- 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`.
175+
- `app/vitest.config.ts` flips to the `jsdom` environment for everything under `packages/web/**` so server tests stay on Node. The setup file also wires `afterEach(cleanup)` because we run with `globals: false`, which disables RTL's normal auto-cleanup.
176+
- Tests live **next to the component** (`Foo.tsx``Foo.test.tsx`), not in a separate `__tests__` directory. Mock the API client and any module-level singletons via `vi.mock`. Use `vi.stubGlobal('EventSource', …)` for SSE-driven components (jsdom doesn't ship one).
177+
- Cover: visible rendering for each `state` branch, every callback prop firing with the right argument, internal state transitions (open/close, pause/resume), and any non-trivial pure helper. Don't reach for snapshots — they break on every Tailwind tweak. Don't repeat assertions already covered by the e2e tier (full SSE streaming flow, real network, full routing).
178+
173179
**Playwright conventions:**
174180
- Specs live under `app/packages/web/e2e/specs/`.
175181
- 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.
182+
- **Page objects** for each route live in `app/packages/web/e2e/pages/` (`DashboardPage`, `CostsPage`, `LogsPage`, etc.). Specs **must** reach for elements through a page-object method (`logs.pauseButton()`, `dashboard.gameCardHeading('minecraft')`) rather than calling `page.getByX(...)` directly. The page object centralises locator drift, gives spec files a vocabulary that reads like the feature it's testing, and is the only place locator strategy (role + accessible name vs. text vs. test-id) gets decided. Add a new page object whenever a spec wants a locator that isn't already wrapped.
183+
- Each page object receives a `Page` in its constructor and exposes `goto()` plus typed `Locator`-returning methods. Encapsulate multi-step flows (`dashboard.filter(query)`, `logs.toggleLevel('ERROR')`) so individual specs stay one-liners.
184+
- Import `{ test, expect, stubApis }` plus the page-object fixture you need (`logs`, `dashboard`, `costs`, `layout`, `authGate`) from `../fixtures/index.js` for authenticated specs; use `@playwright/test` directly for auth-gate specs.
185+
- 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.
178186
- Stubs must cover every `/api/*` endpoint the page hits, or the catch-all returns 404 and the test will surface the missing stub quickly.
179187

180188
## Git & Branch Workflow
@@ -187,6 +195,22 @@ Use `.worktrees/<branch-name>` for feature work (the directory is gitignored). C
187195
git worktree add .worktrees/<branch> -b <branch>
188196
```
189197

198+
## Claude Code Plugins
199+
200+
This repo expects the **`issue-flow`** plugin (from `CoderCoco/claude-plugin-marketplace`) to drive the issue → PR loop. Two skills, used in order:
201+
202+
- **`work-on`** — start work on a GitHub issue. Creates the `claude/issue-<N>-<slug>` branch, scaffolds a worktree, and pulls the issue checklist into the session as the source of truth for what "done" means.
203+
- **`open-pr`** — finish the loop. Verifies the issue checklist is actually complete, picks up the repo's PR conventions (the rules in the next section), opens the PR with the right `Closes #N` keyword, and moves the project card to "In Review".
204+
205+
Install once:
206+
207+
```
208+
/plugin marketplace add CoderCoco/claude-plugin-marketplace
209+
/plugin install issue-flow@claude-plugin-marketplace
210+
```
211+
212+
If the plugin isn't loaded in the current environment, fetch the skill body from the marketplace repo and follow it manually — don't fall back to ad-hoc PR creation, because the skills enforce checklist-completeness and the closing keyword that this repo's `/pr` command takes for granted.
213+
190214
## PR Conventions
191215

192216
- **Always use `/pr` to create pull requests.** The `.claude/commands/pr.md` skill validates the title format before calling the API. Never call `mcp__github__create_pull_request` directly without running this check first.

app/packages/web/e2e/fixtures/game-data.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig } from '../../src/api.js';
1+
import type {
2+
GameStatus,
3+
CostEstimates,
4+
EnvInfo,
5+
WatchdogConfig,
6+
ActualCosts,
7+
DiscordConfigRedacted,
8+
} from '@/api.js';
29

310
/** Stub response for `GET /api/env`. */
411
export const ENV_DATA: EnvInfo = {
@@ -48,3 +55,115 @@ export const COST_DATA: CostEstimates = {
4855
},
4956
totalPerHourIfAllOn: 0.08,
5057
};
58+
59+
/** Multi-game estimates fixture for sort / filter specs on the Costs page. */
60+
export const MULTI_GAME_COST_DATA: CostEstimates = {
61+
games: {
62+
minecraft: {
63+
vcpu: 1,
64+
memoryGb: 2,
65+
costPerHour: 0.08,
66+
costPerDay24h: 1.92,
67+
costPerMonth4hpd: 9.6,
68+
},
69+
valheim: {
70+
vcpu: 2,
71+
memoryGb: 4,
72+
costPerHour: 0.16,
73+
costPerDay24h: 3.84,
74+
costPerMonth4hpd: 19.2,
75+
},
76+
palworld: {
77+
vcpu: 4,
78+
memoryGb: 8,
79+
costPerHour: 0.32,
80+
costPerDay24h: 7.68,
81+
costPerMonth4hpd: 38.4,
82+
},
83+
},
84+
totalPerHourIfAllOn: 0.56,
85+
};
86+
87+
/** Stub response for `GET /api/costs/actual` — 7 days of synthetic spend used by the KPI sparklines. */
88+
export const ACTUAL_COSTS: ActualCosts = {
89+
daily: [
90+
{ date: '2026-04-26', cost: 0.42 },
91+
{ date: '2026-04-27', cost: 0.31 },
92+
{ date: '2026-04-28', cost: 0.55 },
93+
{ date: '2026-04-29', cost: 0.18 },
94+
{ date: '2026-04-30', cost: 0.27 },
95+
{ date: '2026-05-01', cost: 0.40 },
96+
{ date: '2026-05-02', cost: 0.35 },
97+
],
98+
total: 2.48,
99+
currency: 'USD',
100+
days: 7,
101+
};
102+
103+
/**
104+
* Build a deterministic `ActualCosts` payload with `days` daily entries.
105+
* The first half of the window costs $0.50/day and the second half costs
106+
* $1.00/day, so the Costs page renders a non-zero delta-vs-prior pill when
107+
* the page fetches both `days=7` (current) and `days=14` (prior) windows
108+
* from the same stub.
109+
*/
110+
export function makeActualCosts(days: number): ActualCosts {
111+
const daily = Array.from({ length: days }, (_, i) => ({
112+
date: `2026-04-${String((i % 30) + 1).padStart(2, '0')}`,
113+
cost: i < days / 2 ? 0.5 : 1.0,
114+
}));
115+
const total = daily.reduce((sum, d) => sum + d.cost, 0);
116+
return { daily, total: Math.round(total * 100) / 100, currency: 'USD', days };
117+
}
118+
119+
/** A valid Discord snowflake (17–20 digits) for use in test inputs. */
120+
export const VALID_GUILD_ID = '123456789012345678';
121+
/** A second valid snowflake — useful for multi-guild specs. */
122+
export const VALID_GUILD_ID_2 = '987654321098765432';
123+
/** A valid user-shaped snowflake for admin/permission specs. */
124+
export const VALID_USER_ID = '111122223333444455';
125+
126+
/**
127+
* First-run Discord config — no guilds, no admins, no secrets configured.
128+
* Triggers the `/discord` setup-wizard render path.
129+
*/
130+
export const FIRST_RUN_DISCORD_CONFIG: DiscordConfigRedacted = {
131+
clientId: '',
132+
allowedGuilds: [],
133+
admins: { userIds: [], roleIds: [] },
134+
gamePermissions: {},
135+
baseAllowedGuilds: [],
136+
baseAdmins: { userIds: [], roleIds: [] },
137+
botTokenSet: false,
138+
publicKeySet: false,
139+
interactionsEndpointUrl: null,
140+
};
141+
142+
/**
143+
* Fully-configured Discord config — bot token + public key set, one allowlisted
144+
* guild, one admin user. Used to exercise the post-setup tabs.
145+
*/
146+
export const CONFIGURED_DISCORD_CONFIG: DiscordConfigRedacted = {
147+
clientId: '111111111111111111',
148+
allowedGuilds: [VALID_GUILD_ID],
149+
admins: { userIds: [VALID_USER_ID], roleIds: [] },
150+
gamePermissions: {},
151+
baseAllowedGuilds: [],
152+
baseAdmins: { userIds: [], roleIds: [] },
153+
botTokenSet: true,
154+
publicKeySet: true,
155+
interactionsEndpointUrl: 'https://abc123.lambda-url.us-east-1.on.aws/',
156+
};
157+
158+
/**
159+
* A handful of CloudWatch lines with mixed log levels — used by the LogsPage
160+
* specs to exercise level-badge detection, search highlighting, and the
161+
* Levels filter.
162+
*/
163+
export const SAMPLE_LOG_LINES: string[] = [
164+
'2026-05-03T12:00:00Z INFO Server started on port 25565',
165+
'2026-05-03T12:00:01Z DEBUG Loaded world "world" in 1.2s',
166+
'2026-05-03T12:00:02Z WARN Deprecated config option "max-tick-time"',
167+
'2026-05-03T12:00:03Z ERROR Connection refused from 10.0.0.5',
168+
'2026-05-03T12:00:04Z Player joined the game',
169+
];

app/packages/web/e2e/fixtures/index.ts

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,90 @@
11
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';
2+
import type {
3+
GameStatus,
4+
CostEstimates,
5+
EnvInfo,
6+
ActionResult,
7+
WatchdogConfig,
8+
ActualCosts,
9+
DiscordConfigRedacted,
10+
} from '@/api.js';
11+
import {
12+
ENV_DATA,
13+
STOPPED_GAME,
14+
COST_DATA,
15+
WATCHDOG_CONFIG,
16+
CONFIGURED_DISCORD_CONFIG,
17+
makeActualCosts,
18+
} from './game-data.js';
19+
import { AppLayout, AuthGatePage, DashboardPage, CostsPage, LogsPage } from '../pages/index.js';
420

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';
21+
export type {
22+
GameStatus,
23+
CostEstimates,
24+
EnvInfo,
25+
WatchdogConfig,
26+
ActualCosts,
27+
DiscordConfigRedacted,
28+
};
29+
export {
30+
ENV_DATA,
31+
STOPPED_GAME,
32+
RUNNING_GAME,
33+
MULTI_GAME_STATUSES,
34+
COST_DATA,
35+
MULTI_GAME_COST_DATA,
36+
WATCHDOG_CONFIG,
37+
ACTUAL_COSTS,
38+
makeActualCosts,
39+
FIRST_RUN_DISCORD_CONFIG,
40+
CONFIGURED_DISCORD_CONFIG,
41+
VALID_GUILD_ID,
42+
VALID_GUILD_ID_2,
43+
VALID_USER_ID,
44+
SAMPLE_LOG_LINES,
45+
} from './game-data.js';
46+
export { AppLayout, AuthGatePage, DashboardPage, CostsPage, LogsPage } from '../pages/index.js';
747

848
/** Per-spec overrides for the default `/api/*` stubs registered by `stubApis`. */
949
export interface StubOptions {
1050
/** Game statuses returned by `GET /api/status`. Defaults to `[STOPPED_GAME]`. */
1151
statuses?: GameStatus[];
1252
/** Cost estimates returned by `GET /api/costs/estimate`. */
1353
costs?: CostEstimates;
54+
/**
55+
* Either a fixed `ActualCosts` payload returned for every `GET /api/costs/actual`
56+
* call, or a builder receiving the `days` query param so a spec can return
57+
* different totals per window (the Costs page calls both `days` and `days*2`).
58+
* Defaults to `makeActualCosts(days)` so the prior-period delta is non-zero.
59+
*/
60+
actualCosts?: ActualCosts | ((days: number) => ActualCosts);
1461
/** Env info returned by `GET /api/env`. */
1562
env?: EnvInfo;
1663
/** Watchdog config returned by `GET /api/config`. */
1764
config?: WatchdogConfig;
1865
/** Override for `POST /api/start/:game` response. */
1966
startResult?: ActionResult;
67+
/**
68+
* Discord config returned by `GET /api/discord/config`. Defaults to
69+
* `CONFIGURED_DISCORD_CONFIG` so non-Discord specs hitting `/discord` (e.g.
70+
* sidebar nav) don't trip the catch-all 404 handler. Pass
71+
* `FIRST_RUN_DISCORD_CONFIG` to exercise the setup wizard.
72+
*/
73+
discord?: DiscordConfigRedacted;
74+
/**
75+
* Game names returned by `GET /api/games` (used by the Logs page).
76+
* Defaults to the names derived from `statuses`. Override when the Logs
77+
* page should expose games that aren't part of `statuses`.
78+
*/
79+
games?: string[];
80+
/**
81+
* Initial log lines returned by `GET /api/logs/:game` (used by the Logs
82+
* page). Maps game name → seeded lines. Games not present in the map
83+
* receive an empty buffer. The SSE stream at `/api/logs/:game/stream` is
84+
* always aborted so EventSource gives up immediately and tests don't hang
85+
* on a never-ending response.
86+
*/
87+
logLines?: Record<string, string[]>;
2088
}
2189

2290
/**
@@ -35,6 +103,15 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
35103
const env = opts.env ?? ENV_DATA;
36104
const config = opts.config ?? WATCHDOG_CONFIG;
37105
const startResult: ActionResult = opts.startResult ?? { success: true, message: 'Started' };
106+
const discord = opts.discord ?? CONFIGURED_DISCORD_CONFIG;
107+
const games = opts.games ?? statuses.map((s) => s.game);
108+
const logLines = opts.logLines ?? {};
109+
const actualCostsFn: (days: number) => ActualCosts =
110+
typeof opts.actualCosts === 'function'
111+
? opts.actualCosts
112+
: opts.actualCosts !== undefined
113+
? () => opts.actualCosts as ActualCosts
114+
: (days) => makeActualCosts(days);
38115

39116
await page.route('**/api/**', (route) =>
40117
route.fulfill({ status: 404, json: { error: 'not stubbed' } })
@@ -50,8 +127,18 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
50127
return route.fulfill({ json: s });
51128
});
52129

130+
await page.route('**/api/games', (route) => route.fulfill({ json: { games } }));
131+
53132
await page.route('**/api/costs/estimate', (route) => route.fulfill({ json: costs }));
54133

134+
// Trailing `*` matches the `?days=N` query string — Playwright globs are
135+
// matched against the full URL, and `*` (= `[^/]*`) covers query payloads
136+
// that never contain a slash.
137+
await page.route('**/api/costs/actual*', (route) => {
138+
const days = parseInt(new URL(route.request().url()).searchParams.get('days') ?? '7', 10);
139+
return route.fulfill({ json: actualCostsFn(days) });
140+
});
141+
55142
await page.route('**/api/config', (route) => {
56143
if (route.request().method() === 'POST') {
57144
return route.fulfill({ json: { success: true, config } });
@@ -64,14 +151,61 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
64151
await page.route('**/api/stop/*', (route) =>
65152
route.fulfill({ json: { success: true, message: 'Stopped' } as ActionResult })
66153
);
154+
155+
// Discord — read endpoint plus permissive write endpoints. Specs that need
156+
// to assert request bodies should override these with their own page.route().
157+
await page.route('**/api/discord/config', (route) => {
158+
if (route.request().method() === 'PUT') {
159+
return route.fulfill({ json: { success: true, config: discord } });
160+
}
161+
return route.fulfill({ json: discord });
162+
});
163+
await page.route('**/api/discord/guilds', (route) =>
164+
route.fulfill({ json: { success: true, guilds: discord.allowedGuilds } }),
165+
);
166+
await page.route('**/api/discord/guilds/*', (route) =>
167+
route.fulfill({ json: { success: true, guilds: discord.allowedGuilds } }),
168+
);
169+
await page.route('**/api/discord/guilds/*/register-commands', (route) =>
170+
route.fulfill({ json: { success: true, message: 'Registered' } }),
171+
);
172+
await page.route('**/api/discord/admins', (route) =>
173+
route.fulfill({ json: { success: true, admins: discord.admins } }),
174+
);
175+
await page.route('**/api/discord/permissions/*', (route) =>
176+
route.fulfill({ json: { success: true, permissions: discord.gamePermissions } }),
177+
);
178+
179+
// Logs page — the SSE stream is aborted so EventSource gives up immediately.
180+
// Specs that need to drive the stream can override this route after stubApis().
181+
// The `/stream*` glob is registered AFTER `/api/logs/*` so it wins for SSE
182+
// URLs (Playwright matches routes in reverse registration order).
183+
await page.route('**/api/logs/*', (route) => {
184+
const url = new URL(route.request().url());
185+
const game = url.pathname.split('/').pop()!;
186+
return route.fulfill({ json: { game, lines: logLines[game] ?? [] } });
187+
});
188+
await page.route('**/api/logs/*/stream*', (route) => route.abort());
67189
}
68190

69191
type E2EFixtures = {
70192
/**
71193
* A page with `apiToken` pre-seeded in localStorage so every navigation
72-
* starts authenticated. Use this in all specs except auth-gate tests.
194+
* starts authenticated. Use this in specs that need raw page access (e.g.
195+
* to call `stubApis` or `addInitScript`); prefer `dashboard` / `costs` /
196+
* `layout` for higher-level interactions.
73197
*/
74198
authedPage: Page;
199+
/** Page object for the dashboard route — use in any authed-dashboard spec. */
200+
dashboard: DashboardPage;
201+
/** Page object for the `/costs` route — use in any authed-costs spec. */
202+
costs: CostsPage;
203+
/** Page object for the `/logs` route — use in any authed-logs spec. */
204+
logs: LogsPage;
205+
/** Page object for the persistent nav shell (sidebar + top bar). */
206+
layout: AppLayout;
207+
/** Page object for the API-token modal — use in auth-gate specs. */
208+
authGate: AuthGatePage;
75209
};
76210

77211
export const test = base.extend<E2EFixtures>({
@@ -81,6 +215,25 @@ export const test = base.extend<E2EFixtures>({
81215
});
82216
await use(page);
83217
},
218+
// `dashboard` and `costs` depend on `authedPage` because every authed-route
219+
// spec wants the token pre-seeded. `layout` and `authGate` depend on the raw
220+
// `page` so auth-gate specs (which exercise the unauthenticated state) can
221+
// use them without dragging the init script along.
222+
dashboard: async ({ authedPage }, use) => {
223+
await use(new DashboardPage(authedPage));
224+
},
225+
costs: async ({ authedPage }, use) => {
226+
await use(new CostsPage(authedPage));
227+
},
228+
logs: async ({ authedPage }, use) => {
229+
await use(new LogsPage(authedPage));
230+
},
231+
layout: async ({ page }, use) => {
232+
await use(new AppLayout(page));
233+
},
234+
authGate: async ({ page }, use) => {
235+
await use(new AuthGatePage(page));
236+
},
84237
});
85238

86239
export { expect } from '@playwright/test';

0 commit comments

Comments
 (0)