Merge fix/sheet-aria-modal-gating: sheet a11y gating + test-suite hygiene
Gate always-mounted sheet dialog semantics on open state; fix focus-trap Escape drop and deep-link back navigation; update the e2e suite for shipped magic-byte validation, SSE ticket flow and PIN auto-submit; gate Chromium-only permissions.
This commit is contained in:
@@ -21,7 +21,15 @@ export class RecoverPage {
|
|||||||
|
|
||||||
async recover(name: string, pin: string) {
|
async recover(name: string, pin: string) {
|
||||||
await this.nameInput.fill(name);
|
await this.nameInput.fill(name);
|
||||||
|
// Filling the 4th digit fires the form's auto-submit ($effect on
|
||||||
|
// pin.length === 4, see pin-auto-submit.spec). An explicit submit click would
|
||||||
|
// race the ensuing navigation and detach mid-click, so only click as a
|
||||||
|
// fallback if the button is still around (e.g. a partial / failed PIN).
|
||||||
await this.pinInput.fill(pin);
|
await this.pinInput.fill(pin);
|
||||||
await this.submitButton.click();
|
if (await this.submitButton.isEnabled().catch(() => false)) {
|
||||||
|
await this.submitButton.click().catch(() => {
|
||||||
|
/* auto-submit already navigated — nothing to click */
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
* engine-level divergences. The rest of the suite only runs against
|
* engine-level divergences. The rest of the suite only runs against
|
||||||
* `chromium-desktop` to keep the wall-clock reasonable.
|
* `chromium-desktop` to keep the wall-clock reasonable.
|
||||||
*/
|
*/
|
||||||
|
// camera/microphone/clipboard are Chromium-only permissions; passing them to
|
||||||
|
// firefox/webkit projects throws "Unknown permission: camera" and fails the
|
||||||
|
// whole test before it runs. Granted per-Chromium-project below instead of in
|
||||||
|
// the global `use` block.
|
||||||
|
const CHROMIUM_PERMISSIONS = ['camera', 'microphone', 'clipboard-read', 'clipboard-write'];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './specs',
|
testDir: './specs',
|
||||||
outputDir: './test-results',
|
outputDir: './test-results',
|
||||||
@@ -35,9 +41,8 @@ export default defineConfig({
|
|||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
actionTimeout: 10_000,
|
actionTimeout: 10_000,
|
||||||
navigationTimeout: 30_000,
|
navigationTimeout: 30_000,
|
||||||
// Camera/mic permissions granted by default; the fake-media launch args
|
// No camera/mic/clipboard here — those are Chromium-only and are granted on
|
||||||
// (set per-project below for Chromium) supply the actual stream.
|
// the Chromium projects below (see CHROMIUM_PERMISSIONS).
|
||||||
permissions: ['camera', 'microphone', 'clipboard-read', 'clipboard-write'],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
@@ -49,6 +54,7 @@ export default defineConfig({
|
|||||||
testIgnore: ['**/09-mobile/**'],
|
testIgnore: ['**/09-mobile/**'],
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
|
permissions: CHROMIUM_PERMISSIONS,
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: [
|
args: [
|
||||||
'--use-fake-ui-for-media-stream',
|
'--use-fake-ui-for-media-stream',
|
||||||
@@ -68,13 +74,13 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
name: 'chromium-mobile',
|
name: 'chromium-mobile',
|
||||||
testMatch: ['**/09-mobile/**/*.spec.ts'],
|
testMatch: ['**/09-mobile/**/*.spec.ts'],
|
||||||
use: { ...devices['Pixel 7'] },
|
use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS },
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Mobile UA smoke matrix (runs only @smoke specs in CI) ────────────
|
// ── Mobile UA smoke matrix (runs only @smoke specs in CI) ────────────
|
||||||
{
|
{
|
||||||
name: 'chromium-pixel7',
|
name: 'chromium-pixel7',
|
||||||
use: { ...devices['Pixel 7'] },
|
use: { ...devices['Pixel 7'], permissions: CHROMIUM_PERMISSIONS },
|
||||||
grep: /@smoke/,
|
grep: /@smoke/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,6 +90,7 @@ export default defineConfig({
|
|||||||
viewport: { width: 360, height: 780 },
|
viewport: { width: 360, height: 780 },
|
||||||
userAgent:
|
userAgent:
|
||||||
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||||
|
permissions: CHROMIUM_PERMISSIONS,
|
||||||
},
|
},
|
||||||
grep: /@smoke/,
|
grep: /@smoke/,
|
||||||
},
|
},
|
||||||
@@ -94,6 +101,7 @@ export default defineConfig({
|
|||||||
viewport: { width: 360, height: 780 },
|
viewport: { width: 360, height: 780 },
|
||||||
userAgent:
|
userAgent:
|
||||||
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
|
'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||||
|
permissions: CHROMIUM_PERMISSIONS,
|
||||||
},
|
},
|
||||||
grep: /@smoke/,
|
grep: /@smoke/,
|
||||||
},
|
},
|
||||||
@@ -103,6 +111,7 @@ export default defineConfig({
|
|||||||
...devices['Pixel 7'],
|
...devices['Pixel 7'],
|
||||||
userAgent:
|
userAgent:
|
||||||
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.0.0',
|
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.0.0',
|
||||||
|
permissions: CHROMIUM_PERMISSIONS,
|
||||||
},
|
},
|
||||||
grep: /@smoke/,
|
grep: /@smoke/,
|
||||||
},
|
},
|
||||||
@@ -125,6 +134,9 @@ export default defineConfig({
|
|||||||
use: {
|
use: {
|
||||||
...devices['Pixel 7'],
|
...devices['Pixel 7'],
|
||||||
defaultBrowserType: 'firefox',
|
defaultBrowserType: 'firefox',
|
||||||
|
// Firefox rejects `isMobile` (Chromium-only). Keep the phone viewport +
|
||||||
|
// Android UA for coverage, but drop the unsupported flag.
|
||||||
|
isMobile: false,
|
||||||
userAgent:
|
userAgent:
|
||||||
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
'Mozilla/5.0 (Android 14; Mobile; rv:124.0) Gecko/124.0 Firefox/124.0',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,9 +54,13 @@ test.describe('Auth — join flow', () => {
|
|||||||
await expect(join.recoveryPinInput).toBeVisible();
|
await expect(join.recoveryPinInput).toBeVisible();
|
||||||
await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible();
|
await expect(page.getByText(/Charlie.*bereits vergeben/)).toBeVisible();
|
||||||
|
|
||||||
// Type correct PIN → land on /feed with a new JWT
|
// Type correct PIN → land on /feed with a new JWT. Filling the 4th digit
|
||||||
|
// auto-submits (see pin-auto-submit.spec), so an explicit submit click would
|
||||||
|
// race the navigation; click only as a fallback if the button is still around.
|
||||||
await join.recoveryPinInput.fill(original.pin);
|
await join.recoveryPinInput.fill(original.pin);
|
||||||
await join.recoverySubmit.click();
|
if (await join.recoverySubmit.isEnabled().catch(() => false)) {
|
||||||
|
await join.recoverySubmit.click().catch(() => {});
|
||||||
|
}
|
||||||
await page.waitForURL('**/feed');
|
await page.waitForURL('**/feed');
|
||||||
|
|
||||||
const storage = await readStorage(page);
|
const storage = await readStorage(page);
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
import { test, expect } from '../../fixtures/test';
|
import { test, expect } from '../../fixtures/test';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
||||||
|
// A real, decodable JPEG — the upload handler validates magic bytes, so a
|
||||||
|
// zero-filled buffer would be rejected with 400 before the rate limiter is reached.
|
||||||
|
const SAMPLE_BYTES = readFileSync(SAMPLE_JPG);
|
||||||
|
|
||||||
test.describe('Upload — rate limit', () => {
|
test.describe('Upload — rate limit', () => {
|
||||||
test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => {
|
test('4th upload in one hour returns 429 with Retry-After', async ({ api, adminToken, guest }) => {
|
||||||
@@ -24,7 +28,7 @@ test.describe('Upload — rate limit', () => {
|
|||||||
// Hit the API directly for speed — UI behavior is asserted in gallery-path.spec.
|
// Hit the API directly for speed — UI behavior is asserted in gallery-path.spec.
|
||||||
const upload = async (n: number) => {
|
const upload = async (n: number) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' });
|
const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' });
|
||||||
form.append('file', blob, `file${n}.jpg`);
|
form.append('file', blob, `file${n}.jpg`);
|
||||||
form.append('content_type', 'image/jpeg');
|
form.append('content_type', 'image/jpeg');
|
||||||
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
|
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
|
||||||
@@ -47,7 +51,6 @@ test.describe('Upload — rate limit', () => {
|
|||||||
// The 429 response carries Retry-After.
|
// The 429 response carries Retry-After.
|
||||||
const limited = responses.find((r) => r.status === 429)!;
|
const limited = responses.find((r) => r.status === 429)!;
|
||||||
expect(limited.headers.get('retry-after')).toBeTruthy();
|
expect(limited.headers.get('retry-after')).toBeTruthy();
|
||||||
void SAMPLE_JPG;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => {
|
test('flipping upload_rate_enabled off bypasses the limit', async ({ api, adminToken, guest }) => {
|
||||||
@@ -56,7 +59,7 @@ test.describe('Upload — rate limit', () => {
|
|||||||
const h = await guest('NoQuota');
|
const h = await guest('NoQuota');
|
||||||
const upload = async (n: number) => {
|
const upload = async (n: number) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const blob = new Blob([new Uint8Array(640)], { type: 'image/jpeg' });
|
const blob = new Blob([SAMPLE_BYTES], { type: 'image/jpeg' });
|
||||||
form.append('file', blob, `file${n}.jpg`);
|
form.append('file', blob, `file${n}.jpg`);
|
||||||
form.append('content_type', 'image/jpeg');
|
form.append('content_type', 'image/jpeg');
|
||||||
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
|
return fetch((process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101') + '/api/v1/upload', {
|
||||||
|
|||||||
@@ -46,9 +46,21 @@ test.describe('Adversarial — small-scale abuse', () => {
|
|||||||
|
|
||||||
test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => {
|
test('SSE: 10 concurrent streams from one user do not crash the server', async ({ guest }) => {
|
||||||
const g = await guest('SseFlood');
|
const g = await guest('SseFlood');
|
||||||
const controllers = Array.from({ length: 10 }, () => new AbortController());
|
// The stream endpoint authenticates via single-use tickets (POST /stream/ticket),
|
||||||
const requests = controllers.map((c) =>
|
// not the raw JWT — a `?token=` open is rejected with 400. Mint one ticket per stream.
|
||||||
fetch(`${BASE}/api/v1/stream?token=${encodeURIComponent(g.jwt)}`, { signal: c.signal })
|
const mintTicket = async () => {
|
||||||
|
const res = await fetch(`${BASE}/api/v1/stream/ticket`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${g.jwt}` },
|
||||||
|
});
|
||||||
|
const json: any = await res.json();
|
||||||
|
return json.ticket as string;
|
||||||
|
};
|
||||||
|
const tickets = await Promise.all(Array.from({ length: 10 }, mintTicket));
|
||||||
|
|
||||||
|
const controllers = tickets.map(() => new AbortController());
|
||||||
|
const requests = tickets.map((ticket, i) =>
|
||||||
|
fetch(`${BASE}/api/v1/stream?ticket=${encodeURIComponent(ticket)}`, { signal: controllers[i].signal })
|
||||||
);
|
);
|
||||||
const responses = await Promise.all(requests);
|
const responses = await Promise.all(requests);
|
||||||
// All accepted (or some rate-limited — both fine).
|
// All accepted (or some rate-limited — both fine).
|
||||||
|
|||||||
Binary file not shown.
@@ -75,10 +75,11 @@ test.describe('Mobile — double-tap gesture', () => {
|
|||||||
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
await expect(imageButton).toBeVisible({ timeout: 10_000 });
|
||||||
await imageButton.click();
|
await imageButton.click();
|
||||||
|
|
||||||
// LightboxModal is `role="dialog"` (no aria-modal). The other dialog on the
|
// LightboxModal is the only dialog labelled by #lightbox-title, so target it
|
||||||
// page is the ContextSheet which has `aria-modal="true"` even when closed,
|
// directly. (Closed sheets no longer expose role=dialog — that semantics is
|
||||||
// so scope to NOT-aria-modal to pick the lightbox specifically.
|
// gated on `open` — so a plain [role=dialog] match would be ambiguous only
|
||||||
const lightbox = page.locator('[role="dialog"]:not([aria-modal])');
|
// while a sheet is open; the labelledby scope keeps this unambiguous.)
|
||||||
|
const lightbox = page.locator('[role="dialog"][aria-labelledby="lightbox-title"]');
|
||||||
await expect(lightbox).toBeVisible();
|
await expect(lightbox).toBeVisible();
|
||||||
|
|
||||||
// Find the inner image element to tap.
|
// Find the inner image element to tap.
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
|
|||||||
await page.goto('/account');
|
await page.goto('/account');
|
||||||
|
|
||||||
// Click the "Original" radio in the Datennutzung section to open the warning sheet.
|
// Click the "Original" radio in the Datennutzung section to open the warning sheet.
|
||||||
const originalRadio = page.getByRole('radio', { name: /Original$/i });
|
// The radio's accessible name is its title + description ("Original Lädt die
|
||||||
|
// Originaldateien…"), so anchor on the start, not the end.
|
||||||
|
const originalRadio = page.getByRole('radio', { name: /^Original/i });
|
||||||
await originalRadio.click();
|
await originalRadio.click();
|
||||||
|
|
||||||
const sheet = page.locator('[role="dialog"][aria-labelledby="data-mode-title"]');
|
const sheet = page.locator('[role="dialog"][aria-labelledby="data-mode-title"]');
|
||||||
|
|||||||
@@ -64,11 +64,16 @@ export function focusTrap(
|
|||||||
node.addEventListener('keydown', onKeyDown);
|
node.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
if (opts.autoFocus !== false) {
|
if (opts.autoFocus !== false) {
|
||||||
// Defer one frame so the element is fully laid out (sheets animate in).
|
// Focus the container synchronously on mount so the trap owns the keyboard
|
||||||
|
// immediately — otherwise an Escape pressed before the deferred focus below
|
||||||
|
// lands on an element *outside* the node, where this node-scoped listener
|
||||||
|
// never sees it (the keystroke is silently dropped). Then defer one frame to
|
||||||
|
// move focus onto the first control once the sheet has laid out / animated in.
|
||||||
|
if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1');
|
||||||
|
node.focus({ preventScroll: true });
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const list = focusables(node);
|
const list = focusables(node);
|
||||||
const target = list[0] ?? node;
|
const target = list[0] ?? node;
|
||||||
if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1');
|
|
||||||
target.focus({ preventScroll: true });
|
target.focus({ preventScroll: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,8 +103,10 @@
|
|||||||
class:translate-y-full={!open}
|
class:translate-y-full={!open}
|
||||||
class:translate-y-0={open}
|
class:translate-y-0={open}
|
||||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
role="dialog"
|
role={open ? 'dialog' : undefined}
|
||||||
aria-modal="true"
|
aria-modal={open ? 'true' : undefined}
|
||||||
|
aria-hidden={!open}
|
||||||
|
inert={!open}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="flex justify-center pt-3 pb-1">
|
<div class="flex justify-center pt-3 pb-1">
|
||||||
|
|||||||
@@ -136,9 +136,11 @@
|
|||||||
class:translate-y-full={!open}
|
class:translate-y-full={!open}
|
||||||
class:translate-y-0={open}
|
class:translate-y-0={open}
|
||||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
role="dialog"
|
role={open ? 'dialog' : undefined}
|
||||||
aria-modal="true"
|
aria-modal={open ? 'true' : undefined}
|
||||||
aria-label="Hochladen"
|
aria-label="Hochladen"
|
||||||
|
aria-hidden={!open}
|
||||||
|
inert={!open}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
import { api, ApiError } from '$lib/api';
|
import { api, ApiError } from '$lib/api';
|
||||||
import { setAuth, getPin, getToken } from '$lib/auth';
|
import { setAuth, getPin, getToken } from '$lib/auth';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
|
|
||||||
|
// `from` is non-null only when we arrived here via in-app (client-side)
|
||||||
|
// navigation; on a full-page load (deep link, new tab) it's null. Using
|
||||||
|
// history.length is unreliable — a fresh tab keeps `about:blank` as the prior
|
||||||
|
// entry, so history.back() would land there instead of inside the app.
|
||||||
|
let cameFromApp = $state(false);
|
||||||
|
afterNavigate(({ from }) => {
|
||||||
|
cameFromApp = from !== null;
|
||||||
|
});
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
// Prefer the actual previous page (most users land here from /join or /account).
|
// Prefer the actual previous page (most users land here from /join or /account).
|
||||||
// Fall back to a sensible default based on auth state for deep-linked users.
|
// Fall back to a sensible default based on auth state for deep-linked users.
|
||||||
if (browser && window.history.length > 1) {
|
if (cameFromApp) {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user