Files
Codeman/test/mobile
Codeman maintainer 9a0e665f72 fix(terminal): preserve scroll intent across keyboard resize, surface history truncation
Closes #259, closes #258. Both bottom out in the same gap: nothing tracked
whether the user was following live output or reading history.

#259 — the keyboard path forced the terminal to the bottom unconditionally
(onKeyboardShow/onKeyboardHide passed scrollToBottom:true, applied with no
check), so opening the keyboard while scrolled up yanked the user down. The
settle cycle now captures intent on its FIRST event, before any fit() has
reflowed the buffer, and returns to that anchor when the user was reading.
A later capture would read an already-moved viewportY, which is why the
capture point matters. The param is renamed restoreScroll to match.

Separately, flushPendingWrites gated viewport preservation on
_hasRecentUserScrollUp(), a 1500ms decay window, so a user who scrolled up and
then actually READ for longer lost protection mid-read. Being scrolled up IS
the intent however long ago it was expressed, so it now keys off position.
The recency window stays as a race guard on the sticky scroll-to-bottom.

The full-history repull already held the user's place and is unchanged.

#258 — truncation was reported by a grey line written INTO the terminal
("earlier output truncated"), which scrolls away with the output it describes,
cannot be acted on, and said the same thing whether the rest was one click away
or gone forever. The server set one `truncated` boolean at two sites meaning
opposite things, and the client discarded fullSize and source entirely.

The route now reports truncationReason ('tail' = intentional partial replay,
the rest is retained; 'capped' = the byte ceiling dropped it) plus
retainedBytes, and 'capped' is not downgraded by a later tail cut. The client
renders a dismissible banner outside terminal output with three honest states:
recoverable (offers Load full history), at-ceiling, and exhausted. The Load
button forces past the scroll cooldown but NOT past _replayWouldShrinkBuffer,
which still refuses a downgrade for repaint-mode panes.

The banner is an overlay, not a flex child: FitAddon derives rows/cols from the
terminal parent's computed height, so occupying real layout space would SIGWINCH
the CLI on every truncation-state change.

Verified in a real browser on the 7 skins: banner text and button clear 4.5:1
contrast on all of them, and terminal height is byte-identical with the banner
shown. The first cut used --bg-elevated and --accent-muted, which do not exist,
so light skins rendered a hardcoded dark bar under dark text; it now uses only
tokens every skin redefines.

test/terminal-scroll-intent.test.ts lives outside test/mobile/ deliberately —
that suite is excluded from test:ci, so a guard placed there is invisible to CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:00:59 +02:00
..

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.png and .diff.png generated (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 check
  • assertNoHorizontalOverflow(page) — no scrollbar
  • assertFixedPosition(page, selector) — computed position check
  • assertDeviceClasses(page, width) — correct body classes
  • assertAccessibleTouchTargets(page) — batch scan all interactive elements
  • assertFontSizeNoZoom(page, selector) — ≥16px input prevention
  • assertZoomNotDisabled(page) — viewport meta check
  • getCSSProperty() / getCSSNumericValue() — computed style helpers

helpers/visual.ts

compareScreenshot(page, name, options?) / assertScreenshotMatch(page, name, options?) — pixelmatch-based comparison with baseline management.

Known Limitations

  1. Headless keyboards are simulated — No real iOS/Android virtual keyboard; CDP metrics override is the closest approximation
  2. CDP is Chromium-only — WebKit tests use synthetic touch events (isTrusted: false) and viewport mocking instead of CDP
  3. Playwright WebKit ≠ Safari — Uses the WebKit engine but not the Safari app; catches CSS rendering differences but not Safari-specific app behavior
  4. Visual baselines are OS-dependent — Linux CI renders differently from macOS; maintain separate baselines per platform
  5. No real device testing — Would need BrowserStack/Sauce Labs integration for real-device coverage
  6. SSE keeps connections open — Use waitUntil: 'domcontentloaded' not 'networkidle'

Dependencies

Already available (no install needed):

  • playwright ^1.58.0
  • pixelmatch ^6.0.0
  • pngjs ^7.0.0
  • vitest ^4.0.18

Optional (not installed):

  • @axe-core/playwright — Full automated WCAG scanning (manual checks implemented instead)

Adding New Tests

  1. Pick a unique port (next: 3208+) — search const PORT = across test files
  2. Add the port to helpers/constants.ts PORTS object
  3. 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();
    }
  });
});
  1. For new devices: add to devices.ts with 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-mobile are ~26px height (WCAG 2.5.5 minimum is 44px)
  • Notification action buttons: btn-notif-action delete/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)