Files
Codeman/test/mobile/visual-regression.test.ts
T
RandalixandClaude Opus 5.5 6f87186deb test(mobile): createTestServer() binds an ephemeral port (#440)
The helper cached servers by port, but every mobile file starts exactly one, so the cache was never shared: it is now a Set that stopAllTestServers() walks. PORTS leaves helpers/constants.ts, the nine tests set baseUrl after start(), and the README drops the port column and the pick-a-port step.

Committed without the pre-commit hook: accessibility, subagent-windows and visual-regression were not prettier-clean on master already; formatting them would bury the port lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:29:01 +02:00

188 lines
7.2 KiB
TypeScript

// Ephemeral port - Screenshot comparison tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { SELECTORS, VISUAL, VISUAL_BREAKPOINTS, WAIT } from './helpers/constants.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
import { compareScreenshot, assertScreenshotMatch } from './helpers/visual.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
let baseUrl: string;
let server: WebServer;
// Use a representative subset of breakpoints to keep test time reasonable
// Covers: small phone, standard phone, large phone/small tablet boundary, tablet, desktop
const KEY_BREAKPOINTS = [320, 375, 393, 430, 768, 1024] as const;
describe('Visual Regression', () => {
beforeAll(async () => {
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
await closeAllBrowsers();
await stopTestServer(server);
});
// ─── Breakpoint Screenshots ───────────────────────────────────────────────
describe.each(
KEY_BREAKPOINTS.map(w => [w] as [number]),
)('Width %ipx', (width) => {
const height = 812; // Standard phone height for consistency
it('landing page (no sessions)', async () => {
const device = {
name: `custom-${width}`,
category: 'standard-phone' as const,
viewport: { width, height },
deviceScaleFactor: 2,
isMobile: width < 768,
hasTouch: width < 768,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15',
expectedBreakpoint: (width < 600 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as 'phone' | 'tablet' | 'desktop',
isIOS: true,
defaultBrowserType: 'chromium' as const,
};
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const result = await compareScreenshot(page, `landing-${width}w`);
if (result.isNewBaseline) {
console.log(`[visual] Created new baseline: landing-${width}w.png`);
}
expect(result.passed).toBe(true);
} finally {
await context.close();
}
});
it('with keyboard visible', async () => {
const device = {
name: `custom-${width}`,
category: 'standard-phone' as const,
viewport: { width, height },
deviceScaleFactor: 2,
isMobile: width < 768,
hasTouch: width < 768,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15',
expectedBreakpoint: (width < 600 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as 'phone' | 'tablet' | 'desktop',
isIOS: true,
defaultBrowserType: 'chromium' as const,
};
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
// Show keyboard via DOM manipulation (works on all engines)
await showKeyboard(page, 336, { preferredLayer: 'dom' });
await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION);
const result = await compareScreenshot(page, `keyboard-${width}w`);
if (result.isNewBaseline) {
console.log(`[visual] Created new baseline: keyboard-${width}w.png`);
}
expect(result.passed).toBe(true);
await hideKeyboard(page, { preferredLayer: 'dom' });
} finally {
await context.close();
}
});
it('with settings modal open', async () => {
const device = {
name: `custom-${width}`,
category: 'standard-phone' as const,
viewport: { width, height },
deviceScaleFactor: 2,
isMobile: width < 768,
hasTouch: width < 768,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15',
expectedBreakpoint: (width < 600 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as 'phone' | 'tablet' | 'desktop',
isIOS: true,
defaultBrowserType: 'chromium' as const,
};
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
// Open settings modal - try mobile button first, then desktop
const mobileBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
const isPhone = width < 600;
if (isPhone && await mobileBtn.isVisible()) {
await mobileBtn.click();
} else {
// Try clicking a desktop settings trigger if available
await page.evaluate(() => {
const modal = document.getElementById('appSettingsModal');
if (modal) modal.style.display = 'flex';
});
}
await page.waitForTimeout(500);
const result = await compareScreenshot(page, `settings-${width}w`);
if (result.isNewBaseline) {
console.log(`[visual] Created new baseline: settings-${width}w.png`);
}
expect(result.passed).toBe(true);
} finally {
await context.close();
}
});
});
// ─── Representative Device Screenshots ────────────────────────────────────
describe('Representative Devices', () => {
for (const [category, device] of Object.entries(REPRESENTATIVE_DEVICES)) {
it(`${device.name} (${category}) landing page`, async () => {
const { context, page } = await createDevicePage(device, baseUrl);
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const name = `device-${device.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
const result = await compareScreenshot(page, name);
if (result.isNewBaseline) {
console.log(`[visual] Created new baseline: ${name}.png`);
}
expect(result.passed).toBe(true);
} finally {
await context.close();
}
});
}
});
// ─── Baseline Management ──────────────────────────────────────────────────
describe('Baseline management', () => {
/*
* To update visual baselines:
*
* 1. Delete the specific snapshot:
* rm test/mobile/snapshots/<name>.png
*
* 2. Re-run the test:
* npx vitest run test/mobile/visual-regression.test.ts
*
* 3. Review the new baseline:
* The test will create a fresh .png and pass (isNewBaseline = true).
*
* To update ALL baselines:
* rm -rf test/mobile/snapshots/ && npx vitest run test/mobile/visual-regression.test.ts
*
* Diff images for failed comparisons are saved as <name>.diff.png
* alongside the baseline in test/mobile/snapshots/.
*/
it('placeholder — see comments above for baseline update instructions', () => {
expect(true).toBe(true);
});
});
});