diff --git a/.gitignore b/.gitignore index ec9187c..abcf11c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ node_modules/ /test-results/ /playwright-report/ /blob-report/ +/release-fidelity/ /playwright/.cache/ /playwright/.auth/ diff --git a/docs/product/v0.4.0/README.md b/docs/product/v0.4.0/README.md new file mode 100644 index 0000000..6120d09 --- /dev/null +++ b/docs/product/v0.4.0/README.md @@ -0,0 +1,32 @@ +# v0.4.0 — Current Weekend & Product Truth + +Product research references for the v0.4.0 feature sprint. + +## Direction + +The sprint replaces route-first navigation with a state-aware Weekend experience: + +- Weekend is the adaptive home for what happened, what is live, and what is next. +- Preview content folds into Weekend before a session. +- Live remains a stable deep link and becomes Weekend's active-session state. +- Race Hub remains explicit completed-session analysis rather than a primary landing destination. +- Championship and Briefing remain dedicated destinations. +- Explore owns secondary discovery; Admin moves to operator utility. + +## Mockups + +- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state. +- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS. +- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state. + +These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria. + +## Constraints + +- No OpenF1 REST dependency during active sessions. +- Public live GPS is not assumed to be available. +- Championship round numbers exclude tests and cancelled meetings. +- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts. +- Future sessions must not render empty post-session analysis. + +The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70. diff --git a/docs/product/v0.4.0/mockups/weekend-between-races.png b/docs/product/v0.4.0/mockups/weekend-between-races.png new file mode 100644 index 0000000..bf496d5 Binary files /dev/null and b/docs/product/v0.4.0/mockups/weekend-between-races.png differ diff --git a/docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png b/docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png new file mode 100644 index 0000000..aa3789d Binary files /dev/null and b/docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png differ diff --git a/docs/product/v0.4.0/mockups/weekend-live.png b/docs/product/v0.4.0/mockups/weekend-live.png new file mode 100644 index 0000000..121723c Binary files /dev/null and b/docs/product/v0.4.0/mockups/weekend-live.png differ diff --git a/docs/testing.md b/docs/testing.md index 402e0dd..cbdac6e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -57,3 +57,58 @@ npm run test:visual:prod:update ``` Snapshots live under `tests/visual/__snapshots__/`. + +## Release Fidelity and Owner Review + +Visual regression and mockup fidelity are deliberately separate gates. `npm run test:visual:prod` verifies the candidate against committed regression snapshots; it does not establish that the current UI matches the approved product design. + +After the approved references are available at `docs/product//mockups/`, make an offline review packet. For v0.4.0 the packet pairs `weekend-between-races` and `weekend-live` at the 1280×800 desktop reference viewport, plus `weekend-between-sessions-mobile` at the 390×844 mobile reference viewport. The capture configuration uses the same seeded SQLite data and unreachable OpenF1 endpoint as the visual suite. + +```bash +export RELEASE_FIDELITY_VERSION=v0.4.0 +npm run release:fidelity:capture +npm run release:fidelity:packet +``` + +Open `release-fidelity/v0.4.0/index.html` and review every approved-mockup/candidate pair. The owner, not an automated tool, records a decision in the committed file `docs/release/owner-reviews/v0.4.0.md`: + +```md +# Owner Fidelity Sign-off: v0.4.0 + +- Version: v0.4.0 +- Candidate commit: +- Reviewed by: +- Reviewed on: YYYY-MM-DD +- Decision: approved +``` + +Do not create the file or use `approved` until the owner has reviewed the packet. Once it is committed, the release gate can verify the evidence and decision: + +```bash +npm run release:fidelity:verify +``` + +The verifier requires the sign-off commit to be `HEAD` and to change only `docs/release/owner-reviews/.md`. Its full candidate SHA must equal `HEAD^`; any code change after approval requires a new owner sign-off. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval. + +For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off. + +## Deployment and Rollback + +Build and preserve a SHA-256 record with the deployable binary. Verify the staged binary before replacing the running one: + +```bash +mkdir -p dist +go build -trimpath -o dist/box-box ./cmd/main.go +sha256sum dist/box-box | tee dist/box-box.sha256 +sha256sum -c dist/box-box.sha256 +``` + +Record the current production binary and its SHA before deployment. If owner review rejects the release or deployment fails, restore that saved binary, then verify the restored SHA is byte-identical to the pre-deployment record: + +```bash +sha256sum /srv/box-box/box-box +install -m 0755 /srv/box-box/backups/box-box.previous /srv/box-box/box-box +sha256sum -c /srv/box-box/backups/box-box.previous.sha256 +``` + +Restart and health-check the service using the deployment environment's normal procedure. Keep the candidate SHA, prior SHA, fidelity packet path, and owner sign-off path with the release record. diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx index 29c7493..af50173 100644 --- a/frontend/src/pages/CommandCenterPage.tsx +++ b/frontend/src/pages/CommandCenterPage.tsx @@ -20,6 +20,7 @@ import { currentAndNextSession, focusMeetingKind, formatSessionScheduleTime, + meetingEndTime, meetingHasStarted, mostRecentPastMeeting, nextUpcomingMeeting, @@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries( ) as WeekendSession['datasets'] function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) { + const end = meetingEndTime(meeting) + if (end && now >= end) return 'past' if (meeting.meeting_key === focusKey) return 'focus' if (meetingHasStarted(meeting, now)) return 'past' return 'future' @@ -163,6 +166,12 @@ export function CommandCenterPage() { const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend) const lastRaceSessionKey = lastRaceAnalysis?.session.session_key + const railSessions = + focusWeekendSessions.length > 0 + ? focusWeekendSessions + : heroStateKind === 'between' + ? lastPastWeekend?.sessions ?? [] + : [] const lastRaceHubQuery = useQuery({ queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'], @@ -178,6 +187,16 @@ export function CommandCenterPage() { const lastRacePodium = lastRaceHubQuery.data?.results ?? [] const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? '' + const focusNarrative = + heroStateKind === 'between' + ? lastRaceName + ? `${lastRaceName} is in the archive. The next chapter begins at ${focusMeeting?.circuit_short_name || focusMeeting?.meeting_name || 'the next round'}.` + : 'The season is between race weekends. Use the timeline to revisit a completed round or look ahead.' + : currentSession + ? `${currentSession.session_name} is the active chapter of this weekend. Session detail remains available as it lands locally.` + : nextSession + ? `${nextSession.session_name} is next on the timetable. The weekend rail keeps every session and its local analysis in reach.` + : 'The weekend timetable is ready to explore.' if (seasonsQuery.isLoading) { return
loading command center…
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
box-box · command center - {latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full + {latestSeason} season · weekend desk @@ -262,6 +281,26 @@ export function CommandCenterPage() { /> )} + {focusMeeting && ( +
+ +
+ Circuit context +

{focusMeeting.circuit_short_name || focusMeeting.meeting_name}

+

{focusNarrative}

+
+
+ {focusMeeting.location || focusMeeting.country_name} + {formatGpDateRange(focusMeeting)} + + Weekend preview → + +
+
+ )} +
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) { ) } - diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 6889e55..206a77f 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -4764,3 +4764,137 @@ a { color: inherit; text-decoration: none; } background: #f82f34; transform: translateY(-1px); } + +/* Weekend recut: put the active circuit and season journey ahead of data plumbing. */ +.cc-context { + position: relative; + display: grid; + grid-template-columns: minmax(84px, 0.3fr) minmax(0, 1fr) auto; + gap: var(--s5); + align-items: center; + min-height: 112px; + padding: var(--s5) var(--s6); + overflow: hidden; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: + linear-gradient(90deg, color-mix(in srgb, var(--gp-accent) 9%, transparent), transparent 46%), + var(--surface); +} + +.cc-context-decal { + color: color-mix(in srgb, var(--gp-accent) 58%, var(--text)); + font-size: clamp(38px, 6vw, 72px); + font-weight: 800; + letter-spacing: -0.08em; + line-height: 0.75; + opacity: 0.72; +} + +.cc-context-copy h2 { + font-size: 18px; + line-height: 1.15; +} + +.cc-context-kicker { + display: block; + margin-bottom: var(--s2); + color: var(--text-3); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.cc-context-copy p { + max-width: 64ch; + margin-top: var(--s2); + color: var(--text-2); + font-size: 12px; +} + +.cc-context-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--s2); + color: var(--text-3); + font-size: 10px; + text-align: right; +} + +.cc-context-preview { + margin-top: var(--s2); + color: var(--text-2); + font-weight: 700; +} +.cc-context-preview:hover { color: var(--text); } + +.cc-calendar-grid { + display: flex; + gap: 0; + padding: var(--s3) 0 var(--s4); + overflow-x: auto; + scrollbar-width: thin; +} + +.cc-calendar-card { + min-width: 112px; + min-height: 138px; + flex: 1 0 112px; + padding: var(--s3) var(--s4); + border-radius: 0; + border-width: 1px 1px 1px 0; + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} +.cc-calendar-card:first-child { border-left-width: 1px; } +.cc-calendar-card:hover { transform: none; box-shadow: inset 0 -2px 0 var(--gp-card-accent); } +.cc-calendar-card::before { display: none; } +.cc-calendar-accent { top: auto; bottom: 0; height: 2px; opacity: 0.65; } +.cc-calendar-past { filter: none; opacity: 0.62; } +.cc-calendar-past:hover { filter: none; opacity: 1; } +.cc-calendar-focus { background: color-mix(in srgb, var(--gp-card-accent) 10%, transparent); } +.cc-calendar-decal { font-size: 60px; bottom: -5px; } +.cc-calendar-status { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-3); +} +.cc-calendar-status-focus { color: var(--gp-card-accent); } +.cc-calendar-status-future { color: var(--text-2); } + +.cc-data-note { + display: flex; + align-items: center; + gap: var(--s2); + padding-top: var(--s4); + border-top: 1px solid var(--border); + color: var(--text-3); + font-size: 10px; +} + +@media (max-width: 899px) { + .cc-dashboard-grid { gap: var(--s5); } + .cc-dashboard-sidebar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s5); } + .cc-briefing, .cc-data-note { grid-column: 1 / -1; } +} + +@media (max-width: 640px) { + .cc-context { grid-template-columns: 1fr auto; gap: var(--s4); padding: var(--s4); } + .cc-context-decal { display: none; } + .cc-context-meta { align-items: flex-start; grid-column: 1 / -1; text-align: left; } + .cc-context-copy h2 { font-size: 17px; } + .cc-context-copy p { font-size: 11px; } + .cc-dashboard-sidebar { display: flex; } + .cc-calendar-grid { margin: 0 calc(var(--s4) * -1); padding-left: var(--s4); padding-right: var(--s4); } + .cc-calendar-card { min-width: 104px; flex-basis: 104px; } + .cc-calendar-circuit { display: none; } + .cc-champ-snapshot { order: 2; } + .cc-schedule { order: 1; } + .cc-briefing { order: 3; margin-top: var(--s4); } + .cc-data-note { order: 4; } +} diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx index 5fbf1c8..8bd3860 100644 --- a/frontend/src/test/CommandCenterPage.test.tsx +++ b/frontend/src/test/CommandCenterPage.test.tsx @@ -189,6 +189,9 @@ describe('CommandCenterPage', () => { expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar') expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01') + expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Circuit context') + expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Weekend preview') + expect(screen.getByTestId('cc-data-note')).toHaveTextContent('availability is shown per session') expect(screen.getByText('No live session')).toBeInTheDocument() expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco') }) @@ -238,8 +241,40 @@ describe('CommandCenterPage', () => { }) expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now') expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track') + expect(screen.getByTestId('cc-calendar-1301')).toHaveTextContent('Archive') + expect(screen.getByTestId('cc-calendar-1302')).toHaveTextContent('Now') expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live') vi.useRealTimers() }) + + it('keeps the completed local session rail available when the next weekend has no sessions', async () => { + const nextMeeting = { + ...meeting, + meeting_key: 1303, + meeting_name: 'Canada', + country_name: 'Canada', + country_code: 'CAN', + circuit_short_name: 'Montreal', + date_start: '2026-06-12T00:00:00+00:00', + date_end: '2026-06-14T23:59:59+00:00', + year: 2026, + } + + vi.setSystemTime(new Date('2026-06-06T15:15:00Z')) + mockFetchSeasons.mockResolvedValue([2026]) + mockFetchLocalMeetings.mockResolvedValue([meeting]) + mockFetchSeasonMeetings.mockResolvedValue([meeting, nextMeeting]) + mockFetchWeekend.mockResolvedValue(weekend) + mockFetchSessions.mockResolvedValue([]) + + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument() + }) + expect(screen.getByTestId('cc-schedule')).toHaveTextContent('1 sessions') + + vi.useRealTimers() + }) }) diff --git a/package.json b/package.json index 1669ec5..fe62c24 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,12 @@ "test:visual": "playwright test --config playwright.visual.config.ts", "test:visual:prod": "playwright test --config playwright.visual.prod.config.ts", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", - "test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots" + "test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots", + "release:fidelity:capture": "playwright test --config playwright.fidelity.config.ts", + "release:fidelity:packet": "node scripts/release-fidelity/generate.mjs", + "release:fidelity:verify": "node scripts/release-fidelity/verify.mjs", + "release:fidelity:gate": "npm run test:visual:prod && npm run release:fidelity:capture && npm run release:fidelity:packet && npm run release:fidelity:verify", + "test:release-fidelity": "node --test scripts/release-fidelity/*.test.mjs" }, "type": "commonjs", "devDependencies": { diff --git a/playwright.fidelity.config.ts b/playwright.fidelity.config.ts new file mode 100644 index 0000000..ca8ac60 --- /dev/null +++ b/playwright.fidelity.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from '@playwright/test' +import { VIEWPORTS } from './tests/visual/helpers' + +const E2E_DB = '.playwright/boxbox-fidelity.db' +const API_PORT = process.env.BOXBOX_API_PORT ?? '18080' +const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173' + +// Candidate captures are evidence for owner review, not regression baselines. +export default defineConfig({ + testDir: './tests/release-fidelity', + fullyParallel: true, + forbidOnly: !!process.env.CI, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: `http://localhost:${WEB_PORT}`, + colorScheme: 'dark', + trace: 'on-first-retry', + }, + projects: [ + { name: 'desktop', use: { browserName: 'chromium', viewport: VIEWPORTS.desktop } }, + { name: 'mobile', use: { browserName: 'chromium', viewport: VIEWPORTS.mobile } }, + ], + webServer: [ + { + 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, + }, + { + command: `BOXBOX_API_PORT=${API_PORT} npm run dev --prefix frontend -- --port ${WEB_PORT} --strictPort`, + url: `http://localhost:${WEB_PORT}`, + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}) diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts index a2835bd..b304794 100644 --- a/playwright.visual.config.ts +++ b/playwright.visual.config.ts @@ -7,10 +7,11 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173' export default defineConfig({ testDir: './tests/visual', - fullyParallel: true, + // All projects share one seeded SQLite database behind the same Go server. + fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: 1, reporter: process.env.CI ? 'github' : 'html', snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}', expect: { diff --git a/scripts/release-fidelity/generate.mjs b/scripts/release-fidelity/generate.mjs new file mode 100644 index 0000000..6a1d4aa --- /dev/null +++ b/scripts/release-fidelity/generate.mjs @@ -0,0 +1,86 @@ +import { readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join, relative, resolve } from 'node:path' + +const references = [ + { viewport: 'desktop', name: 'weekend-between-races' }, + { viewport: 'desktop', name: 'weekend-live' }, + { viewport: 'mobile', name: 'weekend-between-sessions-mobile' }, +] + +function option(name, fallback) { + const index = process.argv.indexOf(`--${name}`) + return index === -1 ? fallback : process.argv[index + 1] +} + +async function filesBelow(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const files = await Promise.all(entries.map(async (entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? filesBelow(path) : [path] + })) + return files.flat() +} + +async function locateMockup(root, name) { + const preferred = join(root, `${name}.png`) + try { + await stat(preferred) + return preferred + } catch { + const matches = (await filesBelow(root)).filter((path) => path.endsWith(`${name}.png`)) + return matches.length === 1 ? matches[0] : null + } +} + +function imageData(path) { + return readFile(path).then((data) => `data:image/png;base64,${data.toString('base64')}`) +} + +export async function generatePacket({ version, evidence, mockups }) { + const candidateRoot = join(evidence, 'candidate') + const pairs = [] + const missing = [] + + try { + await stat(mockups) + } catch { + throw new Error(`Approved mockups directory is missing: ${mockups}. It is supplied by the product reference integration.`) + } + + for (const { viewport, name } of references) { + const candidate = join(candidateRoot, viewport, `${name}.png`) + const reference = await locateMockup(mockups, name) + try { + await stat(candidate) + } catch { + missing.push(`candidate: ${candidate}`) + } + if (!reference) missing.push(`mockup: ${join(mockups, `${name}.png`)}`) + if (reference) pairs.push({ viewport, name, candidate, reference }) + } + + if (missing.length) { + throw new Error(`Release-fidelity packet is incomplete:\n${missing.join('\n')}`) + } + + const cards = await Promise.all(pairs.map(async ({ viewport, name, candidate, reference }) => ` +

${viewport}: ${name}

+
Approved mockup
+
Candidate
+

Reference: ${relative(process.cwd(), reference)}

`)) + const summary = `# Release Fidelity Evidence: ${version}\n\n- Candidate screenshots: candidate/\n- Approved mockups: ${relative(process.cwd(), mockups)}\n- Review: human owner decision required; this packet does not approve the release.\n\nOpen index.html for side-by-side evidence.\n` + const html = `${version} release-fidelity review

${version} Release-Fidelity Review

Visual regression is a separate automated gate. This packet is evidence for a subjective owner review and is not approval.

${cards.join('')}` + await writeFile(join(evidence, 'summary.md'), summary) + await writeFile(join(evidence, 'index.html'), html) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0') + const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity' + const evidence = resolve(option('evidence', join(output, version))) + const mockups = resolve(option('mockups', join('docs/product', version, 'mockups'))) + generatePacket({ version, evidence, mockups }).then( + () => console.log(`Release-fidelity packet: ${join(evidence, 'index.html')}`), + (error) => { console.error(error.message); process.exitCode = 1 }, + ) +} diff --git a/scripts/release-fidelity/generate.test.mjs b/scripts/release-fidelity/generate.test.mjs new file mode 100644 index 0000000..42a6188 --- /dev/null +++ b/scripts/release-fidelity/generate.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { generatePacket } from './generate.mjs' + +test('generates a self-contained side-by-side packet for desktop and mobile', async () => { + const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-')) + const evidence = join(root, 'evidence') + const mockups = join(root, 'mockups') + const references = [ + ['desktop', 'weekend-between-races'], + ['desktop', 'weekend-live'], + ['mobile', 'weekend-between-sessions-mobile'], + ] + for (const [viewport, name] of references) { + await mkdir(join(evidence, 'candidate', viewport), { recursive: true }) + await mkdir(mockups, { recursive: true }) + await writeFile(join(evidence, 'candidate', viewport, `${name}.png`), 'candidate') + await writeFile(join(mockups, `${name}.png`), 'mockup') + } + + await generatePacket({ version: 'v-test', evidence, mockups }) + assert.match(await readFile(join(evidence, 'index.html'), 'utf8'), /Approved mockup/) + assert.match(await readFile(join(evidence, 'summary.md'), 'utf8'), /human owner decision required/) +}) diff --git a/scripts/release-fidelity/verify.mjs b/scripts/release-fidelity/verify.mjs new file mode 100644 index 0000000..64c370e --- /dev/null +++ b/scripts/release-fidelity/verify.mjs @@ -0,0 +1,50 @@ +import { readFile } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import { join, resolve } from 'node:path' + +function option(name, fallback) { + const index = process.argv.indexOf(`--${name}`) + return index === -1 ? fallback : process.argv[index + 1] +} + +function git(...args) { + return execFileSync('git', args, { encoding: 'utf8' }).trim() +} + +const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0') +const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity' +const evidence = resolve(option('evidence', join(output, version))) +const signoff = option('signoff', join('docs/release/owner-reviews', `${version}.md`)) +const required = ['index.html', 'summary.md'] + +try { + for (const file of required) await readFile(join(evidence, file)) + git('cat-file', '-e', `HEAD:${signoff}`) + const text = git('show', `HEAD:${signoff}`) + const candidate = text.match(/^- Candidate commit: ([0-9a-f]{40})$/mi)?.[1] + if (!candidate) throw new Error(`${signoff} must contain a full candidate commit SHA`) + git('cat-file', '-e', `${candidate}^{commit}`) + const parent = git('rev-parse', 'HEAD^') + const changed = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n').filter(Boolean) + if (changed.length !== 1 || changed[0] !== signoff) { + throw new Error(`HEAD must contain only the sign-off file change: ${signoff}`) + } + if (candidate.toLowerCase() !== parent.toLowerCase()) { + throw new Error(`${signoff} candidate must equal HEAD^ (${parent})`) + } + const fields = [ + ['Version', version], + ['Reviewed by', '.+'], + ['Reviewed on', '\\d{4}-\\d{2}-\\d{2}'], + ['Decision', 'approved'], + ] + for (const [name, value] of fields) { + if (!new RegExp(`^- ${name}: ${value}$`, 'm').test(text)) { + throw new Error(`${signoff} must contain "- ${name}: ${value}"`) + } + } + console.log(`Release-fidelity evidence and committed owner approval verified for ${version} at ${candidate}.`) +} catch (error) { + console.error(`Release-fidelity gate blocked: ${error.message}`) + process.exitCode = 1 +} diff --git a/scripts/release-fidelity/verify.test.mjs b/scripts/release-fidelity/verify.test.mjs new file mode 100644 index 0000000..73f35db --- /dev/null +++ b/scripts/release-fidelity/verify.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import test from 'node:test' + +const verifier = resolve('scripts/release-fidelity/verify.mjs') + +function git(directory, ...args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim() +} + +function verify(root, evidence) { + return execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], { + cwd: root, + encoding: 'utf8', + }) +} + +async function approvedCandidate({ changeCodeWithSignoff = false, decision = 'approved' } = {}) { + const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-verify-')) + git(root, 'init') + git(root, 'config', 'user.email', 'test@example.com') + git(root, 'config', 'user.name', 'Test') + await writeFile(join(root, 'candidate.txt'), 'candidate') + git(root, 'add', 'candidate.txt') + git(root, 'commit', '-m', 'candidate') + const candidate = git(root, 'rev-parse', 'HEAD') + + await mkdir(join(root, 'docs/release/owner-reviews'), { recursive: true }) + await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: ${decision}\n`) + if (changeCodeWithSignoff) await writeFile(join(root, 'candidate.txt'), 'changed with approval') + git(root, 'add', 'docs/release/owner-reviews/v-test.md') + if (changeCodeWithSignoff) git(root, 'add', 'candidate.txt') + git(root, 'commit', '-m', 'owner sign-off') + + const evidence = join(root, 'evidence') + await mkdir(evidence) + await writeFile(join(evidence, 'index.html'), '') + await writeFile(join(evidence, 'summary.md'), '') + return { candidate, evidence, root } +} + +function assertBlocked(root, evidence) { + assert.throws(() => verify(root, evidence), (error) => { + assert.match(String(error.stderr), /HEAD must contain only the sign-off file change/) + return true + }) +} + +test('accepts a sign-off-only HEAD for its direct parent candidate', async () => { + const { candidate, evidence, root } = await approvedCandidate() + const output = verify(root, evidence) + assert.match(output, new RegExp(candidate)) +}) + +test('rejects code committed after approval', async () => { + const { evidence, root } = await approvedCandidate() + await writeFile(join(root, 'candidate.txt'), 'changed after review') + git(root, 'add', 'candidate.txt') + git(root, 'commit', '-m', 'code after approval') + + assertBlocked(root, evidence) +}) + +test('rejects a sign-off commit that also changes code', async () => { + const { evidence, root } = await approvedCandidate({ changeCodeWithSignoff: true }) + assertBlocked(root, evidence) +}) + +test('rejects a dirty working-tree edit that spoofs approval', async () => { + const { candidate, evidence, root } = await approvedCandidate({ decision: 'rejected' }) + await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: approved\n`) + + assert.throws(() => verify(root, evidence), (error) => { + assert.match(String(error.stderr), /Decision: approved/) + return true + }) +}) diff --git a/tests/command-center.spec.ts b/tests/command-center.spec.ts index ae8e4c8..721e0b1 100644 --- a/tests/command-center.spec.ts +++ b/tests/command-center.spec.ts @@ -12,6 +12,30 @@ test.describe('Command Center', () => { await expect(page.getByTestId('hero-last-race-link')).toBeVisible() }) + test('falls back to the local calendar when season metadata fails', async ({ page }) => { + await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') { + await route.continue() + return + } + + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'season calendar unavailable' }), + }) + }) + + await page.goto('/') + + await expect(page.getByTestId('command-center')).toBeVisible() + await expect(page.getByTestId('cc-calendar-1229')).toBeVisible() + await expect( + page.getByText('Using local meetings because the full calendar could not load.'), + ).toBeVisible({ timeout: 15_000 }) + }) + test('nav link reaches command center from race hub', async ({ page }) => { await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.getByRole('link', { name: 'Command' }).click() diff --git a/tests/release-fidelity/capture.spec.ts b/tests/release-fidelity/capture.spec.ts new file mode 100644 index 0000000..fc373cf --- /dev/null +++ b/tests/release-fidelity/capture.spec.ts @@ -0,0 +1,73 @@ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, test, type Page } from '@playwright/test' +import { waitForScreenshotReady } from '../visual/helpers' + +const version = process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0' +const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity' +const activeLiveState = { + is_live: true, + data: { + Drivers: { + '1': { RacingNumber: '1', Position: 1, Interval: '' }, + '44': { RacingNumber: '44', Position: 2, Interval: '+2.314' }, + }, + DriverInfo: { + '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' }, + '44': { RacingNumber: '44', Tla: 'HAM', TeamColour: 'E8002D' }, + }, + Tyres: {}, + RCMessages: [], + Weather: {}, + Session: { MeetingName: 'Monaco', SessionName: 'Race', SessionType: 'Race' }, + TrackStatus: '1', + }, +} + +async function capture(page: Page, name: string, project: string): Promise { + const directory = join(output, version, 'candidate', project) + await mkdir(directory, { recursive: true }) + await page.screenshot({ fullPage: true, path: join(directory, `${name}.png`) }) +} + +async function useSeededCalendar(page: Page): Promise { + await page.route(/\/api\/v1\/meetings\?year=2025&source=openf1$/, (route) => + route.fulfill({ contentType: 'application/json', body: '[]' }), + ) +} + +async function gotoCommandCenterFidelityReady(page: Page): Promise { + await page.goto('/') + await expect(page.getByTestId('command-center')).toBeVisible() + await expect(page.getByTestId('cc-focus')).toBeVisible() + await expect(page.getByTestId('cc-calendar-1229')).toBeVisible() + await page.waitForLoadState('networkidle') + await waitForScreenshotReady(page) +} + +test.describe('release-fidelity candidate captures', () => { + test('approved screen set', async ({ page }, testInfo) => { + const project = testInfo.project.name + await useSeededCalendar(page) + + if (project === 'desktop') { + await page.clock.install({ time: new Date('2025-06-01T12:00:00Z') }) + await gotoCommandCenterFidelityReady(page) + await expect(page.getByTestId('hero-last-race')).toBeVisible() + await capture(page, 'weekend-between-races', project) + + await page.route('**/api/v1/live/state', (route) => + route.fulfill({ contentType: 'application/json', body: JSON.stringify(activeLiveState) }), + ) + await page.clock.setFixedTime(new Date('2025-05-25T14:00:00Z')) + await gotoCommandCenterFidelityReady(page) + await expect(page.getByTestId('hero-live-timing')).toBeVisible() + await capture(page, 'weekend-live', project) + } else { + await page.clock.install({ time: new Date('2025-05-25T12:00:00Z') }) + await gotoCommandCenterFidelityReady(page) + await expect(page.getByTestId('hero-countdown')).toContainText('Next') + await capture(page, 'weekend-between-sessions-mobile', project) + } + }) +}) diff --git a/tests/visual/__snapshots__/desktop/command-center.png b/tests/visual/__snapshots__/desktop/command-center.png index b06f103..0e9d4de 100644 Binary files a/tests/visual/__snapshots__/desktop/command-center.png and b/tests/visual/__snapshots__/desktop/command-center.png differ diff --git a/tests/visual/__snapshots__/mobile/command-center.png b/tests/visual/__snapshots__/mobile/command-center.png index 02d443c..0d78b58 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/__snapshots__/tablet/command-center.png b/tests/visual/__snapshots__/tablet/command-center.png index 52c6adf..7e449e2 100644 Binary files a/tests/visual/__snapshots__/tablet/command-center.png and b/tests/visual/__snapshots__/tablet/command-center.png differ diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts index fe66d1e..72df44a 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -8,6 +8,20 @@ export const VIEWPORTS = { export const FULL_SESSION = 9472 +async function routeSeededSeasonCalendar(page: Page): Promise { + await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') { + await route.continue() + return + } + + url.searchParams.set('source', 'local') + const response = await route.fetch({ url: url.toString() }) + await route.fulfill({ response }) + }) +} + /** Wait for web fonts and layout to settle before screenshots. */ export async function waitForScreenshotReady(page: Page): Promise { await page.evaluate(() => document.fonts.ready) @@ -15,15 +29,11 @@ export async function waitForScreenshotReady(page: Page): Promise { } export async function gotoCommandCenterReady(page: Page): Promise { + await routeSeededSeasonCalendar(page) await page.goto('/') 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 expect(page.getByTestId('cc-calendar-1229')).toBeVisible({ timeout: 15_000 }) await waitForScreenshotReady(page) }