mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Mobile-focused fixes for the web UI: keyboard-accessory layout and overlap, native input visibility above the keyboard, CJK input handling, terminal touch scrolling, tab-menu tap targets, mic-recording glow containment, and mobile resize/keyboard-state handling on tab switch, plus mobile visual-regression test coverage and snapshots. Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
514 lines
20 KiB
TypeScript
514 lines
20 KiB
TypeScript
// Port 3204 - 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 { PORTS, 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';
|
|
|
|
const PORT = PORTS.LAYOUT;
|
|
const BASE_URL = `http://localhost:${PORT}`;
|
|
|
|
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(PORT);
|
|
});
|
|
|
|
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, BASE_URL));
|
|
});
|
|
|
|
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, BASE_URL));
|
|
});
|
|
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL, '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 at the 430px phone/tablet boundary', async () => {
|
|
const device = REPRESENTATIVE_DEVICES['large-phone'];
|
|
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
|
try {
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
await assertHidden(page, '#voiceInputBtn');
|
|
await assertVisible(page, '#voiceInputBtnMobile');
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('uses phone upload and voice controls without toolbar overlap', async () => {
|
|
const device = REPRESENTATIVE_DEVICES['small-phone'];
|
|
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
|
|
try {
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
|
|
await assertVisible(page, '.btn-upload-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, BASE_URL, '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();
|
|
}
|
|
});
|
|
|
|
it('keeps desktop upload and voice controls in one centered row', async () => {
|
|
const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium');
|
|
try {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
await assertVisible(page, '.toolbar-center .btn-upload');
|
|
await assertVisible(page, '#voiceInputBtn');
|
|
|
|
const layout = await page.evaluate(() => {
|
|
const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect();
|
|
const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect();
|
|
return {
|
|
centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)),
|
|
centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)),
|
|
};
|
|
});
|
|
|
|
expect(layout.centerYDifference).toBeLessThanOrEqual(2);
|
|
expect(layout.centerXDifference).toBeGreaterThan(20);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Device Classes ───────────────────────────────────────────────────────
|
|
|
|
describe('Device Classes', () => {
|
|
it('iOS user agent adds ios-device class', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
try {
|
|
await assertHasClass(page, 'body', BODY_CLASSES.TOUCH);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('width < 430 adds device-mobile', async () => {
|
|
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
|
|
try {
|
|
await assertDeviceClasses(page, iPhone14Pro.viewport.width);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('width 430-768 adds device-tablet', async () => {
|
|
const tablet = REPRESENTATIVE_DEVICES['small-tablet'];
|
|
const { context, page } = await createDevicePage(tablet, BASE_URL);
|
|
try {
|
|
await assertDeviceClasses(page, tablet.viewport.width);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('width >= 768 adds device-desktop', async () => {
|
|
const { context, page } = await createDevicePage(iPadPro, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
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, BASE_URL);
|
|
try {
|
|
await assertZoomNotDisabled(page);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
});
|