Files
odysseus/tests/e2e/ios-layout/postmerge-layout.spec.cjs
T
Alexandre Teixeira 6fc6095aec test(e2e): harden mobile layout coverage, artifact isolation, and live pane assertions
- Store Playwright evidence in ignored test-results/ paths by default while preserving env override
- Clean up disposable temporary application data directories in /tmp on global teardown and exit signals
- Add Chromium mobile and tablet viewport profiles (phone portrait/landscape, tablet) for automated coverage
- Add a scoped Chromium mobile layout CI check on shard 1/4 to prevent redundant matrix execution
- Reach mobile Settings and Notes views via genuine sidebar navigation controls opened through the hamburger button
- Add assertions against live rendered Notes and Documents panes honoring safe-area top and height constraints
- Document local WebKit prerequisites and physical iOS/PWA test limitations in tests/e2e/README.md and package.json
2026-10-30 13:41:15 +00:00

314 lines
13 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const path = require('node:path');
async function openApp(page) {
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
expect(response?.ok()).toBeTruthy();
// The runtime currently produces two #message nodes. Select the
// initialized composer while diagnosing whether both are visible.
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
nodes.map((el, index) => ({
index,
initialized: el.dataset.startupPreserveBound === '1',
visible: !!el.getClientRects().length &&
getComputedStyle(el).visibility !== 'hidden',
parent: el.parentElement?.className || '',
}))
);
if (composers.length !== 1) {
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
}
const activeComposer = page.locator(
'textarea#message[data-startup-preserve-bound="1"]'
);
await expect(activeComposer).toHaveCount(1);
await expect(activeComposer).toBeVisible();
expect(
composers.filter(composer => composer.visible).length,
'Exactly one composer should be visible'
).toBe(1);
}
function isMobileViewport(testInfo) {
const width = testInfo.project.use?.viewport?.width || 0;
return width > 0 && width <= 768;
}
/**
* Reach Settings through genuine UI controls on either desktop or mobile viewports.
* On mobile (<=768px), the rail is hidden and the sidebar must be opened via hamburger.
*/
async function openSettings(page, isMobile) {
let settingsButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
settingsButton = sidebar.locator('#user-bar-settings');
} else {
settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
}
await expect(settingsButton, 'Settings button must be reachable and visible').toBeVisible({ timeout: 5000 });
await settingsButton.click();
const dialog = page.locator('#settings-modal');
await expect(dialog, 'Settings modal must become visible').toBeVisible({ timeout: 5000 });
return dialog;
}
/**
* Reach Notes through genuine UI controls on either desktop or mobile viewports.
*/
async function openNotes(page, isMobile) {
let notesButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
notesButton = sidebar.locator('#tool-notes-btn');
} else {
notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
}
await expect(notesButton, 'Notes button must be reachable and visible').toBeVisible({ timeout: 5000 });
await notesButton.click();
const realNotes = page.locator('.notes-pane').first();
await expect(realNotes, 'Notes pane must become visible').toBeVisible({ timeout: 5000 });
return realNotes;
}
/**
* Reach Document editor through genuine UI controls on either desktop or mobile viewports.
*/
async function openDocument(page, isMobile) {
let docButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
docButton = sidebar.locator('#library-new-doc-btn');
} else {
docButton = page.locator('#library-new-doc-btn:visible').first();
if (!(await docButton.count())) {
const hamburger = page.locator('#hamburger-btn:visible').first();
if (await hamburger.count()) {
await hamburger.click();
await page.waitForTimeout(300);
docButton = page.locator('#sidebar:not(.hidden) #library-new-doc-btn');
}
}
}
await expect(docButton, 'Document creation button must be reachable and visible').toBeVisible({ timeout: 5000 });
await docButton.click();
const docPane = page.locator('.doc-editor-pane').first();
await expect(docPane, 'Document editor pane must become visible').toBeVisible({ timeout: 10000 });
return docPane;
}
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
await openApp(page);
const out = testInfo.config.metadata.evidenceDir;
fs.mkdirSync(out, { recursive: true });
const name = testInfo.project.name;
const isMobile = isMobileViewport(testInfo);
// 1. Chat screenshot (default view)
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
// 2. Settings screenshot via actual user-visible navigation
await openApp(page);
await openSettings(page, isMobile);
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
// 3. Notes screenshot via actual user-visible navigation
await openApp(page);
await openNotes(page, isMobile);
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
testInfo.annotations.push({ type: 'evidence', description: out });
});
test('viewport meta does not restrict user zoom', async ({ page }) => {
await openApp(page);
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
});
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
await openApp(page);
const value = await page.evaluate(async () => {
document.documentElement.style.overflowY = 'auto';
document.body.style.overflowY = 'auto';
document.body.style.height = 'auto';
document.body.style.minHeight = '3200px';
const spacer = document.createElement('div');
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
document.body.appendChild(spacer);
window.scrollTo(0, 250);
await new Promise(resolve => setTimeout(resolve, 150));
return window.scrollY;
});
expect(value).toBeGreaterThan(100);
});
test('CSS cascade rules for mobile Notes and Documents respect safe areas', async ({ page }, testInfo) => {
test.skip(!isMobileViewport(testInfo), 'Only mobile viewports <=768px');
await openApp(page);
const results = await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
const values = {};
for (const [key, bodyClass, paneClass] of [
['notes', 'notes-view', 'notes-pane'],
['document', 'doc-view', 'doc-editor-pane'],
]) {
const pane = document.createElement('section');
pane.className = paneClass;
pane.style.opacity = '0'; // CSS cascade regression check
document.body.appendChild(pane);
document.body.classList.add(bodyClass);
const style = window.getComputedStyle(pane);
values[key] = {
top: style.top,
height: style.height,
insetTop: pane.getBoundingClientRect().top,
};
pane.remove();
document.body.classList.remove(bodyClass);
}
return values;
});
for (const key of ['notes', 'document']) {
expect(results[key].top, key).toBe('68px');
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
}
});
test('real Notes and Document panes honor safe-area geometry in the live application', async ({ page }, testInfo) => {
const isMobile = isMobileViewport(testInfo);
test.skip(!isMobile, 'Only mobile viewports <=768px');
await openApp(page);
// Configure simulated safe-area insets (e.g. iPhone notch / Dynamic Island)
await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
});
// 1. Verify actual Notes UI
const realNotes = await openNotes(page, isMobile);
const notesMetrics = await realNotes.evaluate(el => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
top: style.top,
height: style.height,
maxHeight: style.maxHeight,
rectTop: rect.top,
rectHeight: rect.height,
};
});
expect(notesMetrics.top, 'Real Notes pane top must match --sheet-top').toBe('68px');
expect(parseFloat(notesMetrics.height), 'Real Notes pane height must respect safe area').toBeGreaterThanOrEqual(770);
expect(notesMetrics.rectTop, 'Real Notes pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
// 2. Verify actual Document UI
await openApp(page);
await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
});
const realDoc = await openDocument(page, isMobile);
const docMetrics = await realDoc.evaluate(el => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
top: style.top,
height: style.height,
maxHeight: style.maxHeight,
rectTop: rect.top,
rectHeight: rect.height,
};
});
expect(docMetrics.top, 'Real Document pane top must match --sheet-top').toBe('68px');
expect(parseFloat(docMetrics.height), 'Real Document pane height must respect safe area').toBeGreaterThanOrEqual(770);
expect(docMetrics.rectTop, 'Real Document pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
});
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
test.skip(isMobileViewport(testInfo), 'Geometry test uses desktop resizable windows');
await openApp(page);
const value = await page.evaluate(async () => {
document.documentElement.style.setProperty('--safe-left', '58px');
document.body.style.setProperty('padding-left', '58px', 'important');
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
document.body.appendChild(probe);
const key = 'odysseus-layout-restore-test';
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
try {
// Body horizontal padding transitions over 160ms. Wait for the
// simulated safe-area change to settle before restoring geometry.
await new Promise(resolve => setTimeout(resolve, 300));
const padAtRestore = parseFloat(
getComputedStyle(document.body).paddingLeft
);
if (Math.abs(padAtRestore - 58) > 0.5) {
throw new Error(
`Safe-area fixture did not settle: ${padAtRestore}px`
);
}
// Verify the running server actually serves the patched module.
const response = await fetch(
'/static/js/windowResize.js',
{ cache: 'no-store' }
);
if (!response.ok) {
throw new Error('Cannot fetch windowResize.js');
}
const moduleSource = await response.text();
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
throw new Error('Running server is serving unpatched windowResize.js');
}
const { makeWindowResizable } = await import('/static/js/windowResize.js');
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
await new Promise(resolve => setTimeout(resolve, 300));
return {
left: parseFloat(probe.style.left),
width: parseFloat(probe.style.width),
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
viewportWidth: window.innerWidth,
};
} finally {
localStorage.removeItem(key);
probe.remove();
}
});
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
expect(value.width).toBe(480);
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
});