From f4b1c418aed0c8bdaa1a1d23ed981715a5fd8fda Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:17:30 +0000 Subject: [PATCH 1/3] #672 Phase 3 / AC6: visual baselines for the HD rower Adds playwright/tests/visual/crew.spec.ts: one baseline per crew (male, female) per camera view (chase, bow), at medium and 1280x720. The spec pins the crew and view through localStorage, checks the engine fetched the picked scull-.glb, and skips with a well-formed decline on a VM with no hardware adapter. The visual job in next-branch-ci.yml now uses #671's GPU-runner recipe: Chrome Stable, the -server GL userspace, and headful under Xvfb. Under the visual-baseline label it records the baselines in the same step, before the upload, so the artifact carries the PNGs to commit back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UM4shUt4GC6ZsPiQdivoym --- .github/workflows/next-branch-ci.yml | 57 ++++++---- playwright/tests/visual/crew.spec.ts | 153 +++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 playwright/tests/visual/crew.spec.ts 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/playwright/tests/visual/crew.spec.ts b/playwright/tests/visual/crew.spec.ts new file mode 100644 index 00000000..96fc3890 --- /dev/null +++ b/playwright/tests/visual/crew.spec.ts @@ -0,0 +1,153 @@ +import { test, expect, type Page } from '../../fixtures/crash-watch'; +import { assertCanvasNotBlank } from '../helpers/assertCanvasNotBlank'; +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, + ); +} + +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 assertCanvasNotBlank(page, canvas); + await hideOverlays(page); + await forceRender(page); + + // 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, + }); + }); + } +} From 893b3f216152d2d6be8e177ec32caea0056fb452 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:23:23 +0000 Subject: [PATCH 2/3] #672 Phase 3: note which jobs clear the GPU-runner traps Also records that the visual-baseline label only takes effect on the next push, since next-branch-ci.yml does not run on labeled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UM4shUt4GC6ZsPiQdivoym --- agents.md | 7 +++++++ 1 file changed, 7 insertions(+) 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. From 8fbd06b8ba8adc93026449d39c3e1711cc2b8149 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:40:28 +0000 Subject: [PATCH 3/3] #672 Phase 3: check the crew shots for black frames from the screenshot On the GPU runner all four crew shots got WebGPU working and fetched the right GLB, then failed in assertCanvasNotBlank: its drawImage readback read 0/400 lit samples. #648 found the same thing, where a WebGPU canvas presents cleanly but drawImage still reads zero. The black-frame check now decodes Chrome's own canvas screenshot, which is the image the baseline compares. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UM4shUt4GC6ZsPiQdivoym --- playwright/tests/visual/crew.spec.ts | 42 ++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/playwright/tests/visual/crew.spec.ts b/playwright/tests/visual/crew.spec.ts index 96fc3890..a2cb5aac 100644 --- a/playwright/tests/visual/crew.spec.ts +++ b/playwright/tests/visual/crew.spec.ts @@ -1,5 +1,4 @@ import { test, expect, type Page } from '../../fixtures/crash-watch'; -import { assertCanvasNotBlank } from '../helpers/assertCanvasNotBlank'; import { installRideHarness, pinTier, @@ -77,6 +76,42 @@ async function pinCrewAndView(page: Page, crew: string, view: string): 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'); @@ -134,10 +169,13 @@ for (const crew of CREWS) { await page.waitForTimeout(250); } - await assertCanvasNotBlank(page, canvas); 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.