fix: enhance Caddy import tests with improved authentication handling and diagnostics
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user