From 8d2dfbe64035d6f6e504931929b5cc06b94a6218 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 22 Sep 2026 19:32:11 +0200 Subject: [PATCH 01/10] test(app): Raise the Playwright global timeout and report slow test files Signed-off-by: Maciek --- app/src/__tests__/playwright.config.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/__tests__/playwright.config.ts b/app/src/__tests__/playwright.config.ts index 0462b223..c7fdd1b7 100644 --- a/app/src/__tests__/playwright.config.ts +++ b/app/src/__tests__/playwright.config.ts @@ -9,12 +9,14 @@ export default defineConfig({ testDir: './e2e', timeout: 30 * 1000, expect: { timeout: 5000 }, - globalTimeout: 12 * 60 * 1000, + // Must stay well under the 30-minute job timeout in .github/workflows/frontend.yml. + globalTimeout: 20 * 60 * 1000, maxFailures: 10, fullyParallel: true, - // Limit workers in CI to reduce flakiness / resource contention - workers: process.env.CI ? 2 : undefined, + // Public-repo ubuntu-latest runners have 4 vCPUs; leave one for the vite server. + workers: process.env.CI ? 3 : undefined, retries: 1, + reportSlowTests: { max: 10, threshold: 30 * 1000 }, reporter: [ ['github'], ['html', { open: 'never' }] From eac20ad555530ba43474dda9b0fe305774a34017 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 22 Sep 2026 19:32:44 +0200 Subject: [PATCH 02/10] test(app): Drop the chromium-mobile-light Playwright project Signed-off-by: Maciek --- app/src/__tests__/playwright.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/__tests__/playwright.config.ts b/app/src/__tests__/playwright.config.ts index c7fdd1b7..624bacd1 100644 --- a/app/src/__tests__/playwright.config.ts +++ b/app/src/__tests__/playwright.config.ts @@ -44,8 +44,6 @@ export default defineConfig({ { name: 'chromium-mobile-dark', use: { ...devices['Pixel 5'], colorScheme: 'dark', storageState: 'src/__tests__/e2e/.auth-storage.json' }, dependencies: ['auth-setup'] }, // Safari/WebKit coverage: latest supported iPhone (15 Pro) using default WebKit engine { name: 'iphone15pro-dark', use: { ...devices['iPhone 15 Pro'], colorScheme: 'dark', storageState: 'src/__tests__/e2e/.auth-storage.json' }, dependencies: ['auth-setup'] }, - // Mobile baseline: Pixel 5 light mode - { name: 'chromium-mobile-light', use: { ...devices['Pixel 5'], colorScheme: 'light', storageState: 'src/__tests__/e2e/.auth-storage.json' }, dependencies: ['auth-setup'] }, // Desktop without pre-auth storage so login flows can exercise authentication UI { name: 'chromium-desktop', use: { ...devices['Desktop Chrome'] } } ] From 9ef828062d900e19c5e375f17a31e07bacf46cac Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 22 Sep 2026 19:33:06 +0200 Subject: [PATCH 03/10] test(app): Scope Playwright specs to projects with tags instead of runtime skips Signed-off-by: Maciek --- app/package.json | 4 +-- .../connection/connectionStatusHeader.spec.ts | 12 +++------ .../mobileConnectionStatusBar.spec.ts | 6 ++--- app/src/__tests__/e2e/functional/auth.spec.ts | 3 +-- .../custom-rules-empty-groups.spec.ts | 8 ++---- .../custom-rules-group-reorder.spec.ts | 8 ++---- .../custom-rules-paste-target.spec.ts | 5 ++-- .../e2e/functional/login-advanced.spec.ts | 5 ++-- .../e2e/functional/login-basic.spec.ts | 5 ++-- .../__tests__/e2e/functional/logout.spec.ts | 5 ++-- .../e2e/functional/session-limit.spec.ts | 5 ++-- .../functional/settings-tooltip-tap.spec.ts | 5 ++-- .../e2e/functional/setup-ip-tooltip.spec.ts | 5 ++-- .../layout/custom-rules-delete-modal.spec.ts | 4 +-- .../layout/custom-rules-edit-modal.spec.ts | 6 +---- .../e2e/layout/custom-rules-ios.spec.ts | 6 +---- .../e2e/layout/desktop-layout.spec.ts | 3 +-- .../e2e/layout/faq-expand-scroll.spec.ts | 8 +----- app/src/__tests__/e2e/layout/layout.spec.ts | 9 +++---- app/src/__tests__/e2e/layout/logs-ios.spec.ts | 6 +---- .../layout/mobile-header-transition.spec.ts | 5 ++-- .../layout/mobile-horizontal-overflow.spec.ts | 11 +------- .../e2e/layout/mobile-nav-scroll.spec.ts | 12 +-------- .../e2e/layout/mobileHeaderTitle.spec.ts | 12 ++------- .../e2e/layout/navigation-visibility.spec.ts | 6 ++--- .../e2e/layout/protected-pages-ios.spec.ts | 10 +------ .../e2e/layout/responsive-navigation.spec.ts | 6 ++--- .../__tests__/e2e/layout/responsive.spec.ts | 3 +-- .../e2e/layout/setup-guide-scroll.spec.ts | 9 +------ .../__tests__/e2e/layout/setup-ios.spec.ts | 4 +-- .../e2e/layout/setup-linux-dnscrypt.spec.ts | 4 +-- .../setup-overlay-header-visibility.spec.ts | 14 +++------- .../e2e/layout/setup-overlay-mobile.spec.ts | 5 +--- .../e2e/layout/setup-routers-stamps.spec.ts | 4 +-- .../layout/setup-sidepanel-desktop.spec.ts | 4 +-- .../e2e/layout/tap-targets-auth.spec.ts | 8 ++---- .../e2e/layout/tap-targets-protected.spec.ts | 7 +---- .../e2e/layout/ultrawide-layout.spec.ts | 3 +-- .../logs/logs-mobile-bottom-header.spec.ts | 4 +-- .../e2e/logs/logs-mobile-overflow.spec.ts | 13 ++++------ .../e2e/manual/mobileSnapshots.spec.ts | 13 +++------- app/src/__tests__/e2e/utils/desktopOnly.ts | 14 ---------- app/src/__tests__/playwright.config.ts | 26 ++++++++++++++++--- 43 files changed, 95 insertions(+), 220 deletions(-) delete mode 100644 app/src/__tests__/e2e/utils/desktopOnly.ts diff --git a/app/package.json b/app/package.json index 48d11c0b..f1e807b9 100644 --- a/app/package.json +++ b/app/package.json @@ -16,8 +16,8 @@ "test:e2e:headed": "playwright test -c src/__tests__/playwright.config.ts --headed --workers=2", "test:e2e:inspect": "playwright test -c src/__tests__/playwright.config.ts --ui", "test:e2e:report": "playwright show-report", - "snapshots:mobile": "MOBILE_SNAPSHOTS=1 playwright test -c src/__tests__/playwright.config.ts --project=chromium-mobile --project=iphone15pro src/__tests__/e2e/manual/mobileSnapshots.spec.ts --update-snapshots", - "snapshots:mobile:ci": "MOBILE_SNAPSHOTS=1 playwright test -c src/__tests__/playwright.config.ts --project=chromium-mobile --project=iphone15pro src/__tests__/e2e/manual/mobileSnapshots.spec.ts" + "snapshots:mobile": "MOBILE_SNAPSHOTS=1 playwright test -c src/__tests__/playwright.config.ts --project=chromium-mobile-dark --project=iphone15pro-dark src/__tests__/e2e/manual/mobileSnapshots.spec.ts --update-snapshots", + "snapshots:mobile:ci": "MOBILE_SNAPSHOTS=1 playwright test -c src/__tests__/playwright.config.ts --project=chromium-mobile-dark --project=iphone15pro-dark src/__tests__/e2e/manual/mobileSnapshots.spec.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/app/src/__tests__/e2e/connection/connectionStatusHeader.spec.ts b/app/src/__tests__/e2e/connection/connectionStatusHeader.spec.ts index ee526189..3404c04f 100644 --- a/app/src/__tests__/e2e/connection/connectionStatusHeader.spec.ts +++ b/app/src/__tests__/e2e/connection/connectionStatusHeader.spec.ts @@ -30,20 +30,16 @@ async function mockDnsSequence(page: Page, responses: Record[]) }); } -// Desktop only tests rely on chromium-desktop project -// Assumes /setup route renders the header when desktop +// The header only renders on desktop viewports; /setup is where it mounts. -test.describe('Desktop ConnectionStatusHeader', () => { - test.beforeEach(async ({ page }, testInfo) => { - // Skip on mobile viewports - header only appears on desktop - test.skip(!/desktop/i.test(testInfo.project.name), 'Desktop header tests require desktop viewport'); +test.describe('Desktop ConnectionStatusHeader', { tag: '@desktop' }, () => { + test.beforeEach(async ({ page }) => { await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default' }] }); await page.goto('/setup'); await page.evaluate(() => window.localStorage?.removeItem('moddns-storage')); }); - test.afterEach(async ({ page }, testInfo) => { - if (!/desktop/i.test(testInfo.project.name)) return; + test.afterEach(async ({ page }) => { if (page.isClosed()) return; await page.evaluate(() => window.localStorage?.removeItem('moddns-storage')); }); diff --git a/app/src/__tests__/e2e/connection/mobileConnectionStatusBar.spec.ts b/app/src/__tests__/e2e/connection/mobileConnectionStatusBar.spec.ts index 8e84b4fc..dd86b9f5 100644 --- a/app/src/__tests__/e2e/connection/mobileConnectionStatusBar.spec.ts +++ b/app/src/__tests__/e2e/connection/mobileConnectionStatusBar.spec.ts @@ -13,12 +13,10 @@ async function mockDnsSequence(page: Page, responses: Record[]) }); } -test.describe('Mobile ConnectionStatusBar', () => { +test.describe('Mobile ConnectionStatusBar', { tag: '@mobile' }, () => { test.use({ viewport: { width: 375, height: 800 } }); - test.beforeEach(async ({ page }, testInfo) => { - // Skip on desktop - mobile bar only appears on mobile viewports - test.skip(/desktop/i.test(testInfo.project.name), 'Mobile bar tests require mobile viewport'); + test.beforeEach(async ({ page }) => { await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default' }] }); await page.goto('/setup'); }); diff --git a/app/src/__tests__/e2e/functional/auth.spec.ts b/app/src/__tests__/e2e/functional/auth.spec.ts index a0fc11c9..a4cc5c98 100644 --- a/app/src/__tests__/e2e/functional/auth.spec.ts +++ b/app/src/__tests__/e2e/functional/auth.spec.ts @@ -40,8 +40,7 @@ test.describe('@functional Authentication', () => { expect(errors).toEqual([]); }); - test('successful logout returns to login without hook errors', async ({ page }) => { - if (!/desktop/i.test(test.info().project.name)) test.skip(); + test('successful logout returns to login without hook errors', { tag: '@desktop' }, async ({ page }) => { const consoleErrors: string[] = []; page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); }); diff --git a/app/src/__tests__/e2e/functional/custom-rules-empty-groups.spec.ts b/app/src/__tests__/e2e/functional/custom-rules-empty-groups.spec.ts index 875bef2b..42068e22 100644 --- a/app/src/__tests__/e2e/functional/custom-rules-empty-groups.spec.ts +++ b/app/src/__tests__/e2e/functional/custom-rules-empty-groups.spec.ts @@ -23,12 +23,8 @@ const withOneRule = { }, }; -test.describe('@functional custom rules keep empty groups visible', () => { - // eslint-disable-next-line no-empty-pattern - test.beforeEach(({}, testInfo) => { - test.skip(!/chromium-desktop/i.test(testInfo.project.name), 'group folders are exercised on desktop'); - }); - +// Group folders are exercised on desktop. +test.describe('@functional custom rules keep empty groups visible', { tag: '@desktop' }, () => { test('renders registry groups when the profile has no custom rules', async ({ page }) => { await registerMocks(page, { authenticated: true, customProfiles: [withoutRules] }); await page.goto('/custom-rules'); diff --git a/app/src/__tests__/e2e/functional/custom-rules-group-reorder.spec.ts b/app/src/__tests__/e2e/functional/custom-rules-group-reorder.spec.ts index 3ed18fcf..0eb8fe13 100644 --- a/app/src/__tests__/e2e/functional/custom-rules-group-reorder.spec.ts +++ b/app/src/__tests__/e2e/functional/custom-rules-group-reorder.spec.ts @@ -19,12 +19,8 @@ const profile = { }, }; -test.describe('@functional custom rules group reorder', () => { - // eslint-disable-next-line no-empty-pattern - test.beforeEach(({}, testInfo) => { - test.skip(!/chromium-desktop/i.test(testInfo.project.name), 'pointer-drag reorder is exercised on desktop'); - }); - +// Pointer-drag reorder is exercised on desktop. +test.describe('@functional custom rules group reorder', { tag: '@desktop' }, () => { test('renders groups in registry order (not alphabetical)', async ({ page }) => { await registerMocks(page, { authenticated: true, customProfiles: [profile] }); await page.goto('/custom-rules'); diff --git a/app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts b/app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts index f8deedc9..fe1fb512 100644 --- a/app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts +++ b/app/src/__tests__/e2e/functional/custom-rules-paste-target.spec.ts @@ -39,9 +39,8 @@ test.describe('@functional custom rules add box exposes a pasteable input', () = expect(inputBox.width).toBeGreaterThan(controlBox.width * 0.5); }); - test('a real right-click on the empty field reaches the input (desktop)', async ({ page }) => { - test.skip(!/chromium-desktop/i.test(test.info().project.name), 'context menus are a desktop pointer path'); - + // Context menus are a desktop pointer path. + test('a real right-click on the empty field reaches the input', { tag: '@desktop' }, async ({ page }) => { await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { custom_rules: [] } }], diff --git a/app/src/__tests__/e2e/functional/login-advanced.spec.ts b/app/src/__tests__/e2e/functional/login-advanced.spec.ts index 925b1f28..af03574b 100644 --- a/app/src/__tests__/e2e/functional/login-advanced.spec.ts +++ b/app/src/__tests__/e2e/functional/login-advanced.spec.ts @@ -1,5 +1,4 @@ -import { expect } from '@playwright/test'; -import { desktopOnly as test } from '../utils/desktopOnly'; +import { test, expect } from '@playwright/test'; import { AUTH_TOAST_IDS } from '../../../lib/authToasts'; import { installWebAuthnSuccessStub, installWebAuthnErrorStub } from '../utils/webauthn'; @@ -11,7 +10,7 @@ async function ensurePasswordMode(page: import('@playwright/test').Page) { } } -test.describe('Login advanced flows (desktop only)', () => { +test.describe('Login advanced flows', { tag: '@desktop' }, () => { test('TOTP required then success with OTP', async ({ page }) => { let authed = false; diff --git a/app/src/__tests__/e2e/functional/login-basic.spec.ts b/app/src/__tests__/e2e/functional/login-basic.spec.ts index f6aac01c..eb1490e2 100644 --- a/app/src/__tests__/e2e/functional/login-basic.spec.ts +++ b/app/src/__tests__/e2e/functional/login-basic.spec.ts @@ -1,9 +1,8 @@ -import { expect } from '@playwright/test'; -import { desktopOnly as test } from '../utils/desktopOnly'; +import { test, expect } from '@playwright/test'; import { registerMocks } from '../../mocks/registerMocks'; import { AUTH_TOAST_IDS } from '../../../lib/authToasts'; -test.describe('Login basic flows (desktop only)', () => { +test.describe('Login basic flows', { tag: '@desktop' }, () => { test('successful password login shows toast and redirects', async ({ page }) => { // Dynamic auth state for account/profile routes let authed = false; diff --git a/app/src/__tests__/e2e/functional/logout.spec.ts b/app/src/__tests__/e2e/functional/logout.spec.ts index b8b24d5d..009ba9cf 100644 --- a/app/src/__tests__/e2e/functional/logout.spec.ts +++ b/app/src/__tests__/e2e/functional/logout.spec.ts @@ -1,11 +1,10 @@ -import { expect } from '@playwright/test'; -import { desktopOnly as test } from '../utils/desktopOnly'; +import { test, expect } from '@playwright/test'; import { AUTH_TOAST_IDS } from '../../../lib/authToasts'; import { registerMocks } from '../../mocks/registerMocks'; // Covers manual logout via UI and ensures single success toast + redirect. -test.describe('Logout flows (desktop only)', () => { +test.describe('Logout flows', { tag: '@desktop' }, () => { test('Manual logout (force helper) from authenticated home redirects to login and shows toast', async ({ page }) => { await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Main' }] }); diff --git a/app/src/__tests__/e2e/functional/session-limit.spec.ts b/app/src/__tests__/e2e/functional/session-limit.spec.ts index 52275ceb..194dc210 100644 --- a/app/src/__tests__/e2e/functional/session-limit.spec.ts +++ b/app/src/__tests__/e2e/functional/session-limit.spec.ts @@ -1,10 +1,9 @@ -import { expect } from '@playwright/test'; -import { desktopOnly as test } from '../utils/desktopOnly'; +import { test, expect } from '@playwright/test'; import { AUTH_TOAST_IDS } from '../../../lib/authToasts'; // Scenario: user hits session limit (429 with specific error), sees dialog, cancels -> remains on login, no success toast. -test.describe('Session limit dialog cancel flow (desktop only)', () => { +test.describe('Session limit dialog cancel flow', { tag: '@desktop' }, () => { test('Cancel maintains login state without authenticating', async ({ page }) => { const authed = false; diff --git a/app/src/__tests__/e2e/functional/settings-tooltip-tap.spec.ts b/app/src/__tests__/e2e/functional/settings-tooltip-tap.spec.ts index a9c208e5..7c134314 100644 --- a/app/src/__tests__/e2e/functional/settings-tooltip-tap.spec.ts +++ b/app/src/__tests__/e2e/functional/settings-tooltip-tap.spec.ts @@ -4,9 +4,8 @@ import { registerMocks } from '../../mocks/registerMocks'; // #127: the shared Tooltip was hover-only, so the (i) info buttons did nothing // on touch devices. Taps must now toggle the tooltip, and tapping elsewhere // must dismiss it. -test.describe('Settings retention tooltip on touch', () => { - test.beforeEach(async ({ page, isMobile }) => { - test.skip(!isMobile, 'touch interaction is mobile-only'); +test.describe('Settings retention tooltip on touch', { tag: '@mobile' }, () => { + test.beforeEach(async ({ page }) => { await registerMocks(page, { authenticated: true }); }); diff --git a/app/src/__tests__/e2e/functional/setup-ip-tooltip.spec.ts b/app/src/__tests__/e2e/functional/setup-ip-tooltip.spec.ts index e9420bfc..1b9be50b 100644 --- a/app/src/__tests__/e2e/functional/setup-ip-tooltip.spec.ts +++ b/app/src/__tests__/e2e/functional/setup-ip-tooltip.spec.ts @@ -6,9 +6,8 @@ import { registerMocks } from '../../mocks/registerMocks'; // tooltip and must NOT trigger the copy action; tapping the address value must // still copy. Copy always raises a toast (success or failure), so "no toast" // proves copy was not triggered. -test.describe('Setup IP info tooltip on touch', () => { - test.beforeEach(async ({ page, isMobile }) => { - test.skip(!isMobile, 'touch interaction is mobile-only'); +test.describe('Setup IP info tooltip on touch', { tag: '@mobile' }, () => { + test.beforeEach(async ({ page }) => { await registerMocks(page, { authenticated: true }); }); diff --git a/app/src/__tests__/e2e/layout/custom-rules-delete-modal.spec.ts b/app/src/__tests__/e2e/layout/custom-rules-delete-modal.spec.ts index 74f23133..69353aae 100644 --- a/app/src/__tests__/e2e/layout/custom-rules-delete-modal.spec.ts +++ b/app/src/__tests__/e2e/layout/custom-rules-delete-modal.spec.ts @@ -5,10 +5,8 @@ import { registerMocks } from '../../mocks/registerMocks'; // group name is very long (QA issue #634 — "Overflow issue in the Delete group // modal when group name is too long"). The interpolated name must wrap/break // inside the dialog rather than spill past its right edge. -(test.describe as typeof test.describe)('@layout Custom Rules delete group modal long name', () => { +test.describe('@layout Custom Rules delete group modal long name', { tag: '@desktop' }, () => { test('delete confirmation stays within the modal', async ({ page }) => { - test.skip(!/chromium-desktop/i.test(test.info().project.name), 'desktop layout regression'); - const longGroup = 'StuffsadsadadasdasdasdasdasdasdasdasdasdasdaszdasdsddsdsdsFddgd'; await registerMocks(page, { authenticated: true, diff --git a/app/src/__tests__/e2e/layout/custom-rules-edit-modal.spec.ts b/app/src/__tests__/e2e/layout/custom-rules-edit-modal.spec.ts index 0b70172f..0b2425de 100644 --- a/app/src/__tests__/e2e/layout/custom-rules-edit-modal.spec.ts +++ b/app/src/__tests__/e2e/layout/custom-rules-edit-modal.spec.ts @@ -5,10 +5,8 @@ import { registerMocks } from '../../mocks/registerMocks'; // is very long (QA issue #634 — "The Edit modal breaks when the group name is too // long"). The group