723 lines
27 KiB
TypeScript
723 lines
27 KiB
TypeScript
import { test, expect, type Page, type Response } from '@playwright/test';
|
||
import { exec } from 'child_process';
|
||
import { promisify } from 'util';
|
||
import {
|
||
assertNoAuthRedirect,
|
||
attachImportDiagnostics,
|
||
ensureImportUiPreconditions,
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function waitForImportResponseOrFallback(
|
||
page: Page,
|
||
triggerAction: () => Promise<void>,
|
||
scope: string,
|
||
expectedPath: RegExp
|
||
): Promise<Response | null> {
|
||
await assertNoAuthRedirect(page, `${scope} pre-trigger`);
|
||
|
||
try {
|
||
const [response] = await Promise.all([
|
||
page.waitForResponse((r) => expectedPath.test(r.url()), { timeout: 8000 }),
|
||
triggerAction(),
|
||
]);
|
||
return response;
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
if (!errorMessage.includes('waitForResponse')) {
|
||
throw error;
|
||
}
|
||
|
||
await logImportFailureContext(page, scope);
|
||
console.warn(`[${scope}] No matching import response observed; switching to UI-state assertions`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function openImportPageDeterministic(page: Page): Promise<void> {
|
||
const maxAttempts = 2;
|
||
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
try {
|
||
await ensureImportUiPreconditions(page);
|
||
return;
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
const isRetriableWebKitNavigationError = message.includes('WebKit encountered an internal error');
|
||
|
||
if (attempt < maxAttempts && isRetriableWebKitNavigationError) {
|
||
console.warn(`[Navigation] Retrying import page preconditions after WebKit navigation error (attempt ${attempt}/${maxAttempts})`);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' }).catch(() => undefined);
|
||
continue;
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Caddy Import Debug Tests - POC Implementation
|
||
*
|
||
* Purpose: Diagnostic tests to expose failure modes in Caddy import functionality
|
||
* Specification: docs/plans/caddy_import_debug_spec.md
|
||
*
|
||
* CRITICAL FIXES APPLIED:
|
||
* 1. ✅ No loginUser() - uses stored auth state from auth.setup.ts
|
||
* 2. ✅ waitForResponse() registered BEFORE click() to prevent race conditions
|
||
* 3. ✅ Programmatic Docker log capture in afterEach() hook
|
||
* 4. ✅ Health check in beforeAll() validates container state
|
||
*
|
||
* 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...');
|
||
|
||
try {
|
||
const healthResponse = await fetch(`${baseURL}/health`);
|
||
console.log('[Health Check] Response status:', healthResponse.status);
|
||
|
||
if (!healthResponse.ok) {
|
||
throw new Error(`Charon container unhealthy - Status: ${healthResponse.status}`);
|
||
}
|
||
|
||
console.log('[Health Check] ✅ Container is healthy and ready');
|
||
} catch (error) {
|
||
console.error('[Health Check] ❌ Failed:', error);
|
||
throw new Error('Charon container not running or unhealthy. Please start the container with: docker-compose up -d');
|
||
}
|
||
});
|
||
|
||
// CRITICAL FIX #3: Programmatic backend log capture on test failure
|
||
test.afterEach(async ({ page }, testInfo) => {
|
||
diagnosticsByPage.get(page)?.();
|
||
|
||
await resetImportSession(page);
|
||
|
||
if (testInfo.status !== 'passed') {
|
||
await logImportFailureContext(page, 'caddy-import-debug');
|
||
console.log('[Log Capture] Test failed - capturing backend logs...');
|
||
|
||
try {
|
||
const { stdout } = await execAsync(
|
||
'docker logs charon-app 2>&1 | grep -i import | tail -50'
|
||
);
|
||
|
||
if (stdout) {
|
||
console.log('[Log Capture] Backend logs retrieved:', stdout);
|
||
testInfo.attach('backend-logs', {
|
||
body: stdout,
|
||
contentType: 'text/plain'
|
||
});
|
||
console.log('[Log Capture] ✅ Backend logs attached to test report');
|
||
} else {
|
||
console.warn('[Log Capture] ⚠️ No import-related logs found in backend');
|
||
}
|
||
} catch (error) {
|
||
console.warn('[Log Capture] ⚠️ Failed to capture backend logs:', error);
|
||
console.warn('[Log Capture] Ensure Docker container name is "charon-app"');
|
||
}
|
||
}
|
||
});
|
||
|
||
test.describe('Baseline Verification', () => {
|
||
/**
|
||
* Test 1: Simple Valid Caddyfile (POC)
|
||
*
|
||
* Objective: Verify the happy path works correctly and establish baseline behavior
|
||
* Expected: ✅ Should PASS if basic import functionality is working
|
||
*
|
||
* This test determines whether the entire import pipeline is functional:
|
||
* - Frontend uploads Caddyfile content
|
||
* - Backend receives and parses it
|
||
* - Caddy CLI successfully adapts the config
|
||
* - Hosts are extracted and returned
|
||
* - UI displays the preview correctly
|
||
*/
|
||
test('should successfully import a simple valid Caddyfile', async ({ page }) => {
|
||
console.log('\n=== Test 1: Simple Valid Caddyfile (POC) ===');
|
||
|
||
// CRITICAL FIX #1: No loginUser() call
|
||
// Auth state automatically loaded from storage state (auth.setup.ts)
|
||
console.log('[Auth] Using stored authentication state from global setup');
|
||
|
||
// Navigate to import page
|
||
console.log('[Navigation] Going to /tasks/import/caddyfile');
|
||
await openImportPageDeterministic(page);
|
||
|
||
// Simple valid Caddyfile with single reverse proxy
|
||
const caddyfile = `
|
||
test-simple.example.com {
|
||
reverse_proxy localhost:3000
|
||
}
|
||
`.trim();
|
||
|
||
console.log('[Input] Caddyfile content:');
|
||
console.log(caddyfile);
|
||
|
||
// Step 1: Paste Caddyfile content into textarea
|
||
console.log('[Action] Filling textarea with Caddyfile content...');
|
||
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
|
||
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
||
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
|
||
const responseBody = await apiResponse.json();
|
||
console.log('[API] Response body:');
|
||
console.log(JSON.stringify(responseBody, null, 2));
|
||
|
||
// Analyze response structure
|
||
if (responseBody.preview) {
|
||
console.log('[API] ✅ Preview object present');
|
||
console.log('[API] Hosts count:', responseBody.preview.hosts?.length || 0);
|
||
|
||
if (responseBody.preview.hosts && responseBody.preview.hosts.length > 0) {
|
||
console.log('[API] First host:', JSON.stringify(responseBody.preview.hosts[0], null, 2));
|
||
}
|
||
} else {
|
||
console.warn('[API] ⚠️ No preview object in response');
|
||
}
|
||
|
||
if (responseBody.error) {
|
||
console.error('[API] ❌ Error in response:', responseBody.error);
|
||
}
|
||
|
||
// Step 4: Verify preview shows the host domain (use test-id to avoid matching textarea)
|
||
console.log('[Verification] Checking if domain appears in preview...');
|
||
const reviewTable = page.getByTestId('import-review-table');
|
||
await expect(reviewTable.getByText('test-simple.example.com')).toBeVisible({ timeout: 10000 });
|
||
console.log('[Verification] ✅ Domain visible in preview');
|
||
|
||
// Step 5: Verify we got hosts in the API response (forward details in raw_json)
|
||
console.log('[Verification] Checking API returned valid host details...');
|
||
const firstHost = responseBody.preview?.hosts?.[0];
|
||
expect(firstHost).toBeDefined();
|
||
expect(firstHost.forward_port).toBe(3000);
|
||
console.log('[Verification] ✅ Forward port 3000 confirmed in API response');
|
||
|
||
console.log('\n=== Test 1: ✅ PASSED ===\n');
|
||
});
|
||
});
|
||
|
||
test.describe('Import Directives', () => {
|
||
/**
|
||
* Test 2: Caddyfile with Import Directives
|
||
*
|
||
* Objective: Expose the import directive handling - should show appropriate error/guidance
|
||
* Expected: ⚠️ May FAIL if error message is unclear or missing
|
||
*/
|
||
test('should detect import directives and provide actionable error', async ({ page }) => {
|
||
console.log('\n=== Test 2: Import Directives Detection ===');
|
||
|
||
// Auth state loaded from storage - no login needed
|
||
console.log('[Auth] Using stored authentication state');
|
||
await openImportPageDeterministic(page);
|
||
console.log('[Navigation] Navigated to import page');
|
||
|
||
const caddyfileWithImports = `
|
||
import sites.d/*.caddy
|
||
|
||
admin.example.com {
|
||
reverse_proxy localhost:9090
|
||
}
|
||
`.trim();
|
||
|
||
console.log('[Input] Caddyfile with import directive:');
|
||
console.log(caddyfileWithImports);
|
||
|
||
// Paste content with import directive
|
||
console.log('[Action] Filling textarea...');
|
||
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 });
|
||
|
||
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
|
||
const status = apiResponse.status();
|
||
const responseBody = await apiResponse.json();
|
||
console.log('[API] Status:', status);
|
||
console.log('[API] Response:', JSON.stringify(responseBody, null, 2));
|
||
|
||
// Check if backend detected import directives
|
||
if (responseBody.imports && responseBody.imports.length > 0) {
|
||
console.log('✅ Backend detected imports:', responseBody.imports);
|
||
} else {
|
||
console.warn('❌ Backend did NOT detect import directives');
|
||
}
|
||
|
||
// Verify user-facing error message
|
||
console.log('[Verification] Checking for error message display...');
|
||
const errorMessage = page.locator('.bg-red-900, .bg-red-900\\/20');
|
||
await expect(errorMessage).toBeVisible({ timeout: 5000 });
|
||
console.log('[Verification] ✅ Error message visible');
|
||
|
||
// Check error text surfaces the import failure
|
||
const errorText = await errorMessage.textContent();
|
||
console.log('[Verification] Error message displayed to user:', errorText);
|
||
|
||
// Should mention "import" - caddy adapt returns errors like:
|
||
// "import failed: parsing caddy json: invalid character '{' after top-level value"
|
||
// NOTE: Future enhancement could add actionable guidance about multi-file upload
|
||
expect(errorText?.toLowerCase()).toContain('import');
|
||
console.log('[Verification] ✅ Error message surfaces import failure');
|
||
|
||
console.log('\n=== Test 2: Complete ===\n');
|
||
});
|
||
});
|
||
|
||
test.describe('Unsupported Features', () => {
|
||
/**
|
||
* Test 3: Caddyfile with No Reverse Proxy (File Server Only)
|
||
*
|
||
* Objective: Expose silent host skipping - should inform user which hosts were ignored
|
||
* Expected: ⚠️ May FAIL if no feedback about skipped hosts
|
||
*/
|
||
test('should provide feedback when all hosts are file servers (not reverse proxies)', async ({ page }) => {
|
||
console.log('\n=== Test 3: File Server Only ===');
|
||
|
||
// Auth state loaded from storage
|
||
console.log('[Auth] Using stored authentication state');
|
||
await openImportPageDeterministic(page);
|
||
console.log('[Navigation] Navigated to import page');
|
||
|
||
const fileServerCaddyfile = `
|
||
static.example.com {
|
||
file_server
|
||
root * /var/www/html
|
||
}
|
||
|
||
docs.example.com {
|
||
file_server browse
|
||
root * /var/www/docs
|
||
}
|
||
`.trim();
|
||
|
||
console.log('[Input] File server only Caddyfile:');
|
||
console.log(fileServerCaddyfile);
|
||
|
||
// Paste file server config
|
||
console.log('[Action] Filling textarea...');
|
||
await fillImportTextarea(page, fileServerCaddyfile);
|
||
console.log('[Action] ✅ Content pasted');
|
||
|
||
// Parse and capture API response (FIX: register waiter first)
|
||
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
||
const apiResponse = await waitForImportResponseOrFallback(
|
||
page,
|
||
async () => {
|
||
await parseButton.click();
|
||
},
|
||
'debug-file-server-only',
|
||
/\/api\/v1\/import\/upload/i
|
||
);
|
||
|
||
if (apiResponse) {
|
||
console.log('[API] Response received');
|
||
|
||
const status = apiResponse.status();
|
||
const responseBody = await apiResponse.json();
|
||
console.log('[API] Status:', status);
|
||
console.log('[API] Response:', JSON.stringify(responseBody, null, 2));
|
||
|
||
// Check if preview.hosts is empty
|
||
const hosts = responseBody.preview?.hosts || [];
|
||
if (hosts.length === 0) {
|
||
console.log('✅ Backend correctly parsed 0 hosts');
|
||
} else {
|
||
console.warn('❌ Backend unexpectedly returned hosts:', hosts);
|
||
}
|
||
|
||
// Check if warnings exist for unsupported features
|
||
if (hosts.some((h: any) => h.warnings?.length > 0)) {
|
||
console.log('✅ Backend included warnings:', hosts[0].warnings);
|
||
} else {
|
||
console.warn('❌ Backend did NOT include warnings about file_server');
|
||
}
|
||
} else {
|
||
console.log('[API] No upload request observed (likely client-side validation path)');
|
||
}
|
||
|
||
// Verify user-facing error/warning (use .first() since we may have multiple warning banners)
|
||
console.log('[Verification] Checking for warning/error message...');
|
||
const warningMessage = page.locator('.bg-yellow-900, .bg-yellow-900\\/20, .bg-red-900').first();
|
||
await expect(warningMessage).toBeVisible({ timeout: 5000 });
|
||
console.log('[Verification] ✅ Warning/Error message visible');
|
||
|
||
const warningText = await warningMessage.textContent();
|
||
console.log('[Verification] Warning/Error displayed:', warningText);
|
||
|
||
// Should mention "file server" or "not supported" or "no sites found"
|
||
expect(warningText?.toLowerCase()).toMatch(/file.?server|not supported|no (sites|hosts|domains) found/);
|
||
console.log('[Verification] ✅ Message mentions unsupported features');
|
||
|
||
console.log('\n=== Test 3: Complete ===\n');
|
||
});
|
||
|
||
/**
|
||
* Test 5: Caddyfile with Mixed Content (Valid + Unsupported)
|
||
*
|
||
* Objective: Test partial import scenario - some hosts valid, some skipped/warned
|
||
* Expected: ⚠️ May FAIL if skipped hosts not communicated
|
||
*/
|
||
test('should handle mixed valid/invalid hosts and provide detailed feedback', async ({ page }) => {
|
||
console.log('\n=== Test 5: Mixed Content ===');
|
||
|
||
// Auth state loaded from storage
|
||
console.log('[Auth] Using stored authentication state');
|
||
await openImportPageDeterministic(page);
|
||
console.log('[Navigation] Navigated to import page');
|
||
|
||
const mixedCaddyfile = `
|
||
# Valid reverse proxy
|
||
api.example.com {
|
||
reverse_proxy localhost:8080
|
||
}
|
||
|
||
# File server (should be skipped)
|
||
static.example.com {
|
||
file_server
|
||
root * /var/www
|
||
}
|
||
|
||
# Valid reverse proxy with WebSocket
|
||
ws.example.com {
|
||
reverse_proxy localhost:9000 {
|
||
header_up Upgrade websocket
|
||
}
|
||
}
|
||
|
||
# Redirect (should be warned)
|
||
redirect.example.com {
|
||
redir https://other.example.com{uri}
|
||
}
|
||
`.trim();
|
||
|
||
console.log('[Input] Mixed content Caddyfile:');
|
||
console.log(mixedCaddyfile);
|
||
|
||
// Paste mixed content
|
||
console.log('[Action] Filling textarea...');
|
||
await fillImportTextarea(page, mixedCaddyfile);
|
||
console.log('[Action] ✅ Content pasted');
|
||
|
||
// Parse and capture response (FIX: waiter registered first)
|
||
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();
|
||
console.log('[API] Response:', JSON.stringify(responseBody, null, 2));
|
||
|
||
// Analyze what was parsed
|
||
const hosts = responseBody.preview?.hosts || [];
|
||
console.log(`[Analysis] Parsed ${hosts.length} hosts:`, hosts.map((h: any) => h.domain_names));
|
||
|
||
// Should find 2 valid reverse proxies (api + ws)
|
||
expect(hosts.length).toBeGreaterThanOrEqual(2);
|
||
console.log('✅ Found at least 2 hosts');
|
||
|
||
// Check if static.example.com is in list (should NOT be, or should have warning)
|
||
const staticHost = hosts.find((h: any) => h.domain_names === 'static.example.com');
|
||
if (staticHost) {
|
||
console.warn('⚠️ static.example.com was included:', staticHost);
|
||
expect(staticHost.warnings).toBeDefined();
|
||
expect(staticHost.warnings.length).toBeGreaterThan(0);
|
||
console.log('✅ But has warnings:', staticHost.warnings);
|
||
} else {
|
||
console.log('✅ static.example.com correctly excluded');
|
||
}
|
||
|
||
// Check if redirect host has warnings
|
||
const redirectHost = hosts.find((h: any) => h.domain_names === 'redirect.example.com');
|
||
if (redirectHost) {
|
||
console.log('ℹ️ redirect.example.com included:', redirectHost);
|
||
}
|
||
|
||
// Verify UI shows all importable hosts (use test-id to avoid matching textarea)
|
||
console.log('[Verification] Checking if valid hosts visible in preview...');
|
||
const reviewTable = page.getByTestId('import-review-table');
|
||
await expect(reviewTable.getByText('api.example.com')).toBeVisible();
|
||
console.log('[Verification] \u2705 api.example.com visible');
|
||
await expect(reviewTable.getByText('ws.example.com')).toBeVisible();
|
||
console.log('[Verification] ✅ ws.example.com visible');
|
||
|
||
// Check if warnings are displayed
|
||
const warningElements = page.locator('.text-yellow-400, .bg-yellow-900');
|
||
const warningCount = await warningElements.count();
|
||
console.log(`[Verification] UI displays ${warningCount} warning indicators`);
|
||
|
||
console.log('\n=== Test 5: Complete ===\n');
|
||
});
|
||
});
|
||
|
||
test.describe('Parse Errors', () => {
|
||
/**
|
||
* Test 4: Caddyfile with Invalid Syntax
|
||
*
|
||
* Objective: Expose how parse errors from `caddy adapt` are surfaced to the user
|
||
* Expected: ⚠️ May FAIL if error message is cryptic
|
||
*/
|
||
test('should provide clear error message for invalid Caddyfile syntax', async ({ page }) => {
|
||
console.log('\n=== Test 4: Invalid Syntax ===');
|
||
|
||
// Auth state loaded from storage
|
||
console.log('[Auth] Using stored authentication state');
|
||
await openImportPageDeterministic(page);
|
||
console.log('[Navigation] Navigated to import page');
|
||
|
||
const invalidCaddyfile = `
|
||
broken.example.com {
|
||
reverse_proxy localhost:3000
|
||
this is invalid syntax
|
||
another broken line
|
||
}
|
||
`.trim();
|
||
|
||
console.log('[Input] Invalid Caddyfile:');
|
||
console.log(invalidCaddyfile);
|
||
|
||
// Paste invalid content
|
||
console.log('[Action] Filling textarea...');
|
||
await fillImportTextarea(page, invalidCaddyfile);
|
||
console.log('[Action] ✅ Content pasted');
|
||
|
||
// Parse and capture response (FIX: waiter before click)
|
||
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();
|
||
const responseBody = await apiResponse.json();
|
||
console.log('[API] Status:', status);
|
||
console.log('[API] Error Response:', JSON.stringify(responseBody, null, 2));
|
||
|
||
// Should be 400 Bad Request
|
||
expect(status).toBe(400);
|
||
console.log('✅ Status is 400 Bad Request');
|
||
|
||
// Check error message structure
|
||
if (responseBody.error) {
|
||
console.log('✅ Backend returned error:', responseBody.error);
|
||
|
||
// Check if error mentions "caddy adapt" output
|
||
if (responseBody.error.includes('caddy adapt failed')) {
|
||
console.log('✅ Error includes caddy adapt context');
|
||
} else {
|
||
console.warn('⚠️ Error does NOT mention caddy adapt failure');
|
||
}
|
||
|
||
// Check if error includes line number hint
|
||
if (/line \d+/i.test(responseBody.error)) {
|
||
console.log('✅ Error includes line number reference');
|
||
} else {
|
||
console.warn('⚠️ Error does NOT include line number');
|
||
}
|
||
} else {
|
||
console.error('❌ No error field in response body');
|
||
}
|
||
|
||
// Verify UI displays error
|
||
console.log('[Verification] Checking for error message display...');
|
||
const errorMessage = page.locator('.bg-red-900, .bg-red-900\\/20');
|
||
await expect(errorMessage).toBeVisible({ timeout: 5000 });
|
||
console.log('[Verification] ✅ Error message visible');
|
||
|
||
const errorText = await errorMessage.textContent();
|
||
console.log('[Verification] User-facing error:', errorText);
|
||
|
||
// Error should be actionable
|
||
expect(errorText?.length).toBeGreaterThan(10); // Not just "error"
|
||
console.log('[Verification] ✅ Error message is substantive (>10 chars)');
|
||
|
||
console.log('\n=== Test 4: Complete ===\n');
|
||
});
|
||
});
|
||
|
||
test.describe('Multi-File Flow', () => {
|
||
/**
|
||
* Test 6: Import Directive with Multi-File Upload
|
||
*
|
||
* Objective: Test the multi-file upload flow that SHOULD work for imports
|
||
* Expected: ✅ Should PASS if multi-file implementation is correct
|
||
*/
|
||
test('should reject unsafe multi-file payloads with actionable validation feedback', async ({ page }) => {
|
||
console.log('\n=== Test 6: Multi-File Upload ===');
|
||
|
||
// Auth state loaded from storage
|
||
console.log('[Auth] Using stored authentication state');
|
||
await openImportPageDeterministic(page);
|
||
console.log('[Navigation] Navigated to import page');
|
||
|
||
// Main Caddyfile
|
||
const mainCaddyfile = `
|
||
import sites.d/app.caddy
|
||
|
||
admin.example.com {
|
||
reverse_proxy localhost:9090
|
||
}
|
||
`.trim();
|
||
|
||
// Site file
|
||
const siteCaddyfile = `
|
||
app.example.com {
|
||
reverse_proxy localhost:3000
|
||
}
|
||
|
||
api.example.com {
|
||
reverse_proxy localhost:8080
|
||
}
|
||
`.trim();
|
||
|
||
console.log('[Input] Main Caddyfile:');
|
||
console.log(mainCaddyfile);
|
||
console.log('[Input] Site file (sites.d/app.caddy):');
|
||
console.log(siteCaddyfile);
|
||
|
||
// Click multi-file import button
|
||
console.log('[Action] Looking for multi-file upload button...');
|
||
await page.getByRole('button', { name: /multi.*file|multi.*site/i }).click();
|
||
console.log('[Action] ✅ Multi-file button clicked');
|
||
|
||
// Wait for modal to open
|
||
console.log('[Verification] Waiting for modal to appear...');
|
||
const modal = page.locator('[role="dialog"], .modal, [data-testid="multi-site-modal"]');
|
||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||
console.log('[Verification] ✅ Modal visible');
|
||
|
||
// Find the file input within modal
|
||
// NOTE: File input is intentionally hidden (standard UX pattern - label triggers it)
|
||
// We locate it but don't check visibility since hidden inputs can still receive files
|
||
console.log('[Action] Locating file input...');
|
||
const fileInput = modal.locator('input[type="file"]');
|
||
console.log('[Action] ✅ File input located');
|
||
|
||
// Upload ALL files at once
|
||
console.log('[Action] Uploading both files...');
|
||
await fileInput.setInputFiles([
|
||
{ name: 'Caddyfile', mimeType: 'text/plain', buffer: Buffer.from(mainCaddyfile) },
|
||
{ name: 'app.caddy', mimeType: 'text/plain', buffer: Buffer.from(siteCaddyfile) },
|
||
]);
|
||
console.log('[Action] ✅ Files uploaded');
|
||
|
||
// Click upload/parse button in modal (FIX: waiter first)
|
||
// Use more specific selector to avoid matching multiple buttons
|
||
const uploadButton = modal.getByRole('button', { name: /Parse and Review/i });
|
||
|
||
const apiResponse = await waitForImportResponseOrFallback(
|
||
page,
|
||
async () => {
|
||
await uploadButton.click();
|
||
},
|
||
'debug-multi-file-upload',
|
||
/\/api\/v1\/import\/(upload-multi|upload)/i
|
||
);
|
||
|
||
if (!apiResponse) {
|
||
console.log('[API] No multi-file upload request observed; validating client-side state');
|
||
await expect(modal).toBeVisible();
|
||
await expect(uploadButton).toBeVisible();
|
||
|
||
const clientFeedback = modal.locator('.bg-red-900, .bg-red-900\\/20, .bg-yellow-900, .bg-yellow-900\\/20, [role="alert"]');
|
||
if ((await clientFeedback.count()) > 0) {
|
||
await expect(clientFeedback.first()).toBeVisible();
|
||
const feedbackText = (await clientFeedback.first().textContent()) ?? '';
|
||
expect(feedbackText.trim().length).toBeGreaterThan(0);
|
||
console.log('[Verification] Client-side feedback:', feedbackText);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
console.log('[API] Response received');
|
||
|
||
const status = apiResponse.status();
|
||
const responseBody = await apiResponse.json();
|
||
console.log('[API] Multi-file Status:', status);
|
||
console.log('[API] Multi-file Response:', JSON.stringify(responseBody, null, 2));
|
||
|
||
// Hardened import validation rejects this payload and should provide a clear reason.
|
||
expect(status).toBe(400);
|
||
expect(responseBody.error).toBeDefined();
|
||
expect((responseBody.error as string).toLowerCase()).toMatch(/import failed|parsing caddy json|invalid character/);
|
||
|
||
const hosts = responseBody.preview?.hosts || [];
|
||
console.log(`[Analysis] Parsed ${hosts.length} hosts from multi-file import`);
|
||
console.log('[Analysis] Host domains:', hosts.map((h: any) => h.domain_names));
|
||
expect(hosts.length).toBe(0);
|
||
|
||
// Verify users see explicit rejection feedback in the modal or page alert area.
|
||
const errorBanner = page.locator('.bg-red-900, .bg-red-900\\/20, [role="alert"]').first();
|
||
await expect(errorBanner).toBeVisible({ timeout: 10000 });
|
||
await expect(errorBanner).toContainText(/import failed|parsing caddy json|invalid character/i);
|
||
console.log('[Verification] ✅ Rejection feedback visible with actionable message');
|
||
|
||
console.log('\n=== Test 6: ✅ PASSED ===\n');
|
||
});
|
||
});
|
||
});
|