diff --git a/.gitignore b/.gitignore index 167c8ff4..eb5e019b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,8 @@ html-report/ .coverage/ coverage/ .playwright-mcp/ +# Visual-regression baselines are environment-specific; regenerate per env. +e2e/**/*-snapshots/ # Logs *.log diff --git a/e2e/desktop-visual-regression.spec.ts b/e2e/desktop-visual-regression.spec.ts new file mode 100644 index 00000000..6bad62e2 --- /dev/null +++ b/e2e/desktop-visual-regression.spec.ts @@ -0,0 +1,105 @@ +// --------------------------------------------------------------------------- +// Zero-desktop-change gate for the mobile work. +// +// Snapshots every top-level view the mobile pass touches, at desktop widths. +// Capture the baseline on the pre-change state, then run again after the mobile +// changes: any pixel diff means a desktop base class was edited instead of a +// mobile-only `max-md:` override being added. +// +// # baseline (before the mobile changes): +// E2E_PASSWORD=admin123 npx playwright test desktop-visual-regression --update-snapshots +// # gate (after the changes): +// E2E_PASSWORD=admin123 npx playwright test desktop-visual-regression +// +// Snapshots are platform-specific and gitignored; regenerate the baseline in +// the same environment (or a pinned CI/Docker image) you run the gate in. +// --------------------------------------------------------------------------- +import { test, expect, type Page } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +const WIDTHS = [1280, 1440, 1920] as const; +const HEIGHT = 1080; + +interface View { + id: string; + label: string; + open: (page: Page) => Promise; +} + +// Navigate via the app's real chrome (no router): top-bar buttons by aria-label, +// Settings via the profile menu, stack detail via a sidebar row. +const VIEWS: View[] = [ + { id: 'home', label: 'home', open: async () => { /* dashboard is the landing view */ } }, + { id: 'fleet', label: 'fleet', open: (p) => p.getByRole('button', { name: 'Fleet', exact: true }).click() }, + // Resources is intentionally omitted: its reclaim hero / table height depends + // on async Docker data that shifts run-to-run, so it is not deterministically + // snapshot-able in this live environment. Verify its desktop manually, or run + // this gate against a seeded/frozen-data instance in CI to include it. + { id: 'app-store', label: 'app-store', open: (p) => p.getByRole('button', { name: 'App Store', exact: true }).click() }, + { + id: 'settings', label: 'settings', open: async (p) => { + await p.getByRole('button', { name: /profile/i }).click(); + await p.getByRole('button', { name: 'Settings', exact: true }).click(); + }, + }, + { + id: 'stack-detail', label: 'stack-detail', open: async (p) => { + await p.locator('[data-stacks-loaded="true"] [data-testid="stack-row"]').first().click(); + await p.getByText('image', { exact: false }).first().waitFor({ timeout: 10_000 }).catch(() => {}); + }, + }, +]; + +// Paint over genuinely non-deterministic content so it does not cause false +// diffs. Mask only the elements that actually churn between runs, NOT the +// surrounding shell: the rest of the sidebar and the top bar stay unmasked so +// the gate proves they are unchanged. Masked elements keep their box, so a +// layout shift around them still moves unmasked pixels and is caught. +function maskDynamic(page: Page) { + return [ + // The sidebar activity ticker ("x ago" text + live dot) is the only + // churning part of the otherwise-static sidebar. + page.locator('[data-testid="activity-ticker"]'), + // The top-bar notification count changes as alerts arrive. + page.locator('[aria-label^="Notifications"]'), + page.locator('svg'), // sparklines / gauges / charts + page.locator('.xterm'), // live terminal (stack detail) + page.locator('[data-testid="deploy-feedback-pill"]'), + ]; +} + +for (const width of WIDTHS) { + test.describe(`desktop @ ${width}px must not change`, () => { + test.use({ viewport: { width, height: HEIGHT } }); + + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await loginAs(page); + await waitForStacksLoaded(page); + }); + + for (const view of VIEWS) { + test(view.label, async ({ page }) => { + await view.open(page); + // Let the fade-up entrance and async data (Docker images, fleet + // stats) settle before snapshotting so layout height is stable. + await page.waitForTimeout(1200); + // Viewport-only (not fullPage): a fixed-height frame removes + // scroll-height variance from below-fold live data, while the + // above-fold layout is where a base-class regression shows first. + await expect(page).toHaveScreenshot(`${view.label}-${width}.png`, { + animations: 'disabled', + mask: maskDynamic(page), + // The only remaining churn is the in-content live stats that + // can't be masked without hiding layout (dashboard gauge + // values, fleet node CPU/MEM, per-container stats). This small + // budget absorbs that text churn yet stays orders of magnitude + // below any real desktop layout shift, so a base-class + // regression still fails loudly. Run against a seeded / + // frozen-data instance in CI to drop this to 0. + maxDiffPixels: 1200, + }); + }); + } + }); +} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index d7525837..f8313d1c 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -73,7 +73,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
- + Overview @@ -131,7 +131,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} -
+
+ )}
+ )}
@@ -296,7 +325,7 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp glyph={group.glyph} onSelect={() => { setCommandOpen(false); - onSectionChange(item.id); + handleSectionChange(item.id); }} /> ))} diff --git a/frontend/src/components/settings/SettingsSidebar.tsx b/frontend/src/components/settings/SettingsSidebar.tsx index 2bf94c96..8284ee65 100644 --- a/frontend/src/components/settings/SettingsSidebar.tsx +++ b/frontend/src/components/settings/SettingsSidebar.tsx @@ -37,7 +37,7 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o } return ( -