fix: enhance Caddy import tests with improved authentication handling and diagnostics

This commit is contained in:
GitHub Actions
2026-02-26 21:45:10 +00:00
parent e348b5b2a3
commit f9c43d50c6
3 changed files with 516 additions and 125 deletions

View File

@@ -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<void> {
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<Page, () => 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();

View File

@@ -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<void> {
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<Page, () => 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"]');

View File

@@ -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<string> {
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<void> {
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<void> {
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<void>,
scope: string,
expectedPath: RegExp = /\/api\/v1\/import\/(upload|upload-multi)/i
): Promise<import('@playwright/test').Response> {
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<boolean> {
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<void> {
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<void> {
try {
@@ -32,13 +268,7 @@ export async function resetImportSession(page: Page): Promise<void> {
}
export async function ensureImportFormReady(page: Page): Promise<void> {
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<void> {
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<boolean> {
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) {