Simplify E2E coverage #65

Merged
eleith merged 1 commits from simplify-e2e-coverage into main 2026-10-28 16:54:17 +00:00
7 changed files with 285 additions and 1018 deletions

No files matched your search

+7
View File
@@ -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');
-6
View File
@@ -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(() => {
+46 -293
View File
@@ -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(/<noscript\b[^>]*>[\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(/<noscript\b/);
await delayed.requested;
await finishRendering(page);
await expect(section).toHaveCount(0);
await expect(page.getByRole('region', { name: 'Alpha service', exact: true })).toHaveCount(0);
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByRole('searchbox', { name: 'Search', exact: true })).toBeDisabled();
delayed.release();
await hydrated(page);
await expect(page.getByRole('dialog', { name: 'Alpha service', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Alpha service', exact: true })).toHaveCount(1);
await expect(page.getByRole('link', { name: 'Back to directory' })).toHaveCount(0);
const tags = page.getByRole('list', { name: 'Tags' });
await expect(tags).toHaveCount(1);
await expect(tags.getByRole('listitem')).toHaveText([
'login',
'internal',
'admin',
'photos',
'backup'
]);
await expect(tags.getByRole('listitem').nth(2)).toHaveAccessibleName('admin only');
await expect(page.getByRole('list', { name: 'Service information' })).toHaveCount(0);
const external = page.getByRole('link', {
name: 'Open Alpha service (opens in a new tab)',
exact: true
});
await expect(external).toHaveAttribute(
'href',
'https://alpha.example.test/path?from=directory'
);
await expect(external).toHaveAttribute('target', '_blank');
await expect(external).toHaveAttribute('rel', 'noopener noreferrer');
expect(errors).toEqual([]);
} finally {
delayed.release();
@@ -117,17 +75,12 @@ test('direct SSR keeps plain details in noscript without a visible card before h
}
});
test('controlled focus trap, Escape, outside dismissal, close, history and return focus', async ({
page
}) => {
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<void>((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(/<noscript\b[^>]*>[\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<HTMLAnchorElement>('a[href="/?link=alpha"]')!.click();
const first = location.search;
document.querySelector<HTMLAnchorElement>('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);
}
});
}
+38 -70
View File
@@ -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();
}
}
});
+122 -277
View File
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect width="48" height="48" fill="#abcdef"/></svg>';
const favicon =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="7"/></svg>';
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<void>((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 });
+17 -79
View File
@@ -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(
`<img[^>]*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();
+55 -293
View File
@@ -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'),
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#369" d="M0 0h48v48H0z"/></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 = /<script nonce="([^"]+)"/.exec(html)?.[1];
expect(nonce).toBeTruthy();
const policy = response!.headers()['content-security-policy'];
if (!policy) throw new Error('Expected a Content-Security-Policy response header');
const scripts = policy
.split(';')
.find((directive) => directive.trim().startsWith('script-src'))!;
expect(scripts).toContain(`'nonce-${nonce}'`);
expect(scripts).not.toContain("'unsafe-inline'");
expect(policy).toContain("object-src 'none'");
expect(policy).toContain("frame-ancestors 'none'");
await page.evaluate(() => {
const script = document.createElement('script');
script.textContent = 'document.documentElement.dataset.untrustedScript = "ran"';
document.head.append(script);
});
await expect(page.locator('html')).not.toHaveAttribute('data-untrusted-script');
await expect(page.getByRole('button', { name: /theme/ })).toBeDisabled();
await page.evaluate(() => {
const script = document.createElement('script');
script.textContent = 'document.documentElement.dataset.untrustedScript = "ran"';
document.head.append(script);
});
}
test('invalid raw preferences fall back to System', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto(server.url);
for (const raw of ['DARK', '"light"', '', 'invalid']) {
await page.evaluate((value) => localStorage.setItem('directory.theme', value), raw);
await page.reload();
await expectTheme(page, 'system', 'dark');
await expect(
page.getByRole('button', { name: 'Switch to dark theme', exact: true })
).toBeEnabled();
}
await expect(page.locator('html')).not.toHaveAttribute('data-untrusted-script');
});
for (const blocked of ['getItem', 'setItem'] as const) {
test(`blocked storage ${blocked} leaves the in-memory cycle and System listener usable`, async ({
page
}) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.emulateMedia({ colorScheme: 'light' });
await page.addInitScript((method) => {
test('unavailable storage leaves theme selection usable', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.addInitScript(() => {
for (const method of ['getItem', 'setItem'] as const) {
Storage.prototype[method] = () => {
throw new DOMException('Blocked', 'SecurityError');
};
}, blocked);
await page.goto(server.url);
const toggle = page.locator('.header-actions button');
await expect(toggle).toBeEnabled();
await expectTheme(page, 'system', 'light');
await toggle.click();
await expectTheme(page, 'dark', 'dark');
await page.emulateMedia({ colorScheme: 'dark' });
await page.emulateMedia({ colorScheme: 'light' });
await expectTheme(page, 'dark', 'dark');
await toggle.click();
await expectTheme(page, 'light', 'light');
await toggle.click();
await expectTheme(page, 'system', 'light');
await page.emulateMedia({ colorScheme: 'dark' });
await expectTheme(page, 'system', 'dark');
expect(errors).toEqual([]);
});
}
for (const colorScheme of ['light', 'dark'] as const) {
test(`no JavaScript keeps OS ${colorScheme}, disabled toggle and native navigation`, async ({
browser
}) => {
const context = await browser.newContext({ javaScriptEnabled: false, colorScheme });
try {
const page = await context.newPage();
await page.goto(server.url);
await expect(page.locator('html')).not.toHaveAttribute('data-theme');
await expect(page.locator('body')).toHaveCSS('background-color', backgrounds[colorScheme]);
await expect(page.locator('.header-actions button')).toBeDisabled();
await page.keyboard.press('Tab');
const tile = page.getByRole('link', { name: 'Household calendar', exact: true });
await expect(tile).toBeFocused();
await tile.click();
await expect(page).toHaveURL(`${server.url}/?link=calendar`);
const back = page.getByRole('link', { name: 'Back to directory', exact: true });
await expect(back).toHaveCSS('text-decoration-color', accents[colorScheme]);
await back.click();
await expect(page).toHaveURL(`${server.url}/`);
} finally {
await context.close();
}
});
}
await page.goto(server.url);
const toggle = page.getByRole('button', { name: /theme/ });
await expect(toggle).toBeEnabled();
await expectTheme(page, 'system', 'light');
await toggle.click();
await expectTheme(page, 'dark', 'dark');
await toggle.click();
await expectTheme(page, 'light', 'light');
await toggle.click();
await page.emulateMedia({ colorScheme: 'dark' });
await expectTheme(page, 'system', 'dark');
});
test('saved theme on public sign-in and sign-out retains the public configured accents', async ({
page
}) => {
const protectedServer = await startServer({
configuration: configuration + credentialsAuth,
publicDir
});
const errors: string[] = [];
test('saved theme survives sign-in and sign-out', async ({ page }) => {
const app = await startServer({ configuration: configuration + credentialsAuth });
try {
await protectedServer.ready();
await app.ready();
await page.emulateMedia({ colorScheme: 'light' });
await page.addInitScript(() => {
if (localStorage.getItem('directory.theme') === null)
localStorage.setItem('directory.theme', 'dark');
});
for (const path of ['/signin', '/signin/__data.json', '/__data.json']) {
const response = await protectedServer.fetch(path);
expect(response.status).toBe(path === '/__data.json' ? 401 : 200);
const text = await response.text();
if (path === '/signin') {
expect(text).toContain('--color-primary:light-dark(#13579b,#e2c4a6)');
} else {
expect(text).not.toContain('--color-primary');
}
if (path !== '/__data.json') expect(text).toContain(siteDescription);
for (const value of ['/public/private-theme-logo.svg', username, password]) {
expect(text).not.toContain(value);
}
}
await page.route('**/_app/**/*.js', (route) => route.abort());
await page.goto(protectedServer.url);
await expect(page).toHaveURL(`${protectedServer.url}/signin?callbackUrl=%2F`);
await expectTheme(page, 'dark', 'dark');
await expect(page.getByRole('button', { name: 'Switch to light theme' })).toHaveCount(0);
await expect(page.locator('header img')).toHaveAttribute('src', '/logo');
await page.unroute('**/_app/**/*.js');
await page.goto(app.url);
await expect(page).toHaveURL((url) => url.pathname === '/signin');
await page.evaluate(() => localStorage.setItem('directory.theme', 'dark'));
await page.reload();
page.on('pageerror', (error) => errors.push(error.message));
await expectTheme(page, 'dark', 'dark');
await expect(page.getByLabel('Username')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Password')).toBeFocused();
await expect(page.getByLabel('Password').locator('..')).toHaveCSS(
'outline-color',
accents.dark
);
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
const toggle = page.getByRole('button', { name: 'Switch to light theme', exact: true });
await expect(toggle).toBeEnabled();
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
await expectTheme(page, 'dark', 'dark');
await page.setViewportSize({ width: 360, height: 800 });
await toggle.focus();
await expect(toggle).toHaveCSS('outline-color', accents.dark);
await page.keyboard.press('Tab');
const signout = page.getByRole('button', { name: 'Sign out', exact: true });
await expect(signout).toBeFocused();
await expect(signout).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true
);
await signout.click();
await expect(page).toHaveURL(`${protectedServer.url}/signin?callbackUrl=%2F`);
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL((url) => url.pathname === '/signin');
await expectTheme(page, 'dark', 'dark');
await page.getByLabel('Username').focus();
await expect(page.getByLabel('Username').locator('..')).toHaveCSS(
'outline-color',
accents.dark
);
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await toggle.click();
await page.getByRole('button', { name: 'Use system theme', exact: true }).click();
await expectTheme(page, 'system', 'light');
await signout.click();
await expect(page).toHaveURL(`${protectedServer.url}/signin?callbackUrl=%2F`);
await expectTheme(page, 'system', 'light');
await page.evaluate(() => {
document.body.dataset.navigationMarker = 'signed-out';
});
await page.emulateMedia({ colorScheme: 'dark' });
await expectTheme(page, 'system', 'dark');
await page.emulateMedia({ colorScheme: 'light' });
await expectTheme(page, 'system', 'light');
await expect(page.locator('body')).toHaveAttribute('data-navigation-marker', 'signed-out');
expect(errors).toEqual([]);
} finally {
expect(await protectedServer.stop()).toEqual({ code: 0, signal: null });
expect(await app.stop()).toEqual({ code: 0, signal: null });
}
});