✨ 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>
This commit is contained in:
co-authored by
sanjibdevnathlabs
parent
4c50472ee4
commit
63209f9d5a
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting } from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
|
||||
// Dynamic import to ensure DB is initialized before module-level code in server.ts runs
|
||||
let app: any;
|
||||
|
||||
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
|
||||
return {
|
||||
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 150,
|
||||
height: 80,
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-api-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Health ──────────────────────────────────────────────────
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns healthy status', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('healthy');
|
||||
expect(res.body).toHaveProperty('elements_count');
|
||||
expect(res.body).toHaveProperty('timestamp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Elements CRUD ───────────────────────────────────────────
|
||||
|
||||
describe('GET /api/elements', () => {
|
||||
it('returns empty list initially', async () => {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.elements).toEqual([]);
|
||||
expect(res.body.count).toBe(0);
|
||||
});
|
||||
|
||||
it('returns elements after creation', async () => {
|
||||
setElement('e1', makeElement({ id: 'e1' }));
|
||||
setElement('e2', makeElement({ id: 'e2' }));
|
||||
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/elements', () => {
|
||||
it('creates an element and returns it', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 10, y: 20, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.element.type).toBe('rectangle');
|
||||
expect(res.body.element.x).toBe(10);
|
||||
expect(res.body.element).toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('accepts a custom id', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ id: 'custom-id', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 });
|
||||
|
||||
expect(res.body.element.id).toBe('custom-id');
|
||||
});
|
||||
|
||||
it('rejects invalid element type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'invalid-type', x: 0, y: 0 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing required fields', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/elements/:id', () => {
|
||||
it('returns element by id', async () => {
|
||||
setElement('find-me', makeElement({ id: 'find-me', type: 'diamond' }));
|
||||
|
||||
const res = await request(app).get('/api/elements/find-me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.element.id).toBe('find-me');
|
||||
expect(res.body.element.type).toBe('diamond');
|
||||
});
|
||||
|
||||
it('returns 404 for missing element', async () => {
|
||||
const res = await request(app).get('/api/elements/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/elements/:id', () => {
|
||||
it('updates an existing element', async () => {
|
||||
setElement('up1', makeElement({ id: 'up1', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/elements/up1')
|
||||
.send({ x: 500, y: 600 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.element.x).toBe(500);
|
||||
expect(res.body.element.y).toBe(600);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent element', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/elements/missing')
|
||||
.send({ x: 1 });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/elements/:id', () => {
|
||||
it('deletes an existing element', async () => {
|
||||
setElement('del1', makeElement({ id: 'del1' }));
|
||||
|
||||
const res = await request(app).delete('/api/elements/del1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const getRes = await request(app).get('/api/elements/del1');
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent element', async () => {
|
||||
const res = await request(app).delete('/api/elements/missing');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/elements/clear', () => {
|
||||
it('clears all elements', async () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
|
||||
const res = await request(app).delete('/api/elements/clear');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 count when already empty', async () => {
|
||||
const res = await request(app).delete('/api/elements/clear');
|
||||
expect(res.body.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Batch Create ────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/elements/batch', () => {
|
||||
it('creates multiple elements at once', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ type: 'ellipse', x: 200, y: 200, width: 80, height: 80 },
|
||||
{ type: 'text', x: 50, y: 50, text: 'Hello' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(3);
|
||||
expect(res.body.elements.length).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects non-array input', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: 'not-an-array' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('resolves arrow bindings between batch elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ id: 'box1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'box2', type: 'rectangle', x: 300, y: 0, width: 100, height: 50 },
|
||||
{ id: 'arr1', type: 'arrow', x: 0, y: 0, start: { id: 'box1' }, end: { id: 'box2' } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const arrow = res.body.elements.find((e: any) => e.id === 'arr1');
|
||||
expect(arrow).toBeDefined();
|
||||
expect(arrow.points).toBeDefined();
|
||||
expect(arrow.startBinding).toBeDefined();
|
||||
expect(arrow.endBinding).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search ──────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/elements/search', () => {
|
||||
it('filters by type query param', async () => {
|
||||
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
|
||||
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
|
||||
|
||||
const res = await request(app).get('/api/elements/search?type=rectangle');
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.elements[0].type).toBe('rectangle');
|
||||
});
|
||||
|
||||
it('full-text search via q param', async () => {
|
||||
setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } }));
|
||||
setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } }));
|
||||
|
||||
const res = await request(app).get('/api/elements/search?q=Hello');
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.elements[0].id).toBe('t1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync ────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/elements/sync', () => {
|
||||
it('replaces all elements from frontend', async () => {
|
||||
setElement('old', makeElement({ id: 'old' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({
|
||||
elements: [
|
||||
{ id: 'new1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 },
|
||||
{ id: 'new2', type: 'ellipse', x: 50, y: 50, width: 20, height: 20 },
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(2);
|
||||
expect(listRes.body.elements.map((e: any) => e.id).sort()).toEqual(['new1', 'new2']);
|
||||
});
|
||||
|
||||
it('rejects non-array elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: 'nope', timestamp: new Date().toISOString() });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshots ───────────────────────────────────────────────
|
||||
|
||||
describe('Snapshots API', () => {
|
||||
it('POST creates and GET lists snapshots', async () => {
|
||||
setElement('se1', makeElement({ id: 'se1' }));
|
||||
|
||||
const createRes = await request(app)
|
||||
.post('/api/snapshots')
|
||||
.send({ name: 'my-snap' });
|
||||
|
||||
expect(createRes.status).toBe(200);
|
||||
expect(createRes.body.name).toBe('my-snap');
|
||||
expect(createRes.body.elementCount).toBe(1);
|
||||
|
||||
const listRes = await request(app).get('/api/snapshots');
|
||||
expect(listRes.body.count).toBe(1);
|
||||
});
|
||||
|
||||
it('GET /api/snapshots/:name returns a specific snapshot', async () => {
|
||||
setElement('s1', makeElement({ id: 's1' }));
|
||||
await request(app).post('/api/snapshots').send({ name: 'get-snap' });
|
||||
|
||||
const res = await request(app).get('/api/snapshots/get-snap');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.snapshot.name).toBe('get-snap');
|
||||
});
|
||||
|
||||
it('GET /api/snapshots/:name returns 404 for missing', async () => {
|
||||
const res = await request(app).get('/api/snapshots/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST rejects missing name', async () => {
|
||||
const res = await request(app).post('/api/snapshots').send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenants API ─────────────────────────────────────────────
|
||||
|
||||
describe('Tenants API', () => {
|
||||
it('GET /api/tenants returns tenant list', async () => {
|
||||
const res = await request(app).get('/api/tenants');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Array.isArray(res.body.tenants)).toBe(true);
|
||||
expect(res.body.tenants.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('GET /api/tenant/active returns current tenant', async () => {
|
||||
const res = await request(app).get('/api/tenant/active');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tenant.id).toBe('default');
|
||||
});
|
||||
|
||||
it('PUT /api/tenant/active switches tenant', async () => {
|
||||
const { ensureTenant } = await import('../../src/db.js');
|
||||
ensureTenant('switch-test', 'Switch Test', '/test');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'switch-test' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tenant.id).toBe('switch-test');
|
||||
});
|
||||
|
||||
it('PUT /api/tenant/active rejects missing tenantId', async () => {
|
||||
const res = await request(app).put('/api/tenant/active').send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Settings API ────────────────────────────────────────────
|
||||
|
||||
describe('Settings API', () => {
|
||||
it('GET returns null for missing key', async () => {
|
||||
const res = await request(app).get('/api/settings/missing_key');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.value).toBeNull();
|
||||
});
|
||||
|
||||
it('PUT + GET round-trips a value', async () => {
|
||||
await request(app)
|
||||
.put('/api/settings/my_key')
|
||||
.send({ value: 'my_value' });
|
||||
|
||||
const res = await request(app).get('/api/settings/my_key');
|
||||
expect(res.body.value).toBe('my_value');
|
||||
});
|
||||
|
||||
it('PUT rejects missing value', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/settings/no_val')
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Status ─────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/sync/status', () => {
|
||||
it('returns sync status', async () => {
|
||||
const res = await request(app).get('/api/sync/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body).toHaveProperty('elementCount');
|
||||
expect(res.body).toHaveProperty('memoryUsage');
|
||||
expect(res.body).toHaveProperty('websocketClients');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenant-scoped via X-Tenant-Id header ────────────────────
|
||||
|
||||
describe('Tenant-scoped requests via X-Tenant-Id', () => {
|
||||
it('elements are isolated per tenant', async () => {
|
||||
const { ensureTenant } = await import('../../src/db.js');
|
||||
ensureTenant('tenant-a', 'A', '/a');
|
||||
ensureTenant('tenant-b', 'B', '/b');
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'tenant-a')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 10, height: 10 });
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'tenant-b')
|
||||
.send({ type: 'ellipse', x: 0, y: 0, width: 10, height: 10 });
|
||||
|
||||
const resA = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'tenant-a');
|
||||
expect(resA.body.count).toBe(1);
|
||||
expect(resA.body.elements[0].type).toBe('rectangle');
|
||||
|
||||
const resB = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'tenant-b');
|
||||
expect(resB.body.count).toBe(1);
|
||||
expect(resB.body.elements[0].type).toBe('ellipse');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
initDb,
|
||||
closeDb,
|
||||
setElement,
|
||||
getElement,
|
||||
hasElement,
|
||||
deleteElement,
|
||||
getAllElements,
|
||||
getElementCount,
|
||||
clearElements,
|
||||
queryElements,
|
||||
searchElements,
|
||||
getElementHistory,
|
||||
getProjectHistory,
|
||||
saveSnapshot,
|
||||
getSnapshot,
|
||||
listSnapshots,
|
||||
ensureTenant,
|
||||
setActiveTenant,
|
||||
getActiveTenant,
|
||||
getActiveTenantId,
|
||||
listTenants,
|
||||
createProject,
|
||||
listProjects,
|
||||
setActiveProject,
|
||||
getActiveProject,
|
||||
getActiveProjectId,
|
||||
getDefaultProjectForTenant,
|
||||
bulkReplaceElements,
|
||||
getSetting,
|
||||
setSetting,
|
||||
} from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
|
||||
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
|
||||
return {
|
||||
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 150,
|
||||
height: 80,
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
// Reset module-level active tenant/project to 'default' which initDb() always creates
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Element CRUD ────────────────────────────────────────────
|
||||
|
||||
describe('Element CRUD', () => {
|
||||
it('setElement + getElement round-trips correctly', () => {
|
||||
const el = makeElement({ id: 'e1' });
|
||||
setElement('e1', el);
|
||||
|
||||
const fetched = getElement('e1');
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched!.id).toBe('e1');
|
||||
expect(fetched!.type).toBe('rectangle');
|
||||
expect(fetched!.x).toBe(100);
|
||||
expect(fetched!.y).toBe(200);
|
||||
});
|
||||
|
||||
it('hasElement returns true for existing, false for missing', () => {
|
||||
expect(hasElement('missing')).toBe(false);
|
||||
setElement('exists', makeElement({ id: 'exists' }));
|
||||
expect(hasElement('exists')).toBe(true);
|
||||
});
|
||||
|
||||
it('getElement returns undefined for non-existent id', () => {
|
||||
expect(getElement('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setElement updates an existing element and increments version', () => {
|
||||
const el = makeElement({ id: 'e1' });
|
||||
setElement('e1', el);
|
||||
|
||||
const updated = makeElement({ id: 'e1', x: 999 });
|
||||
setElement('e1', updated);
|
||||
|
||||
const fetched = getElement('e1');
|
||||
expect(fetched!.x).toBe(999);
|
||||
});
|
||||
|
||||
it('deleteElement soft-deletes and returns true', () => {
|
||||
setElement('del1', makeElement({ id: 'del1' }));
|
||||
expect(deleteElement('del1')).toBe(true);
|
||||
expect(getElement('del1')).toBeUndefined();
|
||||
expect(hasElement('del1')).toBe(false);
|
||||
});
|
||||
|
||||
it('deleteElement returns false for non-existent id', () => {
|
||||
expect(deleteElement('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('deleted element can be re-created', () => {
|
||||
setElement('recr', makeElement({ id: 'recr' }));
|
||||
deleteElement('recr');
|
||||
expect(getElement('recr')).toBeUndefined();
|
||||
|
||||
setElement('recr', makeElement({ id: 'recr', x: 42 }));
|
||||
expect(getElement('recr')!.x).toBe(42);
|
||||
});
|
||||
|
||||
it('getAllElements returns all non-deleted elements', () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
setElement('c', makeElement({ id: 'c' }));
|
||||
deleteElement('b');
|
||||
|
||||
const all = getAllElements();
|
||||
expect(all.length).toBe(2);
|
||||
expect(all.map(e => e.id).sort()).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('getElementCount returns correct count', () => {
|
||||
expect(getElementCount()).toBe(0);
|
||||
setElement('x', makeElement({ id: 'x' }));
|
||||
setElement('y', makeElement({ id: 'y' }));
|
||||
expect(getElementCount()).toBe(2);
|
||||
deleteElement('x');
|
||||
expect(getElementCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('clearElements soft-deletes all and returns count', () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
setElement('c', makeElement({ id: 'c' }));
|
||||
|
||||
const count = clearElements();
|
||||
expect(count).toBe(3);
|
||||
expect(getAllElements()).toEqual([]);
|
||||
expect(getElementCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('clearElements on empty canvas returns 0', () => {
|
||||
expect(clearElements()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Query & Search ──────────────────────────────────────────
|
||||
|
||||
describe('queryElements', () => {
|
||||
it('filters by type', () => {
|
||||
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
|
||||
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
|
||||
setElement('r2', makeElement({ id: 'r2', type: 'rectangle' }));
|
||||
|
||||
const rects = queryElements('rectangle');
|
||||
expect(rects.length).toBe(2);
|
||||
expect(rects.every(e => e.type === 'rectangle')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by arbitrary property', () => {
|
||||
setElement('a', makeElement({ id: 'a', x: 10, y: 20 }));
|
||||
setElement('b', makeElement({ id: 'b', x: 10, y: 99 }));
|
||||
|
||||
const results = queryElements(undefined, { x: 10 });
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
it('returns all when no filters', () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
expect(queryElements().length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchElements (FTS)', () => {
|
||||
it('finds elements by label text', () => {
|
||||
setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } }));
|
||||
setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } }));
|
||||
|
||||
const results = searchElements('Hello');
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.id).toBe('t1');
|
||||
});
|
||||
|
||||
it('finds elements by type in FTS', () => {
|
||||
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
|
||||
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
|
||||
|
||||
const results = searchElements('rectangle');
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!.id).toBe('r1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Version History ─────────────────────────────────────────
|
||||
|
||||
describe('Version History', () => {
|
||||
it('records create and update operations', () => {
|
||||
setElement('v1', makeElement({ id: 'v1' }));
|
||||
setElement('v1', makeElement({ id: 'v1', x: 999 }));
|
||||
|
||||
const history = getElementHistory('v1');
|
||||
expect(history.length).toBe(2);
|
||||
expect(history[0]!.operation).toBe('update');
|
||||
expect(history[1]!.operation).toBe('create');
|
||||
});
|
||||
|
||||
it('records delete operation', () => {
|
||||
setElement('v2', makeElement({ id: 'v2' }));
|
||||
deleteElement('v2');
|
||||
|
||||
const history = getElementHistory('v2');
|
||||
expect(history.length).toBe(2);
|
||||
expect(history[0]!.operation).toBe('delete');
|
||||
expect(history[1]!.operation).toBe('create');
|
||||
});
|
||||
|
||||
it('getProjectHistory returns all operations across elements', () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
deleteElement('a');
|
||||
|
||||
const history = getProjectHistory();
|
||||
expect(history.length).toBe(3);
|
||||
});
|
||||
|
||||
it('respects limit parameter', () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
setElement('c', makeElement({ id: 'c' }));
|
||||
|
||||
const history = getProjectHistory(2);
|
||||
expect(history.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshots ───────────────────────────────────────────────
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it('save and retrieve a snapshot', () => {
|
||||
const elements = [makeElement({ id: 's1' }), makeElement({ id: 's2' })];
|
||||
saveSnapshot('snap1', elements);
|
||||
|
||||
const snapshot = getSnapshot('snap1');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot!.name).toBe('snap1');
|
||||
expect(snapshot!.elements.length).toBe(2);
|
||||
});
|
||||
|
||||
it('getSnapshot returns undefined for missing name', () => {
|
||||
expect(getSnapshot('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('listSnapshots returns all snapshots with counts', () => {
|
||||
saveSnapshot('snap-a', [makeElement()]);
|
||||
saveSnapshot('snap-b', [makeElement(), makeElement()]);
|
||||
|
||||
const list = listSnapshots();
|
||||
expect(list.length).toBe(2);
|
||||
const snapB = list.find(s => s.name === 'snap-b');
|
||||
expect(snapB!.elementCount).toBe(2);
|
||||
});
|
||||
|
||||
it('saveSnapshot with same name overwrites', () => {
|
||||
saveSnapshot('dup', [makeElement()]);
|
||||
saveSnapshot('dup', [makeElement(), makeElement(), makeElement()]);
|
||||
|
||||
const snapshot = getSnapshot('dup');
|
||||
expect(snapshot!.elements.length).toBe(3);
|
||||
|
||||
const list = listSnapshots();
|
||||
expect(list.filter(s => s.name === 'dup').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenants ─────────────────────────────────────────────────
|
||||
|
||||
describe('Tenants', () => {
|
||||
it('default tenant exists after initDb', () => {
|
||||
const tenant = getActiveTenant();
|
||||
expect(tenant).toBeDefined();
|
||||
expect(tenant.id).toBe('default');
|
||||
});
|
||||
|
||||
it('ensureTenant creates a new tenant', () => {
|
||||
const t = ensureTenant('t1', 'Test Tenant', '/workspace/test');
|
||||
expect(t.id).toBe('t1');
|
||||
expect(t.name).toBe('Test Tenant');
|
||||
expect(t.workspace_path).toBe('/workspace/test');
|
||||
});
|
||||
|
||||
it('ensureTenant is idempotent', () => {
|
||||
ensureTenant('t1', 'Test', '/path');
|
||||
const t2 = ensureTenant('t1', 'Test', '/path');
|
||||
expect(t2.id).toBe('t1');
|
||||
|
||||
const tenants = listTenants();
|
||||
expect(tenants.filter(t => t.id === 't1').length).toBe(1);
|
||||
});
|
||||
|
||||
it('setActiveTenant switches the active tenant', () => {
|
||||
ensureTenant('t2', 'Tenant 2', '/t2');
|
||||
setActiveTenant('t2');
|
||||
expect(getActiveTenantId()).toBe('t2');
|
||||
});
|
||||
|
||||
it('setActiveTenant throws for non-existent tenant', () => {
|
||||
expect(() => setActiveTenant('no-such')).toThrow();
|
||||
});
|
||||
|
||||
it('listTenants returns all tenants', () => {
|
||||
ensureTenant('a', 'A', '/a');
|
||||
ensureTenant('b', 'B', '/b');
|
||||
|
||||
const tenants = listTenants();
|
||||
expect(tenants.length).toBeGreaterThanOrEqual(3); // default + a + b
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Projects ────────────────────────────────────────────────
|
||||
|
||||
describe('Projects', () => {
|
||||
it('default project exists', () => {
|
||||
const project = getActiveProject();
|
||||
expect(project).toBeDefined();
|
||||
expect(project.id).toBe('default');
|
||||
});
|
||||
|
||||
it('createProject creates and can be listed', () => {
|
||||
const proj = createProject('My Project', 'A test project');
|
||||
expect(proj.name).toBe('My Project');
|
||||
|
||||
const projects = listProjects();
|
||||
expect(projects.some(p => p.name === 'My Project')).toBe(true);
|
||||
});
|
||||
|
||||
it('setActiveProject changes the active project', () => {
|
||||
const proj = createProject('Switch Me');
|
||||
setActiveProject(proj.id);
|
||||
expect(getActiveProjectId()).toBe(proj.id);
|
||||
});
|
||||
|
||||
it('setActiveProject throws for non-existent project', () => {
|
||||
expect(() => setActiveProject('fake')).toThrow();
|
||||
});
|
||||
|
||||
it('elements are scoped to the active project', () => {
|
||||
const proj1 = createProject('P1');
|
||||
const proj2 = createProject('P2');
|
||||
|
||||
setActiveProject(proj1.id);
|
||||
setElement('e1', makeElement({ id: 'e1' }));
|
||||
|
||||
setActiveProject(proj2.id);
|
||||
setElement('e2', makeElement({ id: 'e2' }));
|
||||
|
||||
setActiveProject(proj1.id);
|
||||
expect(getAllElements().length).toBe(1);
|
||||
expect(getAllElements()[0]!.id).toBe('e1');
|
||||
|
||||
setActiveProject(proj2.id);
|
||||
expect(getAllElements().length).toBe(1);
|
||||
expect(getAllElements()[0]!.id).toBe('e2');
|
||||
});
|
||||
|
||||
it('getDefaultProjectForTenant creates a default project if none exists', () => {
|
||||
ensureTenant('orphan', 'Orphan', '/orphan');
|
||||
const projId = getDefaultProjectForTenant('orphan');
|
||||
expect(projId).toBe('orphan-default');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────
|
||||
|
||||
describe('Settings', () => {
|
||||
it('getSetting returns undefined for missing key', () => {
|
||||
expect(getSetting('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setSetting + getSetting round-trips', () => {
|
||||
setSetting('theme', 'dark');
|
||||
expect(getSetting('theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('setSetting overwrites existing value', () => {
|
||||
setSetting('key', 'val1');
|
||||
setSetting('key', 'val2');
|
||||
expect(getSetting('key')).toBe('val2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bulk Operations ─────────────────────────────────────────
|
||||
|
||||
describe('bulkReplaceElements', () => {
|
||||
it('replaces all elements atomically', () => {
|
||||
setElement('old1', makeElement({ id: 'old1' }));
|
||||
setElement('old2', makeElement({ id: 'old2' }));
|
||||
|
||||
const newElements = [makeElement({ id: 'new1' }), makeElement({ id: 'new2' }), makeElement({ id: 'new3' })];
|
||||
const count = bulkReplaceElements(newElements);
|
||||
expect(count).toBe(3);
|
||||
|
||||
const all = getAllElements();
|
||||
expect(all.length).toBe(3);
|
||||
expect(all.map(e => e.id).sort()).toEqual(['new1', 'new2', 'new3']);
|
||||
});
|
||||
|
||||
it('replaces with empty array clears all', () => {
|
||||
setElement('x', makeElement({ id: 'x' }));
|
||||
bulkReplaceElements([]);
|
||||
expect(getAllElements()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user