// 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 { // 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 { // 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 | 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 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(); } }); }); });