Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 37 additions & 20 deletions .github/workflows/next-branch-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
191 changes: 191 additions & 0 deletions playwright/tests/visual/crew.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<number> {
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<void> {
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,
});
});
}
}
Loading