mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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>
401 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|