From 4205dfa07215f29ca67019898fefdc1a85ee4294 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 28 Oct 2026 09:52:36 -0700 Subject: [PATCH] Simplify E2E coverage --- apps/web/e2e/bootstrap.e2e.ts | 7 + apps/web/e2e/config-update.e2e.ts | 6 - apps/web/e2e/details-dialog.e2e.ts | 339 +++-------------------- apps/web/e2e/error-recovery.e2e.ts | 108 +++----- apps/web/e2e/signin.e2e.ts | 399 ++++++++------------------- apps/web/e2e/site-header-logo.e2e.ts | 96 ++----- apps/web/e2e/theme-toggle.e2e.ts | 348 ++++------------------- 7 files changed, 285 insertions(+), 1018 deletions(-) diff --git a/apps/web/e2e/bootstrap.e2e.ts b/apps/web/e2e/bootstrap.e2e.ts index f27512f..166fb8a 100644 --- a/apps/web/e2e/bootstrap.e2e.ts +++ b/apps/web/e2e/bootstrap.e2e.ts @@ -9,6 +9,13 @@ test.describe('without JavaScript', () => { await expect(page.getByRole('heading', { name: 'Directory' })).toBeVisible(); await expect(page.getByText("A small directory for your family's homelab.")).toBeVisible(); await expect(page.getByText('No bookmarks yet.')).toBeVisible(); + const logo = page.locator('header img'); + await expect(logo).toHaveAttribute('src', '/favicon.png'); + await expect(logo).toHaveAttribute('alt', ''); + await expect + .poll(() => logo.evaluate((node) => (node as HTMLImageElement).naturalWidth)) + .toBeGreaterThan(0); + await logo.evaluate((node) => (node as HTMLImageElement).decode()); const favicon = await request.get('/favicon.png'); expect(favicon.ok()).toBe(true); expect(favicon.headers()['content-type']).toContain('image/png'); diff --git a/apps/web/e2e/config-update.e2e.ts b/apps/web/e2e/config-update.e2e.ts index 219d25e..65f33b3 100644 --- a/apps/web/e2e/config-update.e2e.ts +++ b/apps/web/e2e/config-update.e2e.ts @@ -184,7 +184,6 @@ test('an accepted edit leaves the current document and typed URL alone until nat await expect(page).toHaveURL(address); await expect(page.locator('.tile-title')).toHaveText(['PRIVATE_C6_SERVICE']); await expect(search(page)).toBeFocused(); - await expect(search(page).locator('..')).toHaveCSS('outline-color', 'rgb(19, 87, 155)'); await writeFile( join(dirname(server.path), 'public', 'config-update.svg'), @@ -202,7 +201,6 @@ test('an accepted edit leaves the current document and typed URL alone until nat ); await expect(search(page)).toHaveValue('tag:PRIVATE_C6_TAG'); await expect(search(page)).toBeFocused(); - await expect(search(page).locator('..')).toHaveCSS('outline-color', 'rgb(19, 87, 155)'); await expect(page.locator('body')).toHaveAttribute('data-update-document', 'original'); await expect(page.locator('header img')).toHaveAttribute('src', '/favicon.png'); expect(imageRequests).toHaveLength(1); @@ -230,7 +228,6 @@ test('an accepted edit leaves the current document and typed URL alone until nat await expect(refresh).toHaveAttribute('href', refreshedAddress); await expect(refresh).toHaveJSProperty('href', refreshedAddress); expect(versionRequests).toHaveLength(checksAtNotice); - await expect(page.locator('.update-notice')).toHaveCSS('position', 'sticky'); await Promise.all([page.waitForEvent('domcontentloaded'), refresh.click()]); await expect(page).toHaveURL(refreshedAddress); await expect(page).toHaveTitle(updatedTitle); @@ -247,7 +244,6 @@ test('an accepted edit leaves the current document and typed URL alone until nat await expect(search(page)).toHaveValue(refreshedQuery); await expect(page.locator('.tile-title')).toHaveText(['PRIVATE_C6_SERVICE']); await search(page).click(); - await expect(search(page).locator('..')).toHaveCSS('outline-color', 'rgb(36, 104, 172)'); await expect(page.locator('header img')).toHaveAttribute('src', logoPath); await expect(page.locator('header img')).toHaveJSProperty('naturalWidth', 48); await expect.poll(() => imageRequests.length).toBe(2); @@ -286,8 +282,6 @@ test('real SPA data changes do not forget the document baseline or steal dialog await page.clock.runFor(5_000); await expect(notice(page)).toHaveCount(1); await expect(action).toBeFocused(); - await expect(page.locator('.update-notice')).toHaveCSS('z-index', '5'); - await expect(page.locator('[data-dialog-overlay]')).toHaveCSS('z-index', '10'); const stoppedAt = checks; await page.clock.runFor(60_000); await page.evaluate(() => { diff --git a/apps/web/e2e/details-dialog.e2e.ts b/apps/web/e2e/details-dialog.e2e.ts index e499e51..89d6d7e 100644 --- a/apps/web/e2e/details-dialog.e2e.ts +++ b/apps/web/e2e/details-dialog.e2e.ts @@ -6,19 +6,11 @@ const longCopy = 'Long details with an unbroken token ' + 'word'.repeat(30) + '\n' + 'Instructions.\n'.repeat(90); const configuration = `site: title: Dialog fixture -appearance: - primary_light_color: '#13579b' - primary_dark_color: '#e2c4a6' links: alpha: title: Alpha service url: https://alpha.example.test/path?from=directory - group: Household description: ${JSON.stringify(longCopy)} - tags: [photos, backup] - requires_auth: true - internal_only: true - admin_only: true beta: title: Beta service url: https://beta.example.test/ @@ -36,9 +28,6 @@ test.afterAll(async () => { async function hydrated(page: Page) { await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeEnabled(); - await expect( - page.getByRole('button', { name: 'Switch to dark theme', exact: true }) - ).toBeEnabled(); } async function holdHydration(page: Page) { @@ -61,55 +50,24 @@ async function finishRendering(page: Page) { ); } -test('direct SSR keeps plain details in noscript without a visible card before hydration, then enhances once', async ({ +test('direct SSR enhances details once after hydration, without flashing plain details', async ({ page }) => { const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); - page.on('console', (message) => { - if (/hydration/i.test(message.text())) errors.push(message.text()); - }); const delayed = await holdHydration(page); try { const response = await page.goto(`${server.url}/?link=alpha`, { waitUntil: 'commit' }); - const html = await response!.text(); - expect(html).toMatch(/]*>[\s\S]*Back to directory[\s\S]*<\/noscript>/); - expect(html).not.toContain('role="dialog"'); - const section = page.getByRole('region', { name: 'Alpha service', exact: true }); - await expect(section).toHaveCount(0); - await expect(page.locator('section.details')).toHaveCount(0); - await expect(page.locator('.tile')).toHaveCount(2); + expect(await response!.text()).toMatch(/ { +test('focus trap, dismissal, history and return focus work together', async ({ page }) => { await page.goto(server.url); await hydrated(page); const tile = page.locator('a[href="/?link=beta"]'); const close = page.getByRole('button', { name: 'Close', exact: true }); - const external = page.getByRole('link', { - name: 'Open Beta service (opens in a new tab)', - exact: true - }); + const external = page.getByRole('link', { name: /^Open Beta service/ }); const destination = page.getByRole('textbox', { name: 'Destination', exact: true }); await tile.click(); await expect(external).toBeFocused(); @@ -143,8 +96,6 @@ test('controlled focus trap, Escape, outside dismissal, close, history and retur await expect(close).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(external).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(close).toBeFocused(); await page.keyboard.press('Escape'); await expect(page).toHaveURL(`${server.url}/`); await expect(tile).toBeFocused(); @@ -164,7 +115,7 @@ test('controlled focus trap, Escape, outside dismissal, close, history and retur await expect(tile).toBeFocused(); }); -test('direct close safely replaces the URL, keeps the baseline dismissed, and reload uses real detail URLs', async ({ +test('direct close replaces the URL and reload cannot reopen dismissed details', async ({ page }) => { await page.goto(`${server.url}/?link=beta`); @@ -172,7 +123,6 @@ test('direct close safely replaces the URL, keeps the baseline dismissed, and re await page.getByRole('button', { name: 'Close', exact: true }).click(); await expect(page).toHaveURL(`${server.url}/`); await expect(page.getByRole('dialog')).toHaveCount(0); - await expect(page.locator('section.details')).toHaveCount(0); await expect(page.getByRole('heading', { level: 1 })).toBeFocused(); await page.locator('a[href="/?link=alpha"]').click(); await expect(page).toHaveURL(`${server.url}/?link=alpha`); @@ -180,70 +130,31 @@ test('direct close safely replaces the URL, keeps the baseline dismissed, and re await expect(page.getByRole('dialog', { name: 'Alpha service' })).toBeVisible(); await page.getByRole('button', { name: 'Close', exact: true }).click(); await expect(page).toHaveURL(`${server.url}/`); - await expect(page.locator('section.details')).toHaveCount(0); await page.reload(); - await expect(page.locator('section.details')).toHaveCount(0); + await expect(page.getByRole('dialog')).toHaveCount(0); }); -for (const entry of ['grid', 'direct']) { - test(`blocked client boot: ${entry} retains disabled controls and native grid URLs without plain details`, async ({ - page - }) => { - let documents = 0; - let chunks = 0; - page.on('request', (request) => { - if (request.isNavigationRequest()) documents++; - }); - await page.route('**/*.js', (route) => { - chunks++; - return route.abort(); - }); - await page.goto(`${server.url}/${entry === 'direct' ? '?link=beta' : ''}`); - await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeDisabled(); - await expect( - page.getByRole('button', { name: 'Switch to dark theme', exact: true }) - ).toBeDisabled(); - if (entry === 'grid') await page.locator('a[href="/?link=beta"]').click(); - await expect(page).toHaveURL(`${server.url}/?link=beta`); - await expect(page.getByRole('region', { name: 'Beta service', exact: true })).toHaveCount(0); - await expect(page.locator('section.details')).toHaveCount(0); - await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeDisabled(); - await expect( - page.getByRole('button', { name: 'Switch to dark theme', exact: true }) - ).toBeDisabled(); - expect(chunks).toBeGreaterThan(0); - await page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())) - ) - ); - expect(documents).toBe(entry === 'grid' ? 2 : 1); - await expect(page.getByRole('dialog')).toHaveCount(0); - await expect(page.getByRole('link', { name: 'Back to directory' })).toHaveCount(0); - const html = await (await server.fetch('/?link=beta')).text(); - expect(html).toMatch(/]*>[\s\S]*Back to directory[\s\S]*<\/noscript>/); - expect(html).toContain('href="https://beta.example.test/"'); - const tile = page.locator('a[href="/?link=alpha"]'); - await expect(tile).toHaveAttribute('data-sveltekit-preload-data', 'off'); - await tile.click(); - await expect(page).toHaveURL(`${server.url}/?link=alpha`); - await expect(page.getByRole('region', { name: 'Alpha service', exact: true })).toHaveCount(0); - await expect(page.locator('.tile')).toHaveCount(2); - expect(documents).toBe(entry === 'grid' ? 3 : 2); - }); -} +test('blocked client boot keeps controls disabled and native grid navigation usable', async ({ + page +}) => { + await page.route('**/*.js', (route) => route.abort()); + await page.goto(server.url); + await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: /theme/ })).toBeDisabled(); + await page.locator('a[href="/?link=beta"]').click(); + await expect(page).toHaveURL(`${server.url}/?link=beta`); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.locator('a[href="/?link=alpha"]').click(); + await expect(page).toHaveURL(`${server.url}/?link=alpha`); + await expect(page.locator('.tile')).toHaveCount(2); +}); -test('modifier and middle activation preserve real new-tab navigation without enhancing the original grid', async ({ +test('modifier and middle activation preserve native new-tab navigation', async ({ page, context }) => { await page.goto(server.url); await hydrated(page); - const assets: string[] = []; - page.on('request', (request) => { - if (['script', 'stylesheet'].includes(request.resourceType())) assets.push(request.url()); - }); for (const options of [ { modifiers: ['Control'] as ['Control'] }, { button: 'middle' as const } @@ -256,108 +167,24 @@ test('modifier and middle activation preserve real new-tab navigation without en await expect(page).toHaveURL(`${server.url}/`); await expect(page.getByRole('dialog')).toHaveCount(0); } - expect(assets).toEqual([]); }); -for (const width of [360, 1280]) { - test(`When-style entrance motion and reduced-motion preference: ${width}px`, async ({ page }) => { - await page.setViewportSize({ width, height: 800 }); - for (const reducedMotion of ['no-preference', 'reduce'] as const) { - await page.emulateMedia({ reducedMotion }); - await page.goto(server.url); - await hydrated(page); - await page.locator('a[href="/?link=beta"]').click(); - const dialog = page.getByRole('dialog', { name: 'Beta service', exact: true }); - const overlay = page.locator('[data-dialog-overlay]'); - await expect(dialog).toBeVisible(); - await expect(overlay).toHaveCSS('backdrop-filter', 'blur(3px)'); - if (reducedMotion === 'reduce') { - await expect(overlay).toHaveCSS('animation-name', 'none'); - await expect(dialog).toHaveCSS('animation-name', 'none'); - } else { - await expect(overlay).toHaveCSS('animation-name', /backdrop-fade-in$/); - await expect(overlay).toHaveCSS('animation-duration', '0.15s'); - await expect(overlay).toHaveCSS('animation-timing-function', 'ease-out'); - if (width === 360) { - await expect(dialog).toHaveCSS('animation-name', /sheet-slide-up$/); - await expect(dialog).toHaveCSS('animation-duration', '0.2s'); - await expect(dialog).toHaveCSS('animation-timing-function', 'ease-out'); - } else { - await expect(dialog).toHaveCSS('animation-name', /card-fade-in$/); - await expect(dialog).toHaveCSS('animation-duration', '0.15s'); - await expect(dialog).toHaveCSS('animation-timing-function', 'ease-out'); - } - } - await dialog.evaluate((node) => - Promise.all(node.getAnimations().map((animation) => animation.finished)) - ); - await expect(overlay).toHaveCSS('opacity', '1'); - const box = (await dialog.boundingBox())!; - if (width === 360) expect(box.y + box.height).toBeCloseTo(800, 0); - else expect(box.y + box.height / 2).toBeCloseTo(400, 0); - await page.keyboard.press('Escape'); - await expect(page.getByRole('dialog')).toHaveCount(0); - await expect(page.locator('a[href="/?link=beta"]')).toBeFocused(); - } - }); +test('long details keep actions reachable on a narrow screen', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`${server.url}/?link=alpha`); + const dialog = page.getByRole('dialog', { name: 'Alpha service' }); + await expect(dialog).toBeVisible(); + const action = dialog.getByRole('link', { name: /^Open / }); + await action.scrollIntoViewIfNeeded(); + await expect(action).toBeInViewport(); + const close = dialog.getByRole('button', { name: 'Close', exact: true }); + await expect(close).toBeInViewport(); + await close.click(); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); - for (const colorScheme of ['light', 'dark'] as const) { - test(`responsive sheet, internal scrolling, scroll lock and protected accent: ${width} ${colorScheme}`, async ({ - page - }) => { - await page.setViewportSize({ width, height: 800 }); - await page.emulateMedia({ colorScheme, reducedMotion: 'reduce' }); - await page.goto(`${server.url}/?link=alpha`); - const dialog = page.getByRole('dialog', { name: 'Alpha service' }); - await expect(dialog).toBeVisible(); - const destination = dialog.getByRole('textbox', { name: 'Destination', exact: true }); - await expect(destination).toHaveCSS( - 'color', - colorScheme === 'light' ? 'rgb(23, 23, 23)' : 'rgb(245, 245, 245)' - ); - const close = dialog.getByRole('button', { name: 'Close', exact: true }); - const external = dialog.getByRole('link', { - name: 'Open Alpha service (opens in a new tab)', - exact: true - }); - await expect(external).toBeFocused(); - await expect(external).toHaveCSS( - 'outline-color', - await external.evaluate((node) => getComputedStyle(node).color) - ); - await expect(external).toHaveCSS('outline-offset', '-2px'); - expect(await dialog.evaluate((node) => node.parentElement === document.body)).toBe(true); - const box = (await dialog.boundingBox())!; - expect(box.x).toBeGreaterThanOrEqual(0); - expect(box.x + box.width).toBeLessThanOrEqual(width); - expect(box.height).toBeLessThanOrEqual(800); - if (width === 360) expect(box.y + box.height).toBeCloseTo(800, 0); - else expect(box.x + box.width / 2).toBeCloseTo(width / 2, 0); - const closeBox = (await close.boundingBox())!; - expect(closeBox.width).toBeGreaterThanOrEqual(44); - expect(closeBox.height).toBeGreaterThanOrEqual(44); - await expect(page.locator('body')).toHaveCSS('overflow', 'hidden'); - const scroll = dialog.locator('.scroll-content'); - expect(await scroll.evaluate((node) => node.scrollHeight > node.clientHeight)).toBe(true); - await scroll.evaluate((node) => { - node.scrollTop = node.scrollHeight; - }); - await expect( - dialog.getByRole('link', { - name: 'Open Alpha service (opens in a new tab)', - exact: true - }) - ).toBeInViewport(); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( - true - ); - await close.click(); - await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden'); - }); - } -} - -test('local details keep the loaded snapshot until real native navigation accepts a replacement', async ({ +test('local details retain their snapshot until native navigation accepts a replacement', async ({ page }) => { const app = await startServer({ configuration }); @@ -382,13 +209,12 @@ test('local details keep the loaded snapshot until real native navigation accept await expect( page.getByRole('dialog', { name: 'Replacement alpha', exact: true }) ).toBeVisible(); - expect(documentRequests.filter((url) => url.includes('__data.json'))).toHaveLength(2); } finally { expect(await app.stop()).toEqual({ code: 0, signal: null }); } }); -test('a native snapshot replacement supersedes an already active tile without reopening stale details', async ({ +test('a native snapshot replacement supersedes active details without reopening stale ones', async ({ page }) => { const app = await startServer({ configuration }); @@ -405,16 +231,15 @@ test('a native snapshot replacement supersedes an already active tile without re await finishRendering(page); await expect(page.getByRole('dialog')).toHaveCount(1); await expect(page.getByRole('heading', { name: 'Alpha service', exact: true })).toHaveCount(0); - await expect(page).toHaveURL(`${app.url}/?link=beta`); await page.getByRole('button', { name: 'Close', exact: true }).click(); await expect(page).toHaveURL(`${app.url}/`); - await expect(page.locator('section.details')).toHaveCount(0); + await expect(page.getByRole('dialog')).toHaveCount(0); } finally { expect(await app.stop()).toEqual({ code: 0, signal: null }); } }); -test('dialog scroll lock and close preserve the grid scroll position', async ({ page }) => { +test('closing details preserves the background scroll position', async ({ page }) => { const app = await startServer({ configuration: configuration.replace( 'title: Dialog fixture', @@ -442,102 +267,33 @@ test('dialog scroll lock and close preserve the grid scroll position', async ({ } }); -test('a disconnected tile returns focus to the directory heading', async ({ page }) => { - await page.goto(server.url); - await hydrated(page); - const tile = page.locator('a[href="/?link=beta"]'); - await tile.click(); - await expect(page.getByRole('dialog')).toBeVisible(); - await tile.evaluate((node) => node.remove()); - await page.getByRole('button', { name: 'Close', exact: true }).click(); - await expect(page.getByRole('heading', { level: 1 })).toBeFocused(); -}); - -for (const dismissal of ['Close', 'Escape', 'outside']) { - test(`hydrated direct details remain controlled after selecting another tile and ${dismissal}`, async ({ - page - }) => { - await page.goto(`${server.url}/?link=alpha`); - await hydrated(page); - await expect(page.getByRole('dialog', { name: 'Alpha service' })).toBeVisible(); - // Exercise a controlled selection change; the modal correctly makes the grid inert to users. - await page.locator('a[href="/?link=beta"]').evaluate((tile: HTMLAnchorElement) => tile.click()); - await expect(page.getByRole('dialog', { name: 'Beta service' })).toBeVisible(); - if (dismissal === 'Close') { - await page.getByRole('button', { name: 'Close', exact: true }).click(); - } else if (dismissal === 'Escape') { - await page.keyboard.press('Escape'); - } else { - await page.locator('[data-dialog-overlay]').click({ position: { x: 5, y: 5 } }); - } - await expect(page).toHaveURL(`${server.url}/?link=alpha`); - await expect(page.getByRole('dialog', { name: 'Alpha service' })).toBeVisible(); - await page.getByRole('button', { name: 'Close', exact: true }).click(); - await expect(page).toHaveURL(`${server.url}/`); - await expect(page.getByRole('dialog')).toHaveCount(0); - await page.locator('a[href="/?link=beta"]').click(); - await expect(page.getByRole('dialog', { name: 'Beta service' })).toBeVisible(); - }); -} - -test('rapid A then B activation synchronously records both URLs and leaves B controlled', async ({ - page -}) => { - await page.goto(server.url); - await hydrated(page); - const locations = await page.evaluate(() => { - document.querySelector('a[href="/?link=alpha"]')!.click(); - const first = location.search; - document.querySelector('a[href="/?link=beta"]')!.click(); - return [first, location.search]; - }); - expect(locations).toEqual(['?link=alpha', '?link=beta']); - await expect(page.getByRole('dialog', { name: 'Beta service' })).toBeVisible(); - await expect(page.getByRole('dialog')).toHaveCount(1); - await page.getByRole('button', { name: 'Close', exact: true }).click(); - await expect(page).toHaveURL(`${server.url}/?link=alpha`); - await expect(page.getByRole('dialog', { name: 'Alpha service' })).toBeVisible(); - await page.getByRole('button', { name: 'Close', exact: true }).click(); - await expect(page).toHaveURL(`${server.url}/`); - await finishRendering(page); - await expect(page.getByRole('dialog')).toHaveCount(0); -}); - -test('browser Back remains native while hydration is pending and cannot reopen details afterward', async ({ - page -}) => { +test('Back during pending hydration cannot reopen details afterward', async ({ page }) => { const delayed = await holdHydration(page); try { await page.goto(server.url, { waitUntil: 'commit' }); await delayed.requested; await page.goto(`${server.url}/?link=alpha`, { waitUntil: 'commit' }); - await expect(page.locator('.tile')).toHaveCount(2); - await expect(page.getByRole('region', { name: 'Alpha service', exact: true })).toHaveCount(0); - await expect(page.getByRole('link', { name: 'Back to directory', exact: true })).toHaveCount(0); + await expect(page.getByRole('dialog')).toHaveCount(0); await page.goBack({ waitUntil: 'commit' }); await expect(page).toHaveURL(`${server.url}/`); delayed.release(); await hydrated(page); await finishRendering(page); await expect(page.getByRole('dialog')).toHaveCount(0); - await expect(page.locator('section.details')).toHaveCount(0); } finally { delayed.release(); await page.unrouteAll({ behavior: 'wait' }); } }); -for (const cancellation of ['back', 'native navigation', 'unmount']) { - test(`active details stay dismissed after ${cancellation} without a pending reopen`, async ({ - page - }) => { +for (const cancellation of ['native navigation', 'unmount']) { + test(`active details stay dismissed after ${cancellation}`, async ({ page }) => { await page.goto(server.url); await hydrated(page); await page.locator('a[href="/?link=alpha"]').click(); - await expect(page.getByRole('dialog', { name: 'Alpha service' })).toBeVisible(); - if (cancellation === 'back') await page.goBack(); + await expect(page.getByRole('dialog')).toBeVisible(); if (cancellation === 'native navigation') await navigateQuery(page, '/'); - if (cancellation === 'unmount') await navigateQuery(page, '/missing'); + else await navigateQuery(page, '/missing'); await finishRendering(page); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page).toHaveURL(`${server.url}${cancellation === 'unmount' ? '/missing' : '/'}`); @@ -545,9 +301,6 @@ for (const cancellation of ['back', 'native navigation', 'unmount']) { await expect( page.getByRole('heading', { name: 'Page not found', exact: true }) ).toBeVisible(); - await expect(page.getByText('404', { exact: true })).toBeVisible(); - } else { - await expect(page.locator('section.details')).toHaveCount(0); } }); } diff --git a/apps/web/e2e/error-recovery.e2e.ts b/apps/web/e2e/error-recovery.e2e.ts index 0624e80..47968fd 100644 --- a/apps/web/e2e/error-recovery.e2e.ts +++ b/apps/web/e2e/error-recovery.e2e.ts @@ -8,74 +8,39 @@ import { } from './support/configuration.js'; import { startServer } from './support/server.js'; -for (const javaScriptEnabled of [true, false]) { - for (const colorScheme of ['light', 'dark'] as const) { - test(`missing pages and bookmarks have native recovery (JavaScript=${javaScriptEnabled}, ${colorScheme})`, async ({ - browser - }) => { - let width = 1280; - if (colorScheme === 'dark') width = 390; - const context = await browser.newContext({ - javaScriptEnabled, - colorScheme, - viewport: { width, height: 800 } - }); - const server = await startServer({ configuration: configuration('') }); - try { - await server.ready(); - const page = await context.newPage(); - for (const path of [ - '/missing-page?message=UNTRUSTED_ERROR_COPY', - '/?link=missing', - '/?link=household&link=administration' - ]) { - const response = await page.goto(server.url + path); - expect(response?.status()).toBe(404); - await expect(page).toHaveTitle('Error 404'); - await expect( - page.getByRole('heading', { name: 'Page not found', exact: true }) - ).toBeVisible(); - await expect(page.getByText('404', { exact: true })).toBeVisible(); - await expect(page.locator('main > p')).toHaveCount(1); - await expect(page.locator('main')).not.toContainText(/directory/i); - const recovery = page.getByRole('link', { name: 'Back to home', exact: true }); - await expect(recovery).toHaveAttribute('href', '/'); - await expect(recovery).toHaveAttribute('data-sveltekit-reload', 'true'); - const statusBounds = await page.getByText('404', { exact: true }).boundingBox(); - const headingBounds = await page - .getByRole('heading', { name: 'Page not found', exact: true }) - .boundingBox(); - const bounds = await recovery.boundingBox(); - const viewport = page.viewportSize()!; - for (const element of [statusBounds!, headingBounds!, bounds!]) { - expect( - Math.abs(element.x + element.width / 2 - viewport.width / 2) - ).toBeLessThanOrEqual(1); - } - const contentCenter = (statusBounds!.y + bounds!.y + bounds!.height) / 2; - expect(Math.abs(contentCenter - viewport.height / 2)).toBeLessThanOrEqual(1); - expect(bounds!.height).toBeGreaterThanOrEqual(48); - expect( - await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth) - ).toBe(true); - for (const value of [...privateSentinels, 'UNTRUSTED_ERROR_COPY']) { - await expect(page.locator('main')).not.toContainText(value); - } - await page.keyboard.press('Tab'); - await expect(recovery).toBeFocused(); - await expect(recovery).toHaveCSS('outline-style', 'solid'); - await expect(recovery).toHaveCSS('outline-width', '2px'); - await page.keyboard.press('Enter'); - await expect(page).toHaveURL(`${server.url}/`); - await expect(page.getByRole('heading', { name: siteTitle, exact: true })).toBeVisible(); - } - } finally { - await context.close(); - await server.stop(); - } - }); +test('without JavaScript missing routes and bookmarks recover with the keyboard', async ({ + browser +}) => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const server = await startServer({ configuration: configuration('') }); + try { + await server.ready(); + const page = await context.newPage(); + for (const path of ['/missing-page?message=UNTRUSTED_ERROR_COPY', '/?link=missing']) { + const response = await page.goto(server.url + path); + expect(response?.status()).toBe(404); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expect(page.getByText('404', { exact: true })).toBeVisible(); + const html = await page.content(); + for (const value of [...privateSentinels, server.path]) expect(html).not.toContain(value); + await expect(page.locator('main')).not.toContainText('UNTRUSTED_ERROR_COPY'); + const recovery = page.getByRole('link', { name: 'Back to home', exact: true }); + await expect(recovery).toBeVisible(); + await expect(recovery).toHaveAttribute('href', '/'); + await page.keyboard.press('Tab'); + await expect(recovery).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(`${server.url}/`); + await expect(page.getByRole('heading', { name: siteTitle, exact: true })).toBeVisible(); + } + } finally { + try { + await context.close(); + } finally { + await server.stop(); + } } -} +}); test('recovery preserves authentication and does not replace private API denials', async ({ browser @@ -98,14 +63,17 @@ test('recovery preserves authentication and does not replace private API denials await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); const response = await page.goto(`${server.url}/?link=missing`); expect(response?.status()).toBe(404); - await expect(page.getByRole('heading', { name: 'Page not found', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); const html = await page.content(); for (const value of [...privateSentinels, server.path]) expect(html).not.toContain(value); await page.getByRole('link', { name: 'Back to home', exact: true }).click(); await expect(page).toHaveURL(`${server.url}/`); await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); } finally { - await context.close(); - await server.stop(); + try { + await context.close(); + } finally { + await server.stop(); + } } }); diff --git a/apps/web/e2e/signin.e2e.ts b/apps/web/e2e/signin.e2e.ts index eeae3a7..1e01fd8 100644 --- a/apps/web/e2e/signin.e2e.ts +++ b/apps/web/e2e/signin.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, test, type Locator } from '@playwright/test'; @@ -15,36 +15,25 @@ import { startIssuer } from './support/oidc-issuer.js'; import { startServer, type TestServer } from './support/server.js'; const logoSource = '/public/PRIVATE_SELECTED_LOGO.svg'; -const otherSource = '/public/PRIVATE_OTHER_IMAGE.svg'; const faviconSource = '/public/PRIVATE_TAB_ICON.svg'; const logo = ''; const favicon = ''; -const light = '#0f766e'; -const dark = '#13579b'; let publicDir: string; -function brandedConfiguration(auth = credentialsAuth, source: string | null = logoSource) { - let logoField = ''; - if (source !== null) logoField = `\n logo_path: ${source}`; - return ( - configuration(auth).replace( - ` title: ${JSON.stringify(siteTitle)}`, - ` title: ${JSON.stringify(siteTitle)}${logoField}\n favicon_path: ${faviconSource}` - ) + - `appearance: - primary_light_color: '${light}' - primary_dark_color: '${dark}' -` +function brandedConfiguration(auth = credentialsAuth, source = logoSource) { + return configuration(auth).replace( + ` title: ${JSON.stringify(siteTitle)}`, + ` title: ${JSON.stringify(siteTitle)}\n logo_path: ${source}\n favicon_path: ${faviconSource}` ); } +// Sign-in HTML/data must omit configured image paths. function expectPrivate(text: string, app: TestServer) { for (const value of [ ...privateSentinels, logoSource, - otherSource, faviconSource, app.path, publicDir, @@ -63,27 +52,11 @@ async function expectDecoded(image: Locator, source: string) { await image.evaluate((node) => (node as HTMLImageElement).decode()); } -function contrast(foreground: string, background: string) { - function luminance(rgb: string) { - const channels = rgb - .match(/[\d.]+/g)! - .slice(0, 3) - .map((value) => { - const channel = Number(value) / 255; - return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; - }); - return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722; - } - const values = [luminance(foreground), luminance(background)].sort((a, b) => a - b); - return (values[1]! + 0.05) / (values[0]! + 0.05); -} - test.setTimeout(60_000); test.beforeEach(async () => { publicDir = await mkdtemp(join(tmpdir(), 'directory-signin-')); await writeFile(join(publicDir, 'PRIVATE_SELECTED_LOGO.svg'), logo); - await writeFile(join(publicDir, 'PRIVATE_OTHER_IMAGE.svg'), favicon); await writeFile(join(publicDir, 'PRIVATE_TAB_ICON.svg'), favicon); }); @@ -91,136 +64,92 @@ test.afterEach(async () => { await rm(publicDir, { recursive: true, force: true }); }); -for (const strategy of ['credentials', 'sso'] as const) { - for (const width of [360, 1280]) { - for (const colorScheme of ['light', 'dark'] as const) { - test(`${strategy} presentation at ${width}px in ${colorScheme}`, async ({ browser }) => { - const issuer = strategy === 'sso' ? await startIssuer() : undefined; - let auth = credentialsAuth; - if (issuer) auth = oidcAuth(issuer.origin); - const app = await startServer({ configuration: brandedConfiguration(auth), publicDir }); - const context = await browser.newContext({ - viewport: { width, height: 900 }, - hasTouch: width === 360, - isMobile: width === 360, - colorScheme, - reducedMotion: 'reduce' - }); - try { - await app.ready(); - const page = await context.newPage(); - await page.goto(`${app.url}/signin?error=PRIVATE_RAW_SDK_ERROR`); - await expect(page).toHaveTitle(`Sign in — ${siteTitle}`); - await expect(page.getByRole('heading', { level: 1 })).toHaveText( - `Sign in to ${siteTitle}` - ); - await expectDecoded(page.locator('header img'), '/logo'); - await expect(page.locator('header img')).toHaveAttribute('width', '48'); - await expect(page.locator('header img')).toHaveAttribute('height', '48'); - await expect(page.locator('header img')).toHaveCSS('width', '48px'); - await expect(page.locator('header img')).toHaveCSS('height', '48px'); - await expect(page.getByRole('alert')).toHaveText('Unable to sign in. Please try again.'); - expect(await page.content()).not.toContain('PRIVATE_RAW_SDK_ERROR'); - await expect(page.getByRole('alert')).toHaveCSS('border-top-style', 'solid'); - await expect(page.getByRole('alert')).toHaveCSS( - 'background-color', - colorScheme === 'light' ? 'rgb(254, 242, 242)' : 'rgb(69, 10, 10)' - ); - const button = page.getByRole('button'); - const colors = await button.evaluate((node) => ({ - foreground: getComputedStyle(node).color, - background: getComputedStyle(node).backgroundColor - })); - expect(colors.background).toMatch(/^rgb\(\d+, \d+, \d+\)$/); - expect(contrast(colors.foreground, colors.background)).toBeGreaterThanOrEqual(7); - for (const input of await page.locator('input:not([type="hidden"])').all()) { - expect((await input.boundingBox())!.height).toBeGreaterThanOrEqual(44); - } - expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(48); - for (const frame of await page.locator('.ui-input').all()) { - expect((await frame.boundingBox())!.height).toBeGreaterThanOrEqual(48); - } - await expect(page.locator('[autofocus]')).toHaveCount(0); - await expect(page.locator('body')).toHaveCSS('font-size', '16px'); - await expect(page.locator('body')).toHaveCSS( - 'background-color', - colorScheme === 'light' ? 'rgb(245, 245, 245)' : 'rgb(16, 19, 20)' - ); - await expect(page.locator('main')).toHaveCSS('animation-name', 'none'); - if (width === 360) { - await expect(page.locator('main')).toHaveCSS('border-top-width', '0px'); - await expect(page.locator('main')).toHaveCSS('box-shadow', 'none'); - expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true); - await page.keyboard.press('Tab'); - } else { - await expect(page.locator('main')).toHaveCSS('border-top-width', '1px'); - const card = await page.locator('main').boundingBox(); - expect(card!.width).toBe(400); - expect(Math.abs(card!.x + card!.width / 2 - width / 2)).toBeLessThan(1); - expect(Math.abs(card!.y + card!.height / 2 - 450)).toBeLessThan(1); - } - if (strategy === 'credentials') { - await expect(button.locator('svg')).toHaveCount(0); - await expect(page.getByLabel('Username')).toBeFocused(); - await expect(page.getByLabel('Username')).toHaveAttribute('autocomplete', 'username'); - await expect(page.getByLabel('Password')).toHaveAttribute( - 'autocomplete', - 'current-password' - ); - await page.keyboard.press('Tab'); - await expect(page.getByLabel('Password')).toBeFocused(); - await page.keyboard.press('Tab'); - } else { - await expect( - page.getByText('Use your single sign-on account to access the directory.') - ).toBeVisible(); - await expect(button).toHaveText('Sign in with single sign-on'); - await expect(page.locator('input:not([type="hidden"])')).toHaveCount(0); - expect(await page.content()).not.toContain(issuer!.origin); - } - await expect(button).toBeFocused(); - await expect(button).toHaveCSS('outline-style', 'solid'); - await expect(button).toHaveCSS('outline-color', colors.foreground); - await expect(button).toHaveCSS('outline-offset', '-2px'); - await button.hover(); - await expect(button).toHaveCSS('box-shadow', 'none'); - await expect(button).toHaveCSS('background-color', colors.background); - await page.mouse.down(); - await expect(button).toHaveCSS( - 'box-shadow', - `${colors.foreground} 0px 0px 0px 3px inset` - ); - await page.mouse.move(0, 0); - await page.mouse.up(); - await expect(button).toHaveCSS('background-color', colors.background); - expect( - await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth) - ).toBe(true); - for (const path of ['/signin', '/signin/__data.json?x-sveltekit-invalidated=11']) { - const response = await app.fetch(path); - expect(response.status).toBe(200); - const text = await response.text(); - expectPrivate(text, app); - for (const value of ['/logo', '/favicon']) expect(text).toContain(value); - if (path === '/signin') { - expect(text).toContain(`--color-primary:light-dark(${light},${dark})`); - } else { - expect(text).not.toContain('--color-primary'); - } - } - } finally { - await context.close(); - expect(await app.stop()).toEqual({ code: 0, signal: null }); - if (issuer) await issuer.stop(); - } - }); - } - } -} - -test('a wide touch device does not autofocus, and credential errors use the same banner', async ({ +test('desktop credentials form supports password managers and keyboard navigation without leaking private data', async ({ browser }) => { + const app = await startServer({ configuration: brandedConfiguration(), publicDir }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + try { + await app.ready(); + const page = await context.newPage(); + await page.goto(`${app.url}/signin?error=PRIVATE_RAW_SDK_ERROR`); + expect(await page.title()).toContain(siteTitle); + await expect(page.getByRole('heading', { level: 1 })).toContainText(siteTitle); + await expectDecoded(page.locator('header img'), '/logo'); + await expect(page.getByRole('alert')).toBeVisible(); + expect(await page.content()).not.toContain('PRIVATE_RAW_SDK_ERROR'); + + const usernameInput = page.getByRole('textbox', { name: 'Username' }); + const passwordInput = page.getByLabel('Password'); + await expect(usernameInput).toHaveAttribute('autocomplete', 'username'); + await expect(passwordInput).toHaveAttribute('type', 'password'); + await expect(passwordInput).toHaveAttribute('autocomplete', 'current-password'); + await expect(usernameInput).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(passwordInput).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeFocused(); + + for (const path of [ + '/signin?error=PRIVATE_RAW_SDK_ERROR', + '/signin/__data.json?x-sveltekit-invalidated=11&error=PRIVATE_RAW_SDK_ERROR' + ]) { + const response = await app.fetch(path); + expect(response.status).toBe(200); + const text = await response.text(); + expectPrivate(text, app); + expect(text).not.toContain('PRIVATE_RAW_SDK_ERROR'); + for (const alias of ['/logo', '/favicon']) expect(text).toContain(alias); + } + } finally { + await context.close(); + expect(await app.stop()).toEqual({ code: 0, signal: null }); + } +}); + +test('mobile SSO form is keyboard accessible without exposing provider or private data', async ({ + browser +}) => { + const issuer = await startIssuer(); + const app = await startServer({ + configuration: brandedConfiguration(oidcAuth(issuer.origin)), + publicDir + }); + const context = await browser.newContext({ + viewport: { width: 360, height: 800 }, + hasTouch: true, + isMobile: true + }); + try { + await app.ready(); + const page = await context.newPage(); + await page.goto(`${app.url}/signin?error=PRIVATE_RAW_SDK_ERROR`); + await expect(page.getByRole('heading', { level: 1 })).toContainText(siteTitle); + await expectDecoded(page.locator('header img'), '/logo'); + await expect(page.getByRole('alert')).toBeVisible(); + const html = await page.content(); + expect(html).not.toContain('PRIVATE_RAW_SDK_ERROR'); + expect(html).not.toContain(issuer.origin); + await expect(page.locator('input:not([type="hidden"])')).toHaveCount(0); + expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Sign in with single sign-on' })).toBeFocused(); + + for (const path of ['/signin', '/signin/__data.json?x-sveltekit-invalidated=11']) { + const response = await app.fetch(path); + expect(response.status).toBe(200); + const text = await response.text(); + expectPrivate(text, app); + expect(text).not.toContain(issuer.origin); + } + } finally { + await context.close(); + expect(await app.stop()).toEqual({ code: 0, signal: null }); + await issuer.stop(); + } +}); + +test('a wide touch device leaves sign-in focus to the user', async ({ browser }) => { const app = await startServer({ configuration: brandedConfiguration(), publicDir }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, @@ -232,9 +161,7 @@ test('a wide touch device does not autofocus, and credential errors use the same const page = await context.newPage(); await page.goto(`${app.url}/signin?error=credentials`); await expectDecoded(page.locator('header img'), '/logo'); - await expect(page.getByRole('alert')).toHaveText( - 'Invalid username or password. Please try again.' - ); + await expect(page.getByRole('alert')).toBeVisible(); expect(await page.evaluate(() => matchMedia('(pointer: fine)').matches)).toBe(false); expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true); await page.keyboard.press('Tab'); @@ -264,7 +191,7 @@ test('native keyboard wrong password, callback sign-in and sign-out work without await page.keyboard.press('Tab'); await page.keyboard.type('wrong-password'); await page.keyboard.press('Enter'); - await expect(page.getByRole('alert')).toHaveText('Unable to sign in. Please try again.'); + await expect(page.getByRole('alert')).toBeVisible(); await expect(page.locator('form')).toHaveAttribute('method', 'POST'); await page.keyboard.press('Tab'); await expect(page.getByLabel('Username')).toBeFocused(); @@ -284,107 +211,6 @@ test('native keyboard wrong password, callback sign-in and sign-out work without } }); -test('only selected logo bytes are public, with independent favicon and no arbitrary selectors', async () => { - const selectedAlsoBookmark = brandedConfiguration().replace( - ' title: PRIVATE_C6_SERVICE\n', - ` logo_path: ${logoSource}\n title: PRIVATE_C6_SERVICE\n` - ); - const app = await startServer({ configuration: selectedAlsoBookmark, publicDir }); - try { - await app.ready(); - for (const method of ['GET', 'HEAD']) { - for (const path of ['/logo', '/logo?file=/public/PRIVATE_OTHER_IMAGE.svg&root=/outside']) { - const response = await app.fetch(path, { method, headers: { accept: 'text/html' } }); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe('private, no-store'); - expect(response.headers.get('x-content-type-options')).toBe('nosniff'); - expect(response.headers.get('content-type')).toBe('image/svg+xml'); - expect(response.headers.get('content-length')).toBe(String(Buffer.byteLength(logo))); - expect(await response.text()).toBe(method === 'HEAD' ? '' : logo); - } - for (const path of [logoSource, otherSource, faviconSource, '/bookmarks/household/logo']) { - const response = await app.fetch(path, { method, headers: { accept: 'text/html' } }); - expect(response.status).toBe(401); - expectPrivate(await response.text(), app); - } - } - expect( - ( - await app.fetch('/logo/PRIVATE_OTHER_IMAGE.svg', { - headers: { accept: 'application/json' } - }) - ).status - ).toBe(401); - expect(await (await app.fetch('/favicon')).text()).toBe(favicon); - await writeFile(join(publicDir, 'PRIVATE_SELECTED_LOGO.svg'), favicon); - expect(await (await app.fetch('/logo')).text()).toBe(favicon); - await writeFile(join(publicDir, 'PRIVATE_OTHER_IMAGE.svg'), logo); - await app.replace(brandedConfiguration(credentialsAuth, otherSource)); - await expect.poll(async () => (await app.fetch('/logo')).text()).toBe(logo); - await app.replace('site: [invalid'); - await expect.poll(() => app.output()).toContain('load_failed'); - expect(await (await app.fetch('/logo')).text()).toBe(logo); - await app.replace(brandedConfiguration(credentialsAuth, null)); - await expect.poll(async () => (await app.fetch('/logo')).status).toBe(404); - for (const method of ['GET', 'HEAD']) { - const response = await app.fetch('/logo', { method }); - expect(response.status).toBe(404); - expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found'); - } - expect(await (await app.fetch('/favicon')).text()).toBe(favicon); - } finally { - expect(await app.stop()).toEqual({ code: 0, signal: null }); - } -}); - -for (const { source, javaScriptEnabled } of [ - { source: null, javaScriptEnabled: false }, - { source: '/public/missing.svg', javaScriptEnabled: false }, - { source: '/public/missing.svg', javaScriptEnabled: true } -]) { - test(`${source ?? 'bundled default'} logo with JavaScript ${javaScriptEnabled}`, async ({ - browser - }) => { - const app = await startServer({ - configuration: brandedConfiguration(credentialsAuth, source), - publicDir - }); - const context = await browser.newContext({ - javaScriptEnabled, - viewport: { width: 360, height: 800 } - }); - try { - await app.ready(); - const page = await context.newPage(); - let logoRequests = 0; - page.on('request', (request) => { - if (new URL(request.url()).pathname === '/logo') logoRequests++; - }); - await page.goto(`${app.url}/signin`); - const image = page.locator('header img'); - if (source !== null && !javaScriptEnabled) { - await expect(image).toHaveAttribute('src', '/logo'); - await expect(image).toHaveJSProperty('complete', true); - await expect(image).toHaveJSProperty('naturalWidth', 0); - } else await expectDecoded(image, '/favicon.png'); - expect(logoRequests).toBe(source === null ? 0 : 1); - await expect(page.getByRole('heading')).toHaveText(`Sign in to ${siteTitle}`); - await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeEnabled(); - await expect(page.locator('head link[rel="icon"]')).toHaveJSProperty( - 'href', - `${app.url}/favicon` - ); - expect(Buffer.from(await (await app.fetch('/favicon.png')).arrayBuffer())).toEqual( - await readFile(new URL('../static/favicon.png', import.meta.url)) - ); - expect((await app.fetch('/logo')).status).toBe(404); - } finally { - await context.close(); - expect(await app.stop()).toEqual({ code: 0, signal: null }); - } - }); -} - test('sign-in catches a logo failure before hydration and stops after a failed fallback', async ({ page }) => { @@ -394,13 +220,6 @@ test('sign-in catches a logo failure before hydration and stops after a failed f }); try { await app.ready(); - await page.route('**/_app/**/*.js', (route) => route.abort()); - await page.goto(`${app.url}/signin`); - const image = page.locator('header img'); - await expect(image).toHaveAttribute('src', '/logo'); - await expect(image).toHaveJSProperty('complete', true); - await expect(image).toHaveJSProperty('naturalWidth', 0); - await page.unroute('**/_app/**/*.js'); let releaseScript!: () => void; const scripts = new Promise((resolve) => { releaseScript = resolve; @@ -409,24 +228,50 @@ test('sign-in catches a logo failure before hydration and stops after a failed f await scripts; await route.continue(); }); - const navigation = page.goto(`${app.url}/signin`, { waitUntil: 'commit' }); - await navigation; + await page.goto(`${app.url}/signin`, { waitUntil: 'commit' }); + const image = page.locator('header img'); await expect(image).toHaveAttribute('src', '/logo'); await expect(image).toHaveJSProperty('complete', true); await expect(image).toHaveJSProperty('naturalWidth', 0); releaseScript(); await expectDecoded(image, '/favicon.png'); await page.unroute('**/_app/**/*.js'); + // Install the native-error observer before hydration. Allow the error handler and + // Svelte's render microtasks to settle across browser frames before counting retries. + await page.addInitScript(() => { + document.addEventListener( + 'error', + (event) => { + if ( + event.target instanceof HTMLImageElement && + event.target.getAttribute('src') === '/favicon.png' + ) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + document.documentElement.dataset.fallbackErrorSettled = 'true'; + }); + }); + } + }, + true + ); + }); let fallbackRequests = 0; await page.route('**/favicon.png', async (route) => { fallbackRequests++; await route.abort(); }); - await page.reload(); + await Promise.all([ + page.waitForEvent( + 'requestfailed', + (request) => new URL(request.url()).pathname === '/favicon.png' + ), + page.reload() + ]); await expect(image).toHaveAttribute('src', '/favicon.png'); await expect(image).toHaveJSProperty('complete', true); await expect(image).toHaveJSProperty('naturalWidth', 0); - await page.waitForTimeout(200); + await expect(page.locator('html')).toHaveAttribute('data-fallback-error-settled', 'true'); expect(fallbackRequests).toBe(1); } finally { expect(await app.stop()).toEqual({ code: 0, signal: null }); diff --git a/apps/web/e2e/site-header-logo.e2e.ts b/apps/web/e2e/site-header-logo.e2e.ts index a65d786..6b8653f 100644 --- a/apps/web/e2e/site-header-logo.e2e.ts +++ b/apps/web/e2e/site-header-logo.e2e.ts @@ -5,7 +5,7 @@ import { expect, test, type Locator } from '@playwright/test'; import { navigateQuery } from './support/navigation.js'; import { startServer, type TestServer } from './support/server.js'; -const title = 'A household directory with a very long unbroken title HouseholdDirectoryForEveryone'; +const title = 'Household directory for everyone'; const siteDescription = 'Useful services for everyone in the household.'; const username = 'PRIVATE_HEADER_USER'; const password = 'PRIVATE_HEADER_PASSWORD'; @@ -57,15 +57,6 @@ async function expectDecoded(image: Locator, source: string) { .toBeGreaterThan(0); await image.evaluate((node) => (node as HTMLImageElement).decode()); await expect(image).toHaveAttribute('alt', ''); - await expect(image).toHaveAttribute('width', '48'); - await expect(image).toHaveAttribute('height', '48'); - await expect(image).toHaveAttribute('loading', 'eager'); - expect( - await image.evaluate((node) => [ - node.getBoundingClientRect().width, - node.getBoundingClientRect().height - ]) - ).toEqual([48, 48]); } async function expectBroken(image: Locator) { @@ -106,37 +97,18 @@ test.afterEach(async () => { } }); -for (const javaScriptEnabled of [true, false]) { - for (const source of [sourceA, undefined]) { - test(`${source ? 'configured' : 'omitted'} header logo renders at 48px with JavaScript ${javaScriptEnabled ? 'on' : 'off'}`, async ({ - browser - }) => { - const app = await boot(source); - const html = await (await app.fetch('/')).text(); - expect(html).toMatch( - new RegExp( - `]*src="${source ?? '/favicon.png'}"[^>]*alt=""[^>]*width="48"[^>]*height="48"[^>]*loading="eager"` - ) - ); - const context = await browser.newContext({ javaScriptEnabled }); - try { - const page = await context.newPage(); - await page.goto(app.url); - await expectDecoded(page.locator('header img'), source ?? '/favicon.png'); - await expect(page.locator('header h1')).toHaveText(title); - await expect(page.locator('header .description')).toHaveText(siteDescription); - await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); - await expect(page.locator('head link[rel="icon"]')).toHaveJSProperty( - 'href', - `${app.url}/favicon` - ); - expect((await app.fetch('/__data.json')).status).toBe(200); - } finally { - await context.close(); - } - }); +test('configured header logo decodes from initial HTML without JavaScript', async ({ browser }) => { + const app = await boot(sourceA); + const context = await browser.newContext({ javaScriptEnabled: false }); + try { + const page = await context.newPage(); + await page.goto(app.url); + await expectDecoded(page.locator('header img'), sourceA); + await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible(); + } finally { + await context.close(); } -} +}); test('without JavaScript a broken configured header leaves title and native navigation usable', async ({ browser @@ -207,25 +179,10 @@ test('source changes retry A to B to repaired A in place, and hydrated errors us } }); -test('logo source and credentials stay private while selected bytes brand sign-in and the narrow header', async ({ +test('sign-in keeps configuration private while selected logo bytes brand the header', async ({ browser }) => { const app = await boot(sourceA, true); - for (const method of ['GET', 'HEAD']) { - for (const cookie of ['', 'authjs.session-token=invalid-header-cookie']) { - const response = await app.fetch(sourceA, { - method, - headers: { accept: 'text/html', cookie } - }); - expect(response.status).toBe(401); - expect(response.headers.get('cache-control')).toBe('private, no-store'); - expect(response.headers.get('x-content-type-options')).toBe('nosniff'); - expect(response.headers.has('location')).toBe(false); - const text = await response.text(); - if (method === 'HEAD') expect(text).toBe(''); - expectNoPrivateData(text); - } - } for (const path of ['/signin', '/signin/__data.json?x-sveltekit-invalidated=11']) { const response = await app.fetch(path); expect(response.status).toBe(200); @@ -235,19 +192,12 @@ test('logo source and credentials stay private while selected bytes brand sign-i expectNoPrivateData(text); } expect(await (await app.fetch('/favicon')).text()).toBe(tabIcon); - const context = await browser.newContext({ - javaScriptEnabled: false, - viewport: { width: 360, height: 800 } - }); + expect(await (await app.fetch('/logo')).text()).toBe(icon); + const context = await browser.newContext({ javaScriptEnabled: false }); try { const page = await context.newPage(); await page.goto(app.url); - await expect(page.locator('header img')).toHaveAttribute('src', '/logo'); - await expect - .poll(() => - page.locator('header img').evaluate((node) => (node as HTMLImageElement).naturalWidth) - ) - .toBeGreaterThan(0); + await expectDecoded(page.locator('header img'), '/logo'); expectNoPrivateData(await page.content()); await page.getByLabel('Username').fill(username); await page.getByLabel('Password').fill(password); @@ -255,25 +205,13 @@ test('logo source and credentials stay private while selected bytes brand sign-i await expectDecoded(page.locator('header img'), sourceA); const signout = page.getByRole('button', { name: 'Sign out' }); await expect(signout).toBeVisible(); - await expect(signout).toBeInViewport(); - expect( - await page - .locator('header h1') - .evaluate( - (node) => - node.getBoundingClientRect().height > parseFloat(getComputedStyle(node).lineHeight) - ) - ).toBe(true); - expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( - 360 - ); for (const method of ['GET', 'HEAD']) { const response = await context.request.fetch(`${app.url}${sourceA}`, { method }); expect(response.status()).toBe(200); expect(await response.text()).toBe(method === 'HEAD' ? '' : icon); } await signout.click(); - await expect(page.locator('header img')).toHaveAttribute('src', '/logo'); + await expectDecoded(page.locator('header img'), '/logo'); expectNoPrivateData(await page.content()); } finally { await context.close(); diff --git a/apps/web/e2e/theme-toggle.e2e.ts b/apps/web/e2e/theme-toggle.e2e.ts index 985f959..4a3283a 100644 --- a/apps/web/e2e/theme-toggle.e2e.ts +++ b/apps/web/e2e/theme-toggle.e2e.ts @@ -1,367 +1,129 @@ import { expect, test, type Page } from '@playwright/test'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { credentialsAuth, password, username } from './support/configuration.js'; +import { navigateQuery } from './support/navigation.js'; import { startServer, type TestServer } from './support/server.js'; -const siteDescription = 'Useful household services in your preferred theme.'; const configuration = `site: title: Theme fixture - description: ${siteDescription} - logo_path: /public/private-theme-logo.svg -appearance: - primary_light_color: '#13579b' - primary_dark_color: '#e2c4a6' links: calendar: title: Household calendar url: https://calendar.example.test/ - group: Household `; -const backgrounds = { light: 'rgb(245, 245, 245)', dark: 'rgb(16, 19, 20)' }; -const accents = { light: 'rgb(19, 87, 155)', dark: 'rgb(226, 196, 166)' }; let server: TestServer; -let publicDir: string; test.beforeAll(async () => { - publicDir = await mkdtemp(join(tmpdir(), 'directory-theme-')); - await writeFile( - join(publicDir, 'private-theme-logo.svg'), - '' - ); - server = await startServer({ configuration, publicDir }); + server = await startServer({ configuration }); await server.ready(); }); test.afterAll(async () => { expect(await server.stop()).toEqual({ code: 0, signal: null }); - await rm(publicDir, { recursive: true, force: true }); }); async function expectTheme(page: Page, preference: string, applied: 'light' | 'dark') { await expect(page.locator('html')).toHaveAttribute('data-theme-preference', preference); await expect(page.locator('html')).toHaveAttribute('data-theme', applied); - await expect(page.locator('body')).toHaveCSS('background-color', backgrounds[applied]); + await expect(page.locator('html')).toHaveCSS('color-scheme', applied); } -test('keyboard cycle, next-action icons, focus, persistence and Kit navigation', async ({ +test('keyboard theme selection follows System, persists, and survives navigation', async ({ page }) => { - const errors: string[] = []; - page.on('pageerror', (error) => errors.push(error.message)); - page.on('console', (message) => { - if (message.type() === 'error' && message.text().includes('Content Security Policy')) { - errors.push(message.text()); - } - }); await page.emulateMedia({ colorScheme: 'light' }); - await page.setViewportSize({ width: 360, height: 800 }); await page.goto(server.url); - const toggle = page.locator('.header-actions button'); + const toggle = page.getByRole('button', { name: /theme/ }); await expect(toggle).toBeEnabled(); await expectTheme(page, 'system', 'light'); - await expect(toggle).toHaveAccessibleName('Switch to dark theme'); - await expect(toggle.locator('svg.icon-moon')).toBeVisible(); - await expect(toggle.locator('svg')).toHaveAttribute('aria-hidden', 'true'); - await expect(toggle.locator('svg')).toHaveAttribute('focusable', 'false'); - const box = await toggle.boundingBox(); - expect(box!.width).toBeGreaterThanOrEqual(44); - expect(box!.height).toBeGreaterThanOrEqual(44); - const headerImage = await page.locator('header img').elementHandle(); + await page.emulateMedia({ colorScheme: 'dark' }); + await expectTheme(page, 'system', 'dark'); + await page.emulateMedia({ colorScheme: 'light' }); + await expectTheme(page, 'system', 'light'); + await page.keyboard.press('Tab'); await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeFocused(); await page.keyboard.press('Tab'); await expect(toggle).toBeFocused(); - await expect(toggle).toHaveCSS('outline-style', 'solid'); - await expect(toggle).toHaveCSS('outline-color', accents.light); await page.keyboard.press('Enter'); await expectTheme(page, 'dark', 'dark'); - await expect(toggle).toHaveAccessibleName('Switch to light theme'); - await expect(toggle.locator('svg.icon-sun-dim')).toBeVisible(); - await expect(toggle).toBeFocused(); - await expect(toggle).toHaveCSS('outline-color', accents.dark); await page.keyboard.press('Space'); await expectTheme(page, 'light', 'light'); - await expect(toggle).toHaveAccessibleName('Use system theme'); - await expect(toggle.locator('svg.icon-faders')).toBeVisible(); await page.keyboard.press('Enter'); await expectTheme(page, 'system', 'light'); await toggle.click(); - expect(await page.evaluate(() => localStorage.getItem('directory.theme'))).toBe('dark'); - expect( - await headerImage!.evaluate((image) => image === document.querySelector('header img')) - ).toBe(true); await page.reload(); + await expect(toggle).toBeEnabled(); await expectTheme(page, 'dark', 'dark'); - await expect(toggle).toHaveAccessibleName('Switch to light theme'); - await page.evaluate(() => { - document.body.dataset.navigationMarker = 'same-document'; - }); - await page.getByRole('link', { name: 'Household calendar', exact: true }).click(); - await expect(page).toHaveURL(`${server.url}/?link=calendar`); + await page.emulateMedia({ colorScheme: 'dark' }); + await page.emulateMedia({ colorScheme: 'light' }); await expectTheme(page, 'dark', 'dark'); - const close = page.getByRole('button', { name: 'Close', exact: true }); - await expect(page.getByRole('dialog')).toBeVisible(); - await expect( - page.getByRole('link', { name: 'Open Household calendar (opens in a new tab)', exact: true }) - ).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(close).toBeFocused(); - await expect(close).toHaveCSS('outline-style', 'solid'); - await expect(close).toHaveCSS('outline-color', accents.dark); - await close.click(); - await expect(page).toHaveURL(`${server.url}/`); + + await navigateQuery(page, '/?q=calendar'); + await expect(page.getByRole('link', { name: 'Household calendar', exact: true })).toBeVisible(); await expectTheme(page, 'dark', 'dark'); - await expect(page.locator('body')).toHaveAttribute('data-navigation-marker', 'same-document'); - expect(errors).toEqual([]); }); -test('System alone follows OS changes; manual palettes and configured accents override the OS', async ({ +test('saved theme applies before hydration and CSP blocks untrusted inline scripts', async ({ page }) => { - await page.emulateMedia({ colorScheme: 'light' }); - await page.goto(server.url); - const toggle = page.locator('.header-actions button'); - await expect(toggle).toBeEnabled(); - await page.emulateMedia({ colorScheme: 'dark' }); - await expectTheme(page, 'system', 'dark'); - await page.emulateMedia({ colorScheme: 'light' }); - await expectTheme(page, 'system', 'light'); - await toggle.click(); - await expectTheme(page, 'dark', 'dark'); - const tile = page.locator('a[href="/?link=calendar"]'); - const close = page.getByRole('button', { name: 'Close', exact: true }); - await tile.click(); - await expect( - page.getByRole('link', { name: 'Open Household calendar (opens in a new tab)', exact: true }) - ).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(close).toBeFocused(); - await expect(close).toHaveCSS('outline-style', 'solid'); - await expect(close).toHaveCSS('outline-color', accents.dark); - await close.click(); - await page.emulateMedia({ colorScheme: 'dark' }); - await page.emulateMedia({ colorScheme: 'light' }); - await expectTheme(page, 'dark', 'dark'); - await toggle.click(); await page.emulateMedia({ colorScheme: 'dark' }); + await page.addInitScript(() => localStorage.setItem('directory.theme', 'light')); + await page.route('**/_app/**/*.js', (route) => route.abort()); + const response = await page.goto(server.url); + const policy = response!.headers()['content-security-policy']; + expect(policy).toContain("object-src 'none'"); + expect(policy).toContain("frame-ancestors 'none'"); await expectTheme(page, 'light', 'light'); - await tile.click(); - await expect( - page.getByRole('link', { name: 'Open Household calendar (opens in a new tab)', exact: true }) - ).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(close).toBeFocused(); - await expect(close).toHaveCSS('outline-style', 'solid'); - await expect(close).toHaveCSS('outline-color', accents.light); - await close.click(); - await toggle.focus(); - await page.keyboard.press('Tab'); - await page.keyboard.press('Shift+Tab'); - await expect(toggle).toBeFocused(); - await expect(toggle).toHaveCSS('outline-color', accents.light); - await toggle.click(); - await expectTheme(page, 'system', 'dark'); - await page.emulateMedia({ colorScheme: 'light' }); - await expectTheme(page, 'system', 'light'); -}); - -for (const saved of ['dark', 'light', 'system']) { - test(`prepaint bootstrap restores ${saved} with hydration withheld under native CSP`, async ({ - page - }) => { - await page.emulateMedia({ colorScheme: 'dark' }); - await page.addInitScript( - (preference) => localStorage.setItem('directory.theme', preference), - saved - ); - await page.route('**/_app/**/*.js', (route) => route.abort()); - const response = await page.goto(server.url); - const applied = saved === 'light' ? 'light' : 'dark'; - await expectTheme(page, saved, applied); - await expect(page.locator('.header-actions button')).toBeDisabled(); - const html = await response!.text(); - expect(html.indexOf('directory.theme')).toBeLessThan(html.indexOf('rel="stylesheet"')); - const nonce = /