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/.auth-storage.json b/app/src/__tests__/e2e/.auth-storage.json deleted file mode 100644 index 5a656d77..00000000 --- a/app/src/__tests__/e2e/.auth-storage.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "cookies": [], - "origins": [ - { - "origin": "http://localhost:5173", - "localStorage": [ - { - "name": "isAuthenticated", - "value": "true" - } - ] - } - ] -} \ No newline at end of file diff --git a/app/src/__tests__/e2e/account/backup-restore.spec.ts b/app/src/__tests__/e2e/account/backup-restore.spec.ts index a655220b..9c84c008 100644 --- a/app/src/__tests__/e2e/account/backup-restore.spec.ts +++ b/app/src/__tests__/e2e/account/backup-restore.spec.ts @@ -100,7 +100,8 @@ async function uploadJsonToDropzone(page: import('@playwright/test').Page, paylo // Test 1: Happy-path export — specRef E1, E13-E15 // --------------------------------------------------------------------------- -test.describe('Backup & Restore — Export', () => { +// Dialog flows are engine-agnostic; run them once, on the desktop project. +test.describe('Backup & Restore — Export', { tag: '@desktop' }, () => { test('happy-path export triggers file download', async ({ page }) => { await setupBaseMocks(page); @@ -172,7 +173,7 @@ test.describe('Backup & Restore — Export', () => { // Test 2: Happy-path import — specRef I1, I4, I19-I20 // --------------------------------------------------------------------------- -test.describe('Backup & Restore — Import', () => { +test.describe('Backup & Restore — Import', { tag: '@desktop' }, () => { test('happy-path import shows results in step 3 and Done closes dialog', async ({ page }) => { await setupBaseMocks(page); @@ -319,10 +320,10 @@ test.describe('Backup & Restore — Import', () => { // action buttons off-screen because DialogBody dropped its height cap at >=640px // wide (sm:max-h-none). On a short-but-wide viewport the header + submit button // must stay in view, with the body scrolling internally. - test('import confirm step stays within a short laptop viewport (no cutoff)', async ({ page }) => { - // Short-but-wide viewport ~ a 13" laptop content area. Layout bugs like this - // are engine-specific — running this under the WebKit (iphone15pro-dark) - // project exercises the engine that shifts where Chromium does not. + // Short-but-wide viewport ~ a 13" laptop content area. Layout bugs like this + // are engine-specific — the extra @ios tag also runs it under WebKit, the + // engine that shifts where Chromium does not. + test('import confirm step stays within a short laptop viewport (no cutoff)', { tag: '@ios' }, async ({ page }) => { await page.setViewportSize({ width: 1000, height: 560 }); await setupBaseMocks(page); diff --git a/app/src/__tests__/e2e/account/changeEmail.spec.ts b/app/src/__tests__/e2e/account/changeEmail.spec.ts index 8a2f1267..ac570346 100644 --- a/app/src/__tests__/e2e/account/changeEmail.spec.ts +++ b/app/src/__tests__/e2e/account/changeEmail.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; const mockAccount = { account_id: 'abc', @@ -12,7 +13,8 @@ const setupRoutes = async ( page: import('@playwright/test').Page, onPatch: (body: string | null) => void, ) => { - await page.addInitScript(() => { window.localStorage.setItem('AUTH_KEY', 'true'); }); + await registerMocks(page, { authenticated: true, accountOverride: mockAccount }); + // Registered after registerMocks so these win over its catch-all. await page.route('**/api/v1/accounts', async route => { if (route.request().method() === 'PATCH') { onPatch(route.request().postData()); @@ -20,15 +22,10 @@ const setupRoutes = async ( } return route.continue(); }); - await page.route('**/api/v1/accounts/current', async route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockAccount) }); - }); - await page.route('**/api/v1/webauthn/passkeys', async route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - }); - await page.route('**/api/v1/profiles', async route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - }); + await page.route('**/api/v1/sub', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'active', plan: 'plus', active_until: '2027-01-01T00:00:00Z' }) })); + await page.route('**/api/v1/webauthn/passkeys', route => + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })); }; // Happy path: two-step flow shows the lockout warning, tolerates a @@ -36,14 +33,10 @@ const setupRoutes = async ( test('email change requires confirm step and sends patch on match', async ({ page }) => { let patchBody: string | null = null; await setupRoutes(page, body => { patchBody = body; }); - await page.goto('http://localhost:5173/account-preferences'); + await page.goto('/account-preferences'); const changeBtn = page.getByRole('button', { name: /Change email/i }); - const isVisible = await changeBtn.isVisible().catch(() => false); - if (!isVisible) { - test.skip(true, 'Change email button not reachable - app server likely not started'); - return; - } + await expect(changeBtn).toBeVisible({ timeout: 10_000 }); await changeBtn.click(); await page.getByPlaceholder('new@example.com').fill('new@example.com'); @@ -69,14 +62,10 @@ test('email change requires confirm step and sends patch on match', async ({ pag test('email change blocked while confirm entry mismatches', async ({ page }) => { let patchCalled = false; await setupRoutes(page, () => { patchCalled = true; }); - await page.goto('http://localhost:5173/account-preferences'); + await page.goto('/account-preferences'); const changeBtn = page.getByRole('button', { name: /Change email/i }); - const isVisible = await changeBtn.isVisible().catch(() => false); - if (!isVisible) { - test.skip(true, 'Change email button not reachable - app server likely not started'); - return; - } + await expect(changeBtn).toBeVisible({ timeout: 10_000 }); await changeBtn.click(); await page.getByPlaceholder('new@example.com').fill('new@example.com'); diff --git a/app/src/__tests__/e2e/account/updatePassword.spec.ts b/app/src/__tests__/e2e/account/updatePassword.spec.ts index 12921ce0..9f04e357 100644 --- a/app/src/__tests__/e2e/account/updatePassword.spec.ts +++ b/app/src/__tests__/e2e/account/updatePassword.spec.ts @@ -1,35 +1,37 @@ import { test, expect } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; -// Intercepts account patch + current get to assert JSON Patch sequence -test('password update sends test+replace operations', async ({ page }) => { - let patchPayload: { updates: { operation: string; path: string; value?: string }[] } | null = null; - // Pre-auth via localStorage before app scripts run - await page.addInitScript(() => { window.localStorage.setItem('AUTH_KEY', 'true'); }); +const mockAccount = { account_id: 'abc', email: 'user@example.com', email_verified: true, auth_methods: ['password'], mfa: { totp: { enabled: false } } }; + +/** Base mocks for the account page plus a PATCH /accounts interceptor. */ +async function setupRoutes(page: import('@playwright/test').Page, onPatch: (body: string | null) => void) { + await registerMocks(page, { authenticated: true, accountOverride: mockAccount }); + // Registered after registerMocks so these win over its catch-all. await page.route('**/api/v1/accounts', async route => { if (route.request().method() === 'PATCH') { - const body = route.request().postData(); - patchPayload = body ? JSON.parse(body) : null; + onPatch(route.request().postData()); return route.fulfill({ status: 200, body: '' }); } return route.continue(); }); - await page.route('**/api/v1/accounts/current', async route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account_id: 'abc', mfa: { totp: { enabled: false } } }) }); - }); - await page.route('**/api/v1/profiles', async route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - }); - await page.goto('http://localhost:5173/account-preferences'); + await page.route('**/api/v1/sub', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'active', plan: 'plus', active_until: '2027-01-01T00:00:00Z' }) })); + await page.route('**/api/v1/webauthn/passkeys', route => + route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })); +} - // Detect availability; skip gracefully if app not reachable - const updateBtn = await page.getByRole('button', { name: /Update password/i }); - const isVisible = await updateBtn.isVisible().catch(() => false); - if (!isVisible) { - // Skip conditionally; Playwright expects boolean first param - test.skip(true, 'Update password button not reachable - app server likely not started'); - return; - } +async function openUpdatePassword(page: import('@playwright/test').Page) { + await page.goto('/account-preferences'); + const updateBtn = page.getByRole('button', { name: /Update password/i }); + await expect(updateBtn).toBeVisible({ timeout: 10_000 }); await updateBtn.click(); +} + +// Intercepts account patch + current get to assert JSON Patch sequence +test('password update sends test+replace operations', async ({ page }) => { + let patchPayload: { updates: { operation: string; path: string; value?: string }[] } | null = null; + await setupRoutes(page, body => { patchPayload = body ? JSON.parse(body) : null; }); + await openUpdatePassword(page); // Fill fields await page.getByLabel('Old password').fill('OldPassword123!'); @@ -52,28 +54,8 @@ test('password update sends test+replace operations', async ({ page }) => { // Negative flow: missing old password should not send patch test('password update blocked without old password', async ({ page }) => { let patchCalled = false; - await page.addInitScript(() => { window.localStorage.setItem('AUTH_KEY', 'true'); }); - await page.route('**/api/v1/accounts', async route => { - if (route.request().method() === 'PATCH') { - patchCalled = true; - return route.fulfill({ status: 200, body: '' }); - } - return route.continue(); - }); - await page.route('**/api/v1/accounts/current', async route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account_id: 'abc', mfa: { totp: { enabled: false } } }) }); - }); - await page.route('**/api/v1/profiles', async route => { - return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - }); - await page.goto('http://localhost:5173/account-preferences'); - const updateBtn = await page.getByRole('button', { name: /Update password/i }); - const isVisible = await updateBtn.isVisible().catch(() => false); - if (!isVisible) { - test.skip(true, 'Update password button not reachable - app server likely not started'); - return; - } - await updateBtn.click(); + await setupRoutes(page, () => { patchCalled = true; }); + await openUpdatePassword(page); await page.getByLabel('New password').fill('NewPassword123!'); await page.getByLabel('Confirm password').fill('NewPassword123!'); await page.getByRole('button', { name: /Save change/i }).click(); diff --git a/app/src/__tests__/e2e/auth.setup.ts b/app/src/__tests__/e2e/auth.setup.ts deleted file mode 100644 index fb2edb02..00000000 --- a/app/src/__tests__/e2e/auth.setup.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Bootstrap auth storage state for dependent projects. -// This runs in the dedicated 'auth-setup' project (see playwright.config.ts) and -// produces the storage state file consumed by mobile projects via `storageState`. -// Keeping this minimal avoids coupling to network mocks (other tests mock APIs). -import { test } from '@playwright/test'; -import { AUTH_KEY } from '@/lib/consts'; - -test.describe('@setup auth storage', () => { - test('create auth storage file', async ({ page, context }) => { - // Navigate to a public page first so no protected loaders interfere. - await page.goto('/login'); - // Seed localStorage auth flag the app expects. - await page.evaluate((key) => localStorage.setItem(key, 'true'), AUTH_KEY); - // Persist resulting storage state for reuse. - await context.storageState({ path: 'src/__tests__/e2e/.auth-storage.json' }); - }); -}); diff --git a/app/src/__tests__/e2e/auth.spec.ts b/app/src/__tests__/e2e/auth.spec.ts deleted file mode 100644 index 673d2258..00000000 --- a/app/src/__tests__/e2e/auth.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -// moved from src/tests/e2e/auth.spec.ts -import { test, expect } from '@playwright/test'; - -test.describe('Authentication', () => { - test.beforeEach(async ({ page }) => { - // Ensure clean auth state each test - await page.addInitScript(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - document.cookie.split(';').forEach(c => { - const eqPos = c.indexOf('='); - const name = eqPos > -1 ? c.substr(0, eqPos) : c; - document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; - }); - }); - }); - - test('redirects unauthenticated user to /login when visiting protected route', async ({ page }) => { - await page.goto('/home'); - // Wait for potential client redirect logic - await page.waitForLoadState('networkidle'); - // Accept either immediate redirect or soft navigation via router push - await expect.poll(async () => page.url()).toMatch(/\/login$/); - }); - - test('login page renders without console errors (mobile)', async ({ page }) => { - const errors: string[] = []; - page.on('console', msg => { - if (msg.type() === 'error') errors.push(msg.text()); - }); - await page.goto('/login'); - // Prefer testid vs dynamic button text - const toggle = page.getByTestId('btn-login-toggle-mode'); - await expect(toggle).toBeVisible(); - // Ensure both forms can be toggled without errors - await toggle.click(); // switch mode - await expect(page.getByTestId(/login-(passkey|password)-form/)).toBeVisible(); - expect(errors).toEqual([]); - }); -}); diff --git a/app/src/__tests__/e2e/connection/connectionStatusHeader.spec.ts b/app/src/__tests__/e2e/connection/connectionStatusHeader.spec.ts index ee526189..42eaa393 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')); }); @@ -92,21 +88,26 @@ test.describe('Desktop ConnectionStatusHeader', () => { await expect(page.getByTestId('conn-header-root')).toBeVisible(); }); - test('different profile state', async ({ page }) => { + test('a check answered by another profile shows Different Profile', async ({ page }) => { + // The active profile is prof1; the resolver reports p2. await mockDnsSequence(page, [ { status: 'ok', profile_id: 'p2', asn: '', asn_organization: 'Org', ip: '1.1.1.1' } ]); await page.reload(); - const badge = page.getByTestId('conn-header-badge-text'); - await expect(badge).toBeVisible(); - // Poll until it resolves to one of expected states (guard against state transition timing) - await expect.poll(async () => (await badge.textContent())?.trim() || '').toMatch(/Connected|Different Profile/i); + await expect(page.getByTestId('conn-header-badge-text')).toHaveText('Different Profile'); }); - test('disconnected 404', async ({ page }) => { + test('a check answered by the active profile shows Connected', async ({ page }) => { + await mockDnsSequence(page, [ + { status: 'ok', profile_id: 'prof1', asn: '', asn_organization: 'Org', ip: '1.1.1.1' } + ]); + await page.reload(); + await expect(page.getByTestId('conn-header-badge-text')).toHaveText('Connected'); + }); + + test('disconnected 404 shows Disconnected', async ({ page }) => { await mockDnsSequence(page, [{ status: 404, body: { error: 'disconnected' } }]); await page.reload(); - const badge = page.getByTestId('conn-header-badge-text'); - await expect(badge).toHaveText(/Disconnected|Connected/); + await expect(page.getByTestId('conn-header-badge-text')).toHaveText('Disconnected'); }); }); diff --git a/app/src/__tests__/e2e/connection/mobileConnectionStatusBar.spec.ts b/app/src/__tests__/e2e/connection/mobileConnectionStatusBar.spec.ts index 8e84b4fc..2b4d0c32 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'); }); @@ -45,5 +43,6 @@ test.describe('Mobile ConnectionStatusBar', () => { await page.reload(); const badge = page.getByTestId('conn-mobile-badge'); await expect(badge).toBeVisible(); + await expect(badge).toHaveText(/Disconnected/); }); }); 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..e1a74ff4 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; @@ -93,7 +92,7 @@ test.describe('Login advanced flows (desktop only)', () => { await expect(page).toHaveURL(/\/home$/); }); - test('Passkey login WebAuthn flow succeeds (network + no error toast)', async ({ page }) => { + test('Passkey login WebAuthn flow succeeds and redirects home', async ({ page }) => { let authed = false; let beginCalled = 0; let finishCalled = 0; @@ -124,16 +123,13 @@ test.describe('Login advanced flows (desktop only)', () => { await page.getByTestId('btn-login-toggle-mode').click(); } await page.getByTestId('input-email-passkey').fill('user@example.com'); - await page.getByTestId('btn-login-passkey-submit').click(); - - // Allow async handlers to run - await page.waitForTimeout(300); + await page.getByTestId('btn-login-passkey-submit').click(); - // Assertions: begin called exactly once and no error toast shown. - // finish endpoint may not be triggered if upstream logic short-circuits before sending payload in test env. - expect(beginCalled).toBe(1); - expect([0,1]).toContain(finishCalled); // tolerate missing finish under test constraints - await expect(page.getByTestId(AUTH_TOAST_IDS.passkeyError)).toHaveCount(0); + // begin → credentials.get (stubbed) → finish, then the authed loaders redirect home. + await page.waitForURL(/\/home$/); + expect(beginCalled).toBe(1); + expect(finishCalled).toBe(1); + await expect(page.getByTestId(AUTH_TOAST_IDS.passkeyError)).toHaveCount(0); }); test('Passkey login failure shows passkey error toast', async ({ page }) => { 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 deleted file mode 100644 index b8b24d5d..00000000 --- a/app/src/__tests__/e2e/functional/logout.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect } from '@playwright/test'; -import { desktopOnly as test } from '../utils/desktopOnly'; -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('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' }] }); - - await page.goto('/home'); - await expect(page).toHaveURL(/\/home$/); - - // Use global helper for deterministic logout since UI trigger test id not guaranteed - await page.evaluate(() => (window as unknown as { __APP_DISPATCH_EVENT__: (e: { type: string }) => void }).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout' })); - - await expect(page).toHaveURL(/\/login$/); - await expect(page.getByTestId(AUTH_TOAST_IDS.logoutSuccess)).toBeVisible(); - await expect(page.getByTestId(AUTH_TOAST_IDS.sessionExpired)).toHaveCount(0); - }); -}); diff --git a/app/src/__tests__/e2e/functional/session-expiry.spec.ts b/app/src/__tests__/e2e/functional/session-expiry.spec.ts index 4a719869..2f2d6d05 100644 --- a/app/src/__tests__/e2e/functional/session-expiry.spec.ts +++ b/app/src/__tests__/e2e/functional/session-expiry.spec.ts @@ -2,41 +2,34 @@ import { test, expect } from '@playwright/test'; import { registerMocks } from '../../mocks/registerMocks'; import { AUTH_TOAST_IDS } from '../../../lib/authToasts'; -// Failing test (initially) to reproduce session expiry UI bug. -// Expected correct behavior: upon session expiration, user is redirected to /login, -// login page content is visible, and a session expired toast appears. -// Current bug: a persistent loading screen (or non-login state) appears instead. +// The app exposes window.__APP_DISPATCH_EVENT__ so tests can trigger a forced logout +// without a real 401 round-trip. Both variants must land on /login with the login +// page rendered (not a lingering loading screen) and raise exactly one toast. -test.describe('@functional Session Expiry', () => { - test('redirects to login with session expired toast when force logout helper invoked', async ({ page }) => { - await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] }); - - // No console log dependency; production logs removed. +type ForceLogout = { type: 'auth/forceLogout'; reason?: string; toastType?: string }; +const dispatch = (page: import('@playwright/test').Page, event: ForceLogout) => + page.evaluate((e) => (window as unknown as { __APP_DISPATCH_EVENT__: (ev: unknown) => void }).__APP_DISPATCH_EVENT__(e), event); +test.describe('@functional Forced logout', () => { + test.beforeEach(async ({ page }) => { + await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] }); await page.goto('/home'); - await expect.poll(() => page.url()).toMatch(/\/home$/); - - // Wait until helper is attached (effect mounts after initial render) - await page.evaluate(() => (window as unknown as { __APP_DISPATCH_EVENT__: (e: { type: string; reason: string; toastType: string }) => void }).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout', reason: 'Session expired - please log in again.', toastType: 'error' })); + await expect(page).toHaveURL(/\/home$/); + }); - await expect.poll(() => page.url(), { timeout: 8000 }).toMatch(/\/login$/); + test('session expiry redirects to login with the session-expired toast', async ({ page }) => { + await dispatch(page, { type: 'auth/forceLogout', reason: 'Session expired - please log in again.', toastType: 'error' }); + await expect(page).toHaveURL(/\/login$/); await expect(page.getByTestId('login-page')).toBeVisible(); - - // Toast assertion by test id - await expect(page.getByTestId(AUTH_TOAST_IDS.sessionExpired)).toBeVisible(); - - // Behavior verified by URL + toast only. + await expect(page.getByTestId(AUTH_TOAST_IDS.sessionExpired)).toBeVisible(); + await expect(page.getByTestId(AUTH_TOAST_IDS.logoutSuccess)).toHaveCount(0); }); - test('session expired toast appears if loader forces logout before navigation to protected page', async ({ page }) => { - // Start unauthenticated but attempt to visit a protected route, emulate loader forcing logout (side effect already done in app code when account fetch 401 + flag) - await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Main' }] }); - await page.goto('/home'); - await expect(page).toHaveURL(/\/home$/); - // Trigger forced logout - await page.evaluate(() => (window as unknown as { __APP_DISPATCH_EVENT__: (e: { type: string; reason: string; toastType: string }) => void }).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout', reason: 'Session expired - please log in again.', toastType: 'error' })); + test('manual logout redirects to login with the logged-out toast', async ({ page }) => { + await dispatch(page, { type: 'auth/forceLogout' }); await expect(page).toHaveURL(/\/login$/); await expect(page.getByTestId('login-page')).toBeVisible(); - await expect(page.getByTestId(AUTH_TOAST_IDS.sessionExpired)).toBeVisible(); + await expect(page.getByTestId(AUTH_TOAST_IDS.logoutSuccess)).toBeVisible(); + await expect(page.getByTestId(AUTH_TOAST_IDS.sessionExpired)).toHaveCount(0); }); }); 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/blocklists-overflow.spec.ts b/app/src/__tests__/e2e/layout/blocklists-overflow.spec.ts index f33c2e43..724505bc 100644 --- a/app/src/__tests__/e2e/layout/blocklists-overflow.spec.ts +++ b/app/src/__tests__/e2e/layout/blocklists-overflow.spec.ts @@ -38,8 +38,10 @@ async function registerLongBlocklists(page: import('@playwright/test').Page) { }); } +// These tests set their own viewport, so the project's device descriptor is irrelevant; +// run once per engine (Chromium desktop + WebKit iPhone) instead of on every project. for (const vp of VIEWPORTS) { - test.describe(`@layout blocklists overflow (${vp.label})`, () => { + test.describe(`@layout blocklists overflow (${vp.label})`, { tag: ['@desktop', '@ios'] }, () => { test.beforeEach(async ({ page }) => { await registerLongBlocklists(page); await page.setViewportSize({ width: vp.width, height: vp.height }); @@ -96,7 +98,7 @@ for (const vp of VIEWPORTS) { // so a horizontally-overflowing inner container shows up as clipped content rather than // a page scrollbar. This guards against that by asserting that no element extends past // the viewport's right edge, across every blocklists tab. -test.describe('@layout blocklists no inner horizontal overflow', () => { +test.describe('@layout blocklists no inner horizontal overflow', { tag: ['@desktop', '@ios'] }, () => { test.beforeEach(async ({ page }) => { await registerMocks(page, { authenticated: true }); await page.setViewportSize({ width: 390, height: 844 }); diff --git a/app/src/__tests__/e2e/layout/blocklists-scroll.spec.ts b/app/src/__tests__/e2e/layout/blocklists-scroll.spec.ts index 78ffec98..5035a61b 100644 --- a/app/src/__tests__/e2e/layout/blocklists-scroll.spec.ts +++ b/app/src/__tests__/e2e/layout/blocklists-scroll.spec.ts @@ -36,8 +36,10 @@ async function registerBlocklistsWithOverflow(page: import('@playwright/test').P }); } +// These tests set their own viewport, so the project's device descriptor is irrelevant; +// run once per engine (Chromium desktop + WebKit iPhone) instead of on every project. for (const vp of VIEWPORTS) { - test.describe(`@layout blocklists scroll reach (${vp.label})`, () => { + test.describe(`@layout blocklists scroll reach (${vp.label})`, { tag: ['@desktop', '@ios'] }, () => { test.beforeEach(async ({ page }) => { await registerBlocklistsWithOverflow(page); await page.setViewportSize({ width: vp.width, height: vp.height }); diff --git a/app/src/__tests__/e2e/layout/content-centering.spec.ts b/app/src/__tests__/e2e/layout/content-centering.spec.ts index 95c631ff..db1478a8 100644 --- a/app/src/__tests__/e2e/layout/content-centering.spec.ts +++ b/app/src/__tests__/e2e/layout/content-centering.spec.ts @@ -21,7 +21,9 @@ const VIEWPORTS = [ const PROTECTED_ROUTES = ['/setup', '/blocklists', '/home', '/settings', '/custom-rules', '/query-logs']; -test.describe('@layout Content centering - body styles', () => { +// These tests set their own viewport, so the project's device descriptor is irrelevant; +// run once per engine (Chromium desktop + WebKit iPhone) instead of on every project. +test.describe('@layout Content centering - body styles', { tag: ['@desktop', '@ios'] }, () => { test('body element should not have centering flex styles', async ({ page }) => { await registerMocks(page, { authenticated: true }); await page.goto('/setup'); @@ -72,67 +74,39 @@ test.describe('@layout Content centering - body styles', () => { }); }); -test.describe('@layout Content centering - app content area', () => { +test.describe('@layout Content centering - viewport matrix', { tag: ['@desktop', '@ios'] }, () => { test.beforeEach(async ({ page }) => { await registerMocks(page, { authenticated: true }); }); - test('app-content fills full viewport width on mobile', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('/setup'); - - const appContent = page.getByTestId('app-content'); - await expect(appContent).toBeVisible(); - - const box = await appContent.boundingBox(); - const viewport = page.viewportSize()!; - - // app-content should start at x=0 (no left offset) - expect(box!.x).toBe(0); - // app-content should span full viewport width - expect(box!.width).toBeGreaterThanOrEqual(viewport.width - 1); - }); - + // One test per viewport keeps a fresh page for each; WebKit is unreliable across + // repeated navigations in a single page. for (const vp of VIEWPORTS) { - test(`content area starts at left edge on ${vp.name}`, async ({ page }) => { + test(`app-content starts at the left edge and setup-container is centered on ${vp.name}`, async ({ page }) => { await page.setViewportSize({ width: vp.width, height: vp.height }); await page.goto('/setup'); const appContent = page.getByTestId('app-content'); - const box = await appContent.boundingBox(); - - // Content should start at x=0, not offset to the right - expect(box!.x, `app-content x offset on ${vp.name}`).toBe(0); - }); - } -}); - -test.describe('@layout Content centering - symmetric margins', () => { - test.beforeEach(async ({ page }) => { - await registerMocks(page, { authenticated: true }); - }); - - for (const vp of VIEWPORTS.filter(v => v.width < 1280)) { - test(`setup-container has symmetric margins on ${vp.name}`, async ({ page }) => { - await page.setViewportSize({ width: vp.width, height: vp.height }); - await page.goto('/setup'); - - const container = page.getByTestId('setup-container'); - await expect(container).toBeVisible(); - - const box = await container.boundingBox(); - const viewport = page.viewportSize()!; - - const leftMargin = box!.x; - const rightMargin = viewport.width - (box!.x + box!.width); - - // Left and right margins should be roughly equal (within 30px tolerance) - // This accounts for px-4 (16px) padding which may round differently - const marginDiff = Math.abs(leftMargin - rightMargin); - expect( - marginDiff, - `Asymmetric margins on ${vp.name}: left=${leftMargin.toFixed(0)}px, right=${rightMargin.toFixed(0)}px, diff=${marginDiff.toFixed(0)}px` - ).toBeLessThan(30); + await expect(appContent).toBeVisible(); + const contentBox = (await appContent.boundingBox())!; + // Content should start at x=0, not offset to the right, and span the viewport. + expect(contentBox.x, `app-content x offset on ${vp.name}`).toBe(0); + expect(contentBox.width, `app-content width on ${vp.name}`).toBeGreaterThanOrEqual(vp.width - 1); + + // Below the desktop breakpoint the setup container is centered with symmetric margins. + if (vp.width < 1280) { + const container = page.getByTestId('setup-container'); + await expect(container, `setup-container on ${vp.name}`).toBeVisible(); + const box = (await container.boundingBox())!; + const leftMargin = box.x; + const rightMargin = vp.width - (box.x + box.width); + // 30px tolerance accounts for px-4 (16px) padding rounding differently per side. + const marginDiff = Math.abs(leftMargin - rightMargin); + expect( + marginDiff, + `Asymmetric margins on ${vp.name}: left=${leftMargin.toFixed(0)}px, right=${rightMargin.toFixed(0)}px, diff=${marginDiff.toFixed(0)}px` + ).toBeLessThan(30); + } }); } 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