Files
Codeman/test/mobile/accessibility.test.ts
T
Codeman maintainer f604deba3e chore: remove backend code nothing references
Found with knip (exports, types, class members) and confirmed by a text
search of src, test, scripts and packages before each removal.

- Per-CLI get<Cli>NotFoundMessage() (nine of them) with their message
  constants: missingCliMessage() builds that text from the registry now.
- Registry leftovers: renderCliCommand, asCliId, cliIds, isCliRunnable,
  createCliResolverForTest, profileExists, and two name lists that only
  repeated schema enums.
- A task-description cache in Session that was written on every chunk of
  terminal output (one regex pass per line) and never read since the
  subagent watcher takes descriptions from transcripts. The cache module,
  its pattern and both parse methods go with it.
- Session.parentAgentId / childAgentIds: never set by anything, so the two
  optional SessionState fields were never on the wire.
- Unreferenced methods on TmuxManager (pipe-pane pair, syncMouseMode),
  RespawnController, StateStore.recoverFromBackup (load() already falls
  back to the .bak file), TeamWatcher, SseStreamManager and the AI
  checkers' getErrorVerdict.
- updateCaseEnvVars: the settings.local.json env path that envOverrides
  replaced.
- Re-exports in the utils, cli-registry, routes and prompts barrels that
  no importer used, and unused test helpers (MockStateStore, the mobile
  throttle helpers and others).

Eight mobile tests imported the WebServer type from a path that does not
exist (../src instead of ../../src); type-only, so nothing failed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 13:58:22 +01:00

401 lines
14 KiB
TypeScript

// Ephemeral port - Mobile accessibility tests
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { SELECTORS, KEYBOARD, MIN_TOUCH_TARGET, WAIT } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
import {
assertAccessibleTouchTargets, assertFontSizeNoZoom,
assertZoomNotDisabled, getCSSProperty,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../../src/web/server.js';
let baseUrl: string;
describe('Mobile Accessibility', () => {
let server: WebServer;
beforeAll(async () => {
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
await stopTestServer(server);
await closeAllBrowsers();
});
// ── Touch Target Size (WCAG 2.5.5) ───────────────────────────────────────
describe('Touch Target Size (WCAG 2.5.5)', () => {
let page: Page;
let context: BrowserContext;
afterEach(async () => {
if (context) await context.close();
});
it('all visible interactive elements >= 44x44px on phones', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
const violations = await assertAccessibleTouchTargets(page, MIN_TOUCH_TARGET);
// Log violations for debugging but don't fail hard — some elements
// may intentionally be smaller (e.g., inline links in text)
if (violations.length > 0) {
console.log(`Touch target violations on phone (${violations.length}):`);
for (const v of violations.slice(0, 10)) {
console.log(` ${v.selector}: ${v.width}x${v.height}px`);
}
}
// Document all button violations for future fixes. Known undersized:
// - Toolbar buttons: btn-claude, btn-stop, btn-shell (~26px height)
// - btn-settings-mobile, btn-case-mobile (~26px height)
// - btn-notif-action (~26x26px)
// These are documented findings — track total count to prevent regressions.
expect(violations.length).toBeLessThanOrEqual(15);
});
it('all visible interactive elements >= 44x44px on tablets', async () => {
const device = REPRESENTATIVE_DEVICES['standard-tablet'];
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
const violations = await assertAccessibleTouchTargets(page, MIN_TOUCH_TARGET);
if (violations.length > 0) {
console.log(`Touch target violations on tablet (${violations.length}):`);
for (const v of violations.slice(0, 10)) {
console.log(` ${v.selector}: ${v.width}x${v.height}px`);
}
}
// Tablets show more UI elements (including accessory bar buttons ~19px,
// header settings icon 32px). Track total to prevent regressions.
expect(violations.length).toBeLessThanOrEqual(20);
});
it('keyboard accessory bar buttons meet size requirement', async () => {
const device = REPRESENTATIVE_DEVICES['standard-phone'];
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
// Show keyboard to make accessory bar visible
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT, { preferredLayer: 'dom' });
await page.waitForTimeout(300);
const barButtons = page.locator('.keyboard-accessory-bar button');
const count = await barButtons.count();
for (let i = 0; i < count; i++) {
const btn = barButtons.nth(i);
if (await btn.isVisible()) {
const box = await btn.boundingBox();
if (box) {
// Accessory bar buttons should be tappable (buttons are ~28px inside 44px bar)
expect(box.height).toBeGreaterThanOrEqual(24);
expect(box.width).toBeGreaterThanOrEqual(24);
}
}
}
await hideKeyboard(page, { preferredLayer: 'dom' });
});
});
// ── Text & Input Accessibility ────────────────────────────────────────────
describe('Text & Input Accessibility', () => {
let page: Page;
let context: BrowserContext;
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
afterEach(async () => {
await context.close();
});
it('text inputs use font-size >= 16px to prevent iOS auto-zoom', async () => {
// Check all visible text inputs
const inputs = page.locator('input[type="text"], input:not([type]), textarea');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
if (await input.isVisible()) {
const fontSize = await input.evaluate((el) => {
return parseFloat(getComputedStyle(el).fontSize);
});
expect(fontSize).toBeGreaterThanOrEqual(16);
}
}
});
it('body text is readable (font-size >= 12px)', async () => {
const bodyFontSize = await page.evaluate(() => {
return parseFloat(getComputedStyle(document.body).fontSize);
});
expect(bodyFontSize).toBeGreaterThanOrEqual(12);
});
});
// ── Focus Management ──────────────────────────────────────────────────────
describe('Focus Management', () => {
let page: Page;
let context: BrowserContext;
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
afterEach(async () => {
await context.close();
});
it('Escape key closes open modal', async () => {
// Open settings modal
const settingsBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
if (await settingsBtn.isVisible()) {
await settingsBtn.click();
await page.waitForTimeout(500);
const modal = page.locator(SELECTORS.SETTINGS_MODAL);
if (await modal.isVisible()) {
// Press Escape
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// Modal should be closed
const stillVisible = await modal.isVisible();
expect(stillVisible).toBe(false);
}
}
});
it('Tab key cycles through focusable elements in modal', async () => {
// Open settings modal
const settingsBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
if (await settingsBtn.isVisible()) {
await settingsBtn.click();
await page.waitForTimeout(500);
const modal = page.locator(SELECTORS.SETTINGS_MODAL);
if (await modal.isVisible()) {
// Press Tab multiple times and verify focus stays within modal
const focusedElements: string[] = [];
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
await page.waitForTimeout(100);
const focused = await page.evaluate(() => {
const el = document.activeElement;
if (!el) return 'none';
const modal = el.closest('#appSettingsModal, .modal');
return modal ? 'in-modal' : 'outside-modal';
});
focusedElements.push(focused);
}
// All focused elements should be within the modal
const outsideCount = focusedElements.filter((f) => f === 'outside-modal').length;
expect(outsideCount).toBe(0);
}
}
});
});
// ── Semantic HTML & ARIA ──────────────────────────────────────────────────
describe('Semantic HTML & ARIA', () => {
let page: Page;
let context: BrowserContext;
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
afterEach(async () => {
await context.close();
});
it('buttons have accessible names', async () => {
const buttonsWithoutNames = await page.evaluate(() => {
const buttons = document.querySelectorAll('button');
const violations: string[] = [];
for (const btn of buttons) {
const style = getComputedStyle(btn);
if (style.display === 'none' || style.visibility === 'hidden') continue;
const name =
btn.getAttribute('aria-label') ||
btn.getAttribute('title') ||
btn.textContent?.trim() ||
'';
if (!name) {
const id = btn.id ? `#${btn.id}` : '';
const cls = btn.className && typeof btn.className === 'string'
? '.' + btn.className.trim().split(/\s+/).slice(0, 2).join('.')
: '';
violations.push(`button${id}${cls}`);
}
}
return violations;
});
if (buttonsWithoutNames.length > 0) {
console.log('Buttons without accessible names:', buttonsWithoutNames);
}
expect(buttonsWithoutNames.length).toBe(0);
});
it('images have alt text', async () => {
const imagesWithoutAlt = await page.evaluate(() => {
const imgs = document.querySelectorAll('img');
const violations: string[] = [];
for (const img of imgs) {
if (!img.alt && !img.getAttribute('aria-label') && img.getAttribute('role') !== 'presentation') {
violations.push(img.src.slice(-30));
}
}
return violations;
});
expect(imagesWithoutAlt.length).toBe(0);
});
});
// ── Zoom & Viewport ──────────────────────────────────────────────────────
describe('Zoom & Viewport', () => {
let page: Page;
let context: BrowserContext;
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
afterEach(async () => {
await context.close();
});
it('viewport meta does not disable pinch zoom', async () => {
await assertZoomNotDisabled(page);
});
it('viewport meta has width=device-width', async () => {
const content = await page.evaluate(() => {
const meta = document.querySelector('meta[name="viewport"]');
return meta?.getAttribute('content') || '';
});
expect(content).toContain('width=device-width');
});
it('viewport meta has initial-scale=1', async () => {
const content = await page.evaluate(() => {
const meta = document.querySelector('meta[name="viewport"]');
return meta?.getAttribute('content') || '';
});
expect(content).toContain('initial-scale=1');
});
it('content remains usable at 200% zoom', async () => {
// Simulate zoom by setting a smaller viewport (effectively 2x zoom)
await page.setViewportSize({
width: Math.floor(device.viewport.width / 2),
height: Math.floor(device.viewport.height / 2),
});
await page.waitForTimeout(500);
// Key elements should still be visible
const headerVisible = await page.locator(SELECTORS.HEADER).isVisible();
const toolbarVisible = await page.locator(SELECTORS.TOOLBAR).isVisible();
expect(headerVisible).toBe(true);
expect(toolbarVisible).toBe(true);
// No horizontal overflow
const overflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
// At 2x zoom some overflow may be acceptable, just log it
if (overflow) {
console.log('Horizontal overflow detected at 200% zoom');
}
});
});
// ── Color & Contrast ─────────────────────────────────────────────────────
describe('Color & Contrast', () => {
let page: Page;
let context: BrowserContext;
const device = REPRESENTATIVE_DEVICES['standard-phone'];
beforeEach(async () => {
const result = await createDevicePage(device, baseUrl, 'chromium');
page = result.page;
context = result.context;
});
afterEach(async () => {
await context.close();
});
it('interactive elements have visible focus indicators', async () => {
// Tab to the first interactive element
await page.keyboard.press('Tab');
await page.waitForTimeout(200);
const hasFocusStyle = await page.evaluate(() => {
const focused = document.activeElement;
if (!focused || focused === document.body) return null;
const style = getComputedStyle(focused);
const outline = style.outlineStyle;
const boxShadow = style.boxShadow;
// Should have either outline or box-shadow for focus
return {
hasOutline: outline !== 'none' && outline !== '',
hasBoxShadow: boxShadow !== 'none' && boxShadow !== '',
outline: style.outline,
boxShadow: boxShadow.substring(0, 50),
};
});
if (hasFocusStyle) {
const hasIndicator = hasFocusStyle.hasOutline || hasFocusStyle.hasBoxShadow;
// Log for debugging even if it fails
if (!hasIndicator) {
console.log('Focus style:', hasFocusStyle);
}
}
});
});
});