Skip to content

Commit bd51068

Browse files
CoderCococlaude
andauthored
feat(web): redesign /logs with LIVE/PAUSED, search, autoscroll (#116)
Closes #63 Promote the bottom-of-dashboard log tail into a dedicated `/logs` route with the ops-dashboard treatment: - LIVE/PAUSED status pill (pulsing cyan / muted slate) replaces the bare Pause button label; the button itself becomes a separate Pause/Resume. - Searchable game selector (custom GameCombobox) replaces the plain `<select>` and resets the buffer on switch. - Per-line color-coded level badges (INFO/WARN/ERROR/DEBUG) detected via a word-boundary regex; lines without a level fall back to plain text. - In-stream search input highlights matches in the visible buffer using `<mark>` spans without filtering them out. - Multi-select level filter (DropdownMenu + Badge) hides whole levels; defaults to all on. - Autoscroll toggle, on by default; turning it off freezes the scroll position even as new lines arrive. - Footer summary: line count, oldest-line age, hidden-level count, and buffered-while-paused count. SSE plumbing (initial /api/logs snapshot + EventSource on /api/logs/:game/stream) and the pause-buffers/resume-flushes behaviour are preserved verbatim from the old `LogsPanel`. Removed `LogsPanel` from the Dashboard and deleted the file — `/logs` is now the only consumer. https://claude.ai/code/session_01AJDEL37XXB5i6GRDJugNvx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5ff4fe commit bd51068

18 files changed

Lines changed: 2008 additions & 174 deletions

CLAUDE.md

Lines changed: 11 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

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,16 @@ export const CONFIGURED_DISCORD_CONFIG: DiscordConfigRedacted = {
154154
publicKeySet: true,
155155
interactionsEndpointUrl: 'https://abc123.lambda-url.us-east-1.on.aws/',
156156
};
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: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
CONFIGURED_DISCORD_CONFIG,
1717
makeActualCosts,
1818
} from './game-data.js';
19-
import { AppLayout, AuthGatePage, DashboardPage, CostsPage } from '../pages/index.js';
19+
import { AppLayout, AuthGatePage, DashboardPage, CostsPage, LogsPage } from '../pages/index.js';
2020

2121
export type {
2222
GameStatus,
@@ -41,8 +41,9 @@ export {
4141
VALID_GUILD_ID,
4242
VALID_GUILD_ID_2,
4343
VALID_USER_ID,
44+
SAMPLE_LOG_LINES,
4445
} from './game-data.js';
45-
export { AppLayout, AuthGatePage, DashboardPage, CostsPage } from '../pages/index.js';
46+
export { AppLayout, AuthGatePage, DashboardPage, CostsPage, LogsPage } from '../pages/index.js';
4647

4748
/** Per-spec overrides for the default `/api/*` stubs registered by `stubApis`. */
4849
export interface StubOptions {
@@ -70,6 +71,20 @@ export interface StubOptions {
7071
* `FIRST_RUN_DISCORD_CONFIG` to exercise the setup wizard.
7172
*/
7273
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[]>;
7388
}
7489

7590
/**
@@ -89,7 +104,8 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
89104
const config = opts.config ?? WATCHDOG_CONFIG;
90105
const startResult: ActionResult = opts.startResult ?? { success: true, message: 'Started' };
91106
const discord = opts.discord ?? CONFIGURED_DISCORD_CONFIG;
92-
const games = statuses.map((s) => s.game);
107+
const games = opts.games ?? statuses.map((s) => s.game);
108+
const logLines = opts.logLines ?? {};
93109
const actualCostsFn: (days: number) => ActualCosts =
94110
typeof opts.actualCosts === 'function'
95111
? opts.actualCosts
@@ -159,6 +175,17 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
159175
await page.route('**/api/discord/permissions/*', (route) =>
160176
route.fulfill({ json: { success: true, permissions: discord.gamePermissions } }),
161177
);
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());
162189
}
163190

164191
type E2EFixtures = {
@@ -173,6 +200,8 @@ type E2EFixtures = {
173200
dashboard: DashboardPage;
174201
/** Page object for the `/costs` route — use in any authed-costs spec. */
175202
costs: CostsPage;
203+
/** Page object for the `/logs` route — use in any authed-logs spec. */
204+
logs: LogsPage;
176205
/** Page object for the persistent nav shell (sidebar + top bar). */
177206
layout: AppLayout;
178207
/** Page object for the API-token modal — use in auth-gate specs. */
@@ -196,6 +225,9 @@ export const test = base.extend<E2EFixtures>({
196225
costs: async ({ authedPage }, use) => {
197226
await use(new CostsPage(authedPage));
198227
},
228+
logs: async ({ authedPage }, use) => {
229+
await use(new LogsPage(authedPage));
230+
},
199231
layout: async ({ page }, use) => {
200232
await use(new AppLayout(page));
201233
},
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type { Page, Locator } from '@playwright/test';
2+
3+
/** Detected log level — drives the per-line badge color and the Levels filter. */
4+
export type LogLevelLabel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
5+
6+
/**
7+
* Page object for the `/logs` route added in CoderCoco/game-server-deploy#63.
8+
* Wraps the LIVE/PAUSED pill, the searchable game combobox, the in-stream
9+
* search input, the Levels multi-select, the autoscroll toggle, the
10+
* Pause/Resume button, the log box, and the footer line-count summary so
11+
* spec files read as test logic rather than locator soup.
12+
*/
13+
export class LogsPage {
14+
constructor(public readonly page: Page) {}
15+
16+
/** Navigate to `/logs` directly. */
17+
async goto(): Promise<void> {
18+
await this.page.goto('/logs');
19+
}
20+
21+
// ── Header ───────────────────────────────────────────────────────────
22+
23+
/** "Server Logs" heading — used as a "the page mounted" smoke check. */
24+
heading(): Locator {
25+
return this.page.getByRole('heading', { name: 'Server Logs' });
26+
}
27+
28+
/**
29+
* The LIVE/PAUSED status pill. Exact-match prevents the badge from
30+
* substring-matching incidental words ("Lively", "Alive") inside log
31+
* lines.
32+
*/
33+
liveBadge(): Locator {
34+
return this.page.getByText('Live', { exact: true });
35+
}
36+
37+
/** Counterpart to `liveBadge()` — visible while the stream is paused. */
38+
pausedBadge(): Locator {
39+
return this.page.getByText('Paused', { exact: true });
40+
}
41+
42+
// ── Toolbar ──────────────────────────────────────────────────────────
43+
44+
/**
45+
* Game combobox trigger. The `aria-label` always starts with
46+
* `"Game selector"` so the regex matches regardless of which game is
47+
* currently selected.
48+
*/
49+
gameComboboxTrigger(): Locator {
50+
return this.page.getByRole('button', { name: /^Game selector/ });
51+
}
52+
53+
/** Search input rendered inside the combobox popover after it opens. */
54+
gameSearchInput(): Locator {
55+
return this.page.getByPlaceholder('Search games…');
56+
}
57+
58+
/** Filtered game item inside the open popover, by game name. */
59+
gameOption(name: string): Locator {
60+
return this.page.getByRole('button', { name, exact: true });
61+
}
62+
63+
/**
64+
* Open the combobox, type into the search filter, and click the
65+
* matching game option. The trigger collapses on selection.
66+
*/
67+
async selectGame(name: string): Promise<void> {
68+
await this.gameComboboxTrigger().click();
69+
await this.gameSearchInput().fill(name);
70+
await this.gameOption(name).click();
71+
}
72+
73+
/** In-stream search input that highlights matches in the visible buffer. */
74+
searchInput(): Locator {
75+
return this.page.getByPlaceholder('Search visible buffer…');
76+
}
77+
78+
/** Type into the in-stream search input and let React re-render highlights. */
79+
async search(query: string): Promise<void> {
80+
await this.searchInput().fill(query);
81+
}
82+
83+
/**
84+
* Levels multi-select trigger. The button label reads `Levels (N/4)`,
85+
* so a `/Levels/` regex matches no matter how many levels are currently
86+
* shown — narrow with `levelsTriggerWithCount` for an exact count.
87+
*/
88+
levelsTrigger(): Locator {
89+
return this.page.getByRole('button', { name: /Levels/ });
90+
}
91+
92+
/** Levels trigger asserted to display a specific visible-count (e.g. `3/4`). */
93+
levelsTriggerWithCount(visible: number, total = 4): Locator {
94+
// The button's accessible name is the visible text "Levels (V/T)". Exact
95+
// match on a literal string avoids constructing a dynamic regex (and the
96+
// CodeQL "incomplete string escaping" alert that comes with it).
97+
return this.page.getByRole('button', { name: `Levels (${visible}/${total})`, exact: true });
98+
}
99+
100+
/** Checkbox item inside the open Levels menu, by level label. */
101+
levelMenuItem(level: LogLevelLabel): Locator {
102+
return this.page.getByRole('menuitemcheckbox', { name: level });
103+
}
104+
105+
/**
106+
* Open the Levels menu, toggle a level off (or on), and dismiss the menu
107+
* with Escape so subsequent assertions aren't obscured by the popover.
108+
* The menu stays open by design (`onSelect` preventDefault) so we close
109+
* it explicitly here.
110+
*/
111+
async toggleLevel(level: LogLevelLabel): Promise<void> {
112+
await this.levelsTrigger().click();
113+
await this.levelMenuItem(level).click();
114+
await this.page.keyboard.press('Escape');
115+
}
116+
117+
/** Autoscroll checkbox — wrapped in a `<label>` with text "Autoscroll". */
118+
autoscrollCheckbox(): Locator {
119+
return this.page.getByLabel('Autoscroll');
120+
}
121+
122+
/** Pause/Resume button. The accessible name flips with the state. */
123+
pauseButton(): Locator {
124+
return this.page.getByRole('button', { name: 'Pause' });
125+
}
126+
127+
/** Counterpart to `pauseButton()` — visible while the stream is paused. */
128+
resumeButton(): Locator {
129+
return this.page.getByRole('button', { name: 'Resume' });
130+
}
131+
132+
// ── Log stream ───────────────────────────────────────────────────────
133+
134+
/**
135+
* A `<mark>` highlight rendered by the in-stream search. Without a
136+
* search query active the page contains zero `<mark>` elements, so this
137+
* is a stable signal for "search-highlighting is working".
138+
*/
139+
highlightMarks(): Locator {
140+
return this.page.locator('mark');
141+
}
142+
143+
/** A specific search highlight by exact matched text. */
144+
highlightMark(text: string): Locator {
145+
return this.page.locator('mark', { hasText: text });
146+
}
147+
148+
/**
149+
* The first level badge of a given level inside the log box. Each
150+
* matching line renders one badge; this picks the first occurrence
151+
* which is enough to assert "this level was detected at all".
152+
*/
153+
levelBadge(level: LogLevelLabel): Locator {
154+
return this.page.getByText(level, { exact: true }).first();
155+
}
156+
157+
// ── Footer ───────────────────────────────────────────────────────────
158+
159+
/**
160+
* Footer summary line — `<N> lines · oldest <age>` plus optional
161+
* "<K> levels hidden" / "buffered N" suffixes. `count` anchors the
162+
* regex to the start so unrelated `5` substrings elsewhere don't
163+
* match.
164+
*/
165+
footerLineCount(count: number): Locator {
166+
return this.page.getByText(new RegExp(`^${count} lines? · oldest `));
167+
}
168+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { AppLayout } from './AppLayout.js';
22
export { AuthGatePage } from './AuthGatePage.js';
33
export { DashboardPage, type ServerStateLabel } from './DashboardPage.js';
44
export { CostsPage, type CostsRangeLabel } from './CostsPage.js';
5+
export { LogsPage, type LogLevelLabel } from './LogsPage.js';

app/packages/web/e2e/specs/dashboard.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ test.describe('dashboard', () => {
102102
await dashboard.goto();
103103

104104
await layout.navigateTo('Logs', '/logs');
105+
// The /logs route is no longer a placeholder — verify the redesigned
106+
// page actually renders so a regression to the placeholder breaks here.
107+
await expect(dashboard.page.getByRole('heading', { name: 'Server Logs' })).toBeVisible();
105108
});
106109

107110
test('should navigate to the Discord page via sidebar', async ({ dashboard, layout }) => {

0 commit comments

Comments
 (0)