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/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
|
/release-fidelity/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/playwright/.auth/
|
/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__/`.
|
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,
|
currentAndNextSession,
|
||||||
focusMeetingKind,
|
focusMeetingKind,
|
||||||
formatSessionScheduleTime,
|
formatSessionScheduleTime,
|
||||||
|
meetingEndTime,
|
||||||
meetingHasStarted,
|
meetingHasStarted,
|
||||||
mostRecentPastMeeting,
|
mostRecentPastMeeting,
|
||||||
nextUpcomingMeeting,
|
nextUpcomingMeeting,
|
||||||
@@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries(
|
|||||||
) as WeekendSession['datasets']
|
) as WeekendSession['datasets']
|
||||||
|
|
||||||
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
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 (meeting.meeting_key === focusKey) return 'focus'
|
||||||
if (meetingHasStarted(meeting, now)) return 'past'
|
if (meetingHasStarted(meeting, now)) return 'past'
|
||||||
return 'future'
|
return 'future'
|
||||||
@@ -163,6 +166,12 @@ export function CommandCenterPage() {
|
|||||||
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
||||||
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
||||||
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
||||||
|
const railSessions =
|
||||||
|
focusWeekendSessions.length > 0
|
||||||
|
? focusWeekendSessions
|
||||||
|
: heroStateKind === 'between'
|
||||||
|
? lastPastWeekend?.sessions ?? []
|
||||||
|
: []
|
||||||
|
|
||||||
const lastRaceHubQuery = useQuery({
|
const lastRaceHubQuery = useQuery({
|
||||||
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
||||||
@@ -178,6 +187,16 @@ export function CommandCenterPage() {
|
|||||||
|
|
||||||
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
||||||
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
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) {
|
if (seasonsQuery.isLoading) {
|
||||||
return <div className="page loading-state">loading command center…</div>
|
return <div className="page loading-state">loading command center…</div>
|
||||||
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
|
|||||||
<div className="cc-topbar">
|
<div className="cc-topbar">
|
||||||
<span className="cc-topbar-label mono">box-box · command center</span>
|
<span className="cc-topbar-label mono">box-box · command center</span>
|
||||||
<span className="cc-topbar-meta mono">
|
<span className="cc-topbar-meta mono">
|
||||||
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
{latestSeason} season · weekend desk
|
||||||
</span>
|
</span>
|
||||||
<span className="cc-live-pill" data-testid="cc-live-status">
|
<span className="cc-live-pill" data-testid="cc-live-status">
|
||||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
<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-grid">
|
||||||
<div className="cc-dashboard-main">
|
<div className="cc-dashboard-main">
|
||||||
<div
|
<div
|
||||||
@@ -298,7 +337,9 @@ export function CommandCenterPage() {
|
|||||||
<div className="cc-calendar-accent" aria-hidden="true" />
|
<div className="cc-calendar-accent" aria-hidden="true" />
|
||||||
<div className="cc-calendar-top mono">
|
<div className="cc-calendar-top mono">
|
||||||
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
<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>
|
||||||
<div className="cc-calendar-id">
|
<div className="cc-calendar-id">
|
||||||
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
||||||
@@ -371,14 +412,14 @@ export function CommandCenterPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{focusWeekendSessions.length > 0 && (
|
{railSessions.length > 0 && (
|
||||||
<section className="cc-schedule" data-testid="cc-schedule">
|
<section className="cc-schedule" data-testid="cc-schedule">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Weekend Schedule</span>
|
<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>
|
||||||
<div className="cc-session-strip" role="list">
|
<div className="cc-session-strip" role="list">
|
||||||
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
{railSessions.map(({ session, source, datasets }) => {
|
||||||
const status = classifySessionStatus(session, nowDate)
|
const status = classifySessionStatus(session, nowDate)
|
||||||
const isNext = nextSession?.session_key === session.session_key
|
const isNext = nextSession?.session_key === session.session_key
|
||||||
const isCurrent = currentSession?.session_key === session.session_key
|
const isCurrent = currentSession?.session_key === session.session_key
|
||||||
@@ -404,9 +445,9 @@ export function CommandCenterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="cc-session-name">{session.session_name}</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-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" />
|
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||||
{formatCoverageHint(datasets)}
|
{source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -416,6 +457,10 @@ export function CommandCenterPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PaddockBriefing />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4764,3 +4764,137 @@ a { color: inherit; text-decoration: none; }
|
|||||||
background: #f82f34;
|
background: #f82f34;
|
||||||
transform: translateY(-1px);
|
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-focus')).toHaveTextContent('Monaco')
|
||||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
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.getByText('No live session')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
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-focus')).toHaveTextContent('Live now')
|
||||||
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
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')
|
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||||
|
|
||||||
vi.useRealTimers()
|
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": "playwright test --config playwright.visual.config.ts",
|
||||||
"test:visual:prod": "playwright test --config playwright.visual.prod.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: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",
|
"type": "commonjs",
|
||||||
"devDependencies": {
|
"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({
|
export default defineConfig({
|
||||||
testDir: './tests/visual',
|
testDir: './tests/visual',
|
||||||
fullyParallel: true,
|
// All projects share one seeded SQLite database behind the same Go server.
|
||||||
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 1,
|
||||||
reporter: process.env.CI ? 'github' : 'html',
|
reporter: process.env.CI ? 'github' : 'html',
|
||||||
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
|
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
|
||||||
expect: {
|
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()
|
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 }) => {
|
test('nav link reaches command center from race hub', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await page.getByRole('link', { name: 'Command' }).click()
|
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
|
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. */
|
/** Wait for web fonts and layout to settle before screenshots. */
|
||||||
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||||
await page.evaluate(() => document.fonts.ready)
|
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> {
|
export async function gotoCommandCenterReady(page: Page): Promise<void> {
|
||||||
|
await routeSeededSeasonCalendar(page)
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||||
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
||||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible({ timeout: 15_000 })
|
||||||
// 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)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||