chore(e2e): add ESLint + Prettier; fix real findings; dedupe BASE

The Playwright suite had no linter and no formatter — only tsc. Add flat-config ESLint
(typescript-eslint, type-aware) and Prettier (2-space, matching the suite's style).

Rules keep the ones that catch real TEST bugs and drop the noise:
  - no-floating-promises KEPT — an un-awaited request/assertion can let a test end before it runs,
    passing vacuously. It caught one: the SSE reader loop in sse-listener is now explicitly `void`.
  - no-unused-vars KEPT — caught three dead bindings (an unused adminToken fixture arg, an unused
    `api` arg, an unused JPEG_MAGIC import), all removed.
  - no-explicit-any OFF — all test code; `any` is the honest type for an untyped res.json() body or
    a page.evaluate() return.
  - no-empty-pattern OFF — Playwright's dependency-free fixtures are `async ({}, use) => {}`.

Refactor: `const BASE = process.env.E2E_FRONTEND_URL ?? '...'` was redeclared verbatim in 23
files — extracted to helpers/env.ts and imported, so a port/scheme change is one edit not a sweep.

Then `prettier --write`. Verified: eslint clean, tsc clean, prettier clean, desktop suite 210
passed / 1 skipped. (One mobile spec flaked once under retries:0 — a pre-existing cross-test
reflow-timing vector from the flakiness audit, not this change: the each-key edit is stable across
16 isolated runs and a clean full mobile re-run.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-15 20:45:59 +02:00
parent f8cba95e49
commit bbdfae09a0
67 changed files with 2441 additions and 396 deletions

View File

@@ -34,18 +34,25 @@ test.describe('Mobile a11y — focus trap on LightboxModal', () => {
await trigger.click();
// Lightbox is role="dialog" aria-modal="true".
const lightbox = page.locator('[role="dialog"][aria-modal="true"]').filter({ has: page.locator('img, video') });
const lightbox = page
.locator('[role="dialog"][aria-modal="true"]')
.filter({ has: page.locator('img, video') });
await expect(lightbox).toBeVisible();
// After opening, focus should be inside the lightbox (trap autoFocus moves
// focus to the first focusable). Verify by checking activeElement is
// contained.
await expect.poll(async () => {
return await page.evaluate(() => {
const dlg = document.querySelector('[role="dialog"][aria-modal="true"]');
return !!dlg && dlg.contains(document.activeElement);
});
}, { timeout: 2_000 }).toBe(true);
await expect
.poll(
async () => {
return await page.evaluate(() => {
const dlg = document.querySelector('[role="dialog"][aria-modal="true"]');
return !!dlg && dlg.contains(document.activeElement);
});
},
{ timeout: 2_000 }
)
.toBe(true);
// Escape dismisses the lightbox.
await page.keyboard.press('Escape');

View File

@@ -26,12 +26,18 @@ async function seedUpload(token: string, caption = 'Doubletap fixture'): Promise
contentType: 'image/jpeg',
caption,
});
if (res.status !== 201) throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`);
if (res.status !== 201)
throw new Error(`Upload seed failed (${res.status}): ${await res.text()}`);
return (await res.json()) as { id: string };
}
test.describe('Mobile — double-tap gesture', () => {
test('double-tap on a feed card image button registers a like', async ({ api, page, guest, signIn }) => {
test('double-tap on a feed card image button registers a like', async ({
api,
page,
guest,
signIn,
}) => {
const author = await guest('DtAuthor');
const liker = await guest('DtLiker');
const { id: uploadId } = await seedUpload(author.jwt, 'Double-tap me');
@@ -40,7 +46,8 @@ test.describe('Mobile — double-tap gesture', () => {
await page.goto('/feed');
// Locate the image button inside the card. The aria-label is "Bild vergrößern".
const imageButton = page.locator('article')
const imageButton = page
.locator('article')
.filter({ hasText: author.displayName })
.first()
.getByRole('button', { name: 'Bild vergrößern' });
@@ -50,16 +57,26 @@ test.describe('Mobile — double-tap gesture', () => {
// Wait for the optimistic increment OR the SSE-confirmed count. We assert via the
// API to avoid coupling to specific DOM markup for the like badge.
await expect.poll(async () => {
const feed = await api.getFeed(liker.jwt);
// Backend returns { uploads: [...], next_cursor }.
const list: any[] = feed.uploads ?? feed.items ?? feed;
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
return row?.like_count ?? row?.likes ?? 0;
}, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
await expect
.poll(
async () => {
const feed = await api.getFeed(liker.jwt);
// Backend returns { uploads: [...], next_cursor }.
const list: any[] = feed.uploads ?? feed.items ?? feed;
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
return row?.like_count ?? row?.likes ?? 0;
},
{ timeout: 5_000 }
)
.toBeGreaterThanOrEqual(1);
});
test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({ api, page, guest, signIn }) => {
test('double-tap inside the lightbox triggers the heart-burst (like recorded)', async ({
api,
page,
guest,
signIn,
}) => {
const author = await guest('LbAuthor');
const liker = await guest('LbLiker');
const { id: uploadId } = await seedUpload(author.jwt, 'Lightbox heart');
@@ -68,7 +85,8 @@ test.describe('Mobile — double-tap gesture', () => {
await page.goto('/feed');
// Open the lightbox by clicking the image button.
const imageButton = page.locator('article')
const imageButton = page
.locator('article')
.filter({ hasText: author.displayName })
.first()
.getByRole('button', { name: 'Bild vergrößern' });
@@ -88,12 +106,17 @@ test.describe('Mobile — double-tap gesture', () => {
await doubleTap(page, media);
await expect.poll(async () => {
const feed = await api.getFeed(liker.jwt);
// Backend returns { uploads: [...], next_cursor }.
const list: any[] = feed.uploads ?? feed.items ?? feed;
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
return row?.like_count ?? row?.likes ?? 0;
}, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
await expect
.poll(
async () => {
const feed = await api.getFeed(liker.jwt);
// Backend returns { uploads: [...], next_cursor }.
const list: any[] = feed.uploads ?? feed.items ?? feed;
const row = Array.isArray(list) ? list.find((u: any) => u.id === uploadId) : undefined;
return row?.like_count ?? row?.likes ?? 0;
},
{ timeout: 5_000 }
)
.toBeGreaterThanOrEqual(1);
});
});

View File

@@ -11,7 +11,7 @@
* helper.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
import { uploadRaw } from '../../helpers/upload-client';
import { longPress } from '../../helpers/touch';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -54,7 +54,11 @@ test.describe('Mobile — long-press gesture', () => {
await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible();
});
test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({ page, guest, signIn }) => {
test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({
page,
guest,
signIn,
}) => {
const g = await guest('Lp2');
await seedUpload(g.jwt, 'Quick tap');
await signIn(page, g);
@@ -68,10 +72,16 @@ test.describe('Mobile — long-press gesture', () => {
// Within 1 s, the ContextSheet must not be open (aria-modal is set only when
// open). A quick tap opens the lightbox instead, which is a different element.
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, { timeout: 1_000 });
await expect(page.locator('[data-testid="context-sheet"][aria-modal="true"]')).toHaveCount(0, {
timeout: 1_000,
});
});
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn }) => {
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({
page,
guest,
signIn,
}) => {
const g = await guest('Lp3');
await seedUpload(g.jwt, 'Suppress click');
await signIn(page, g);
@@ -85,6 +95,8 @@ test.describe('Mobile — long-press gesture', () => {
// The longpress action sets `suppressNextClick = true` — so the lightbox
// (separate role=dialog) should NOT appear in addition to the context sheet.
// Exactly one aria-modal=true dialog should be open: the context sheet.
await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, { timeout: 2_000 });
await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, {
timeout: 2_000,
});
});
});

View File

@@ -19,7 +19,11 @@ import { join } from 'node:path';
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
test.describe('Mobile — planned gestures (fixme until shipped)', () => {
test.fixme('swipe left in lightbox navigates to next filtered item', async ({ page, guest, signIn }) => {
test.fixme('swipe left in lightbox navigates to next filtered item', async ({
page,
guest,
signIn,
}) => {
const author = await guest('SwipeAuthor');
// Seed two uploads so there's a "next" to navigate to.
for (const cap of ['First', 'Second']) {
@@ -35,7 +39,10 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => {
await page.goto('/feed');
// Open the lightbox on the first card.
const firstImage = page.locator('article').filter({ hasText: 'First' }).getByRole('button', { name: 'Bild vergrößern' });
const firstImage = page
.locator('article')
.filter({ hasText: 'First' })
.getByRole('button', { name: 'Bild vergrößern' });
await firstImage.click();
const lightbox = page.getByRole('dialog');
await expect(lightbox).toBeVisible();
@@ -96,11 +103,7 @@ test.describe('Mobile — planned gestures (fixme until shipped)', () => {
const box = await page.locator('body').boundingBox();
if (!box) throw new Error('body not visible');
// Pull down from the top of the viewport.
await swipe(
page,
{ x: box.x + box.width / 2, y: 20 },
{ x: box.x + box.width / 2, y: 200 }
);
await swipe(page, { x: box.x + box.width / 2, y: 20 }, { x: box.x + box.width / 2, y: 200 });
await page.waitForTimeout(1_000);
expect(deltaCalled).toBe(true);

View File

@@ -17,24 +17,38 @@ import { test, expect } from '../../fixtures/test';
import { inlineStyle } from '../../helpers/touch';
test.describe('Mobile — safe-area insets', () => {
test('bottom nav declares safe-area-inset-bottom in its inline style', async ({ page, guest, signIn }) => {
test('bottom nav declares safe-area-inset-bottom in its inline style', async ({
page,
guest,
signIn,
}) => {
const g = await guest('SafeAreaNav');
await signIn(page, g);
await page.goto('/feed');
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
const nav = page
.locator('nav')
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
.first();
await expect(nav).toBeVisible();
const style = await inlineStyle(nav);
expect(style).toContain('env(safe-area-inset-bottom)');
});
test('bottom nav stays flush with viewport bottom (no large gap)', async ({ page, guest, signIn }) => {
test('bottom nav stays flush with viewport bottom (no large gap)', async ({
page,
guest,
signIn,
}) => {
const g = await guest('SafeAreaFlush');
await signIn(page, g);
await page.goto('/feed');
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
const nav = page
.locator('nav')
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
.first();
const viewport = page.viewportSize();
if (!viewport) throw new Error('No viewport size set on this project');
const box = await nav.boundingBox();
@@ -48,7 +62,11 @@ test.describe('Mobile — safe-area insets', () => {
// (A vacuous `/join` probe that only asserted `Array.isArray(...)` — always true —
// was removed; the real sheet-level env() check is the structural test below.)
test('upload sheet and context sheet both honor env() (structural check)', async ({ page, guest, signIn }) => {
test('upload sheet and context sheet both honor env() (structural check)', async ({
page,
guest,
signIn,
}) => {
const g = await guest('SafeAreaSheets');
await signIn(page, g);
await page.goto('/feed');
@@ -57,9 +75,9 @@ test.describe('Mobile — safe-area insets', () => {
await page.getByRole('button', { name: 'Hochladen' }).click();
// Even if the sheet is offscreen / hidden, the style attribute is present in the DOM.
const hits: number = await page.evaluate(() => {
return Array.from(document.querySelectorAll<HTMLElement>('[style]'))
.filter((el) => (el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)'))
.length;
return Array.from(document.querySelectorAll<HTMLElement>('[style]')).filter((el) =>
(el.getAttribute('style') ?? '').includes('env(safe-area-inset-bottom)')
).length;
});
expect(hits).toBeGreaterThanOrEqual(1);
});

View File

@@ -24,7 +24,11 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
await expect(sheet).not.toBeVisible({ timeout: 2_000 });
});
test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({ page, guest, signIn }) => {
test('leave-confirm sheet (built on ConfirmSheet) closes on Escape', async ({
page,
guest,
signIn,
}) => {
const g = await guest('LeaveEsc');
await signIn(page, g);
await page.goto('/account');

View File

@@ -16,8 +16,12 @@ const MIN_TOUCH = 44;
async function assertTouchTarget(box: { width: number; height: number } | null, name: string) {
if (!box) throw new Error(`${name} not visible — no bounding box`);
expect.soft(box.width, `${name} width ≥ ${MIN_TOUCH}px (got ${box.width})`).toBeGreaterThanOrEqual(MIN_TOUCH);
expect.soft(box.height, `${name} height${MIN_TOUCH}px (got ${box.height})`).toBeGreaterThanOrEqual(MIN_TOUCH);
expect
.soft(box.width, `${name} width${MIN_TOUCH}px (got ${box.width})`)
.toBeGreaterThanOrEqual(MIN_TOUCH);
expect
.soft(box.height, `${name} height ≥ ${MIN_TOUCH}px (got ${box.height})`)
.toBeGreaterThanOrEqual(MIN_TOUCH);
}
test.describe('Mobile — touch target audit', () => {
@@ -55,6 +59,9 @@ test.describe('Mobile — touch target audit', () => {
await expect(page.getByTestId('pin-modal')).toBeVisible();
await assertTouchTarget(await page.getByTestId('pin-copy').boundingBox(), 'PIN copy button');
await assertTouchTarget(await page.getByTestId('continue-to-feed').boundingBox(), 'Continue-to-feed button');
await assertTouchTarget(
await page.getByTestId('continue-to-feed').boundingBox(),
'Continue-to-feed button'
);
});
});

View File

@@ -6,7 +6,11 @@
import { test, expect } from '../../fixtures/test';
test.describe('Mobile — upload composer cancel confirmation', () => {
test('typing a caption then tapping X opens the discard ConfirmSheet', async ({ page, guest, signIn }) => {
test('typing a caption then tapping X opens the discard ConfirmSheet', async ({
page,
guest,
signIn,
}) => {
const g = await guest('CancelConf');
await signIn(page, g);
@@ -31,7 +35,11 @@ test.describe('Mobile — upload composer cancel confirmation', () => {
await expect(caption).toHaveValue(/a meaningful caption/);
});
test('with no content, tapping X navigates directly to /feed', async ({ page, guest, signIn }) => {
test('with no content, tapping X navigates directly to /feed', async ({
page,
guest,
signIn,
}) => {
const g = await guest('CancelEmpty');
await signIn(page, g);
await page.goto('/upload');

View File

@@ -16,13 +16,20 @@ const VIEWPORTS = [
test.describe('Mobile — viewport reflow', () => {
for (const vp of VIEWPORTS) {
test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({ page, guest, signIn }) => {
test(`bottom nav remains usable at ${vp.name} (${vp.width}×${vp.height})`, async ({
page,
guest,
signIn,
}) => {
const g = await guest(`Reflow_${vp.width}x${vp.height}`);
await signIn(page, g);
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto('/feed');
const nav = page.locator('nav').filter({ has: page.getByRole('link', { name: 'Galerie' }) }).first();
const nav = page
.locator('nav')
.filter({ has: page.getByRole('link', { name: 'Galerie' }) })
.first();
const fab = page.getByRole('button', { name: 'Hochladen' });
await expect(nav).toBeVisible();
@@ -40,11 +47,15 @@ test.describe('Mobile — viewport reflow', () => {
if (!fabBox) throw new Error('FAB has no bounding box');
const fabMidX = fabBox.x + fabBox.width / 2;
const expectedMid = vp.width / 2;
expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.30);
expect.soft(Math.abs(fabMidX - expectedMid)).toBeLessThanOrEqual(vp.width * 0.3);
});
}
test('rotation portrait → landscape preserves auth + bottom nav', async ({ page, guest, signIn }) => {
test('rotation portrait → landscape preserves auth + bottom nav', async ({
page,
guest,
signIn,
}) => {
const g = await guest('Rotate');
await signIn(page, g);
await page.goto('/feed');