mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Apple's "Designing for iPhone Duo" asks an app to adapt to both displays, to stay continuous as the device opens and closes, and to treat the band a partly-open display folds through as a reserved region. Three things here. 1. A visual-viewport resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is never the virtual keyboard, which only ever takes height. handleViewportResize() read any height drop over 150px as the keyboard appearing, so closing a Duo (890 to 678pt tall) latched keyboardVisible with no keyboard on screen: the accessory bar appeared, main grew 84px of dead padding, and updateAppHeight() stopped refreshing --app-height. The latch was sticky, because clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at. Rotating any phone hit the same latch. The shape branch re-baselines instead, which is also what lets a keyboard opened after the fold be detected. 2. The hinge is now a reserved region in CSS. --fold-inline-end and --fold-block-end measure the strip to keep clear from the Viewport Segments env() variables, and are 0px everywhere else, so the seven centred overlays are inert by construction off a foldable. Each shrinks its content box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. 3. iPhone Duo (outer) and iPhone Duo (inner) join the mobile device registry, derived from Apple's published pixel specs at 3x. Verified in Chromium: flat, a dialog stays centred at 313 of a 626pt viewport; in book pose it centres at 153 inside the 0-305 leading segment with its right edge at 293, while the backdrop still spans all 626. The 3-term calc on the offline overlay resolves to 367px in tabletop pose and 20px flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
494 lines
19 KiB
TypeScript
494 lines
19 KiB
TypeScript
// Port 3203 - Settings modal mobile tests
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
import type { Page, BrowserContext } from 'playwright';
|
|
import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, 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 { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
|
|
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
|
|
import type { WebServer } from '../src/web/server.js';
|
|
|
|
const PORT = PORTS.SETTINGS;
|
|
const BASE_URL = `http://localhost:${PORT}`;
|
|
|
|
describe('Settings Modal', () => {
|
|
let server: WebServer;
|
|
|
|
beforeAll(async () => {
|
|
server = await createTestServer(PORT);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await stopTestServer(server);
|
|
await closeAllBrowsers();
|
|
});
|
|
|
|
// ── Mobile-Specific Behavior ──────────────────────────────────────────────
|
|
|
|
describe('Mobile-Specific Behavior', () => {
|
|
let page: Page;
|
|
let context: BrowserContext;
|
|
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
|
|
|
beforeEach(async () => {
|
|
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
|
page = result.page;
|
|
context = result.context;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await context.close();
|
|
});
|
|
|
|
it('uses codeman-app-settings-mobile localStorage key', async () => {
|
|
const key = await page.evaluate(() => {
|
|
return (window as any).app?.getSettingsStorageKey?.() ?? null;
|
|
});
|
|
// If the function exists, it should return the mobile key
|
|
if (key !== null) {
|
|
expect(key).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
|
|
} else {
|
|
// Verify by checking what key is used when saving settings
|
|
await page.evaluate(() => {
|
|
localStorage.setItem('codeman-app-settings-mobile', JSON.stringify({ testFlag: true }));
|
|
});
|
|
const stored = await page.evaluate(() => {
|
|
return localStorage.getItem('codeman-app-settings-mobile');
|
|
});
|
|
expect(stored).toContain('testFlag');
|
|
}
|
|
});
|
|
|
|
it('mobile defaults: all panels hidden, subagent tracking OFF, ralph OFF', async () => {
|
|
const defaults = await page.evaluate(() => {
|
|
const fn = (window as any).app?.getDefaultSettings;
|
|
if (fn) return fn.call((window as any).app);
|
|
return null;
|
|
});
|
|
|
|
if (defaults !== null) {
|
|
expect(defaults.showFontControls).toBe(false);
|
|
expect(defaults.showSystemStats).toBe(false);
|
|
expect(defaults.showTokenCount).toBe(false);
|
|
expect(defaults.showCost).toBe(false);
|
|
expect(defaults.showMonitor).toBe(false);
|
|
expect(defaults.showProjectInsights).toBe(false);
|
|
expect(defaults.showFileBrowser).toBe(false);
|
|
expect(defaults.showSubagents).toBe(false);
|
|
expect(defaults.subagentTrackingEnabled).toBe(false);
|
|
expect(defaults.ralphTrackerEnabled).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('settings gear button visible in toolbar on phones', async () => {
|
|
await assertVisible(page, SELECTORS.SETTINGS_MOBILE);
|
|
});
|
|
|
|
it('settings gear button is inside toolbar area', async () => {
|
|
const gearBox = await page.locator(SELECTORS.SETTINGS_MOBILE).boundingBox();
|
|
const toolbarBox = await page.locator(SELECTORS.TOOLBAR).boundingBox();
|
|
|
|
if (gearBox && toolbarBox) {
|
|
// Gear button should be within toolbar's vertical range
|
|
expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5);
|
|
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(toolbarBox.y + toolbarBox.height + 5);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Full-Screen Modal ─────────────────────────────────────────────────────
|
|
|
|
describe('Full-Screen Modal', () => {
|
|
let page: Page;
|
|
let context: BrowserContext;
|
|
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
|
|
|
beforeEach(async () => {
|
|
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
|
page = result.page;
|
|
context = result.context;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await context.close();
|
|
});
|
|
|
|
async function openSettingsModal(): Promise<void> {
|
|
// Try clicking the mobile settings button
|
|
const btn = page.locator(SELECTORS.SETTINGS_MOBILE);
|
|
if (await btn.isVisible()) {
|
|
await btn.click();
|
|
await page.waitForTimeout(500);
|
|
} else {
|
|
// Fallback: trigger via JS
|
|
await page.evaluate(() => {
|
|
const fn = (window as any).app?.openAppSettings;
|
|
if (fn) fn.call((window as any).app);
|
|
// Also try directly showing the modal
|
|
const modal = document.querySelector('#appSettingsModal') as HTMLElement;
|
|
if (modal) modal.style.display = 'flex';
|
|
});
|
|
await page.waitForTimeout(500);
|
|
}
|
|
}
|
|
|
|
it('modal takes full width on mobile', async () => {
|
|
await openSettingsModal();
|
|
const modalContent = page.locator(SELECTORS.SETTINGS_MODAL_CONTENT).first();
|
|
if (await modalContent.isVisible()) {
|
|
const box = await modalContent.boundingBox();
|
|
const viewport = page.viewportSize()!;
|
|
// Full width = very close to viewport width
|
|
expect(box!.width).toBeGreaterThanOrEqual(viewport.width - 2);
|
|
}
|
|
});
|
|
|
|
it('modal takes full height on mobile', async () => {
|
|
await openSettingsModal();
|
|
const modalContent = page.locator(SELECTORS.SETTINGS_MODAL_CONTENT).first();
|
|
if (await modalContent.isVisible()) {
|
|
const box = await modalContent.boundingBox();
|
|
const viewport = page.viewportSize()!;
|
|
expect(box!.height).toBeGreaterThanOrEqual(viewport.height - 2);
|
|
}
|
|
});
|
|
|
|
it('modal has no border-radius on mobile (flat edges)', async () => {
|
|
await openSettingsModal();
|
|
const modalContent = page.locator(SELECTORS.SETTINGS_MODAL_CONTENT).first();
|
|
if (await modalContent.isVisible()) {
|
|
const radius = await getCSSProperty(page, SELECTORS.SETTINGS_MODAL_CONTENT, 'border-radius');
|
|
const numericRadius = parseFloat(radius);
|
|
expect(numericRadius).toBeLessThanOrEqual(1); // 0 or essentially 0
|
|
}
|
|
});
|
|
|
|
it('modal tabs are horizontally scrollable', async () => {
|
|
await openSettingsModal();
|
|
const modalTabs = page.locator(SELECTORS.SETTINGS_MODAL_TABS).first();
|
|
if (await modalTabs.isVisible()) {
|
|
// Use first() since multiple modal-tabs elements exist
|
|
const flexWrap = await page.evaluate((sel) => {
|
|
const el = document.querySelector(sel);
|
|
return el ? getComputedStyle(el).flexWrap : '';
|
|
}, SELECTORS.SETTINGS_MODAL_TABS);
|
|
const overflowX = await page.evaluate((sel) => {
|
|
const el = document.querySelector(sel);
|
|
return el ? getComputedStyle(el).overflowX : '';
|
|
}, SELECTORS.SETTINGS_MODAL_TABS);
|
|
expect(flexWrap).toBe('nowrap');
|
|
expect(overflowX).toBe('auto');
|
|
}
|
|
});
|
|
|
|
it('modal body adjusts height when keyboard visible', async () => {
|
|
await openSettingsModal();
|
|
const modalBody = page.locator(SELECTORS.SETTINGS_MODAL_BODY).first();
|
|
if (await modalBody.isVisible()) {
|
|
// Show keyboard via DOM (safest for modal context)
|
|
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT, { preferredLayer: 'dom' });
|
|
await page.waitForTimeout(300);
|
|
|
|
const maxHeight = await getCSSProperty(page, SELECTORS.SETTINGS_MODAL_BODY, 'max-height');
|
|
// When keyboard is visible, modal body should be constrained
|
|
// The CSS rule is max-height: 40vh
|
|
if (maxHeight.includes('vh') || parseFloat(maxHeight) < device.viewport.height * 0.5) {
|
|
expect(true).toBe(true); // Constrained
|
|
}
|
|
|
|
await hideKeyboard(page, { preferredLayer: 'dom' });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Settings Toggles ──────────────────────────────────────────────────────
|
|
|
|
describe('Settings Toggles', () => {
|
|
let page: Page;
|
|
let context: BrowserContext;
|
|
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
|
|
|
beforeEach(async () => {
|
|
const result = await createDevicePage(device, BASE_URL, 'chromium');
|
|
page = result.page;
|
|
context = result.context;
|
|
|
|
// Clear settings before each test
|
|
await page.evaluate((key) => localStorage.removeItem(key), STORAGE_KEYS.SETTINGS_MOBILE);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await context.close();
|
|
});
|
|
|
|
async function openSettingsAndToggle(settingId: string): Promise<boolean> {
|
|
// Open settings modal
|
|
const btn = page.locator(SELECTORS.SETTINGS_MOBILE);
|
|
if (await btn.isVisible()) {
|
|
await btn.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
// Find and toggle the setting
|
|
const toggle = page.locator(`#${settingId}`);
|
|
if (await toggle.isVisible()) {
|
|
const wasCheked = await toggle.isChecked();
|
|
await toggle.click();
|
|
await page.waitForTimeout(200);
|
|
return !wasCheked;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function getStoredSettings(): Promise<Record<string, unknown> | null> {
|
|
return page.evaluate((key) => {
|
|
const raw = localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : null;
|
|
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
|
}
|
|
|
|
it('font controls toggle changes and persists', async () => {
|
|
await openSettingsAndToggle('showFontControls');
|
|
const settings = await getStoredSettings();
|
|
if (settings) {
|
|
expect(settings).toHaveProperty('showFontControls');
|
|
}
|
|
});
|
|
|
|
it('subagent tracking toggle changes and persists', async () => {
|
|
await openSettingsAndToggle('subagentTrackingEnabled');
|
|
const settings = await getStoredSettings();
|
|
if (settings) {
|
|
expect(settings).toHaveProperty('subagentTrackingEnabled');
|
|
}
|
|
});
|
|
|
|
it('ralph tracker toggle changes and persists', async () => {
|
|
await openSettingsAndToggle('ralphTrackerEnabled');
|
|
const settings = await getStoredSettings();
|
|
if (settings) {
|
|
expect(settings).toHaveProperty('ralphTrackerEnabled');
|
|
}
|
|
});
|
|
|
|
it('image watcher toggle changes and persists', async () => {
|
|
await openSettingsAndToggle('imageWatcherEnabled');
|
|
const settings = await getStoredSettings();
|
|
if (settings) {
|
|
expect(settings).toHaveProperty('imageWatcherEnabled');
|
|
}
|
|
});
|
|
|
|
it('tall tabs toggle changes and persists', async () => {
|
|
await openSettingsAndToggle('tallTabs');
|
|
const settings = await getStoredSettings();
|
|
if (settings) {
|
|
expect(settings).toHaveProperty('tallTabs');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Persistence Roundtrip ─────────────────────────────────────────────────
|
|
|
|
describe('Persistence Roundtrip', () => {
|
|
const device = REPRESENTATIVE_DEVICES['standard-phone'];
|
|
|
|
it('settings survive page reload', async () => {
|
|
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
|
|
|
|
try {
|
|
// Store a test setting
|
|
await page.evaluate((key) => {
|
|
localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
showFontControls: true,
|
|
showMonitor: true,
|
|
subagentTrackingEnabled: true,
|
|
})
|
|
);
|
|
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
|
|
|
// Reload page
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
|
|
|
// Verify settings persisted
|
|
const settings = await page.evaluate((key) => {
|
|
const raw = localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : null;
|
|
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
|
|
|
expect(settings).not.toBeNull();
|
|
expect(settings?.showFontControls).toBe(true);
|
|
expect(settings?.showMonitor).toBe(true);
|
|
expect(settings?.subagentTrackingEnabled).toBe(true);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('mobile notification prefs stored separately from desktop settings', async () => {
|
|
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
|
|
|
|
try {
|
|
// Store both mobile and desktop settings
|
|
await page.evaluate(
|
|
({ mobileKey, desktopKey, notifKey }) => {
|
|
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
|
|
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
|
|
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
|
|
},
|
|
{
|
|
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
|
|
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
|
|
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
|
|
}
|
|
);
|
|
|
|
// Verify they are independent
|
|
const mobile = await page.evaluate(
|
|
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
|
STORAGE_KEYS.SETTINGS_MOBILE
|
|
);
|
|
const desktop = await page.evaluate(
|
|
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
|
STORAGE_KEYS.SETTINGS_DESKTOP
|
|
);
|
|
const notif = await page.evaluate(
|
|
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
|
STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE
|
|
);
|
|
|
|
expect(mobile.showFontControls).toBe(false);
|
|
expect(desktop.showFontControls).toBe(true);
|
|
expect(notif.mobileNotif).toBe(true);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Tablet vs Desktop Settings ────────────────────────────────────────────
|
|
|
|
describe('Tablet vs Desktop Settings', () => {
|
|
it('tablet uses desktop settings key', async () => {
|
|
const device = REPRESENTATIVE_DEVICES['standard-tablet'];
|
|
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
|
|
|
|
try {
|
|
const key = await page.evaluate(() => {
|
|
return (window as any).app?.getSettingsStorageKey?.() ?? null;
|
|
});
|
|
|
|
// Tablets (>= 430px) should NOT use mobile settings key
|
|
if (key !== null) {
|
|
expect(key).toBe(STORAGE_KEYS.SETTINGS_DESKTOP);
|
|
}
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('settings gear button not in toolbar on desktop', async () => {
|
|
const device = REPRESENTATIVE_DEVICES['large-tablet'];
|
|
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
|
|
|
|
try {
|
|
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('keeps handheld settings and finds no keyboard when an iPhone Duo closes', async () => {
|
|
// The Duo pair does not cross the desktop breakpoint the way Find N5 does
|
|
// (466 and 626 are both in the tablet band), so what this covers is the
|
|
// other half of "a continuous experience as the device opens and closes":
|
|
// the fold takes 212px of height, which handleViewportResize() used to
|
|
// read as the virtual keyboard appearing. Unit-covered in
|
|
// test/viewport-shape-change.test.ts; this drives the real resize.
|
|
const inner = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (inner)')!;
|
|
const outer = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (outer)')!;
|
|
const { page, context } = await createDevicePage(inner, BASE_URL, 'chromium');
|
|
|
|
try {
|
|
await page.evaluate((key) => {
|
|
localStorage.setItem(key, JSON.stringify({ showResponseViewer: true }));
|
|
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
|
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
|
|
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
|
|
|
await page.setViewportSize(outer.viewport);
|
|
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
|
|
|
const state = await page.evaluate(() => ({
|
|
handheld: (window as any).MobileDetection.isHandheldDevice(),
|
|
storageKey: (window as any).app.getSettingsStorageKey(),
|
|
// The two user-visible symptoms of the latch. KeyboardHandler itself
|
|
// is a script-scope const with no window export, and the flag is
|
|
// asserted directly in the unit test.
|
|
keyboardClass: document.body.classList.contains('keyboard-visible'),
|
|
mainPadding: (document.querySelector('.main') as HTMLElement | null)?.style.paddingBottom ?? '',
|
|
}));
|
|
|
|
expect(state.handheld).toBe(true);
|
|
expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
|
|
expect(state.keyboardClass).toBe(false);
|
|
expect(state.mainPadding).toBe('');
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
|
|
it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => {
|
|
const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
|
|
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');
|
|
|
|
try {
|
|
// Seed the preferences while folded, exactly as a phone user does.
|
|
await page.setViewportSize({ width: 412, height: 915 });
|
|
await page.evaluate((key) => {
|
|
localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
showResponseViewer: true,
|
|
extendedKeyboardBar: true,
|
|
})
|
|
);
|
|
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
|
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
|
|
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
|
|
|
// Unfolding can reload Android WebView. The viewport now uses desktop
|
|
// layout, but the physical device and its preferences have not changed.
|
|
await page.setViewportSize(device.viewport);
|
|
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
|
|
await page.waitForTimeout(WAIT.SSE_CONNECT);
|
|
|
|
const state = await page.evaluate(() => ({
|
|
deviceType: (window as any).MobileDetection.getDeviceType(),
|
|
handheld: (window as any).MobileDetection.isHandheldDevice(),
|
|
storageKey: (window as any).app.getSettingsStorageKey(),
|
|
responseViewerVisible: !document
|
|
.querySelector('.btn-response-viewer-header')
|
|
?.classList.contains('btn-response-viewer-header--hidden'),
|
|
keyboardExtended: Boolean(document.querySelector('.keyboard-accessory-bar [data-action="arrow-left"]')),
|
|
}));
|
|
|
|
expect(state.deviceType).toBe('desktop');
|
|
expect(state.handheld).toBe(true);
|
|
expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
|
|
expect(state.responseViewerVisible).toBe(true);
|
|
expect(state.keyboardExtended).toBe(true);
|
|
|
|
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
|
|
await assertVisible(page, '.keyboard-accessory-bar');
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
});
|
|
});
|
|
});
|