// 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(); } }); }); // ─── Toolbar Collision Regression ─────────────────────────────────────── describe('Toolbar Collision Regression', () => { it('keeps phone toolbar controls inside the viewport', async () => { const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!; const { context, page } = await createDevicePage(device, BASE_URL, 'chromium'); try { await page.waitForTimeout(WAIT.PAGE_SETTLE); const layout = await page.evaluate(() => { const buttons = [...document.querySelectorAll('.toolbar button')] .filter((el): el is HTMLButtonElement => { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }) .map((el) => { const rect = el.getBoundingClientRect(); return { selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`, left: rect.left, right: rect.right, }; }); return { overflow: buttons.filter(({ left, right }) => left < 0 || right > window.innerWidth), caseWidth: document.querySelector('.btn-case-mobile')?.getBoundingClientRect().width ?? 0, }; }); expect(layout.overflow).toEqual([]); expect(layout.caseWidth).toBeGreaterThanOrEqual(36); } finally { await context.close(); } }); it('does not render the desktop voice button at the 430px phone/tablet boundary', async () => { const device = REPRESENTATIVE_DEVICES['large-phone']; const { context, page } = await createDevicePage(device, BASE_URL, 'chromium'); try { await page.waitForTimeout(WAIT.PAGE_SETTLE); await assertHidden(page, '#voiceInputBtn'); await assertVisible(page, '#voiceInputBtnMobile'); } finally { await context.close(); } }); it('uses phone upload and voice controls without toolbar overlap', async () => { const device = REPRESENTATIVE_DEVICES['small-phone']; const { context, page } = await createDevicePage(device, BASE_URL, 'chromium'); try { await page.waitForTimeout(WAIT.PAGE_SETTLE); expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false); await assertVisible(page, '.btn-upload-mobile'); await assertVisible(page, '#voiceInputBtnMobile'); const violations = await page.evaluate(() => { const visibleToolbarButtons = [...document.querySelectorAll('.toolbar button')] .filter((el): el is HTMLButtonElement => { const rect = el.getBoundingClientRect(); const style = getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }) .map((el) => ({ selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`, rect: el.getBoundingClientRect(), })); const overlaps: string[] = []; for (let i = 0; i < visibleToolbarButtons.length; i += 1) { for (let j = i + 1; j < visibleToolbarButtons.length; j += 1) { const a = visibleToolbarButtons[i]; const b = visibleToolbarButtons[j]; const intersects = a.rect.left < b.rect.right && a.rect.right > b.rect.left && a.rect.top < b.rect.bottom && a.rect.bottom > b.rect.top; if (intersects) { overlaps.push(`${a.selector} overlaps ${b.selector}`); } } } return overlaps; }); expect(violations).toEqual([]); } finally { await context.close(); } }); it('keeps the mobile recording mic effect inside the button bounds', async () => { const device = REPRESENTATIVE_DEVICES['small-phone']; const { context, page } = await createDevicePage(device, BASE_URL, 'chromium'); try { await page.waitForTimeout(WAIT.PAGE_SETTLE); await page.evaluate(() => { document.getElementById('voiceInputBtnMobile')?.classList.add('recording'); }); const shadow = await page.evaluate(() => { const button = document.getElementById('voiceInputBtnMobile'); return button ? getComputedStyle(button).boxShadow : ''; }); expect(shadow).toContain('inset'); expect(shadow).not.toContain(' 6px '); } finally { await context.close(); } }); it('keeps desktop upload and voice controls in one centered row', async () => { const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium'); try { await page.setViewportSize({ width: 1280, height: 800 }); await page.waitForTimeout(WAIT.PAGE_SETTLE); await assertVisible(page, '.toolbar-center .btn-upload'); await assertVisible(page, '#voiceInputBtn'); const layout = await page.evaluate(() => { const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect(); const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect(); return { centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)), centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)), }; }); expect(layout.centerYDifference).toBeLessThanOrEqual(2); expect(layout.centerXDifference).toBeGreaterThan(20); } 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(); } }); }); });