chore: bump version to 0.1540

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-18 16:19:21 +01:00
co-authored by Claude Opus 4.6
parent f1c109cff9
commit 4a1de883d4
46 changed files with 5011 additions and 2 deletions
+183
View File
@@ -0,0 +1,183 @@
import type { Page, Locator } from 'playwright';
import { MIN_TOUCH_TARGET, BREAKPOINTS, BODY_CLASSES, SELECTORS } from './constants.js';
/** Assert an element meets minimum touch target size (WCAG 2.5.5 / Apple HIG) */
export async function assertTouchTarget(
locator: Locator,
minSize: number = MIN_TOUCH_TARGET,
): Promise<void> {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(minSize);
expect(box!.height).toBeGreaterThanOrEqual(minSize);
}
/** Assert no horizontal overflow (scrollbar) exists */
export async function assertNoHorizontalOverflow(page: Page): Promise<void> {
const overflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(overflow).toBe(false);
}
/** Assert element has fixed positioning */
export async function assertFixedPosition(page: Page, selector: string): Promise<void> {
const position = await getCSSProperty(page, selector, 'position');
expect(position).toBe('fixed');
}
/** Assert correct body device classes based on viewport width */
export async function assertDeviceClasses(page: Page, width: number): Promise<void> {
const classes = await page.evaluate(() => document.body.className);
if (width < BREAKPOINTS.PHONE_MAX) {
expect(classes).toContain(BODY_CLASSES.MOBILE);
expect(classes).not.toContain(BODY_CLASSES.TABLET);
expect(classes).not.toContain(BODY_CLASSES.DESKTOP);
} else if (width < BREAKPOINTS.TABLET_MAX) {
expect(classes).not.toContain(BODY_CLASSES.MOBILE);
expect(classes).toContain(BODY_CLASSES.TABLET);
expect(classes).not.toContain(BODY_CLASSES.DESKTOP);
} else {
expect(classes).not.toContain(BODY_CLASSES.MOBILE);
expect(classes).not.toContain(BODY_CLASSES.TABLET);
expect(classes).toContain(BODY_CLASSES.DESKTOP);
}
}
/** Assert element is hidden (display:none or visibility:hidden or not in DOM) */
export async function assertHidden(page: Page, selector: string): Promise<void> {
const visible = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return false;
const style = getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}, selector);
expect(visible).toBe(false);
}
/** Assert element is visible */
export async function assertVisible(page: Page, selector: string): Promise<void> {
const visible = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return false;
const style = getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}, selector);
expect(visible).toBe(true);
}
/** Get a computed CSS property value */
export async function getCSSProperty(
page: Page,
selector: string,
property: string,
): Promise<string> {
return page.evaluate(
({ sel, prop }) => {
const el = document.querySelector(sel);
if (!el) throw new Error(`Element not found: ${sel}`);
return getComputedStyle(el).getPropertyValue(prop);
},
{ sel: selector, prop: property },
);
}
/** Get computed numeric value (parses px values) */
export async function getCSSNumericValue(
page: Page,
selector: string,
property: string,
): Promise<number> {
const value = await getCSSProperty(page, selector, property);
return parseFloat(value) || 0;
}
/** Scan ALL visible interactive elements for touch target compliance.
* Returns list of violations (elements smaller than minSize). */
export async function assertAccessibleTouchTargets(
page: Page,
minSize: number = MIN_TOUCH_TARGET,
): Promise<{ selector: string; width: number; height: number }[]> {
const violations = await page.evaluate((min) => {
const interactiveSelectors = 'button, a, [role="button"], input, select, textarea, [tabindex]';
const elements = document.querySelectorAll(interactiveSelectors);
const results: { selector: string; width: number; height: number }[] = [];
for (const el of elements) {
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
continue;
}
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
if (rect.width < min || rect.height < min) {
// Generate a useful selector for the failing element
const tag = el.tagName.toLowerCase();
const id = el.id ? `#${el.id}` : '';
const cls = el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\s+/).join('.')
: '';
const text = el.textContent?.trim().substring(0, 20) || '';
results.push({
selector: `${tag}${id}${cls} ("${text}")`,
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
}
return results;
}, minSize);
return violations;
}
/** Assert font-size >= 16px on text inputs (prevents iOS auto-zoom) */
export async function assertFontSizeNoZoom(page: Page, selector: string): Promise<void> {
const fontSize = await getCSSNumericValue(page, selector, 'font-size');
expect(fontSize).toBeGreaterThanOrEqual(16);
}
/** Assert an element has a specific CSS class */
export async function assertHasClass(page: Page, selector: string, className: string): Promise<void> {
const has = await page.evaluate(
({ sel, cls }) => document.querySelector(sel)?.classList.contains(cls) ?? false,
{ sel: selector, cls: className },
);
expect(has).toBe(true);
}
/** Assert an element does NOT have a specific CSS class */
export async function assertNotHasClass(page: Page, selector: string, className: string): Promise<void> {
const has = await page.evaluate(
({ sel, cls }) => document.querySelector(sel)?.classList.contains(cls) ?? false,
{ sel: selector, cls: className },
);
expect(has).toBe(false);
}
/** Assert element's computed transform includes a translateY value */
export async function assertTranslateY(
page: Page,
selector: string,
expectedY: number,
tolerance: number = 2,
): Promise<void> {
const transform = await getCSSProperty(page, selector, 'transform');
// transform is a matrix(...) string; extract translateY
// matrix(a, b, c, d, tx, ty) — ty is the 6th value
const match = transform.match(/matrix\([^,]+,[^,]+,[^,]+,[^,]+,[^,]+,\s*([^)]+)\)/);
const ty = match ? parseFloat(match[1]) : 0;
expect(Math.abs(ty - expectedY)).toBeLessThanOrEqual(tolerance);
}
/** Check viewport meta tag doesn't disable zoom */
export async function assertZoomNotDisabled(page: Page): Promise<void> {
const meta = await page.evaluate(() => {
const el = document.querySelector('meta[name="viewport"]');
return el?.getAttribute('content') || '';
});
expect(meta).not.toContain('maximum-scale=1');
expect(meta).not.toContain('user-scalable=no');
expect(meta).not.toContain('user-scalable=0');
}
+65
View File
@@ -0,0 +1,65 @@
import { chromium, webkit, type Browser, type BrowserContext, type Page } from 'playwright';
import type { DeviceEntry } from '../devices.js';
const browsers: Record<string, Browser> = {};
export async function getBrowser(engine: 'chromium' | 'webkit' = 'chromium'): Promise<Browser> {
if (!browsers[engine]?.isConnected()) {
const launcher = engine === 'webkit' ? webkit : chromium;
try {
browsers[engine] = await launcher.launch({ headless: true });
} catch (e: unknown) {
// Fall back to Chromium if WebKit libraries are missing
if (engine === 'webkit') {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('Missing') || msg.includes('dependencies') || msg.includes('browserType.launch')) {
console.log('WebKit unavailable, falling back to Chromium');
return getBrowser('chromium');
}
}
throw e;
}
}
return browsers[engine];
}
export async function createDeviceContext(
device: DeviceEntry,
engineOverride?: 'chromium' | 'webkit',
): Promise<BrowserContext> {
const engine = engineOverride ?? device.defaultBrowserType;
const browser = await getBrowser(engine);
return browser.newContext({
viewport: device.viewport,
deviceScaleFactor: device.deviceScaleFactor,
isMobile: device.isMobile,
hasTouch: device.hasTouch,
userAgent: device.userAgent,
});
}
export async function createDevicePage(
device: DeviceEntry,
url: string,
engineOverride?: 'chromium' | 'webkit',
): Promise<{ context: BrowserContext; page: Page }> {
const context = await createDeviceContext(device, engineOverride);
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded' });
// Wait for initial JS to execute and device classes to be applied
await page.waitForTimeout(1000);
return { context, page };
}
export async function closeAllBrowsers(): Promise<void> {
for (const [key, browser] of Object.entries(browsers)) {
if (browser.isConnected()) {
await browser.close();
}
delete browsers[key];
}
}
export function isChromium(engine: 'chromium' | 'webkit'): boolean {
return engine === 'chromium';
}
+76
View File
@@ -0,0 +1,76 @@
import type { Page, CDPSession } from 'playwright';
/** Get or create a CDP session for the page (Chromium only) */
export async function getCDP(page: Page): Promise<CDPSession> {
return page.context().newCDPSession(page);
}
/** Shrink visual viewport to simulate keyboard via device metrics override.
* Triggers REAL visualViewport resize event that KeyboardHandler detects. */
export async function setVisualViewportHeight(
cdp: CDPSession,
width: number,
height: number,
scale: number,
): Promise<void> {
await cdp.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: scale,
mobile: true,
});
}
/** Clear device metrics override, restoring original viewport */
export async function clearDeviceMetricsOverride(cdp: CDPSession): Promise<void> {
await cdp.send('Emulation.clearDeviceMetricsOverride');
}
/** Dispatch a trusted touch event via CDP Input domain.
* Produces isTrusted=true events, unlike synthetic TouchEvent dispatch. */
export async function dispatchTouchEvent(
cdp: CDPSession,
type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel',
touchPoints: Array<{ x: number; y: number }>,
): Promise<void> {
await cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: touchPoints.map(p => ({ x: Math.round(p.x), y: Math.round(p.y) })),
});
}
/** Throttle CPU to simulate mobile hardware (rate = slowdown factor, e.g. 4 = 4x slower) */
export async function setCPUThrottle(cdp: CDPSession, rate: number): Promise<void> {
await cdp.send('Emulation.setCPUThrottlingRate', { rate });
}
/** Clear CPU throttling */
export async function clearCPUThrottle(cdp: CDPSession): Promise<void> {
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
}
/** Emulate network conditions */
export async function setNetworkThrottle(
cdp: CDPSession,
downloadKbps: number,
uploadKbps: number,
latencyMs: number,
): Promise<void> {
await cdp.send('Network.enable');
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (downloadKbps * 1024) / 8,
uploadThroughput: (uploadKbps * 1024) / 8,
latency: latencyMs,
});
}
/** Clear network throttling */
export async function clearNetworkThrottle(cdp: CDPSession): Promise<void> {
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: -1,
uploadThroughput: -1,
latency: 0,
});
}
+132
View File
@@ -0,0 +1,132 @@
// Port assignments
export const PORTS = {
KEYBOARD: 3200,
TABS: 3201,
SUBAGENT_WINDOWS: 3202,
SETTINGS: 3203,
LAYOUT: 3204,
DEVICE_MATRIX: 3205,
VISUAL_REGRESSION: 3206,
ACCESSIBILITY: 3207,
} as const;
// CSS Selectors
export const SELECTORS = {
// Header
HEADER: '.header',
HEADER_BRAND: '.header-brand',
CASE_SELECT_GROUP: '.case-select-group',
VERSION_DISPLAY: '.version-display',
// Toolbar
TOOLBAR: '.toolbar',
TOOLBAR_RIGHT: '.toolbar-right',
SETTINGS_MOBILE: '.btn-settings-mobile',
CASE_MOBILE: '.btn-case-mobile',
// Tabs
TABS_CONTAINER: '.session-tabs',
TAB: '.session-tab',
TAB_ACTIVE: '.session-tab.active',
TAB_CLOSE: '.tab-close',
TAB_COUNT_GROUP: '.tab-count-group',
TAB_NAME: '.tab-name',
// Keyboard
KEYBOARD_ACCESSORY: '.keyboard-accessory-bar',
KEYBOARD_ACCESSORY_VISIBLE: '.keyboard-accessory-bar.visible',
// Terminal
TERMINAL_CONTAINER: '.terminal-container',
MAIN: '.main',
// Subagent windows
SUBAGENT_WINDOW: '.subagent-window',
SUBAGENT_HEADER: '.subagent-header',
SUBAGENT_BADGE: '.subagent-badge',
// Settings
SETTINGS_MODAL: '#appSettingsModal',
SETTINGS_MODAL_CONTENT: '.modal-content',
SETTINGS_MODAL_BODY: '.modal-body',
SETTINGS_MODAL_TABS: '.modal-tabs',
// Modals general
MODAL: '.modal',
MODAL_CONTENT: '.modal-content',
} as const;
// Device breakpoints (match app.js MobileDetection)
export const BREAKPOINTS = {
PHONE_MAX: 430,
TABLET_MAX: 768,
} as const;
// Keyboard constants (match app.js KeyboardHandler)
export const KEYBOARD = {
SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show
HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide
TYPICAL_IOS_HEIGHT: 336,
FOCUSIN_DELAY: 400,
ANIMATION_DELAY: 150,
RESIZE_DELAY_SHOW: 150,
RESIZE_DELAY_HIDE: 100,
ACCESSORY_BAR_HEIGHT: 44,
TOOLBAR_HEIGHT: 40,
CONFIRM_TIMEOUT: 2000,
} as const;
// Swipe constants (match app.js SwipeHandler)
export const SWIPE = {
MIN_DISTANCE: 80,
MAX_TIME: 300,
MAX_VERTICAL_DRIFT: 100,
} as const;
// Subagent window constants
export const SUBAGENT = {
MOBILE_CARD_HEIGHT: 110,
MOBILE_CARD_GAP: 4,
MOBILE_CARD_STRIDE: 114, // height + gap
TOOLBAR_OFFSET: 40,
DEFAULT_HEADER_HEIGHT: 36,
} as const;
// Visual regression
export const VISUAL = {
DEFAULT_THRESHOLD: 0.1,
MAX_DIFF_PERCENT: 0.5,
SNAPSHOT_DIR: 'mobile-test/snapshots',
} as const;
// Visual regression breakpoints (width values for screenshot comparison)
export const VISUAL_BREAKPOINTS = [320, 375, 390, 393, 430, 440, 600, 768, 834, 1024] as const;
// Wait times
export const WAIT = {
DOM_CONTENT_LOADED: 'domcontentloaded' as const,
PAGE_SETTLE: 3000,
KEYBOARD_ANIMATION: 200,
SSE_CONNECT: 1000,
};
// Touch target minimum (WCAG 2.5.5 / Apple HIG)
export const MIN_TOUCH_TARGET = 44;
// Body CSS classes
export const BODY_CLASSES = {
MOBILE: 'device-mobile',
TABLET: 'device-tablet',
DESKTOP: 'device-desktop',
TOUCH: 'touch-device',
IOS: 'ios-device',
SAFARI: 'safari-browser',
KEYBOARD_VISIBLE: 'keyboard-visible',
} as const;
// Settings localStorage keys
export const STORAGE_KEYS = {
SETTINGS_MOBILE: 'claudeman-app-settings-mobile',
SETTINGS_DESKTOP: 'claudeman-app-settings',
NOTIFICATION_PREFS_MOBILE: 'claudeman-notification-prefs-mobile',
} as const;
+328
View File
@@ -0,0 +1,328 @@
import type { Page } from 'playwright';
import { getCDP, setVisualViewportHeight, clearDeviceMetricsOverride } from './cdp.js';
export type KeyboardLayer = 'cdp' | 'mock' | 'dom';
export interface KeyboardSimOptions {
/** Preferred layer (auto-selects best available if not specified) */
preferredLayer?: KeyboardLayer;
/** Whether the page is running in Chromium (CDP available) */
isChromium?: boolean;
}
/** Result of a keyboard simulation attempt */
export interface KeyboardSimResult {
layer: KeyboardLayer;
success: boolean;
}
// ─── Page globals access ───
// KeyboardHandler and app are `const` declarations in app.js (not a module).
// `const` at script top-level lives in the global lexical environment but
// is NOT a property of `window`. Use string-based page.evaluate() to
// access them, which runs directly in the page's global scope.
/** Check if KeyboardHandler.keyboardVisible is true */
async function isKeyboardVisible(page: Page): Promise<boolean> {
return page.evaluate(`
typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === true
`);
}
/** Check if KeyboardHandler.keyboardVisible is false (explicitly false, not undefined) */
async function isKeyboardHidden(page: Page): Promise<boolean> {
return page.evaluate(`
typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible === false
`);
}
// ─── Layer 1: CDP Metrics Override (Chromium only) ───
/** Simulate keyboard show by shrinking device metrics via CDP.
* In headless mode, setDeviceMetricsOverride changes BOTH innerHeight and
* visualViewport.height. On a real device, only visualViewport shrinks while
* innerHeight stays at the layout viewport size. The safety check in
* updateLayoutForKeyboard() computes keyboardOffset = innerHeight - vv.height
* and dismisses if <= 0. So we must override innerHeight to return the
* original value and visualViewport.height to return the shrunk value. */
export async function showKeyboardViaCDP(page: Page, keyboardHeight: number): Promise<boolean> {
try {
const cdp = await getCDP(page);
const viewport = page.viewportSize()!;
const newHeight = viewport.height - keyboardHeight;
await setVisualViewportHeight(
cdp,
viewport.width,
newHeight,
1,
);
await page.waitForTimeout(100);
await page.evaluate(`(function(newH, origH) {
var vv = window.visualViewport;
if (!vv) return;
// Override visualViewport.height to return shrunk value
Object.defineProperty(vv, 'height', {
get: function() { return newH; },
configurable: true,
});
// CRITICAL: Override innerHeight to return ORIGINAL height.
// CDP changes both innerHeight and visualViewport, but on real devices
// only visualViewport shrinks. Without this, the safety check in
// updateLayoutForKeyboard() sees keyboardOffset=0 and dismisses.
Object.defineProperty(window, 'innerHeight', {
get: function() { return origH; },
configurable: true,
});
// Directly invoke handler — event dispatch alone is unreliable in headless
if (typeof KeyboardHandler !== 'undefined' &&
typeof KeyboardHandler.handleViewportResize === 'function') {
KeyboardHandler.handleViewportResize();
}
vv.dispatchEvent(new Event('resize'));
})(${newHeight}, ${viewport.height})`);
await page.waitForTimeout(300);
return isKeyboardVisible(page);
} catch {
return false;
}
}
/** Dismiss keyboard via CDP by restoring original metrics */
export async function hideKeyboardViaCDP(page: Page): Promise<boolean> {
try {
const cdp = await getCDP(page);
await clearDeviceMetricsOverride(cdp);
await page.waitForTimeout(100);
// Restore height overrides to original values, then invoke handler.
// Note: `delete vv.height` does NOT work on VisualViewport platform objects
// in Chromium — the instance property override persists. We must explicitly
// redefine the getter to return the original height.
await page.evaluate(`(function() {
var vv = window.visualViewport;
if (!vv) return;
// Get the original height (initialViewportHeight was set during init)
var origH = (typeof KeyboardHandler !== 'undefined')
? KeyboardHandler.initialViewportHeight
: window.innerHeight;
// Override height to return original value (simulates keyboard dismiss)
Object.defineProperty(vv, 'height', {
get: function() { return origH; },
configurable: true,
});
// Restore innerHeight to original value too
Object.defineProperty(window, 'innerHeight', {
get: function() { return origH; },
configurable: true,
});
// Invoke handler — sees matching heights, triggers hide
if (typeof KeyboardHandler !== 'undefined' &&
typeof KeyboardHandler.handleViewportResize === 'function') {
KeyboardHandler.handleViewportResize();
}
vv.dispatchEvent(new Event('resize'));
})()`);
await page.waitForTimeout(300);
return isKeyboardHidden(page);
} catch {
return false;
}
}
// ─── Layer 2: VisualViewport Mock (cross-engine) ───
/** Install visualViewport height mock via addInitScript.
* MUST be called before page.goto(). */
export async function setupViewportMock(page: Page): Promise<void> {
await page.addInitScript(() => {
let heightOverride: number | null = null;
const realVV = window.visualViewport;
if (!realVV) return;
const origDesc = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(realVV),
'height',
);
Object.defineProperty(realVV, 'height', {
get() {
if (heightOverride !== null) return heightOverride;
return origDesc?.get?.call(this) ?? window.innerHeight;
},
configurable: true,
});
(window as any).__mockKeyboardHeight = (kbHeight: number | null) => {
heightOverride = kbHeight !== null ? window.innerHeight - kbHeight : null;
realVV.dispatchEvent(new Event('resize'));
};
});
}
/** Show keyboard via visualViewport mock */
export async function showKeyboardViaMock(page: Page, keyboardHeight: number): Promise<boolean> {
try {
await page.evaluate((h) => {
const fn = (window as any).__mockKeyboardHeight;
if (fn) fn(h);
}, keyboardHeight);
await page.waitForTimeout(300);
return isKeyboardVisible(page);
} catch {
return false;
}
}
/** Hide keyboard via visualViewport mock */
export async function hideKeyboardViaMock(page: Page): Promise<boolean> {
try {
await page.evaluate(() => {
const fn = (window as any).__mockKeyboardHeight;
if (fn) fn(null);
});
await page.waitForTimeout(300);
return isKeyboardHidden(page);
} catch {
return false;
}
}
// ─── Layer 3: Direct DOM Manipulation (fallback) ───
/** Force keyboard visible state via direct DOM manipulation.
* Does not trigger KeyboardHandler — sets CSS state only. */
export async function showKeyboardViaDOM(page: Page, keyboardHeight: number): Promise<boolean> {
try {
await page.evaluate(`(function(h) {
document.body.classList.add('keyboard-visible');
// Set KeyboardHandler state if it exists
if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.keyboardVisible = true;
var toolbar = document.querySelector('.toolbar');
var accessoryBar = document.querySelector('.keyboard-accessory-bar');
var main = document.querySelector('.main');
if (toolbar) toolbar.style.transform = 'translateY(' + (-h) + 'px)';
if (accessoryBar) {
accessoryBar.classList.add('visible');
accessoryBar.style.transform = 'translateY(' + (-h) + 'px)';
}
if (main) main.style.paddingBottom = (h + 94) + 'px';
})(${keyboardHeight})`);
return true;
} catch {
return false;
}
}
/** Hide keyboard via direct DOM manipulation */
export async function hideKeyboardViaDOM(page: Page): Promise<boolean> {
try {
await page.evaluate(`(function() {
document.body.classList.remove('keyboard-visible');
if (typeof KeyboardHandler !== 'undefined') {
KeyboardHandler.keyboardVisible = false;
if (typeof KeyboardHandler.resetLayout === 'function') KeyboardHandler.resetLayout();
}
var toolbar = document.querySelector('.toolbar');
var accessoryBar = document.querySelector('.keyboard-accessory-bar');
var main = document.querySelector('.main');
if (toolbar) toolbar.style.transform = '';
if (accessoryBar) {
accessoryBar.classList.remove('visible');
accessoryBar.style.transform = '';
}
if (main) main.style.paddingBottom = '';
})()`);
return true;
} catch {
return false;
}
}
// ─── Unified API ───
/** Show keyboard using the best available simulation layer.
* Tries CDP → Mock → DOM in order. */
export async function showKeyboard(
page: Page,
keyboardHeight: number,
options: KeyboardSimOptions = {},
): Promise<KeyboardSimResult> {
const { preferredLayer, isChromium = true } = options;
// Try specific layer if requested
if (preferredLayer) {
const success = await tryLayer(page, keyboardHeight, preferredLayer, isChromium);
return { layer: preferredLayer, success };
}
// Auto-select: try CDP first (Chromium only), then mock, then DOM
if (isChromium) {
const cdpSuccess = await showKeyboardViaCDP(page, keyboardHeight);
if (cdpSuccess) return { layer: 'cdp', success: true };
}
const mockSuccess = await showKeyboardViaMock(page, keyboardHeight);
if (mockSuccess) return { layer: 'mock', success: true };
const domSuccess = await showKeyboardViaDOM(page, keyboardHeight);
return { layer: 'dom', success: domSuccess };
}
/** Hide keyboard using the best available simulation layer */
export async function hideKeyboard(
page: Page,
options: KeyboardSimOptions = {},
): Promise<KeyboardSimResult> {
const { preferredLayer, isChromium = true } = options;
if (preferredLayer) {
let success = false;
switch (preferredLayer) {
case 'cdp': success = await hideKeyboardViaCDP(page); break;
case 'mock': success = await hideKeyboardViaMock(page); break;
case 'dom': success = await hideKeyboardViaDOM(page); break;
}
return { layer: preferredLayer, success };
}
if (isChromium) {
const cdpSuccess = await hideKeyboardViaCDP(page);
if (cdpSuccess) return { layer: 'cdp', success: true };
}
const mockSuccess = await hideKeyboardViaMock(page);
if (mockSuccess) return { layer: 'mock', success: true };
const domSuccess = await hideKeyboardViaDOM(page);
return { layer: 'dom', success: domSuccess };
}
async function tryLayer(
page: Page,
keyboardHeight: number,
layer: KeyboardLayer,
isChromium: boolean,
): Promise<boolean> {
switch (layer) {
case 'cdp':
if (!isChromium) return false;
return showKeyboardViaCDP(page, keyboardHeight);
case 'mock':
return showKeyboardViaMock(page, keyboardHeight);
case 'dom':
return showKeyboardViaDOM(page, keyboardHeight);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { WebServer } from '../../src/web/server.js';
let servers: Map<number, WebServer> = new Map();
export async function createTestServer(port: number): Promise<WebServer> {
if (servers.has(port)) {
return servers.get(port)!;
}
const server = new WebServer(port, false, true); // testMode = true
await server.start();
servers.set(port, server);
return server;
}
export async function stopTestServer(server: WebServer): Promise<void> {
await server.stop();
for (const [port, s] of servers) {
if (s === server) {
servers.delete(port);
break;
}
}
}
export async function stopAllTestServers(): Promise<void> {
for (const server of servers.values()) {
await server.stop();
}
servers.clear();
}
+261
View File
@@ -0,0 +1,261 @@
import type { Page } from 'playwright';
import { getCDP, dispatchTouchEvent } from './cdp.js';
import { SWIPE } from './constants.js';
export type SwipeDirection = 'left' | 'right' | 'up' | 'down';
export interface SwipeOptions {
/** Distance in pixels (default: SWIPE.MIN_DISTANCE + 20 = 100) */
distance?: number;
/** Duration in ms (default: 150, must be < SWIPE.MAX_TIME) */
duration?: number;
/** Number of intermediate touch move points (default: 5) */
steps?: number;
/** Start position override (default: center of target element) */
startPoint?: { x: number; y: number };
}
// ─── CDP Touch (Chromium, trusted events) ───
/** Perform a swipe gesture via CDP Input.dispatchTouchEvent (trusted, Chromium only).
* Target element defaults to '.main' (where SwipeHandler listens). */
export async function swipeViaCDP(
page: Page,
direction: SwipeDirection,
options: SwipeOptions = {},
): Promise<void> {
const {
distance = SWIPE.MIN_DISTANCE + 20,
duration = 150,
steps = 5,
} = options;
const cdp = await getCDP(page);
const targetSelector = '.main';
const box = await page.locator(targetSelector).boundingBox();
if (!box) throw new Error(`Element ${targetSelector} not found`);
let startX: number, startY: number, endX: number, endY: number;
if (options.startPoint) {
startX = options.startPoint.x;
startY = options.startPoint.y;
} else {
startX = box.x + box.width / 2;
startY = box.y + box.height / 2;
}
// Calculate end point based on direction
switch (direction) {
case 'left':
startX = box.x + box.width * 0.75;
endX = startX - distance;
endY = startY;
break;
case 'right':
startX = box.x + box.width * 0.25;
endX = startX + distance;
endY = startY;
break;
case 'up':
endX = startX;
endY = startY - distance;
break;
case 'down':
endX = startX;
endY = startY + distance;
break;
}
// Touch start
await dispatchTouchEvent(cdp, 'touchStart', [{ x: startX, y: startY }]);
// Intermediate moves
const stepDelay = duration / steps;
for (let i = 1; i <= steps; i++) {
const progress = i / steps;
const x = startX + (endX! - startX) * progress;
const y = startY + (endY! - startY) * progress;
await dispatchTouchEvent(cdp, 'touchMove', [{ x, y }]);
await page.waitForTimeout(stepDelay);
}
// Touch end
await dispatchTouchEvent(cdp, 'touchEnd', []);
}
// ─── Synthetic Touch (cross-engine fallback) ───
/** Perform a swipe gesture via synthetic TouchEvent dispatch.
* Events have isTrusted=false but still trigger handler callbacks. */
export async function swipeViaSynthetic(
page: Page,
direction: SwipeDirection,
options: SwipeOptions = {},
): Promise<void> {
const {
distance = SWIPE.MIN_DISTANCE + 20,
duration = 150,
steps = 5,
} = options;
await page.evaluate(
({ dir, dist, dur, numSteps }) => {
const main = document.querySelector('.main');
if (!main) return;
const rect = main.getBoundingClientRect();
let startX = rect.left + rect.width / 2;
let startY = rect.top + rect.height / 2;
let endX = startX;
let endY = startY;
switch (dir) {
case 'left':
startX = rect.left + rect.width * 0.75;
endX = startX - dist;
break;
case 'right':
startX = rect.left + rect.width * 0.25;
endX = startX + dist;
break;
case 'up':
endY = startY - dist;
break;
case 'down':
endY = startY + dist;
break;
}
function createTouch(x: number, y: number) {
return new Touch({
identifier: 0,
target: main!,
clientX: x,
clientY: y,
pageX: x,
pageY: y,
});
}
// touchstart
main.dispatchEvent(new TouchEvent('touchstart', {
touches: [createTouch(startX, startY)],
changedTouches: [createTouch(startX, startY)],
bubbles: true,
cancelable: true,
}));
// Intermediate moves
const stepDelay = dur / numSteps;
for (let i = 1; i <= numSteps; i++) {
const progress = i / numSteps;
const x = startX + (endX - startX) * progress;
const y = startY + (endY - startY) * progress;
setTimeout(() => {
main.dispatchEvent(new TouchEvent('touchmove', {
touches: [createTouch(x, y)],
changedTouches: [createTouch(x, y)],
bubbles: true,
cancelable: true,
}));
}, stepDelay * i);
}
// touchend
setTimeout(() => {
main.dispatchEvent(new TouchEvent('touchend', {
touches: [],
changedTouches: [createTouch(endX, endY)],
bubbles: true,
cancelable: true,
}));
}, dur + 10);
},
{ dir: direction, dist: distance, dur: duration, numSteps: steps },
);
// Wait for the full gesture + a little buffer
await page.waitForTimeout(duration + 100);
}
// ─── Unified API ───
/** Perform a swipe gesture using the best available method.
* Uses CDP for Chromium (trusted events), synthetic for WebKit. */
export async function swipe(
page: Page,
direction: SwipeDirection,
options: SwipeOptions & { isChromium?: boolean } = {},
): Promise<void> {
const { isChromium = true, ...swipeOpts } = options;
if (isChromium) {
try {
await swipeViaCDP(page, direction, swipeOpts);
return;
} catch {
// Fall through to synthetic
}
}
await swipeViaSynthetic(page, direction, swipeOpts);
}
/** Tap an element via CDP (trusted touch) */
export async function tapViaCDP(page: Page, selector: string): Promise<void> {
const cdp = await getCDP(page);
const box = await page.locator(selector).boundingBox();
if (!box) throw new Error(`Element ${selector} not found for tap`);
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await dispatchTouchEvent(cdp, 'touchStart', [{ x, y }]);
await page.waitForTimeout(50);
await dispatchTouchEvent(cdp, 'touchEnd', []);
}
/** Tap an element via synthetic touch (cross-engine) */
export async function tapViaSynthetic(page: Page, selector: string): Promise<void> {
await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return;
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const touch = new Touch({
identifier: 0,
target: el,
clientX: x,
clientY: y,
});
el.dispatchEvent(new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
bubbles: true,
}));
el.dispatchEvent(new TouchEvent('touchend', {
touches: [],
changedTouches: [touch],
bubbles: true,
}));
}, selector);
}
/** Tap using the best available method */
export async function tap(
page: Page,
selector: string,
options: { isChromium?: boolean } = {},
): Promise<void> {
if (options.isChromium !== false) {
try {
await tapViaCDP(page, selector);
return;
} catch {
// Fall through
}
}
await tapViaSynthetic(page, selector);
}
+134
View File
@@ -0,0 +1,134 @@
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import type { Page } from 'playwright';
import { VISUAL } from './constants.js';
export interface CompareOptions {
/** Pixel matching threshold (0-1, lower = stricter). Default: 0.1 */
threshold?: number;
/** Max allowed percentage of differing pixels. Default: 0.5 */
maxDiffPercent?: number;
/** Custom snapshot directory */
snapshotDir?: string;
}
export interface CompareResult {
/** Whether comparison passed (or baseline was created) */
passed: boolean;
/** Was this a new baseline? */
isNewBaseline: boolean;
/** Number of differing pixels */
diffPixels?: number;
/** Percentage of differing pixels */
diffPercent?: number;
/** Path to diff image (only on failure) */
diffPath?: string;
}
/** Compare a page screenshot against a stored baseline.
* If no baseline exists, creates one and passes. */
export async function compareScreenshot(
page: Page,
name: string,
options: CompareOptions = {},
): Promise<CompareResult> {
const threshold = options.threshold ?? VISUAL.DEFAULT_THRESHOLD;
const maxDiffPercent = options.maxDiffPercent ?? VISUAL.MAX_DIFF_PERCENT;
const snapshotDir = options.snapshotDir ?? join(process.cwd(), VISUAL.SNAPSHOT_DIR);
const baselinePath = join(snapshotDir, `${name}.png`);
const actualPath = join(snapshotDir, `${name}.actual.png`);
const diffPath = join(snapshotDir, `${name}.diff.png`);
// Ensure snapshot directory exists
if (!existsSync(snapshotDir)) {
mkdirSync(snapshotDir, { recursive: true });
}
const actualBuffer = await page.screenshot({ fullPage: false });
// First run: save as baseline
if (!existsSync(baselinePath)) {
writeFileSync(baselinePath, actualBuffer);
return { passed: true, isNewBaseline: true };
}
const baseline = PNG.sync.read(readFileSync(baselinePath));
const actual = PNG.sync.read(actualBuffer);
// Handle dimension mismatch
if (baseline.width !== actual.width || baseline.height !== actual.height) {
writeFileSync(actualPath, actualBuffer);
throw new Error(
`Dimension mismatch for "${name}": ` +
`baseline ${baseline.width}x${baseline.height} vs ` +
`actual ${actual.width}x${actual.height}. ` +
`Actual saved to ${actualPath}`,
);
}
const diff = new PNG({ width: baseline.width, height: baseline.height });
const numDiffPixels = pixelmatch(
baseline.data,
actual.data,
diff.data,
baseline.width,
baseline.height,
{ threshold },
);
const totalPixels = baseline.width * baseline.height;
const diffPercent = (numDiffPixels / totalPixels) * 100;
if (diffPercent > maxDiffPercent) {
writeFileSync(actualPath, actualBuffer);
writeFileSync(diffPath, PNG.sync.write(diff));
return {
passed: false,
isNewBaseline: false,
diffPixels: numDiffPixels,
diffPercent,
diffPath,
};
}
// Clean up old failure artifacts if test now passes
cleanupArtifact(actualPath);
cleanupArtifact(diffPath);
return {
passed: true,
isNewBaseline: false,
diffPixels: numDiffPixels,
diffPercent,
};
}
/** Assert screenshot matches baseline, throwing on failure */
export async function assertScreenshotMatch(
page: Page,
name: string,
options: CompareOptions = {},
): Promise<void> {
const result = await compareScreenshot(page, name, options);
if (!result.passed) {
throw new Error(
`Visual regression: "${name}" has ${result.diffPercent!.toFixed(2)}% pixel diff ` +
`(max ${options.maxDiffPercent ?? VISUAL.MAX_DIFF_PERCENT}%). ` +
`See ${result.diffPath}`,
);
}
}
function cleanupArtifact(path: string): void {
try {
if (existsSync(path)) {
const { unlinkSync } = require('fs');
unlinkSync(path);
}
} catch {
// Ignore cleanup errors
}
}