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>
212 lines
9.7 KiB
TypeScript
212 lines
9.7 KiB
TypeScript
import type { Page } from '@playwright/test';
|
|
import { test, expect } from './fixtures';
|
|
import {
|
|
clickByLabel,
|
|
clickMenuBarItem,
|
|
clickMenuItem,
|
|
} from '../e2e/utils/element-tracker';
|
|
|
|
/**
|
|
* pl_editor (drawing-sheet editor) WASM E2E Tests
|
|
*
|
|
* Mirrors eeschema.spec.ts. Smoke + the wxFileDialog folder-navigation
|
|
* regression we fixed at the wxWidgets level (filedlgg.cpp). The widget-level
|
|
* coverage lives in tests/e2e/filedialog-folder-nav.spec.ts; this file proves
|
|
* the fix also works through pl_editor's own File menu.
|
|
*/
|
|
|
|
async function completeWizard(page: Page): Promise<void> {
|
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
|
|
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
|
await page.waitForTimeout(2000);
|
|
|
|
await page.screenshot({ path: 'test-results/pl_editor-wizard-00-initial.png', scale: 'css' });
|
|
|
|
for (let i = 1; i <= 10; i++) {
|
|
let clicked = await clickByLabel(page, 'Next >');
|
|
|
|
if (!clicked) {
|
|
clicked = await clickByLabel(page, 'Finish');
|
|
|
|
if (clicked) {
|
|
await page.waitForTimeout(500);
|
|
await page.screenshot({
|
|
path: `test-results/pl_editor-wizard-${String(i).padStart(2, '0')}-finish.png`,
|
|
scale: 'css'
|
|
});
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
await page.waitForTimeout(500);
|
|
await page.screenshot({
|
|
path: `test-results/pl_editor-wizard-${String(i).padStart(2, '0')}.png`,
|
|
scale: 'css'
|
|
});
|
|
}
|
|
|
|
await page.waitForTimeout(2000);
|
|
}
|
|
|
|
function hasAbort(testLogger: { consoleLogs: string[]; errors: string[] }): boolean {
|
|
return [...testLogger.consoleLogs, ...testLogger.errors].some(line => line.includes('Aborted('));
|
|
}
|
|
|
|
test.describe('pl_editor WASM', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/kicad/pl_editor.html');
|
|
});
|
|
|
|
test('app loads, canvas visible, no WASM abort', async ({ page, testLogger }) => {
|
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
|
|
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
|
await page.waitForTimeout(1500);
|
|
await page.screenshot({ path: 'test-results/pl_editor-01-loaded.png', scale: 'css' });
|
|
|
|
expect(hasAbort(testLogger), 'no WASM abort during load').toBe(false);
|
|
|
|
const canvasCount = await page.locator('canvas').count();
|
|
expect(canvasCount).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('first-run wizard is skipped by the seeded config (none appears)', async ({ page, testLogger }) => {
|
|
// The harness seeds a default KiCad config in preRun (like the web app's
|
|
// boot.ts), so STARTWIZARD::CheckAndRun() finds NeedsUserInput()==false and
|
|
// never opens the modal wizard. completeWizard() therefore finds nothing to
|
|
// click and the editor comes straight up. Assert no wizard/dialog is ever
|
|
// visible — the inverse of the old "click through the wizard" flow.
|
|
await completeWizard(page);
|
|
|
|
const blockingDialogs = await page.evaluate(() => {
|
|
const registry = window.wxElementRegistry;
|
|
if (!registry) return -1;
|
|
return registry.findAll({ visible: true })
|
|
.filter((el: { typeName: string }) =>
|
|
/^wxDialog|Wizard/.test(el.typeName))
|
|
.length;
|
|
});
|
|
expect(blockingDialogs, 'no setup wizard/dialog should be visible (seed skipped it)').toBe(0);
|
|
expect(hasAbort(testLogger), 'no WASM abort during launch').toBe(false);
|
|
|
|
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 }) => {
|
|
await completeWizard(page);
|
|
|
|
const fileMenuClicked = await clickMenuBarItem(page, 'File');
|
|
expect(fileMenuClicked, 'File menubar item should be clickable').toBe(true);
|
|
await page.waitForTimeout(400);
|
|
|
|
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
|
|
// widgets), not the regular findAll({visible:true}) set. Use findAllRendered
|
|
// and filter to menuitem elementType — same pattern as load-pcb-probe.spec.ts.
|
|
const menuLabels = await page.evaluate(() => {
|
|
const registry = window.wxElementRegistry;
|
|
if (!registry || !registry.findAllRendered) return [];
|
|
return registry.findAllRendered({})
|
|
.filter((r: { elementType: string }) => r.elementType === 'menuitem')
|
|
.map((r: { label?: string }) => r.label || '')
|
|
.filter((l: string) => l.length > 0);
|
|
});
|
|
|
|
// wxWidgets labels typically end with "..." (three ASCII dots) but some
|
|
// builds use the Unicode horizontal ellipsis "…". Accept either.
|
|
const hasOpen = menuLabels.some(l => /^Open[\.…]/.test(l) || l === 'Open');
|
|
const hasSaveAs = menuLabels.some(l => /^Save As[\.…]/.test(l) || l === 'Save As');
|
|
expect(hasOpen, `menu should contain "Open..." (saw labels: ${menuLabels.slice(0, 30).join(', ')})`).toBe(true);
|
|
expect(hasSaveAs, `menu should contain "Save As..." (saw labels: ${menuLabels.slice(0, 30).join(', ')})`).toBe(true);
|
|
|
|
// Dismiss the menu so we don't leak state into the next test.
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(200);
|
|
|
|
expect(hasAbort(testLogger)).toBe(false);
|
|
});
|
|
|
|
test('Save As file dialog: typing a folder + Enter navigates into it (regression)', async ({ page, testLogger }) => {
|
|
await completeWizard(page);
|
|
|
|
// Open File > Save As
|
|
await clickMenuBarItem(page, 'File');
|
|
await page.waitForTimeout(300);
|
|
const savedAsClicked = await clickMenuItem(page, 'Save As...');
|
|
expect(savedAsClicked, 'Save As... menu item should be clickable').toBe(true);
|
|
|
|
// Wait for the wxFileDialog to appear in the registry.
|
|
await page.waitForFunction(() => {
|
|
const registry = window.wxElementRegistry;
|
|
if (!registry) return false;
|
|
return registry.findAll({ visible: true })
|
|
.some((el: { typeName: string }) => el.typeName === 'wxFileDialog');
|
|
}, null, { timeout: 15000 });
|
|
|
|
// The dialog object exists in the registry as soon as C++ constructs it,
|
|
// but the directory enumeration (MEMFS readdir → asyncify suspend) hasn't
|
|
// returned yet so the inner file list isn't painted. Without this wait the
|
|
// screenshot catches the dialog as a black rectangle.
|
|
await page.waitForTimeout(600);
|
|
|
|
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
|
|
// "Unable to load /dev file". After the OnOk fix, the dialog should navigate
|
|
// into the folder instead.
|
|
await page.keyboard.type('/dev');
|
|
await page.waitForTimeout(200);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForTimeout(900);
|
|
|
|
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.
|
|
const dialogStillOpen = await page.evaluate(() => {
|
|
const registry = window.wxElementRegistry;
|
|
if (!registry) return false;
|
|
return registry.findAll({ visible: true })
|
|
.some((el: { typeName: string }) => el.typeName === 'wxFileDialog');
|
|
});
|
|
expect(dialogStillOpen, 'wxFileDialog should remain open after Enter on a folder').toBe(true);
|
|
|
|
// The pre-fix error path surfaced "Unable to load <path> file" through KiCad's
|
|
// logger when the folder was returned as a "file". Ensure it didn't fire.
|
|
const unableToLoad = testLogger.consoleLogs.some(l => /Unable to load.*\/dev/.test(l));
|
|
expect(unableToLoad, 'KiCad must not surface "Unable to load /dev file"').toBe(false);
|
|
|
|
// Close the dialog cleanly so it doesn't leak to a subsequent step.
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(300);
|
|
|
|
expect(hasAbort(testLogger)).toBe(false);
|
|
});
|
|
|
|
test('canvas + toolbar metrics look sane', async ({ page, testLogger }) => {
|
|
await completeWizard(page);
|
|
|
|
const metrics = await page.evaluate(() => {
|
|
const registry = window.wxElementRegistry;
|
|
const all = registry ? registry.findAll({ visible: true }) : [];
|
|
const toolbars = all.filter((el: { typeName: string }) => /ToolBar/.test(el.typeName));
|
|
const glCanvas = document.querySelector('canvas[id*="gl"]') as HTMLCanvasElement | null;
|
|
|
|
return {
|
|
registryTotal: all.length,
|
|
toolbarCount: toolbars.length,
|
|
mainCanvasOk: (() => {
|
|
const c = document.getElementById('canvas') as HTMLCanvasElement | null;
|
|
return !!c && c.width > 0 && c.height > 0;
|
|
})(),
|
|
glCanvasOk: !!glCanvas && glCanvas.width > 0 && glCanvas.height > 0,
|
|
};
|
|
});
|
|
|
|
expect(metrics.registryTotal, 'registry should be populated').toBeGreaterThan(10);
|
|
expect(metrics.toolbarCount, 'at least one toolbar should be visible').toBeGreaterThanOrEqual(1);
|
|
expect(metrics.mainCanvasOk, 'main canvas has nonzero dimensions').toBe(true);
|
|
expect(metrics.glCanvasOk, 'GL canvas has nonzero dimensions').toBe(true);
|
|
expect(hasAbort(testLogger)).toBe(false);
|
|
});
|
|
});
|