475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
/**
|
|
* Caddy Import - WebKit-Specific E2E Tests
|
|
*
|
|
* Tests WebKit (Safari) specific edge cases and behaviors to ensure
|
|
* Caddyfile import works correctly on macOS/iOS Safari.
|
|
*
|
|
* EXECUTION:
|
|
* npx playwright test tests/webkit-specific --project=webkit
|
|
*
|
|
* SCOPE:
|
|
* - Event listener attachment and propagation
|
|
* - Async state management * - Form submission behavior
|
|
* - Cookie/session storage handling
|
|
* - Touch event handling (iOS simulation)
|
|
* - Large file performance
|
|
*
|
|
* NOTE: Tests are skipped if not running in WebKit browser.
|
|
*/
|
|
|
|
import { test, expect } from '../../fixtures/auth-fixtures';
|
|
import { Page } from '@playwright/test';
|
|
import {
|
|
attachImportDiagnostics,
|
|
ensureImportUiPreconditions,
|
|
getStoredAuthHeader,
|
|
logImportFailureContext,
|
|
resetImportSession,
|
|
waitForSuccessfulImportResponse,
|
|
} from './import-page-helpers';
|
|
|
|
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', { headers: await getStoredAuthHeader(page) });
|
|
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
|
|
*/
|
|
async function setupImportMocks(page: Page, success: boolean = true) {
|
|
let hasSession = false;
|
|
|
|
await page.route('**/api/v1/import/status', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
json: hasSession
|
|
? {
|
|
has_pending: true,
|
|
session: {
|
|
id: 'webkit-test-session',
|
|
state: 'reviewing',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
}
|
|
: { has_pending: false },
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/v1/import/upload', async (route) => {
|
|
if (success) {
|
|
hasSession = true;
|
|
await route.fulfill({
|
|
status: 200,
|
|
json: {
|
|
session: {
|
|
id: 'webkit-test-session',
|
|
state: 'transient',
|
|
source_file: '/imports/uploads/webkit-test-session.caddyfile',
|
|
},
|
|
preview: {
|
|
hosts: [
|
|
{ domain_names: 'test.example.com', forward_host: 'localhost', forward_port: 3000, forward_scheme: 'http' },
|
|
],
|
|
conflicts: [],
|
|
warnings: [],
|
|
},
|
|
caddyfile_content: 'test.example.com { reverse_proxy localhost:3000 }',
|
|
},
|
|
});
|
|
} else {
|
|
await route.fulfill({
|
|
status: 400,
|
|
json: { error: 'Invalid Caddyfile syntax' },
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.route('**/api/v1/backups', async (route) => {
|
|
if (route.request().method() === 'POST') {
|
|
await route.fulfill({
|
|
status: 201,
|
|
json: { filename: 'backup.tar.gz', size: 1000, time: new Date().toISOString() },
|
|
});
|
|
} else {
|
|
await route.continue();
|
|
}
|
|
});
|
|
}
|
|
|
|
test.describe('Caddy Import - WebKit-Specific @webkit-only', () => {
|
|
const diagnosticsByPage = new WeakMap<Page, () => void>();
|
|
|
|
test.beforeEach(async ({ page, adminUser }) => {
|
|
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 }, testInfo) => {
|
|
diagnosticsByPage.get(page)?.();
|
|
if (testInfo.status !== 'passed') {
|
|
await logImportFailureContext(page, 'caddy-import-webkit');
|
|
}
|
|
await resetImportSession(page).catch(() => {
|
|
// Best-effort cleanup to avoid leaking pending import sessions to subsequent tests.
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 1: Event listener attachment verification
|
|
* Safari/WebKit may handle React event delegation differently
|
|
*/
|
|
test('should have click handler attached to Parse button', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
await test.step('Verify Parse button is clickable in WebKit', async () => {
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await expect(parseButton).toBeVisible();
|
|
|
|
// Fill content to enable button
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('webkit-test.example.com { reverse_proxy localhost:3000 }');
|
|
await expect(parseButton).toBeEnabled();
|
|
|
|
// Verify button responds to pointer events (Safari-specific check)
|
|
const hasPointerEvents = await parseButton.evaluate((btn) => {
|
|
const style = window.getComputedStyle(btn);
|
|
return style.pointerEvents !== 'none';
|
|
});
|
|
expect(hasPointerEvents).toBeTruthy();
|
|
});
|
|
|
|
await test.step('Verify click sends API request', async () => {
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
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');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 2: Async state update race condition
|
|
* WebKit's JavaScript engine (JavaScriptCore) may have different timing
|
|
*/
|
|
test('should handle async state updates correctly', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
await test.step('Set up API mock with delay', async () => {
|
|
await page.route('**/api/v1/import/upload', async (route) => {
|
|
// Simulate network latency
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
await route.fulfill({
|
|
status: 200,
|
|
json: {
|
|
session: {
|
|
id: 'webkit-async-session',
|
|
state: 'transient',
|
|
},
|
|
preview: {
|
|
hosts: [
|
|
{ domain_names: 'async.example.com', forward_host: 'localhost', forward_port: 3000, forward_scheme: 'http' },
|
|
],
|
|
conflicts: [],
|
|
warnings: [],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
await test.step('Fill and submit rapidly', async () => {
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('async.example.com { reverse_proxy localhost:3000 }');
|
|
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-async-state');
|
|
|
|
// Verify UI updates correctly after async operation
|
|
const reviewTable = page.locator('[data-testid="import-review-table"]');
|
|
await expect(reviewTable).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText('async.example.com')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 3: Form submission behavior
|
|
* Safari may treat button clicks inside forms differently
|
|
*/
|
|
test('should handle button click without form submission', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
const navigationOccurred: string[] = [];
|
|
await test.step('Monitor for unexpected navigation', async () => {
|
|
page.on('framenavigated', (frame) => {
|
|
if (frame === page.mainFrame()) {
|
|
navigationOccurred.push(frame.url());
|
|
}
|
|
});
|
|
});
|
|
|
|
await test.step('Click Parse button and verify no form submission', async () => {
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('form-test.example.com { reverse_proxy localhost:3000 }');
|
|
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-form-submit');
|
|
|
|
// Verify no full-page navigation occurred (only initial + maybe same URL)
|
|
const uniqueUrls = [...new Set(navigationOccurred)];
|
|
expect(uniqueUrls.length).toBeLessThanOrEqual(1);
|
|
|
|
// Review table should appear without page reload
|
|
const reviewTable = page.locator('[data-testid="import-review-table"]');
|
|
await expect(reviewTable).toBeVisible();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 4: Cookie/session storage handling
|
|
* WebKit's cookie/storage behavior may differ from Chromium
|
|
*/
|
|
test('should maintain session state and send cookies', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
let requestHeaders: Record<string, string> = {};
|
|
await test.step('Monitor request headers', async () => {
|
|
page.on('request', (request) => {
|
|
if (request.url().includes('/api/v1/import/upload')) {
|
|
requestHeaders = request.headers();
|
|
}
|
|
});
|
|
});
|
|
|
|
await test.step('Perform import and verify cookies sent', async () => {
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('cookie-test.example.com { reverse_proxy localhost:3000 }');
|
|
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-cookie-session');
|
|
|
|
// Verify headers captured
|
|
expect(Object.keys(requestHeaders).length).toBeGreaterThan(0);
|
|
|
|
// Verify cookie or authorization present
|
|
const hasCookie = !!requestHeaders['cookie'];
|
|
const hasAuth = !!requestHeaders['authorization'];
|
|
expect(hasCookie || hasAuth).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 5: Button interaction after rapid state changes
|
|
* Safari may handle rapid state updates differently
|
|
*/
|
|
test('should handle button state changes correctly', async ({ page, adminUser }) => {
|
|
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').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 }');
|
|
await expect(parseButton).toBeEnabled();
|
|
|
|
// Clear content - button should disable again
|
|
await textarea.clear();
|
|
await expect(parseButton).toBeDisabled();
|
|
|
|
// Fill again - button should enable
|
|
await textarea.fill('rapid2.example.com { reverse_proxy localhost:3001 }');
|
|
await expect(parseButton).toBeEnabled();
|
|
});
|
|
|
|
await test.step('Click button and verify loading state', async () => {
|
|
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: [],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 6: Large file handling
|
|
* WebKit memory management may differ from Chromium/Firefox
|
|
*/
|
|
test('should handle large Caddyfile upload without memory issues', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
await test.step('Generate and paste large Caddyfile', async () => {
|
|
// Generate 100 host entries
|
|
let largeCaddyfile = '';
|
|
for (let i = 0; i < 100; i++) {
|
|
largeCaddyfile += `
|
|
safari-host${i}.example.com {
|
|
reverse_proxy backend${i}:${3000 + i}
|
|
tls internal
|
|
encode gzip
|
|
header -Server
|
|
}
|
|
`;
|
|
}
|
|
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill(largeCaddyfile);
|
|
|
|
// Verify textarea updated correctly
|
|
const value = await textarea.inputValue();
|
|
expect(value.length).toBeGreaterThan(10000);
|
|
expect(value).toContain('safari-host99.example.com');
|
|
});
|
|
|
|
await test.step('Upload large file', async () => {
|
|
await page.route('**/api/v1/import/upload', async (route) => {
|
|
const postData = route.request().postData();
|
|
expect(postData).toBeTruthy();
|
|
expect(postData!.length).toBeGreaterThan(10000);
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
json: {
|
|
session: {
|
|
id: 'webkit-large-file-session',
|
|
state: 'transient',
|
|
},
|
|
preview: {
|
|
hosts: Array.from({ length: 100 }, (_, i) => ({
|
|
domain_names: `safari-host${i}.example.com`,
|
|
forward_host: `backend${i}`,
|
|
forward_port: 3000 + i,
|
|
forward_scheme: 'http',
|
|
})),
|
|
conflicts: [],
|
|
warnings: [],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await waitForSuccessfulImportResponse(page, () => parseButton.click(), 'webkit-large-file');
|
|
|
|
// Should complete within reasonable time
|
|
const reviewTable = page.locator('[data-testid="import-review-table"]');
|
|
await expect(reviewTable).toBeVisible({ timeout: 15000 });
|
|
|
|
// Verify hosts rendered
|
|
await expect(page.getByText('safari-host0.example.com')).toBeVisible();
|
|
});
|
|
});
|
|
});
|