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:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user