diff --git a/docs/testing.md b/docs/testing.md index f7a5981..626514e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -88,7 +88,7 @@ Do not create the file or use `approved` until the owner has reviewed the packet npm run release:fidelity:verify ``` -The verifier checks that the packet exists and the sign-off exists in `HEAD`, names the current candidate commit, and has the required owner fields. It intentionally cannot assess visual fidelity or create approval. +The verifier checks that the packet exists, the sign-off exists in `HEAD`, and its full candidate SHA names a commit that is an ancestor of the sign-off commit. 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. diff --git a/scripts/release-fidelity/verify.mjs b/scripts/release-fidelity/verify.mjs index 35759d9..2ce3953 100644 --- a/scripts/release-fidelity/verify.mjs +++ b/scripts/release-fidelity/verify.mjs @@ -21,10 +21,17 @@ try { for (const file of required) await readFile(join(evidence, file)) git('cat-file', '-e', `HEAD:${signoff}`) const text = await readFile(signoff, 'utf8') - const commit = git('rev-parse', 'HEAD') + 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 signoffCommit = git('log', '-1', '--format=%H', 'HEAD', '--', signoff) + try { + git('merge-base', '--is-ancestor', candidate, signoffCommit) + } catch { + throw new Error(`${signoff} must be committed after candidate ${candidate}`) + } const fields = [ ['Version', version], - ['Candidate commit', commit], ['Reviewed by', '.+'], ['Reviewed on', '\\d{4}-\\d{2}-\\d{2}'], ['Decision', 'approved'], @@ -34,7 +41,7 @@ try { throw new Error(`${signoff} must contain "- ${name}: ${value}"`) } } - console.log(`Release-fidelity evidence and committed owner approval verified for ${version}.`) + 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..0aec6f3 --- /dev/null +++ b/scripts/release-fidelity/verify.test.mjs @@ -0,0 +1,38 @@ +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() +} + +test('accepts a committed sign-off for an ancestor candidate', async () => { + 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: approved\n`) + git(root, 'add', 'docs/release/owner-reviews/v-test.md') + 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'), '') + const output = execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], { + cwd: root, + encoding: 'utf8', + }) + assert.match(output, new RegExp(candidate)) +}) diff --git a/tests/release-fidelity/capture.spec.ts b/tests/release-fidelity/capture.spec.ts index 3aab074..fc373cf 100644 --- a/tests/release-fidelity/capture.spec.ts +++ b/tests/release-fidelity/capture.spec.ts @@ -1,13 +1,28 @@ import { mkdir } from 'node:fs/promises' import { join } from 'node:path' import { expect, test, type Page } from '@playwright/test' -import { - gotoLiveEmptyReady, - waitForScreenshotReady, -} from '../visual/helpers' +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) @@ -15,6 +30,12 @@ async function capture(page: Page, name: string, project: string): Promise 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() @@ -27,14 +48,25 @@ async function gotoCommandCenterFidelityReady(page: Page): Promise { 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 gotoLiveEmptyReady(page) + + 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) } })