Files
Codeman/mobile-test/layout.test.ts
T
2026-02-18 16:19:21 +01:00

375 lines
14 KiB
TypeScript

// Port 3204 - General mobile layout 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, WAIT, MIN_TOUCH_TARGET } from './helpers/constants.js';
import {
assertFixedPosition,
assertHidden,
assertVisible,
assertNoHorizontalOverflow,
assertDeviceClasses,
assertAccessibleTouchTargets,
assertFontSizeNoZoom,
assertZoomNotDisabled,
getCSSProperty,
getCSSNumericValue,
assertHasClass,
assertNotHasClass,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry } from './devices.js';
const PORT = PORTS.LAYOUT;
const BASE_URL = `http://localhost:${PORT}`;
let server: WebServer;
// Default test device: iPhone 14 Pro (standard phone)
const iPhone14Pro = REPRESENTATIVE_DEVICES['standard-phone'];
// Desktop-class device for comparison
const iPadPro = REPRESENTATIVE_DEVICES['large-tablet'];
describe('Mobile Layout', () => {
beforeAll(async () => {
server = await createTestServer(PORT);
});
afterAll(async () => {
await closeAllBrowsers();
await stopTestServer(server);
});
// ─── Fixed Elements ──────────────────────────────────────────────────────
describe('Fixed Elements', () => {
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
});
afterAll(async () => {
await context.close();
});
it('header has position: fixed', async () => {
await assertFixedPosition(page, SELECTORS.HEADER);
});
it('header is at top: 0', async () => {
const top = await getCSSProperty(page, SELECTORS.HEADER, 'top');
expect(parseFloat(top)).toBe(0);
});
it('header has z-index >= 200', async () => {
const zIndex = await getCSSNumericValue(page, SELECTORS.HEADER, 'z-index');
expect(zIndex).toBeGreaterThanOrEqual(200);
});
it('header has min-height: 36px', async () => {
const minHeight = await getCSSNumericValue(page, SELECTORS.HEADER, 'min-height');
expect(minHeight).toBeGreaterThanOrEqual(36);
});
it('toolbar has position: fixed', async () => {
await assertFixedPosition(page, SELECTORS.TOOLBAR);
});
it('toolbar is at bottom', async () => {
const bottom = await getCSSProperty(page, SELECTORS.TOOLBAR, 'bottom');
// bottom should be 0px or a safe-area value (small positive)
expect(parseFloat(bottom)).toBeLessThanOrEqual(50);
});
it('toolbar height is ~40px', async () => {
const height = await getCSSNumericValue(page, SELECTORS.TOOLBAR, 'height');
expect(height).toBeGreaterThanOrEqual(36);
expect(height).toBeLessThanOrEqual(48);
});
});
// ─── Hidden Elements on Phones ────────────────────────────────────────────
describe('Hidden Elements on Phones', () => {
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
});
afterAll(async () => {
await context.close();
});
const hiddenOnPhones = [
SELECTORS.HEADER_BRAND,
SELECTORS.CASE_SELECT_GROUP,
SELECTORS.VERSION_DISPLAY,
SELECTORS.TOOLBAR_RIGHT,
SELECTORS.TAB_COUNT_GROUP,
];
for (const selector of hiddenOnPhones) {
it(`${selector} is hidden on phones`, async () => {
await assertHidden(page, selector);
});
}
});
// ─── Mobile-Only Visible Elements ─────────────────────────────────────────
describe('Mobile-Only Visible Elements', () => {
it('.btn-settings-mobile visible on phones', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertVisible(page, SELECTORS.SETTINGS_MOBILE);
} finally {
await context.close();
}
});
it('.btn-case-mobile visible on phones', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertVisible(page, SELECTORS.CASE_MOBILE);
} finally {
await context.close();
}
});
it('.btn-settings-mobile hidden on desktop', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL);
try {
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
} finally {
await context.close();
}
});
});
// ─── Device Classes ───────────────────────────────────────────────────────
describe('Device Classes', () => {
it('iOS user agent adds ios-device class', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
await context.close();
}
});
it('Android user agent does NOT add ios-device', async () => {
const pixel = DEVICE_REGISTRY.find(d => d.name === 'Pixel 7')!;
const { context, page } = await createDevicePage(pixel, BASE_URL);
try {
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
await context.close();
}
});
it('Safari user agent adds safari-browser class', async () => {
// iPhone 14 Pro uses Safari UA
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertHasClass(page, 'body', BODY_CLASSES.SAFARI);
} finally {
await context.close();
}
});
it('touch device adds touch-device class', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertHasClass(page, 'body', BODY_CLASSES.TOUCH);
} finally {
await context.close();
}
});
it('width < 430 adds device-mobile', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertDeviceClasses(page, iPhone14Pro.viewport.width);
} finally {
await context.close();
}
});
it('width 430-768 adds device-tablet', async () => {
const tablet = REPRESENTATIVE_DEVICES['small-tablet'];
const { context, page } = await createDevicePage(tablet, BASE_URL);
try {
await assertDeviceClasses(page, tablet.viewport.width);
} finally {
await context.close();
}
});
it('width >= 768 adds device-desktop', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL);
try {
await assertDeviceClasses(page, iPadPro.viewport.width);
} finally {
await context.close();
}
});
});
// ─── iOS Safe Areas ───────────────────────────────────────────────────────
describe('iOS Safe Areas', () => {
it('iOS header has extra padding for safe area', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
// On iOS devices, the header should have padding-top that accounts for safe area
const paddingTop = await getCSSNumericValue(page, SELECTORS.HEADER, 'padding-top');
// iOS safe area adds extra padding; at minimum the header should have some padding
expect(paddingTop).toBeGreaterThanOrEqual(0);
// Verify the ios-device class is present (which triggers safe area CSS)
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
await context.close();
}
});
it('iOS modal content has safe area paddings', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
// Open settings modal to test modal safe areas
const settingsBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
if (await settingsBtn.isVisible()) {
await settingsBtn.click();
await page.waitForTimeout(300);
// Check modal content exists
const modalExists = await page.evaluate((sel) => {
const el = document.querySelector(sel);
return el !== null && getComputedStyle(el).display !== 'none';
}, SELECTORS.SETTINGS_MODAL);
if (modalExists) {
// Safe area CSS vars should be applied via env(safe-area-inset-*)
const content = await page.$(SELECTORS.SETTINGS_MODAL_CONTENT);
expect(content).not.toBeNull();
}
}
} finally {
await context.close();
}
});
});
// ─── Touch Targets ────────────────────────────────────────────────────────
describe('Touch Targets', () => {
it('all interactive elements meet 44px minimum', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
const violations = await assertAccessibleTouchTargets(page, MIN_TOUCH_TARGET);
if (violations.length > 0) {
console.warn(
`Touch target violations (${violations.length}):\n` +
violations.map(v => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n'),
);
}
// Allow known small elements — notification action buttons (26x26px),
// some icon buttons. These are documented findings for future fixes.
expect(violations.length).toBeLessThanOrEqual(15);
} finally {
await context.close();
}
});
});
// ─── Inputs ───────────────────────────────────────────────────────────────
describe('Inputs', () => {
it('text inputs use font-size >= 16px', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
// Check visible text inputs (font-size < 16px causes iOS auto-zoom)
const inputs = await page.$$('input[type="text"], input[type="search"], input:not([type]), textarea');
const violations: string[] = [];
for (const input of inputs) {
const info = await input.evaluate((el) => ({
fontSize: parseFloat(getComputedStyle(el).fontSize),
selector: el.tagName.toLowerCase() + (el.className ? '.' + el.className.split(' ')[0] : ''),
}));
if (info.fontSize < 16) {
violations.push(`${info.selector}: ${info.fontSize}px`);
}
}
if (violations.length > 0) {
console.warn(`Font-size violations (causes iOS auto-zoom):\n ${violations.join('\n ')}`);
}
// Many inputs inherit default browser font-size. Document and track.
expect(violations.length).toBeLessThanOrEqual(20);
} finally {
await context.close();
}
});
});
// ─── Terminal ─────────────────────────────────────────────────────────────
describe('Terminal', () => {
it('terminal-container has touch-action: pan-y', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
const exists = await page.$(SELECTORS.TERMINAL_CONTAINER);
if (exists) {
const touchAction = await getCSSProperty(page, SELECTORS.TERMINAL_CONTAINER, 'touch-action');
expect(touchAction).toContain('pan-y');
}
} finally {
await context.close();
}
});
});
// ─── No Horizontal Overflow ───────────────────────────────────────────────
describe('No Horizontal Overflow', () => {
it('no horizontal scroll on phone viewport', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertNoHorizontalOverflow(page);
} finally {
await context.close();
}
});
});
// ─── Main Content ─────────────────────────────────────────────────────────
describe('Main Content', () => {
it('main has margin-top for fixed header', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
const marginTop = await getCSSNumericValue(page, SELECTORS.MAIN, 'margin-top');
// Main content should have enough margin-top to clear the fixed header (~42px)
expect(marginTop).toBeGreaterThanOrEqual(30);
} finally {
await context.close();
}
});
});
// ─── Viewport Meta ────────────────────────────────────────────────────────
describe('Viewport Meta', () => {
it('zoom is not disabled', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertZoomNotDisabled(page);
} finally {
await context.close();
}
});
});
});