Files
excalidraw-mcp-sentinel/tests/backend/arrow-bindings.test.ts
T
Sanjib DevnathandGitHub 2e743c1356 🐛 fix(sync): resolve delete persistence regression and harden data-safety invariants (#13)
Deletions made in the UI were silently lost on page reload because the sync
baseline (lastSyncedElementsRef) was never populated after initial load,
making the delta algorithm unable to detect removed elements. Additionally,
import_scene and restore_snapshot used a non-atomic clear+create pattern
that could permanently lose all canvas data if the batch create failed
after clearing, and duplicate_elements copied stale binding references
pointing to original element IDs instead of remapped duplicates.

🔧 Sync baseline restoration:
- Populate deletion-detection baseline on every server-to-client data path
  (page load, delta resync, hello handshake, initial elements broadcast)
- Establish sync version and hash baselines to prevent phantom re-syncs

🛡️ Data-loss prevention:
- Backup current scene before destructive clear in replace-mode operations
- Atomic restore from backup when subsequent batch create fails
- Remap all binding references (start/end IDs, boundElements, containerId)
  to new IDs during element duplication

 Comprehensive test coverage (154 new tests, 344 total):
- Delta sync flows including deletion persistence and bidirectional sync
- Multi-tenant element/sync/WebSocket isolation
- Arrow binding resolution across all shape types and edge cases
- MCP tool integration covering backup-restore and binding remapping
- Input validation and security boundary testing
- Frontend sync algorithm unit tests reproducing the exact regression

🎯 Eliminates the most critical data-integrity risks: deletions now
persist reliably, destructive operations are rollback-safe, and the
full test suite provides regression coverage for every sync path.
2026-03-18 10:07:07 +05:30

267 lines
8.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, setActiveTenant } 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;
let app: any;
function makeRect(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `rect-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 0,
y: 0,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
function makeEllipse(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `ell-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'ellipse',
x: 0,
y: 0,
width: 120,
height: 120,
version: 1,
...overrides,
};
}
function makeDiamond(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `dia-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'diamond',
x: 0,
y: 0,
width: 100,
height: 100,
version: 1,
...overrides,
};
}
function makeArrow(id: string, startId?: string, endId?: string): any {
return {
id,
type: 'arrow',
x: 0,
y: 0,
width: 100,
height: 0,
...(startId ? { start: { id: startId } } : {}),
...(endId ? { end: { id: endId } } : {}),
};
}
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-arrow-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Arrow Binding Resolution via Batch Create ──────────────
describe('Arrow binding resolution - rectangles', () => {
it('resolves arrow between two rectangles', async () => {
const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'r2', x: 300, y: 0, width: 100, height: 50 });
const arrow = makeArrow('a1', 'r1', 'r2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
expect(res.body.success).toBe(true);
const createdArrow = res.body.elements.find((e: any) => e.id === 'a1');
expect(createdArrow).toBeDefined();
// Arrow should have computed start/end points
expect(typeof createdArrow.x).toBe('number');
expect(typeof createdArrow.y).toBe('number');
expect(typeof createdArrow.width).toBe('number');
expect(typeof createdArrow.height).toBe('number');
});
it('arrow points are positioned between the two rectangles', async () => {
const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'r2', x: 400, y: 0, width: 100, height: 50 });
const arrow = makeArrow('a1', 'r1', 'r2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
const a = res.body.elements.find((e: any) => e.id === 'a1');
// Arrow should have reasonable coordinates between the two shapes
// The exact positions depend on edge-point computation; just verify it's between the two shape centers
expect(a.x).toBeGreaterThanOrEqual(0);
expect(a.x + a.width).toBeLessThanOrEqual(600);
});
});
describe('Arrow binding resolution - ellipses', () => {
it('resolves arrow between two ellipses', async () => {
const e1 = makeEllipse({ id: 'e1', x: 0, y: 0, width: 80, height: 80 });
const e2 = makeEllipse({ id: 'e2', x: 300, y: 0, width: 80, height: 80 });
const arrow = makeArrow('ae1', 'e1', 'e2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [e1, e2, arrow] });
expect(res.body.success).toBe(true);
const a = res.body.elements.find((e: any) => e.id === 'ae1');
expect(a).toBeDefined();
});
});
describe('Arrow binding resolution - diamonds', () => {
it('resolves arrow between two diamonds', async () => {
const d1 = makeDiamond({ id: 'd1', x: 0, y: 0, width: 100, height: 100 });
const d2 = makeDiamond({ id: 'd2', x: 300, y: 0, width: 100, height: 100 });
const arrow = makeArrow('ad1', 'd1', 'd2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [d1, d2, arrow] });
expect(res.body.success).toBe(true);
const a = res.body.elements.find((e: any) => e.id === 'ad1');
expect(a).toBeDefined();
});
});
describe('Arrow binding resolution - mixed shapes', () => {
it('resolves arrow from rectangle to ellipse', async () => {
const r = makeRect({ id: 'mr', x: 0, y: 0, width: 100, height: 50 });
const e = makeEllipse({ id: 'me', x: 300, y: 0, width: 80, height: 80 });
const arrow = makeArrow('ma1', 'mr', 'me');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r, e, arrow] });
expect(res.body.success).toBe(true);
});
it('resolves arrow from diamond to rectangle', async () => {
const d = makeDiamond({ id: 'md', x: 0, y: 0, width: 100, height: 100 });
const r = makeRect({ id: 'mr2', x: 300, y: 0, width: 150, height: 80 });
const arrow = makeArrow('ma2', 'md', 'mr2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [d, r, arrow] });
expect(res.body.success).toBe(true);
});
});
describe('Arrow binding resolution - edge cases', () => {
it('arrow with only start binding', async () => {
const r = makeRect({ id: 'so', x: 0, y: 0, width: 100, height: 50 });
const arrow = makeArrow('sa1', 'so', undefined);
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r, arrow] });
expect(res.body.success).toBe(true);
});
it('arrow with only end binding', async () => {
const r = makeRect({ id: 'eo', x: 300, y: 0, width: 100, height: 50 });
const arrow = makeArrow('ea1', undefined, 'eo');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r, arrow] });
expect(res.body.success).toBe(true);
});
it('arrow referencing non-existent element does not crash', async () => {
const arrow = makeArrow('ghost-arrow', 'nonexistent-1', 'nonexistent-2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [arrow] });
expect(res.body.success).toBe(true);
});
it('arrow between overlapping shapes (same center)', async () => {
const r1 = makeRect({ id: 'ov1', x: 100, y: 100, width: 100, height: 50 });
const r2 = makeRect({ id: 'ov2', x: 100, y: 100, width: 100, height: 50 });
const arrow = makeArrow('ova', 'ov1', 'ov2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
// Should not crash even with identical centers (dx=0, dy=0)
expect(res.body.success).toBe(true);
});
it('arrow between vertically aligned shapes', async () => {
const r1 = makeRect({ id: 'vr1', x: 100, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'vr2', x: 100, y: 300, width: 100, height: 50 });
const arrow = makeArrow('va', 'vr1', 'vr2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
expect(res.body.success).toBe(true);
const a = res.body.elements.find((e: any) => e.id === 'va');
// Arrow should connect shapes that are vertically aligned — just verify it exists and has valid dimensions
expect(typeof a.width).toBe('number');
expect(typeof a.height).toBe('number');
});
it('cross-batch arrow referencing pre-existing element', async () => {
// Create a shape first
setElement('pre-existing', makeRect({ id: 'pre-existing', x: 0, y: 0, width: 100, height: 50 }));
// Batch create an arrow referencing the pre-existing shape
const r2 = makeRect({ id: 'batch-r', x: 300, y: 0, width: 100, height: 50 });
const arrow = makeArrow('cross-arrow', 'pre-existing', 'batch-r');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r2, arrow] });
expect(res.body.success).toBe(true);
});
it('multiple arrows between same two shapes', async () => {
const r1 = makeRect({ id: 'multi-r1', x: 0, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'multi-r2', x: 300, y: 0, width: 100, height: 50 });
const a1 = makeArrow('multi-a1', 'multi-r1', 'multi-r2');
const a2 = makeArrow('multi-a2', 'multi-r2', 'multi-r1');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, a1, a2] });
expect(res.body.success).toBe(true);
expect(res.body.elements).toHaveLength(4);
});
});