Files
Codeman/test/mobile/visual-regression.test.ts
T
JD c087d0ae4d fix(mobile): raise the phone breakpoint from 430px to 600px
The phone tier stopped at innerWidth < 430 and @media (max-width: 430px), so every current large phone landed in the tablet layout: the 430pt iPhone 14 Pro Max, 15 Plus, 15 Pro Max and 16 Plus, the 440pt iPhone 16 Pro Max and 17 Pro Max, Pixel 6 Pro, 7 Pro and OnePlus 12 Pro, the 448pt Pixel 8 Pro and 9 Pro XL, and the Galaxy Z Fold 5 cover screen at 460. On those devices the header icon row replaced the session pill, the toolbar kept the desktop Run Shell button instead of Enter and the mic, the keyboard accessory bar could never become visible because its .visible rule lives inside the phone block, and the toolbar jumped to the top of the page when the keyboard opened.

The new cutoff is 600, the line test/mobile/devices.ts already draws between large phones (430-599) and small tablets (600-767). No physical device sits between 480 and 600, but a phone zoomed out one or two steps in Safari does: a 440pt iPhone at 85% or 75% page zoom reports 518px or 587px and still needs the phone controls, which a 480 cutoff would have taken away. The phone block is max-width: 599px and the tablet block starts at min-width: 600px, so a 600px device is a tablet in CSS and in getDeviceType() alike instead of straddling the boundary the way 430pt phones did.

The number changes everywhere it is encoded: JS, CSS, comments, CLAUDE.md, the CI tests that pin the phone block, and the test:mobile helpers. Measurement history that names 430px stays as written.
2026-09-08 00:56:39 -04:00

188 lines
7.2 KiB
TypeScript

// Port 3206 - 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 { PORTS, 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';
const PORT = PORTS.VISUAL_REGRESSION;
const BASE_URL = `http://localhost:${PORT}`;
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(PORT);
});
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, BASE_URL);
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, BASE_URL);
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, BASE_URL);
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, BASE_URL);
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);
});
});
});