mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
454 lines
17 KiB
TypeScript
454 lines
17 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 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();
|
|
}
|
|
});
|
|
});
|
|
});
|