/** * E2E tests for the Configurations modal on mobile (BUG-071 + #114). * * Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px); * skipped on the desktop project that the CI job executes — same convention * as mobile-editor.spec.js. * * Covered: * - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav * as a left slide-over drawer (fixed positioning), hidden by default; * - the drawer has a close button (#config-toc-close) and a dimming backdrop * (tap outside closes the drawer, not the whole modal); * - picking a TOC entry scrolls to the section, marks the link active and * collapses the drawer; * - the modal is full-screen (100dvh) and interactive controls are ≥44px; * - the modal content does not overflow horizontally at 393px. * * Run: * npx playwright test tests/e2e/config-mobile.spec.js --project=chromium-mobile * (ObsiGate listening on http://localhost:2029, auth disabled) */ import { test, expect } from '@playwright/test'; const MOBILE_MAX_WIDTH = 768; const MIN_TOUCH_TARGET = 44; async function boot(page) { await page.goto('/'); await page.waitForSelector('#app:not(.hidden)', { timeout: 15000 }); await expect(page.locator('#header-menu-btn')).toBeVisible({ timeout: 15000 }); } async function openConfigModal(page) { await page.locator('#header-menu-btn').click(); await page.locator('#config-open-btn').click(); await expect(page.locator('#config-modal.active')).toBeVisible(); } test.describe('Configurations modal on mobile (BUG-071 + #114)', () => { test('hamburger reveals the TOC as a fixed drawer', async ({ page, viewport }) => { test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); await boot(page); await openConfigModal(page); // TOC hidden by default on mobile, hamburger visible. await expect(page.locator('#config-hamburger')).toBeVisible(); await expect(page.locator('#config-nav')).toBeHidden(); await page.locator('#config-hamburger').click(); await expect(page.locator('#config-nav')).toBeVisible(); // #114: the drawer is a fixed slide-over, not an inline top block. const position = await page.locator('#config-nav').evaluate( (el) => window.getComputedStyle(el).position, ); expect(position).toBe('fixed'); // The modal carries the drawer-open class (backdrop contract). await expect(page.locator('#config-modal')).toHaveClass(/config-toc-open/); }); test('close button and backdrop dismiss the drawer, not the modal', async ({ page, viewport }) => { test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); await boot(page); await openConfigModal(page); // Close button inside the drawer header. await page.locator('#config-hamburger').click(); await expect(page.locator('#config-toc-close')).toBeVisible(); await page.locator('#config-toc-close').click(); await expect(page.locator('#config-nav')).toBeHidden(); await expect(page.locator('#config-modal.active')).toBeVisible(); // Backdrop tap closes the drawer first; the modal stays open. // Click near the right edge of the modal (outside the left drawer). await page.locator('#config-hamburger').click(); await expect(page.locator('#config-nav')).toBeVisible(); await page.locator('#config-modal').click({ position: { x: (viewport?.width ?? 393) - 8, y: 200 } }); await expect(page.locator('#config-nav')).toBeHidden(); await expect(page.locator('#config-modal.active')).toBeVisible(); }); test('picking a section scrolls to it and collapses the drawer', async ({ page, viewport }) => { test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); await boot(page); await openConfigModal(page); await page.locator('#config-hamburger').click(); const link = page.locator('#config-nav a[href="#cfg-tokens"]'); await expect(link).toBeVisible(); await link.click(); // Drawer collapses on mobile after selection… await expect(page.locator('#config-nav')).toBeHidden(); // …the link is marked active… await expect(link).toHaveClass(/active/); // …and the section scrolls into view inside the modal (smooth scroll: // poll for the settled position instead of racing the animation). await expect .poll( async () => { const box = await page.locator('#cfg-tokens').boundingBox(); const modalBox = await page.locator('#config-modal').boundingBox(); if (!box || !modalBox) return Number.POSITIVE_INFINITY; return box.y - (modalBox.y + modalBox.height); }, { timeout: 8000 }, ) .toBeLessThanOrEqual(0); }); test('modal is full-screen and key controls meet the 44px touch target', async ({ page, viewport }) => { test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); await boot(page); await openConfigModal(page); // Full-screen: the modal container fills the viewport. const container = page.locator('#config-modal .editor-container'); const box = await container.boundingBox(); expect(box).not.toBeNull(); expect(box.width).toBeGreaterThanOrEqual((viewport?.width ?? 0) - 2); expect(box.height).toBeGreaterThanOrEqual((viewport?.height ?? 0) - 2); // Header controls (hamburger + close) are ≥44px. for (const sel of ['#config-hamburger', '#config-close']) { const b = await page.locator(sel).boundingBox(); expect(b, `${sel} has no bounding box`).not.toBeNull(); expect(b.height, `${sel} height`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET); expect(b.width, `${sel} width`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET); } // Backend Save row: buttons are ≥44px tall. await page.locator('#config-hamburger').click(); await page.locator('#config-nav a[href="#cfg-backend-settings"]').click(); await expect(page.locator('#config-nav')).toBeHidden(); const saveBtn = page.locator('#cfg-save-backend'); await expect(saveBtn).toBeVisible(); const saveBox = await saveBtn.boundingBox(); expect(saveBox).not.toBeNull(); expect(saveBox.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET); }); test('no horizontal overflow at 393px', async ({ page, viewport }) => { test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required'); await boot(page); await openConfigModal(page); for (const section of ['#cfg-ai', '#cfg-tokens', '#cfg-webhooks', '#cfg-partages-publics']) { await page.locator('#config-hamburger').click(); await page.locator(`#config-nav a[href="${section}"]`).click(); } const overflow = await page.evaluate(() => { const scroller = document.getElementById('config-scroll'); return scroller.scrollWidth - scroller.clientWidth; }); expect(overflow).toBeLessThanOrEqual(1); }); });