Add reproducible browser and accessibility gates

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

View file

@ -1,4 +1,4 @@
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app check-app-boundaries check-streaming check-idempotency check-recovery check-model-correspondence check-protocol check-product check-runtime check-v1 check-specs check-web-contract generate-web-contract app-up app-down test-frontend check-frontend build-frontend .PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app check-app-boundaries check-streaming check-idempotency check-recovery check-model-correspondence check-protocol check-product check-browser check-runtime check-v1 check-specs check-web-contract generate-web-contract app-up app-down test-frontend check-frontend build-frontend
test: test:
./tests/run-host-tests.sh ./tests/run-host-tests.sh
@ -42,6 +42,9 @@ check-protocol: check-model-correspondence
check-product: test-frontend check-frontend build-frontend check-web-contract check-product: test-frontend check-frontend build-frontend check-web-contract
./tests/kind/product-run.sh ./tests/kind/product-run.sh
check-browser: test-frontend check-frontend build-frontend
./tests/kind/browser-run.sh
check-web-contract: check-web-contract:
./tests/web-contract.sh ./tests/web-contract.sh

View file

@ -27,6 +27,32 @@ same-origin credentials, encoding, binary bodies, response headers, declared
and undeclared failures, network failures, and the no-automatic-retry policy. and undeclared failures, network failures, and the no-automatic-retry policy.
`make check-product` runs this gate before its kind product lane. `make check-product` runs this gate before its kind product lane.
## Browser behavior and accessibility
`make check-browser` runs the browser unit and component tests, strict
TypeScript and Svelte diagnostics, the production Vite build, and one
Playwright journey against compiled assets served by the real Haskell
application. The reference lane uses Redis, Keycloak, and Garage from the
isolated kind fixture.
Vitest 4.1.10 with Testing Library owns component behavior. Playwright 1.61.1
with Chromium and axe-core 4.12.1 owns rendered accessibility and critical
product journeys. The browser runner installs the Chromium revision selected
by the pinned Playwright package before creating the fixture; the host
provides Playwright's supported Linux shared libraries. The browser gate
covers authenticated startup, active-root state, keyboard operation,
browsing, bounded partial search, upload failure and retry, worker-backed
upload, download, single-delete focus recovery, persisted recursive-delete
recovery, logout, and production asset loading. Accessibility checks cover
programmatic names, roles, states, focus, keyboard operation, and serious or
critical axe findings, including rendered color contrast.
Playwright retains traces, screenshots, and video for failures under
`web/test-results/`; its HTML report is written under `web/playwright-report/`.
Both directories are disposable and ignored. The fixture authentication file
contains only synthetic credentials, is mode-restricted, and is removed by
the runner.
## Model correspondence ## Model correspondence
`make check-model-correspondence` runs before the kind protocol-conformance `make check-model-correspondence` runs before the kind protocol-conformance

View file

@ -400,6 +400,16 @@ executable nuthatch-dependency-probe
, process , process
, temporary , temporary
executable nuthatch-browser-session-fixture
import: shared
main-is: BrowserSessionFixture.hs
hs-source-dirs: test
build-depends:
, nuthatch
, aeson
, base
, text
test-suite nuthatch-unit test-suite nuthatch-unit
import: shared import: shared
type: exitcode-stdio-1.0 type: exitcode-stdio-1.0

View file

@ -0,0 +1,43 @@
module Main (main) where
import qualified Data.Aeson as A
import qualified Data.Text as T
import Nuthatch.Config (HttpConfig (..), secret)
import Nuthatch.Session (mintSession, newCsrfToken)
import Nuthatch.Types
( CsrfToken (..)
, SessionToken (..)
)
import System.Environment (getArgs, lookupEnv)
main :: IO ()
main = do
[output] <- getArgs
jwtSecret <- maybe (fail "NUTHATCH_JWT_SECRET is required") pure
=<< lookupEnv "NUTHATCH_JWT_SECRET"
token <- mintSession
(fixtureHttpConfig (T.pack jwtSecret))
"https://browser-fixture.invalid"
"browser-user"
(Just "Browser Fixture")
>>= either (fail . T.unpack) pure
csrf <- newCsrfToken
A.encodeFile output $
A.object
[ "session" A..= unSessionToken token
, "csrf" A..= unCsrfToken csrf
]
fixtureHttpConfig :: T.Text -> HttpConfig
fixtureHttpConfig jwtSecret = HttpConfig
{ cfgListenHost = "127.0.0.1"
, cfgListenPort = 8080
, cfgTlsCertPath = ""
, cfgTlsKeyPath = ""
, cfgPublicBaseUrl = "https://nuthatch.invalid"
, cfgJwtSecret = secret jwtSecret
, cfgSessionHours = 1
, cfgCookieName = "nuthatch_session"
, cfgCsrfCookieName = "nuthatch_csrf"
, cfgOidcTransactionCookieName = "nuthatch_oidc"
}

View file

@ -28,6 +28,7 @@ targets=(
check-recovery check-recovery
check-protocol check-protocol
check-product check-product
check-browser
check-runtime check-runtime
check-specs check-specs
) )

125
tests/kind/browser-run.sh Executable file
View file

@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/tests/kind/lib.sh"
USER_KUBECONFIG_BEFORE="$(user_kubeconfig_digest)"
SUCCEEDED=false
OWNS_FIXTURE=false
APP_IMAGE=nuthatch-browser:local
LANE=001
LOCAL_PORT=18443
PORT_FORWARD_PID=
AUTH_FILE=
cleanup() {
local status=$?
trap - EXIT INT TERM
if [[ -n "$PORT_FORWARD_PID" ]]; then
kill "$PORT_FORWARD_PID" >/dev/null 2>&1 || true
wait "$PORT_FORWARD_PID" 2>/dev/null || true
fi
if [[ -n "$AUTH_FILE" ]]; then
rm -f "$AUTH_FILE"
fi
if [[ "${NUTHATCH_TESTBED_RETAIN:-0}" = 1 ]] && [[ "$SUCCEEDED" != true ]]; then
printf 'retained kubeconfig: %s\n' "$KUBECONFIG_PATH"
printf 'retained context: %s\n' "$CONTEXT"
exit "$status"
fi
if [[ "$OWNS_FIXTURE" = true ]]; then
destroy_fixture || status=1
fi
if [[ "$(user_kubeconfig_digest)" != "$USER_KUBECONFIG_BEFORE" ]]; then
printf 'user kubeconfig changed unexpectedly\n' >&2
status=1
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
render_application() {
local profile_file="$1"
local oidc_secret
oidc_secret="$(<"$profile_file.oidc-secret")"
sed \
-e "s|@@LANE@@|$LANE|g" \
-e "s|@@APP_IMAGE@@|$APP_IMAGE|g" \
-e "s|@@PG_CONN@@|$(sed -n 's/^NUTHATCH_PG_CONN=//p' "$profile_file")|g" \
-e "s|@@REDIS_URL@@|$(sed -n 's/^NUTHATCH_REDIS_URL=//p' "$profile_file")|g" \
-e "s|@@OIDC_ISSUER@@|$(sed -n 's/^NUTHATCH_OIDC_ISSUER=//p' "$profile_file")|g" \
-e "s|@@OIDC_CLIENT_ID@@|$(sed -n 's/^NUTHATCH_OIDC_CLIENT_ID=//p' "$profile_file")|g" \
-e "s|@@OIDC_ADDITIONAL_AUDIENCES@@|$(sed -n 's/^NUTHATCH_OIDC_ADDITIONAL_AUDIENCES=//p' "$profile_file")|g" \
-e "s|@@OIDC_CLIENT_SECRET@@|$oidc_secret|g" \
-e "s|@@S3_ENDPOINT@@|$(sed -n 's/^NUTHATCH_S3_ENDPOINT=//p' "$profile_file")|g" \
-e "s|@@S3_REGION@@|$(sed -n 's/^NUTHATCH_S3_REGION=//p' "$profile_file")|g" \
-e "s|@@S3_BUCKET@@|$(sed -n 's/^NUTHATCH_S3_BUCKET=//p' "$profile_file")|g" \
-e "s|@@S3_ACCESS_KEY@@|$(sed -n 's/^NUTHATCH_S3_ACCESS_KEY=//p' "$profile_file")|g" \
-e "s|@@S3_SECRET_KEY@@|$(sed -n 's/^NUTHATCH_S3_SECRET_KEY=//p' "$profile_file")|g" \
"$ROOT/deploy/kubernetes/nuthatch.yaml.in" >"$RUNTIME/browser-app-$LANE.yaml"
sed -i \
-e 's/NUTHATCH_BROWSE_PAGE_SIZE: "200"/NUTHATCH_BROWSE_PAGE_SIZE: "20"/' \
-e 's/NUTHATCH_SEARCH_MAX_KEYS: "500"/NUTHATCH_SEARCH_MAX_KEYS: "3"/' \
-e 's/NUTHATCH_DELETE_PAGE_SIZE: "100"/NUTHATCH_DELETE_PAGE_SIZE: "2"/' \
"$RUNTIME/browser-app-$LANE.yaml"
}
npm --prefix "$ROOT/web" exec -- playwright install chromium
acquire_fixture_lock
OWNS_FIXTURE=true
setup_fixture durable coordination oidc target
source "$RUNTIME/fixture.env"
"$NUTHATCH_REAL_NERDCTL" --namespace "$NERDCTL_NAMESPACE" build \
--file "$ROOT/Dockerfile" \
--target test-runtime \
--tag "$APP_IMAGE" \
"$ROOT"
load_local_image "$APP_IMAGE" "$NERDCTL_NAMESPACE"
profile="$(write_lane_profile "$LANE" redis keycloak garage)"
render_application "$RUNTIME/$profile.env"
kube apply --filename "$RUNTIME/browser-app-$LANE.yaml" >/dev/null
kube --namespace "$FIXTURE_NAMESPACE" rollout status \
"deployment/nuthatch-$LANE" --timeout=180s
AUTH_FILE="$RUNTIME/browser-auth.json"
NUTHATCH_JWT_SECRET="synthetic-kind-session-jwt-secret-$LANE" \
cabal run nuthatch-browser-session-fixture --offline -- "$AUTH_FILE" >/dev/null
chmod 600 "$AUTH_FILE"
kube --namespace "$FIXTURE_NAMESPACE" port-forward \
--address 127.0.0.1 "service/nuthatch-$LANE" "$LOCAL_PORT:8080" \
>"$RUNTIME/browser-port-forward.log" 2>&1 &
PORT_FORWARD_PID=$!
for _ in $(seq 1 60); do
if curl --fail --insecure --silent \
"https://127.0.0.1:$LOCAL_PORT/healthz" >/dev/null; then
break
fi
if ! kill -0 "$PORT_FORWARD_PID" 2>/dev/null; then
printf 'browser port-forward exited before becoming ready\n' >&2
exit 1
fi
sleep 1
done
curl --fail --insecure --silent \
"https://127.0.0.1:$LOCAL_PORT/healthz" >/dev/null
NUTHATCH_BROWSER_BASE_URL="https://127.0.0.1:$LOCAL_PORT" \
NUTHATCH_BROWSER_AUTH_FILE="$AUTH_FILE" \
npm --prefix "$ROOT/web" run test:browser
kube --namespace "$FIXTURE_NAMESPACE" delete \
"deployment/nuthatch-$LANE" \
"service/nuthatch-$LANE" \
"configmap/nuthatch-$LANE" \
"secret/nuthatch-$LANE" \
--ignore-not-found >/dev/null
cleanup_lane_state "$LANE" redis keycloak garage
cleanup_lane_resources browser-reference "$profile"
SUCCEEDED=true
printf 'Nuthatch production browser gate passed\n'

2
web/.gitignore vendored
View file

@ -2,3 +2,5 @@ dist/
node_modules/ node_modules/
.svelte-kit/ .svelte-kit/
*.log *.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", "build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json",
"dev": "vite", "dev": "vite",
"test": "node --test test/*.test.mjs" "test": "node --test test/*.test.mjs && vitest run",
"test:browser": "playwright test"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "2.2.0", "@noble/hashes": "2.2.0",
"svelte": "5.56.6" "svelte": "5.56.6"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.12.1",
"@playwright/test": "1.61.1",
"@sveltejs/vite-plugin-svelte": "7.2.0", "@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", "svelte-check": "4.7.3",
"typescript": "6.0.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 busy = $state(false);
let notice = $state(''); let notice = $state('');
let error = $state(''); let error = $state('');
let focusPath = $state<string | null>(null);
let deletionIndex: number | null = null;
let deletePersistence: RecursiveDeletePersistence | null = null; let deletePersistence: RecursiveDeletePersistence | null = null;
let breadcrumbItems = $derived(breadcrumbs(browseState.prefix)); let breadcrumbItems = $derived(breadcrumbs(browseState.prefix));
@ -92,11 +94,13 @@
} }
async function selectRoot(root: Root): Promise<void> { async function selectRoot(root: Root): Promise<void> {
focusPath = null;
setRoot(browseState, root); setRoot(browseState, root);
await browse(false); await browse(false);
} }
async function openPrefix(path: string): Promise<void> { async function openPrefix(path: string): Promise<void> {
focusPath = null;
setPrefix(browseState, path); setPrefix(browseState, path);
await browse(false); await browse(false);
} }
@ -146,6 +150,9 @@
entry.is_prefix entry.is_prefix
); );
if (!window.confirm(warning)) return; if (!window.confirm(warning)) return;
deletionIndex = browseState.entries.findIndex(
({ path }) => path === entry.path
);
if (entry.is_prefix) { if (entry.is_prefix) {
await recursiveDelete(browseState.root, entry.path); await recursiveDelete(browseState.root, entry.path);
return; return;
@ -172,6 +179,7 @@
); );
notice = `Deleted ${path}`; notice = `Deleted ${path}`;
await browse(false); await browse(false);
focusAfterDeletion();
} catch (caught) { } catch (caught) {
error = errorMessage(caught); error = errorMessage(caught);
} finally { } finally {
@ -199,6 +207,7 @@
resumableDelete = null; resumableDelete = null;
notice = `Deleted ${result.deleted} objects from ${prefix}`; notice = `Deleted ${result.deleted} objects from ${prefix}`;
await browse(false); await browse(false);
focusAfterDeletion();
} catch (caught) { } catch (caught) {
resumableDelete = savedRecursiveDelete(deletePersistence); resumableDelete = savedRecursiveDelete(deletePersistence);
error = errorMessage(caught); 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> { async function logout(): Promise<void> {
if (session === null) return; if (session === null) return;
await browserProductClient.logout(session.csrf); await browserProductClient.logout(session.csrf);
@ -277,6 +293,7 @@
partial={browseState.partial} partial={browseState.partial}
{busy} {busy}
query={browseState.query} query={browseState.query}
{focusPath}
onquery={(query) => { onquery={(query) => {
browseState.query = query; browseState.query = query;
browseState.continuation = null; browseState.continuation = null;
@ -284,5 +301,8 @@
onbrowse={browse} onbrowse={browse}
onopen={openEntry} onopen={openEntry}
ondelete={deleteEntry} ondelete={deleteEntry}
onfocused={() => {
focusPath = null;
}}
/> />
</main> </main>

View file

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

View file

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

View file

@ -25,6 +25,7 @@
<button <button
class:active={root === 'personal'} class:active={root === 'personal'}
type="button" type="button"
aria-pressed={root === 'personal'}
disabled={busy} disabled={busy}
onclick={() => void onselect('personal')}>Personal</button onclick={() => void onselect('personal')}>Personal</button
> >
@ -32,6 +33,7 @@
class:active={root === 'shared'} class:active={root === 'shared'}
class:shared={root === 'shared'} class:shared={root === 'shared'}
type="button" type="button"
aria-pressed={root === 'shared'}
disabled={busy} disabled={busy}
onclick={() => void onselect('shared')}>Shared</button 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, "noEmit": true,
"strict": 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 { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig, loadEnv } from 'vite'; import { loadEnv } from 'vite';
import { defineConfig } from 'vitest/config';
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const backend = const backend =
loadEnv(mode, '.', '').NUTHATCH_DEV_BACKEND ?? 'http://127.0.0.1:8080'; loadEnv(mode, '.', '').NUTHATCH_DEV_BACKEND ?? 'http://127.0.0.1:8080';
return { return {
plugins: [svelte()], plugins: [svelte()],
resolve: mode === 'test' ? { conditions: ['browser'] } : undefined,
build: { build: {
manifest: true, manifest: true,
sourcemap: false sourcemap: false
}, },
test: {
environment: 'jsdom',
include: ['test/components/**/*.test.ts']
},
server: { server: {
proxy: { proxy: {
'/api': backend, '/api': backend,