Simplify E2E coverage #65
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user