diff --git a/tests/core/caddy-import/caddy-import-debug.spec.ts b/tests/core/caddy-import/caddy-import-debug.spec.ts index e5e5aec3..62f5c79b 100644 --- a/tests/core/caddy-import/caddy-import-debug.spec.ts +++ b/tests/core/caddy-import/caddy-import-debug.spec.ts @@ -1,10 +1,43 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { ensureImportFormReady } from './import-page-helpers'; +import { + attachImportDiagnostics, + ensureImportFormReady, + logImportFailureContext, + resetImportSession, + waitForSuccessfulImportResponse, +} from './import-page-helpers'; const execAsync = promisify(exec); +async function fillImportTextarea(page: Page, content: string): Promise { + const importPageMarker = page.getByTestId('import-banner').first(); + if ((await importPageMarker.count()) > 0) { + await expect(importPageMarker).toBeVisible(); + } + + for (let attempt = 1; attempt <= 2; attempt += 1) { + const textarea = page.locator('textarea').first(); + + try { + await expect(textarea).toBeVisible(); + await expect(textarea).toBeEditable(); + await textarea.click(); + await textarea.press('ControlOrMeta+A'); + await textarea.fill(content); + return; + } catch (error) { + if (attempt === 2) { + throw error; + } + + // Retry after ensuring the form remains in an interactive state. + await ensureImportFormReady(page); + } + } +} + /** * Caddy Import Debug Tests - POC Implementation * @@ -20,6 +53,13 @@ const execAsync = promisify(exec); * Current Status: POC - Test 1 only (Baseline validation) */ test.describe('Caddy Import Debug Tests @caddy-import-debug', () => { + const diagnosticsByPage = new WeakMap void>(); + + test.beforeEach(async ({ page }) => { + diagnosticsByPage.set(page, attachImportDiagnostics(page, 'caddy-import-debug')); + await resetImportSession(page); + }); + // CRITICAL FIX #4: Pre-test health check test.beforeAll(async ({ baseURL }) => { console.log('[Health Check] Validating Charon container state...'); @@ -40,8 +80,11 @@ test.describe('Caddy Import Debug Tests @caddy-import-debug', () => { }); // CRITICAL FIX #3: Programmatic backend log capture on test failure - test.afterEach(async ({ }, testInfo) => { + test.afterEach(async ({ page }, testInfo) => { + diagnosticsByPage.get(page)?.(); + if (testInfo.status !== 'passed') { + await logImportFailureContext(page, 'caddy-import-debug'); console.log('[Log Capture] Test failed - capturing backend logs...'); try { @@ -104,31 +147,21 @@ test-simple.example.com { // Step 1: Paste Caddyfile content into textarea console.log('[Action] Filling textarea with Caddyfile content...'); - await page.locator('textarea').fill(caddyfile); + await fillImportTextarea(page, caddyfile); console.log('[Action] ✅ Content pasted'); // Step 2: Set up API response waiter BEFORE clicking parse button // CRITICAL FIX #2: Race condition prevention - console.log('[Setup] Registering API response waiter...'); const parseButton = page.getByRole('button', { name: /parse|review/i }); - - // Register promise FIRST to avoid race condition - const responsePromise = page.waitForResponse(response => { - const matches = response.url().includes('/api/v1/import/upload') && response.status() === 200; - if (matches) { - console.log('[API] Matched upload response:', response.url(), response.status()); - } - return matches; - }, { timeout: 15000 }); - - console.log('[Setup] ✅ Response waiter registered'); - - // NOW trigger the action - console.log('[Action] Clicking parse button...'); - await parseButton.click(); - console.log('[Action] ✅ Parse button clicked, waiting for API response...'); - - const apiResponse = await responsePromise; + const apiResponse = await waitForSuccessfulImportResponse( + page, + async () => { + console.log('[Action] Clicking parse button...'); + await parseButton.click(); + console.log('[Action] ✅ Parse button clicked, waiting for API response...'); + }, + 'debug-simple-parse' + ); console.log('[API] Response received:', apiResponse.status(), apiResponse.statusText()); // Step 3: Log full API response for diagnostics @@ -198,26 +231,16 @@ admin.example.com { // Paste content with import directive console.log('[Action] Filling textarea...'); - await page.locator('textarea').fill(caddyfileWithImports); + await fillImportTextarea(page, caddyfileWithImports); console.log('[Action] ✅ Content pasted'); // Click parse and capture response (FIX: waitForResponse BEFORE click) const parseButton = page.getByRole('button', { name: /parse|review/i }); - // Register response waiter FIRST - console.log('[Setup] Registering API response waiter...'); - const responsePromise = page.waitForResponse(response => { - const matches = response.url().includes('/api/v1/import/upload'); - if (matches) { - console.log('[API] Matched upload response:', response.url(), response.status()); - } - return matches; - }, { timeout: 15000 }); - - // THEN trigger action - console.log('[Action] Clicking parse button...'); - await parseButton.click(); - const apiResponse = await responsePromise; + const [apiResponse] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/api/v1/import/upload'), { timeout: 15000 }), + parseButton.click(), + ]); console.log('[API] Response received'); // Log status and response body @@ -286,22 +309,14 @@ docs.example.com { // Paste file server config console.log('[Action] Filling textarea...'); - await page.locator('textarea').fill(fileServerCaddyfile); + await fillImportTextarea(page, fileServerCaddyfile); console.log('[Action] ✅ Content pasted'); // Parse and capture API response (FIX: register waiter first) - console.log('[Setup] Registering API response waiter...'); - const responsePromise = page.waitForResponse(response => { - const matches = response.url().includes('/api/v1/import/upload'); - if (matches) { - console.log('[API] Matched upload response:', response.url(), response.status()); - } - return matches; - }, { timeout: 15000 }); - - console.log('[Action] Clicking parse button...'); - await page.getByRole('button', { name: /parse|review/i }).click(); - const apiResponse = await responsePromise; + const [apiResponse] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/api/v1/import/upload') && response.ok(), { timeout: 15000 }), + page.getByRole('button', { name: /parse|review/i }).click(), + ]); console.log('[API] Response received'); const status = apiResponse.status(); @@ -385,22 +400,14 @@ redirect.example.com { // Paste mixed content console.log('[Action] Filling textarea...'); - await page.locator('textarea').fill(mixedCaddyfile); + await fillImportTextarea(page, mixedCaddyfile); console.log('[Action] ✅ Content pasted'); // Parse and capture response (FIX: waiter registered first) - console.log('[Setup] Registering API response waiter...'); - const responsePromise = page.waitForResponse(response => { - const matches = response.url().includes('/api/v1/import/upload'); - if (matches) { - console.log('[API] Matched upload response:', response.url(), response.status()); - } - return matches; - }, { timeout: 15000 }); - - console.log('[Action] Clicking parse button...'); - await page.getByRole('button', { name: /parse|review/i }).click(); - const apiResponse = await responsePromise; + const [apiResponse] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/api/v1/import/upload') && response.ok(), { timeout: 15000 }), + page.getByRole('button', { name: /parse|review/i }).click(), + ]); console.log('[API] Response received'); const responseBody = await apiResponse.json(); @@ -477,22 +484,14 @@ broken.example.com { // Paste invalid content console.log('[Action] Filling textarea...'); - await page.locator('textarea').fill(invalidCaddyfile); + await fillImportTextarea(page, invalidCaddyfile); console.log('[Action] ✅ Content pasted'); // Parse and capture response (FIX: waiter before click) - console.log('[Setup] Registering API response waiter...'); - const responsePromise = page.waitForResponse(response => { - const matches = response.url().includes('/api/v1/import/upload'); - if (matches) { - console.log('[API] Matched upload response:', response.url(), response.status()); - } - return matches; - }, { timeout: 15000 }); - - console.log('[Action] Clicking parse button...'); - await page.getByRole('button', { name: /parse|review/i }).click(); - const apiResponse = await responsePromise; + const [apiResponse] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/api/v1/import/upload'), { timeout: 15000 }), + page.getByRole('button', { name: /parse|review/i }).click(), + ]); console.log('[API] Response received'); const status = apiResponse.status(); @@ -614,19 +613,12 @@ api.example.com { const uploadButton = modal.getByRole('button', { name: /Parse and Review/i }); // Register response waiter BEFORE clicking - console.log('[Setup] Registering API response waiter...'); - const responsePromise = page.waitForResponse(response => { - const matches = response.url().includes('/api/v1/import/upload-multi') || - response.url().includes('/api/v1/import/upload'); - if (matches) { - console.log('[API] Matched upload response:', response.url(), response.status()); - } - return matches; - }, { timeout: 15000 }); - - console.log('[Action] Clicking upload button...'); - await uploadButton.click(); - const apiResponse = await responsePromise; + const [apiResponse] = await Promise.all([ + page.waitForResponse((response) => + (response.url().includes('/api/v1/import/upload-multi') || response.url().includes('/api/v1/import/upload')) && + response.ok(), { timeout: 15000 }), + uploadButton.click(), + ]); console.log('[API] Response received'); const responseBody = await apiResponse.json(); diff --git a/tests/core/caddy-import/caddy-import-webkit.spec.ts b/tests/core/caddy-import/caddy-import-webkit.spec.ts index 731c2d36..a98a24cb 100644 --- a/tests/core/caddy-import/caddy-import-webkit.spec.ts +++ b/tests/core/caddy-import/caddy-import-webkit.spec.ts @@ -19,12 +19,71 @@ import { test, expect } from '../../fixtures/auth-fixtures'; import { Page } from '@playwright/test'; -import { ensureImportUiPreconditions, resetImportSession } from './import-page-helpers'; +import { + attachImportDiagnostics, + ensureImportUiPreconditions, + logImportFailureContext, + resetImportSession, + waitForSuccessfulImportResponse, +} from './import-page-helpers'; function webkitOnly(browserName: string) { test.skip(browserName !== 'webkit', 'This suite only runs on WebKit'); } +const WEBKIT_TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-test@example.com'; +const WEBKIT_TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'TestPassword123!'; + +async function ensureWebkitAuthSession(page: Page): Promise { + await page.goto('/tasks/import/caddyfile', { waitUntil: 'domcontentloaded' }); + + const emailInput = page + .getByRole('textbox', { name: /email/i }) + .first() + .or(page.locator('input[type="email"]').first()); + const passwordInput = page.locator('input[type="password"]').first(); + const loginButton = page.getByRole('button', { name: /login|sign in/i }).first(); + + const [emailVisible, passwordVisible, loginButtonVisible] = await Promise.all([ + emailInput.isVisible().catch(() => false), + passwordInput.isVisible().catch(() => false), + loginButton.isVisible().catch(() => false), + ]); + + const loginUiPresent = emailVisible && passwordVisible && loginButtonVisible; + const loginRoute = page.url().includes('/login'); + + if (loginUiPresent || loginRoute) { + if (!loginRoute) { + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + } + + await emailInput.fill(WEBKIT_TEST_EMAIL); + await passwordInput.fill(WEBKIT_TEST_PASSWORD); + + const loginResponsePromise = page + .waitForResponse( + (response) => response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST', + { timeout: 15000 } + ) + .catch(() => null); + + await loginButton.click(); + await loginResponsePromise; + await page.waitForURL((url) => !url.pathname.includes('/login'), { + timeout: 15000, + waitUntil: 'domcontentloaded', + }); + } + + const meResponse = await page.request.get('/api/v1/auth/me'); + if (!meResponse.ok()) { + throw new Error( + `WebKit auth bootstrap verification failed: /api/v1/auth/me returned ${meResponse.status()} at ${page.url()}` + ); + } +} + /** * Helper to set up import API mocks */ @@ -90,14 +149,22 @@ async function setupImportMocks(page: Page, success: boolean = true) { } test.describe('Caddy Import - WebKit-Specific @webkit-only', () => { + const diagnosticsByPage = new WeakMap void>(); + test.beforeEach(async ({ browserName, page, adminUser }) => { webkitOnly(browserName); + diagnosticsByPage.set(page, attachImportDiagnostics(page, 'caddy-import-webkit')); await setupImportMocks(page); + await ensureWebkitAuthSession(page); await resetImportSession(page); await ensureImportUiPreconditions(page, adminUser); }); - test.afterEach(async ({ page }) => { + test.afterEach(async ({ page }, testInfo) => { + diagnosticsByPage.get(page)?.(); + if (testInfo.status !== 'passed') { + await logImportFailureContext(page, 'caddy-import-webkit'); + } await resetImportSession(page); }); @@ -128,12 +195,13 @@ test.describe('Caddy Import - WebKit-Specific @webkit-only', () => { }); await test.step('Verify click sends API request', async () => { - const requestPromise = page.waitForRequest((req) => req.url().includes('/api/v1/import/upload')); - const parseButton = page.getByRole('button', { name: /parse|review/i }); - await parseButton.click(); - - const request = await requestPromise; + const response = await waitForSuccessfulImportResponse( + page, + () => parseButton.click(), + 'webkit-click-handler' + ); + const request = response.request(); expect(request.url()).toContain('/api/v1/import/upload'); expect(request.method()).toBe('POST'); }); @@ -176,7 +244,7 @@ test.describe('Caddy Import - WebKit-Specific @webkit-only', () => { await textarea.fill('async.example.com { reverse_proxy localhost:3000 }'); const parseButton = page.getByRole('button', { name: /parse|review/i }); - await parseButton.click(); + await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-async-state'); // Verify UI updates correctly after async operation const reviewTable = page.locator('[data-testid="import-review-table"]'); @@ -208,10 +276,7 @@ test.describe('Caddy Import - WebKit-Specific @webkit-only', () => { await textarea.fill('form-test.example.com { reverse_proxy localhost:3000 }'); const parseButton = page.getByRole('button', { name: /parse|review/i }); - await parseButton.click(); - - // Wait for response - await page.waitForResponse((r) => r.url().includes('/api/v1/import/upload'), { timeout: 5000 }); + await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-form-submit'); // Verify no full-page navigation occurred (only initial + maybe same URL) const uniqueUrls = [...new Set(navigationOccurred)]; @@ -246,9 +311,7 @@ test.describe('Caddy Import - WebKit-Specific @webkit-only', () => { await textarea.fill('cookie-test.example.com { reverse_proxy localhost:3000 }'); const parseButton = page.getByRole('button', { name: /parse|review/i }); - await parseButton.click(); - - await page.waitForResponse((r) => r.url().includes('/api/v1/import/upload'), { timeout: 5000 }); + await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-cookie-session'); // Verify headers captured expect(Object.keys(requestHeaders).length).toBeGreaterThan(0); @@ -265,16 +328,26 @@ test.describe('Caddy Import - WebKit-Specific @webkit-only', () => { * Safari may handle rapid state updates differently */ test('should handle button state changes correctly', async ({ page, adminUser }) => { - await test.step('Navigate to import page', async () => { + await test.step('Navigate to import page with clean import state', async () => { + await resetImportSession(page); await ensureImportUiPreconditions(page, adminUser); + + const textarea = page.locator('textarea').first(); + await expect(textarea).toBeVisible(); + await expect(page.getByText(/pending import session/i).first()).toBeHidden(); + + // Deterministic baseline: empty import input must keep Parse disabled. + await textarea.clear(); + await expect(textarea).toHaveValue(''); + + const parseButton = page.getByRole('button', { name: /parse|review/i }).first(); + await expect(parseButton).toBeVisible(); + await expect(parseButton).toBeDisabled(); }); await test.step('Rapidly fill content and check button state', async () => { - const textarea = page.locator('textarea'); - const parseButton = page.getByRole('button', { name: /parse|review/i }); - - // Initially button should be disabled (empty content) - await expect(parseButton).toBeDisabled(); + const textarea = page.locator('textarea').first(); + const parseButton = page.getByRole('button', { name: /parse|review/i }).first(); // Fill content - button should enable await textarea.fill('rapid.example.com { reverse_proxy localhost:3000 }'); @@ -290,15 +363,43 @@ test.describe('Caddy Import - WebKit-Specific @webkit-only', () => { }); await test.step('Click button and verify loading state', async () => { - const parseButton = page.getByRole('button', { name: /parse|review/i }); - await parseButton.click(); + await page.route('**/api/v1/import/upload', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 250)); + await route.fulfill({ + status: 200, + json: { + session: { + id: 'webkit-button-state-session', + state: 'transient', + }, + preview: { + hosts: [ + { + domain_names: 'rapid2.example.com', + forward_host: 'localhost', + forward_port: 3001, + forward_scheme: 'http', + }, + ], + conflicts: [], + warnings: [], + }, + }, + }); + }); - // Button should be disabled during processing - await expect(parseButton).toBeDisabled({ timeout: 1000 }); + const parseButton = page.getByRole('button', { name: /parse and review/i }).first(); + const importResponsePromise = waitForSuccessfulImportResponse( + page, + () => parseButton.click(), + 'webkit-button-state' + ); + await importResponsePromise; // After completion, review table should appear const reviewTable = page.locator('[data-testid="import-review-table"]'); await expect(reviewTable).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: /review changes/i }).first()).toBeEnabled(); }); }); @@ -362,7 +463,7 @@ safari-host${i}.example.com { }); const parseButton = page.getByRole('button', { name: /parse|review/i }); - await parseButton.click(); + await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-large-file'); // Should complete within reasonable time const reviewTable = page.locator('[data-testid="import-review-table"]'); diff --git a/tests/core/caddy-import/import-page-helpers.ts b/tests/core/caddy-import/import-page-helpers.ts index e24be335..2d55686c 100644 --- a/tests/core/caddy-import/import-page-helpers.ts +++ b/tests/core/caddy-import/import-page-helpers.ts @@ -1,7 +1,243 @@ import { expect, test, type Page } from '@playwright/test'; import { loginUser, type TestUser } from '../../fixtures/auth-fixtures'; +import { readFileSync } from 'fs'; +import { STORAGE_STATE } from '../../constants'; const IMPORT_PAGE_PATH = '/tasks/import/caddyfile'; +const SETUP_TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-test@example.com'; +const SETUP_TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'TestPassword123!'; +const IMPORT_BLOCKING_STATUS_CODES = new Set([401, 403, 302, 429]); +const IMPORT_ERROR_PATTERNS = /(cors|cross-origin|same-origin|cookie|csrf|forbidden|unauthorized|security|host)/i; + +type ImportDiagnosticsCleanup = () => void; + +function diagnosticLog(message: string): void { + if (process.env.PLAYWRIGHT_IMPORT_DIAGNOSTICS === '0') { + return; + } + console.log(message); +} + +async function readCurrentPath(page: Page): Promise { + return page.evaluate(() => window.location.pathname).catch(() => ''); +} + +export async function getImportAuthMarkers(page: Page): Promise<{ + currentUrl: string; + currentPath: string; + loginRoute: boolean; + setupRoute: boolean; + hasLoginForm: boolean; + hasSetupForm: boolean; + hasPendingSessionBanner: boolean; + hasTextarea: boolean; +}> { + const currentUrl = page.url(); + const currentPath = await readCurrentPath(page); + + const [hasLoginForm, hasSetupForm, hasPendingSessionBanner, hasTextarea] = await Promise.all([ + page.locator('form').filter({ has: page.getByRole('button', { name: /sign in|login/i }) }).first().isVisible().catch(() => false), + page.getByRole('button', { name: /create admin|finish setup|setup/i }).first().isVisible().catch(() => false), + page.getByText(/pending import session/i).first().isVisible().catch(() => false), + page.locator('textarea').first().isVisible().catch(() => false), + ]); + + return { + currentUrl, + currentPath, + loginRoute: currentUrl.includes('/login') || currentPath.includes('/login'), + setupRoute: currentUrl.includes('/setup') || currentPath.includes('/setup'), + hasLoginForm, + hasSetupForm, + hasPendingSessionBanner, + hasTextarea, + }; +} + +export async function assertNoAuthRedirect(page: Page, context: string): Promise { + const markers = await getImportAuthMarkers(page); + if (markers.loginRoute || markers.setupRoute || markers.hasLoginForm || markers.hasSetupForm) { + throw new Error( + `${context}: blocked by auth/setup state (url=${markers.currentUrl}, path=${markers.currentPath}, ` + + `loginRoute=${markers.loginRoute}, setupRoute=${markers.setupRoute}, ` + + `hasLoginForm=${markers.hasLoginForm}, hasSetupForm=${markers.hasSetupForm})` + ); + } +} + +export function attachImportDiagnostics(page: Page, scope: string): ImportDiagnosticsCleanup { + if (process.env.PLAYWRIGHT_IMPORT_DIAGNOSTICS === '0') { + return () => {}; + } + + const onResponse = (response: { status: () => number; url: () => string }): void => { + const status = response.status(); + if (!IMPORT_BLOCKING_STATUS_CODES.has(status)) { + return; + } + + const url = response.url(); + if (!/\/api\/v1\/(auth|import)|\/login|\/setup/i.test(url)) { + return; + } + + diagnosticLog(`[Diag:${scope}] blocking-status=${status} url=${url}`); + }; + + const onConsole = (msg: { type: () => string; text: () => string }): void => { + const text = msg.text(); + if (!IMPORT_ERROR_PATTERNS.test(text)) { + return; + } + + diagnosticLog(`[Diag:${scope}] console.${msg.type()} ${text}`); + }; + + const onPageError = (error: Error): void => { + const text = error.message || String(error); + if (!IMPORT_ERROR_PATTERNS.test(text)) { + return; + } + + diagnosticLog(`[Diag:${scope}] pageerror ${text}`); + }; + + page.on('response', onResponse); + page.on('console', onConsole); + page.on('pageerror', onPageError); + + return () => { + page.off('response', onResponse); + page.off('console', onConsole); + page.off('pageerror', onPageError); + }; +} + +export async function logImportFailureContext(page: Page, scope: string): Promise { + const markers = await getImportAuthMarkers(page); + diagnosticLog( + `[Diag:${scope}] failure-context url=${markers.currentUrl} path=${markers.currentPath} ` + + `loginRoute=${markers.loginRoute} setupRoute=${markers.setupRoute} ` + + `hasLoginForm=${markers.hasLoginForm} hasSetupForm=${markers.hasSetupForm} ` + + `hasPendingSessionBanner=${markers.hasPendingSessionBanner} hasTextarea=${markers.hasTextarea}` + ); +} + +export async function waitForSuccessfulImportResponse( + page: Page, + triggerAction: () => Promise, + scope: string, + expectedPath: RegExp = /\/api\/v1\/import\/(upload|upload-multi)/i +): Promise { + await assertNoAuthRedirect(page, `${scope} pre-trigger`); + + try { + const [response] = await Promise.all([ + page.waitForResponse((r) => expectedPath.test(r.url()) && r.ok(), { timeout: 15000 }), + triggerAction(), + ]); + return response; + } catch (error) { + await logImportFailureContext(page, scope); + throw error; + } +} + +function extractTokenFromState(rawState: unknown): string | null { + if (!rawState || typeof rawState !== 'object') { + return null; + } + + const state = rawState as { origins?: Array<{ localStorage?: Array<{ name?: string; value?: string }> }> }; + const origins = Array.isArray(state.origins) ? state.origins : []; + for (const origin of origins) { + const entries = Array.isArray(origin.localStorage) ? origin.localStorage : []; + const tokenEntry = entries.find((item) => item?.name === 'charon_auth_token' && typeof item.value === 'string'); + if (tokenEntry?.value) { + return tokenEntry.value; + } + } + + return null; +} + +function readStoredAuthToken(): string | null { + try { + const raw = JSON.parse(readFileSync(STORAGE_STATE, 'utf-8')); + return extractTokenFromState(raw); + } catch { + return null; + } +} + +async function restoreAuthFromStorageState(page: Page): Promise { + try { + const state = JSON.parse(readFileSync(STORAGE_STATE, 'utf-8')) as { + cookies?: Array<{ + name: string; + value: string; + domain?: string; + path?: string; + expires?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: 'Lax' | 'None' | 'Strict'; + }>; + }; + const token = extractTokenFromState(state); + const cookies = Array.isArray(state.cookies) ? state.cookies : []; + + if (!token && cookies.length === 0) { + return false; + } + + if (cookies.length > 0) { + await page.context().addCookies(cookies); + } + + if (token) { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.evaluate((authToken: string) => { + localStorage.setItem('charon_auth_token', authToken); + }, token); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => {}); + } + + return true; + } catch { + return false; + } +} + +async function loginWithSetupCredentials(page: Page): Promise { + if (!page.url().includes('/login')) { + await page.goto('/login', { waitUntil: 'domcontentloaded' }); + } + + await page.locator('input[type="email"]').first().fill(SETUP_TEST_EMAIL); + await page.locator('input[type="password"]').first().fill(SETUP_TEST_PASSWORD); + + const [loginResponse] = await Promise.all([ + page.waitForResponse((response) => response.url().includes('/api/v1/auth/login'), { timeout: 15000 }), + page.getByRole('button', { name: /sign in|login/i }).first().click(), + ]); + + if (!loginResponse.ok()) { + const body = await loginResponse.text().catch(() => ''); + throw new Error(`Setup-credential login failed: ${loginResponse.status()} ${body}`); + } + + const payload = (await loginResponse.json().catch(() => ({}))) as { token?: string }; + if (payload.token) { + await page.evaluate((authToken: string) => { + localStorage.setItem('charon_auth_token', authToken); + }, payload.token); + } + + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15000 }); + await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' }); +} export async function resetImportSession(page: Page): Promise { try { @@ -32,13 +268,7 @@ export async function resetImportSession(page: Page): Promise { } export async function ensureImportFormReady(page: Page): Promise { - const currentUrl = page.url(); - const currentPath = await page.evaluate(() => window.location.pathname).catch(() => ''); - if (currentUrl.includes('/login') || currentPath.includes('/login')) { - throw new Error( - `Auth state lost: import form is unavailable because the page is on login (url=${currentUrl}, path=${currentPath})` - ); - } + await assertNoAuthRedirect(page, 'ensureImportFormReady initial check'); const headingByRole = page.getByRole('heading', { name: /import|caddyfile/i }).first(); const headingLike = page @@ -53,13 +283,65 @@ export async function ensureImportFormReady(page: Page): Promise { await expect(page.locator('main, body').first()).toContainText(/import|caddyfile/i); } - await expect(page.locator('textarea')).toBeVisible(); + const textarea = page.locator('textarea').first(); + const textareaVisible = await textarea.isVisible().catch(() => false); + if (!textareaVisible) { + const pendingSessionVisible = await page.getByText(/pending import session/i).first().isVisible().catch(() => false); + if (pendingSessionVisible) { + diagnosticLog('[Diag:import-ready] pending import session detected, canceling to restore textarea'); + + const browserCancelStatus = await page + .evaluate(async () => { + const token = localStorage.getItem('charon_auth_token'); + const commonHeaders = token ? { Authorization: `Bearer ${token}` } : {}; + + const statusResponse = await fetch('/api/v1/import/status', { + method: 'GET', + credentials: 'include', + headers: commonHeaders, + }); + let sessionId = ''; + if (statusResponse.ok) { + const statusBody = (await statusResponse.json()) as { session?: { id?: string } }; + sessionId = statusBody?.session?.id || ''; + } + + const cancelUrl = sessionId + ? `/api/v1/import/cancel?session_uuid=${encodeURIComponent(sessionId)}` + : '/api/v1/import/cancel'; + + const response = await fetch(cancelUrl, { + method: 'DELETE', + credentials: 'include', + headers: commonHeaders, + }); + return response.status; + }) + .catch(() => null); + diagnosticLog(`[Diag:import-ready] browser cancel status=${browserCancelStatus ?? 'n/a'}`); + + const cancelButton = page.getByRole('button', { name: /^cancel$/i }).first(); + const cancelButtonVisible = await cancelButton.isVisible().catch(() => false); + + if (cancelButtonVisible) { + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/api/v1/import/cancel'), { timeout: 10000 }).catch(() => null), + cancelButton.click(), + ]); + } + + await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' }); + await assertNoAuthRedirect(page, 'ensureImportFormReady after pending-session reset'); + } + } + + await expect(textarea).toBeVisible(); await expect(page.getByRole('button', { name: /parse|review/i }).first()).toBeVisible(); } async function hasLoginUiMarkers(page: Page): Promise { const currentUrl = page.url(); - const currentPath = await page.evaluate(() => window.location.pathname).catch(() => ''); + const currentPath = await readCurrentPath(page); if (currentUrl.includes('/login') || currentPath.includes('/login')) { return true; } @@ -107,6 +389,22 @@ export async function ensureAuthenticatedImportFormReady(page: Page, adminUser?: }); } + if (await hasLoginUiMarkers(page)) { + await test.step('Auth recovery fallback: restore auth from setup storage state', async () => { + const restored = await restoreAuthFromStorageState(page); + if (!restored) { + throw new Error(`Unable to restore auth from ${STORAGE_STATE}`); + } + await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' }); + }); + } + + if (await hasLoginUiMarkers(page)) { + await test.step('Auth recovery fallback: UI login with setup credentials', async () => { + await loginWithSetupCredentials(page); + }); + } + await ensureImportFormReady(page); return true; } catch (error) {