Add reproducible browser and accessibility gates

This commit is contained in:
ms2k 2026-07-20 05:02:08 +09:00
commit 9ad8d27adf
13 changed files with 1887 additions and 103 deletions

2
web/.gitignore vendored
View file

@ -2,3 +2,5 @@ dist/
node_modules/
.svelte-kit/
*.log
playwright-report/
test-results/

1204
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,16 +12,25 @@
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json",
"dev": "vite",
"test": "node --test test/*.test.mjs"
"test": "node --test test/*.test.mjs && vitest run",
"test:browser": "playwright test"
},
"dependencies": {
"@noble/hashes": "2.2.0",
"svelte": "5.56.6"
},
"devDependencies": {
"@axe-core/playwright": "4.12.1",
"@playwright/test": "1.61.1",
"@sveltejs/vite-plugin-svelte": "7.2.0",
"@testing-library/svelte": "5.4.2",
"@testing-library/user-event": "14.6.1",
"@types/node": "24.13.3",
"axe-core": "4.12.1",
"jsdom": "29.1.1",
"svelte-check": "4.7.3",
"typescript": "6.0.3",
"vite": "8.1.5"
"vite": "8.1.5",
"vitest": "4.1.10"
}
}

21
web/playwright.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'test/browser',
outputDir: 'test-results',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }]
],
use: {
baseURL: process.env.NUTHATCH_BROWSER_BASE_URL,
ignoreHTTPSErrors: true,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
});

View file

@ -61,6 +61,8 @@
let busy = $state(false);
let notice = $state('');
let error = $state('');
let focusPath = $state<string | null>(null);
let deletionIndex: number | null = null;
let deletePersistence: RecursiveDeletePersistence | null = null;
let breadcrumbItems = $derived(breadcrumbs(browseState.prefix));
@ -92,11 +94,13 @@
}
async function selectRoot(root: Root): Promise<void> {
focusPath = null;
setRoot(browseState, root);
await browse(false);
}
async function openPrefix(path: string): Promise<void> {
focusPath = null;
setPrefix(browseState, path);
await browse(false);
}
@ -146,6 +150,9 @@
entry.is_prefix
);
if (!window.confirm(warning)) return;
deletionIndex = browseState.entries.findIndex(
({ path }) => path === entry.path
);
if (entry.is_prefix) {
await recursiveDelete(browseState.root, entry.path);
return;
@ -172,6 +179,7 @@
);
notice = `Deleted ${path}`;
await browse(false);
focusAfterDeletion();
} catch (caught) {
error = errorMessage(caught);
} finally {
@ -199,6 +207,7 @@
resumableDelete = null;
notice = `Deleted ${result.deleted} objects from ${prefix}`;
await browse(false);
focusAfterDeletion();
} catch (caught) {
resumableDelete = savedRecursiveDelete(deletePersistence);
error = errorMessage(caught);
@ -233,6 +242,13 @@
);
}
function focusAfterDeletion(): void {
if (deletionIndex === null) return;
const index = Math.min(deletionIndex, browseState.entries.length - 1);
focusPath = index < 0 ? '' : browseState.entries[index].path;
deletionIndex = null;
}
async function logout(): Promise<void> {
if (session === null) return;
await browserProductClient.logout(session.csrf);
@ -277,6 +293,7 @@
partial={browseState.partial}
{busy}
query={browseState.query}
{focusPath}
onquery={(query) => {
browseState.query = query;
browseState.continuation = null;
@ -284,5 +301,8 @@
onbrowse={browse}
onopen={openEntry}
ondelete={deleteEntry}
onfocused={() => {
focusPath = null;
}}
/>
</main>

View file

@ -57,7 +57,7 @@ header {
height: 2rem;
place-items: center;
border-radius: 0.55rem 0.55rem 0.55rem 0.1rem;
background: #bd4b30;
background: #b8462d;
color: white;
font-family: Georgia, serif;
}
@ -86,7 +86,7 @@ main {
.eyebrow {
margin: 0 0 0.35rem;
color: #bd4b30;
color: #b8462d;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.13em;
@ -104,7 +104,7 @@ h1 {
.hero p:last-child {
max-width: 38rem;
color: #68716c;
color: #626b66;
}
.roots {
@ -193,7 +193,7 @@ h1 {
}
.dropzone.dragging {
border-color: #bd4b30;
border-color: #b8462d;
background: #fff0e7;
}
@ -206,7 +206,7 @@ h1 {
.dropzone span,
.list small,
.empty span {
color: #68716c;
color: #626b66;
}
.dropzone label {

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import type { EntryResponse, Root } from '../generated/contract.ts';
import { formatSize } from '../presentation.ts';
@ -9,10 +10,12 @@
partial: boolean;
busy: boolean;
query: string;
focusPath: string | null;
onquery: (query: string) => void;
onbrowse: (append: boolean) => Promise<void>;
onopen: (entry: EntryResponse) => Promise<void>;
ondelete: (entry: EntryResponse) => Promise<void>;
onfocused: () => void;
};
let {
@ -22,11 +25,33 @@
partial,
busy,
query,
focusPath,
onquery,
onbrowse,
onopen,
ondelete
ondelete,
onfocused
}: Props = $props();
let browserElement: HTMLElement;
let refreshButton: HTMLButtonElement;
$effect(() => {
const path = focusPath;
entries;
if (path === null || busy) return;
void tick().then(() => {
const target =
path === ''
? refreshButton
: Array.from(
browserElement.querySelectorAll<HTMLButtonElement>(
'[data-entry-path]'
)
).find((button) => button.dataset.entryPath === path);
target?.focus();
if (target !== undefined) onfocused();
});
});
function submitSearch(event: SubmitEvent): void {
event.preventDefault();
@ -43,7 +68,7 @@
}
</script>
<section class="browser">
<section class="browser" bind:this={browserElement}>
<div class="toolbar">
<form onsubmit={submitSearch}>
<label for="search">Search this root</label>
@ -64,6 +89,7 @@
</div>
</form>
<button
bind:this={refreshButton}
class="quiet"
type="button"
onclick={() => void onbrowse(false)}
@ -81,6 +107,7 @@
{#each entries as entry}
<article>
<button
data-entry-path={entry.path}
class="entry"
type="button"
disabled={busy}
@ -98,6 +125,7 @@
class:danger={root === 'shared'}
class="delete"
type="button"
aria-label={`Delete ${entry.name}`}
onclick={() => void ondelete(entry)}
disabled={busy}>Delete</button
>

View file

@ -25,6 +25,7 @@
<button
class:active={root === 'personal'}
type="button"
aria-pressed={root === 'personal'}
disabled={busy}
onclick={() => void onselect('personal')}>Personal</button
>
@ -32,6 +33,7 @@
class:active={root === 'shared'}
class:shared={root === 'shared'}
type="button"
aria-pressed={root === 'shared'}
disabled={busy}
onclick={() => void onselect('shared')}>Shared</button
>

View 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([]);
}

View file

@ -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;
}

View 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([]);
}

View file

@ -12,5 +12,11 @@
"noEmit": true,
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
"include": [
"src/**/*.ts",
"src/**/*.svelte",
"test/**/*.ts",
"vite.config.ts",
"playwright.config.ts"
]
}

View file

@ -1,15 +1,21 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig, loadEnv } from 'vite';
import { loadEnv } from 'vite';
import { defineConfig } from 'vitest/config';
export default defineConfig(({ mode }) => {
const backend =
loadEnv(mode, '.', '').NUTHATCH_DEV_BACKEND ?? 'http://127.0.0.1:8080';
return {
plugins: [svelte()],
resolve: mode === 'test' ? { conditions: ['browser'] } : undefined,
build: {
manifest: true,
sourcemap: false
},
test: {
environment: 'jsdom',
include: ['test/components/**/*.test.ts']
},
server: {
proxy: {
'/api': backend,