Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 0 additions & 14 deletions app/src/__tests__/e2e/.auth-storage.json

This file was deleted.

13 changes: 7 additions & 6 deletions app/src/__tests__/e2e/account/backup-restore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
33 changes: 11 additions & 22 deletions app/src/__tests__/e2e/account/changeEmail.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { registerMocks } from '../../mocks/registerMocks';

const mockAccount = {
account_id: 'abc',
Expand All @@ -12,38 +13,30 @@ 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());
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(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
// casing/whitespace variant in the confirm entry, and sends the PATCH.
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');
Expand All @@ -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');
Expand Down
70 changes: 26 additions & 44 deletions app/src/__tests__/e2e/account/updatePassword.spec.ts
Original file line number Diff line number Diff line change
@@ -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!');
Expand All @@ -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();
Expand Down
17 changes: 0 additions & 17 deletions app/src/__tests__/e2e/auth.setup.ts

This file was deleted.

40 changes: 0 additions & 40 deletions app/src/__tests__/e2e/auth.spec.ts

This file was deleted.

33 changes: 17 additions & 16 deletions app/src/__tests__/e2e/connection/connectionStatusHeader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,16 @@ async function mockDnsSequence(page: Page, responses: Record<string, unknown>[])
});
}

// 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'));
});
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ async function mockDnsSequence(page: Page, responses: Record<string, unknown>[])
});
}

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');
});
Expand All @@ -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/);
});
});
3 changes: 1 addition & 2 deletions app/src/__tests__/e2e/functional/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()); });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading