Addresses the review on #244. BLOCKING (item 1). selectSession() ends with scrollToLastNonEmptyLine(), which parks the viewport above the bottom for any session taller than the screen, so after a tab switch every tap classified as 'history' — touchstart ran preventDefault() + blur, and touchend's early return skipped focus. Both routes to focus closed on one gesture, the same mechanism as #173. Suppressing the mouse REPORT while scrolled up is right and is kept; suppressing FOCUS is not. touchstart now only preventDefaults 'content' taps (a scrolled-up viewport sends nothing, so there is no compatibility click worth cancelling), and the 'history' branch focuses instead of blurring. Verified against the maintainer's own test, which was already on master and red: `keeps the terminal input focusable after a tab switch parks the viewport off-bottom` fails without this change and passes with it. Item 2: dropped both `terminal-action-pending` guards. The class exists nowhere in the repo, so both branches were permanently false and the comment promised coverage that did not exist. Item 3: removed the `Working` literals. Live claude 2.1.226 prints "Cooked for 2m 6s" with a different bullet and a randomised verb, so they were dead code. The status row is matched by its affordance ("esc to interrupt") instead, which is what makes it actionable. The affordance regex is also tightened to require a key or gesture name, so prose like "click here to open the file" no longer dismisses the keyboard. Item 4: removed _shouldForwardTouchScrollToApp and its test. It was never called, and wiring it as written would have restricted forwarding to claude only, dropping gemini from the path #205 established — a behaviour change this PR has no reason to make. Smaller items: the touchstart classification is cached and reused for the touchend of the same gesture (keyed on exact coordinates, so a moved finger re-classifies), removing two of the three full-viewport scans per gesture; the duplicated touchLastX assignment is gone; and the no-touch bail-out returns null rather than claiming 'history'. test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same 5 pre-existing failures as master, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codeman Mobile Test Suite
Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit).
326 tests across 136 devices — all passing.
Purpose
Validates Codeman's mobile UI across 136 devices, covering:
- Keyboard simulation — 3-layer approach to emulate virtual keyboards in headless browsers
- Touch/swipe interactions — CDP trusted events (Chromium) + synthetic fallback (WebKit)
- Responsive layout — CSS breakpoints, device classes, safe areas, overflow prevention
- Visual regression — Pixel-level screenshot comparison at key breakpoints
- Accessibility — WCAG 2.5.5 touch targets, zoom, focus management, semantic HTML
Quick Start
⚠️ Go through npm run test:mobile, not npx vitest directly. The suite serves the
page from src/web/public, but npm run build puts the xterm vendor bundles in
dist/web/public, so without them every /vendor/xterm* request 404s, Terminal is
never defined and every test touching app.terminal fails on a null. The
pretest:mobile hook (scripts/prepare-test-vendor.mjs) is what puts them in place,
and npm only fires it for npm run test:mobile. Run the prepare script by hand first
if you really need a bare npx vitest.
# Run all mobile tests
npm run test:mobile
# Run a single test file
npm run test:mobile -- test/mobile/keyboard.test.ts
# Quick mode: 6 representative devices, skip full matrix
CI_QUICK=1 npm run test:mobile
# Full device matrix only (136 devices)
npm run test:mobile -- test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run)
rm -rf test/mobile/snapshots/*.png
npm run test:mobile -- test/mobile/visual-regression.test.ts
Test Files
| File | Port | Description |
|---|---|---|
keyboard.test.ts |
3200 | Virtual keyboard simulation (3-layer: CDP, mock, DOM) |
tabs.test.ts |
3201 | Tab switching, swipe navigation, keyboard nav |
subagent-windows.test.ts |
3202 | Mobile subagent card dimensions, stacking, interactions |
settings.test.ts |
3203 | Settings modal, mobile defaults, persistence |
layout.test.ts |
3204 | General mobile layout, fixed elements, device classes |
device-matrix.test.ts |
3205 | Cross-device parametric tests (136 devices) |
visual-regression.test.ts |
3206 | Screenshot comparison at key breakpoints |
accessibility.test.ts |
3207 | WCAG touch targets, zoom, focus, ARIA |
Device Matrix
| Category | Width Range | Count | Representative |
|---|---|---|---|
| small-phone | < 375px | ~10 | iPhone SE |
| standard-phone | 375–429px | ~35 | iPhone 14 Pro |
| large-phone | 430–599px | ~10 | iPhone 15 Pro Max |
| small-tablet | 600–767px | ~8 | Nexus 7 |
| standard-tablet | 768–834px | ~8 | iPad Mini |
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
136 devices are defined in devices.ts — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.).
How Devices Are Differentiated
Each device is identified by a combination of properties, not just screen size:
| Property | What It Controls | Example Impact |
|---|---|---|
| Viewport width/height | CSS breakpoint selection, layout mode | 393px → phone layout, 768px → tablet layout |
| User agent string | Body classes (ios-device, safari-browser) |
iOS devices get safe-area padding, Safari gets CSS workarounds |
| Device scale factor | Retina rendering (1x, 2x, 3x DPR) | Visual regression baselines are DPR-aware |
isMobile flag |
Browser viewport behavior | Mobile viewports don't have scrollbars |
hasTouch flag |
Touch device detection → touch-device class |
Enables KeyboardHandler, SwipeHandler, touch target checks |
defaultBrowserType |
Chromium vs WebKit engine | Dual-engine tests catch Safari CSS rendering differences |
Two devices can have the same viewport but behave differently — an iPad Mini (768x1024, Safari UA, iOS, WebKit) and a Galaxy Tab S7 (800x1280, Chrome UA, Android, Chromium) hit different CSS paths due to user agent detection and engine rendering.
CSS Breakpoints
Matching app.js MobileDetection and mobile.css media queries:
| Breakpoint | Width | CSS Class | Header | Toolbar |
|---|---|---|---|---|
| Phone | ≤ 430px | device-mobile |
Fixed at top | Fixed at bottom |
| Tablet | 431–768px | device-tablet |
Fixed at top | Relative (in flow) |
| Desktop | > 768px | device-desktop |
Relative (in flow) | Relative (in flow) |
Breakpoint boundaries (430px, 768px) use max-width which is inclusive — a 430px device is phone, a 768px device is tablet.
Architecture
Test File
├─ helpers/server.ts → WebServer(port, false, testMode=true)
├─ helpers/browser.ts → Playwright Chromium / WebKit
├─ helpers/cdp.ts → Chrome DevTools Protocol (Chromium only)
├─ helpers/keyboard-sim.ts → 3-layer keyboard simulation
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
├─ helpers/visual.ts → pixelmatch screenshot comparison
└─ devices.ts → 136-device registry
Keyboard Simulation — 3-Layer Approach
Headless browsers cannot trigger real virtual keyboards. We use three layers, auto-selecting the best available:
| Layer | Method | Engine | Fidelity |
|---|---|---|---|
| 1. CDP Metrics | Emulation.setDeviceMetricsOverride — shrinks device height, fires real visualViewport resize |
Chromium only | Highest — triggers KeyboardHandler.handleViewportResize() natively |
| 2. VisualViewport Mock | addInitScript() that wraps visualViewport.height getter and dispatches resize events |
Cross-engine | High — same event path, mocked height value |
| 3. Direct DOM | Sets keyboard-visible class, inline transforms on toolbar/accessory/main |
Cross-engine | CSS-level only — skips handler chain |
The unified showKeyboard(page, height, options?) tries Layer 1 → 2 → 3 automatically.
Touch Simulation
| Method | Engine | isTrusted |
Usage |
|---|---|---|---|
CDP Input.dispatchTouchEvent |
Chromium | true |
Default for swipe/tap tests |
Synthetic TouchEvent |
Cross-engine | false |
WebKit fallback, still triggers handlers |
Swipe simulation includes intermediate move points and timing to satisfy SwipeHandler thresholds (≥80px distance, ≤300ms duration, ≤100px vertical drift).
Visual Regression
Uses pixelmatch + pngjs (both in devDeps) for pixel-level comparison:
- Baselines stored in
test/mobile/snapshots/(git-tracked) - Tolerance: 0.5% pixel diff (handles anti-aliasing)
- On failure:
.actual.pngand.diff.pnggenerated (gitignored) - Update: Delete baseline PNGs and re-run — new baselines auto-created
Snapshots at 10 key breakpoints: 320, 375, 390, 393, 430, 440, 600, 768, 834, 1024px.
Helpers Reference
helpers/constants.ts
All magic numbers centralized: ports, CSS selectors, breakpoint thresholds, keyboard constants, swipe parameters, touch target minimums, body CSS classes, localStorage keys.
helpers/server.ts
createTestServer(port) / stopTestServer(server) — wraps WebServer with testMode=true.
helpers/browser.ts
createDevicePage(device, url, engine?) — creates a Playwright browser context with the device's viewport, DPR, UA, touch support, navigates to the URL.
helpers/cdp.ts
Low-level CDP wrappers: getCDP(), setVisualViewportHeight(), dispatchTouchEvent(), setCPUThrottle(), setNetworkThrottle().
helpers/keyboard-sim.ts
Unified showKeyboard() / hideKeyboard() with auto layer selection. Also exports per-layer functions for targeted testing.
helpers/touch-sim.ts
Unified swipe() / tap() with auto CDP/synthetic selection. Also exports per-method functions.
helpers/assertions.ts
assertTouchTarget(locator, minSize)— WCAG 2.5.5 checkassertNoHorizontalOverflow(page)— no scrollbarassertFixedPosition(page, selector)— computed position checkassertDeviceClasses(page, width)— correct body classesassertAccessibleTouchTargets(page)— batch scan all interactive elementsassertFontSizeNoZoom(page, selector)— ≥16px input preventionassertZoomNotDisabled(page)— viewport meta checkgetCSSProperty()/getCSSNumericValue()— computed style helpers
helpers/visual.ts
compareScreenshot(page, name, options?) / assertScreenshotMatch(page, name, options?) — pixelmatch-based comparison with baseline management.
Known Limitations
- Headless keyboards are simulated — No real iOS/Android virtual keyboard; CDP metrics override is the closest approximation
- CDP is Chromium-only — WebKit tests use synthetic touch events (
isTrusted: false) and viewport mocking instead of CDP - Playwright WebKit ≠ Safari — Uses the WebKit engine but not the Safari app; catches CSS rendering differences but not Safari-specific app behavior
- Visual baselines are OS-dependent — Linux CI renders differently from macOS; maintain separate baselines per platform
- No real device testing — Would need BrowserStack/Sauce Labs integration for real-device coverage
- SSE keeps connections open — Use
waitUntil: 'domcontentloaded'not'networkidle'
Dependencies
Already available (no install needed):
playwright^1.58.0pixelmatch^6.0.0pngjs^7.0.0vitest^4.0.18
Optional (not installed):
@axe-core/playwright— Full automated WCAG scanning (manual checks implemented instead)
Adding New Tests
- Pick a unique port (next: 3208+) — search
const PORT =across test files - Add the port to
helpers/constants.tsPORTS object - Use the standard test pattern:
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
describe('My Test', () => {
let server;
beforeAll(async () => { server = await createTestServer(MY_PORT); });
afterAll(async () => { await stopTestServer(server); await closeAllBrowsers(); });
it('works on phone', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const { page, context } = await createDevicePage(device, `http://localhost:${MY_PORT}`);
try {
// ... assertions ...
} finally {
await context.close();
}
});
});
- For new devices: add to
devices.tswith correct category, viewport, UA, DPR
Findings Log
Accessibility issues discovered by the test suite (tracked for future fixes):
- Toolbar buttons undersized:
btn-claude,btn-stop,btn-shell,btn-settings-mobile,btn-case-mobileare ~26px height (WCAG 2.5.5 minimum is 44px) - Notification action buttons:
btn-notif-actiondelete/dismiss buttons are 26x26px - Keyboard accessory bar buttons: 19–23px height (inside 44px bar, but individual buttons are small)
- Header settings icon on tablet: 32x32px (below 44px minimum)
- Text input font sizes: ~14 inputs inherit default browser font-size below 16px (causes iOS auto-zoom on focus)