diff --git a/CLAUDE.md b/CLAUDE.md index 65171ad..8f6f83b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,12 +172,13 @@ Replay: `h`/`l` or arrows scrub laps - Go: tests in `openf1_test.go` hit the real OpenF1 API and use `skipOnRateLimit(t, err)` to skip on HTTP 429 (require internet). `internal/web` handler tests run offline. - Frontend: Vitest + Testing Library in `frontend/src/test/` (`npm run test` inside `frontend/`). -- E2E/visual: Playwright at repo root (`npm run test:e2e`, `npm run test:visual`). Configs seed a temp domain DB and start the Go server with `BOXBOX_DISABLE_LIVE=1` plus a Vite dev server — no manual setup needed. +- E2E/visual: Playwright at repo root (`npm run test:e2e`, `npm run test:visual`). Configs seed a temp domain DB and start the Go server with `BOXBOX_DISABLE_LIVE=1` and `BOXBOX_OPENF1_BASE_URL` pointed at an unreachable address (hermetic: no live OpenF1 dependency, date-stable) plus a Vite dev server — no manual setup needed. ## Environment - `OPENF1_API_KEY` — Optional Bearer token for paid tier (live session WebSocket access) - `BOXBOX_DISABLE_LIVE=1` — Skip the background SignalR live feed in web mode (used by e2e) +- `BOXBOX_OPENF1_BASE_URL` — Override the OpenF1 API root (default `https://api.openf1.org`; e2e sets an unreachable address for hermetic runs) - `BOXBOX_API_PORT` — Go API port that the Vite dev proxy targets (default 8080) - Logs: TUI writes `box-box.log` in project root; web/ingest modes log to stderr - HTTP cache at `~/.cache/box-box/cache.db`; domain DB at `~/.local/share/box-box/boxbox.db` (override with `--db`) diff --git a/cmd/main.go b/cmd/main.go index 8461830..5e84f5d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -41,11 +41,19 @@ func main() { return } + // BOXBOX_OPENF1_BASE_URL overrides the upstream OpenF1 API root. E2E runs + // point it at an unreachable address so tests stay hermetic and + // deterministic regardless of wall-clock date or network state. + baseURL := os.Getenv("BOXBOX_OPENF1_BASE_URL") + if baseURL == "" { + baseURL = "https://api.openf1.org" + } + var client *api.OpenF1Client if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" { - client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey) + client = api.NewOpenF1ClientWithKey(baseURL, 15*time.Second, apiKey) } else { - client = api.NewOpenF1Client("https://api.openf1.org", 15*time.Second) + client = api.NewOpenF1Client(baseURL, 15*time.Second) } defer client.Close() diff --git a/playwright.config.ts b/playwright.config.ts index b6f4165..4de3f36 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,7 +24,9 @@ export default defineConfig({ ], webServer: [ { - command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`, + // BOXBOX_OPENF1_BASE_URL points at an unreachable address so the e2e stack + // never depends on the live OpenF1 API (keeps runs hermetic + date-stable). + command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 BOXBOX_OPENF1_BASE_URL=http://127.0.0.1:9 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`, url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`, reuseExistingServer: false, timeout: 120_000, diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts index 7d1bfc4..a2835bd 100644 --- a/playwright.visual.config.ts +++ b/playwright.visual.config.ts @@ -49,7 +49,9 @@ export default defineConfig({ ], webServer: [ { - command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`, + // BOXBOX_OPENF1_BASE_URL points at an unreachable address so the visual stack + // never depends on the live OpenF1 API (keeps runs hermetic + date-stable). + command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 BOXBOX_OPENF1_BASE_URL=http://127.0.0.1:9 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`, url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`, reuseExistingServer: false, timeout: 120_000, diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts index c397e14..54e9cf3 100644 --- a/tests/race-hub.spec.ts +++ b/tests/race-hub.spec.ts @@ -17,24 +17,29 @@ test.describe('Race Hub Weekend Workspace', () => { ) }) - test('shows final classification when switching to Race Story', async ({ page }) => { + test('shows final running order when switching to Race Story', async ({ page }) => { await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.getByRole('tab', { name: 'Race Story' }).click() - await expect(page.getByText('Final Classification')).toBeVisible() - await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible() - await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible() + const verRow = page.locator('.rs-driver-row', { + has: page.locator('.rs-driver-name', { hasText: 'VER' }), + }) + const hamRow = page.locator('.rs-driver-row', { + has: page.locator('.rs-driver-name', { hasText: 'HAM' }), + }) + await expect(verRow.locator('.rs-pos-col')).toHaveText('1') + await expect(hamRow.locator('.rs-pos-col')).toHaveText('2') }) - test('Race Story exposes classification, grid, and positions sub-views', async ({ page }) => { + test('Race Story renders the position evolution chart for a full session', async ({ page }) => { await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.getByRole('tab', { name: 'Race Story' }).click() - await page.getByRole('tab', { name: 'Starting Grid' }).click() - await expect(page.locator('.sec-title', { hasText: 'Starting Grid' })).toBeVisible() - - await page.getByRole('tab', { name: 'Positions' }).click() await expect(page.locator('[data-testid="position-chart"]')).toBeVisible() + await expect( + page.getByRole('img', { name: 'Position evolution chart' }), + ).toBeVisible() + await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible() }) test('strategy tab renders stint chart when stints are available', async ({ page }) => { @@ -53,12 +58,11 @@ test.describe('Race Hub Weekend Workspace', () => { await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible() }) - test('positions sub-view shows missing notice when positions are unavailable', async ({ + test('Race Story shows missing notice when positions are unavailable', async ({ page, }) => { await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.getByRole('tab', { name: 'Race Story' }).click() - await page.getByRole('tab', { name: 'Positions' }).click() await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible() await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible() diff --git a/tests/visual/__snapshots__/mobile/command-center.png b/tests/visual/__snapshots__/mobile/command-center.png index 2f338f2..85d92d9 100644 Binary files a/tests/visual/__snapshots__/mobile/command-center.png and b/tests/visual/__snapshots__/mobile/command-center.png differ diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts index 92102f0..931fc94 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -19,6 +19,11 @@ export async function gotoCommandCenterReady(page: Page): Promise { await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('cc-focus')).toBeVisible() await expect(page.getByTestId('cc-session-9472')).toBeVisible() + // The e2e stack runs with an unreachable OpenF1 base URL, so wait for the + // season-calendar query to settle on its local fallback before screenshotting. + await expect( + page.getByText('Using local meetings because the full calendar could not load.'), + ).toBeVisible() await waitForScreenshotReady(page) }