mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
refactor: restructure repo for cleaner GitHub landing page
Reduce visible top-level items from 21 to 14: - Untrack test-results/, tmp/, public symlink (added to .gitignore) - Move agent-teams/ → docs/agent-teams/ - Move mobile-test/ → test/mobile/ - Move tools/remotion/ → scripts/remotion/ - Move eslint.config.js, vitest.config.ts → config/ All path references updated across CLAUDE.md, package.json, .prettierignore, vitest configs, and capture scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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: 'test/mobile/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: 'codeman-app-settings-mobile',
|
||||
SETTINGS_DESKTOP: 'codeman-app-settings',
|
||||
NOTIFICATION_PREFS_MOBILE: 'codeman-notification-prefs-mobile',
|
||||
} as const;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user