feat(tests): screenshot regression + Discord review, perf-tracked
New tooling in tests/tools/screenshots/ (TypeScript via tsx): - compare.ts: one pixelmatch engine (AA-excluded), connected-component "where to look" boxes, old|new+boxes|heatmap triptych, per-engine floors. - promote.ts: churn-free updater — overwrite a baseline only when decoded pixels differ beyond the floor, copying CI bytes verbatim (no re-encode churn); pulls a CI run via `gh run download` or a local --from dir. - post-discord.ts: always-on CI-on-main report (SHA + e2e status + the track-only runtime-perf table), then screenshot triptychs, batched + size-capped + flood-collapsed + 429-aware. - perf-report.ts: perf table with Δ vs the previous main run (via gh). - changelog.ts: no-build git-history baseline differ (Discord trigger B). - noise.ts / gen-manifest.ts: calibration + manifest generation. CI wiring: - wasm-build.yml: post-test step runs the gate + report on the already- produced test-results (no extra build); report-only (continue-on-error), posts only on push to main, inert without DISCORD_WEBHOOK_URL. - ci-ubicloud.yml: secrets: inherit (pass the webhook through). - screenshot-changelog.yml: ~30s no-build changelog on baseline changes. screenshot-manifest.json: canonical 354-name set + best-effort engine tags (313 chromium-swiftshader / 41 firefox-llvmpipe). Normalize scale:'device'->'css' across 18 spec files (no-op at CI DSF=1) so committed baselines are uniformly css-scaled. Design: CI's Linux render is the single source of truth; no pinned container (accept rare env drift -> re-promote); dev commits via promote. Replaces the byte-cmp compare-screenshots.sh + file-size-proxy update-baseline-screenshots.sh (kept for now until the first re-baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05127af431
commit
13551f4f22
36 changed files with 3186 additions and 63 deletions
3
.github/workflows/ci-ubicloud.yml
vendored
3
.github/workflows/ci-ubicloud.yml
vendored
|
|
@ -35,6 +35,9 @@ concurrency:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/wasm-build.yml
|
uses: ./.github/workflows/wasm-build.yml
|
||||||
|
# Pass repo secrets (DISCORD_WEBHOOK_URL) through to the reusable build so the
|
||||||
|
# post-test step can post the screenshot + perf report on push to main.
|
||||||
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
# -O1 asyncify shrink — the level we ship (release.yml uses the same -O1).
|
# -O1 asyncify shrink — the level we ship (release.yml uses the same -O1).
|
||||||
# 3D viewer ON so 3d-viewer.spec.ts has a viewer.
|
# 3D viewer ON so 3d-viewer.spec.ts has a viewer.
|
||||||
|
|
|
||||||
41
.github/workflows/screenshot-changelog.yml
vendored
Normal file
41
.github/workflows/screenshot-changelog.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
name: screenshot-changelog
|
||||||
|
|
||||||
|
# Discord trigger B: when a push to main changes committed baseline PNGs, post an
|
||||||
|
# old | new+boxes | heatmap triptych per changed baseline (ADDED image / REMOVED
|
||||||
|
# title too). No build, no GPU — it only diffs two git revisions of PNGs that are
|
||||||
|
# already CI-rendered, so it runs in ~30s. Complements the re-render drift gate in
|
||||||
|
# wasm-build.yml (which catches un-blessed renders); this is the human-facing feed
|
||||||
|
# of intentional baseline updates as they land.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'tests/baseline-screenshots/**.png'
|
||||||
|
- 'tests/e2e/baseline-screenshots/**.png'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: screenshot-changelog-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# HEAD^ is needed to diff the tip commit's baseline changes. (A push of
|
||||||
|
# multiple commits reports only the tip commit's baseline diff — fine for
|
||||||
|
# a changelog; baseline refreshes are single commits in practice.)
|
||||||
|
fetch-depth: 2
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Install screenshot tooling deps
|
||||||
|
working-directory: tests
|
||||||
|
run: npm ci
|
||||||
|
- name: Post baseline changelog to Discord
|
||||||
|
working-directory: tests
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
run: npm run screenshots:changelog
|
||||||
27
.github/workflows/wasm-build.yml
vendored
27
.github/workflows/wasm-build.yml
vendored
|
|
@ -42,6 +42,13 @@ on:
|
||||||
description: "Upload the publishable output/ subset as the 'wasm-output' artifact"
|
description: "Upload the publishable output/ subset as the 'wasm-output' artifact"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
secrets:
|
||||||
|
# Declared so this reusable workflow may reference ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
# (an undeclared secret reference is a workflow startup failure). ci-ubicloud.yml
|
||||||
|
# passes it via `secrets: inherit`; release.yml doesn't (required: false) → the
|
||||||
|
# screenshot/perf report step just no-ops there.
|
||||||
|
DISCORD_WEBHOOK_URL:
|
||||||
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
|
|
@ -295,11 +302,13 @@ jobs:
|
||||||
run: npm run setup:kicad
|
run: npm run setup:kicad
|
||||||
|
|
||||||
- name: wxWidgets e2e (npm run test)
|
- name: wxWidgets e2e (npm run test)
|
||||||
|
id: wx_e2e
|
||||||
if: inputs.run_tests
|
if: inputs.run_tests
|
||||||
working-directory: tests
|
working-directory: tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
- name: KiCad e2e (npm run test:kicad:ci)
|
- name: KiCad e2e (npm run test:kicad:ci)
|
||||||
|
id: kicad_e2e
|
||||||
if: inputs.run_tests
|
if: inputs.run_tests
|
||||||
working-directory: tests
|
working-directory: tests
|
||||||
run: xvfb-run -a npm run test:kicad:ci
|
run: xvfb-run -a npm run test:kicad:ci
|
||||||
|
|
@ -314,6 +323,24 @@ jobs:
|
||||||
working-directory: tests
|
working-directory: tests
|
||||||
run: xvfb-run -a npm run test:perf
|
run: xvfb-run -a npm run test:perf
|
||||||
|
|
||||||
|
# Screenshot drift gate + always-on Discord report (screenshots + perf).
|
||||||
|
# Report-only during rollout: compare.ts exits 0 without --fail-on-change and
|
||||||
|
# the step is continue-on-error, so it never blocks the build — flip to gating
|
||||||
|
# once the per-engine floors are calibrated (tests/tools/screenshots/config.ts,
|
||||||
|
# seeded by `npm run screenshots:noise`). post-discord posts ONLY on push to
|
||||||
|
# main and no-ops without DISCORD_WEBHOOK_URL (inert on PRs/forks). It reads the
|
||||||
|
# already-produced test-results (screenshots + perf-*.json) — no extra build.
|
||||||
|
- name: Screenshot compare + Discord report (screenshots + perf)
|
||||||
|
if: always() && inputs.run_tests
|
||||||
|
continue-on-error: true
|
||||||
|
working-directory: tests
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
npm run screenshots:check
|
||||||
|
npm run screenshots:report -- --e2e ${{ (steps.wx_e2e.outcome == 'success' && steps.kicad_e2e.outcome == 'success') && 'pass' || 'fail' }}
|
||||||
|
|
||||||
- name: Upload test logs & screenshots
|
- name: Upload test logs & screenshots
|
||||||
if: always() && inputs.run_tests
|
if: always() && inputs.run_tests
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ A lot of native module have to be compiled to wasm, the most complex is wxwidget
|
||||||
The e2e tests are in /tests, with a README and WHATWORKS md files
|
The e2e tests are in /tests, with a README and WHATWORKS md files
|
||||||
The e2e tests are separated per feature
|
The e2e tests are separated per feature
|
||||||
Wxwidgets wasm port has hooks for finding positions of UI elements, tests use that
|
Wxwidgets wasm port has hooks for finding positions of UI elements, tests use that
|
||||||
The test have screenshots that are tracked with git, use compare-screenshots.sh to see what changed
|
The test screenshots are tracked with git; CI's Linux render is the source of truth (tooling: tests/tools/screenshots/, see its README).
|
||||||
Update test images with /scripts/update-baseline-screenshots.sh when a new image is added
|
To update baselines, promote a CI run's render (churn-free — only meaningfully-changed images restage): `cd tests && npm run screenshots:promote -- --run <ci-run-id>`, then commit. `npm run screenshots:check` is the local gate; on each main push CI posts a screenshot-diff + runtime-perf report to Discord.
|
||||||
The tests have log files in tests/logs/{wxwidgets/kicad}/{test-name} after each run where the js console and cpp logs are visible
|
The tests have log files in tests/logs/{wxwidgets/kicad}/{test-name} after each run where the js console and cpp logs are visible
|
||||||
Always check screenshots for validating tests
|
Always check screenshots for validating tests
|
||||||
Run e2e tests from /tests folder: `npm run test:kicad` or `npm run test:e2e` (not playwright directly)
|
Run e2e tests from /tests folder: `npm run test:kicad` or `npm run test:e2e` (not playwright directly)
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -193,6 +193,22 @@ npx playwright test menu # Menu tests only
|
||||||
|
|
||||||
See [tests/README.md](tests/README.md) for test documentation.
|
See [tests/README.md](tests/README.md) for test documentation.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
CI's Linux render is the source of truth for baseline screenshots. On each `main`
|
||||||
|
push, CI compares its render against the committed baselines and posts the diff
|
||||||
|
(plus the runtime-perf numbers) to Discord. To update baselines after an intended
|
||||||
|
render change, promote a CI run's render — only meaningfully-changed images
|
||||||
|
restage, so it stays churn-free:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tests
|
||||||
|
npm run screenshots:check # local gate: current vs baselines
|
||||||
|
npm run screenshots:promote -- --run <ci-run-id> # adopt a CI run's render, then commit
|
||||||
|
```
|
||||||
|
|
||||||
|
See [tests/tools/screenshots/README.md](tests/tools/screenshots/README.md).
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
- **wxWidgets WASM**: Core widgets working (menus, dialogs, grids, trees, OpenGL)
|
- **wxWidgets WASM**: Core widgets working (menus, dialogs, grids, trees, OpenGL)
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ test.describe('3D viewer from pcbnew', () => {
|
||||||
await waitForPcbnew(page);
|
await waitForPcbnew(page);
|
||||||
|
|
||||||
await loadBoard(page, testLogger);
|
await loadBoard(page, testLogger);
|
||||||
await page.screenshot({ path: `test-results/3d-viewer-00-board-loaded.png`, scale: 'device' });
|
await page.screenshot({ path: `test-results/3d-viewer-00-board-loaded.png`, scale: 'css' });
|
||||||
|
|
||||||
const glBefore = await countGlCanvases(page);
|
const glBefore = await countGlCanvases(page);
|
||||||
console.log(`[TEST] glcanvas count before opening 3D viewer: ${glBefore}`);
|
console.log(`[TEST] glcanvas count before opening 3D viewer: ${glBefore}`);
|
||||||
|
|
@ -129,7 +129,7 @@ test.describe('3D viewer from pcbnew', () => {
|
||||||
// the scene and render a few progressive passes.
|
// the scene and render a few progressive passes.
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
await page.screenshot({ path: `test-results/3d-viewer-${DEMO.name}.png`, scale: 'device' });
|
await page.screenshot({ path: `test-results/3d-viewer-${DEMO.name}.png`, scale: 'css' });
|
||||||
|
|
||||||
// Read the 3D viewer canvas (the newest glcanvas) directly from its backing
|
// Read the 3D viewer canvas (the newest glcanvas) directly from its backing
|
||||||
// store: copy it onto a 2D canvas with drawImage and sample pixels. This is
|
// store: copy it onto a 2D canvas with drawImage and sample pixels. This is
|
||||||
|
|
@ -312,7 +312,7 @@ test.describe('3D viewer from pcbnew', () => {
|
||||||
return all.find((id) => !before.includes(id)) ?? all[all.length - 1] ?? null;
|
return all.find((id) => !before.includes(id)) ?? all[all.length - 1] ?? null;
|
||||||
}, winsBefore);
|
}, winsBefore);
|
||||||
expect(winId, 'the 3D viewer should open a new top-level window').toBeTruthy();
|
expect(winId, 'the 3D viewer should open a new top-level window').toBeTruthy();
|
||||||
await page.screenshot({ path: 'test-results/3d-viewer-titlebar.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/3d-viewer-titlebar.png', scale: 'css' });
|
||||||
|
|
||||||
// It must have a real DOM title bar (the frames-only fix covers the viewer).
|
// It must have a real DOM title bar (the frames-only fix covers the viewer).
|
||||||
const bar = page.locator(`#${winId} .window-titlebar`);
|
const bar = page.locator(`#${winId} .window-titlebar`);
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ test.describe('PCB Calculator WASM', () => {
|
||||||
const hasRegulatorPanel = labels.some(l => l === 'Calculate');
|
const hasRegulatorPanel = labels.some(l => l === 'Calculate');
|
||||||
expect(hasRegulatorPanel, `expected the calculator's default Regulator panel to be live (Calculate button registered). Got: ${JSON.stringify(labels.slice(0, 30))}`).toBe(true);
|
expect(hasRegulatorPanel, `expected the calculator's default Regulator panel to be live (Calculate button registered). Got: ${JSON.stringify(labels.slice(0, 30))}`).toBe(true);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/calculator-loaded.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/calculator-loaded.png', scale: 'css' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('treebook lists expected panels', async ({ page, testLogger }) => {
|
test('treebook lists expected panels', async ({ page, testLogger }) => {
|
||||||
|
|
@ -132,7 +132,7 @@ test.describe('PCB Calculator WASM', () => {
|
||||||
void testLogger;
|
void testLogger;
|
||||||
await completeFirstRunWizard(page);
|
await completeFirstRunWizard(page);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/calculator-before-switch.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/calculator-before-switch.png', scale: 'css' });
|
||||||
|
|
||||||
const clicked = await clickTreeItem(page, 'Color Code');
|
const clicked = await clickTreeItem(page, 'Color Code');
|
||||||
expect(clicked, 'expected to find and click the Color Code tree item').toBe(true);
|
expect(clicked, 'expected to find and click the Color Code tree item').toBe(true);
|
||||||
|
|
@ -145,6 +145,6 @@ test.describe('PCB Calculator WASM', () => {
|
||||||
const onColorCodePanel = labelsAfter.some(l => /Tolerance/i.test(l));
|
const onColorCodePanel = labelsAfter.some(l => /Tolerance/i.test(l));
|
||||||
expect(onColorCodePanel, `expected Color Code panel to be active after click; labels: ${JSON.stringify(labelsAfter.slice(0, 40))}`).toBe(true);
|
expect(onColorCodePanel, `expected Color Code panel to be active after click; labels: ${JSON.stringify(labelsAfter.slice(0, 40))}`).toBe(true);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/calculator-color-code.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/calculator-color-code.png', scale: 'css' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -223,14 +223,14 @@ test.describe('Eeschema crosshair modes', () => {
|
||||||
|
|
||||||
await page.mouse.move(probe.x, probe.y);
|
await page.mouse.move(probe.x, probe.y);
|
||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
const shotSmall = await page.screenshot({ path: 'test-results/eeschema-crosshair-00-small.png', scale: 'device' });
|
const shotSmall = await page.screenshot({ path: 'test-results/eeschema-crosshair-00-small.png', scale: 'css' });
|
||||||
|
|
||||||
// click 1 -> full-window
|
// click 1 -> full-window
|
||||||
await clickAndSettle();
|
await clickAndSettle();
|
||||||
await expect.poll(tooltipNow, {
|
await expect.poll(tooltipNow, {
|
||||||
message: 'one click should advance to Full-Window Crosshairs', timeout: 6000,
|
message: 'one click should advance to Full-Window Crosshairs', timeout: 6000,
|
||||||
}).toContain('Full-Window Crosshairs');
|
}).toContain('Full-Window Crosshairs');
|
||||||
const shotFull = await page.screenshot({ path: 'test-results/eeschema-crosshair-01-full.png', scale: 'device' });
|
const shotFull = await page.screenshot({ path: 'test-results/eeschema-crosshair-01-full.png', scale: 'css' });
|
||||||
expect((await compareScreenshots(page, shotSmall, shotFull, diffRegion)).diffPixels,
|
expect((await compareScreenshots(page, shotSmall, shotFull, diffRegion)).diffPixels,
|
||||||
'full-window crosshair should visibly differ from the small crosshair').toBeGreaterThan(200);
|
'full-window crosshair should visibly differ from the small crosshair').toBeGreaterThan(200);
|
||||||
|
|
||||||
|
|
@ -239,7 +239,7 @@ test.describe('Eeschema crosshair modes', () => {
|
||||||
await expect.poll(tooltipNow, {
|
await expect.poll(tooltipNow, {
|
||||||
message: 'second click should advance to 45 Degree Crosshairs', timeout: 6000,
|
message: 'second click should advance to 45 Degree Crosshairs', timeout: 6000,
|
||||||
}).toContain('45 Degree Crosshairs');
|
}).toContain('45 Degree Crosshairs');
|
||||||
const shot45 = await page.screenshot({ path: 'test-results/eeschema-crosshair-02-45.png', scale: 'device' });
|
const shot45 = await page.screenshot({ path: 'test-results/eeschema-crosshair-02-45.png', scale: 'css' });
|
||||||
expect((await compareScreenshots(page, shotFull, shot45, diffRegion)).diffPixels,
|
expect((await compareScreenshots(page, shotFull, shot45, diffRegion)).diffPixels,
|
||||||
'45-degree crosshair should visibly differ from the full-window crosshair').toBeGreaterThan(200);
|
'45-degree crosshair should visibly differ from the full-window crosshair').toBeGreaterThan(200);
|
||||||
|
|
||||||
|
|
@ -248,7 +248,7 @@ test.describe('Eeschema crosshair modes', () => {
|
||||||
await expect.poll(tooltipNow, {
|
await expect.poll(tooltipNow, {
|
||||||
message: 'third click should cycle back to Small crosshairs', timeout: 6000,
|
message: 'third click should cycle back to Small crosshairs', timeout: 6000,
|
||||||
}).toContain('Small crosshairs');
|
}).toContain('Small crosshairs');
|
||||||
await page.screenshot({ path: 'test-results/eeschema-crosshair-03-small-again.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/eeschema-crosshair-03-small-again.png', scale: 'css' });
|
||||||
|
|
||||||
const realErrors = testLogger.errors.filter((error: string) => !error.includes('favicon'));
|
const realErrors = testLogger.errors.filter((error: string) => !error.includes('favicon'));
|
||||||
expect(realErrors).toEqual([]);
|
expect(realErrors).toEqual([]);
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ test.describe('Eeschema schematic load', () => {
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: 'test-results/eeschema-load-rendered.png',
|
path: 'test-results/eeschema-load-rendered.png',
|
||||||
scale: 'device',
|
scale: 'css',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ test.describe('Eeschema URL-detection regex', () => {
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: 'test-results/eeschema-url-regex.png',
|
path: 'test-results/eeschema-url-regex.png',
|
||||||
scale: 'device',
|
scale: 'css',
|
||||||
});
|
});
|
||||||
|
|
||||||
// The wxRegEx compile failure surfaces two ways: a wxLogError logged to
|
// The wxRegEx compile failure surfaces two ways: a wxLogError logged to
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
}, null, { timeout: 150000 });
|
}, null, { timeout: 150000 });
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/eeschema-wizard-00-initial.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/eeschema-wizard-00-initial.png', scale: 'css' });
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
let clicked = await clickByLabel(page, 'Next >');
|
let clicked = await clickByLabel(page, 'Next >');
|
||||||
|
|
@ -241,7 +241,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/eeschema-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
path: `test-results/eeschema-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,7 +251,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/eeschema-wizard-${String(i).padStart(2, '0')}.png`,
|
path: `test-results/eeschema-wizard-${String(i).padStart(2, '0')}.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,7 +313,7 @@ test.describe('Eeschema WASM', () => {
|
||||||
path: 'test-results/eeschema-loaded-css.png',
|
path: 'test-results/eeschema-loaded-css.png',
|
||||||
scale: 'css'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
await page.screenshot({ path: 'test-results/eeschema-loaded.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/eeschema-loaded.png', scale: 'css' });
|
||||||
|
|
||||||
const canvasCount = await page.locator('canvas').count();
|
const canvasCount = await page.locator('canvas').count();
|
||||||
expect(canvasCount).toBeGreaterThan(0);
|
expect(canvasCount).toBeGreaterThan(0);
|
||||||
|
|
@ -408,7 +408,7 @@ test.describe('Eeschema WASM', () => {
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: 'test-results/eeschema-draw-wires-00-before-tool-click.png',
|
path: 'test-results/eeschema-draw-wires-00-before-tool-click.png',
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await clickByTooltip(page, 'Draw Wires', { elementType: 'tool' })).toBe(true);
|
expect(await clickByTooltip(page, 'Draw Wires', { elementType: 'tool' })).toBe(true);
|
||||||
|
|
@ -429,7 +429,7 @@ test.describe('Eeschema WASM', () => {
|
||||||
|
|
||||||
const afterToolClick = await page.screenshot({
|
const afterToolClick = await page.screenshot({
|
||||||
path: 'test-results/eeschema-draw-wires-01-after-click.png',
|
path: 'test-results/eeschema-draw-wires-01-after-click.png',
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
|
|
||||||
const glCanvasId = await page.evaluate(() => {
|
const glCanvasId = await page.evaluate(() => {
|
||||||
|
|
@ -475,7 +475,7 @@ test.describe('Eeschema WASM', () => {
|
||||||
|
|
||||||
const afterDrawing = await page.screenshot({
|
const afterDrawing = await page.screenshot({
|
||||||
path: 'test-results/eeschema-draw-wires-02-after-drawing.png',
|
path: 'test-results/eeschema-draw-wires-02-after-drawing.png',
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
|
|
||||||
const diffRegion: DiffRegion = {
|
const diffRegion: DiffRegion = {
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,12 @@ test.describe('gerbview Print dialog (WASM)', () => {
|
||||||
|
|
||||||
test('no Print Preview button, and the dialog reopens (no wedge)', async ({ page, testLogger }) => {
|
test('no Print Preview button, and the dialog reopens (no wedge)', async ({ page, testLogger }) => {
|
||||||
await waitForGerbview(page);
|
await waitForGerbview(page);
|
||||||
await page.screenshot({ path: 'test-results/gerbview-print-00-loaded.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/gerbview-print-00-loaded.png', scale: 'css' });
|
||||||
|
|
||||||
// --- Open the Print dialog ---
|
// --- Open the Print dialog ---
|
||||||
await openPrintDialog(page);
|
await openPrintDialog(page);
|
||||||
expect(await printDialogIsOpen(page), 'Print dialog should be open').toBe(true);
|
expect(await printDialogIsOpen(page), 'Print dialog should be open').toBe(true);
|
||||||
await page.screenshot({ path: 'test-results/gerbview-print-01-dialog.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/gerbview-print-01-dialog.png', scale: 'css' });
|
||||||
|
|
||||||
// --- The broken "Print Preview" button must be gone in the browser ---
|
// --- The broken "Print Preview" button must be gone in the browser ---
|
||||||
const preview = await findByLabel(page, 'Print Preview', { visible: true, exact: true });
|
const preview = await findByLabel(page, 'Print Preview', { visible: true, exact: true });
|
||||||
|
|
@ -139,7 +139,7 @@ test.describe('gerbview Print dialog (WASM)', () => {
|
||||||
await openPrintDialog(page);
|
await openPrintDialog(page);
|
||||||
expect(await printDialogIsOpen(page), 'Print dialog should reopen (no wedge)').toBe(true);
|
expect(await printDialogIsOpen(page), 'Print dialog should reopen (no wedge)').toBe(true);
|
||||||
await page.waitForTimeout(2500); // let the reopened dialog finish painting before capture
|
await page.waitForTimeout(2500); // let the reopened dialog finish painting before capture
|
||||||
await page.screenshot({ path: 'test-results/gerbview-print-02-reopened.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/gerbview-print-02-reopened.png', scale: 'css' });
|
||||||
|
|
||||||
expect(hasAbort(testLogger), 'no WASM abort during the flow').toBe(false);
|
expect(hasAbort(testLogger), 'no WASM abort during the flow').toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
}, null, { timeout: 90000 });
|
}, null, { timeout: 90000 });
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/gerbview-wizard-00-initial.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/gerbview-wizard-00-initial.png', scale: 'css' });
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
let clicked = await clickByLabel(page, 'Next >');
|
let clicked = await clickByLabel(page, 'Next >');
|
||||||
|
|
@ -37,7 +37,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/gerbview-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
path: `test-results/gerbview-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/gerbview-wizard-${String(i).padStart(2, '0')}.png`,
|
path: `test-results/gerbview-wizard-${String(i).padStart(2, '0')}.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ test.describe('gerbview WASM', () => {
|
||||||
|
|
||||||
test('app loads, canvas visible, no WASM abort', async ({ page, testLogger }) => {
|
test('app loads, canvas visible, no WASM abort', async ({ page, testLogger }) => {
|
||||||
await completeWizard(page);
|
await completeWizard(page);
|
||||||
await page.screenshot({ path: 'test-results/gerbview-01-loaded.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/gerbview-01-loaded.png', scale: 'css' });
|
||||||
|
|
||||||
expect(hasAbort(testLogger), 'no WASM abort during load').toBe(false);
|
expect(hasAbort(testLogger), 'no WASM abort during load').toBe(false);
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ test.describe('gerbview WASM', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/gerbview-02-metrics.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/gerbview-02-metrics.png', scale: 'css' });
|
||||||
|
|
||||||
expect(metrics.registryTotal, 'registry should be populated').toBeGreaterThan(10);
|
expect(metrics.registryTotal, 'registry should be populated').toBeGreaterThan(10);
|
||||||
expect(metrics.toolbarCount, 'at least one toolbar should be visible').toBeGreaterThanOrEqual(1);
|
expect(metrics.toolbarCount, 'at least one toolbar should be visible').toBeGreaterThanOrEqual(1);
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ test.describe('PCB load probe', () => {
|
||||||
test('inspect File→Open dialog state', async ({ page }) => {
|
test('inspect File→Open dialog state', async ({ page }) => {
|
||||||
await completeWizard(page);
|
await completeWizard(page);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/probe-00-after-wizard.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/probe-00-after-wizard.png', scale: 'css' });
|
||||||
|
|
||||||
await dumpMemfs(page, [
|
await dumpMemfs(page, [
|
||||||
'/',
|
'/',
|
||||||
|
|
@ -214,7 +214,7 @@ test.describe('PCB load probe', () => {
|
||||||
console.log(`[PROBE] File menu clicked: ${fileClicked}`);
|
console.log(`[PROBE] File menu clicked: ${fileClicked}`);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/probe-01-file-menu-open.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/probe-01-file-menu-open.png', scale: 'css' });
|
||||||
await dumpRegistry(page, 'file-menu-open');
|
await dumpRegistry(page, 'file-menu-open');
|
||||||
|
|
||||||
// Click Open menu item (try common label variants)
|
// Click Open menu item (try common label variants)
|
||||||
|
|
@ -228,12 +228,12 @@ test.describe('PCB load probe', () => {
|
||||||
// is fast but goes through the Asyncify loop.
|
// is fast but goes through the Asyncify loop.
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/probe-02-after-open-click.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/probe-02-after-open-click.png', scale: 'css' });
|
||||||
await dumpRegistry(page, 'after-open-click');
|
await dumpRegistry(page, 'after-open-click');
|
||||||
|
|
||||||
// Wait a bit longer and dump again, in case the dialog paints late
|
// Wait a bit longer and dump again, in case the dialog paints late
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
await page.screenshot({ path: 'test-results/probe-03-late.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/probe-03-late.png', scale: 'css' });
|
||||||
await dumpRegistry(page, 'late');
|
await dumpRegistry(page, 'late');
|
||||||
|
|
||||||
// Final check: re-dump MEMFS so we can confirm nothing changed under us
|
// Final check: re-dump MEMFS so we can confirm nothing changed under us
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ function runLoadPcbTest(demo: DemoCfg): void {
|
||||||
await waitForPcbnew(page);
|
await waitForPcbnew(page);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/load-pcb-${demo.name}-00-pcbnew-ready.png`,
|
path: `test-results/load-pcb-${demo.name}-00-pcbnew-ready.png`,
|
||||||
scale: 'device',
|
scale: 'css',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Inject .kicad_pcb + .kicad_pro into the dialog's start dir. ──
|
// ── Inject .kicad_pcb + .kicad_pro into the dialog's start dir. ──
|
||||||
|
|
@ -140,7 +140,7 @@ function runLoadPcbTest(demo: DemoCfg): void {
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/load-pcb-${demo.name}-01-dialog-open.png`,
|
path: `test-results/load-pcb-${demo.name}-01-dialog-open.png`,
|
||||||
scale: 'device',
|
scale: 'css',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Focus the filename text field, type the name, accept. ──────
|
// ── Focus the filename text field, type the name, accept. ──────
|
||||||
|
|
@ -189,7 +189,7 @@ function runLoadPcbTest(demo: DemoCfg): void {
|
||||||
// fully painted by the time waitForBoardLoaded returns.
|
// fully painted by the time waitForBoardLoaded returns.
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/load-pcb-${demo.name}.png`,
|
path: `test-results/load-pcb-${demo.name}.png`,
|
||||||
scale: 'device',
|
scale: 'css',
|
||||||
});
|
});
|
||||||
|
|
||||||
const allLines = [...testLogger.consoleLogs, ...testLogger.errors];
|
const allLines = [...testLogger.consoleLogs, ...testLogger.errors];
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ test.describe('PCBnew move with "m" (#9)', () => {
|
||||||
const drawnId = newItems[0].id;
|
const drawnId = newItems[0].id;
|
||||||
const pos0 = await getPos(page, drawnId);
|
const pos0 = await getPos(page, drawnId);
|
||||||
|
|
||||||
const beforeMove = await page.screenshot({ path: 'test-results/pcbnew-move-00-before.png', scale: 'device' });
|
const beforeMove = await page.screenshot({ path: 'test-results/pcbnew-move-00-before.png', scale: 'css' });
|
||||||
|
|
||||||
// Hover the cursor onto the line and select it, then move with the keyboard.
|
// Hover the cursor onto the line and select it, then move with the keyboard.
|
||||||
await page.mouse.move(midPoint.x, midPoint.y);
|
await page.mouse.move(midPoint.x, midPoint.y);
|
||||||
|
|
@ -155,7 +155,7 @@ test.describe('PCBnew move with "m" (#9)', () => {
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
const afterMove = await page.screenshot({ path: 'test-results/pcbnew-move-01-after.png', scale: 'device' });
|
const afterMove = await page.screenshot({ path: 'test-results/pcbnew-move-01-after.png', scale: 'css' });
|
||||||
|
|
||||||
const pos1 = await getPos(page, drawnId);
|
const pos1 = await getPos(page, drawnId);
|
||||||
const dx = pos1.x - pos0.x;
|
const dx = pos1.x - pos0.x;
|
||||||
|
|
|
||||||
|
|
@ -314,7 +314,7 @@ test.describe('PCBnew WASM', () => {
|
||||||
expect(reference.meanChannelDiff, `${reference.name} mean channel diff`).toBeLessThan(region.maxMeanChannelDiff);
|
expect(reference.meanChannelDiff, `${reference.name} mean channel diff`).toBeLessThan(region.maxMeanChannelDiff);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/pcbnew-loaded.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pcbnew-loaded.png', scale: 'css' });
|
||||||
|
|
||||||
const canvasCount = await page.locator('canvas').count();
|
const canvasCount = await page.locator('canvas').count();
|
||||||
expect(canvasCount).toBeGreaterThan(0);
|
expect(canvasCount).toBeGreaterThan(0);
|
||||||
|
|
@ -409,7 +409,7 @@ test.describe('PCBnew WASM', () => {
|
||||||
|
|
||||||
const beforeToolClick = await page.screenshot({
|
const beforeToolClick = await page.screenshot({
|
||||||
path: 'test-results/pcbnew-draw-lines-00-before-tool-click.png',
|
path: 'test-results/pcbnew-draw-lines-00-before-tool-click.png',
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await clickByTooltip(page, 'Draw Lines', { elementType: 'tool' })).toBe(true);
|
expect(await clickByTooltip(page, 'Draw Lines', { elementType: 'tool' })).toBe(true);
|
||||||
|
|
@ -430,7 +430,7 @@ test.describe('PCBnew WASM', () => {
|
||||||
|
|
||||||
const afterToolClick = await page.screenshot({
|
const afterToolClick = await page.screenshot({
|
||||||
path: 'test-results/pcbnew-draw-lines-01-after-click.png',
|
path: 'test-results/pcbnew-draw-lines-01-after-click.png',
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
|
|
||||||
const glCanvasId = await page.evaluate(() => {
|
const glCanvasId = await page.evaluate(() => {
|
||||||
|
|
@ -491,7 +491,7 @@ test.describe('PCBnew WASM', () => {
|
||||||
|
|
||||||
const afterDrawing = await page.screenshot({
|
const afterDrawing = await page.screenshot({
|
||||||
path: 'test-results/pcbnew-draw-lines-02-after-drawing.png',
|
path: 'test-results/pcbnew-draw-lines-02-after-drawing.png',
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
|
|
||||||
const diffRegion: DiffRegion = {
|
const diffRegion: DiffRegion = {
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ test.describe('pl_editor drawing-sheet load', () => {
|
||||||
.toMatch(/load-test/i);
|
.toMatch(/load-test/i);
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.screenshot({ path: 'test-results/pl_editor-load-rendered.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pl_editor-load-rendered.png', scale: 'css' });
|
||||||
|
|
||||||
expect(hasAbort(testLogger), 'no WASM abort during open').toBe(false);
|
expect(hasAbort(testLogger), 'no WASM abort during open').toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/pl_editor-wizard-00-initial.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pl_editor-wizard-00-initial.png', scale: 'css' });
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
let clicked = await clickByLabel(page, 'Next >');
|
let clicked = await clickByLabel(page, 'Next >');
|
||||||
|
|
@ -32,7 +32,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/pl_editor-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
path: `test-results/pl_editor-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/pl_editor-wizard-${String(i).padStart(2, '0')}.png`,
|
path: `test-results/pl_editor-wizard-${String(i).padStart(2, '0')}.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ test.describe('pl_editor WASM', () => {
|
||||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
|
||||||
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
await page.screenshot({ path: 'test-results/pl_editor-01-loaded.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pl_editor-01-loaded.png', scale: 'css' });
|
||||||
|
|
||||||
expect(hasAbort(testLogger), 'no WASM abort during load').toBe(false);
|
expect(hasAbort(testLogger), 'no WASM abort during load').toBe(false);
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ test.describe('pl_editor WASM', () => {
|
||||||
expect(blockingDialogs, 'no setup wizard/dialog should be visible (seed skipped it)').toBe(0);
|
expect(blockingDialogs, 'no setup wizard/dialog should be visible (seed skipped it)').toBe(0);
|
||||||
expect(hasAbort(testLogger), 'no WASM abort during launch').toBe(false);
|
expect(hasAbort(testLogger), 'no WASM abort during launch').toBe(false);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/pl_editor-02-no-wizard.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pl_editor-02-no-wizard.png', scale: 'css' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('File menu exposes Open... and Save As...', async ({ page, testLogger }) => {
|
test('File menu exposes Open... and Save As...', async ({ page, testLogger }) => {
|
||||||
|
|
@ -99,7 +99,7 @@ test.describe('pl_editor WASM', () => {
|
||||||
expect(fileMenuClicked, 'File menubar item should be clickable').toBe(true);
|
expect(fileMenuClicked, 'File menubar item should be clickable').toBe(true);
|
||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/pl_editor-03-file-menu.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pl_editor-03-file-menu.png', scale: 'css' });
|
||||||
|
|
||||||
// Menu items are tracked in the "rendered" half of the registry (popup
|
// Menu items are tracked in the "rendered" half of the registry (popup
|
||||||
// widgets), not the regular findAll({visible:true}) set. Use findAllRendered
|
// widgets), not the regular findAll({visible:true}) set. Use findAllRendered
|
||||||
|
|
@ -150,7 +150,7 @@ test.describe('pl_editor WASM', () => {
|
||||||
// screenshot catches the dialog as a black rectangle.
|
// screenshot catches the dialog as a black rectangle.
|
||||||
await page.waitForTimeout(600);
|
await page.waitForTimeout(600);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/pl_editor-04-save-as-dialog.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pl_editor-04-save-as-dialog.png', scale: 'css' });
|
||||||
|
|
||||||
// The bug: pressing Enter on a folder name treated it as a file and surfaced
|
// The bug: pressing Enter on a folder name treated it as a file and surfaced
|
||||||
// "Unable to load /dev file". After the OnOk fix, the dialog should navigate
|
// "Unable to load /dev file". After the OnOk fix, the dialog should navigate
|
||||||
|
|
@ -160,7 +160,7 @@ test.describe('pl_editor WASM', () => {
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/pl_editor-04b-after-enter.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/pl_editor-04b-after-enter.png', scale: 'css' });
|
||||||
|
|
||||||
// The wxFileDialog should still be visible — we navigated into /dev, didn't close it.
|
// The wxFileDialog should still be visible — we navigated into /dev, didn't close it.
|
||||||
const dialogStillOpen = await page.evaluate(() => {
|
const dialogStillOpen = await page.evaluate(() => {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
}, null, { timeout: 90000 });
|
}, null, { timeout: 90000 });
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/symbol_editor-wizard-00-initial.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/symbol_editor-wizard-00-initial.png', scale: 'css' });
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
let clicked = await clickByLabel(page, 'Next >');
|
let clicked = await clickByLabel(page, 'Next >');
|
||||||
|
|
@ -38,7 +38,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/symbol_editor-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
path: `test-results/symbol_editor-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ async function completeWizard(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/symbol_editor-wizard-${String(i).padStart(2, '0')}.png`,
|
path: `test-results/symbol_editor-wizard-${String(i).padStart(2, '0')}.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ test.describe('symbol_editor WASM', () => {
|
||||||
|
|
||||||
test('app loads, canvas visible, no WASM abort', async ({ page, testLogger }) => {
|
test('app loads, canvas visible, no WASM abort', async ({ page, testLogger }) => {
|
||||||
await completeWizard(page);
|
await completeWizard(page);
|
||||||
await page.screenshot({ path: 'test-results/symbol_editor-01-loaded.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/symbol_editor-01-loaded.png', scale: 'css' });
|
||||||
|
|
||||||
expect(hasAbort(testLogger), 'no WASM abort during load').toBe(false);
|
expect(hasAbort(testLogger), 'no WASM abort during load').toBe(false);
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ test.describe('symbol_editor WASM', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/symbol_editor-02-metrics.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/symbol_editor-02-metrics.png', scale: 'css' });
|
||||||
|
|
||||||
expect(metrics.registryTotal, 'registry should be populated').toBeGreaterThan(10);
|
expect(metrics.registryTotal, 'registry should be populated').toBeGreaterThan(10);
|
||||||
expect(metrics.toolbarCount, 'at least one toolbar should be visible').toBeGreaterThanOrEqual(1);
|
expect(metrics.toolbarCount, 'at least one toolbar should be visible').toBeGreaterThanOrEqual(1);
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ export async function completeWizard(page: Page, opts: { screenshots?: boolean }
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
if (opts.screenshots) {
|
if (opts.screenshots) {
|
||||||
await page.screenshot({ path: 'test-results/wizard-00-initial.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/wizard-00-initial.png', scale: 'css' });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 1; i <= 10; i++) {
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
|
@ -164,7 +164,7 @@ export async function completeWizard(page: Page, opts: { screenshots?: boolean }
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/wizard-${String(i).padStart(2, '0')}-finish.png`,
|
path: `test-results/wizard-${String(i).padStart(2, '0')}-finish.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +176,7 @@ export async function completeWizard(page: Page, opts: { screenshots?: boolean }
|
||||||
if (opts.screenshots) {
|
if (opts.screenshots) {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `test-results/wizard-${String(i).padStart(2, '0')}.png`,
|
path: `test-results/wizard-${String(i).padStart(2, '0')}.png`,
|
||||||
scale: 'device'
|
scale: 'css'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
tests/package-lock.json
generated
92
tests/package-lock.json
generated
|
|
@ -10,8 +10,13 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/pixelmatch": "^5.2.6",
|
||||||
|
"@types/pngjs": "^6.0.5",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
|
"pixelmatch": "^5.3.0",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"serve": "^14.2.0",
|
"serve": "^14.2.0",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"yjs": "^13.6.31"
|
"yjs": "^13.6.31"
|
||||||
}
|
}
|
||||||
|
|
@ -484,6 +489,26 @@
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pixelmatch": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/pngjs": {
|
||||||
|
"version": "6.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
|
||||||
|
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@zeit/schemas": {
|
"node_modules/@zeit/schemas": {
|
||||||
"version": "2.36.0",
|
"version": "2.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
|
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
|
||||||
|
|
@ -1285,6 +1310,29 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pixelmatch": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"pngjs": "^6.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pixelmatch": "bin/pixelmatch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pixelmatch/node_modules/pngjs": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.57.0",
|
"version": "1.57.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||||
|
|
@ -1317,6 +1365,16 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
@ -1557,6 +1615,40 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "2.19.0",
|
"version": "2.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,24 @@
|
||||||
"test:asyncify:firefox": "playwright test --config=playwright-asyncify.config.ts --project=firefox",
|
"test:asyncify:firefox": "playwright test --config=playwright-asyncify.config.ts --project=firefox",
|
||||||
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed",
|
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed",
|
||||||
"test:asyncify:safari": "playwright test --config=playwright-asyncify.config.ts --project=webkit",
|
"test:asyncify:safari": "playwright test --config=playwright-asyncify.config.ts --project=webkit",
|
||||||
"test:asyncify:all": "npm run test:asyncify:firefox && npm run test:asyncify:safari && npm run test:asyncify:chrome"
|
"test:asyncify:all": "npm run test:asyncify:firefox && npm run test:asyncify:safari && npm run test:asyncify:chrome",
|
||||||
|
"screenshots:check": "tsx tools/screenshots/compare.ts",
|
||||||
|
"screenshots:promote": "tsx tools/screenshots/promote.ts",
|
||||||
|
"screenshots:noise": "tsx tools/screenshots/noise.ts",
|
||||||
|
"screenshots:report": "tsx tools/screenshots/post-discord.ts",
|
||||||
|
"screenshots:changelog": "tsx tools/screenshots/changelog.ts",
|
||||||
|
"screenshots:manifest": "tsx tools/screenshots/gen-manifest.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/pixelmatch": "^5.2.6",
|
||||||
|
"@types/pngjs": "^6.0.5",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
|
"pixelmatch": "^5.3.0",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"serve": "^14.2.0",
|
"serve": "^14.2.0",
|
||||||
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"yjs": "^13.6.31"
|
"yjs": "^13.6.31"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1421
tests/screenshot-manifest.json
Normal file
1421
tests/screenshot-manifest.json
Normal file
File diff suppressed because it is too large
Load diff
40
tests/tools/screenshots/README.md
Normal file
40
tests/tools/screenshots/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Screenshot regression + Discord review tooling
|
||||||
|
|
||||||
|
One comparison engine + a churn-free updater + a Discord reporter for the e2e
|
||||||
|
screenshots. Design and rationale: `~/.claude/plans/…snowglobe.md` (or ask).
|
||||||
|
|
||||||
|
**Source of truth = CI's Linux render.** The dev never authors baselines on the
|
||||||
|
Mac (Mac fonts/GL ≠ CI). Instead, CI renders on every push; when a render change
|
||||||
|
is intentional you *promote* CI's artifact into the committed baselines. The
|
||||||
|
environment isn't pinned — if the host's Mesa/fonts drift, the gate lights up in
|
||||||
|
Discord and you just re-promote (broad + low-intensity change ⇒ likely drift).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `config.ts` — thresholds, baseline dirs, per-engine floors (calibrate!), clustering knobs.
|
||||||
|
- `image-ops.ts` — PNG load/save, pixelmatch diff (AA-excluded), connected-component boxes, triptych compositing, size-cap resize.
|
||||||
|
- `compare.ts` — the engine: classify baselines vs `test-results/` → `test-results/screenshot-diff/report.json` + triptych/heatmap PNGs. `--pair` diffs two files.
|
||||||
|
- `promote.ts` — churn-free updater: pull a CI run's shots (`gh run download`) or `--from DIR`; overwrite a baseline only when pixels differ beyond the floor (verbatim bytes, no re-encode) → no git churn.
|
||||||
|
- `perf-report.ts` — renders the track-only runtime-perf table (loadMs/openMs/FPS) with Δ vs the previous main run (fetched via `gh`).
|
||||||
|
- `post-discord.ts` — the always-on CI-on-main report: SHA + e2e status + perf table, then screenshot triptychs (batched, size-capped, flood-collapsed).
|
||||||
|
- `changelog.ts` — Discord trigger B: git-history diff of committed baseline PNGs (no build/GPU).
|
||||||
|
- `noise.ts` — calibration: diff two identical-input renders → per-engine noise floor.
|
||||||
|
- `gen-manifest.ts` — regenerate `screenshot-manifest.json` (canonical name list + best-effort engine tag) by scanning the committed baselines + specs.
|
||||||
|
|
||||||
|
## npm scripts (run from `tests/`)
|
||||||
|
```
|
||||||
|
npm run screenshots:check # gate: baselines vs test-results → report.json (exit 0; add --fail-on-change to gate)
|
||||||
|
npm run screenshots:promote -- --run <ci-run-id> # churn-free re-baseline from a CI run (or --from DIR)
|
||||||
|
npm run screenshots:report -- --e2e pass # post the CI report to Discord (main+push only; needs DISCORD_WEBHOOK_URL)
|
||||||
|
npm run screenshots:changelog # post the baseline changelog (main+push only)
|
||||||
|
npm run screenshots:noise -- run1/ run2/ # calibrate floors
|
||||||
|
npm run screenshots:manifest # regenerate the manifest (--check to verify it's fresh)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Activation checklist
|
||||||
|
- [x] `screenshot-manifest.json` generated (name list authoritative; engine tags best-effort until calibration).
|
||||||
|
- [x] `scale:'device'`→`'css'` normalized (no-op at CI's DSF=1).
|
||||||
|
1. Add the `DISCORD_WEBHOOK_URL` repo secret — until then everything is inert.
|
||||||
|
2. Calibrate: run the suite twice in CI, `screenshots:noise` the two dirs, set `FLOORS` in `config.ts`.
|
||||||
|
3. First re-baseline: `promote` a clean CI run's render, commit (expect a big, one-time chrome-font diff vs the Mac baselines).
|
||||||
|
4. Delete the old `scripts/{compare,update-baseline}-screenshots.sh`.
|
||||||
|
5. Once floors are proven stable, flip the gate to `--fail-on-change`.
|
||||||
155
tests/tools/screenshots/changelog.ts
Normal file
155
tests/tools/screenshots/changelog.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* Baseline changelog (Discord trigger B) — no build, no GPU.
|
||||||
|
*
|
||||||
|
* On push to main, diff the committed baseline PNGs between two commits and post
|
||||||
|
* a "Baseline changelog" to Discord: a triptych (old | new+boxes | heatmap) for
|
||||||
|
* each CHANGED file, the image for each ADDED, and a titled line for each REMOVED.
|
||||||
|
* "added / removed" only mean anything as a git-history diff, which is why this is
|
||||||
|
* separate from the re-render drift gate.
|
||||||
|
*
|
||||||
|
* CLI (from tests/):
|
||||||
|
* tsx tools/screenshots/changelog.ts [--base REV] [--head REV] [--dry-run] [--force]
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { BASELINE_DIRS, DIFF_OUT_DIR, floorFor } from './config';
|
||||||
|
import { comparePair, type Report } from './compare';
|
||||||
|
import { loadPng, savePng } from './image-ops';
|
||||||
|
import { buildAttachments, paginate, postMessage } from './post-discord';
|
||||||
|
|
||||||
|
function git(root: string, args: string[]): string {
|
||||||
|
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitBlob(root: string, rev: string, repoPath: string): Buffer | null {
|
||||||
|
try {
|
||||||
|
return execFileSync('git', ['-C', root, 'show', `${rev}:${repoPath}`], { maxBuffer: 1 << 28 });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Record<string, string | boolean> {
|
||||||
|
const out: Record<string, string | boolean> = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--dry-run') out.dryRun = true;
|
||||||
|
else if (a === '--force') out.force = true;
|
||||||
|
else if (a === '--base') out.base = argv[++i];
|
||||||
|
else if (a === '--head') out.head = argv[++i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const repoRoot = git(cwd, ['rev-parse', '--show-toplevel']);
|
||||||
|
const head = (args.head as string) || 'HEAD';
|
||||||
|
let base = (args.base as string) || '';
|
||||||
|
if (!base) {
|
||||||
|
try {
|
||||||
|
base = git(cwd, ['rev-parse', `${head}^`]);
|
||||||
|
} catch {
|
||||||
|
console.log('[changelog] no parent commit — nothing to diff');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo-relative baseline dir prefixes (e.g. tests/baseline-screenshots).
|
||||||
|
const prefixes = BASELINE_DIRS.map((d) => path.relative(repoRoot, path.join(cwd, d)));
|
||||||
|
const diff = git(repoRoot, ['diff', '--name-status', base, head, '--', ...prefixes]);
|
||||||
|
if (!diff) {
|
||||||
|
console.log('[changelog] no baseline changes between', base.slice(0, 7), 'and', head);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(cwd, DIFF_OUT_DIR);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-'));
|
||||||
|
const report: Report = {
|
||||||
|
generatedFor: process.env.GITHUB_SHA || head,
|
||||||
|
changed: [],
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
unchangedCount: 0,
|
||||||
|
driftLikely: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of diff.split('\n')) {
|
||||||
|
const parts = line.split('\t');
|
||||||
|
const status = parts[0][0]; // M/A/D/R
|
||||||
|
const repoPath = parts[status === 'R' ? 2 : 1];
|
||||||
|
const oldPath = status === 'R' ? parts[1] : repoPath;
|
||||||
|
const name = path.basename(repoPath);
|
||||||
|
|
||||||
|
if (status === 'A' || status === 'R') {
|
||||||
|
const buf = gitBlob(repoRoot, head, repoPath);
|
||||||
|
if (buf) {
|
||||||
|
const rel = path.join(DIFF_OUT_DIR, `${name}.added.png`);
|
||||||
|
fs.writeFileSync(path.join(cwd, rel), buf);
|
||||||
|
report.added.push({ name, image: rel });
|
||||||
|
}
|
||||||
|
if (status === 'R') report.removed.push({ name: path.basename(oldPath) });
|
||||||
|
} else if (status === 'D') {
|
||||||
|
report.removed.push({ name });
|
||||||
|
} else {
|
||||||
|
// Modified: triptych old vs new.
|
||||||
|
const oldBuf = gitBlob(repoRoot, base, repoPath);
|
||||||
|
const newBuf = gitBlob(repoRoot, head, repoPath);
|
||||||
|
if (!oldBuf || !newBuf) continue;
|
||||||
|
const oldFile = path.join(tmp, `old-${name}`);
|
||||||
|
const newFile = path.join(tmp, `new-${name}`);
|
||||||
|
fs.writeFileSync(oldFile, oldBuf);
|
||||||
|
fs.writeFileSync(newFile, newBuf);
|
||||||
|
const { result, heatmap, triptych } = comparePair(loadPng(oldFile), loadPng(newFile), name, floorFor(name));
|
||||||
|
const triptychRel = path.join(DIFF_OUT_DIR, `${name}.triptych.png`);
|
||||||
|
const heatmapRel = path.join(DIFF_OUT_DIR, `${name}.heatmap.png`);
|
||||||
|
savePng(path.join(cwd, triptychRel), triptych);
|
||||||
|
savePng(path.join(cwd, heatmapRel), heatmap);
|
||||||
|
report.changed.push({ ...result, triptych: triptychRel, heatmap: heatmapRel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.changed.sort((a, b) => b.changedRatio - a.changedRatio);
|
||||||
|
|
||||||
|
const sha7 = (process.env.GITHUB_SHA || head).slice(0, 7);
|
||||||
|
let subject = '';
|
||||||
|
try {
|
||||||
|
subject = git(cwd, ['log', '-1', '--pretty=%s', head]);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
const header =
|
||||||
|
`🗂️ **Baseline changelog** · \`${sha7}\`` +
|
||||||
|
(subject ? `\n> ${subject}` : '') +
|
||||||
|
`\n${report.changed.length} changed, ${report.added.length} added, ${report.removed.length} removed` +
|
||||||
|
(report.removed.length ? '\n➖ REMOVED: ' + report.removed.map((r) => `\`${r.name}\``).join(', ') : '');
|
||||||
|
|
||||||
|
const { files, notes } = buildAttachments(cwd, report);
|
||||||
|
const messages = paginate(header + (notes.length ? '\n' + notes.join('\n') : ''), files);
|
||||||
|
|
||||||
|
const isMainPush = process.env.GITHUB_REF === 'refs/heads/main' && process.env.GITHUB_EVENT_NAME === 'push';
|
||||||
|
if (args.dryRun || (!args.force && !isMainPush)) {
|
||||||
|
for (const [i, m] of messages.entries()) {
|
||||||
|
console.log(`--- message ${i + 1}/${messages.length} (${m.files.length} files) ---`);
|
||||||
|
if (m.content) console.log(m.content);
|
||||||
|
for (const f of m.files) console.log(` [attach] ${f.name} (${f.buffer.length} bytes)`);
|
||||||
|
}
|
||||||
|
if (!args.dryRun) console.log('[changelog] not a push to main — not posting');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const webhook = process.env.DISCORD_WEBHOOK_URL;
|
||||||
|
if (!webhook) {
|
||||||
|
console.log('[changelog] DISCORD_WEBHOOK_URL unset — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const m of messages) await postMessage(webhook, m);
|
||||||
|
console.log(`[changelog] posted ${messages.length} message(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(`[changelog] ${e.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
238
tests/tools/screenshots/compare.ts
Normal file
238
tests/tools/screenshots/compare.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
/**
|
||||||
|
* The one screenshot comparison engine + its CLI.
|
||||||
|
*
|
||||||
|
* Run modes (from the `tests/` directory):
|
||||||
|
* tsx tools/screenshots/compare.ts # gate: baselines vs test-results
|
||||||
|
* tsx tools/screenshots/compare.ts --fail-on-change # same, but exit 1 on any change
|
||||||
|
* tsx tools/screenshots/compare.ts --pair OLD NEW --name N --out DIR # diff two files
|
||||||
|
*
|
||||||
|
* The gate mode classifies every screenshot into changed / added / removed /
|
||||||
|
* unchanged, writes per-change triptych + heatmap PNGs and a machine-readable
|
||||||
|
* report.json into DIFF_OUT_DIR, and (unless --fail-on-change) exits 0 so it can
|
||||||
|
* run report-only first. post-discord.ts and the changelog workflow import the
|
||||||
|
* exported helpers rather than re-deriving the diff.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { PNG } from 'pngjs';
|
||||||
|
import {
|
||||||
|
BASELINE_DIRS,
|
||||||
|
RESULTS_DIR,
|
||||||
|
DIFF_OUT_DIR,
|
||||||
|
MANIFEST_PATH,
|
||||||
|
type EngineFloor,
|
||||||
|
type Manifest,
|
||||||
|
floorFor,
|
||||||
|
} from './config';
|
||||||
|
import { diffImages, cluster, drawBoxes, composite, loadPng, savePng, type Box } from './image-ops';
|
||||||
|
|
||||||
|
export type PairVerdict = 'unchanged' | 'changed';
|
||||||
|
|
||||||
|
export type PairResult = {
|
||||||
|
name: string;
|
||||||
|
verdict: PairVerdict;
|
||||||
|
dimsMatch: boolean;
|
||||||
|
diffPixels: number;
|
||||||
|
changedRatio: number;
|
||||||
|
meanChannelDiff: number;
|
||||||
|
boxes: Box[];
|
||||||
|
/** heuristic: a broad, low-intensity change smells like host env drift, not a code regression */
|
||||||
|
driftHint: 'regression-like' | 'drift-like' | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Broad + low-intensity ⇒ likely environment drift rather than a localized regression. */
|
||||||
|
function driftHint(changed: boolean, changedRatio: number, meanChannelDiff: number): PairResult['driftHint'] {
|
||||||
|
if (!changed) return null;
|
||||||
|
return changedRatio > 0.05 && meanChannelDiff < 4 ? 'drift-like' : 'regression-like';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare two decoded PNGs; returns the verdict/metrics plus the heatmap and old|new+boxes|heatmap triptych. */
|
||||||
|
export function comparePair(
|
||||||
|
oldPng: PNG,
|
||||||
|
newPng: PNG,
|
||||||
|
name: string,
|
||||||
|
floor: EngineFloor
|
||||||
|
): { result: PairResult; heatmap: PNG; triptych: PNG } {
|
||||||
|
const d = diffImages(oldPng, newPng);
|
||||||
|
const changed = !d.dimsMatch || d.changedRatio > floor.changedRatio;
|
||||||
|
const boxes = changed ? cluster(d.mask, d.width, d.height) : [];
|
||||||
|
const triptych = composite([oldPng, drawBoxes(newPng, boxes), d.heatmap]);
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
name,
|
||||||
|
verdict: changed ? 'changed' : 'unchanged',
|
||||||
|
dimsMatch: d.dimsMatch,
|
||||||
|
diffPixels: d.diffPixels,
|
||||||
|
changedRatio: d.changedRatio,
|
||||||
|
meanChannelDiff: d.meanChannelDiff,
|
||||||
|
boxes,
|
||||||
|
driftHint: driftHint(changed, d.changedRatio, d.meanChannelDiff),
|
||||||
|
},
|
||||||
|
heatmap: d.heatmap,
|
||||||
|
triptych,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChangedEntry = PairResult & { triptych: string; heatmap: string };
|
||||||
|
export type Report = {
|
||||||
|
generatedFor: string | null;
|
||||||
|
changed: ChangedEntry[];
|
||||||
|
added: Array<{ name: string; image: string }>;
|
||||||
|
removed: Array<{ name: string }>;
|
||||||
|
unchangedCount: number;
|
||||||
|
/** many changes, mostly drift-like ⇒ probably a host Mesa/font refresh; re-promote rather than debug */
|
||||||
|
driftLikely: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DRIFT_BULK = 20;
|
||||||
|
|
||||||
|
function listPngs(dir: string): string[] {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** basename → absolute baseline path (first BASELINE_DIRS entry wins; collisions warned). */
|
||||||
|
function baselineIndex(root: string): Map<string, string> {
|
||||||
|
const index = new Map<string, string>();
|
||||||
|
for (const dir of BASELINE_DIRS) {
|
||||||
|
const abs = path.join(root, dir);
|
||||||
|
for (const name of listPngs(abs)) {
|
||||||
|
if (index.has(name)) {
|
||||||
|
console.warn(`[compare] duplicate baseline name ${name} (${dir} shadowed by earlier dir)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index.set(name, path.join(abs, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadManifest(root: string): Manifest | undefined {
|
||||||
|
const p = path.join(root, MANIFEST_PATH);
|
||||||
|
if (!fs.existsSync(p)) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(p, 'utf8')) as Manifest;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[compare] could not parse ${MANIFEST_PATH}: ${(e as Error).message}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full gate run: classify baselines vs the current run's screenshots. */
|
||||||
|
export function classify(root: string, sha: string | null): Report {
|
||||||
|
const baselines = baselineIndex(root);
|
||||||
|
const resultsDir = path.join(root, RESULTS_DIR);
|
||||||
|
const actuals = new Set(listPngs(resultsDir));
|
||||||
|
const manifest = loadManifest(root);
|
||||||
|
const outDir = path.join(root, DIFF_OUT_DIR);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const report: Report = {
|
||||||
|
generatedFor: sha,
|
||||||
|
changed: [],
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
unchangedCount: 0,
|
||||||
|
driftLikely: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Changed / unchanged / removed: iterate the committed baselines.
|
||||||
|
for (const [name, baselinePath] of baselines) {
|
||||||
|
if (!actuals.has(name)) {
|
||||||
|
// Missing output. Only call it REMOVED when the manifest expects it — otherwise
|
||||||
|
// a flaky/OOM'd/skipped spec that simply didn't write a PNG would masquerade as
|
||||||
|
// an intentional removal. (The stronger "did the spec actually run" cross-check
|
||||||
|
// against the Playwright JSON report lands with the manifest work.)
|
||||||
|
if (manifest?.screenshots.some((e) => e.name === name)) {
|
||||||
|
report.removed.push({ name });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { result, heatmap, triptych } = comparePair(
|
||||||
|
loadPng(baselinePath),
|
||||||
|
loadPng(path.join(resultsDir, name)),
|
||||||
|
name,
|
||||||
|
floorFor(name, manifest)
|
||||||
|
);
|
||||||
|
if (result.verdict === 'unchanged') {
|
||||||
|
report.unchangedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const triptychRel = path.join(DIFF_OUT_DIR, `${name}.triptych.png`);
|
||||||
|
const heatmapRel = path.join(DIFF_OUT_DIR, `${name}.heatmap.png`);
|
||||||
|
savePng(path.join(root, triptychRel), triptych);
|
||||||
|
savePng(path.join(root, heatmapRel), heatmap);
|
||||||
|
report.changed.push({ ...result, triptych: triptychRel, heatmap: heatmapRel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Added: an actual with no committed baseline.
|
||||||
|
for (const name of actuals) {
|
||||||
|
if (baselines.has(name)) continue;
|
||||||
|
const imageRel = path.join(DIFF_OUT_DIR, `${name}.added.png`);
|
||||||
|
fs.copyFileSync(path.join(resultsDir, name), path.join(root, imageRel));
|
||||||
|
report.added.push({ name, image: imageRel });
|
||||||
|
}
|
||||||
|
|
||||||
|
const driftLike = report.changed.filter((c) => c.driftHint === 'drift-like').length;
|
||||||
|
report.driftLikely = report.changed.length >= DRIFT_BULK && driftLike * 2 >= report.changed.length;
|
||||||
|
|
||||||
|
report.changed.sort((a, b) => b.changedRatio - a.changedRatio);
|
||||||
|
report.added.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
report.removed.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Record<string, string | boolean> {
|
||||||
|
const out: Record<string, string | boolean> = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--fail-on-change') out.failOnChange = true;
|
||||||
|
else if (a === '--pair') {
|
||||||
|
out.oldPath = argv[++i];
|
||||||
|
out.newPath = argv[++i];
|
||||||
|
} else if (a === '--name') out.name = argv[++i];
|
||||||
|
else if (a === '--out') out.out = argv[++i];
|
||||||
|
else if (a === '--sha') out.sha = argv[++i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPair(args: Record<string, string | boolean>): void {
|
||||||
|
const name = (args.name as string) || 'pair';
|
||||||
|
const outDir = (args.out as string) || path.join(RESULTS_DIR, 'screenshot-diff');
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const oldPng = fs.existsSync(args.oldPath as string)
|
||||||
|
? loadPng(args.oldPath as string)
|
||||||
|
: new PNG({ width: 1, height: 1 });
|
||||||
|
const newPng = fs.existsSync(args.newPath as string)
|
||||||
|
? loadPng(args.newPath as string)
|
||||||
|
: new PNG({ width: 1, height: 1 });
|
||||||
|
const { result, heatmap, triptych } = comparePair(oldPng, newPng, name, floorFor(name));
|
||||||
|
savePng(path.join(outDir, `${name}.triptych.png`), triptych);
|
||||||
|
savePng(path.join(outDir, `${name}.heatmap.png`), heatmap);
|
||||||
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
if (args.oldPath && args.newPath) {
|
||||||
|
runPair(args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = process.cwd();
|
||||||
|
const report = classify(root, (args.sha as string) || process.env.GITHUB_SHA || null);
|
||||||
|
fs.writeFileSync(path.join(root, DIFF_OUT_DIR, 'report.json'), JSON.stringify(report, null, 2));
|
||||||
|
const { changed, added, removed, unchangedCount, driftLikely } = report;
|
||||||
|
console.log(
|
||||||
|
`[compare] changed=${changed.length} added=${added.length} removed=${removed.length} ` +
|
||||||
|
`unchanged=${unchangedCount}${driftLikely ? ' (looks like environment drift → re-promote)' : ''}`
|
||||||
|
);
|
||||||
|
for (const c of changed.slice(0, 10)) {
|
||||||
|
console.log(` CHANGED ${c.name} ratio=${(c.changedRatio * 100).toFixed(3)}% ${c.driftHint}`);
|
||||||
|
}
|
||||||
|
if (args.failOnChange && (changed.length || added.length || removed.length)) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
84
tests/tools/screenshots/config.ts
Normal file
84
tests/tools/screenshots/config.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Central config for the screenshot regression + review tooling.
|
||||||
|
*
|
||||||
|
* The comparison engine (compare.ts), the churn-free updater (promote.ts) and
|
||||||
|
* the Discord reporter (post-discord.ts) all read their knobs from here so
|
||||||
|
* there is exactly one place to tune thresholds and paths.
|
||||||
|
*
|
||||||
|
* Paths are relative to the `tests/` directory (that is the working directory
|
||||||
|
* the npm scripts and CI steps run from).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Committed baseline directories, scanned in order. Filenames are the keys. */
|
||||||
|
export const BASELINE_DIRS = ['baseline-screenshots', 'e2e/baseline-screenshots'] as const;
|
||||||
|
|
||||||
|
/** Where Playwright writes the current run's screenshots (gitignored). */
|
||||||
|
export const RESULTS_DIR = 'test-results';
|
||||||
|
|
||||||
|
/** Where compare.ts writes diff/heatmap/triptych artifacts (gitignored). */
|
||||||
|
export const DIFF_OUT_DIR = 'test-results/screenshot-diff';
|
||||||
|
|
||||||
|
/** The manifest that records every expected screenshot + which engine renders it. */
|
||||||
|
export const MANIFEST_PATH = 'screenshot-manifest.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pixelmatch per-pixel settings.
|
||||||
|
* - `threshold` is the YIQ perceptual distance (0..1) below which two pixels
|
||||||
|
* are considered equal. 0.1 tolerates gamma/AA jitter but catches real colour
|
||||||
|
* change.
|
||||||
|
* - `includeAA: false` (the pixelmatch default) means anti-aliased edge pixels
|
||||||
|
* are DETECTED AND IGNORED — exactly the sub-pixel/AA noise the old
|
||||||
|
* `maxDiff>16` counter was dominated by (see screenshot-compare.ts:36-43).
|
||||||
|
*/
|
||||||
|
export const PIXELMATCH = { threshold: 0.1, includeAA: false } as const;
|
||||||
|
|
||||||
|
/** Colour pixelmatch paints a real (non-AA) diff pixel with — the mask reads this back. */
|
||||||
|
export const DIFF_COLOR: [number, number, number] = [255, 0, 0];
|
||||||
|
|
||||||
|
/** Connected-component clustering ("where to look") parameters. */
|
||||||
|
export const CLUSTER = {
|
||||||
|
dilate: 2, // grow the mask so fragmented glyph pixels merge into one box
|
||||||
|
minBoxArea: 16, // drop specks smaller than this (px²)
|
||||||
|
maxBoxes: 6, // draw at most this many (largest-first) red boxes
|
||||||
|
boxColor: [255, 0, 0] as [number, number, number],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Horizontal montage layout for the old | new+boxes | heatmap triptych. */
|
||||||
|
export const TRIPTYCH = {
|
||||||
|
gap: 8,
|
||||||
|
bg: [24, 24, 24, 255] as [number, number, number, number],
|
||||||
|
padFill: [40, 0, 40, 255] as [number, number, number, number], // magenta pad on dim-mismatch
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-engine verdict floors. A screenshot is CHANGED when its AA-excluded
|
||||||
|
* changed-pixel ratio exceeds `changedRatio`. `meanChannelGuard` is recorded
|
||||||
|
* for the drift-vs-regression heuristic (broad + low-intensity ⇒ environment
|
||||||
|
* drift, not a localized regression), not for the primary verdict.
|
||||||
|
*
|
||||||
|
* NOTE: these are PLACEHOLDERS. `npm run screenshots:noise` renders the suite
|
||||||
|
* twice on the CI host and prints the real intra-CI floor per engine; set
|
||||||
|
* `changedRatio ≈ measured × 3` from that and commit the numbers here.
|
||||||
|
*/
|
||||||
|
export type EngineFloor = { changedRatio: number; meanChannelGuard: number };
|
||||||
|
|
||||||
|
export const FLOORS: Record<string, EngineFloor> = {
|
||||||
|
'firefox-llvmpipe': { changedRatio: 0.002, meanChannelGuard: 2.0 },
|
||||||
|
'chromium-swiftshader': { changedRatio: 0.002, meanChannelGuard: 2.0 },
|
||||||
|
default: { changedRatio: 0.002, meanChannelGuard: 2.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-file rectangles to ignore before diffing (e.g. a live clock).
|
||||||
|
* Keyed by screenshot filename. Empty for now.
|
||||||
|
*/
|
||||||
|
export const IGNORE_REGIONS: Record<string, Array<{ x: number; y: number; width: number; height: number }>> = {};
|
||||||
|
|
||||||
|
export type ManifestEntry = { name: string; engine: string };
|
||||||
|
export type Manifest = { screenshots: ManifestEntry[] };
|
||||||
|
|
||||||
|
/** Resolve the verdict floor for a screenshot via the manifest's engine tag. */
|
||||||
|
export function floorFor(name: string, manifest?: Manifest): EngineFloor {
|
||||||
|
const engine = manifest?.screenshots.find((e) => e.name === name)?.engine;
|
||||||
|
return (engine && FLOORS[engine]) || FLOORS.default;
|
||||||
|
}
|
||||||
124
tests/tools/screenshots/gen-manifest.ts
Normal file
124
tests/tools/screenshots/gen-manifest.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* Generate tests/screenshot-manifest.json — the canonical list of expected
|
||||||
|
* screenshots + the engine that renders each.
|
||||||
|
*
|
||||||
|
* The NAME list is authoritative (it's the committed baseline set) and is what
|
||||||
|
* lets compare/promote tell an intentional REMOVAL from a flaky/absent render.
|
||||||
|
* The ENGINE tag is best-effort (attributed by scanning which spec writes each
|
||||||
|
* `test-results/<prefix>` and which project runs that spec) and only feeds the
|
||||||
|
* per-engine floors — refine after calibration.
|
||||||
|
*
|
||||||
|
* Engine routing (from the two playwright configs):
|
||||||
|
* e2e/*.spec.ts → chromium-swiftshader (npm run test — wx suite)
|
||||||
|
* kicad/*.spec.ts → firefox-llvmpipe, or chromium-swiftshader if the
|
||||||
|
* spec is in PCBNEW_FAMILY_SPECS (chromium-ci on CI)
|
||||||
|
* web/*.spec.ts → firefox-llvmpipe (web config, --project=firefox)
|
||||||
|
*
|
||||||
|
* CLI (from tests/): tsx tools/screenshots/gen-manifest.ts [--check]
|
||||||
|
* --check exits 1 if the committed manifest is stale (for CI hygiene).
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { BASELINE_DIRS, MANIFEST_PATH, type Manifest } from './config';
|
||||||
|
|
||||||
|
const CHROMIUM = 'chromium-swiftshader';
|
||||||
|
const FIREFOX = 'firefox-llvmpipe';
|
||||||
|
const DEFAULT_ENGINE = CHROMIUM; // baseline-screenshots is dominated by the wx suite
|
||||||
|
|
||||||
|
function listSpecs(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (!fs.existsSync(dir)) return out;
|
||||||
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) out.push(...listSpecs(p));
|
||||||
|
// Only spec files — a screenshot literal in a util (e.g. completeWizard's
|
||||||
|
// wizard-*) would be attributed to the util's dir, not its real caller.
|
||||||
|
// Leaving those unmatched lets them fall to the correct chromium default.
|
||||||
|
else if (e.name.endsWith('.spec.ts')) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pcbnew-family spec basenames (routed to chromium-ci on CI), read from the config. */
|
||||||
|
function pcbnewFamily(root: string): Set<string> {
|
||||||
|
const cfg = fs.readFileSync(path.join(root, 'playwright-kicad.config.ts'), 'utf8');
|
||||||
|
const block = cfg.match(/PCBNEW_FAMILY_SPECS\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? '';
|
||||||
|
return new Set([...block.matchAll(/'[^']*?([\w.-]+\.spec\.ts)'/g)].map((m) => m[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineForSpec(root: string, specPath: string, family: Set<string>): string {
|
||||||
|
const rel = path.relative(root, specPath);
|
||||||
|
const base = path.basename(specPath);
|
||||||
|
if (rel.startsWith('e2e/')) return CHROMIUM;
|
||||||
|
if (rel.startsWith('web/')) return FIREFOX;
|
||||||
|
if (rel.startsWith('kicad/')) return family.has(base) ? CHROMIUM : FIREFOX;
|
||||||
|
return DEFAULT_ENGINE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build prefix → engine from every `test-results/<prefix>` literal in the specs. */
|
||||||
|
function prefixEngineMap(root: string, family: Set<string>): Array<{ prefix: string; engine: string }> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const dir of ['e2e', 'kicad', 'web']) {
|
||||||
|
for (const spec of listSpecs(path.join(root, dir))) {
|
||||||
|
const engine = engineForSpec(root, spec, family);
|
||||||
|
const content = fs.readFileSync(spec, 'utf8');
|
||||||
|
for (const m of content.matchAll(/test-results\/([A-Za-z0-9_-]+)/g)) {
|
||||||
|
const prefix = m[1];
|
||||||
|
// First writer wins; a chromium spec shouldn't be overridden by a later firefox one for the same literal.
|
||||||
|
if (!map.has(prefix)) map.set(prefix, engine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Longest prefix first so the most specific match wins.
|
||||||
|
return [...map.entries()].map(([prefix, engine]) => ({ prefix, engine })).sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBaselines(root: string): string[] {
|
||||||
|
const names = new Set<string>();
|
||||||
|
for (const dir of BASELINE_DIRS) {
|
||||||
|
const abs = path.join(root, dir);
|
||||||
|
if (!fs.existsSync(abs)) continue;
|
||||||
|
for (const f of fs.readdirSync(abs)) if (f.toLowerCase().endsWith('.png')) names.add(f);
|
||||||
|
}
|
||||||
|
return [...names].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const check = process.argv.includes('--check');
|
||||||
|
const root = process.cwd();
|
||||||
|
const family = pcbnewFamily(root);
|
||||||
|
const prefixes = prefixEngineMap(root, family);
|
||||||
|
|
||||||
|
let unmatched = 0;
|
||||||
|
const screenshots = listBaselines(root).map((name) => {
|
||||||
|
const stem = name.replace(/\.png$/i, '');
|
||||||
|
const hit = prefixes.find((p) => stem === p.prefix || stem.startsWith(p.prefix));
|
||||||
|
if (!hit) unmatched++;
|
||||||
|
return { name, engine: hit?.engine ?? DEFAULT_ENGINE };
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest: Manifest & { _note: string } = {
|
||||||
|
_note: 'engine tags are best-effort (gen-manifest.ts); the name list is authoritative. Refine engines after calibration.',
|
||||||
|
screenshots,
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(manifest, null, 2) + '\n';
|
||||||
|
const outPath = path.join(root, MANIFEST_PATH);
|
||||||
|
|
||||||
|
const dist = screenshots.reduce<Record<string, number>>((d, s) => ((d[s.engine] = (d[s.engine] ?? 0) + 1), d), {});
|
||||||
|
console.log(`[manifest] ${screenshots.length} screenshots; engines=${JSON.stringify(dist)}; default-assigned=${unmatched}`);
|
||||||
|
|
||||||
|
if (check) {
|
||||||
|
const current = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : '';
|
||||||
|
if (current !== json) {
|
||||||
|
console.error('[manifest] STALE — run `npm run screenshots:manifest` and commit');
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log('[manifest] up to date');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(outPath, json);
|
||||||
|
console.log(`[manifest] wrote ${MANIFEST_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
256
tests/tools/screenshots/image-ops.ts
Normal file
256
tests/tools/screenshots/image-ops.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
/**
|
||||||
|
* Reusable pixel operations for the screenshot tooling: PNG load/save, padding,
|
||||||
|
* the pixelmatch-backed diff, connected-component clustering ("where to look"),
|
||||||
|
* box drawing, and horizontal compositing for the triptych.
|
||||||
|
*
|
||||||
|
* All images are handled as pngjs PNGs whose `.data` is a length `w*h*4` RGBA
|
||||||
|
* Buffer, regardless of the source PNG colour type (pngjs normalizes to RGBA).
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { PNG } from 'pngjs';
|
||||||
|
import pixelmatch from 'pixelmatch';
|
||||||
|
import { PIXELMATCH, DIFF_COLOR, CLUSTER, TRIPTYCH } from './config';
|
||||||
|
|
||||||
|
export type Box = { x: number; y: number; width: number; height: number; area: number };
|
||||||
|
|
||||||
|
export type DiffResult = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dimsMatch: boolean;
|
||||||
|
/** AA-excluded changed-pixel count (from pixelmatch). */
|
||||||
|
diffPixels: number;
|
||||||
|
/** diffPixels / (width*height). */
|
||||||
|
changedRatio: number;
|
||||||
|
/** mean |Δ| over every RGBA channel sample of the whole frame (matches the legacy metric). */
|
||||||
|
meanChannelDiff: number;
|
||||||
|
/** pixelmatch heatmap: dimmed base + red diffs / yellow AA. */
|
||||||
|
heatmap: PNG;
|
||||||
|
/** boolean mask (1 = real, non-AA changed pixel) for clustering. */
|
||||||
|
mask: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadPng(file: string): PNG {
|
||||||
|
return PNG.sync.read(fs.readFileSync(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePng(file: string, png: PNG): void {
|
||||||
|
fs.writeFileSync(file, PNG.sync.write(png));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New PNG of `w`×`h` filled with `fill` (RGBA), with `src` blitted at top-left. */
|
||||||
|
export function padTo(src: PNG, w: number, h: number, fill: [number, number, number, number]): PNG {
|
||||||
|
const out = new PNG({ width: w, height: h });
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
out.data[i] = fill[0];
|
||||||
|
out.data[i + 1] = fill[1];
|
||||||
|
out.data[i + 2] = fill[2];
|
||||||
|
out.data[i + 3] = fill[3];
|
||||||
|
}
|
||||||
|
for (let y = 0; y < Math.min(h, src.height); y++) {
|
||||||
|
const srcRow = y * src.width * 4;
|
||||||
|
const dstRow = y * w * 4;
|
||||||
|
const rowBytes = Math.min(w, src.width) * 4;
|
||||||
|
src.data.copy(out.data, dstRow, srcRow, srcRow + rowBytes);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff two images. On a dimension mismatch both are padded (magenta) to the
|
||||||
|
* union size and `dimsMatch` is false (the caller treats that as CHANGED).
|
||||||
|
* The changed-pixel mask is read back from the heatmap's red diff pixels, so it
|
||||||
|
* inherits pixelmatch's anti-aliasing exclusion.
|
||||||
|
*/
|
||||||
|
export function diffImages(a: PNG, b: PNG): DiffResult {
|
||||||
|
const dimsMatch = a.width === b.width && a.height === b.height;
|
||||||
|
const width = Math.max(a.width, b.width);
|
||||||
|
const height = Math.max(a.height, b.height);
|
||||||
|
const pa = dimsMatch ? a : padTo(a, width, height, TRIPTYCH.padFill);
|
||||||
|
const pb = dimsMatch ? b : padTo(b, width, height, TRIPTYCH.padFill);
|
||||||
|
|
||||||
|
const heatmap = new PNG({ width, height });
|
||||||
|
const diffPixels = pixelmatch(pa.data, pb.data, heatmap.data, width, height, {
|
||||||
|
threshold: PIXELMATCH.threshold,
|
||||||
|
includeAA: PIXELMATCH.includeAA,
|
||||||
|
diffColor: DIFF_COLOR,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whole-frame mean channel delta (drift-vs-regression heuristic input).
|
||||||
|
let totalChannelDiff = 0;
|
||||||
|
for (let i = 0; i < pa.data.length; i++) {
|
||||||
|
totalChannelDiff += Math.abs(pa.data[i] - pb.data[i]);
|
||||||
|
}
|
||||||
|
const meanChannelDiff = totalChannelDiff / pa.data.length;
|
||||||
|
|
||||||
|
// Mask = heatmap pixels painted with DIFF_COLOR (red). AA pixels are yellow, so excluded.
|
||||||
|
const mask = new Uint8Array(width * height);
|
||||||
|
for (let p = 0; p < width * height; p++) {
|
||||||
|
const o = p * 4;
|
||||||
|
if (heatmap.data[o] > 200 && heatmap.data[o + 1] < 80 && heatmap.data[o + 2] < 80) {
|
||||||
|
mask[p] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dimsMatch,
|
||||||
|
diffPixels,
|
||||||
|
changedRatio: diffPixels / (width * height),
|
||||||
|
meanChannelDiff,
|
||||||
|
heatmap,
|
||||||
|
mask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dilate a boolean mask by `r` (square structuring element), out of place. */
|
||||||
|
function dilate(mask: Uint8Array, w: number, h: number, r: number): Uint8Array {
|
||||||
|
if (r <= 0) return mask;
|
||||||
|
const out = new Uint8Array(w * h);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
if (!mask[y * w + x]) continue;
|
||||||
|
const y0 = Math.max(0, y - r);
|
||||||
|
const y1 = Math.min(h - 1, y + r);
|
||||||
|
const x0 = Math.max(0, x - r);
|
||||||
|
const x1 = Math.min(w - 1, x + r);
|
||||||
|
for (let yy = y0; yy <= y1; yy++) {
|
||||||
|
for (let xx = x0; xx <= x1; xx++) out[yy * w + xx] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 8-connected connected-components over the (dilated) mask → bounding boxes,
|
||||||
|
* largest-area first, capped at `maxBoxes`, specks below `minBoxArea` dropped.
|
||||||
|
*/
|
||||||
|
export function cluster(mask: Uint8Array, w: number, h: number): Box[] {
|
||||||
|
const grown = dilate(mask, w, h, CLUSTER.dilate);
|
||||||
|
const seen = new Uint8Array(w * h);
|
||||||
|
const boxes: Box[] = [];
|
||||||
|
const stack: number[] = [];
|
||||||
|
|
||||||
|
for (let start = 0; start < grown.length; start++) {
|
||||||
|
if (!grown[start] || seen[start]) continue;
|
||||||
|
let minX = w, minY = h, maxX = 0, maxY = 0, count = 0;
|
||||||
|
stack.push(start);
|
||||||
|
seen[start] = 1;
|
||||||
|
while (stack.length) {
|
||||||
|
const p = stack.pop()!;
|
||||||
|
const px = p % w;
|
||||||
|
const py = (p - px) / w;
|
||||||
|
count++;
|
||||||
|
if (px < minX) minX = px;
|
||||||
|
if (px > maxX) maxX = px;
|
||||||
|
if (py < minY) minY = py;
|
||||||
|
if (py > maxY) maxY = py;
|
||||||
|
for (let dy = -1; dy <= 1; dy++) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) {
|
||||||
|
if (!dx && !dy) continue;
|
||||||
|
const nx = px + dx;
|
||||||
|
const ny = py + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||||
|
const np = ny * w + nx;
|
||||||
|
if (grown[np] && !seen[np]) {
|
||||||
|
seen[np] = 1;
|
||||||
|
stack.push(np);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bw = maxX - minX + 1;
|
||||||
|
const bh = maxY - minY + 1;
|
||||||
|
const area = bw * bh;
|
||||||
|
if (area >= CLUSTER.minBoxArea) {
|
||||||
|
boxes.push({ x: minX, y: minY, width: bw, height: bh, area });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boxes.sort((p, q) => q.area - p.area);
|
||||||
|
return boxes.slice(0, CLUSTER.maxBoxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw 2px rectangle outlines for each box onto a copy of `png`. */
|
||||||
|
export function drawBoxes(png: PNG, boxes: Box[]): PNG {
|
||||||
|
const out = new PNG({ width: png.width, height: png.height });
|
||||||
|
png.data.copy(out.data);
|
||||||
|
const [r, g, b] = CLUSTER.boxColor;
|
||||||
|
const set = (x: number, y: number) => {
|
||||||
|
if (x < 0 || y < 0 || x >= out.width || y >= out.height) return;
|
||||||
|
const o = (y * out.width + x) * 4;
|
||||||
|
out.data[o] = r;
|
||||||
|
out.data[o + 1] = g;
|
||||||
|
out.data[o + 2] = b;
|
||||||
|
out.data[o + 3] = 255;
|
||||||
|
};
|
||||||
|
for (const box of boxes) {
|
||||||
|
for (let t = 0; t < 2; t++) {
|
||||||
|
for (let x = box.x; x < box.x + box.width; x++) {
|
||||||
|
set(x, box.y + t);
|
||||||
|
set(x, box.y + box.height - 1 - t);
|
||||||
|
}
|
||||||
|
for (let y = box.y; y < box.y + box.height; y++) {
|
||||||
|
set(box.x + t, y);
|
||||||
|
set(box.x + box.width - 1 - t, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest-neighbour downscale by `scale` (0<scale<1). Fast, quality secondary — it only exists to fit Discord's size caps. */
|
||||||
|
export function resizeNearest(png: PNG, scale: number): PNG {
|
||||||
|
const w = Math.max(1, Math.round(png.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(png.height * scale));
|
||||||
|
const out = new PNG({ width: w, height: h });
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const sy = Math.min(png.height - 1, Math.floor(y / scale));
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const sx = Math.min(png.width - 1, Math.floor(x / scale));
|
||||||
|
const s = (sy * png.width + sx) * 4;
|
||||||
|
const d = (y * w + x) * 4;
|
||||||
|
out.data[d] = png.data[s];
|
||||||
|
out.data[d + 1] = png.data[s + 1];
|
||||||
|
out.data[d + 2] = png.data[s + 2];
|
||||||
|
out.data[d + 3] = png.data[s + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode `png`, halving resolution until the PNG is <= maxBytes (or it can't shrink further). */
|
||||||
|
export function encodeWithinCap(png: PNG, maxBytes: number): Buffer {
|
||||||
|
let current = png;
|
||||||
|
let buf = PNG.sync.write(current);
|
||||||
|
while (buf.length > maxBytes && current.width > 320) {
|
||||||
|
current = resizeNearest(current, 0.5);
|
||||||
|
buf = PNG.sync.write(current);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontally montage images (heights normalized to the tallest) with a gap + bg. */
|
||||||
|
export function composite(panels: PNG[]): PNG {
|
||||||
|
const gap = TRIPTYCH.gap;
|
||||||
|
const bg = TRIPTYCH.bg;
|
||||||
|
const height = Math.max(...panels.map((p) => p.height));
|
||||||
|
const width = panels.reduce((s, p) => s + p.width, 0) + gap * (panels.length - 1);
|
||||||
|
const out = new PNG({ width, height });
|
||||||
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
|
out.data[i] = bg[0];
|
||||||
|
out.data[i + 1] = bg[1];
|
||||||
|
out.data[i + 2] = bg[2];
|
||||||
|
out.data[i + 3] = bg[3];
|
||||||
|
}
|
||||||
|
let xOffset = 0;
|
||||||
|
for (const panel of panels) {
|
||||||
|
for (let y = 0; y < panel.height; y++) {
|
||||||
|
const srcRow = y * panel.width * 4;
|
||||||
|
const dstRow = (y * width + xOffset) * 4;
|
||||||
|
panel.data.copy(out.data, dstRow, srcRow, srcRow + panel.width * 4);
|
||||||
|
}
|
||||||
|
xOffset += panel.width + gap;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
76
tests/tools/screenshots/noise.ts
Normal file
76
tests/tools/screenshots/noise.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* Calibration: measure the intra-CI noise floor.
|
||||||
|
*
|
||||||
|
* Render the suite twice on the same host (into two dirs), then:
|
||||||
|
* tsx tools/screenshots/noise.ts <run1-dir> <run2-dir>
|
||||||
|
* prints, per engine (via the manifest) and globally, the max / p95 / mean
|
||||||
|
* changed-pixel ratio between the two identical-input renders. Set
|
||||||
|
* config.ts FLOORS.<engine>.changedRatio ≈ (max × 3) from this.
|
||||||
|
*
|
||||||
|
* A near-zero floor means the render is deterministic enough to catch real
|
||||||
|
* changes tightly; a large floor exposes nondeterminism to chase before the
|
||||||
|
* gate can be trusted.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { MANIFEST_PATH, type Manifest } from './config';
|
||||||
|
import { diffImages, loadPng } from './image-ops';
|
||||||
|
|
||||||
|
function listPngs(dir: string): string[] {
|
||||||
|
return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.png')) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineOf(name: string, manifest?: Manifest): string {
|
||||||
|
return manifest?.screenshots.find((e) => e.name === name)?.engine ?? 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stats(values: number[]): { n: number; max: number; p95: number; mean: number } {
|
||||||
|
if (!values.length) return { n: 0, max: 0, p95: 0, mean: 0 };
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
|
||||||
|
return {
|
||||||
|
n: values.length,
|
||||||
|
max: sorted[sorted.length - 1],
|
||||||
|
p95,
|
||||||
|
mean: values.reduce((s, v) => s + v, 0) / values.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const [dir1, dir2] = process.argv.slice(2);
|
||||||
|
if (!dir1 || !dir2) {
|
||||||
|
console.error('usage: noise.ts <run1-dir> <run2-dir>');
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = process.cwd();
|
||||||
|
const manifestPath = path.join(root, MANIFEST_PATH);
|
||||||
|
const manifest = fs.existsSync(manifestPath)
|
||||||
|
? (JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const common = listPngs(dir1).filter((n) => fs.existsSync(path.join(dir2, n)));
|
||||||
|
const byEngine = new Map<string, number[]>();
|
||||||
|
const all: number[] = [];
|
||||||
|
for (const name of common) {
|
||||||
|
const d = diffImages(loadPng(path.join(dir1, name)), loadPng(path.join(dir2, name)));
|
||||||
|
const ratio = d.dimsMatch ? d.changedRatio : 1;
|
||||||
|
all.push(ratio);
|
||||||
|
const eng = engineOf(name, manifest);
|
||||||
|
(byEngine.get(eng) ?? byEngine.set(eng, []).get(eng)!).push(ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = (label: string, values: number[]) => {
|
||||||
|
const s = stats(values);
|
||||||
|
console.log(
|
||||||
|
`${label.padEnd(24)} n=${String(s.n).padStart(4)} max=${(s.max * 100).toFixed(4)}% ` +
|
||||||
|
`p95=${(s.p95 * 100).toFixed(4)}% mean=${(s.mean * 100).toFixed(4)}% ` +
|
||||||
|
`→ suggest changedRatio=${(s.max * 3).toExponential(2)}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
console.log(`[noise] compared ${common.length} screenshots rendered twice`);
|
||||||
|
for (const [eng, values] of byEngine) report(eng, values);
|
||||||
|
report('ALL', all);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
164
tests/tools/screenshots/perf-report.ts
Normal file
164
tests/tools/screenshots/perf-report.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/**
|
||||||
|
* Renders the track-only runtime-perf block for the CI-on-main Discord comment.
|
||||||
|
*
|
||||||
|
* The perf e2e (tests/kicad/{eeschema,pcbnew}-perf.spec.ts) already writes
|
||||||
|
* test-results/perf-{app}.json — schema { app, when, loadMs, openMs, fps:[{throttle,fps}] }.
|
||||||
|
* We read those, fetch the PREVIOUS successful main run's perf via `gh run
|
||||||
|
* download` (so we can show a Δ without committing a baseline — stays
|
||||||
|
* no-write-back), and format an aligned monospace table (Discord doesn't render
|
||||||
|
* markdown tables, so it goes in a ``` code block).
|
||||||
|
*
|
||||||
|
* Track-only: nothing here gates the build. A regression past REGRESSION_PCT on
|
||||||
|
* the stable metrics (loadMs/openMs) is only flagged (a `*`), never failed. FPS
|
||||||
|
* is CPU-bound/noisy on CI's headless SwiftShader path, so it's shown but marked
|
||||||
|
* indicative.
|
||||||
|
*
|
||||||
|
* CLI (from tests/):
|
||||||
|
* tsx tools/screenshots/perf-report.ts [--results DIR] [--prev DIR] [--repo owner/repo]
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { RESULTS_DIR } from './config';
|
||||||
|
|
||||||
|
export const PERF_APPS = ['eeschema', 'pcbnew'] as const;
|
||||||
|
const REGRESSION_PCT = 10; // stable-metric regression past this is flagged with `*`
|
||||||
|
const CI_WORKFLOW = 'ci-ubicloud.yml';
|
||||||
|
|
||||||
|
export type Fps = { throttle: number; fps: number };
|
||||||
|
export type PerfData = { app: string; when?: string; loadMs: number; openMs: number; fps: Fps[] };
|
||||||
|
|
||||||
|
export function readPerf(dir: string): Map<string, PerfData> {
|
||||||
|
const out = new Map<string, PerfData>();
|
||||||
|
for (const app of PERF_APPS) {
|
||||||
|
const p = path.join(dir, `perf-${app}.json`);
|
||||||
|
if (!fs.existsSync(p)) continue;
|
||||||
|
try {
|
||||||
|
out.set(app, JSON.parse(fs.readFileSync(p, 'utf8')) as PerfData);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[perf] could not parse ${p}: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort fetch of the previous successful main CI run's perf JSONs into a
|
||||||
|
* temp dir. Returns the dir, or null if gh is unavailable / no prior run.
|
||||||
|
* `currentSha` is skipped so a re-run doesn't diff against itself.
|
||||||
|
*/
|
||||||
|
export function fetchPreviousPerf(repo: string | undefined, currentSha: string | undefined): string | null {
|
||||||
|
try {
|
||||||
|
const gh = (args: string[]) =>
|
||||||
|
execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||||
|
const repoArgs = repo ? ['--repo', repo] : [];
|
||||||
|
const runs = JSON.parse(
|
||||||
|
gh([
|
||||||
|
'run', 'list', '--workflow', CI_WORKFLOW, '--branch', 'main', '--status', 'success',
|
||||||
|
'--limit', '15', '--json', 'databaseId,headSha', ...repoArgs,
|
||||||
|
])
|
||||||
|
) as Array<{ databaseId: number; headSha: string }>;
|
||||||
|
const prev = runs.find((r) => r.headSha !== currentSha);
|
||||||
|
if (!prev) return null;
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-prev-'));
|
||||||
|
// Artifact is named ubicloud-e2e-<run_id>; -D extracts it under tmp/<artifact>/...
|
||||||
|
gh(['run', 'download', String(prev.databaseId), '-D', tmp, ...repoArgs]);
|
||||||
|
// Find the dir that actually holds the perf-*.json (artifact nests test-results/).
|
||||||
|
const hit = findPerfDir(tmp);
|
||||||
|
return hit;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPerfDir(root: string): string | null {
|
||||||
|
const stack = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entries.some((e) => e.isFile() && /^perf-\w+\.json$/.test(e.name))) return dir;
|
||||||
|
for (const e of entries) if (e.isDirectory()) stack.push(path.join(dir, e.name));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(cur: number, prev: number): number {
|
||||||
|
return prev === 0 ? 0 : ((cur - prev) / prev) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a stable-metric value with a Δ vs previous (lower is better). */
|
||||||
|
function fmtMetric(cur: number, prev?: number): string {
|
||||||
|
if (prev === undefined) return `${cur}`;
|
||||||
|
const p = pct(cur, prev);
|
||||||
|
const arrow = p < 0 ? '▼' : p > 0 ? '▲' : '·';
|
||||||
|
const flag = p > REGRESSION_PCT ? '*' : ''; // regression (slower) beyond threshold
|
||||||
|
return `${cur} ${arrow}${Math.abs(p).toFixed(0)}%${flag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtFps(fps: Fps[]): string {
|
||||||
|
return [1, 4, 6].map((t) => {
|
||||||
|
const hit = fps.find((f) => f.throttle === t);
|
||||||
|
return hit ? Math.round(hit.fps) : '–';
|
||||||
|
}).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(s: string, n: number): string {
|
||||||
|
return s.length >= n ? s : s + ' '.repeat(n - s.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PerfReport = { block: string; regressed: boolean };
|
||||||
|
|
||||||
|
/** Build the fenced code-block perf table for the Discord comment. */
|
||||||
|
export function buildPerfReport(opts: { resultsDir?: string; prevDir?: string | null } = {}): PerfReport {
|
||||||
|
const cur = readPerf(opts.resultsDir ?? RESULTS_DIR);
|
||||||
|
if (cur.size === 0) return { block: '', regressed: false };
|
||||||
|
const prev = opts.prevDir ? readPerf(opts.prevDir) : new Map<string, PerfData>();
|
||||||
|
|
||||||
|
const headers = ['app', 'loadMs (Δ)', 'openMs (Δ)', 'FPS 1/4/6'];
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let regressed = false;
|
||||||
|
for (const app of PERF_APPS) {
|
||||||
|
const c = cur.get(app);
|
||||||
|
if (!c) continue;
|
||||||
|
const p = prev.get(app);
|
||||||
|
const loadCell = fmtMetric(c.loadMs, p?.loadMs);
|
||||||
|
const openCell = fmtMetric(c.openMs, p?.openMs);
|
||||||
|
if (loadCell.endsWith('*') || openCell.endsWith('*')) regressed = true;
|
||||||
|
rows.push([app, loadCell, openCell, fmtFps(c.fps)]);
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return { block: '', regressed: false };
|
||||||
|
|
||||||
|
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
||||||
|
const line = (cells: string[]) => cells.map((c, i) => pad(c, widths[i])).join(' ');
|
||||||
|
const body = [line(headers), rows.map((r) => line(r)).join('\n')].join('\n');
|
||||||
|
const footnote = `${prev.size ? 'Δ vs previous main run. ' : 'no prior main run for Δ. '}` +
|
||||||
|
`* = >${REGRESSION_PCT}% slower (track-only, non-gating). FPS is CI-headless — indicative only.`;
|
||||||
|
return { block: '**Runtime perf** (eeschema + pcbnew)\n```\n' + body + '\n```\n' + footnote, regressed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--results') out.results = argv[++i];
|
||||||
|
else if (a === '--prev') out.prev = argv[++i];
|
||||||
|
else if (a === '--repo') out.repo = argv[++i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const prevDir = args.prev ?? fetchPreviousPerf(args.repo || process.env.GITHUB_REPOSITORY, process.env.GITHUB_SHA);
|
||||||
|
const { block, regressed } = buildPerfReport({ resultsDir: args.results, prevDir });
|
||||||
|
process.stdout.write((block || '(no perf-*.json found)') + '\n');
|
||||||
|
if (regressed) console.error('[perf] a stable metric regressed >10% vs previous main (track-only, not failing)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
226
tests/tools/screenshots/post-discord.ts
Normal file
226
tests/tools/screenshots/post-discord.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
/**
|
||||||
|
* The always-on CI-on-main Discord report.
|
||||||
|
*
|
||||||
|
* One message leads with the commit SHA + e2e pass/fail + the runtime-perf table
|
||||||
|
* (perf-report.ts), then screenshot changes are attached as triptychs
|
||||||
|
* (old | new+boxes | heatmap), ADDED images, and a REMOVED list. Because perf is
|
||||||
|
* always present, the comment fires on every main commit even with no screenshot
|
||||||
|
* change — "see it in hindsight on every commit."
|
||||||
|
*
|
||||||
|
* Safety / robustness:
|
||||||
|
* - Posts only on push to main (unless --force); a missing DISCORD_WEBHOOK_URL is
|
||||||
|
* a silent no-op, so it's inert on PRs / forks (which can't see the secret).
|
||||||
|
* - Attachments batched ≤10/message and downscaled to fit Discord's size caps;
|
||||||
|
* 429 Retry-After honoured; first-run flood (hundreds of ADDED) collapsed.
|
||||||
|
* - --dry-run composes everything and prints it without POSTing (how it's tested).
|
||||||
|
*
|
||||||
|
* CLI (from tests/):
|
||||||
|
* tsx tools/screenshots/post-discord.ts [--dry-run] [--force] [--e2e pass|fail] [--subject S]
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { DIFF_OUT_DIR } from './config';
|
||||||
|
import { loadPng, encodeWithinCap } from './image-ops';
|
||||||
|
import { buildPerfReport, fetchPreviousPerf } from './perf-report';
|
||||||
|
import type { Report } from './compare';
|
||||||
|
|
||||||
|
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
||||||
|
const MAX_MSG_BYTES = 24 * 1024 * 1024;
|
||||||
|
const MAX_FILES_PER_MSG = 10;
|
||||||
|
const MAX_TOTAL_FILES = 30; // ~3 messages of images; excess is summarized, not posted
|
||||||
|
const FLOOD_N = 12; // more ADDED than this ⇒ show a few exemplars, not all
|
||||||
|
|
||||||
|
type Attachment = { name: string; buffer: Buffer };
|
||||||
|
type Message = { content: string; files: Attachment[] };
|
||||||
|
|
||||||
|
function sanitize(name: string): string {
|
||||||
|
return name.replace(/[^\w.-]+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function e2eBadge(status: string | undefined): string {
|
||||||
|
if (status === 'pass') return '✅ e2e passed';
|
||||||
|
if (status === 'fail') return '❌ e2e failed';
|
||||||
|
return 'ℹ️ e2e status unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitSubject(sha: string | undefined): string {
|
||||||
|
if (!sha) return '';
|
||||||
|
try {
|
||||||
|
return execFileSync('git', ['log', '-1', '--pretty=%s', sha], { encoding: 'utf8' }).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReport(root: string): Report | null {
|
||||||
|
const p = path.join(root, DIFF_OUT_DIR, 'report.json');
|
||||||
|
if (!fs.existsSync(p)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(p, 'utf8')) as Report;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a PNG path to a within-cap attachment, or null if missing/unreadable. */
|
||||||
|
function attach(root: string, rel: string, name: string): Attachment | null {
|
||||||
|
const abs = path.isAbsolute(rel) ? rel : path.join(root, rel);
|
||||||
|
if (!fs.existsSync(abs)) return null;
|
||||||
|
try {
|
||||||
|
return { name: sanitize(name), buffer: encodeWithinCap(loadPng(abs), MAX_FILE_BYTES) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the header text: SHA, subject, e2e, perf table, screenshot summary, removed list. */
|
||||||
|
function buildHeader(report: Report | null, perfBlock: string, meta: { sha?: string; subject: string; e2e?: string }): string {
|
||||||
|
const shortSha = meta.sha ? meta.sha.slice(0, 7) : 'local';
|
||||||
|
const lines = [`📸 **CI screenshot + perf report** · \`${shortSha}\``];
|
||||||
|
if (meta.subject) lines.push(`> ${meta.subject}`);
|
||||||
|
lines.push(e2eBadge(meta.e2e));
|
||||||
|
if (perfBlock) lines.push('', perfBlock);
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
if (!report || (!report.changed.length && !report.added.length && !report.removed.length)) {
|
||||||
|
lines.push('✅ no screenshot drift');
|
||||||
|
} else if (report.driftLikely) {
|
||||||
|
lines.push(`⚠️ **${report.changed.length} screenshots changed broadly** — looks like host env drift → re-promote (\`npm run screenshots:promote\`)`);
|
||||||
|
} else {
|
||||||
|
lines.push(`⚠️ **screenshot drift**: ${report.changed.length} changed, ${report.added.length} added, ${report.removed.length} removed`);
|
||||||
|
}
|
||||||
|
if (report?.removed.length) {
|
||||||
|
lines.push('➖ REMOVED: ' + report.removed.map((r) => `\`${r.name}\``).join(', '));
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect the image attachments (changed triptychs + added images) with flood-collapse + total cap. */
|
||||||
|
export function buildAttachments(root: string, report: Report | null): { files: Attachment[]; notes: string[] } {
|
||||||
|
const files: Attachment[] = [];
|
||||||
|
const notes: string[] = [];
|
||||||
|
if (!report) return { files, notes };
|
||||||
|
|
||||||
|
for (const c of report.changed) {
|
||||||
|
if (files.length >= MAX_TOTAL_FILES) break;
|
||||||
|
const a = attach(root, c.triptych, `CHANGED_${c.name}`);
|
||||||
|
if (a) files.push(a);
|
||||||
|
}
|
||||||
|
const addedToShow = report.added.length > FLOOD_N ? report.added.slice(0, 3) : report.added;
|
||||||
|
if (report.added.length > FLOOD_N) {
|
||||||
|
notes.push(`➕ ${report.added.length} added (showing ${addedToShow.length})`);
|
||||||
|
}
|
||||||
|
for (const ad of addedToShow) {
|
||||||
|
if (files.length >= MAX_TOTAL_FILES) break;
|
||||||
|
const a = attach(root, ad.image, `ADDED_${ad.name}`);
|
||||||
|
if (a) files.push(a);
|
||||||
|
}
|
||||||
|
const shown = files.length;
|
||||||
|
const wanted = report.changed.length + addedToShow.length;
|
||||||
|
if (wanted > shown) notes.push(`(${wanted - shown} more images omitted — see the CI artifact)`);
|
||||||
|
return { files, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split attachments into messages of ≤10 files and ≤MAX_MSG_BYTES; content only on the first. */
|
||||||
|
export function paginate(header: string, files: Attachment[]): Message[] {
|
||||||
|
if (!files.length) return [{ content: header, files: [] }];
|
||||||
|
const messages: Message[] = [];
|
||||||
|
let batch: Attachment[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
const flush = () => {
|
||||||
|
messages.push({ content: messages.length === 0 ? header : '', files: batch });
|
||||||
|
batch = [];
|
||||||
|
bytes = 0;
|
||||||
|
};
|
||||||
|
for (const f of files) {
|
||||||
|
if (batch.length >= MAX_FILES_PER_MSG || bytes + f.buffer.length > MAX_MSG_BYTES) flush();
|
||||||
|
batch.push(f);
|
||||||
|
bytes += f.buffer.length;
|
||||||
|
}
|
||||||
|
if (batch.length || messages.length === 0) flush();
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postMessage(webhook: string, msg: Message): Promise<void> {
|
||||||
|
for (let attemptNo = 0; attemptNo < 6; attemptNo++) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('payload_json', JSON.stringify({ content: msg.content || '', allowed_mentions: { parse: [] } }));
|
||||||
|
msg.files.forEach((f, i) => form.append(`files[${i}]`, new Blob([new Uint8Array(f.buffer)], { type: 'image/png' }), f.name));
|
||||||
|
const res = await fetch(webhook, { method: 'POST', body: form });
|
||||||
|
if (res.ok) return;
|
||||||
|
if (res.status === 429) {
|
||||||
|
const retryAfter = Number(res.headers.get('retry-after')) || 2;
|
||||||
|
await sleep((retryAfter + 0.5) * 1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Discord POST failed ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
throw new Error('Discord POST failed after retries (429)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Record<string, string | boolean> {
|
||||||
|
const out: Record<string, string | boolean> = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--dry-run') out.dryRun = true;
|
||||||
|
else if (a === '--force') out.force = true;
|
||||||
|
else if (a === '--e2e') out.e2e = argv[++i];
|
||||||
|
else if (a === '--subject') out.subject = argv[++i];
|
||||||
|
else if (a === '--repo') out.repo = argv[++i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const root = process.cwd();
|
||||||
|
const isMainPush = process.env.GITHUB_REF === 'refs/heads/main' && process.env.GITHUB_EVENT_NAME === 'push';
|
||||||
|
if (!args.force && !args.dryRun && !isMainPush) {
|
||||||
|
console.log('[discord] not a push to main — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const webhook = process.env.DISCORD_WEBHOOK_URL;
|
||||||
|
if (!webhook && !args.dryRun) {
|
||||||
|
console.log('[discord] DISCORD_WEBHOOK_URL unset — skipping (inert on PRs/forks)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha = process.env.GITHUB_SHA;
|
||||||
|
const report = readReport(root);
|
||||||
|
const prevDir = fetchPreviousPerf((args.repo as string) || process.env.GITHUB_REPOSITORY, sha);
|
||||||
|
const { block: perfBlock } = buildPerfReport({ prevDir });
|
||||||
|
|
||||||
|
let header = buildHeader(report, perfBlock, {
|
||||||
|
sha,
|
||||||
|
subject: (args.subject as string) ?? commitSubject(sha),
|
||||||
|
e2e: args.e2e as string,
|
||||||
|
});
|
||||||
|
const { files, notes } = buildAttachments(root, report);
|
||||||
|
if (notes.length) header += '\n' + notes.join('\n');
|
||||||
|
|
||||||
|
const messages = paginate(header, files);
|
||||||
|
|
||||||
|
if (args.dryRun) {
|
||||||
|
for (const [i, m] of messages.entries()) {
|
||||||
|
console.log(`--- message ${i + 1}/${messages.length} (${m.files.length} files, ${m.files.reduce((s, f) => s + f.buffer.length, 0)} bytes) ---`);
|
||||||
|
if (m.content) console.log(m.content);
|
||||||
|
for (const f of m.files) console.log(` [attach] ${f.name} (${f.buffer.length} bytes)`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of messages) await postMessage(webhook!, m);
|
||||||
|
console.log(`[discord] posted ${messages.length} message(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(`[discord] ${e.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
149
tests/tools/screenshots/promote.ts
Normal file
149
tests/tools/screenshots/promote.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
/**
|
||||||
|
* Churn-free baseline updater — "promote CI's render".
|
||||||
|
*
|
||||||
|
* CI's x86 render is the source of truth. This pulls a CI run's screenshots
|
||||||
|
* (`gh run download`) — or a local dir via --from — and, for each one, overwrites
|
||||||
|
* the committed baseline ONLY when the decoded pixels differ beyond the per-engine
|
||||||
|
* floor. Unchanged baselines are left byte-identical (never re-encoded), so git
|
||||||
|
* sees no churn. New shots are added; baselines with no render are reported as
|
||||||
|
* removal candidates and only deleted with --prune.
|
||||||
|
*
|
||||||
|
* CLI (from tests/):
|
||||||
|
* tsx tools/screenshots/promote.ts --run <ci-run-id> [--repo owner/repo] [--prune] [--dry-run]
|
||||||
|
* tsx tools/screenshots/promote.ts --from <dir> [--prune] [--dry-run]
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { BASELINE_DIRS, MANIFEST_PATH, floorFor, type Manifest } from './config';
|
||||||
|
import { diffImages, loadPng } from './image-ops';
|
||||||
|
|
||||||
|
function listPngs(dir: string): string[] {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** basename → absolute committed baseline path (first BASELINE_DIRS entry wins). */
|
||||||
|
function baselineIndex(root: string): Map<string, string> {
|
||||||
|
const index = new Map<string, string>();
|
||||||
|
for (const dir of BASELINE_DIRS) {
|
||||||
|
for (const name of listPngs(path.join(root, dir))) {
|
||||||
|
if (!index.has(name)) index.set(name, path.join(root, dir, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadManifest(root: string): Manifest | undefined {
|
||||||
|
const p = path.join(root, MANIFEST_PATH);
|
||||||
|
if (!fs.existsSync(p)) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(p, 'utf8')) as Manifest;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download a CI run's artifact and return the top-level test-results dir holding the shots. */
|
||||||
|
function downloadRun(runId: string, repo?: string): string {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'promote-'));
|
||||||
|
const repoArgs = repo ? ['--repo', repo] : [];
|
||||||
|
execFileSync('gh', ['run', 'download', runId, '-D', tmp, ...repoArgs], { stdio: 'inherit' });
|
||||||
|
// The artifact stores test-results/** — find that dir; its immediate *.png are the shots
|
||||||
|
// (exclude the nested screenshot-diff/ triptychs).
|
||||||
|
const stack = [tmp];
|
||||||
|
while (stack.length) {
|
||||||
|
const dir = stack.pop()!;
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
if (path.basename(dir) === 'test-results') return dir;
|
||||||
|
for (const e of entries) if (e.isDirectory()) stack.push(path.join(dir, e.name));
|
||||||
|
}
|
||||||
|
throw new Error(`no test-results/ dir found in the downloaded artifact under ${tmp}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plan = { updated: string[]; added: string[]; unchanged: string[]; removedCandidates: string[] };
|
||||||
|
|
||||||
|
function buildPlan(root: string, renderDir: string, manifest?: Manifest): { plan: Plan; apply: () => void } {
|
||||||
|
const baselines = baselineIndex(root);
|
||||||
|
const rendered = new Set(listPngs(renderDir));
|
||||||
|
const plan: Plan = { updated: [], added: [], unchanged: [], removedCandidates: [] };
|
||||||
|
const actions: Array<() => void> = [];
|
||||||
|
|
||||||
|
for (const name of rendered) {
|
||||||
|
const src = path.join(renderDir, name);
|
||||||
|
const existing = baselines.get(name);
|
||||||
|
if (!existing) {
|
||||||
|
const dest = path.join(root, BASELINE_DIRS[0], name);
|
||||||
|
plan.added.push(name);
|
||||||
|
actions.push(() => fs.copyFileSync(src, dest)); // verbatim bytes
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const d = diffImages(loadPng(existing), loadPng(src));
|
||||||
|
const floor = floorFor(name, manifest);
|
||||||
|
if (!d.dimsMatch || d.changedRatio > floor.changedRatio) {
|
||||||
|
plan.updated.push(name);
|
||||||
|
actions.push(() => fs.copyFileSync(src, existing)); // verbatim bytes, no re-encode → no churn
|
||||||
|
} else {
|
||||||
|
plan.unchanged.push(name); // leave the committed file untouched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removal candidates: a committed baseline the manifest expects but this render didn't produce.
|
||||||
|
for (const [name, abs] of baselines) {
|
||||||
|
if (rendered.has(name)) continue;
|
||||||
|
if (manifest && !manifest.screenshots.some((e) => e.name === name)) continue;
|
||||||
|
plan.removedCandidates.push(name);
|
||||||
|
actions.push(() => {}); // pruning is opt-in (see main)
|
||||||
|
void abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plan, apply: () => actions.forEach((a) => a()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Record<string, string | boolean> {
|
||||||
|
const out: Record<string, string | boolean> = {};
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--run') out.run = argv[++i];
|
||||||
|
else if (a === '--from') out.from = argv[++i];
|
||||||
|
else if (a === '--repo') out.repo = argv[++i];
|
||||||
|
else if (a === '--prune') out.prune = true;
|
||||||
|
else if (a === '--dry-run') out.dryRun = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const root = process.cwd();
|
||||||
|
if (!args.run && !args.from) {
|
||||||
|
console.error('usage: promote.ts --run <ci-run-id> [--repo owner/repo] | --from <dir> [--prune] [--dry-run]');
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const renderDir = args.from ? (args.from as string) : downloadRun(args.run as string, args.repo as string);
|
||||||
|
const manifest = loadManifest(root);
|
||||||
|
const { plan, apply } = buildPlan(root, renderDir, manifest);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[promote] updated=${plan.updated.length} added=${plan.added.length} ` +
|
||||||
|
`unchanged=${plan.unchanged.length} removal-candidates=${plan.removedCandidates.length}`
|
||||||
|
);
|
||||||
|
for (const n of plan.updated) console.log(` UPDATE ${n}`);
|
||||||
|
for (const n of plan.added) console.log(` ADD ${n}`);
|
||||||
|
for (const n of plan.removedCandidates) console.log(` REMOVE? ${n}${args.prune ? ' (pruning)' : ' (use --prune to delete)'}`);
|
||||||
|
|
||||||
|
if (args.dryRun) {
|
||||||
|
console.log('[promote] dry-run — no files written');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply();
|
||||||
|
if (args.prune) {
|
||||||
|
const baselines = baselineIndex(root);
|
||||||
|
for (const n of plan.removedCandidates) fs.rmSync(baselines.get(n)!, { force: true });
|
||||||
|
}
|
||||||
|
console.log('[promote] done — review `git status` and commit the changed baselines');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
@ -62,7 +62,7 @@ test.describe('web app — tool switching', () => {
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: 'test-results/web-switch-sch-to-pcb.png',
|
path: 'test-results/web-switch-sch-to-pcb.png',
|
||||||
scale: 'device',
|
scale: 'css',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ test.describe('web app — tool switching', () => {
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: 'test-results/web-switch-pcb-to-sch.png',
|
path: 'test-results/web-switch-pcb-to-sch.png',
|
||||||
scale: 'device',
|
scale: 'css',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ test.describe('web app — tool open paths', () => {
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await bootAndAssert(page, tc);
|
await bootAndAssert(page, tc);
|
||||||
await page.screenshot({ path: `test-results/web-${tool}.png`, scale: 'device' });
|
await page.screenshot({ path: `test-results/web-${tool}.png`, scale: 'css' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue