- Add security.ts: helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, Mermaid input limits, rate limiting (general/destructive/burst) - WS auth challenge-response with 5 s timeout and close code 4001 - Fix sync crash: array check before logger access (500 → 400) - Fix sync/v2: validate element type before write (invalid → 400) - Upgrade zod 3.22.4 → 3.25.5 (fixes ERR_PACKAGE_PATH_NOT_EXPORTED on startup) - Extract ElementSharedFieldsSchema; move VALID_ELEMENT_TYPES to module level - Docker: resource limits, .dockerignore hardening - Add .project-hooks/pre-commit; expand test coverage (369 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
const API = 'http://127.0.0.1:3100';
|
|
|
|
async function resetCanvas(request: any): Promise<void> {
|
|
const listRes = await request.get(`${API}/api/elements`);
|
|
const listBody = await listRes.json();
|
|
await Promise.all(
|
|
(listBody.elements ?? []).map((element: { id: string }) =>
|
|
request.delete(`${API}/api/elements/${element.id}`)
|
|
)
|
|
);
|
|
}
|
|
|
|
test.beforeEach(async ({ request }) => {
|
|
await resetCanvas(request);
|
|
});
|
|
|
|
// ─── Page Load ───────────────────────────────────────────────
|
|
|
|
test.describe('Page Load', () => {
|
|
test('canvas page loads successfully', async ({ page }) => {
|
|
await page.goto('/');
|
|
await expect(page.locator('.header h1')).toContainText('Excalidraw Canvas');
|
|
});
|
|
|
|
test('shows connected status after WebSocket connects', async ({ page }) => {
|
|
await page.goto('/');
|
|
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
|
});
|
|
|
|
test('has Clear Canvas button', async ({ page }) => {
|
|
await page.goto('/');
|
|
await expect(page.locator('button:has-text("Clear Canvas")')).toBeVisible();
|
|
});
|
|
|
|
test('has Sync button', async ({ page }) => {
|
|
await page.goto('/');
|
|
await expect(page.locator('button:has-text("Sync")')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ─── Health Endpoint ─────────────────────────────────────────
|
|
|
|
test.describe('Health Endpoint', () => {
|
|
test('returns healthy status', async ({ request }) => {
|
|
const res = await request.get(`${API}/health`);
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.status).toBe('healthy');
|
|
});
|
|
});
|
|
|
|
// ─── API Element CRUD via Playwright request ─────────────────
|
|
|
|
test.describe('Element CRUD via API', () => {
|
|
test('create and list elements', async ({ request }) => {
|
|
const createRes = await request.post(`${API}/api/elements`, {
|
|
data: {
|
|
id: 'e2e-rect-1',
|
|
type: 'rectangle',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 100,
|
|
backgroundColor: '#ff6b6b',
|
|
},
|
|
});
|
|
expect(createRes.ok()).toBe(true);
|
|
|
|
const listRes = await request.get(`${API}/api/elements`);
|
|
const listBody = await listRes.json();
|
|
expect(listBody.count).toBe(1);
|
|
expect(listBody.elements[0].id).toBe('e2e-rect-1');
|
|
});
|
|
|
|
test('batch create elements', async ({ request }) => {
|
|
const batchRes = await request.post(`${API}/api/elements/batch`, {
|
|
data: {
|
|
elements: [
|
|
{ id: 'batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
|
{ id: 'batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
|
{ id: 'batch-3', type: 'text', x: 50, y: 100, text: 'E2E Test' },
|
|
],
|
|
},
|
|
});
|
|
expect(batchRes.ok()).toBe(true);
|
|
|
|
const listRes = await request.get(`${API}/api/elements`);
|
|
const listBody = await listRes.json();
|
|
expect(listBody.count).toBe(3);
|
|
});
|
|
|
|
test('delete element', async ({ request }) => {
|
|
await request.post(`${API}/api/elements`, {
|
|
data: { id: 'del-e2e', type: 'rectangle', x: 50, y: 50, width: 100, height: 100 },
|
|
});
|
|
|
|
const delRes = await request.delete(`${API}/api/elements/del-e2e`);
|
|
expect(delRes.ok()).toBe(true);
|
|
|
|
const checkRes = await request.get(`${API}/api/elements/del-e2e`);
|
|
expect(checkRes.status()).toBe(404);
|
|
});
|
|
|
|
test('clear all elements', async ({ request }) => {
|
|
await request.post(`${API}/api/elements/batch`, {
|
|
data: {
|
|
elements: [
|
|
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
|
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
|
|
],
|
|
},
|
|
});
|
|
|
|
const clearRes = await request.delete(`${API}/api/elements/clear?confirm=true`);
|
|
expect(clearRes.ok()).toBe(true);
|
|
const clearBody = await clearRes.json();
|
|
expect(clearBody.count).toBe(2);
|
|
|
|
const listRes = await request.get(`${API}/api/elements`);
|
|
const listBody = await listRes.json();
|
|
expect(listBody.count).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Real-time Sync ──────────────────────────────────────────
|
|
|
|
test.describe('Real-time Canvas Sync', () => {
|
|
test('element created via API appears in canvas', async ({ page, request }) => {
|
|
await page.goto('/');
|
|
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
await request.post(`${API}/api/elements`, {
|
|
data: {
|
|
id: 'sync-rect',
|
|
type: 'rectangle',
|
|
x: 100,
|
|
y: 100,
|
|
width: 200,
|
|
height: 100,
|
|
},
|
|
});
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Verify element exists in backend
|
|
const verifyRes = await request.get(`${API}/api/elements/sync-rect`);
|
|
expect(verifyRes.ok()).toBe(true);
|
|
});
|
|
|
|
test('canvas_cleared broadcast clears the canvas', async ({ page, request }) => {
|
|
await page.goto('/');
|
|
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
await request.post(`${API}/api/elements`, {
|
|
data: { id: 'clear-test', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
|
});
|
|
await page.waitForTimeout(300);
|
|
|
|
await request.delete(`${API}/api/elements/clear?confirm=true`);
|
|
await page.waitForTimeout(500);
|
|
|
|
const listRes = await request.get(`${API}/api/elements`);
|
|
const body = await listRes.json();
|
|
expect(body.count).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Clear Canvas UI Confirmation ────────────────────────────
|
|
|
|
test.describe('Clear Canvas UI Confirmation', () => {
|
|
test('Clear Canvas button shows confirmation dialog', async ({ page, request }) => {
|
|
// Reset the skip-confirm preference
|
|
await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, {
|
|
data: { value: 'false' },
|
|
});
|
|
|
|
await page.goto('/');
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.locator('button:has-text("Clear Canvas")').click();
|
|
|
|
const dialog = page.locator('.confirm-dialog');
|
|
await expect(dialog).toBeVisible({ timeout: 2000 });
|
|
await expect(dialog.locator('.confirm-title')).toContainText('Clear Canvas');
|
|
});
|
|
|
|
test('Cancel closes the confirmation dialog', async ({ page, request }) => {
|
|
await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, {
|
|
data: { value: 'false' },
|
|
});
|
|
|
|
await page.goto('/');
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.locator('button:has-text("Clear Canvas")').click();
|
|
await expect(page.locator('.confirm-dialog')).toBeVisible();
|
|
|
|
await page.locator('.confirm-dialog button:has-text("Cancel")').click();
|
|
await expect(page.locator('.confirm-dialog')).not.toBeVisible();
|
|
});
|
|
|
|
test('Confirm button clears the canvas', async ({ page, request }) => {
|
|
await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, {
|
|
data: { value: 'false' },
|
|
});
|
|
|
|
await request.post(`${API}/api/elements`, {
|
|
data: { type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
|
});
|
|
|
|
await page.goto('/');
|
|
await page.waitForTimeout(500);
|
|
|
|
await page.locator('button:has-text("Clear Canvas")').click();
|
|
await expect(page.locator('.confirm-dialog')).toBeVisible();
|
|
|
|
await page.locator('.confirm-dialog button:has-text("Clear")').click();
|
|
await expect(page.locator('.confirm-dialog')).not.toBeVisible();
|
|
|
|
await page.waitForTimeout(1000);
|
|
|
|
const listRes = await request.get(`${API}/api/elements`);
|
|
const body = await listRes.json();
|
|
expect(body.count).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Snapshots ───────────────────────────────────────────────
|
|
|
|
test.describe('Snapshots via API', () => {
|
|
test('create and list snapshots', async ({ request }) => {
|
|
await request.post(`${API}/api/elements`, {
|
|
data: { type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
|
});
|
|
|
|
const snapRes = await request.post(`${API}/api/snapshots`, {
|
|
data: { name: 'e2e-snapshot' },
|
|
});
|
|
expect(snapRes.ok()).toBe(true);
|
|
|
|
const listRes = await request.get(`${API}/api/snapshots`);
|
|
const listBody = await listRes.json();
|
|
expect(listBody.snapshots.some((s: any) => s.name === 'e2e-snapshot')).toBe(true);
|
|
});
|
|
|
|
test('get snapshot by name', async ({ request }) => {
|
|
await request.post(`${API}/api/elements`, {
|
|
data: { type: 'ellipse', x: 10, y: 10, width: 30, height: 30 },
|
|
});
|
|
await request.post(`${API}/api/snapshots`, {
|
|
data: { name: 'get-snap' },
|
|
});
|
|
|
|
const res = await request.get(`${API}/api/snapshots/get-snap`);
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.snapshot.name).toBe('get-snap');
|
|
});
|
|
});
|
|
|
|
// ─── Settings via API ────────────────────────────────────────
|
|
|
|
test.describe('Settings via API', () => {
|
|
test('set and get a setting', async ({ request }) => {
|
|
await request.put(`${API}/api/settings/e2e_key`, {
|
|
data: { value: 'e2e_value' },
|
|
});
|
|
|
|
const res = await request.get(`${API}/api/settings/e2e_key`);
|
|
const body = await res.json();
|
|
expect(body.value).toBe('e2e_value');
|
|
});
|
|
|
|
test('returns null for missing key', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/settings/nonexistent_key`);
|
|
const body = await res.json();
|
|
expect(body.value).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── Sync Version API ───────────────────────────────────────
|
|
|
|
test.describe('Sync Version API', () => {
|
|
test('GET /api/sync/version returns initial version', async ({ request }) => {
|
|
const res = await request.get(`${API}/api/sync/version`);
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(typeof body.syncVersion).toBe('number');
|
|
});
|
|
|
|
test('sync version increases after element creation', async ({ request }) => {
|
|
const beforeRes = await request.get(`${API}/api/sync/version`);
|
|
const beforeBody = await beforeRes.json();
|
|
const versionBefore = beforeBody.syncVersion;
|
|
|
|
await request.post(`${API}/api/elements`, {
|
|
data: {
|
|
id: 'sync-ver-el',
|
|
type: 'rectangle',
|
|
x: 10,
|
|
y: 10,
|
|
width: 100,
|
|
height: 50,
|
|
},
|
|
});
|
|
|
|
const afterRes = await request.get(`${API}/api/sync/version`);
|
|
const afterBody = await afterRes.json();
|
|
expect(afterBody.syncVersion).toBeGreaterThan(versionBefore);
|
|
});
|
|
});
|
|
|
|
// ─── Delta Sync v2 API ──────────────────────────────────────
|
|
|
|
test.describe('Delta Sync v2 API', () => {
|
|
test('accepts empty changes and returns current state', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
|
data: { lastSyncVersion: 0, changes: [] },
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(typeof body.currentSyncVersion).toBe('number');
|
|
expect(Array.isArray(body.serverChanges)).toBe(true);
|
|
});
|
|
|
|
test('applies upsert changes via delta sync', async ({ request }) => {
|
|
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
|
data: {
|
|
lastSyncVersion: 0,
|
|
changes: [
|
|
{
|
|
id: 'delta-upsert-1',
|
|
action: 'upsert',
|
|
element: {
|
|
id: 'delta-upsert-1',
|
|
type: 'rectangle',
|
|
x: 50,
|
|
y: 50,
|
|
width: 120,
|
|
height: 60,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.success).toBe(true);
|
|
expect(body.appliedCount).toBeGreaterThanOrEqual(1);
|
|
|
|
// Verify the element exists via GET
|
|
const getRes = await request.get(`${API}/api/elements/delta-upsert-1`);
|
|
expect(getRes.ok()).toBe(true);
|
|
const getBody = await getRes.json();
|
|
expect(getBody.element.id).toBe('delta-upsert-1');
|
|
});
|
|
|
|
test('returns server changes for elements created via normal API', async ({ request }) => {
|
|
// Create an element via the normal REST API
|
|
await request.post(`${API}/api/elements`, {
|
|
data: {
|
|
id: 'normal-api-el',
|
|
type: 'ellipse',
|
|
x: 200,
|
|
y: 200,
|
|
width: 80,
|
|
height: 80,
|
|
},
|
|
});
|
|
|
|
// Now call delta sync with lastSyncVersion: 0 to get all server changes
|
|
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
|
|
data: { lastSyncVersion: 0, changes: [] },
|
|
});
|
|
expect(syncRes.ok()).toBe(true);
|
|
const syncBody = await syncRes.json();
|
|
expect(syncBody.serverChanges.some((el: any) => el.id === 'normal-api-el')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── canvasStatus in API responses ──────────────────────────
|
|
|
|
test.describe('canvasStatus in API responses', () => {
|
|
test('element creation response includes canvasStatus', async ({ request }) => {
|
|
const createRes = await request.post(`${API}/api/elements`, {
|
|
data: {
|
|
id: 'status-check-el',
|
|
type: 'rectangle',
|
|
x: 300,
|
|
y: 300,
|
|
width: 150,
|
|
height: 75,
|
|
},
|
|
});
|
|
expect(createRes.ok()).toBe(true);
|
|
const body = await createRes.json();
|
|
|
|
// syncedToCanvas should be a boolean
|
|
expect(typeof body.syncedToCanvas).toBe('boolean');
|
|
|
|
// canvasStatus object should be present with expected fields
|
|
expect(body.canvasStatus).toBeDefined();
|
|
expect(typeof body.canvasStatus.connectedBrowsers).toBe('number');
|
|
expect(typeof body.canvasStatus.ackedBy).toBe('number');
|
|
expect(typeof body.canvasStatus.reason).toBe('string');
|
|
expect(typeof body.canvasStatus.scope).toBe('string');
|
|
});
|
|
});
|
|
|
|
// ─── Real-time Sync with ACK ────────────────────────────────
|
|
|
|
test.describe('Real-time Sync with ACK', () => {
|
|
test('syncedToCanvas is true when browser is connected', async ({ page, request }) => {
|
|
// Open the page and wait for WebSocket connection
|
|
await page.goto('/');
|
|
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
// Create an element via API while browser is connected
|
|
const createRes = await request.post(`${API}/api/elements`, {
|
|
data: {
|
|
id: 'ack-test-rect',
|
|
type: 'rectangle',
|
|
x: 400,
|
|
y: 400,
|
|
width: 200,
|
|
height: 100,
|
|
backgroundColor: '#4ecdc4',
|
|
},
|
|
});
|
|
expect(createRes.ok()).toBe(true);
|
|
const body = await createRes.json();
|
|
|
|
// Browser should have ACKed, so syncedToCanvas should be true
|
|
expect(body.syncedToCanvas).toBe(true);
|
|
|
|
// Also verify the element exists in the backend
|
|
const verifyRes = await request.get(`${API}/api/elements/ack-test-rect`);
|
|
expect(verifyRes.ok()).toBe(true);
|
|
const verifyBody = await verifyRes.json();
|
|
expect(verifyBody.element.id).toBe('ack-test-rect');
|
|
});
|
|
|
|
test('batch create with browser connected gets ACK', async ({ page, request }) => {
|
|
// Open the page and wait for WebSocket connection
|
|
await page.goto('/');
|
|
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
// Batch create elements via API while browser is connected
|
|
const batchRes = await request.post(`${API}/api/elements/batch`, {
|
|
data: {
|
|
elements: [
|
|
{ id: 'ack-batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
|
{ id: 'ack-batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
|
],
|
|
},
|
|
});
|
|
expect(batchRes.ok()).toBe(true);
|
|
const body = await batchRes.json();
|
|
|
|
// Browser should have ACKed the batch broadcast
|
|
expect(body.syncedToCanvas).toBe(true);
|
|
});
|
|
});
|