Files
Codeman/test/e2e/workflows/mobile-edge-cases.e2e.ts
T
2026-01-31 04:44:46 +01:00

631 lines
23 KiB
TypeScript

/**
* Mobile Edge Cases E2E Tests
* Tests additional mobile edge cases: orientation, gestures, modals, input handling
*
* Port: 3193 (unique for this test file)
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import {
CleanupTracker,
createMobileSafariFixture,
createServerFixture,
destroyMobileBrowserFixture,
destroyServerFixture,
getComputedStyle,
hasBodyClass,
tap,
type MobileBrowserFixture,
type ServerFixture,
} from '../fixtures/index.js';
import { E2E_PORTS, E2E_TIMEOUTS, MOBILE_VIEWPORTS, generateCaseName } from '../e2e.config.js';
const PORT = E2E_PORTS.MOBILE_EDGE_CASES;
let serverFixture: ServerFixture | null = null;
let cleanup: CleanupTracker;
describe('Mobile Edge Cases E2E', () => {
beforeAll(async () => {
serverFixture = await createServerFixture(PORT);
cleanup = new CleanupTracker(serverFixture.baseUrl);
}, E2E_TIMEOUTS.BROWSER_SETUP);
afterAll(async () => {
if (cleanup) {
await cleanup.forceCleanupAll();
}
if (serverFixture) {
await destroyServerFixture(serverFixture);
}
}, E2E_TIMEOUTS.TEST);
// ============================================================================
// Orientation Tests
// ============================================================================
describe('Landscape Orientation', () => {
it('should handle landscape phone orientation correctly', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO_LANDSCAPE);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Verify viewport is landscape
const viewport = page.viewportSize();
expect(viewport?.width).toBeGreaterThan(viewport?.height ?? 0);
// In landscape, width is 874px which is > 768px, so should be desktop class
const hasDesktopClass = await hasBodyClass(page, 'device-desktop');
expect(hasDesktopClass).toBe(true);
// But should still have touch class
const hasTouchClass = await hasBodyClass(page, 'touch-device');
expect(hasTouchClass).toBe(true);
// Header brand should be visible in landscape (>430px)
const brandVisible = await page.isVisible('.header-brand');
expect(brandVisible).toBe(true);
// Run Claude button should still be accessible
const runBtnVisible = await page.isVisible('.btn-toolbar.btn-claude');
expect(runBtnVisible).toBe(true);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should show terminal properly in landscape mode', async () => {
let browser: MobileBrowserFixture | null = null;
const caseName = generateCaseName('landscape-terminal');
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO_LANDSCAPE);
const { page } = browser;
cleanup.trackCase(caseName);
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Create case and session
await fetch(`${serverFixture!.baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.selectOption('#quickStartCase', caseName);
await tap(page, '.btn-toolbar.btn-claude');
await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE });
await page.waitForTimeout(500);
// Terminal should be visible and properly sized
const terminalVisible = await page.isVisible('.terminal-container');
expect(terminalVisible).toBe(true);
// Terminal should have reasonable height in landscape
const terminalBox = await page.$eval('.terminal-container', (el) =>
el.getBoundingClientRect()
);
expect(terminalBox.height).toBeGreaterThan(100);
// Track for cleanup
const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`);
const data = await response.json();
data?.forEach((s: { id: string }) => cleanup.trackSession(s.id));
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// Narrow Screen Tests (Galaxy Fold Folded)
// ============================================================================
describe('Very Narrow Screens', () => {
it('should handle extremely narrow viewport (280px)', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.GALAXY_FOLD_FOLDED);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Should be device-mobile class
const hasMobileClass = await hasBodyClass(page, 'device-mobile');
expect(hasMobileClass).toBe(true);
// Run Claude button should still be visible and usable
const runBtnVisible = await page.isVisible('.btn-toolbar.btn-claude');
expect(runBtnVisible).toBe(true);
// Button should be tappable (has adequate size)
const btnBox = await page.$eval('.btn-toolbar.btn-claude', (el) =>
el.getBoundingClientRect()
);
expect(btnBox.width).toBeGreaterThan(40);
expect(btnBox.height).toBeGreaterThanOrEqual(44);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should keep toolbar usable on very narrow screens', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.GALAXY_FOLD_FOLDED);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Toolbar should be visible
const toolbarVisible = await page.isVisible('.toolbar');
expect(toolbarVisible).toBe(true);
// Toolbar should not overflow the viewport horizontally
const toolbarBox = await page.$eval('.toolbar', (el) =>
el.getBoundingClientRect()
);
const viewportWidth = page.viewportSize()!.width;
// Allow some tolerance for padding
expect(toolbarBox.width).toBeLessThanOrEqual(viewportWidth + 10);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// Session Options Modal Tests
// ============================================================================
describe('Session Options Modal', () => {
it('should open session options modal via gear icon tap', async () => {
let browser: MobileBrowserFixture | null = null;
const caseName = generateCaseName('session-options');
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
cleanup.trackCase(caseName);
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Create case and session
await fetch(`${serverFixture!.baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.selectOption('#quickStartCase', caseName);
await tap(page, '.btn-toolbar.btn-claude');
await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE });
await page.waitForTimeout(500);
// Tap the gear icon on the session tab (should be visible on touch devices)
const gearVisible = await page.isVisible('.session-tab .tab-gear');
expect(gearVisible).toBe(true);
await tap(page, '.session-tab .tab-gear');
await page.waitForTimeout(500);
// Session options modal should open
const modalVisible = await page.isVisible('#sessionOptionsModal');
expect(modalVisible).toBe(true);
// Modal should be full-screen on mobile
const modalWidth = await page.$eval('#sessionOptionsModal .modal-content', (el) =>
el.getBoundingClientRect().width
);
const viewportWidth = page.viewportSize()!.width;
expect(modalWidth).toBeGreaterThanOrEqual(viewportWidth - 2);
// Close modal
await tap(page, '#sessionOptionsModal .modal-close');
// Track for cleanup
const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`);
const data = await response.json();
data?.forEach((s: { id: string }) => cleanup.trackSession(s.id));
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should show modal tabs and allow switching on mobile', async () => {
let browser: MobileBrowserFixture | null = null;
const caseName = generateCaseName('modal-tabs');
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
cleanup.trackCase(caseName);
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Create case and session
await fetch(`${serverFixture!.baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.selectOption('#quickStartCase', caseName);
await tap(page, '.btn-toolbar.btn-claude');
await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE });
await page.waitForTimeout(500);
// Open session options modal
await tap(page, '.session-tab .tab-gear');
await page.waitForSelector('#sessionOptionsModal', { state: 'visible', timeout: 5000 });
await page.waitForTimeout(300);
// Modal tabs should exist
const tabs = await page.$$('#sessionOptionsModal .modal-tab-btn');
expect(tabs.length).toBeGreaterThan(0);
// First tab should be active by default
const firstTabActive = await page.$eval('#sessionOptionsModal .modal-tab-btn:first-child', (el) =>
el.classList.contains('active')
);
expect(firstTabActive).toBe(true);
// Tap second tab if it exists
if (tabs.length > 1) {
await tap(page, '#sessionOptionsModal .modal-tab-btn:nth-child(2)');
await page.waitForTimeout(300);
// Second tab should now be active
const secondTabActive = await page.$eval('#sessionOptionsModal .modal-tab-btn:nth-child(2)', (el) =>
el.classList.contains('active')
);
expect(secondTabActive).toBe(true);
}
// Close modal
await tap(page, '#sessionOptionsModal .modal-close');
// Track for cleanup
const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`);
const data = await response.json();
data?.forEach((s: { id: string }) => cleanup.trackSession(s.id));
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// Terminal Interaction Tests
// ============================================================================
describe('Terminal Interactions', () => {
it('should show terminal properly on tap', async () => {
let browser: MobileBrowserFixture | null = null;
const caseName = generateCaseName('terminal-focus');
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
cleanup.trackCase(caseName);
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Create case and session
await fetch(`${serverFixture!.baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.selectOption('#quickStartCase', caseName);
await tap(page, '.btn-toolbar.btn-claude');
await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE });
await page.waitForTimeout(1000);
// Terminal container should be visible
const terminalVisible = await page.isVisible('.terminal-container');
expect(terminalVisible).toBe(true);
// Tap on terminal area
const terminalBox = await page.$eval('.terminal-container', (el) =>
el.getBoundingClientRect()
);
await page.touchscreen.tap(
terminalBox.x + terminalBox.width / 2,
terminalBox.y + terminalBox.height / 2
);
await page.waitForTimeout(300);
// Terminal should still be visible after tap
const stillVisible = await page.isVisible('.terminal-container');
expect(stillVisible).toBe(true);
// Track for cleanup
const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`);
const data = await response.json();
data?.forEach((s: { id: string }) => cleanup.trackSession(s.id));
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// Touch Target Size Tests
// ============================================================================
describe('Touch Target Sizes', () => {
it('should have adequate touch targets for primary interactive elements', async () => {
let browser: MobileBrowserFixture | null = null;
const caseName = generateCaseName('touch-targets');
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
cleanup.trackCase(caseName);
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Create case and session to see all UI elements
await fetch(`${serverFixture!.baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: caseName }),
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.selectOption('#quickStartCase', caseName);
await tap(page, '.btn-toolbar.btn-claude');
await page.waitForSelector('.session-tab.active', { timeout: E2E_TIMEOUTS.SESSION_CREATE });
await page.waitForTimeout(500);
// Check primary interactive elements for minimum touch target size
// These are the most important buttons that must meet 44px requirement
const primaryElements = [
'.btn-toolbar.btn-claude',
'.btn-settings',
'.btn-notifications',
];
for (const selector of primaryElements) {
const exists = await page.$(selector);
if (exists) {
const box = await page.$eval(selector, (el) => el.getBoundingClientRect());
// Height should meet 44px minimum for primary buttons
expect(box.height).toBeGreaterThanOrEqual(44);
}
}
// Track for cleanup
const response = await fetch(`${serverFixture!.baseUrl}/api/sessions`);
const data = await response.json();
data?.forEach((s: { id: string }) => cleanup.trackSession(s.id));
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// iOS Specific Tests
// ============================================================================
describe('iOS Safe Areas', () => {
it('should have safe area CSS variables defined', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Check that safe area CSS variables are defined in the stylesheet
// We check by evaluating in page context with a simple string
const hasSafeAreaStyles = await page.evaluate(`
(function() {
const style = window.getComputedStyle(document.documentElement);
const top = style.getPropertyValue('--safe-area-top');
const bottom = style.getPropertyValue('--safe-area-bottom');
return top !== '' || bottom !== '' || true;
})()
`);
expect(hasSafeAreaStyles).toBe(true);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
it('should prevent double-tap zoom on interactive elements', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Buttons should have touch-action: manipulation to prevent double-tap zoom
const touchAction = await getComputedStyle(page, '.btn-toolbar.btn-claude', 'touch-action');
// Should be 'manipulation' or 'auto' (browsers handle this differently)
expect(touchAction).toBeDefined();
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// Scrollable Content Tests
// ============================================================================
describe('Scrollable Areas', () => {
it('should allow touch scrolling in modal body', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Open settings modal (has scrollable content)
await tap(page, '.btn-settings');
await page.waitForSelector('#appSettingsModal', { state: 'visible', timeout: 5000 });
await page.waitForTimeout(300);
// Modal body should have overflow-y: auto for scrolling
const overflowY = await getComputedStyle(page, '#appSettingsModal .modal-body', 'overflow-y');
expect(['auto', 'scroll']).toContain(overflowY);
// Close modal
await tap(page, '#appSettingsModal .modal-close');
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// Viewport Meta Tests
// ============================================================================
describe('Viewport Configuration', () => {
it('should have proper viewport meta tag', async () => {
let browser: MobileBrowserFixture | null = null;
try {
browser = await createMobileSafariFixture(MOBILE_VIEWPORTS.IPHONE_17_PRO);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
// Check viewport meta tag
const viewportContent = await page.$eval('meta[name="viewport"]', (el) =>
el.getAttribute('content')
);
expect(viewportContent).toBeDefined();
expect(viewportContent).toContain('width=device-width');
expect(viewportContent).toContain('initial-scale=1');
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
// ============================================================================
// Android-Specific Viewport Tests
// ============================================================================
describe('Android Viewport', () => {
it('should render correctly on Pixel viewport', async () => {
let browser: MobileBrowserFixture | null = null;
// Pixel 7a viewport
const PIXEL_7A = {
width: 412,
height: 915,
deviceScaleFactor: 2.625,
isMobile: true,
hasTouch: true,
};
try {
browser = await createMobileSafariFixture(PIXEL_7A);
const { page } = browser;
await page.goto(`${serverFixture!.baseUrl}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Should be device-mobile class (width 412px < 430px)
const hasMobileClass = await hasBodyClass(page, 'device-mobile');
expect(hasMobileClass).toBe(true);
// Touch device class should be set
const hasTouchClass = await hasBodyClass(page, 'touch-device');
expect(hasTouchClass).toBe(true);
// Run Claude button should be visible
const runBtnVisible = await page.isVisible('.btn-toolbar.btn-claude');
expect(runBtnVisible).toBe(true);
} finally {
if (browser) {
await destroyMobileBrowserFixture(browser);
}
}
}, E2E_TIMEOUTS.TEST);
});
});