Add reproducible browser and accessibility gates
This commit is contained in:
parent
4d31c441d0
commit
9ad8d27adf
19 changed files with 1887 additions and 103 deletions
5
Makefile
5
Makefile
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
43
test/BrowserSessionFixture.hs
Normal file
43
test/BrowserSessionFixture.hs
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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
125
tests/kind/browser-run.sh
Executable 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
2
web/.gitignore
vendored
|
|
@ -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
1204
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
21
web/playwright.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
>
|
>
|
||||||
|
|
|
||||||
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([]);
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue