388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
/**
|
|
* Caddy Import - Firefox-Specific E2E Tests
|
|
*
|
|
* Tests Firefox-specific edge cases and behaviors to prevent regressions
|
|
* like GitHub Issue #567 (Parse button not working in Firefox).
|
|
*
|
|
* EXECUTION:
|
|
* npx playwright test tests/firefox-specific --project=firefox
|
|
*
|
|
* SCOPE:
|
|
* - Event listener attachment and propagation
|
|
* - Async state update race conditions
|
|
* - CORS preflight handling (if applicable)
|
|
* - Cookie/auth header transmission
|
|
* - Button double-click protection
|
|
* - Large file handling
|
|
*
|
|
* NOTE: Tests are skipped if not running in Firefox browser.
|
|
*/
|
|
|
|
import { test, expect } from '../../fixtures/auth-fixtures';
|
|
import { Page } from '@playwright/test';
|
|
import {
|
|
ensureImportUiPreconditions,
|
|
resetImportSession,
|
|
waitForSuccessfulImportResponse,
|
|
} from './import-page-helpers';
|
|
|
|
/**
|
|
* 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: 'firefox-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: 'firefox-test-session',
|
|
state: 'transient',
|
|
source_file: '/imports/uploads/firefox-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 - Firefox-Specific @firefox-only', () => {
|
|
/**
|
|
* TEST 1: Event listener attachment verification
|
|
* Ensures the Parse button has proper click handlers in Firefox
|
|
*/
|
|
test('should have click handler attached to Parse button', async ({ page, adminUser }) => {
|
|
|
|
await test.step('Navigate to import page', async () => {
|
|
await setupImportMocks(page);
|
|
await resetImportSession(page);
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
await test.step('Verify Parse button exists and is interactive', async () => {
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await expect(parseButton).toBeVisible();
|
|
|
|
// Button should not be disabled when content exists
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('test.example.com { reverse_proxy localhost:3000 }');
|
|
await expect(parseButton).toBeEnabled();
|
|
|
|
// Firefox-safe actionability check without mutating state.
|
|
await parseButton.click({ trial: true });
|
|
});
|
|
|
|
await test.step('Verify click event fires in Firefox', async () => {
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
const response = await waitForSuccessfulImportResponse(
|
|
page,
|
|
() => parseButton.click(),
|
|
'firefox-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
|
|
* Firefox's event loop may expose race conditions in state updates
|
|
*/
|
|
test('should handle rapid click and state updates', async ({ page, adminUser }) => {
|
|
|
|
await test.step('Navigate to import page', async () => {
|
|
await setupImportMocks(page);
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
await test.step('Set up API mock with slight delay', async () => {
|
|
await page.route('**/api/v1/import/upload', async (route) => {
|
|
// Simulate network delay
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
await route.fulfill({
|
|
status: 200,
|
|
json: {
|
|
session: {
|
|
id: 'rapid-click-session',
|
|
state: 'transient',
|
|
},
|
|
preview: {
|
|
hosts: [
|
|
{ domain_names: 'rapid.example.com', forward_host: 'localhost', forward_port: 3000, forward_scheme: 'http' },
|
|
],
|
|
conflicts: [],
|
|
warnings: [],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
await test.step('Fill content and click immediately', async () => {
|
|
const textarea = page.locator('textarea');
|
|
// Fill content rapidly
|
|
await textarea.fill('rapid.example.com { reverse_proxy localhost:3000 }');
|
|
|
|
// Click parse button immediately after fill (no extra wait)
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await parseButton.click();
|
|
|
|
// Verify request was sent despite rapid action
|
|
const reviewTable = page.locator('[data-testid="import-review-table"]');
|
|
await expect(reviewTable).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText('rapid.example.com')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 3: CORS preflight handling
|
|
* Firefox has stricter CORS enforcement; verify no preflight issues
|
|
*/
|
|
test('should handle CORS correctly (same-origin)', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await setupImportMocks(page);
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
const corsIssues: string[] = [];
|
|
await test.step('Monitor for CORS errors', async () => {
|
|
// Listen for failed requests
|
|
page.on('requestfailed', (request) => {
|
|
corsIssues.push(`Failed: ${request.url()} - ${request.failure()?.errorText}`);
|
|
});
|
|
|
|
// Listen for console errors
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error' && msg.text().toLowerCase().includes('cors')) {
|
|
corsIssues.push(`Console: ${msg.text()}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
await test.step('Perform import and check for CORS issues', async () => {
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('cors-test.example.com { reverse_proxy localhost:3000 }');
|
|
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
await waitForSuccessfulImportResponse(
|
|
page,
|
|
() => parseButton.click(),
|
|
'firefox-cors-same-origin',
|
|
/\/api\/v1\/import\/upload/i
|
|
);
|
|
|
|
// Verify no CORS issues
|
|
expect(corsIssues).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 4: Cookie/auth header verification
|
|
* Ensures Firefox sends auth cookies correctly with API requests
|
|
*/
|
|
test('should send authentication cookies with requests', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await setupImportMocks(page);
|
|
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 auth headers', async () => {
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('auth-test.example.com { reverse_proxy localhost:3000 }');
|
|
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
const uploadResponse = await waitForSuccessfulImportResponse(
|
|
page,
|
|
() => parseButton.click(),
|
|
'firefox-auth-headers',
|
|
/\/api\/v1\/import\/upload/i
|
|
);
|
|
|
|
// Verify headers were captured
|
|
const sentHeaders = Object.keys(requestHeaders).length > 0
|
|
? requestHeaders
|
|
: uploadResponse.request().headers();
|
|
expect(Object.keys(sentHeaders).length).toBeGreaterThan(0);
|
|
|
|
// Verify cookie or authorization header present
|
|
const hasCookie = !!sentHeaders['cookie'];
|
|
const hasAuth = !!sentHeaders['authorization'];
|
|
expect(hasCookie || hasAuth).toBeTruthy();
|
|
|
|
// Verify content-type is correct
|
|
expect(sentHeaders['content-type']).toContain('application/json');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 5: Button double-click protection
|
|
* Firefox must prevent duplicate API requests from rapid clicks
|
|
*/
|
|
test('should prevent duplicate requests on double-click', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await setupImportMocks(page);
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
const requestCount: string[] = [];
|
|
await test.step('Monitor API request count', async () => {
|
|
page.on('request', (request) => {
|
|
if (request.url().includes('/api/v1/import/upload')) {
|
|
requestCount.push(request.method());
|
|
}
|
|
});
|
|
});
|
|
|
|
await test.step('Double-click Parse button rapidly', async () => {
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill('doubleclick.example.com { reverse_proxy localhost:3000 }');
|
|
|
|
const parseButton = page.getByRole('button', { name: /parse|review/i });
|
|
|
|
// Double-click rapidly (Firefox may handle differently than Chromium)
|
|
await parseButton.click({ clickCount: 2, delay: 50 });
|
|
|
|
// Wait for any requests to complete
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Should only send ONE request despite double-click
|
|
expect(requestCount.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
await test.step('Verify button disabled during processing', async () => {
|
|
// If a request was sent, button should have been disabled
|
|
if (requestCount.length > 0) {
|
|
const reviewTable = page.locator('[data-testid="import-review-table"]');
|
|
await expect(reviewTable).toBeVisible({ timeout: 10000 });
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* TEST 6: Large file handling
|
|
* Verifies Firefox handles large Caddyfile uploads without lag or timeout
|
|
*/
|
|
test('should handle large Caddyfile upload (10KB+)', async ({ page, adminUser }) => {
|
|
await test.step('Navigate to import page', async () => {
|
|
await setupImportMocks(page);
|
|
await resetImportSession(page);
|
|
await ensureImportUiPreconditions(page, adminUser);
|
|
});
|
|
|
|
await test.step('Generate large Caddyfile content', async () => {
|
|
// Generate deterministic payload >10KB for all browsers/runtimes.
|
|
let largeCaddyfile = '';
|
|
for (let i = 0; i < 180; i++) {
|
|
largeCaddyfile += `
|
|
host${i}.example.com {
|
|
reverse_proxy backend${i}:${3000 + i}
|
|
tls internal
|
|
encode gzip
|
|
}
|
|
`;
|
|
}
|
|
|
|
const textarea = page.locator('textarea');
|
|
await textarea.fill(largeCaddyfile);
|
|
|
|
// Verify no UI lag (textarea should update immediately)
|
|
const value = await textarea.inputValue();
|
|
expect(value.length).toBeGreaterThan(10000);
|
|
expect(value).toContain('host179.example.com');
|
|
});
|
|
|
|
await test.step('Upload large file to API', async () => {
|
|
// Mock upload with large payload
|
|
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: 'large-file-session',
|
|
state: 'transient',
|
|
},
|
|
preview: {
|
|
hosts: Array.from({ length: 100 }, (_, i) => ({
|
|
domain_names: `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 parseButton.click();
|
|
|
|
// Should complete within reasonable time (15 seconds)
|
|
const reviewTable = page.locator('[data-testid="import-review-table"]');
|
|
await expect(reviewTable).toBeVisible({ timeout: 15000 });
|
|
|
|
// Verify hosts are displayed
|
|
await expect(page.getByText('host0.example.com')).toBeVisible();
|
|
});
|
|
});
|
|
});
|