mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
Merge pull request #162 from shenlvkang-collab/fix/foldable-mobile-settings
fix(mobile): preserve settings across foldable postures
This commit is contained in:
@@ -228,6 +228,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
||||||
|
|
||||||
|
**Foldable settings identity**: responsive layout remains width-driven through `MobileDetection.getDeviceType()`, but the localStorage namespace/defaults use `MobileDetection.isHandheldDevice()` so an Android foldable keeps `codeman-app-settings-mobile` after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then `navigator.userAgentData.mobile`; Android WebView is covered by the `Mobile` UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as `showResponseViewer` and `extendedKeyboardBar`. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`.
|
||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
|
|
||||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry (see Command palette above).
|
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry (see Command palette above).
|
||||||
@@ -296,7 +298,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
|
|||||||
|
|
||||||
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
|
||||||
|
|
||||||
**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (135 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
|
**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (136 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
|
||||||
|
|
||||||
## Debugging
|
## Debugging
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,35 @@ const MobileDetection = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether this browser belongs to a handheld device.
|
||||||
|
*
|
||||||
|
* Unlike getDeviceType(), this classification must remain stable when a
|
||||||
|
* foldable changes posture. An unfolded phone can expose a desktop-width
|
||||||
|
* viewport, but it still needs the same per-device settings that were saved
|
||||||
|
* while folded. User-Agent Client Hints are preferred where available; the
|
||||||
|
* legacy token fallback covers Android WebView and iPhone browsers.
|
||||||
|
*/
|
||||||
|
isHandheldDevice() {
|
||||||
|
if (!this.isTouchDevice()) return false;
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent || '';
|
||||||
|
|
||||||
|
// Prefer explicit UA form-factor signals. Besides matching real browsers,
|
||||||
|
// this avoids Chromium emulation reporting userAgentData.mobile=true for
|
||||||
|
// an iPad/tablet context created with isMobile=true.
|
||||||
|
if (/iPad|Tablet|Silk|PlayBook|Kindle|Windows NT|CrOS|Macintosh/i.test(userAgent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)) return false;
|
||||||
|
if (/Mobi|iPhone|iPod/i.test(userAgent)) return true;
|
||||||
|
|
||||||
|
const uaDataMobile = navigator.userAgentData?.mobile;
|
||||||
|
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
/** Check if device is iOS (iPhone, iPad, iPod) */
|
/** Check if device is iOS (iPhone, iPad, iPod) */
|
||||||
isIOS() {
|
isIOS() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1758,17 +1758,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return settings.ralphTrackerEnabled ?? false;
|
return settings.ralphTrackerEnabled ?? false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get the settings storage key based on device type (mobile vs desktop)
|
// Keep the settings namespace stable across foldable posture changes. Layout
|
||||||
|
// still follows viewport width, but an unfolded phone remains the same
|
||||||
|
// handheld device and must not silently switch to desktop preferences.
|
||||||
getSettingsStorageKey() {
|
getSettingsStorageKey() {
|
||||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
const isHandheld =
|
||||||
return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
|
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
|
||||||
|
return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get default settings based on device type
|
// Get default settings based on device type
|
||||||
// Note: Notification prefs are handled separately by NotificationManager
|
// Note: Notification prefs are handled separately by NotificationManager
|
||||||
getDefaultSettings() {
|
getDefaultSettings() {
|
||||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
const isHandheld =
|
||||||
if (isMobile) {
|
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
|
||||||
|
if (isHandheld) {
|
||||||
// Mobile defaults: minimal UI for small screens
|
// Mobile defaults: minimal UI for small screens
|
||||||
return {
|
return {
|
||||||
// Header visibility - hide everything on mobile
|
// Header visibility - hide everything on mobile
|
||||||
@@ -2180,7 +2184,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
|
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
MobileDetection.getDeviceType() === 'mobile' &&
|
(MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') &&
|
||||||
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
|
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
|
||||||
) {
|
) {
|
||||||
const s = this.loadAppSettingsFromStorage();
|
const s = this.loadAppSettingsFromStorage();
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit).
|
Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit).
|
||||||
|
|
||||||
**325 tests across 135 devices — all passing.**
|
**326 tests across 136 devices — all passing.**
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Validates Codeman's mobile UI across 135 devices, covering:
|
Validates Codeman's mobile UI across 136 devices, covering:
|
||||||
|
|
||||||
- **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
|
- **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
|
||||||
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit)
|
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit)
|
||||||
@@ -26,7 +26,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.t
|
|||||||
# Quick mode — 6 representative devices, skip full matrix
|
# Quick mode — 6 representative devices, skip full matrix
|
||||||
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
|
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
|
||||||
|
|
||||||
# Full device matrix only (135 devices)
|
# Full device matrix only (136 devices)
|
||||||
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
|
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
|
||||||
|
|
||||||
# Update visual baselines (delete old baselines, re-run)
|
# Update visual baselines (delete old baselines, re-run)
|
||||||
@@ -43,7 +43,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
|
|||||||
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
|
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
|
||||||
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
|
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
|
||||||
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
|
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
|
||||||
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (135 devices) |
|
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) |
|
||||||
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
|
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
|
||||||
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
|
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
|
|||||||
| standard-tablet | 768–834px | ~8 | iPad Mini |
|
| standard-tablet | 768–834px | ~8 | iPad Mini |
|
||||||
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
|
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
|
||||||
|
|
||||||
135 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 67 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, iPad Air M2, Surface Pro, etc.).
|
136 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.).
|
||||||
|
|
||||||
### How Devices Are Differentiated
|
### How Devices Are Differentiated
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ Test File
|
|||||||
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
|
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
|
||||||
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
|
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
|
||||||
├─ helpers/visual.ts → pixelmatch screenshot comparison
|
├─ helpers/visual.ts → pixelmatch screenshot comparison
|
||||||
└─ devices.ts → 135-device registry
|
└─ devices.ts → 136-device registry
|
||||||
```
|
```
|
||||||
|
|
||||||
### Keyboard Simulation — 3-Layer Approach
|
### Keyboard Simulation — 3-Layer Approach
|
||||||
|
|||||||
+40
-29
@@ -1,6 +1,7 @@
|
|||||||
import { devices as playwrightDevices } from 'playwright';
|
import { devices as playwrightDevices } from 'playwright';
|
||||||
|
|
||||||
export type DeviceCategory = 'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet';
|
export type DeviceCategory =
|
||||||
|
'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet';
|
||||||
|
|
||||||
export interface DeviceEntry {
|
export interface DeviceEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -55,14 +56,7 @@ function fromPlaywright(name: string): DeviceEntry | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create a custom DeviceEntry for devices not in Playwright. */
|
/** Create a custom DeviceEntry for devices not in Playwright. */
|
||||||
function custom(
|
function custom(name: string, width: number, height: number, dpr: number, ua: string, isIOS: boolean): DeviceEntry {
|
||||||
name: string,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
dpr: number,
|
|
||||||
ua: string,
|
|
||||||
isIOS: boolean,
|
|
||||||
): DeviceEntry {
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
category: categoryFor(width),
|
category: categoryFor(width),
|
||||||
@@ -163,9 +157,9 @@ const PLAYWRIGHT_DEVICE_NAMES = [
|
|||||||
'iPad Pro 11', // 834x1194
|
'iPad Pro 11', // 834x1194
|
||||||
];
|
];
|
||||||
|
|
||||||
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES
|
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES.map((n) => fromPlaywright(n)).filter(
|
||||||
.map(n => fromPlaywright(n))
|
(d): d is DeviceEntry => d !== null
|
||||||
.filter((d): d is DeviceEntry => d !== null);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Custom devices — newer models and those missing from Playwright
|
// Custom devices — newer models and those missing from Playwright
|
||||||
@@ -240,7 +234,14 @@ const customEntries: DeviceEntry[] = [
|
|||||||
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
|
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
|
||||||
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
|
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
|
||||||
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
|
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
|
||||||
custom('Kindle Fire HD 8', 600, 1024, 1.5, 'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36', false),
|
custom(
|
||||||
|
'Kindle Fire HD 8',
|
||||||
|
600,
|
||||||
|
1024,
|
||||||
|
1.5,
|
||||||
|
'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36',
|
||||||
|
false
|
||||||
|
),
|
||||||
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
|
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
|
||||||
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
|
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
|
||||||
|
|
||||||
@@ -256,13 +257,23 @@ const customEntries: DeviceEntry[] = [
|
|||||||
custom('iPad Pro 12.9 (6th gen)', 1024, 1366, 2, IPAD_UA('16_0'), true),
|
custom('iPad Pro 12.9 (6th gen)', 1024, 1366, 2, IPAD_UA('16_0'), true),
|
||||||
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
|
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
|
||||||
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
|
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
|
||||||
custom('Surface Pro 7', 912, 1368, 2,
|
custom(
|
||||||
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0', false),
|
'Surface Pro 7',
|
||||||
|
912,
|
||||||
|
1368,
|
||||||
|
2,
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0',
|
||||||
|
false
|
||||||
|
),
|
||||||
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
|
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
|
||||||
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
|
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
|
||||||
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
|
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
|
||||||
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
|
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
|
||||||
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
|
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
|
||||||
|
// Find N5 inner display is 2248x2480 physical pixels. At DPR 2 its full-
|
||||||
|
// resolution CSS viewport crosses Codeman's desktop breakpoint while the
|
||||||
|
// browser remains a mobile/touch device.
|
||||||
|
custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false),
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -276,29 +287,29 @@ export const DEVICE_REGISTRY: DeviceEntry[] = [...playwrightEntries, ...customEn
|
|||||||
// Per-category exports
|
// Per-category exports
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-phone');
|
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-phone');
|
||||||
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-phone');
|
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-phone');
|
||||||
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-phone');
|
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-phone');
|
||||||
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-tablet');
|
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-tablet');
|
||||||
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-tablet');
|
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-tablet');
|
||||||
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-tablet');
|
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-tablet');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Platform exports
|
// Platform exports
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.isIOS);
|
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.isIOS);
|
||||||
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => !d.isIOS);
|
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => !d.isIOS);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Representative devices — one per category for quick smoke tests
|
// Representative devices — one per category for quick smoke tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const REPRESENTATIVE_DEVICES: Record<DeviceCategory, DeviceEntry> = {
|
export const REPRESENTATIVE_DEVICES: Record<DeviceCategory, DeviceEntry> = {
|
||||||
'small-phone': SMALL_PHONES.find(d => d.name === 'iPhone SE')!,
|
'small-phone': SMALL_PHONES.find((d) => d.name === 'iPhone SE')!,
|
||||||
'standard-phone': STANDARD_PHONES.find(d => d.name === 'iPhone 14 Pro')!,
|
'standard-phone': STANDARD_PHONES.find((d) => d.name === 'iPhone 14 Pro')!,
|
||||||
'large-phone': LARGE_PHONES.find(d => d.name === 'iPhone 15 Pro Max')!,
|
'large-phone': LARGE_PHONES.find((d) => d.name === 'iPhone 15 Pro Max')!,
|
||||||
'small-tablet': SMALL_TABLETS.find(d => d.name === 'Nexus 7')!,
|
'small-tablet': SMALL_TABLETS.find((d) => d.name === 'Nexus 7')!,
|
||||||
'standard-tablet': STANDARD_TABLETS.find(d => d.name === 'iPad Mini')!,
|
'standard-tablet': STANDARD_TABLETS.find((d) => d.name === 'iPad Mini')!,
|
||||||
'large-tablet': LARGE_TABLETS.find(d => d.name === 'iPad Pro 11')!,
|
'large-tablet': LARGE_TABLETS.find((d) => d.name === 'iPad Pro 11')!,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './
|
|||||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||||
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
||||||
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
|
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
|
||||||
import {
|
import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
|
||||||
assertVisible, assertHidden, getCSSProperty, getCSSNumericValue,
|
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
|
||||||
} from './helpers/assertions.js';
|
|
||||||
import { REPRESENTATIVE_DEVICES } from './devices.js';
|
|
||||||
import type { WebServer } from '../src/web/server.js';
|
import type { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
const PORT = PORTS.SETTINGS;
|
const PORT = PORTS.SETTINGS;
|
||||||
@@ -94,9 +92,7 @@ describe('Settings Modal', () => {
|
|||||||
if (gearBox && toolbarBox) {
|
if (gearBox && toolbarBox) {
|
||||||
// Gear button should be within toolbar's vertical range
|
// Gear button should be within toolbar's vertical range
|
||||||
expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5);
|
expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5);
|
||||||
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(
|
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(toolbarBox.y + toolbarBox.height + 5);
|
||||||
toolbarBox.y + toolbarBox.height + 5,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -304,11 +300,14 @@ describe('Settings Modal', () => {
|
|||||||
try {
|
try {
|
||||||
// Store a test setting
|
// Store a test setting
|
||||||
await page.evaluate((key) => {
|
await page.evaluate((key) => {
|
||||||
localStorage.setItem(key, JSON.stringify({
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
showFontControls: true,
|
showFontControls: true,
|
||||||
showMonitor: true,
|
showMonitor: true,
|
||||||
subagentTrackingEnabled: true,
|
subagentTrackingEnabled: true,
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
}, STORAGE_KEYS.SETTINGS_MOBILE);
|
||||||
|
|
||||||
// Reload page
|
// Reload page
|
||||||
@@ -335,20 +334,32 @@ describe('Settings Modal', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Store both mobile and desktop settings
|
// Store both mobile and desktop settings
|
||||||
await page.evaluate(({ mobileKey, desktopKey, notifKey }) => {
|
await page.evaluate(
|
||||||
|
({ mobileKey, desktopKey, notifKey }) => {
|
||||||
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
|
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
|
||||||
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
|
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
|
||||||
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
|
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
|
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
|
||||||
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
|
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
|
||||||
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
|
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Verify they are independent
|
// Verify they are independent
|
||||||
const mobile = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_MOBILE);
|
const mobile = await page.evaluate(
|
||||||
const desktop = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_DESKTOP);
|
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
|
||||||
const notif = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE);
|
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(mobile.showFontControls).toBe(false);
|
||||||
expect(desktop.showFontControls).toBe(true);
|
expect(desktop.showFontControls).toBe(true);
|
||||||
@@ -390,5 +401,53 @@ describe('Settings Modal', () => {
|
|||||||
await context.close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user