Merge pull request #101 from AmanTahiliani/release/v0.4.0-current-weekend
release: v0.4.0 - Weekend fidelity recovery
1
.gitignore
vendored
@@ -30,6 +30,7 @@ node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/release-fidelity/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
|
||||
32
docs/product/v0.4.0/README.md
Normal file
@@ -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.
|
||||
BIN
docs/product/v0.4.0/mockups/weekend-between-races.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
docs/product/v0.4.0/mockups/weekend-live.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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/<version>/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: <full commit SHA>
|
||||
- Reviewed by: <owner name>
|
||||
- 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/<version>.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.
|
||||
|
||||
@@ -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 <div className="page loading-state">loading command center…</div>
|
||||
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
|
||||
<div className="cc-topbar">
|
||||
<span className="cc-topbar-label mono">box-box · command center</span>
|
||||
<span className="cc-topbar-meta mono">
|
||||
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
||||
{latestSeason} season · weekend desk
|
||||
</span>
|
||||
<span className="cc-live-pill" data-testid="cc-live-status">
|
||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||
@@ -262,6 +281,26 @@ export function CommandCenterPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{focusMeeting && (
|
||||
<section className="cc-context" data-testid="cc-circuit-context">
|
||||
<div className="cc-context-decal mono" aria-hidden="true">
|
||||
{countryDecal(focusMeeting)}
|
||||
</div>
|
||||
<div className="cc-context-copy">
|
||||
<span className="cc-context-kicker mono">Circuit context</span>
|
||||
<h2>{focusMeeting.circuit_short_name || focusMeeting.meeting_name}</h2>
|
||||
<p>{focusNarrative}</p>
|
||||
</div>
|
||||
<div className="cc-context-meta mono">
|
||||
<span>{focusMeeting.location || focusMeeting.country_name}</span>
|
||||
<span>{formatGpDateRange(focusMeeting)}</span>
|
||||
<Link to="/preview" className="cc-context-preview">
|
||||
Weekend preview →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="cc-dashboard-grid">
|
||||
<div className="cc-dashboard-main">
|
||||
<div
|
||||
@@ -298,7 +337,9 @@ export function CommandCenterPage() {
|
||||
<div className="cc-calendar-accent" aria-hidden="true" />
|
||||
<div className="cc-calendar-top mono">
|
||||
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
|
||||
<span className={`cc-calendar-status cc-calendar-status-${status}`}>
|
||||
{status === 'past' ? 'Archive' : status === 'focus' ? 'Now' : 'Ahead'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="cc-calendar-id">
|
||||
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
||||
@@ -371,14 +412,14 @@ export function CommandCenterPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{focusWeekendSessions.length > 0 && (
|
||||
{railSessions.length > 0 && (
|
||||
<section className="cc-schedule" data-testid="cc-schedule">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Weekend Schedule</span>
|
||||
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
|
||||
<span className="sec-meta mono">{railSessions.length} sessions</span>
|
||||
</div>
|
||||
<div className="cc-session-strip" role="list">
|
||||
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
||||
{railSessions.map(({ session, source, datasets }) => {
|
||||
const status = classifySessionStatus(session, nowDate)
|
||||
const isNext = nextSession?.session_key === session.session_key
|
||||
const isCurrent = currentSession?.session_key === session.session_key
|
||||
@@ -404,9 +445,9 @@ export function CommandCenterPage() {
|
||||
</div>
|
||||
<div className="cc-session-name">{session.session_name}</div>
|
||||
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
||||
<div className="cc-session-cov mono">
|
||||
<div className="cc-session-cov mono" title={formatCoverageHint(datasets)}>
|
||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||
{formatCoverageHint(datasets)}
|
||||
{source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -416,6 +457,10 @@ export function CommandCenterPage() {
|
||||
)}
|
||||
|
||||
<PaddockBriefing />
|
||||
<div className="cc-data-note mono" data-testid="cc-data-note">
|
||||
<span className="cc-cov-dot cc-cov-local" aria-hidden="true" />
|
||||
{meetingStats.full}/{meetingStats.total || 0} locally complete · availability is shown per session
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
37
playwright.fidelity.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
86
scripts/release-fidelity/generate.mjs
Normal file
@@ -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 }) => `
|
||||
<section><h2>${viewport}: ${name}</h2><div class="pair">
|
||||
<figure><figcaption>Approved mockup</figcaption><img src="${await imageData(reference)}"></figure>
|
||||
<figure><figcaption>Candidate</figcaption><img src="${await imageData(candidate)}"></figure>
|
||||
</div><p>Reference: <code>${relative(process.cwd(), reference)}</code></p></section>`))
|
||||
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 = `<!doctype html><title>${version} release-fidelity review</title><style>body{background:#111;color:#eee;font:16px system-ui;margin:2rem}section{margin:3rem 0}.pair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}figure{margin:0}img{max-width:100%;border:1px solid #555}figcaption{font-weight:700;margin-bottom:.5rem}code{color:#9fd}@media(max-width:700px){.pair{grid-template-columns:1fr}}</style><h1>${version} Release-Fidelity Review</h1><p>Visual regression is a separate automated gate. This packet is evidence for a subjective owner review and is not approval.</p>${cards.join('')}</html>`
|
||||
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 },
|
||||
)
|
||||
}
|
||||
27
scripts/release-fidelity/generate.test.mjs
Normal file
@@ -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/)
|
||||
})
|
||||
50
scripts/release-fidelity/verify.mjs
Normal file
@@ -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
|
||||
}
|
||||
80
scripts/release-fidelity/verify.test.mjs
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
73
tests/release-fidelity/capture.spec.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
await page.route(/\/api\/v1\/meetings\?year=2025&source=openf1$/, (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: '[]' }),
|
||||
)
|
||||
}
|
||||
|
||||
async function gotoCommandCenterFidelityReady(page: Page): Promise<void> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 154 KiB |
@@ -8,6 +8,20 @@ export const VIEWPORTS = {
|
||||
|
||||
export const FULL_SESSION = 9472
|
||||
|
||||
async function routeSeededSeasonCalendar(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
@@ -15,15 +29,11 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function gotoCommandCenterReady(page: Page): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||