Files
Codeman/test/mobile/device-matrix.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

217 lines
7.9 KiB
TypeScript

// Port 3205 - Cross-device parametric 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, BREAKPOINTS, BODY_CLASSES } from './helpers/constants.js';
import {
assertFixedPosition,
assertHidden,
assertVisible,
assertNoHorizontalOverflow,
assertDeviceClasses,
assertAccessibleTouchTargets,
getCSSProperty,
getCSSNumericValue,
} from './helpers/assertions.js';
import {
REPRESENTATIVE_DEVICES,
DEVICE_REGISTRY,
type DeviceEntry,
type DeviceCategory,
} from './devices.js';
const PORT = PORTS.DEVICE_MATRIX;
const BASE_URL = `http://localhost:${PORT}`;
let server: WebServer;
// Hidden on phones (< 600px width)
const PHONE_HIDDEN_SELECTORS = [
SELECTORS.HEADER_BRAND,
SELECTORS.CASE_SELECT_GROUP,
SELECTORS.VERSION_DISPLAY,
SELECTORS.TOOLBAR_RIGHT,
SELECTORS.TAB_COUNT_GROUP,
];
// Visible only on phones
const PHONE_ONLY_SELECTORS = [
SELECTORS.SETTINGS_MOBILE,
SELECTORS.CASE_MOBILE,
];
describe('Device Matrix', () => {
beforeAll(async () => {
server = await createTestServer(PORT);
});
afterAll(async () => {
await closeAllBrowsers();
await stopTestServer(server);
});
// ─── Representative Devices ───────────────────────────────────────────────
describe.each(
Object.entries(REPRESENTATIVE_DEVICES) as [DeviceCategory, DeviceEntry][],
)('Representative: %s', (category, device) => {
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(device, BASE_URL));
});
afterAll(async () => {
await context.close();
});
it(`has correct device class for ${device.name} (${device.viewport.width}px)`, async () => {
await assertDeviceClasses(page, device.viewport.width);
});
it('no horizontal overflow', async () => {
await assertNoHorizontalOverflow(page);
});
it('header positioning matches breakpoint', async () => {
const { width } = device.viewport;
const position = await getCSSProperty(page, SELECTORS.HEADER, 'position');
if (width <= BREAKPOINTS.TABLET_MAX) {
// Phone + tablet (max-width: 768px includes 768): fixed header
expect(position).toBe('fixed');
const top = await getCSSProperty(page, SELECTORS.HEADER, 'top');
expect(parseFloat(top)).toBe(0);
} else {
// Desktop: relative header (not fixed)
expect(position).toBe('relative');
}
});
it('toolbar positioning matches breakpoint', async () => {
const { width } = device.viewport;
const position = await getCSSProperty(page, SELECTORS.TOOLBAR, 'position');
if (width <= BREAKPOINTS.PHONE_MAX) {
// Phone (max-width: 599px): fixed toolbar
expect(position).toBe('fixed');
} else {
// Tablet/desktop: relative toolbar
expect(position).toBe('relative');
}
});
it('correct elements hidden/visible for breakpoint', async () => {
const { width } = device.viewport;
const isPhone = width < BREAKPOINTS.PHONE_MAX;
// Skip strict assertions for devices at the phone/tablet boundary (±10px)
const atBoundary = Math.abs(width - BREAKPOINTS.PHONE_MAX) <= 10
|| Math.abs(width - BREAKPOINTS.TABLET_MAX) <= 10;
if (atBoundary) return;
if (isPhone) {
// Phone: certain elements hidden, mobile buttons visible
for (const sel of PHONE_HIDDEN_SELECTORS) {
await assertHidden(page, sel);
}
for (const sel of PHONE_ONLY_SELECTORS) {
await assertVisible(page, sel);
}
} else {
// Tablet/desktop: phone-hidden elements should be visible, mobile buttons hidden
for (const sel of PHONE_ONLY_SELECTORS) {
await assertHidden(page, sel);
}
}
});
it('touch targets pass minimum size', async () => {
const violations = await assertAccessibleTouchTargets(page);
// Log violations for debugging but allow a small number
if (violations.length > 0) {
console.warn(
`[${device.name}] Touch target violations (${violations.length}):\n` +
violations.map(v => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n'),
);
}
// Larger viewports show more UI elements, so allow more violations.
// Known violators: notification action buttons (26x26), some icon buttons.
const { width } = device.viewport;
// Larger viewports show more elements; scale threshold accordingly
const maxViolations = width >= BREAKPOINTS.TABLET_MAX ? 25
: width >= BREAKPOINTS.PHONE_MAX ? 20
: 15;
expect(violations.length).toBeLessThanOrEqual(maxViolations);
});
});
// ─── Full Device Matrix (skip with CI_QUICK=1) ───────────────────────────
describe.skipIf(process.env.CI_QUICK === '1')('Full Device Matrix', () => {
for (const device of DEVICE_REGISTRY) {
it(`${device.name} (${device.viewport.width}x${device.viewport.height}): correct layout`, async () => {
const { context, page } = await createDevicePage(device, BASE_URL);
try {
// Verify viewport was applied
const viewportSize = page.viewportSize();
expect(viewportSize?.width).toBe(device.viewport.width);
expect(viewportSize?.height).toBe(device.viewport.height);
// No horizontal overflow
await assertNoHorizontalOverflow(page);
// Correct breakpoint class
await assertDeviceClasses(page, device.viewport.width);
// Header fixed at top (only on phone/tablet breakpoints, max-width: 768px)
if (device.viewport.width <= BREAKPOINTS.TABLET_MAX) {
await assertFixedPosition(page, SELECTORS.HEADER);
}
} finally {
await context.close();
}
});
}
});
// ─── Dual Engine Matrix (representatives only) ────────────────────────────
describe('Dual Engine Matrix', () => {
const engines = ['chromium', 'webkit'] as const;
for (const [category, device] of Object.entries(REPRESENTATIVE_DEVICES) as [DeviceCategory, DeviceEntry][]) {
describe(`${device.name} (${category})`, () => {
it('layout matches across engines', async () => {
const results: Record<string, { position: string; top: string; zIndex: number }> = {};
for (const engine of engines) {
const { context, page } = await createDevicePage(device, BASE_URL, engine);
try {
const position = await getCSSProperty(page, SELECTORS.HEADER, 'position');
const top = await getCSSProperty(page, SELECTORS.HEADER, 'top');
const zIndex = await getCSSNumericValue(page, SELECTORS.HEADER, 'z-index');
results[engine] = { position, top, zIndex };
// Each engine should have no overflow
await assertNoHorizontalOverflow(page);
// Each engine should have correct device classes
await assertDeviceClasses(page, device.viewport.width);
} finally {
await context.close();
}
}
// Cross-engine comparison: key CSS properties should match
expect(results.chromium.position).toBe(results.webkit.position);
expect(parseFloat(results.chromium.top)).toBe(parseFloat(results.webkit.top));
// z-index should be the same
expect(results.chromium.zIndex).toBe(results.webkit.zIndex);
});
});
}
});
});