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>
501 lines
19 KiB
TypeScript
501 lines
19 KiB
TypeScript
// Ephemeral port - General mobile layout tests
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import type { BrowserContext, Page } from 'playwright';
|
|
import type { WebServer } from '../../src/web/server.js';
|
|
import { createTestServer, stopTestServer } from './helpers/server.js';
|
|
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
|
import { SELECTORS, BREAKPOINTS, BODY_CLASSES, WAIT, MIN_TOUCH_TARGET } from './helpers/constants.js';
|
|
import {
|
|
assertFixedPosition,
|
|
assertHidden,
|
|
assertVisible,
|
|
assertNoHorizontalOverflow,
|
|
assertDeviceClasses,
|
|
assertAccessibleTouchTargets,
|
|
assertFontSizeNoZoom,
|
|
assertZoomNotDisabled,
|
|
getCSSProperty,
|
|
getCSSNumericValue,
|
|
assertHasClass,
|
|
assertNotHasClass,
|
|
} from './helpers/assertions.js';
|
|
import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry } from './devices.js';
|
|
|
|
let baseUrl: string;
|
|
|
|
let server: WebServer;
|
|
|
|
// Default test device: iPhone 14 Pro (standard phone)
|
|
const iPhone14Pro = REPRESENTATIVE_DEVICES['standard-phone'];
|
|
// Desktop-class device for comparison
|
|
const iPadPro = REPRESENTATIVE_DEVICES['large-tablet'];
|
|
|
|
describe('Mobile Layout', () => {
|
|
beforeAll(async () => {
|
|
server = await createTestServer();
|
|
baseUrl = `http://localhost:${server.boundPort}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await closeAllBrowsers();
|
|
await stopTestServer(server);
|
|
});
|
|
|
|
// ─── Fixed Elements ──────────────────────────────────────────────────────
|
|
|
|
describe('Fixed Elements', () => {
|
|
let context: BrowserContext;
|
|
let page: Page;
|
|
|
|
beforeAll(async () => {
|
|
({ context, page } = await createDevicePage(iPhone14Pro, baseUrl));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await context.close();
|
|
});
|
|
|
|
it('header has position: fixed', async () => {
|
|
await assertFixedPosition(page, SELECTORS.HEADER);
|
|
});
|
|
|
|
it('header is at top: 0', async () => {
|
|
const top = await getCSSProperty(page, SELECTORS.HEADER, 'top');
|
|
expect(parseFloat(top)).toBe(0);
|
|
});
|
|
|
|
it('header has z-index >= 200', async () => {
|
|
const zIndex = await getCSSNumericValue(page, SELECTORS.HEADER, 'z-index');
|
|
expect(zIndex).toBeGreaterThanOrEqual(200);
|
|
});
|
|
|
|
it('header has min-height: 36px', async () => {
|
|
const minHeight = await getCSSNumericValue(page, SELECTORS.HEADER, 'min-height');
|
|
expect(minHeight).toBeGreaterThanOrEqual(36);
|
|
});
|
|
|
|
it('toolbar has position: fixed', async () => {
|
|
await assertFixedPosition(page, SELECTORS.TOOLBAR);
|
|
});
|
|
|
|
it('toolbar is at bottom', async () => {
|
|
const bottom = await getCSSProperty(page, SELECTORS.TOOLBAR, 'bottom');
|
|
// bottom should be 0px or a safe-area value (small positive)
|
|
expect(parseFloat(bottom)).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it('toolbar height is ~40px', async () => {
|
|
const height = await getCSSNumericValue(page, SELECTORS.TOOLBAR, 'height');
|
|
expect(height).toBeGreaterThanOrEqual(36);
|
|
expect(height).toBeLessThanOrEqual(48);
|
|
});
|
|
});
|
|
|
|
// ─── Hidden Elements on Phones ────────────────────────────────────────────
|
|
|
|
describe('Hidden Elements on Phones', () => {
|
|
let context: BrowserContext;
|
|
let page: Page;
|
|
|
|
beforeAll(async () => {
|
|
({ context, page } = await createDevicePage(iPhone14Pro, baseUrl));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await context.close();
|
|
});
|
|
|
|
const hiddenOnPhones = [
|
|
SELECTORS.HEADER_BRAND,
|
|
SELECTORS.CASE_SELECT_GROUP,
|
|
SELECTORS.VERSION_DISPLAY,
|
|
SELECTORS.TOOLBAR_RIGHT,
|
|
SELECTORS.TAB_COUNT_GROUP,
|
|
];
|
|
|
|
for (const selector of hiddenOnPhones) {
|
|
it(`${selector} is hidden on phones`, async () => {
|
|
await assertHidden(page, selector);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ─── Mobile-Only Visible Elements ─────────────────────────────────────────
|
|
|
|
describe('Mobile-Only Visible Elements', () => {
|
|
it('.btn-settings-mobile visible on phones', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertVisible(page, SELECTORS.SETTINGS_MOBILE);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('.btn-case-mobile visible on phones', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertVisible(page, SELECTORS.CASE_MOBILE);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('.btn-settings-mobile hidden on desktop', async () => {
|
|
const { context, page } = await createDevicePage(iPadPro, baseUrl);
|
|
try {
|
|
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Toolbar Collision Regression ───────────────────────────────────────
|
|
|
|
describe('Toolbar Collision Regression', () => {
|
|
it('keeps phone toolbar controls inside the viewport', async () => {
|
|
const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!;
|
|
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
|
|
try {
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
const layout = await page.evaluate(() => {
|
|
const buttons = [...document.querySelectorAll('.toolbar button')]
|
|
.filter((el): el is HTMLButtonElement => {
|
|
const rect = el.getBoundingClientRect();
|
|
const style = getComputedStyle(el);
|
|
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
})
|
|
.map((el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
return {
|
|
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
|
|
left: rect.left,
|
|
right: rect.right,
|
|
};
|
|
});
|
|
return {
|
|
overflow: buttons.filter(({ left, right }) => left < 0 || right > window.innerWidth),
|
|
caseWidth: document.querySelector('.btn-case-mobile')?.getBoundingClientRect().width ?? 0,
|
|
};
|
|
});
|
|
|
|
expect(layout.overflow).toEqual([]);
|
|
expect(layout.caseWidth).toBeGreaterThanOrEqual(36);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('does not render the desktop voice button on a 430px large phone', async () => {
|
|
const device = REPRESENTATIVE_DEVICES['large-phone'];
|
|
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
|
|
try {
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
await assertHidden(page, '#voiceInputBtn');
|
|
await assertVisible(page, '#voiceInputBtnMobile');
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('keeps the desktop voice control visible and the phone attach button hidden', async () => {
|
|
const { context, page } = await createDevicePage(iPadPro, baseUrl, 'chromium');
|
|
try {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
await assertVisible(page, '#voiceInputBtn');
|
|
await assertHidden(page, SELECTORS.ATTACH_MOBILE_WRAP);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('uses phone attach and voice controls without toolbar overlap', async () => {
|
|
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
|
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
|
|
try {
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
await assertVisible(page, SELECTORS.ATTACH_MOBILE);
|
|
await assertVisible(page, '#voiceInputBtnMobile');
|
|
|
|
const violations = await page.evaluate(() => {
|
|
const visibleToolbarButtons = [...document.querySelectorAll('.toolbar button')]
|
|
.filter((el): el is HTMLButtonElement => {
|
|
const rect = el.getBoundingClientRect();
|
|
const style = getComputedStyle(el);
|
|
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
})
|
|
.map((el) => ({
|
|
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
|
|
rect: el.getBoundingClientRect(),
|
|
}));
|
|
|
|
const overlaps: string[] = [];
|
|
for (let i = 0; i < visibleToolbarButtons.length; i += 1) {
|
|
for (let j = i + 1; j < visibleToolbarButtons.length; j += 1) {
|
|
const a = visibleToolbarButtons[i];
|
|
const b = visibleToolbarButtons[j];
|
|
const intersects =
|
|
a.rect.left < b.rect.right &&
|
|
a.rect.right > b.rect.left &&
|
|
a.rect.top < b.rect.bottom &&
|
|
a.rect.bottom > b.rect.top;
|
|
if (intersects) {
|
|
overlaps.push(`${a.selector} overlaps ${b.selector}`);
|
|
}
|
|
}
|
|
}
|
|
return overlaps;
|
|
});
|
|
|
|
expect(violations).toEqual([]);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('keeps the mobile recording mic effect inside the button bounds', async () => {
|
|
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
|
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
|
|
try {
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
await page.evaluate(() => {
|
|
document.getElementById('voiceInputBtnMobile')?.classList.add('recording');
|
|
});
|
|
|
|
const shadow = await page.evaluate(() => {
|
|
const button = document.getElementById('voiceInputBtnMobile');
|
|
return button ? getComputedStyle(button).boxShadow : '';
|
|
});
|
|
|
|
expect(shadow).toContain('inset');
|
|
expect(shadow).not.toContain(' 6px ');
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Device Classes ───────────────────────────────────────────────────────
|
|
|
|
describe('Device Classes', () => {
|
|
it('iOS user agent adds ios-device class', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('Android user agent does NOT add ios-device', async () => {
|
|
const pixel = DEVICE_REGISTRY.find((d) => d.name === 'Pixel 7')!;
|
|
const { context, page } = await createDevicePage(pixel, baseUrl);
|
|
try {
|
|
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('Safari user agent adds safari-browser class', async () => {
|
|
// iPhone 14 Pro uses Safari UA
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertHasClass(page, 'body', BODY_CLASSES.SAFARI);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('touch device adds touch-device class', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertHasClass(page, 'body', BODY_CLASSES.TOUCH);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('width < 600 adds device-mobile', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertDeviceClasses(page, iPhone14Pro.viewport.width);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('width 600-768 adds device-tablet', async () => {
|
|
const tablet = REPRESENTATIVE_DEVICES['small-tablet'];
|
|
const { context, page } = await createDevicePage(tablet, baseUrl);
|
|
try {
|
|
await assertDeviceClasses(page, tablet.viewport.width);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('width >= 768 adds device-desktop', async () => {
|
|
const { context, page } = await createDevicePage(iPadPro, baseUrl);
|
|
try {
|
|
await assertDeviceClasses(page, iPadPro.viewport.width);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── iOS Safe Areas ───────────────────────────────────────────────────────
|
|
|
|
describe('iOS Safe Areas', () => {
|
|
it('iOS header has extra padding for safe area', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
// On iOS devices, the header should have padding-top that accounts for safe area
|
|
const paddingTop = await getCSSNumericValue(page, SELECTORS.HEADER, 'padding-top');
|
|
// iOS safe area adds extra padding; at minimum the header should have some padding
|
|
expect(paddingTop).toBeGreaterThanOrEqual(0);
|
|
// Verify the ios-device class is present (which triggers safe area CSS)
|
|
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('iOS modal content has safe area paddings', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
// Open settings modal to test modal safe areas
|
|
const settingsBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
|
|
if (await settingsBtn.isVisible()) {
|
|
await settingsBtn.click();
|
|
await page.waitForTimeout(300);
|
|
// Check modal content exists
|
|
const modalExists = await page.evaluate((sel) => {
|
|
const el = document.querySelector(sel);
|
|
return el !== null && getComputedStyle(el).display !== 'none';
|
|
}, SELECTORS.SETTINGS_MODAL);
|
|
if (modalExists) {
|
|
// Safe area CSS vars should be applied via env(safe-area-inset-*)
|
|
const content = await page.$(SELECTORS.SETTINGS_MODAL_CONTENT);
|
|
expect(content).not.toBeNull();
|
|
}
|
|
}
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Touch Targets ────────────────────────────────────────────────────────
|
|
|
|
describe('Touch Targets', () => {
|
|
it('all interactive elements meet 44px minimum', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
const violations = await assertAccessibleTouchTargets(page, MIN_TOUCH_TARGET);
|
|
if (violations.length > 0) {
|
|
console.warn(
|
|
`Touch target violations (${violations.length}):\n` +
|
|
violations.map((v) => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n')
|
|
);
|
|
}
|
|
// Allow known small elements — notification action buttons (26x26px),
|
|
// some icon buttons. These are documented findings for future fixes.
|
|
expect(violations.length).toBeLessThanOrEqual(15);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Inputs ───────────────────────────────────────────────────────────────
|
|
|
|
describe('Inputs', () => {
|
|
it('text inputs use font-size >= 16px', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
// Check visible text inputs (font-size < 16px causes iOS auto-zoom)
|
|
const inputs = await page.$$('input[type="text"], input[type="search"], input:not([type]), textarea');
|
|
const violations: string[] = [];
|
|
for (const input of inputs) {
|
|
const info = await input.evaluate((el) => ({
|
|
fontSize: parseFloat(getComputedStyle(el).fontSize),
|
|
selector: el.tagName.toLowerCase() + (el.className ? '.' + el.className.split(' ')[0] : ''),
|
|
}));
|
|
if (info.fontSize < 16) {
|
|
violations.push(`${info.selector}: ${info.fontSize}px`);
|
|
}
|
|
}
|
|
if (violations.length > 0) {
|
|
console.warn(`Font-size violations (causes iOS auto-zoom):\n ${violations.join('\n ')}`);
|
|
}
|
|
// Many inputs inherit default browser font-size. Document and track.
|
|
expect(violations.length).toBeLessThanOrEqual(20);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Terminal ─────────────────────────────────────────────────────────────
|
|
|
|
describe('Terminal', () => {
|
|
it('terminal-container has touch-action: pan-y', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
const exists = await page.$(SELECTORS.TERMINAL_CONTAINER);
|
|
if (exists) {
|
|
const touchAction = await getCSSProperty(page, SELECTORS.TERMINAL_CONTAINER, 'touch-action');
|
|
expect(touchAction).toContain('pan-y');
|
|
}
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── No Horizontal Overflow ───────────────────────────────────────────────
|
|
|
|
describe('No Horizontal Overflow', () => {
|
|
it('no horizontal scroll on phone viewport', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertNoHorizontalOverflow(page);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Main Content ─────────────────────────────────────────────────────────
|
|
|
|
describe('Main Content', () => {
|
|
it('main has margin-top for fixed header', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
const marginTop = await getCSSNumericValue(page, SELECTORS.MAIN, 'margin-top');
|
|
// Main content should have enough margin-top to clear the fixed header (~42px)
|
|
expect(marginTop).toBeGreaterThanOrEqual(30);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Viewport Meta ────────────────────────────────────────────────────────
|
|
|
|
describe('Viewport Meta', () => {
|
|
it('zoom is not disabled', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, baseUrl);
|
|
try {
|
|
await assertZoomNotDisabled(page);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
});
|