diff --git a/tests/core/domain-dns-management.spec.ts b/tests/core/domain-dns-management.spec.ts index 5df76839..0001c78c 100644 --- a/tests/core/domain-dns-management.spec.ts +++ b/tests/core/domain-dns-management.spec.ts @@ -6,6 +6,7 @@ import { waitForModal, waitForResourceInUI, } from '../utils/wait-helpers'; +import { getStorageStateAuthHeaders } from '../utils/api-helpers'; /** * Domain & DNS Management Workflow @@ -71,7 +72,7 @@ test.describe('Domain & DNS Management', () => { await test.step('Clean up domain via API', async () => { if (createdId) { - await page.request.delete(`/api/v1/domains/${createdId}`); + await page.request.delete(`/api/v1/domains/${createdId}`, { headers: getStorageStateAuthHeaders() }); } }); }); @@ -81,6 +82,7 @@ test.describe('Domain & DNS Management', () => { const domainName = generateDomainName('delete-domain'); const createResponse = await page.request.post('/api/v1/domains', { data: { name: domainName }, + headers: getStorageStateAuthHeaders(), }); const created = await createResponse.json(); const domainId = created.uuid || created.id; @@ -90,31 +92,32 @@ test.describe('Domain & DNS Management', () => { }); await test.step('Confirm domain card is visible', async () => { + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForLoadingComplete(page); await waitForResourceInUI(page, domainName); await expect(page.getByRole('heading', { name: domainName })).toBeVisible(); }); await test.step('Delete domain from card', async () => { - const domainCard = page.locator('div').filter({ - has: page.getByRole('heading', { name: domainName }), - }).first(); - await expect(domainCard).toBeVisible(); - - const deleteButton = domainCard.getByRole('button', { name: /delete/i }).first(); + const heading = page.getByRole('heading', { name: domainName }); + const deleteButton = heading + .locator('xpath=ancestor::div[contains(@class, "bg-dark-card")]') + .getByRole('button', { name: /delete/i }); await expect(deleteButton).toBeVisible(); page.once('dialog', async (dialog) => { await dialog.accept(); }); - const deleteResponse = clickAndWaitForResponse( - page, - deleteButton, - new RegExp(`/api/v1/domains/${domainId}`), - { status: 200 } + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/v1/domains/') && + resp.request().method() === 'DELETE', + { timeout: 15000 } ); - await deleteResponse; + await deleteButton.click(); + await responsePromise; }); }); @@ -143,7 +146,7 @@ test.describe('Domain & DNS Management', () => { }); await test.step('Open add provider dialog', async () => { - await page.request.get('/api/v1/dns-providers/types'); + await page.request.get('/api/v1/dns-providers/types', { headers: getStorageStateAuthHeaders() }); const addButton = page.getByRole('button', { name: /add.*provider/i }).first(); await addButton.click(); await waitForModal(page, /provider/i); @@ -182,12 +185,14 @@ test.describe('Domain & DNS Management', () => { }); await test.step('Delete provider via API', async () => { - await page.request.delete(`/api/v1/dns-providers/${id}`); + await page.request.delete(`/api/v1/dns-providers/${id}`, { headers: getStorageStateAuthHeaders() }); }); await test.step('Verify provider card removed', async () => { + // Navigate away first to clear any in-memory SWR cache + await page.goto('about:blank'); await navigateToDnsProviders(page); - await expect(page.getByRole('heading', { name })).toHaveCount(0); + await expect(page.getByRole('heading', { name })).toHaveCount(0, { timeout: 15000 }); }); }); diff --git a/tests/core/proxy-hosts.spec.ts b/tests/core/proxy-hosts.spec.ts index 6c0ba73c..441726d1 100644 --- a/tests/core/proxy-hosts.spec.ts +++ b/tests/core/proxy-hosts.spec.ts @@ -274,10 +274,9 @@ test.describe('Proxy Hosts - CRUD Operations', () => { }); await test.step('Enter invalid domain', async () => { - const domainInput = page.locator('#domain-names').or(page.getByLabel(/domain/i)); - await domainInput.first().fill('not a valid domain!'); - - // Tab away to trigger validation + const domainCombobox = page.locator('#domain-names'); + await domainCombobox.click(); + await page.keyboard.type('not a valid domain!'); await page.keyboard.press('Tab'); }); @@ -333,9 +332,11 @@ test.describe('Proxy Hosts - CRUD Operations', () => { const nameInput = page.locator('#proxy-name'); await nameInput.fill(`Test Host ${Date.now()}`); - // Domain - const domainInput = page.locator('#domain-names'); - await domainInput.fill(hostConfig.domain); + // Domain (combobox component) + const domainCombobox = page.locator('#domain-names'); + await domainCombobox.click(); + await page.keyboard.type(hostConfig.domain); + await page.keyboard.press('Tab'); // Dismiss the "New Base Domain Detected" dialog if it appears after domain input await dismissDomainDialog(page); @@ -428,7 +429,9 @@ test.describe('Proxy Hosts - CRUD Operations', () => { await test.step('Fill in fields with SSL options', async () => { await page.locator('#proxy-name').fill(`SSL Test ${Date.now()}`); - await page.locator('#domain-names').fill(hostConfig.domain); + await page.locator('#domain-names').click(); + await page.keyboard.type(hostConfig.domain); + await page.keyboard.press('Tab'); await page.locator('#forward-host').fill(hostConfig.forwardHost); await page.locator('#forward-port').clear(); await page.locator('#forward-port').fill(String(hostConfig.forwardPort)); @@ -476,7 +479,9 @@ test.describe('Proxy Hosts - CRUD Operations', () => { await test.step('Fill form with WebSocket enabled', async () => { await page.locator('#proxy-name').fill(`WS Test ${Date.now()}`); - await page.locator('#domain-names').fill(hostConfig.domain); + await page.locator('#domain-names').click(); + await page.keyboard.type(hostConfig.domain); + await page.keyboard.press('Tab'); await page.locator('#forward-host').fill(hostConfig.forwardHost); await page.locator('#forward-port').clear(); await page.locator('#forward-port').fill(String(hostConfig.forwardPort)); @@ -702,15 +707,20 @@ test.describe('Proxy Hosts - CRUD Operations', () => { await expect(page.getByRole('dialog')).toBeVisible(); // Wait for edit modal to open const domainInput = page.locator('#domain-names'); - const originalDomain = await domainInput.inputValue(); - // Append a test suffix + // Clear existing domain and type new one (combobox component) const newDomain = `test-${Date.now()}.example.com`; - await domainInput.clear(); - await domainInput.fill(newDomain); + await domainInput.click(); + await page.keyboard.press('Control+a'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(newDomain); + await page.keyboard.press('Tab'); - // Save - await page.getByRole('button', { name: /save/i }).click(); + // Dismiss the "New Base Domain Detected" dialog if it appears + await dismissDomainDialog(page); + + // Save — use specific selector to avoid strict mode violation with domain dialog buttons + await page.getByTestId('proxy-host-save').or(page.getByRole('button', { name: /^save$/i })).first().click(); await waitForLoadingComplete(page); // Verify update (check for new domain or revert) diff --git a/tests/dns-provider-types.spec.ts b/tests/dns-provider-types.spec.ts index c3f54380..522650cb 100644 --- a/tests/dns-provider-types.spec.ts +++ b/tests/dns-provider-types.spec.ts @@ -7,6 +7,8 @@ import { waitForLoadingComplete, } from './utils/wait-helpers'; import { getFormFieldByLabel } from './utils/ui-helpers'; +import { STORAGE_STATE } from './constants'; +import { readFileSync } from 'fs'; /** * DNS Provider Types E2E Tests @@ -18,14 +20,35 @@ import { getFormFieldByLabel } from './utils/ui-helpers'; * - Provider selector in UI */ +function getAuthHeaders(): Record { + try { + const state = JSON.parse(readFileSync(STORAGE_STATE, 'utf-8')); + for (const origin of state.origins ?? []) { + for (const entry of origin.localStorage ?? []) { + if (entry.name === 'charon_auth_token' && entry.value) { + return { Authorization: `Bearer ${entry.value}` }; + } + } + } + for (const cookie of state.cookies ?? []) { + if (cookie.name === 'auth_token' && cookie.value) { + return { Authorization: `Bearer ${cookie.value}` }; + } + } + } catch { /* no-op */ } + return {}; +} + + + test.describe('DNS Provider Types', () => { - test.beforeEach(async ({ request }) => { - await waitForAPIHealth(request); + test.beforeEach(async ({ page }) => { + await waitForAPIHealth(page.request); }); test.describe('API: /api/v1/dns-providers/types', () => { - test('should return all provider types including built-in and custom', async ({ request }) => { - const response = await request.get('/api/v1/dns-providers/types'); + test('should return all provider types including built-in and custom', async ({ page }) => { + const response = await page.request.get('/api/v1/dns-providers/types', { headers: getAuthHeaders() }); expect(response.ok()).toBeTruthy(); const data = await response.json(); @@ -46,8 +69,8 @@ test.describe('DNS Provider Types', () => { expect(typeNames).toContain('script'); }); - test('each provider type should have required fields', async ({ request }) => { - const response = await request.get('/api/v1/dns-providers/types'); + test('each provider type should have required fields', async ({ page }) => { + const response = await page.request.get('/api/v1/dns-providers/types', { headers: getAuthHeaders() }); expect(response.ok()).toBeTruthy(); const data = await response.json(); const types = data.types; @@ -60,8 +83,8 @@ test.describe('DNS Provider Types', () => { } }); - test('manual provider type should have correct configuration', async ({ request }) => { - const response = await request.get('/api/v1/dns-providers/types'); + test('manual provider type should have correct configuration', async ({ page }) => { + const response = await page.request.get('/api/v1/dns-providers/types', { headers: getAuthHeaders() }); expect(response.ok()).toBeTruthy(); const data = await response.json(); const types = data.types; @@ -74,8 +97,8 @@ test.describe('DNS Provider Types', () => { // since DNS records are created manually by the user }); - test('webhook provider type should have url field', async ({ request }) => { - const response = await request.get('/api/v1/dns-providers/types'); + test('webhook provider type should have url field', async ({ page }) => { + const response = await page.request.get('/api/v1/dns-providers/types', { headers: getAuthHeaders() }); expect(response.ok()).toBeTruthy(); const data = await response.json(); const types = data.types; @@ -88,8 +111,8 @@ test.describe('DNS Provider Types', () => { expect(fieldNames.some((name: string) => name.toLowerCase().includes('url'))).toBeTruthy(); }); - test('rfc2136 provider type should have server and key fields', async ({ request }) => { - const response = await request.get('/api/v1/dns-providers/types'); + test('rfc2136 provider type should have server and key fields', async ({ page }) => { + const response = await page.request.get('/api/v1/dns-providers/types', { headers: getAuthHeaders() }); expect(response.ok()).toBeTruthy(); const data = await response.json(); const types = data.types; @@ -102,8 +125,8 @@ test.describe('DNS Provider Types', () => { expect(fieldNames.some((name: string) => name.includes('server') || name.includes('nameserver'))).toBeTruthy(); }); - test('script provider type should have command/path field', async ({ request }) => { - const response = await request.get('/api/v1/dns-providers/types'); + test('script provider type should have command/path field', async ({ page }) => { + const response = await page.request.get('/api/v1/dns-providers/types', { headers: getAuthHeaders() }); expect(response.ok()).toBeTruthy(); const data = await response.json(); const types = data.types; diff --git a/tests/fixtures/auth-fixtures.ts b/tests/fixtures/auth-fixtures.ts index f5e29204..35b2feff 100644 --- a/tests/fixtures/auth-fixtures.ts +++ b/tests/fixtures/auth-fixtures.ts @@ -435,9 +435,28 @@ export async function loginUser( if (response.ok()) { const body = await response.json().catch(() => ({})) as { token?: string }; if (body.token) { - await page.addInitScript((token: string) => { + // Navigate first, then set token via evaluate to avoid addInitScript race condition + await page.goto('/'); + await page.evaluate((token: string) => { localStorage.setItem('charon_auth_token', token); }, body.token); + + const storageState = await page.request.storageState(); + if (storageState.cookies?.length) { + await page.context().addCookies(storageState.cookies); + } + + // Reload so the app picks up the token from localStorage + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => {}); + + // Guard: if app is stuck at loading splash, force reload + const loadingVisible = await page.locator('text=Loading application').isVisible().catch(() => false); + if (loadingVisible) { + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => {}); + } + return; } const storageState = await page.request.storageState(); @@ -486,7 +505,7 @@ export async function logoutUser(page: import('@playwright/test').Page): Promise await logoutButton.click(); // Wait for redirect to login page - await page.waitForURL(/\/login/, { timeout: 15000 }); + await page.waitForURL(/\/login/, { timeout: 15000, waitUntil: 'domcontentloaded' }); } /** diff --git a/tests/settings/user-management.spec.ts b/tests/settings/user-management.spec.ts index b1df47d2..9bad739a 100644 --- a/tests/settings/user-management.spec.ts +++ b/tests/settings/user-management.spec.ts @@ -178,7 +178,7 @@ test.describe('User Management', () => { await test.step('Verify pending status appears in list', async () => { // Reload to see the new user - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Find the pending status indicator @@ -556,7 +556,7 @@ test.describe('User Management', () => { }); await test.step('Reload page to see new user', async () => { - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); }); @@ -603,7 +603,7 @@ test.describe('User Management', () => { await waitForLoadingComplete(page); // Reload to ensure newly created user is in the query cache - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Wait for table to be visible @@ -673,7 +673,7 @@ test.describe('User Management', () => { }); const permissionsModal = await test.step('Open permissions modal', async () => { - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ @@ -727,7 +727,7 @@ test.describe('User Management', () => { }); const permissionsModal = await test.step('Open permissions modal', async () => { - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ @@ -787,7 +787,7 @@ test.describe('User Management', () => { }); await test.step('Open permissions modal', async () => { - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ @@ -842,7 +842,7 @@ test.describe('User Management', () => { }); await test.step('Reload to see new user', async () => { - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); // Wait for table to have data await page.waitForSelector('table tbody tr', { timeout: 10000 }); @@ -910,7 +910,7 @@ test.describe('User Management', () => { }); await test.step('Reload to see new user', async () => { - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); }); @@ -1032,7 +1032,7 @@ test.describe('User Management', () => { }); await test.step('Reload and find pending user', async () => { - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page); const userRow = page.getByRole('row').filter({ diff --git a/tests/tasks/long-running-operations.spec.ts b/tests/tasks/long-running-operations.spec.ts index 4935979a..e495280e 100644 --- a/tests/tasks/long-running-operations.spec.ts +++ b/tests/tasks/long-running-operations.spec.ts @@ -1,5 +1,6 @@ import { test, expect, loginUser } from '../fixtures/auth-fixtures'; import { waitForToast, waitForLoadingComplete } from '../utils/wait-helpers'; +import { getStorageStateAuthHeaders } from '../utils/api-helpers'; /** * Integration: Long-Running Operations @@ -28,6 +29,7 @@ test.describe('Long-Running Operations', () => { const createUserViaApi = async (page: import('@playwright/test').Page) => { const response = await page.request.post('/api/v1/users', { data: testUser, + headers: getStorageStateAuthHeaders(), }); expect(response.ok()).toBe(true); @@ -44,6 +46,7 @@ test.describe('Long-Running Operations', () => { websocket_support: false, enabled: true, }, + headers: getStorageStateAuthHeaders(), }); expect(response.ok()).toBe(true); @@ -170,7 +173,7 @@ test.describe('Long-Running Operations', () => { await test.step('Perform additional operations during backup', async () => { const start = Date.now(); - const response = await page.request.get('/api/v1/proxy-hosts'); + const response = await page.request.get('/api/v1/proxy-hosts', { headers: getStorageStateAuthHeaders() }); const duration = Date.now() - start; diff --git a/tests/utils/api-helpers.ts b/tests/utils/api-helpers.ts index f07a619e..e1005f2a 100644 --- a/tests/utils/api-helpers.ts +++ b/tests/utils/api-helpers.ts @@ -22,6 +22,31 @@ */ import { APIRequestContext, APIResponse } from '@playwright/test'; +import { readFileSync } from 'fs'; +import { STORAGE_STATE } from '../constants'; + +/** + * Read auth token from storage state and return Authorization headers. + * Use this for page.request calls that need Bearer token auth. + */ +export function getStorageStateAuthHeaders(): Record { + try { + const state = JSON.parse(readFileSync(STORAGE_STATE, 'utf-8')); + for (const origin of state.origins ?? []) { + for (const entry of origin.localStorage ?? []) { + if (entry.name === 'charon_auth_token' && entry.value) { + return { Authorization: `Bearer ${entry.value}` }; + } + } + } + for (const cookie of state.cookies ?? []) { + if (cookie.name === 'auth_token' && cookie.value) { + return { Authorization: `Bearer ${cookie.value}` }; + } + } + } catch { /* no-op */ } + return {}; +} /** * API error response diff --git a/tests/utils/wait-helpers.ts b/tests/utils/wait-helpers.ts index 7b29f2cf..72ed7544 100644 --- a/tests/utils/wait-helpers.ts +++ b/tests/utils/wait-helpers.ts @@ -950,7 +950,7 @@ export async function waitForResourceInUI( // If not found and we have reload attempts left, try reloading if (reloadCount < maxReloads) { reloadCount += 1; - await page.reload(); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForLoadingComplete(page, { timeout: 5000 }).catch(() => {}); continue; }