Add reproducible browser and accessibility gates
This commit is contained in:
parent
4d31c441d0
commit
9ad8d27adf
3 changed files with 1887 additions and 103 deletions
218
web/test/browser/product-journey.spec.ts
Normal file
218
web/test/browser/product-journey.spec.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
type BrowserAuth = {
|
||||
session: string;
|
||||
csrf: string;
|
||||
};
|
||||
|
||||
const authPath = process.env.NUTHATCH_BROWSER_AUTH_FILE;
|
||||
if (authPath === undefined) {
|
||||
throw new Error('NUTHATCH_BROWSER_AUTH_FILE is required');
|
||||
}
|
||||
const auth = JSON.parse(await readFile(authPath, 'utf8')) as BrowserAuth;
|
||||
|
||||
test.beforeEach(async ({ context, baseURL }) => {
|
||||
if (baseURL === undefined) throw new Error('NUTHATCH_BROWSER_BASE_URL is required');
|
||||
const hostname = new URL(baseURL).hostname;
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'nuthatch_session',
|
||||
value: auth.session,
|
||||
domain: hostname,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax'
|
||||
},
|
||||
{
|
||||
name: 'nuthatch_csrf',
|
||||
value: auth.csrf,
|
||||
domain: hostname,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: 'Lax'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('compiled application completes the critical browser journey', async ({
|
||||
page,
|
||||
context
|
||||
}, testInfo) => {
|
||||
const browserErrors: string[] = [];
|
||||
const assets = new Set<string>();
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') browserErrors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||||
page.on('response', (response) => {
|
||||
if (new URL(response.url()).pathname.startsWith('/assets/')) {
|
||||
assets.add(response.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/app');
|
||||
await expect(page.getByRole('heading', { name: 'Your files' })).toBeVisible();
|
||||
await expect(page.getByText('Browser Fixture')).toBeVisible();
|
||||
expect(assets.size).toBeGreaterThan(0);
|
||||
await expectAccessible(page);
|
||||
|
||||
const shared = page.getByRole('button', { name: 'Shared', exact: true });
|
||||
await shared.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(shared).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByText('Shared root', { exact: true })).toBeVisible();
|
||||
await expectAccessible(page);
|
||||
await page.getByRole('button', { name: 'Personal', exact: true }).click();
|
||||
const fileInput = page.getByLabel('Choose file');
|
||||
await expect(fileInput).toBeEnabled();
|
||||
|
||||
let failUpload = true;
|
||||
await page.route('**/api/upload?**', async (route) => {
|
||||
if (failUpload) {
|
||||
failUpload = false;
|
||||
await route.abort('failed');
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
await fileInput.setInputFiles({
|
||||
name: 'upload-retry.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('browser upload')
|
||||
});
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Retry same upload' })).toBeVisible();
|
||||
await expectAccessible(page);
|
||||
expect(browserErrors).toEqual(['Failed to load resource: net::ERR_FAILED']);
|
||||
browserErrors.length = 0;
|
||||
await page.unroute('**/api/upload?**');
|
||||
await page.getByRole('button', { name: 'Retry same upload' }).click();
|
||||
await expect(page.getByText('Uploaded upload-retry.txt', { exact: true })).toBeVisible();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.getByRole('button', { name: /^↓ upload-retry\.txt/ }).click()
|
||||
]);
|
||||
expect(download.suggestedFilename()).toBe('upload-retry.txt');
|
||||
|
||||
for (const path of [
|
||||
'search/a.txt',
|
||||
'search/b.txt',
|
||||
'search/c.txt',
|
||||
'search/d.txt',
|
||||
'00-journey.txt'
|
||||
]) {
|
||||
await uploadFixture(page, path, path);
|
||||
}
|
||||
await page.getByRole('button', { name: 'Refresh' }).click();
|
||||
const search = page.getByRole('textbox', { name: 'Search this root' });
|
||||
await search.fill('search');
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await expect(page.getByText(/Partial search results/)).toBeVisible();
|
||||
await expectAccessible(page);
|
||||
|
||||
await search.fill('00-journey');
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Delete 00-journey.txt' }).click();
|
||||
await expect(page.getByText('Deleted 00-journey.txt', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Refresh' })).toBeFocused();
|
||||
|
||||
await uploadFixture(page, '00-tree/a.txt', 'a');
|
||||
await uploadFixture(page, '00-tree/b.txt', 'b');
|
||||
await search.fill('');
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: /00-tree\// })).toBeVisible();
|
||||
|
||||
let failBatch = true;
|
||||
await page.route('**/api/delete-batch', async (route) => {
|
||||
if (failBatch) {
|
||||
failBatch = false;
|
||||
await route.abort('failed');
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Delete 00-tree' }).click();
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Resume deletion' })).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => localStorage.getItem('nuthatch-recursive-delete'))
|
||||
).not.toBeNull();
|
||||
await expectAccessible(page);
|
||||
expect(browserErrors).toEqual(['Failed to load resource: net::ERR_FAILED']);
|
||||
browserErrors.length = 0;
|
||||
|
||||
await page.unroute('**/api/delete-batch');
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: 'Resume deletion' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Resume deletion' }).click();
|
||||
await expect(page.getByText(/Deleted 2 objects from 00-tree/)).toBeVisible();
|
||||
await expectAccessible(page);
|
||||
|
||||
const logout = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === '/logout' &&
|
||||
response.request().method() === 'POST'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Log out' }).click();
|
||||
expect((await logout).status()).toBe(200);
|
||||
const cookies = await context.cookies();
|
||||
expect(cookies.some(({ name }) => name === 'nuthatch_session')).toBe(false);
|
||||
|
||||
await testInfo.attach('browser-errors', {
|
||||
body: browserErrors.join('\n'),
|
||||
contentType: 'text/plain'
|
||||
});
|
||||
expect(browserErrors).toEqual([]);
|
||||
});
|
||||
|
||||
async function uploadFixture(
|
||||
page: import('@playwright/test').Page,
|
||||
path: string,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
const response = await page.evaluate(
|
||||
async ({ path, body, csrf }) => {
|
||||
const bytes = new TextEncoder().encode(body);
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const contentSha256 = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join('');
|
||||
const query = new URLSearchParams({
|
||||
root: 'personal',
|
||||
path,
|
||||
operation_id: crypto.randomUUID()
|
||||
});
|
||||
const result = await fetch(`/api/upload?${query}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Declared-Size': String(bytes.byteLength),
|
||||
'X-Content-SHA256': contentSha256,
|
||||
'X-CSRF-Token': csrf
|
||||
},
|
||||
body: bytes
|
||||
});
|
||||
return { status: result.status, body: await result.text() };
|
||||
},
|
||||
{ path, body, csrf: auth.csrf }
|
||||
);
|
||||
expect(response.status, response.body).toBe(200);
|
||||
}
|
||||
|
||||
async function expectAccessible(
|
||||
page: import('@playwright/test').Page
|
||||
): Promise<void> {
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
expect(
|
||||
results.violations.filter(({ impact }) =>
|
||||
impact === 'serious' || impact === 'critical'
|
||||
)
|
||||
).toEqual([]);
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import test from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
test('components render root, browse, upload, delete, resume, notice, and error states', async () => {
|
||||
const server = await createServer({
|
||||
appType: 'custom',
|
||||
configFile: false,
|
||||
plugins: [svelte()],
|
||||
server: { hmr: false, middlewareMode: true }
|
||||
});
|
||||
try {
|
||||
const { render } = await server.ssrLoadModule('svelte/server');
|
||||
const WorkspaceHeader = await component(server, 'WorkspaceHeader');
|
||||
const workspace = render(WorkspaceHeader, {
|
||||
props: { root: 'shared', busy: false, onselect: async () => {} }
|
||||
}).body;
|
||||
assert.match(workspace, /Shared files/);
|
||||
assert.match(workspace, /Shared root/);
|
||||
assert.match(workspace, /Personal/);
|
||||
|
||||
const UploadPanel = await component(server, 'UploadPanel');
|
||||
assert.match(
|
||||
render(UploadPanel, {
|
||||
props: { busy: false, onfile: async () => {} }
|
||||
}).body,
|
||||
/Drop a file here/
|
||||
);
|
||||
|
||||
const ObjectBrowser = await component(server, 'ObjectBrowser');
|
||||
const browser = render(ObjectBrowser, {
|
||||
props: {
|
||||
root: 'shared',
|
||||
entries: [
|
||||
{ name: 'folder', is_prefix: true, size: null, path: 'folder' }
|
||||
],
|
||||
continuation: 'next',
|
||||
partial: true,
|
||||
busy: false,
|
||||
query: 'fold',
|
||||
onquery: () => {},
|
||||
onbrowse: async () => {},
|
||||
onopen: async () => {},
|
||||
ondelete: async () => {}
|
||||
}
|
||||
}).body;
|
||||
assert.match(browser, /Search this root/);
|
||||
assert.match(browser, /Partial search results/);
|
||||
assert.match(browser, /folder\//);
|
||||
assert.match(browser, /Delete/);
|
||||
assert.match(browser, /Clear/);
|
||||
|
||||
const ResumeDelete = await component(server, 'ResumeDelete');
|
||||
assert.match(
|
||||
render(ResumeDelete, {
|
||||
props: {
|
||||
pending: { root: 'personal', prefix: 'tree', items: [] },
|
||||
busy: false,
|
||||
onresume: async () => {}
|
||||
}
|
||||
}).body,
|
||||
/Resume deletion/
|
||||
);
|
||||
|
||||
const Feedback = await component(server, 'Feedback');
|
||||
const feedback = render(Feedback, {
|
||||
props: {
|
||||
notice: 'Uploaded file.txt',
|
||||
error: 'Delete is pending',
|
||||
uploadPending: true,
|
||||
uploadFollowup: 'replay',
|
||||
deletePending: true,
|
||||
deleteFollowup: 'restart',
|
||||
onuploadretry: async () => {},
|
||||
ondeleteretry: async () => {}
|
||||
}
|
||||
}).body;
|
||||
assert.match(feedback, /Uploaded file\.txt/);
|
||||
assert.match(feedback, /Retry same upload/);
|
||||
assert.match(feedback, /Start new delete operation/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function component(server, name) {
|
||||
const loaded = await server.ssrLoadModule(`/src/components/${name}.svelte`);
|
||||
return loaded.default;
|
||||
}
|
||||
152
web/test/components/accessibility.test.ts
Normal file
152
web/test/components/accessibility.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { cleanup, render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import axe from 'axe-core';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import Feedback from '../../src/components/Feedback.svelte';
|
||||
import ObjectBrowser from '../../src/components/ObjectBrowser.svelte';
|
||||
import UploadPanel from '../../src/components/UploadPanel.svelte';
|
||||
import WorkspaceHeader from '../../src/components/WorkspaceHeader.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('accessible component behavior', () => {
|
||||
test('root state has names, pressed state, and keyboard activation', async () => {
|
||||
const onselect = vi.fn(async () => {});
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(WorkspaceHeader, {
|
||||
root: 'personal',
|
||||
busy: false,
|
||||
onselect
|
||||
});
|
||||
|
||||
const personal = screen.getByRole('button', { name: 'Personal' });
|
||||
const shared = screen.getByRole('button', { name: 'Shared' });
|
||||
expect(personal.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(shared.getAttribute('aria-pressed')).toBe('false');
|
||||
shared.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onselect).toHaveBeenCalledWith('shared');
|
||||
await expectNoSeriousViolations(container);
|
||||
});
|
||||
|
||||
test('feedback exposes live notices, alerts, and retry names', async () => {
|
||||
const { container } = render(Feedback, {
|
||||
notice: 'Uploaded file.txt',
|
||||
error: 'Delete is pending',
|
||||
uploadPending: true,
|
||||
uploadFollowup: 'replay',
|
||||
deletePending: true,
|
||||
deleteFollowup: 'restart',
|
||||
onuploadretry: async () => {},
|
||||
ondeleteretry: async () => {}
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('Uploaded file.txt');
|
||||
expect(screen.getByRole('alert').textContent).toContain('Delete is pending');
|
||||
screen.getByRole('button', { name: 'Retry same upload' });
|
||||
screen.getByRole('button', { name: 'Start new delete operation' });
|
||||
await expectNoSeriousViolations(container);
|
||||
});
|
||||
|
||||
test('browser state names deletion controls and reports busy state', async () => {
|
||||
const rendered = render(ObjectBrowser, browserProps());
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Search this root' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Delete alpha.txt' })).toBeTruthy();
|
||||
expect(rendered.container.querySelector('.list')?.getAttribute('aria-busy')).toBe(
|
||||
'false'
|
||||
);
|
||||
await rendered.rerender({ ...browserProps(), busy: true });
|
||||
expect(rendered.container.querySelector('.list')?.getAttribute('aria-busy')).toBe(
|
||||
'true'
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole('textbox', {
|
||||
name: 'Search this root'
|
||||
}) as HTMLInputElement).disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('button', {
|
||||
name: 'Delete alpha.txt'
|
||||
}) as HTMLButtonElement).disabled
|
||||
).toBe(true);
|
||||
await expectNoSeriousViolations(rendered.container);
|
||||
});
|
||||
|
||||
test('post-delete focus moves to the logical surviving entry', async () => {
|
||||
const onfocused = vi.fn();
|
||||
const rendered = render(ObjectBrowser, {
|
||||
...browserProps(),
|
||||
onfocused
|
||||
});
|
||||
|
||||
await rendered.rerender({
|
||||
...browserProps(),
|
||||
entries: [
|
||||
{
|
||||
name: 'beta.txt',
|
||||
path: 'beta.txt',
|
||||
is_prefix: false,
|
||||
size: 4
|
||||
}
|
||||
],
|
||||
focusPath: 'beta.txt',
|
||||
onfocused
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement?.textContent).toContain('beta.txt');
|
||||
});
|
||||
expect(onfocused).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('upload input retains a native named keyboard alternative', async () => {
|
||||
const { container } = render(UploadPanel, {
|
||||
busy: false,
|
||||
onfile: async () => {}
|
||||
});
|
||||
expect(screen.getByLabelText('Choose file').getAttribute('type')).toBe('file');
|
||||
await expectNoSeriousViolations(container);
|
||||
});
|
||||
});
|
||||
|
||||
function browserProps() {
|
||||
return {
|
||||
root: 'personal' as const,
|
||||
entries: [
|
||||
{
|
||||
name: 'alpha.txt',
|
||||
path: 'alpha.txt',
|
||||
is_prefix: false,
|
||||
size: 5
|
||||
},
|
||||
{
|
||||
name: 'beta.txt',
|
||||
path: 'beta.txt',
|
||||
is_prefix: false,
|
||||
size: 4
|
||||
}
|
||||
],
|
||||
continuation: null,
|
||||
partial: false,
|
||||
busy: false,
|
||||
query: '',
|
||||
focusPath: null,
|
||||
onquery: () => {},
|
||||
onbrowse: async () => {},
|
||||
onopen: async () => {},
|
||||
ondelete: async () => {},
|
||||
onfocused: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
async function expectNoSeriousViolations(container: HTMLElement) {
|
||||
const results = await axe.run(container, {
|
||||
rules: { 'color-contrast': { enabled: false } }
|
||||
});
|
||||
expect(
|
||||
results.violations.filter(({ impact }) =>
|
||||
impact === 'serious' || impact === 'critical'
|
||||
)
|
||||
).toEqual([]);
|
||||
}
|
||||
Loading…
Reference in a new issue