diff --git a/.github/workflows/next-branch-ci.yml b/.github/workflows/next-branch-ci.yml index 6c66fe4c..9929c99f 100644 --- a/.github/workflows/next-branch-ci.yml +++ b/.github/workflows/next-branch-ci.yml @@ -246,26 +246,49 @@ jobs: cache: 'npm' - name: Install dependencies run: npm ci - - name: Install Playwright Chromium - run: npx playwright install --with-deps chromium + - name: Install Playwright Chromium + Google Chrome (for WebGPU) + # #672 Phase 3: the same recipe that got the T4 drawing into a PNG for + # the marketing shots (#671, agents.md §8 "the three traps"). The + # headless shell `playwright install chromium` lands has no + # `navigator.gpu`, so the visual specs run on Chrome Stable. + run: | + npx playwright install --with-deps chromium + npx playwright install --with-deps chrome - name: Install Vulkan runtime for the NVIDIA GPU - # Same shape as the webgpu-engine job: without the Vulkan userspace - # + the NVIDIA ICD matching the running driver, Chromium's WebGPU - # backend falls back to SwiftShader and the engine declines. + # Same recipe as regen-marketing-shots.yml: the fleet VMs run the + # `-server` driver flavour, so the GL userspace is + # `libnvidia-gl-$DRV-server`; without it Chrome hides `navigator.gpu`. + # Xvfb is for the headful run below. run: | set -x sudo apt-get update - sudo apt-get install -y --no-install-recommends libvulkan1 vulkan-tools libgl1 libegl1 - DRV_MAJOR=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1 | cut -d. -f1) + sudo apt-get install -y --no-install-recommends libvulkan1 vulkan-tools libgl1 libegl1 xvfb xauth + DRV_MAJOR=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>&1 \ + | grep -E '^[0-9]+\.' | head -1 | cut -d. -f1) if [ -n "$DRV_MAJOR" ]; then - sudo apt-get install -y --no-install-recommends "libnvidia-gl-$DRV_MAJOR" \ - || echo "libnvidia-gl-$DRV_MAJOR not available via apt — relying on the image's own ICD" + sudo apt-get install -y --no-install-recommends "libnvidia-gl-$DRV_MAJOR-server" \ + || sudo apt-get install -y --no-install-recommends "libnvidia-gl-$DRV_MAJOR" \ + || echo "neither libnvidia-gl-$DRV_MAJOR-server nor libnvidia-gl-$DRV_MAJOR available — Chrome will not expose navigator.gpu" fi - name: Run visual baselines (engine) - # `scene.spec.ts` drives real /row via the demo path; on a VM without - # a hardware adapter each shot `test.skip`s with a well-formed decline - # so a routine fleet-VM gap doesn't read as a visual regression. - run: npx playwright test --config=playwright/playwright.config.visual.ts + # On a VM without a hardware adapter each shot `test.skip`s with a + # well-formed decline, so a routine fleet-VM gap doesn't read as a + # visual regression. + # + # Headful Chrome under Xvfb: Chrome Stable's headless path on Linux + # does not composite the WebGPU swap chain into `page.screenshot` + # (#671). VISUAL_HEADFUL=1 tells the crew spec to launch headful. + # + # Only on a `visual-baseline` label does this re-record + # `__snapshots__/` (`--update-snapshots`), so the committed baselines + # are only overwritten on purpose. It is one step, ahead of the + # upload, so the label run's PNGs are in the artifact to commit back. + env: + VISUAL_HEADFUL: '1' + UPDATE_FLAG: ${{ (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'visual-baseline')) && '--update-snapshots' || '' }} + run: | + xvfb-run -a --server-args="-screen 0 1920x1080x24" \ + npx playwright test --config=playwright/playwright.config.visual.ts $UPDATE_FLAG - name: Upload baselines (recorded or diff) if: ${{ always() }} uses: actions/upload-artifact@v4 @@ -277,15 +300,9 @@ jobs: path: | playwright/tests/visual/__snapshots__ playwright-report + test-results if-no-files-found: ignore retention-days: 14 - - name: Record baselines (visual-baseline label only) - # Only on a `visual-baseline` label the engine's `__snapshots__/` is - # regenerated with `--update-snapshots`; a non-labelled PR cannot - # update them, so the committed baselines are only overwritten - # intentionally. - if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'visual-baseline') }} - run: npx playwright test --config=playwright/playwright.config.visual.ts --update-snapshots # #588 FR4 / AC4 (D3-a + D10 "every build"): StrykerJS mutation testing # scoped to the engine's plan layer, the smoothing/physics helper on the diff --git a/agents.md b/agents.md index 5a181858..358491cb 100644 --- a/agents.md +++ b/agents.md @@ -347,6 +347,13 @@ per 1600×900 shot). A nearly-blank canvas is 90–130 KB and the engine canvas region reads as uniform background colour even though the full page `nonZeroPixelRatio` can approach 1.0 from HUD chrome. +Two workflows clear all three today: `regen-marketing-shots.yml` (marketing +shots, `MARKETING_HEADFUL=1`) and the `visual` job in `next-branch-ci.yml` +(engine baselines, `VISUAL_HEADFUL=1`, used by +`playwright/tests/visual/crew.spec.ts`, #672). `next-branch-ci.yml` runs on +`opened`/`synchronize`/`reopened` only, so adding the `visual-baseline` label +to an open PR records nothing until the next push. + Related CI traps: `uploadBc7` in `src/lib/renderer/engine/textures.ts` must send each mip's BLOCK-ALIGNED physical extent (`blocksX * 4` wide, `blocksY * 4` tall) to `queue.writeTexture`, not the logical mip width. diff --git a/playwright/tests/visual/crew.spec.ts b/playwright/tests/visual/crew.spec.ts new file mode 100644 index 00000000..a2cb5aac --- /dev/null +++ b/playwright/tests/visual/crew.spec.ts @@ -0,0 +1,191 @@ +import { test, expect, type Page } from '../../fixtures/crash-watch'; +import { + installRideHarness, + pinTier, + startDemoRide, + waitForRideOutcome, + forceRender, + readHealth, + assertWellFormedDecline, +} from '../helpers/engineRide'; + +/** + * #672 Phase 3 / AC6 — visual baselines for the high-definition rower. + * + * One committed PNG per crew (male, female) per camera view (chase, bow), so a + * regenerate of `scripts/crew_body.py` that changes how the rower looks shows + * up as a diff rather than as a surprise on the next ride. Chase is the view a + * ride opens on; bow looks back at the rower's face and hands, which is where + * the HD body differs most from the placeholder it replaced. + * + * Recorded on `linux-nvidia-gpu-runner` with the same launch recipe the + * marketing shots proved there (#671, agents.md §8 "the three traps"): Chrome + * Stable rather than the headless shell, the Vulkan WebGPU flags, and headful + * under Xvfb so the swap chain lands in the screenshot. On a VM without a + * hardware adapter each shot skips with a well-formed decline, as + * `scene.spec.ts` does. + */ + +test.use({ + channel: 'chrome', + headless: process.env.VISUAL_HEADFUL === '1' ? false : undefined, + launchOptions: { + ...(process.env.PW_LOCAL_CHROME ? { executablePath: process.env.PW_LOCAL_CHROME } : {}), + args: [ + '--enable-unsafe-webgpu', + '--enable-features=Vulkan', + '--use-angle=vulkan', + '--no-sandbox', + '--disable-dev-shm-usage', + ], + }, + viewport: null, +}); + +const CREW_STORAGE_KEY = 'virtualrow:crew'; +const CAMERA_VIEW_STORAGE_KEY = 'virtualrow:camera-view'; + +const CREWS = ['male', 'female'] as const; +const VIEWS = ['chase', 'bow'] as const; + +/** The DOM over the stage. Same list as `scene.spec.ts`: the HUD is a live clock. */ +const HUD_SELECTORS = [ + '.row-hud-strip', + '.row-hud-actions', + '.ghost-picker', + '.route-info-overlay', + '.race-callout', +] as const; + +/** Long enough for the crew GLB, textures and post-FX to settle (scene.spec.ts). */ +const SETTLE_MS = 8_000; +const CAPTURE_TIMEOUT_MS = 30_000; + +async function pinCrewAndView(page: Page, crew: string, view: string): Promise { + await page.addInitScript( + ([crewKey, crewValue, viewKey, viewValue]) => { + try { + localStorage.setItem(crewKey, crewValue); + localStorage.setItem(viewKey, viewValue); + } catch { + // Private mode: the shot would show the defaults, and the GLB check + // below fails loudly rather than recording the wrong rower. + } + }, + [CREW_STORAGE_KEY, crew, CAMERA_VIEW_STORAGE_KEY, view] as const, + ); +} + +/** + * Share of a 20×20 grid of the canvas's on-screen pixels that are not black. + * + * Read from Chrome's own screenshot, decoded in the page, rather than through + * `assertCanvasNotBlank`'s `drawImage` readback: on the GPU runner a WebGPU + * canvas presents cleanly while `drawImage` still reads zero (#648, see + * `marketing-screenshots.spec.ts`), and the screenshot is what the baseline + * compares anyway. + */ +async function onScreenNonBlankShare(page: Page, png: Buffer): Promise { + return page.evaluate(async (b64) => { + const img = new Image(); + img.src = `data:image/png;base64,${b64}`; + await img.decode(); + const c = document.createElement('canvas'); + c.width = img.naturalWidth; + c.height = img.naturalHeight; + const ctx = c.getContext('2d'); + if (!ctx || c.width === 0 || c.height === 0) return 0; + ctx.drawImage(img, 0, 0); + let nonBlank = 0; + for (let yi = 0; yi < 20; yi += 1) { + for (let xi = 0; xi < 20; xi += 1) { + const d = ctx.getImageData( + Math.floor(((xi + 0.5) / 20) * c.width), + Math.floor(((yi + 0.5) / 20) * c.height), + 1, + 1, + ).data; + if (d[0] + d[1] + d[2] >= 15) nonBlank += 1; + } + } + return nonBlank / 400; + }, png.toString('base64')); +} + +async function hideOverlays(page: Page): Promise { + await page.evaluate((selectors) => { + const style = document.createElement('style'); + style.id = 'visual-baseline-hide'; + style.textContent = `${selectors.join(',\n')} { display: none !important; }`; + document.head.appendChild(style); + }, [...HUD_SELECTORS]); +} + +for (const crew of CREWS) { + for (const view of VIEWS) { + test(`the ${crew} HD rower looks the same from the ${view} view`, async ({ page }) => { + test.slow(); + + await page.setViewportSize({ width: 1280, height: 720 }); + await installRideHarness(page); + await pinTier(page, 'medium'); + await pinCrewAndView(page, crew, view); + + // The picked rower is the one in the boat: the engine fetches its GLB. + // Settled to a boolean so a declined run that never fetches it leaves + // no unhandled rejection behind. + const crewGlb = page + .waitForResponse( + (res) => res.url().endsWith(`/assets/boat/scull-${crew}.glb`) && res.ok(), + { timeout: 60_000 }, + ) + .then( + () => true, + () => false, + ); + + await startDemoRide(page); + const outcome = await waitForRideOutcome(page, 45_000); + + if (outcome !== 'ready') { + await assertWellFormedDecline(page); + const health = await readHealth(page); + test.skip( + true, + `crew baseline skipped — engine declined (fallback=${health?.fallbackReason ?? 'unknown'})`, + ); + return; + } + + expect(await crewGlb, `scull-${crew}.glb is the rower in the boat`).toBe(true); + + const canvas = page.locator('.rower3d-canvas-container canvas').first(); + await expect(canvas).toBeVisible({ timeout: 20_000 }); + await expect(canvas).toHaveAttribute('data-backend', 'webgpu'); + + const settleStart = Date.now(); + while (Date.now() - settleStart < SETTLE_MS) { + await forceRender(page); + await page.waitForTimeout(250); + } + + await hideOverlays(page); + await forceRender(page); + + // A black frame must never become the baseline. + const share = await onScreenNonBlankShare(page, await canvas.screenshot()); + expect(share, 'the canvas shows a lit scene, not a black frame').toBeGreaterThanOrEqual(0.2); + + // Perceptual, as in scene.spec.ts. The stroke is live, so the rower is + // never in quite the same pose twice; the tolerance has to admit that + // while still catching a body that has changed shape or colour. + await expect(canvas).toHaveScreenshot(`crew-${crew}-${view}.png`, { + maxDiffPixelRatio: 0.02, + threshold: 0.3, + animations: 'disabled', + mask: HUD_SELECTORS.map((selector) => page.locator(selector)), + timeout: CAPTURE_TIMEOUT_MS, + }); + }); + } +}