Files
excalidraw-mcp-sentinel/tests/backend/ws.test.ts
T
63209f9d5a feat: add test suite, CI/CD pipeline, setup wizard, and upstream feature ports (#6)
Establish comprehensive quality infrastructure for a project that previously
had zero tests, enabling confident refactoring and community contributions
with automated guardrails. Port upstream enhancements for font normalization,
image element support, and arrow binding preservation.

🏗️ Testing infrastructure:
- Unit tests for SQLite persistence layer and element validation helpers
- Integration tests for REST API, WebSocket broadcast, and arrow binding
- E2E tests with Playwright for canvas rendering and real-time sync
- Vitest + Playwright configuration with proper isolation

👷 CI/CD pipeline:
- Auto-versioning from conventional commits on push to main
- Auto-publish to NPM and Docker Hub on GitHub release
- Matrix testing across Node 18/20/22 with pinned dependencies
- Docker health check with diagnostic logging on failure
- Preserve rollup status checks for branch protection gates

📦 Developer experience:
- Interactive setup wizard for first-time configuration
- Canvas clear confirmation and scene description tools
- Frontend helpers extracted for testability

🔧 Upstream feature ports:
- Font family normalization (string names to numeric IDs)
- Image element support with file management API
- Arrow binding preservation through server round-trips
- Vite config fix for font subsetting worker chunk names
- Idempotent database initialization for standalone Docker mode

🐛 Docker fixes:
- Set EXCALIDRAW_DB_PATH in both Dockerfiles to writable /app/data/
- Make initDb() idempotent and closeDb() reset-safe for test isolation

🎯 Provides the safety net needed for rapid iteration — every PR is
validated across 120 test cases before merge, and releases are fully
automated from commit to published package.

Co-authored-by: sanjibdevnathlabs <devnath.sanjib@gmail.com>
2026-03-13 12:08:07 +05:30

256 lines
7.6 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { initDb, closeDb, setElement, clearElements } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import WebSocket from 'ws';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let port: number;
let startCanvasServer: () => Promise<void>;
let stopCanvasServer: () => Promise<void>;
/**
* Connect a WS client and immediately start buffering all messages.
* Returns the ws handle + a collected messages array.
*/
function connectAndCollect(): Promise<{ ws: WebSocket; messages: any[] }> {
return new Promise((resolve, reject) => {
const messages: any[] = [];
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('message', (raw) => messages.push(JSON.parse(raw.toString())));
ws.on('open', () => {
// Give the server a moment to push initial messages
setTimeout(() => resolve({ ws, messages }), 300);
});
ws.on('error', reject);
});
}
function waitForMessageOfType(ws: WebSocket, type: string, timeoutMs = 5000): Promise<any> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout waiting for message type: ${type}`)), timeoutMs);
const handler = (data: WebSocket.RawData) => {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timer);
ws.off('message', handler);
resolve(msg);
}
};
ws.on('message', handler);
});
}
function connectClient(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
}
function drainInitialMessages(ws: WebSocket): Promise<void> {
return new Promise((resolve) => {
let count = 0;
const handler = () => {
count++;
if (count >= 3) {
ws.off('message', handler);
resolve();
}
};
ws.on('message', handler);
setTimeout(() => {
ws.off('message', handler);
resolve();
}, 1000);
});
}
beforeAll(async () => {
port = 3200 + Math.floor(Math.random() * 100);
process.env.CANVAS_PORT = String(port);
process.env.HOST = 'localhost';
dbPath = path.join(os.tmpdir(), `excalidraw-ws-test-${Date.now()}.db`);
initDb(dbPath);
const mod = await import('../../src/server.js');
startCanvasServer = mod.startCanvasServer;
stopCanvasServer = mod.stopCanvasServer;
await startCanvasServer();
});
afterAll(async () => {
await stopCanvasServer();
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
beforeEach(() => {
clearElements();
});
describe('WebSocket connection', () => {
it('connects and receives tenant_switched, initial_elements, sync_status', async () => {
const { ws, messages } = await connectAndCollect();
const types = messages.map(m => m.type);
expect(types).toContain('tenant_switched');
expect(types).toContain('initial_elements');
expect(types).toContain('sync_status');
const initMsg = messages.find(m => m.type === 'initial_elements');
expect(Array.isArray(initMsg.elements)).toBe(true);
const syncMsg = messages.find(m => m.type === 'sync_status');
expect(syncMsg).toHaveProperty('elementCount');
ws.close();
});
it('receives initial_elements with existing data', async () => {
setElement('init-el', {
id: 'init-el', type: 'rectangle', x: 10, y: 20, width: 100, height: 50, version: 1,
} as ServerElement);
const { ws, messages } = await connectAndCollect();
const initMsg = messages.find(m => m.type === 'initial_elements');
expect(initMsg).toBeDefined();
expect(initMsg.elements.length).toBe(1);
expect(initMsg.elements[0].id).toBe('init-el');
ws.close();
});
});
describe('WebSocket broadcasts', () => {
it('broadcasts element_created on POST /api/elements', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const createdPromise = waitForMessageOfType(ws, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }),
});
const msg = await createdPromise;
expect(msg.element.type).toBe('rectangle');
ws.close();
});
it('broadcasts element_deleted on DELETE /api/elements/:id', async () => {
setElement('del-ws', {
id: 'del-ws', type: 'ellipse', x: 0, y: 0, width: 30, height: 30, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const deletedPromise = waitForMessageOfType(ws, 'element_deleted');
await fetch(`http://localhost:${port}/api/elements/del-ws`, { method: 'DELETE' });
const msg = await deletedPromise;
expect(msg.elementId).toBe('del-ws');
ws.close();
});
it('broadcasts element_updated on PUT /api/elements/:id', async () => {
setElement('upd-ws', {
id: 'upd-ws', type: 'rectangle', x: 0, y: 0, width: 50, height: 50, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const updatedPromise = waitForMessageOfType(ws, 'element_updated');
await fetch(`http://localhost:${port}/api/elements/upd-ws`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: 999 }),
});
const msg = await updatedPromise;
expect(msg.element.x).toBe(999);
ws.close();
});
it('broadcasts canvas_cleared on DELETE /api/elements/clear', async () => {
setElement('clr1', {
id: 'clr1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const clearedPromise = waitForMessageOfType(ws, 'canvas_cleared');
await fetch(`http://localhost:${port}/api/elements/clear`, { method: 'DELETE' });
const msg = await clearedPromise;
expect(msg.type).toBe('canvas_cleared');
expect(msg).toHaveProperty('timestamp');
ws.close();
});
it('broadcasts elements_batch_created on POST /api/elements/batch', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const batchPromise = waitForMessageOfType(ws, 'elements_batch_created');
await fetch(`http://localhost:${port}/api/elements/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
],
}),
});
const msg = await batchPromise;
expect(msg.elements.length).toBe(2);
ws.close();
});
it('broadcasts to multiple connected clients', async () => {
const ws1 = await connectClient();
const ws2 = await connectClient();
await drainInitialMessages(ws1);
await drainInitialMessages(ws2);
const promise1 = waitForMessageOfType(ws1, 'element_created');
const promise2 = waitForMessageOfType(ws2, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'diamond', x: 0, y: 0, width: 60, height: 60 }),
});
const [msg1, msg2] = await Promise.all([promise1, promise2]);
expect(msg1.element.type).toBe('diamond');
expect(msg2.element.type).toBe('diamond');
ws1.close();
ws2.close();
});
});