Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1977d86f9 | ||
|
|
2e743c1356 | ||
|
|
459dbfdb3a | ||
|
|
7c59972bb1 |
+97
-5
@@ -185,10 +185,11 @@ function App(): JSX.Element {
|
||||
try {
|
||||
const response = await fetch('/api/elements', { headers: tenantHeaders() })
|
||||
const result: ApiResponse = await response.json()
|
||||
|
||||
|
||||
if (result.success && result.elements) {
|
||||
if (result.elements.length === 0) {
|
||||
excalidrawAPI?.updateScene({ elements: [] })
|
||||
lastSyncedElementsRef.current = new Map()
|
||||
return
|
||||
}
|
||||
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
|
||||
@@ -200,6 +201,30 @@ function App(): JSX.Element {
|
||||
const convertedElements = convertElementsPreservingImageProps(cleanedElements)
|
||||
excalidrawAPI?.updateScene({ elements: convertedElements })
|
||||
}
|
||||
|
||||
// Populate sync baseline so deletions are detected on next sync
|
||||
const baselineMap = new Map<string, ServerElement>()
|
||||
for (const el of result.elements) {
|
||||
baselineMap.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = baselineMap
|
||||
}
|
||||
|
||||
// Fetch current sync version so delta sync works correctly
|
||||
try {
|
||||
const versionRes = await fetch('/api/sync/version', { headers: tenantHeaders() })
|
||||
const versionData = await versionRes.json()
|
||||
if (versionData.success && typeof versionData.syncVersion === 'number') {
|
||||
lastSyncVersionRef.current = versionData.syncVersion
|
||||
lastReceivedSyncVersionRef.current = versionData.syncVersion
|
||||
localStorage.setItem('excalidraw-last-sync-version', String(versionData.syncVersion))
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Set hash baseline so auto-sync doesn't immediately re-sync unchanged content
|
||||
if (excalidrawAPI) {
|
||||
const sceneElements = excalidrawAPI.getSceneElements()
|
||||
lastSyncedHashRef.current = computeElementHash(sceneElements)
|
||||
}
|
||||
|
||||
// Also load files (image data)
|
||||
@@ -260,6 +285,12 @@ function App(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const sendHello = (tenantId: string): void => {
|
||||
const ws = websocketRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId }))
|
||||
}
|
||||
|
||||
const sendAck = (msgId: string | undefined, status: 'applied' | 'partial' | 'failed', elementCount?: number, expectedCount?: number): void => {
|
||||
if (!msgId) return
|
||||
const ws = websocketRef.current
|
||||
@@ -307,6 +338,16 @@ function App(): JSX.Element {
|
||||
lastReceivedSyncVersionRef.current = data.currentSyncVersion
|
||||
lastSyncVersionRef.current = data.currentSyncVersion
|
||||
localStorage.setItem('excalidraw-last-sync-version', String(data.currentSyncVersion))
|
||||
// Update sync baseline so deletion detection works after resync
|
||||
if (api) {
|
||||
const activeElements = api.getSceneElements().filter(el => !el.isDeleted)
|
||||
const baselineMap = new Map<string, any>()
|
||||
for (const el of normalizeForBackend(activeElements)) {
|
||||
baselineMap.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = baselineMap
|
||||
lastSyncedHashRef.current = computeElementHash(api.getSceneElements())
|
||||
}
|
||||
console.log(`Delta resync complete: received ${data.serverChanges.length} changes, now at v${data.currentSyncVersion}`)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -347,6 +388,12 @@ function App(): JSX.Element {
|
||||
elements: convertedElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
// Update sync baseline for deletion detection
|
||||
const initBaseline = new Map<string, any>()
|
||||
for (const el of data.elements) {
|
||||
initBaseline.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = initBaseline
|
||||
}
|
||||
break
|
||||
|
||||
@@ -450,6 +497,32 @@ function App(): JSX.Element {
|
||||
console.log('Received image export request', data)
|
||||
if (data.requestId) {
|
||||
try {
|
||||
// Viewport capture: grab the rendered canvas DOM element directly
|
||||
// This captures exactly what the user sees, respecting zoom/scroll.
|
||||
if (data.captureViewport && data.format !== 'svg') {
|
||||
const canvasEl = document.querySelector('.excalidraw__canvas') as HTMLCanvasElement
|
||||
?? document.querySelector('canvas') as HTMLCanvasElement
|
||||
if (canvasEl) {
|
||||
const dataUrl = canvasEl.toDataURL('image/png')
|
||||
const base64 = dataUrl.split(',')[1]
|
||||
if (base64) {
|
||||
await fetch('/api/export/image/result', {
|
||||
method: 'POST',
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
format: 'png',
|
||||
data: base64
|
||||
})
|
||||
})
|
||||
console.log('Viewport screenshot captured for request', data.requestId)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Fall through to exportToBlob if canvas capture failed
|
||||
console.warn('Viewport canvas capture failed, falling back to exportToBlob')
|
||||
}
|
||||
|
||||
const elements = api.getSceneElements()
|
||||
const appState = api.getAppState()
|
||||
const files = api.getFiles()
|
||||
@@ -547,13 +620,13 @@ function App(): JSX.Element {
|
||||
if (data.scrollToContent) {
|
||||
const allElements = api.getSceneElements()
|
||||
if (allElements.length > 0) {
|
||||
api.scrollToContent(allElements, { fitToViewport: true, animate: true })
|
||||
api.scrollToContent(allElements, { fitToViewport: true, animate: false })
|
||||
}
|
||||
} else if (data.scrollToElementId) {
|
||||
const allElements = api.getSceneElements()
|
||||
const targetElement = allElements.find(el => el.id === data.scrollToElementId)
|
||||
if (targetElement) {
|
||||
api.scrollToContent([targetElement], { fitToViewport: false, animate: true })
|
||||
api.scrollToContent([targetElement], { fitToViewport: false, animate: false })
|
||||
} else {
|
||||
throw new Error(`Element ${data.scrollToElementId} not found`)
|
||||
}
|
||||
@@ -645,8 +718,8 @@ function App(): JSX.Element {
|
||||
console.log('Tenant switched:', data.tenant)
|
||||
if (data.tenant) {
|
||||
const incoming = data.tenant as TenantInfo
|
||||
// Only reload if the switch came from an external source (MCP tool)
|
||||
// and we aren't already on that tenant (UI-driven switch handles its own reload)
|
||||
// Send hello to register WS connection under the correct tenant scope
|
||||
sendHello(incoming.id)
|
||||
if (incoming.id !== activeTenantIdRef.current) {
|
||||
activeTenantIdRef.current = incoming.id
|
||||
setActiveTenant(incoming)
|
||||
@@ -662,6 +735,25 @@ function App(): JSX.Element {
|
||||
}
|
||||
break
|
||||
|
||||
case 'hello_ack':
|
||||
console.log('Hello acknowledged by server:', data.tenantId, data.projectId)
|
||||
if (data.elements && Array.isArray(data.elements) && data.elements.length > 0) {
|
||||
const converted = convertToExcalidrawElements(data.elements)
|
||||
api.updateScene({
|
||||
elements: converted,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
// Update sync baseline for deletion detection
|
||||
const helloBaseline = new Map<string, any>()
|
||||
for (const el of data.elements) {
|
||||
helloBaseline.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = helloBaseline
|
||||
} else if (data.elements && data.elements.length === 0) {
|
||||
lastSyncedElementsRef.current = new Map()
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', data.type)
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
|
||||
+109
-15
@@ -208,9 +208,25 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
|
||||
return result as SyncResponse;
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message);
|
||||
// Don't throw - we want MCP operations to work even if canvas is unavailable
|
||||
return null;
|
||||
const err = error as Error & { cause?: { code?: string } };
|
||||
// Distinguish network errors (canvas truly unavailable) from API errors (canvas responded with error).
|
||||
// Network errors: return null so MCP can degrade gracefully.
|
||||
// API errors: re-throw so the caller gets the actual error message.
|
||||
const isNetworkError = err.message?.includes('fetch failed') ||
|
||||
err.message?.includes('ECONNREFUSED') ||
|
||||
err.cause?.code === 'ECONNREFUSED' ||
|
||||
err.cause?.code === 'ENOTFOUND' ||
|
||||
err.message?.includes('network') ||
|
||||
err.name === 'TypeError'; // fetch throws TypeError for network failures
|
||||
|
||||
if (isNetworkError) {
|
||||
logger.warn(`Canvas unavailable for ${operation}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
// API error — propagate the actual error message
|
||||
logger.warn(`Canvas API error for ${operation}:`, err.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,7 +1059,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...elementProps,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
fontSize: elementProps.fontSize ?? USER_PREFS.fontSize,
|
||||
strokeWidth: elementProps.strokeWidth ?? USER_PREFS.strokeWidth,
|
||||
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
||||
...(startElementId ? { start: { id: startElementId } } : {}),
|
||||
...(endElementId ? { end: { id: endElementId } } : {}),
|
||||
@@ -1549,7 +1568,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...elementProps,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
fontSize: elementProps.fontSize ?? USER_PREFS.fontSize,
|
||||
strokeWidth: elementProps.strokeWidth ?? USER_PREFS.strokeWidth,
|
||||
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
||||
...(startElementId ? { start: { id: startElementId } } : {}),
|
||||
...(endElementId ? { end: { id: endElementId } } : {}),
|
||||
@@ -1819,10 +1841,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (params.mode === 'replace') {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
}
|
||||
|
||||
// Batch create the imported elements
|
||||
const elementsToCreate = importElements.map(el => ({
|
||||
...el,
|
||||
@@ -1832,7 +1850,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
version: 1
|
||||
}));
|
||||
|
||||
const canvasElements = await batchCreateElementsOnCanvas(elementsToCreate);
|
||||
if (params.mode === 'replace') {
|
||||
// Backup current elements before clearing to prevent data loss
|
||||
const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() });
|
||||
const backupData = await backupResp.json() as ApiResponse;
|
||||
const backupElements = backupData.elements || [];
|
||||
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
|
||||
try {
|
||||
await batchCreateElementsOnCanvas(elementsToCreate);
|
||||
} catch (createError) {
|
||||
// Restore backup atomically to prevent data loss
|
||||
logger.error('Import failed after clear, restoring backup:', (createError as Error).message);
|
||||
if (backupElements.length > 0) {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, {
|
||||
method: 'POST',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ elements: backupElements })
|
||||
});
|
||||
}
|
||||
throw new Error(`Import failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`);
|
||||
}
|
||||
} else {
|
||||
await batchCreateElementsOnCanvas(elementsToCreate);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
@@ -1904,24 +1946,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
logger.info('Duplicating elements via MCP', { count: params.elementIds.length });
|
||||
|
||||
const duplicates: ServerElement[] = [];
|
||||
// Build ID map first so binding references can be remapped
|
||||
const idMap = new Map<string, string>();
|
||||
const originals: ServerElement[] = [];
|
||||
for (const id of params.elementIds) {
|
||||
const original = await getElementFromCanvas(id);
|
||||
if (!original) {
|
||||
logger.warn(`Element ${id} not found, skipping duplicate`);
|
||||
continue;
|
||||
}
|
||||
const newId = generateId();
|
||||
idMap.set(id, newId);
|
||||
originals.push(original);
|
||||
}
|
||||
|
||||
const duplicates: ServerElement[] = [];
|
||||
for (const original of originals) {
|
||||
const { createdAt, updatedAt, version, syncedAt, source, syncTimestamp, ...rest } = original;
|
||||
const duplicate: ServerElement = {
|
||||
...rest,
|
||||
id: generateId(),
|
||||
id: idMap.get(original.id) || generateId(),
|
||||
x: original.x + offsetX,
|
||||
y: original.y + offsetY,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1
|
||||
};
|
||||
|
||||
// Remap binding references to point to duplicated elements
|
||||
const dup = duplicate as any;
|
||||
if (dup.startElementId && idMap.has(dup.startElementId)) {
|
||||
dup.startElementId = idMap.get(dup.startElementId);
|
||||
}
|
||||
if (dup.endElementId && idMap.has(dup.endElementId)) {
|
||||
dup.endElementId = idMap.get(dup.endElementId);
|
||||
}
|
||||
if (dup.start?.id && idMap.has(dup.start.id)) {
|
||||
dup.start = { ...dup.start, id: idMap.get(dup.start.id) };
|
||||
}
|
||||
if (dup.end?.id && idMap.has(dup.end.id)) {
|
||||
dup.end = { ...dup.end, id: idMap.get(dup.end.id) };
|
||||
}
|
||||
if (Array.isArray(dup.boundElements)) {
|
||||
dup.boundElements = dup.boundElements.map((be: any) => ({
|
||||
...be,
|
||||
id: idMap.has(be.id) ? idMap.get(be.id) : be.id
|
||||
}));
|
||||
}
|
||||
if (dup.containerId && idMap.has(dup.containerId)) {
|
||||
dup.containerId = idMap.get(dup.containerId);
|
||||
}
|
||||
|
||||
duplicates.push(duplicate);
|
||||
}
|
||||
|
||||
@@ -1976,10 +2051,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } };
|
||||
|
||||
// Backup current elements before clearing to prevent data loss
|
||||
const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() });
|
||||
const backupData = await backupResp.json() as ApiResponse;
|
||||
const backupElements = backupData.elements || [];
|
||||
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
|
||||
// Restore elements
|
||||
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
|
||||
// Restore elements from snapshot
|
||||
try {
|
||||
await batchCreateElementsOnCanvas(data.snapshot.elements);
|
||||
} catch (createError) {
|
||||
// Restore backup atomically to prevent data loss
|
||||
logger.error('Snapshot restore failed after clear, restoring backup:', (createError as Error).message);
|
||||
if (backupElements.length > 0) {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, {
|
||||
method: 'POST',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ elements: backupElements })
|
||||
});
|
||||
}
|
||||
throw new Error(`Snapshot restore failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
@@ -2169,7 +2262,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
format: 'png',
|
||||
background: params.background ?? true
|
||||
background: params.background ?? true,
|
||||
captureViewport: true
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
+45
-7
@@ -198,6 +198,43 @@ async function broadcastWithAck(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Per-Scope Broadcast Serialization ────────────────────────────────────
|
||||
// When multiple MCP tool calls fire in parallel (e.g., parallel create_element),
|
||||
// each produces a broadcastWithAck. Without serialization, the frontend receives
|
||||
// overlapping WS messages and getSceneElements() returns stale snapshots,
|
||||
// causing earlier elements to be clobbered.
|
||||
// This queue ensures broadcasts within the same scope are sent one at a time,
|
||||
// waiting for the previous ACK before sending the next.
|
||||
const scopeBroadcastQueues = new Map<string, Promise<AckResult>>();
|
||||
|
||||
async function serializedBroadcastWithAck(
|
||||
tenantId: string,
|
||||
projectId: string,
|
||||
message: WebSocketMessage,
|
||||
timeoutMs: number = 3000
|
||||
): Promise<AckResult> {
|
||||
const scopeKey = `${tenantId}/${projectId}`;
|
||||
|
||||
// Chain onto the previous broadcast for this scope (or start fresh)
|
||||
const previous = scopeBroadcastQueues.get(scopeKey) ?? Promise.resolve({} as AckResult);
|
||||
|
||||
const current = previous
|
||||
// Wait for previous to settle (success or failure) before sending ours
|
||||
.catch(() => {})
|
||||
.then(() => broadcastWithAck(tenantId, projectId, message, timeoutMs));
|
||||
|
||||
scopeBroadcastQueues.set(scopeKey, current);
|
||||
|
||||
try {
|
||||
return await current;
|
||||
} finally {
|
||||
// Clean up if we're still the tail of the queue
|
||||
if (scopeBroadcastQueues.get(scopeKey) === current) {
|
||||
scopeBroadcastQueues.delete(scopeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy broadcast: sends to ALL connected clients (used for global messages
|
||||
// like tenant_switched that aren't scoped to a single project).
|
||||
function broadcast(message: WebSocketMessage): void {
|
||||
@@ -261,8 +298,8 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'hello') {
|
||||
const helloTenantId = msg.tenantId as string;
|
||||
const helloProjectId = msg.projectId as string;
|
||||
if (helloTenantId && helloProjectId) {
|
||||
const helloProjectId = (msg.projectId as string) || getDefaultProjectForTenant(msg.tenantId) || `${msg.tenantId}-default`;
|
||||
if (helloTenantId) {
|
||||
// Move connection to the correct scope
|
||||
moveConnection(ws, helloTenantId, helloProjectId);
|
||||
logger.info(`Client identified: tenant=${helloTenantId} project=${helloProjectId}`);
|
||||
@@ -429,7 +466,7 @@ app.post('/api/elements', async (req: Request, res: Response) => {
|
||||
element: element
|
||||
};
|
||||
(message as any).sync_version = sv;
|
||||
const ackResult = await broadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -490,7 +527,7 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
|
||||
element: updatedElement
|
||||
};
|
||||
(message as any).sync_version = sv;
|
||||
const ackResult = await broadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -820,7 +857,7 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => {
|
||||
elements: createdElements
|
||||
};
|
||||
(message as any).sync_version = latestSyncVersion;
|
||||
const ackResult = await broadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1101,7 +1138,7 @@ const pendingExports = new Map<string, PendingExport>();
|
||||
|
||||
app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { format, background } = req.body;
|
||||
const { format, background, captureViewport } = req.body;
|
||||
|
||||
if (!format || !['png', 'svg'].includes(format)) {
|
||||
return res.status(400).json({
|
||||
@@ -1133,7 +1170,8 @@ app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
type: 'export_image_request',
|
||||
requestId,
|
||||
format,
|
||||
background: background ?? true
|
||||
background: background ?? true,
|
||||
captureViewport: captureViewport ?? false
|
||||
});
|
||||
|
||||
exportPromise
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
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>;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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 collectMessages(ws: WebSocket, count: number, timeoutMs = 5000): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messages: any[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
ws.off('message', handler);
|
||||
resolve(messages); // return whatever we collected
|
||||
}, timeoutMs);
|
||||
const handler = (data: WebSocket.RawData) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
messages.push(msg);
|
||||
if (messages.length >= count) {
|
||||
clearTimeout(timer);
|
||||
ws.off('message', handler);
|
||||
resolve(messages);
|
||||
}
|
||||
};
|
||||
ws.on('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
port = 3300 + Math.floor(Math.random() * 100);
|
||||
process.env.CANVAS_PORT = String(port);
|
||||
process.env.HOST = 'localhost';
|
||||
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-bugfix-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();
|
||||
});
|
||||
|
||||
// ─── Fix 3: Hello handshake without explicit projectId ──────
|
||||
|
||||
describe('Hello handshake without projectId', () => {
|
||||
it('server resolves projectId when hello only has tenantId', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
|
||||
// Send hello with only tenantId (no projectId)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
tenantId: 'default',
|
||||
// projectId intentionally omitted
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.type).toBe('hello_ack');
|
||||
expect(msg.tenantId).toBe('default');
|
||||
// Server should have resolved a project ID
|
||||
expect(msg.projectId).toBeDefined();
|
||||
expect(typeof msg.projectId).toBe('string');
|
||||
expect(msg.projectId.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(msg.elements)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('hello_ack includes existing elements for the resolved project', async () => {
|
||||
setElement('hello-noproj-el', {
|
||||
id: 'hello-noproj-el', type: 'rectangle', x: 5, y: 10, width: 80, height: 40, version: 1,
|
||||
} as ServerElement);
|
||||
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
tenantId: 'default',
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.elements.length).toBeGreaterThanOrEqual(1);
|
||||
const found = msg.elements.find((el: any) => el.id === 'hello-noproj-el');
|
||||
expect(found).toBeDefined();
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 3: WS registration after hello ──────────────────────
|
||||
|
||||
describe('WS scoped broadcast after hello', () => {
|
||||
it('client receives broadcasts after hello handshake', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
// Send hello to properly register
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
// Now create an element — the hello-registered client should receive the broadcast
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 6: Serialized broadcasts prevent race conditions ────
|
||||
|
||||
describe('Serialized broadcast ordering', () => {
|
||||
it('parallel element creations arrive in order to WS client', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
// Send hello to register properly
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
// Auto-ACK all messages so the serialized queue advances
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.msgId && msg.type !== 'hello_ack') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Fire 5 parallel element creations
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `serial-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
for (const res of responses) {
|
||||
expect(res.ok).toBe(true);
|
||||
}
|
||||
|
||||
// Verify all 5 elements exist in the DB
|
||||
const listRes = await fetch(`http://localhost:${port}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(5);
|
||||
|
||||
const ids = listBody.elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual([
|
||||
'serial-0',
|
||||
'serial-1',
|
||||
'serial-2',
|
||||
'serial-3',
|
||||
'serial-4',
|
||||
]);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('parallel creates all get ACKed when client is responsive', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
// Send hello
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
// Auto-ACK
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.msgId && msg.type !== 'hello_ack') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Fire 3 parallel creates and check all get syncedToCanvas: true
|
||||
const promises = Array.from({ length: 3 }, (_, i) =>
|
||||
fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `ack-serial-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
}),
|
||||
}).then(r => r.json())
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const result of results) {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.syncedToCanvas).toBe(true);
|
||||
}
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sync_version monotonically increases across parallel creates ─
|
||||
|
||||
describe('sync_version ordering with parallel creates', () => {
|
||||
it('each element_created broadcast has a unique monotonic sync_version', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
const receivedVersions: number[] = [];
|
||||
|
||||
// Auto-ACK and collect sync_versions
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'element_created' && msg.sync_version !== undefined) {
|
||||
receivedVersions.push(msg.sync_version);
|
||||
}
|
||||
if (msg.msgId && msg.type !== 'hello_ack') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Create 3 elements in parallel
|
||||
const promises = Array.from({ length: 3 }, (_, i) =>
|
||||
fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `sv-order-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Wait for all broadcasts to be received
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// All 3 sync_versions should be unique
|
||||
expect(receivedVersions.length).toBe(3);
|
||||
const unique = new Set(receivedVersions);
|
||||
expect(unique.size).toBe(3);
|
||||
|
||||
// Due to serialized broadcast, they should arrive in monotonic order
|
||||
for (let i = 1; i < receivedVersions.length; i++) {
|
||||
expect(receivedVersions[i]).toBeGreaterThan(receivedVersions[i - 1]!);
|
||||
}
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, setActiveTenant, clearElements } 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 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-bugfix-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 {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Fix 1: Batch create returns proper error messages ──────
|
||||
|
||||
describe('Batch create error handling', () => {
|
||||
it('rejects invalid element in batch with descriptive error', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ type: 'invalid-type', x: 0, y: 0 }, // invalid type
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
// Should include actual validation error, not "HTTP server unavailable"
|
||||
expect(res.body.error).toBeDefined();
|
||||
expect(res.body.error).not.toContain('HTTP server unavailable');
|
||||
});
|
||||
|
||||
it('batch create with all valid elements succeeds', 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: 0, width: 80, height: 80 },
|
||||
{ type: 'text', x: 50, y: 50, text: 'Hello' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBe(3);
|
||||
});
|
||||
|
||||
it('batch create preserves all elements in DB', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ id: 'b1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'b2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(2);
|
||||
const ids = listRes.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('b1');
|
||||
expect(ids).toContain('b2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 2: Image export endpoint passes captureViewport ────
|
||||
|
||||
describe('Image export captureViewport parameter', () => {
|
||||
it('accepts captureViewport parameter in export request', async () => {
|
||||
// Without a connected WS client, this will 503.
|
||||
// We just verify the endpoint accepts the parameter without crashing.
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'png', background: true, captureViewport: true });
|
||||
|
||||
// 503 = no frontend connected (expected in tests), but not 400 (bad request)
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toContain('No frontend client connected');
|
||||
});
|
||||
|
||||
it('rejects invalid format even with captureViewport', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'bmp', captureViewport: true });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 4: set_viewport uses animate: false ────────────────
|
||||
// (This is tested in E2E where the browser processes viewport commands.)
|
||||
// For the backend, we verify the viewport endpoint accepts requests.
|
||||
|
||||
describe('Viewport endpoint', () => {
|
||||
it('accepts viewport control request', async () => {
|
||||
// Without a connected WS client this will 503
|
||||
const res = await request(app)
|
||||
.post('/api/viewport')
|
||||
.send({ scrollToContent: true });
|
||||
|
||||
// The viewport endpoint may not exist as a REST endpoint — it's WS-driven.
|
||||
// If it returns 404, that's fine; the point is we don't crash.
|
||||
expect([200, 404, 503].includes(res.status)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Concurrent element creation doesn't lose elements ──────
|
||||
|
||||
describe('Concurrent element creation', () => {
|
||||
it('parallel POST /api/elements all persist correctly', async () => {
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements')
|
||||
.send({
|
||||
id: `concurrent-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const res of results) {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
}
|
||||
|
||||
// All 5 elements should exist in the DB
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(5);
|
||||
|
||||
const ids = listRes.body.elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual([
|
||||
'concurrent-0',
|
||||
'concurrent-1',
|
||||
'concurrent-2',
|
||||
'concurrent-3',
|
||||
'concurrent-4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parallel batch + single creates all persist', async () => {
|
||||
const batchPromise = request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ id: 'batch-a', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
||||
{ id: 'batch-b', type: 'ellipse', x: 100, y: 0, width: 50, height: 50 },
|
||||
],
|
||||
});
|
||||
|
||||
const singlePromise = request(app)
|
||||
.post('/api/elements')
|
||||
.send({ id: 'single-c', type: 'diamond', x: 200, y: 0, width: 60, height: 60 });
|
||||
|
||||
const [batchRes, singleRes] = await Promise.all([batchPromise, singlePromise]);
|
||||
expect(batchRes.status).toBe(200);
|
||||
expect(singleRes.status).toBe(200);
|
||||
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setActiveTenant, getCurrentSyncVersion } 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 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-mcp-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 {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Clear Canvas Token Flow (via REST) ─────────────────────
|
||||
// Simulates the clear_canvas MCP tool's token-based confirmation
|
||||
|
||||
describe('Clear canvas confirmation flow', () => {
|
||||
it('DELETE /api/elements/clear removes all elements', async () => {
|
||||
setElement('cl-1', makeElement({ id: 'cl-1' }));
|
||||
setElement('cl-2', makeElement({ id: 'cl-2' }));
|
||||
expect(getAllElements()).toHaveLength(2);
|
||||
|
||||
const res = await request(app).delete('/api/elements/clear');
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBeDefined();
|
||||
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clear on empty canvas returns zero count', async () => {
|
||||
const res = await request(app).delete('/api/elements/clear');
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('cleared elements stay gone on subsequent GET requests', async () => {
|
||||
setElement('stay-gone', makeElement({ id: 'stay-gone' }));
|
||||
await request(app).delete('/api/elements/clear');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Import Scene (Replace Mode) ────────────────────────────
|
||||
// Tests the REST layer that import_scene MCP tool uses
|
||||
|
||||
describe('Import scene - replace mode via sync', () => {
|
||||
it('POST /api/elements/sync replaces all elements atomically', async () => {
|
||||
setElement('old-1', makeElement({ id: 'old-1' }));
|
||||
setElement('old-2', makeElement({ id: 'old-2' }));
|
||||
|
||||
const newElements = [
|
||||
makeElement({ id: 'new-1', x: 0 }),
|
||||
makeElement({ id: 'new-2', x: 100 }),
|
||||
makeElement({ id: 'new-3', x: 200 }),
|
||||
];
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: newElements });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(3);
|
||||
const ids = elements.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['new-1', 'new-2', 'new-3']);
|
||||
});
|
||||
|
||||
it('POST /api/elements/sync with empty array clears all', async () => {
|
||||
setElement('will-be-replaced', makeElement({ id: 'will-be-replaced' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: [] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('old elements do not reappear after replace', async () => {
|
||||
setElement('ghost', makeElement({ id: 'ghost' }));
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: [makeElement({ id: 'replacement' })] });
|
||||
|
||||
// Multiple GET requests should consistently show only the replacement
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.elements[0].id).toBe('replacement');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Import Scene (Merge Mode) ──────────────────────────────
|
||||
|
||||
describe('Import scene - merge mode via batch', () => {
|
||||
it('POST /api/elements/batch adds without removing existing', async () => {
|
||||
setElement('existing', makeElement({ id: 'existing', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
makeElement({ id: 'imported-1', x: 100 }),
|
||||
makeElement({ id: 'imported-2', x: 200 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(3);
|
||||
const ids = elements.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['existing', 'imported-1', 'imported-2']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Restore Snapshot ───────────────────────────────────────
|
||||
|
||||
describe('Snapshot create and restore flow', () => {
|
||||
it('save snapshot, clear, verify snapshot still exists', async () => {
|
||||
setElement('snap-1', makeElement({ id: 'snap-1' }));
|
||||
setElement('snap-2', makeElement({ id: 'snap-2' }));
|
||||
|
||||
// Save snapshot
|
||||
const snapRes = await request(app)
|
||||
.post('/api/snapshots')
|
||||
.send({ name: 'before-clear' });
|
||||
expect(snapRes.body.success).toBe(true);
|
||||
|
||||
// Clear
|
||||
await request(app).delete('/api/elements/clear');
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
// Snapshot should still contain the elements
|
||||
const getRes = await request(app).get('/api/snapshots/before-clear');
|
||||
expect(getRes.body.success).toBe(true);
|
||||
expect(getRes.body.snapshot.elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('restore via sync endpoint preserves all snapshot elements', async () => {
|
||||
const elements = [
|
||||
makeElement({ id: 'rs-1', x: 0 }),
|
||||
makeElement({ id: 'rs-2', x: 100 }),
|
||||
];
|
||||
for (const el of elements) setElement(el.id, el);
|
||||
|
||||
// Save snapshot
|
||||
await request(app).post('/api/snapshots').send({ name: 'restore-test' });
|
||||
|
||||
// Clear and add different elements
|
||||
await request(app).delete('/api/elements/clear');
|
||||
setElement('different', makeElement({ id: 'different' }));
|
||||
|
||||
// Get snapshot
|
||||
const snapRes = await request(app).get('/api/snapshots/restore-test');
|
||||
const snapshotElements = snapRes.body.snapshot.elements;
|
||||
|
||||
// Restore via sync (atomic replace)
|
||||
const syncRes = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: snapshotElements });
|
||||
expect(syncRes.body.success).toBe(true);
|
||||
|
||||
// Verify restored state
|
||||
const final = getAllElements();
|
||||
expect(final).toHaveLength(2);
|
||||
const ids = final.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['rs-1', 'rs-2']);
|
||||
});
|
||||
|
||||
it('restore non-existent snapshot returns 404', async () => {
|
||||
const res = await request(app).get('/api/snapshots/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('snapshot overwrites with same name', async () => {
|
||||
setElement('v1-el', makeElement({ id: 'v1-el' }));
|
||||
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
|
||||
|
||||
setElement('v2-el', makeElement({ id: 'v2-el' }));
|
||||
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
|
||||
|
||||
const res = await request(app).get('/api/snapshots/overwrite-test');
|
||||
expect(res.body.snapshot.elements).toHaveLength(2); // Both elements
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Duplicate Elements ─────────────────────────────────────
|
||||
|
||||
describe('Duplicate elements via API', () => {
|
||||
it('duplicating elements creates new IDs', async () => {
|
||||
setElement('dup-src', makeElement({ id: 'dup-src', x: 0, y: 0 }));
|
||||
|
||||
// Get the original
|
||||
const getRes = await request(app).get('/api/elements/dup-src');
|
||||
expect(getRes.body.success).toBe(true);
|
||||
|
||||
// Create a duplicate via batch (simulating what duplicate_elements does)
|
||||
const original = getRes.body.element;
|
||||
const duplicate = {
|
||||
...original,
|
||||
id: 'dup-copy',
|
||||
x: original.x + 20,
|
||||
y: original.y + 20,
|
||||
};
|
||||
|
||||
const batchRes = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [duplicate] });
|
||||
|
||||
expect(batchRes.body.success).toBe(true);
|
||||
expect(getAllElements()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('duplicated arrow with remapped bindings points to duplicated shapes', async () => {
|
||||
// Create shape + arrow
|
||||
const rect = makeElement({ id: 'dup-rect', x: 0, y: 0, width: 100, height: 50 });
|
||||
const rect2 = makeElement({ id: 'dup-rect2', x: 300, y: 0, width: 100, height: 50 });
|
||||
setElement('dup-rect', rect);
|
||||
setElement('dup-rect2', rect2);
|
||||
|
||||
// Create arrow binding references
|
||||
const arrow = {
|
||||
id: 'dup-arrow',
|
||||
type: 'arrow',
|
||||
x: 100, y: 25,
|
||||
width: 200, height: 0,
|
||||
start: { id: 'dup-rect' },
|
||||
end: { id: 'dup-rect2' },
|
||||
};
|
||||
|
||||
// Simulate duplication with ID remapping
|
||||
const idMap = new Map([
|
||||
['dup-rect', 'copy-rect'],
|
||||
['dup-rect2', 'copy-rect2'],
|
||||
['dup-arrow', 'copy-arrow'],
|
||||
]);
|
||||
|
||||
const dupArrow: any = {
|
||||
...arrow,
|
||||
id: 'copy-arrow',
|
||||
x: arrow.x + 20,
|
||||
y: arrow.y + 20,
|
||||
start: { id: idMap.get(arrow.start.id) || arrow.start.id },
|
||||
end: { id: idMap.get(arrow.end.id) || arrow.end.id },
|
||||
};
|
||||
|
||||
expect(dupArrow.start.id).toBe('copy-rect');
|
||||
expect(dupArrow.end.id).toBe('copy-rect2');
|
||||
|
||||
// Create the duplicated shapes and arrow
|
||||
const batchRes = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
makeElement({ id: 'copy-rect', x: 20, y: 20, width: 100, height: 50 }),
|
||||
makeElement({ id: 'copy-rect2', x: 320, y: 20, width: 100, height: 50 }),
|
||||
dupArrow,
|
||||
],
|
||||
});
|
||||
|
||||
expect(batchRes.body.success).toBe(true);
|
||||
const createdArrow = batchRes.body.elements.find((e: any) => e.id === 'copy-arrow');
|
||||
expect(createdArrow).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mermaid Conversion Relay ───────────────────────────────
|
||||
|
||||
describe('Mermaid conversion relay', () => {
|
||||
it('POST /api/elements/from-mermaid accepts valid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({
|
||||
mermaidDiagram: 'graph TD\n A-->B',
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.mermaidDiagram).toBe('graph TD\n A-->B');
|
||||
expect(res.body.message).toContain('frontend');
|
||||
});
|
||||
|
||||
it('rejects empty mermaid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: '' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing mermaid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts diagram with config options', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({
|
||||
mermaidDiagram: 'sequenceDiagram\n A->>B: Hello',
|
||||
config: { theme: 'dark' },
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.config).toEqual({ theme: 'dark' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Image Export Relay ─────────────────────────────────────
|
||||
|
||||
describe('Image export relay', () => {
|
||||
it('POST /api/export/image without connected browser returns 503', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'png', background: true });
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/export/image accepts captureViewport parameter', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'png', background: true, captureViewport: true });
|
||||
|
||||
// Will be 503 since no browser, but should not 400 on the parameter
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Viewport Relay ─────────────────────────────────────────
|
||||
|
||||
describe('Viewport relay', () => {
|
||||
it('POST /api/viewport without connected browser returns 503', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/viewport')
|
||||
.send({ action: 'scrollToContent' });
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('accepts various viewport actions', async () => {
|
||||
for (const action of ['scrollToContent', 'zoomToFit']) {
|
||||
const res = await request(app)
|
||||
.post('/api/viewport')
|
||||
.send({ action });
|
||||
|
||||
// 503 expected (no browser), but validates the action is accepted
|
||||
expect(res.status).toBe(503);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Files API ──────────────────────────────────────────────
|
||||
|
||||
describe('Files API comprehensive', () => {
|
||||
it('GET /api/files returns empty initially', async () => {
|
||||
const res = await request(app).get('/api/files');
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Object.keys(res.body.files)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('POST /api/files adds files and GET returns them', async () => {
|
||||
await request(app)
|
||||
.post('/api/files')
|
||||
.send({
|
||||
files: {
|
||||
'f1': { id: 'f1', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
|
||||
'f2': { id: 'f2', mimeType: 'image/jpeg', dataURL: 'data:image/jpeg;base64,xyz', created: Date.now() },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/files');
|
||||
expect(Object.keys(res.body.files)).toHaveLength(2);
|
||||
expect(res.body.files['f1'].mimeType).toBe('image/png');
|
||||
expect(res.body.files['f2'].mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('DELETE /api/files/:id removes the file', async () => {
|
||||
await request(app)
|
||||
.post('/api/files')
|
||||
.send({
|
||||
files: {
|
||||
'del-f': { id: 'del-f', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
|
||||
},
|
||||
});
|
||||
|
||||
const delRes = await request(app).delete('/api/files/del-f');
|
||||
expect(delRes.body.success).toBe(true);
|
||||
|
||||
const listRes = await request(app).get('/api/files');
|
||||
expect(listRes.body.files['del-f']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('DELETE /api/files/:id for non-existent file returns 404', async () => {
|
||||
const res = await request(app).delete('/api/files/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/files rejects non-object body', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/files')
|
||||
.send({ files: 'not-an-object' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Status ────────────────────────────────────────────
|
||||
|
||||
describe('Sync status endpoint', () => {
|
||||
it('GET /api/sync/status returns element count', async () => {
|
||||
setElement('ss-1', makeElement({ id: 'ss-1' }));
|
||||
setElement('ss-2', makeElement({ id: 'ss-2' }));
|
||||
|
||||
const res = await request(app).get('/api/sync/status');
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.elementCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Element Version History ────────────────────────────────
|
||||
|
||||
describe('Element version history via API', () => {
|
||||
it('element has version after creation and update', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
expect(createRes.body.success).toBe(true);
|
||||
|
||||
const updateRes = await request(app)
|
||||
.put('/api/elements/hist-el')
|
||||
.send({ x: 500 });
|
||||
expect(updateRes.body.success).toBe(true);
|
||||
|
||||
const getRes = await request(app).get('/api/elements/hist-el');
|
||||
expect(getRes.body.element.x).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Handling ─────────────────────────────────────────
|
||||
|
||||
describe('API error handling', () => {
|
||||
it('POST /api/elements with invalid JSON returns 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('not-json');
|
||||
|
||||
// Express body-parser returns 400 or 500 on parse failure depending on version
|
||||
expect([400, 500]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('PUT /api/elements/:id on non-existent element returns 404', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/elements/nonexistent')
|
||||
.send({ x: 100 });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE /api/elements/:id on non-existent returns 404', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/api/elements/nonexistent');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/elements/batch rejects non-array elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: 'not-an-array' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/snapshots rejects missing name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/snapshots')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-security-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 {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Input Validation ───────────────────────────────────────
|
||||
|
||||
describe('Input validation - element creation', () => {
|
||||
it('rejects element with missing type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects element with invalid type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'malicious<script>', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects element with negative dimensions gracefully', async () => {
|
||||
// Server should handle negative dimensions without crashing
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: -100, height: -50 });
|
||||
|
||||
// May succeed (Excalidraw allows negative) or fail validation — either is acceptable
|
||||
expect([200, 400]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('handles very large coordinates without crashing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 1e15, y: 1e15, width: 100, height: 50 });
|
||||
|
||||
// Should not crash the server
|
||||
expect([200, 400]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - batch operations', () => {
|
||||
it('rejects batch with non-array elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: { not: 'an array' } });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects batch with null elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: null });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('handles extremely large batch without crash', async () => {
|
||||
const elements = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `bulk-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 10,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - sync endpoints', () => {
|
||||
it('POST /api/elements/sync rejects non-array elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: 'not-array' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/elements/sync/v2 rejects non-number lastSyncVersion', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 'not-a-number', changes: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/elements/sync/v2 handles missing changes gracefully', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 0 });
|
||||
|
||||
// Should use default empty array
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - settings', () => {
|
||||
it('PUT /api/settings/:key rejects missing value', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/settings/test')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/settings/:key returns null for missing key', async () => {
|
||||
const res = await request(app).get('/api/settings/nonexistent');
|
||||
expect(res.body.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - tenant operations', () => {
|
||||
it('PUT /api/tenant/active rejects missing tenantId', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PUT /api/tenant/active rejects non-existent tenant', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'nonexistent-tenant-xyz' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - search', () => {
|
||||
it('GET /api/elements/search with no params returns all elements', async () => {
|
||||
const res = await request(app).get('/api/elements/search');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/elements/search handles special characters in query', async () => {
|
||||
const res = await request(app).get('/api/elements/search?q=%22OR%201%3D1');
|
||||
// FTS5 may reject special chars with 500 — acceptable as long as server doesn't crash
|
||||
expect([200, 400, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - mermaid', () => {
|
||||
it('rejects non-string mermaid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: 12345 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Header Handling ────────────────────────────────────────
|
||||
|
||||
describe('X-Tenant-Id header handling', () => {
|
||||
it('invalid X-Tenant-Id gracefully falls back', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'nonexistent-tenant');
|
||||
|
||||
// Should either return empty elements or error — 500 is acceptable for unknown tenant
|
||||
expect([200, 400, 404, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Content-Type Handling ──────────────────────────────────
|
||||
|
||||
describe('Content-Type edge cases', () => {
|
||||
it('POST with no content-type header handles gracefully', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send('');
|
||||
|
||||
// Should not crash
|
||||
expect([200, 400]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, deleteElement, clearElements, getCurrentSyncVersion, getChangesSince, 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 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-sync-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 {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Deletion Flows ──────────────────────────
|
||||
|
||||
describe('Delta sync v2 - deletion flows', () => {
|
||||
it('deletes elements when client sends action:delete', async () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
setElement('c', makeElement({ id: 'c' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'a', action: 'delete' },
|
||||
{ id: 'b', action: 'delete' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.appliedCount).toBe(2);
|
||||
|
||||
const remaining = getAllElements();
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).toBe('c');
|
||||
});
|
||||
|
||||
it('deletes all elements when client sends delete for every element', async () => {
|
||||
setElement('x', makeElement({ id: 'x' }));
|
||||
setElement('y', makeElement({ id: 'y' }));
|
||||
setElement('z', makeElement({ id: 'z' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'x', action: 'delete' },
|
||||
{ id: 'y', action: 'delete' },
|
||||
{ id: 'z', action: 'delete' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.appliedCount).toBe(3);
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('delete for non-existent element does not crash', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'ghost', action: 'delete' }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('deleted elements do not reappear on subsequent GET /api/elements', async () => {
|
||||
setElement('persist-1', makeElement({ id: 'persist-1' }));
|
||||
setElement('persist-2', makeElement({ id: 'persist-2' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: 'persist-1', action: 'delete' }],
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.elements[0].id).toBe('persist-2');
|
||||
});
|
||||
|
||||
it('deleted elements do not reappear after multiple reload cycles', async () => {
|
||||
setElement('reload-1', makeElement({ id: 'reload-1' }));
|
||||
setElement('reload-2', makeElement({ id: 'reload-2' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Simulate: frontend syncs deletions
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'reload-1', action: 'delete' },
|
||||
{ id: 'reload-2', action: 'delete' },
|
||||
],
|
||||
});
|
||||
|
||||
// Simulate: multiple page reloads fetching elements
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(0);
|
||||
expect(res.body.elements).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Mixed Operations ────────────────────────
|
||||
|
||||
describe('Delta sync v2 - mixed operations', () => {
|
||||
it('handles mixed upserts and deletes in single sync', async () => {
|
||||
setElement('a', makeElement({ id: 'a', x: 0 }));
|
||||
setElement('b', makeElement({ id: 'b', x: 100 }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'a', action: 'delete' },
|
||||
{ id: 'c', action: 'upsert', element: makeElement({ id: 'c', x: 200 }) },
|
||||
{ id: 'b', action: 'upsert', element: makeElement({ id: 'b', x: 150 }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.appliedCount).toBe(3);
|
||||
|
||||
const remaining = getAllElements();
|
||||
expect(remaining).toHaveLength(2);
|
||||
const ids = remaining.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['b', 'c']);
|
||||
|
||||
const b = remaining.find(e => e.id === 'b')!;
|
||||
expect(b.x).toBe(150);
|
||||
});
|
||||
|
||||
it('upsert after delete re-creates the element', async () => {
|
||||
setElement('revive', makeElement({ id: 'revive', x: 0 }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Delete it
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: 'revive', action: 'delete' }],
|
||||
});
|
||||
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
// Re-create it
|
||||
const v1 = getCurrentSyncVersion();
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v1,
|
||||
changes: [{ id: 'revive', action: 'upsert', element: makeElement({ id: 'revive', x: 999 }) }],
|
||||
});
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].id).toBe('revive');
|
||||
expect(elements[0].x).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Bidirectional ───────────────────────────
|
||||
|
||||
describe('Delta sync v2 - bidirectional sync', () => {
|
||||
it('returns server-side changes not sent by client', async () => {
|
||||
// Server has elements from MCP
|
||||
setElement('mcp-1', makeElement({ id: 'mcp-1' }));
|
||||
setElement('mcp-2', makeElement({ id: 'mcp-2' }));
|
||||
|
||||
// Client syncs from version 0 with its own new element
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'fe-1', action: 'upsert', element: makeElement({ id: 'fe-1' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
// Server should return mcp-1 and mcp-2 as changes the client hasn't seen
|
||||
const serverChangeIds = res.body.serverChanges.map((c: any) => c.id).sort();
|
||||
expect(serverChangeIds).toEqual(['mcp-1', 'mcp-2']);
|
||||
// fe-1 should NOT be in serverChanges (client already knows about it)
|
||||
expect(serverChangeIds).not.toContain('fe-1');
|
||||
});
|
||||
|
||||
it('server-side deletes appear as delete actions in serverChanges', async () => {
|
||||
setElement('srv-del', makeElement({ id: 'srv-del' }));
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Server-side delete (simulating MCP delete_element)
|
||||
deleteElement('srv-del');
|
||||
const v1 = getCurrentSyncVersion();
|
||||
|
||||
// Client syncs from before the delete
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: v0, changes: [] });
|
||||
|
||||
const deleteChange = res.body.serverChanges.find((c: any) => c.id === 'srv-del');
|
||||
expect(deleteChange).toBeDefined();
|
||||
expect(deleteChange.action).toBe('delete');
|
||||
});
|
||||
|
||||
it('excludes client-sent IDs from serverChanges', async () => {
|
||||
setElement('shared', makeElement({ id: 'shared', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'shared', action: 'upsert', element: makeElement({ id: 'shared', x: 50 }) },
|
||||
],
|
||||
});
|
||||
|
||||
// 'shared' should NOT appear in serverChanges since the client sent it
|
||||
const serverIds = res.body.serverChanges.map((c: any) => c.id);
|
||||
expect(serverIds).not.toContain('shared');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Multiple Rounds ─────────────────────────
|
||||
|
||||
describe('Delta sync v2 - multiple rounds', () => {
|
||||
it('tracks sync version across multiple sync rounds', async () => {
|
||||
// Round 1: create elements
|
||||
const r1 = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a' }) },
|
||||
{ id: 'r1-b', action: 'upsert', element: makeElement({ id: 'r1-b' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(r1.body.currentSyncVersion).toBeGreaterThan(0);
|
||||
const v1 = r1.body.currentSyncVersion;
|
||||
|
||||
// Round 2: update one, delete one, create one
|
||||
const r2 = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v1,
|
||||
changes: [
|
||||
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a', x: 999 }) },
|
||||
{ id: 'r1-b', action: 'delete' },
|
||||
{ id: 'r2-c', action: 'upsert', element: makeElement({ id: 'r2-c' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(r2.body.currentSyncVersion).toBeGreaterThan(v1);
|
||||
expect(r2.body.appliedCount).toBe(3);
|
||||
// No new server-side changes should be returned
|
||||
expect(r2.body.serverChanges).toHaveLength(0);
|
||||
|
||||
// Verify final state
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(2);
|
||||
const ids = elements.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['r1-a', 'r2-c']);
|
||||
expect(elements.find(e => e.id === 'r1-a')!.x).toBe(999);
|
||||
});
|
||||
|
||||
it('empty sync returns current version without changes', async () => {
|
||||
setElement('existing', makeElement({ id: 'existing' }));
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: v0, changes: [] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.appliedCount).toBe(0);
|
||||
expect(res.body.serverChanges).toHaveLength(0);
|
||||
expect(res.body.currentSyncVersion).toBe(v0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version Monotonicity ──────────────────────────────
|
||||
|
||||
describe('Sync version monotonicity', () => {
|
||||
it('sync version always increases after mutations', async () => {
|
||||
const versions: number[] = [];
|
||||
|
||||
// Create
|
||||
setElement('mono-a', makeElement({ id: 'mono-a' }));
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Update via sync
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'mono-a', action: 'upsert', element: makeElement({ id: 'mono-a', x: 50 }) }],
|
||||
});
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Delete via sync
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: versions[versions.length - 1],
|
||||
changes: [{ id: 'mono-a', action: 'delete' }],
|
||||
});
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Create via API
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Every version should be strictly greater than the previous
|
||||
for (let i = 1; i < versions.length; i++) {
|
||||
expect(versions[i]).toBeGreaterThan(versions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('getChangesSince correctly filters by version', async () => {
|
||||
setElement('cs-a', makeElement({ id: 'cs-a' }));
|
||||
const v1 = getCurrentSyncVersion();
|
||||
|
||||
setElement('cs-b', makeElement({ id: 'cs-b' }));
|
||||
const v2 = getCurrentSyncVersion();
|
||||
|
||||
setElement('cs-c', makeElement({ id: 'cs-c' }));
|
||||
const v3 = getCurrentSyncVersion();
|
||||
|
||||
// Changes since v1 should include cs-b and cs-c but not cs-a
|
||||
const changes = getChangesSince(v1);
|
||||
const ids = changes.map(c => c.id).sort();
|
||||
expect(ids).toEqual(['cs-b', 'cs-c']);
|
||||
|
||||
// Changes since v2 should only include cs-c
|
||||
const changes2 = getChangesSince(v2);
|
||||
expect(changes2).toHaveLength(1);
|
||||
expect(changes2[0].id).toBe('cs-c');
|
||||
|
||||
// Changes since v3 should be empty
|
||||
expect(getChangesSince(v3)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Concurrent Sync Requests ───────────────────────────────
|
||||
|
||||
describe('Concurrent sync requests', () => {
|
||||
it('parallel sync requests all complete without data loss', async () => {
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: `par-${i}`, action: 'upsert', element: makeElement({ id: `par-${i}`, x: i * 100 }) },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const r of results) {
|
||||
expect(r.body.success).toBe(true);
|
||||
expect(r.body.appliedCount).toBe(1);
|
||||
}
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('parallel deletes all take effect', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setElement(`pd-${i}`, makeElement({ id: `pd-${i}` }));
|
||||
}
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: `pd-${i}`, action: 'delete' }],
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync After Clear ───────────────────────────────────────
|
||||
|
||||
describe('Sync after clear', () => {
|
||||
it('elements created after clear persist correctly', async () => {
|
||||
setElement('pre-clear', makeElement({ id: 'pre-clear' }));
|
||||
|
||||
await request(app).delete('/api/elements/clear');
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'post-clear', action: 'upsert', element: makeElement({ id: 'post-clear' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.appliedCount).toBe(1);
|
||||
expect(getAllElements()).toHaveLength(1);
|
||||
expect(getAllElements()[0].id).toBe('post-clear');
|
||||
});
|
||||
|
||||
it('sync from version 0 after clear returns clear as delete changes', async () => {
|
||||
setElement('was-here', makeElement({ id: 'was-here' }));
|
||||
clearElements();
|
||||
|
||||
// Sync from 0 should see the element as a delete
|
||||
const changes = getChangesSince(0);
|
||||
const deleteChange = changes.find(c => c.id === 'was-here');
|
||||
expect(deleteChange).toBeDefined();
|
||||
expect(deleteChange!.action).toBe('delete');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Overwrite Sync (Legacy) ────────────────────────────────
|
||||
|
||||
describe('POST /api/elements/sync (legacy overwrite)', () => {
|
||||
it('replaces all elements and deleted ones stay gone on GET', async () => {
|
||||
setElement('old-1', makeElement({ id: 'old-1' }));
|
||||
setElement('old-2', makeElement({ id: 'old-2' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({
|
||||
elements: [makeElement({ id: 'new-1' })],
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].id).toBe('new-1');
|
||||
|
||||
// Old elements should not be returned
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(1);
|
||||
expect(listRes.body.elements[0].id).toBe('new-1');
|
||||
});
|
||||
|
||||
it('overwrite with empty array clears all elements', async () => {
|
||||
setElement('gone', makeElement({ id: 'gone' }));
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: [] });
|
||||
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/sync/version consistency ──────────────────────
|
||||
|
||||
describe('GET /api/sync/version', () => {
|
||||
it('matches internal getCurrentSyncVersion', async () => {
|
||||
setElement('sv-check', makeElement({ id: 'sv-check' }));
|
||||
const internal = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app).get('/api/sync/version');
|
||||
expect(res.body.syncVersion).toBe(internal);
|
||||
});
|
||||
|
||||
it('increases after sync/v2 applies changes', async () => {
|
||||
const r1 = await request(app).get('/api/sync/version');
|
||||
const v1 = r1.body.syncVersion;
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'bump', action: 'upsert', element: makeElement({ id: 'bump' }) }],
|
||||
});
|
||||
|
||||
const r2 = await request(app).get('/api/sync/version');
|
||||
expect(r2.body.syncVersion).toBeGreaterThan(v1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setActiveTenant, ensureTenant, setActiveProject, getActiveProjectId, getCurrentSyncVersion, getChangesSince, clearElements } from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import WebSocket from 'ws';
|
||||
import request from 'supertest';
|
||||
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>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Connect and wait until initial messages are drained. */
|
||||
async function connectAndDrain(): Promise<WebSocket> {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
return ws;
|
||||
}
|
||||
|
||||
/** Send hello and wait for hello_ack. */
|
||||
async function sendHelloAndWait(ws: WebSocket, tenantId: string): Promise<any> {
|
||||
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 8000);
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId }));
|
||||
return ackPromise;
|
||||
}
|
||||
|
||||
function collectMessagesFor(ws: WebSocket, durationMs: number): Promise<any[]> {
|
||||
return new Promise((resolve) => {
|
||||
const msgs: any[] = [];
|
||||
const handler = (data: WebSocket.RawData) => msgs.push(JSON.parse(data.toString()));
|
||||
ws.on('message', handler);
|
||||
setTimeout(() => {
|
||||
ws.off('message', handler);
|
||||
resolve(msgs);
|
||||
}, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
port = 3300 + Math.floor(Math.random() * 100);
|
||||
process.env.CANVAS_PORT = String(port);
|
||||
process.env.HOST = 'localhost';
|
||||
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-isolation-test-${Date.now()}.db`);
|
||||
initDb(dbPath);
|
||||
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
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(() => {
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
// ─── Element Isolation per Tenant ───────────────────────────
|
||||
|
||||
describe('Element isolation per tenant', () => {
|
||||
it('elements in tenant A are not visible to tenant B', async () => {
|
||||
ensureTenant('tenant-a', 'Tenant A', '/path/a');
|
||||
ensureTenant('tenant-b', 'Tenant B', '/path/b');
|
||||
|
||||
// Create element in tenant A
|
||||
setActiveTenant('tenant-a');
|
||||
const projA = getActiveProjectId();
|
||||
setElement('el-a', makeElement({ id: 'el-a' }), projA);
|
||||
|
||||
// Create element in tenant B
|
||||
setActiveTenant('tenant-b');
|
||||
const projB = getActiveProjectId();
|
||||
setElement('el-b', makeElement({ id: 'el-b' }), projB);
|
||||
|
||||
// Verify isolation via API
|
||||
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].id).toBe('el-a');
|
||||
|
||||
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].id).toBe('el-b');
|
||||
});
|
||||
|
||||
it('deleting elements in tenant A does not affect tenant B', async () => {
|
||||
ensureTenant('del-a', 'Del A', '/path/del-a');
|
||||
ensureTenant('del-b', 'Del B', '/path/del-b');
|
||||
|
||||
setActiveTenant('del-a');
|
||||
const projA = getActiveProjectId();
|
||||
setElement('del-el-a', makeElement({ id: 'del-el-a' }), projA);
|
||||
|
||||
setActiveTenant('del-b');
|
||||
const projB = getActiveProjectId();
|
||||
setElement('del-el-b', makeElement({ id: 'del-el-b' }), projB);
|
||||
|
||||
// Delete from tenant A via API
|
||||
await request(app)
|
||||
.delete('/api/elements/del-el-a')
|
||||
.set('X-Tenant-Id', 'del-a');
|
||||
|
||||
// Tenant A should be empty
|
||||
const resA = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'del-a');
|
||||
expect(resA.body.count).toBe(0);
|
||||
|
||||
// Tenant B should still have its element
|
||||
const resB = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'del-b');
|
||||
expect(resB.body.count).toBe(1);
|
||||
expect(resB.body.elements[0].id).toBe('del-el-b');
|
||||
});
|
||||
|
||||
it('clear in tenant A does not affect tenant B', async () => {
|
||||
ensureTenant('clr-a', 'Clr A', '/path/clr-a');
|
||||
ensureTenant('clr-b', 'Clr B', '/path/clr-b');
|
||||
|
||||
setActiveTenant('clr-a');
|
||||
setElement('clr-el-a', makeElement({ id: 'clr-el-a' }), getActiveProjectId());
|
||||
|
||||
setActiveTenant('clr-b');
|
||||
setElement('clr-el-b', makeElement({ id: 'clr-el-b' }), getActiveProjectId());
|
||||
|
||||
// Clear tenant A
|
||||
await request(app)
|
||||
.delete('/api/elements/clear')
|
||||
.set('X-Tenant-Id', 'clr-a');
|
||||
|
||||
const resA = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'clr-a');
|
||||
expect(resA.body.count).toBe(0);
|
||||
|
||||
const resB = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'clr-b');
|
||||
expect(resB.body.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version Isolation per Tenant ──────────────────────
|
||||
|
||||
describe('Sync version isolation', () => {
|
||||
it('sync versions are independent per tenant/project', async () => {
|
||||
ensureTenant('sv-a', 'SV A', '/path/sv-a');
|
||||
ensureTenant('sv-b', 'SV B', '/path/sv-b');
|
||||
|
||||
// Create in tenant A
|
||||
setActiveTenant('sv-a');
|
||||
const projA = getActiveProjectId();
|
||||
setElement('sv-el-a', makeElement({ id: 'sv-el-a' }), projA);
|
||||
const vA = getCurrentSyncVersion(projA);
|
||||
|
||||
// Create in tenant B
|
||||
setActiveTenant('sv-b');
|
||||
const projB = getActiveProjectId();
|
||||
setElement('sv-el-b', makeElement({ id: 'sv-el-b' }), projB);
|
||||
const vB = getCurrentSyncVersion(projB);
|
||||
|
||||
// Both should have version 1 (independent counters)
|
||||
expect(vA).toBe(1);
|
||||
expect(vB).toBe(1);
|
||||
});
|
||||
|
||||
it('delta sync v2 is scoped to the requesting tenant', async () => {
|
||||
ensureTenant('ds-a', 'DS A', '/path/ds-a');
|
||||
ensureTenant('ds-b', 'DS B', '/path/ds-b');
|
||||
|
||||
// Create in tenant A via API
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ds-a')
|
||||
.send({ id: 'ds-el-a', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
// Create in tenant B via API
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ds-b')
|
||||
.send({ id: 'ds-el-b', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
// Sync for tenant A from version 0
|
||||
const resA = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.set('X-Tenant-Id', 'ds-a')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
const idsA = resA.body.serverChanges.map((c: any) => c.id);
|
||||
expect(idsA).toContain('ds-el-a');
|
||||
expect(idsA).not.toContain('ds-el-b');
|
||||
|
||||
// Sync for tenant B from version 0
|
||||
const resB = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.set('X-Tenant-Id', 'ds-b')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
const idsB = resB.body.serverChanges.map((c: any) => c.id);
|
||||
expect(idsB).toContain('ds-el-b');
|
||||
expect(idsB).not.toContain('ds-el-a');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WebSocket Tenant Isolation ─────────────────────────────
|
||||
|
||||
describe('WebSocket tenant-scoped broadcasts', () => {
|
||||
it('broadcast for tenant A does NOT reach client registered to tenant B', async () => {
|
||||
ensureTenant('ws-a', 'WS A', '/path/ws-a');
|
||||
ensureTenant('ws-b', 'WS B', '/path/ws-b');
|
||||
|
||||
const wsA = await connectAndDrain();
|
||||
const wsB = await connectAndDrain();
|
||||
|
||||
await sendHelloAndWait(wsA, 'ws-a');
|
||||
await sendHelloAndWait(wsB, 'ws-b');
|
||||
|
||||
// Start collecting messages on client B
|
||||
const bMessages = collectMessagesFor(wsB, 2000);
|
||||
|
||||
// Create element in tenant A scope
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ws-a')
|
||||
.send({ id: 'ws-only-a', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
const received = await bMessages;
|
||||
|
||||
// Client B should NOT receive the element_created for tenant A
|
||||
const created = received.filter(m => m.type === 'element_created' && m.element?.id === 'ws-only-a');
|
||||
expect(created).toHaveLength(0);
|
||||
|
||||
wsA.close();
|
||||
wsB.close();
|
||||
});
|
||||
|
||||
it('broadcast for tenant A reaches all clients registered to tenant A', async () => {
|
||||
ensureTenant('ws-multi', 'WS Multi', '/path/ws-multi');
|
||||
|
||||
const ws1 = await connectAndDrain();
|
||||
const ws2 = await connectAndDrain();
|
||||
|
||||
await sendHelloAndWait(ws1, 'ws-multi');
|
||||
await sendHelloAndWait(ws2, 'ws-multi');
|
||||
|
||||
const p1 = waitForMessageOfType(ws1, 'element_created');
|
||||
const p2 = waitForMessageOfType(ws2, 'element_created');
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ws-multi')
|
||||
.send({ id: 'ws-shared', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
const [m1, m2] = await Promise.all([p1, p2]);
|
||||
expect(m1.element.id).toBe('ws-shared');
|
||||
expect(m2.element.id).toBe('ws-shared');
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hello Handshake Isolation ──────────────────────────────
|
||||
|
||||
describe('Hello handshake returns scoped elements', () => {
|
||||
it('hello with tenantId returns only that tenant elements', async () => {
|
||||
ensureTenant('hello-a', 'Hello A', '/path/hello-a');
|
||||
ensureTenant('hello-b', 'Hello B', '/path/hello-b');
|
||||
|
||||
// Populate both tenants
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'hello-a')
|
||||
.send({ id: 'ha-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'hello-b')
|
||||
.send({ id: 'hb-el', type: 'ellipse', x: 0, y: 0, width: 80, height: 80 });
|
||||
|
||||
const ws = await connectAndDrain();
|
||||
const ack = await sendHelloAndWait(ws, 'hello-a');
|
||||
|
||||
expect(ack.tenantId).toBe('hello-a');
|
||||
expect(ack.elements).toBeDefined();
|
||||
|
||||
const elementIds = ack.elements.map((e: any) => e.id);
|
||||
expect(elementIds).toContain('ha-el');
|
||||
expect(elementIds).not.toContain('hb-el');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenant Switch via API ──────────────────────────────────
|
||||
|
||||
describe('Tenant switch via API', () => {
|
||||
it('PUT /api/tenant/active switches context and broadcasts', async () => {
|
||||
ensureTenant('switch-to', 'Switch To', '/path/switch-to');
|
||||
|
||||
const ws = await connectAndDrain();
|
||||
const switchPromise = waitForMessageOfType(ws, 'tenant_switched', 8000);
|
||||
|
||||
await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'switch-to' });
|
||||
|
||||
const msg = await switchPromise;
|
||||
expect(msg.tenant).toBeDefined();
|
||||
expect(msg.tenant.id).toBe('switch-to');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('GET /api/elements after tenant switch returns new tenant elements', async () => {
|
||||
ensureTenant('ctx-old', 'Old', '/path/old');
|
||||
ensureTenant('ctx-new', 'New', '/path/new');
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ctx-new')
|
||||
.send({ id: 'new-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
// Switch to new tenant
|
||||
await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'ctx-new' });
|
||||
|
||||
// Elements should be from the new tenant
|
||||
const res = await request(app).get('/api/elements');
|
||||
const ids = res.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('new-el');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const API = 'http://localhost:3100';
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
});
|
||||
|
||||
// ─── Fix 3: Hello handshake → real-time sync works immediately ──
|
||||
|
||||
test.describe('Hello handshake and real-time sync', () => {
|
||||
test('element created via API appears in canvas without page reload', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
// Wait for hello handshake to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create an element via API — it should appear in the canvas immediately
|
||||
const createRes = await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'hello-sync-test',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#a5d8ff',
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const body = await createRes.json();
|
||||
|
||||
// syncedToCanvas should be true because the browser's WS is registered
|
||||
// via hello handshake
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
});
|
||||
|
||||
test('batch create via API syncs to canvas', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const batchRes = await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'batch-sync-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'batch-sync-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(batchRes.ok()).toBe(true);
|
||||
const body = await batchRes.json();
|
||||
|
||||
// Should be ACKed because browser is connected and registered
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
expect(body.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 6: Parallel creates don't lose elements ────────────
|
||||
|
||||
test.describe('Parallel element creation (race condition fix)', () => {
|
||||
test('5 parallel API creates all persist and sync to canvas', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Fire 5 parallel element creations
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: `parallel-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 150,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 60,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const res of results) {
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
// All 5 should exist in the DB
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(5);
|
||||
|
||||
// Wait for all broadcasts to complete
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify via Excalidraw API that all 5 are in the canvas
|
||||
const canvasElementCount = await page.evaluate(() => {
|
||||
// Access the Excalidraw API through the window if exposed
|
||||
const excalidrawWrapper = document.querySelector('.excalidraw');
|
||||
if (!excalidrawWrapper) return -1;
|
||||
// Count rendered canvas elements via the backend
|
||||
return fetch('/api/elements')
|
||||
.then(r => r.json())
|
||||
.then(data => data.count);
|
||||
});
|
||||
expect(canvasElementCount).toBe(5);
|
||||
});
|
||||
|
||||
test('parallel batch + single create all persist', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const [batchRes, singleRes] = await Promise.all([
|
||||
request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'mix-batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'mix-batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mix-single', type: 'diamond', x: 400, y: 0, width: 60, height: 60 },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(batchRes.ok()).toBe(true);
|
||||
expect(singleRes.ok()).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 1: Batch create error messages ─────────────────────
|
||||
|
||||
test.describe('Batch create error handling (E2E)', () => {
|
||||
test('batch with invalid element returns descriptive error, not "unavailable"', async ({ request }) => {
|
||||
const res = await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ type: 'invalid-thing', x: 0, y: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).not.toContain('HTTP server unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 5: Viewport control ────────────────────────────────
|
||||
|
||||
test.describe('Viewport control', () => {
|
||||
test('set_viewport scrollToContent works without animation delay', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create some elements spread across the canvas
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'vp-el-1', type: 'rectangle', x: 0, y: 0, width: 200, height: 100 },
|
||||
{ id: 'vp-el-2', type: 'rectangle', x: 1000, y: 1000, width: 200, height: 100 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Elements should exist
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 4: Screenshot capture ──────────────────────────────
|
||||
|
||||
test.describe('Screenshot and image export', () => {
|
||||
test('export image endpoint works with browser connected', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create an element so there's something to capture
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'screenshot-el',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#ff6b6b',
|
||||
},
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Request a screenshot (full scene export)
|
||||
const exportRes = await request.post(`${API}/api/export/image`, {
|
||||
data: { format: 'png', background: true },
|
||||
});
|
||||
expect(exportRes.ok()).toBe(true);
|
||||
const exportBody = await exportRes.json();
|
||||
expect(exportBody.success).toBe(true);
|
||||
expect(exportBody.format).toBe('png');
|
||||
expect(typeof exportBody.data).toBe('string');
|
||||
expect(exportBody.data.length).toBeGreaterThan(100); // non-trivial base64
|
||||
});
|
||||
|
||||
test('viewport screenshot (captureViewport) works with browser connected', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create an element
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'vp-screenshot-el',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#4ecdc4',
|
||||
},
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Request a viewport screenshot
|
||||
const exportRes = await request.post(`${API}/api/export/image`, {
|
||||
data: { format: 'png', background: true, captureViewport: true },
|
||||
});
|
||||
expect(exportRes.ok()).toBe(true);
|
||||
const exportBody = await exportRes.json();
|
||||
expect(exportBody.success).toBe(true);
|
||||
expect(exportBody.format).toBe('png');
|
||||
expect(typeof exportBody.data).toBe('string');
|
||||
expect(exportBody.data.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const API = 'http://localhost:3100';
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
|
||||
async function waitForConnected(page: Page): Promise<void> {
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
}
|
||||
|
||||
async function waitForElements(request: any, expectedCount: number, timeoutMs = 5000): Promise<any[]> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const res = await request.get(`${API}/api/elements`);
|
||||
const body = await res.json();
|
||||
if (body.count === expectedCount) return body.elements;
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${expectedCount} elements`);
|
||||
}
|
||||
|
||||
async function getServerElementCount(request: any): Promise<number> {
|
||||
const res = await request.get(`${API}/api/elements`);
|
||||
const body = await res.json();
|
||||
return body.count;
|
||||
}
|
||||
|
||||
async function getSyncVersion(request: any): Promise<number> {
|
||||
const res = await request.get(`${API}/api/sync/version`);
|
||||
const body = await res.json();
|
||||
return body.syncVersion;
|
||||
}
|
||||
|
||||
// ─── THE Critical Regression Test ───────────────────────────
|
||||
// This is the exact scenario that was broken: delete in UI → sync → reload → elements gone
|
||||
|
||||
test.describe('Delete + Sync + Reload persistence', () => {
|
||||
test('elements deleted via API stay gone after page reload', async ({ page, request }) => {
|
||||
// 1. Create elements on server
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'del-r1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
|
||||
});
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'del-r2', type: 'ellipse', x: 400, y: 100, width: 150, height: 150 },
|
||||
});
|
||||
|
||||
// 2. Load the page, verify elements loaded
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500); // Let elements render
|
||||
|
||||
// 3. Delete them via sync/v2 (simulating what the Sync button does after UI deletion)
|
||||
const syncVersion = await getSyncVersion(request);
|
||||
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: syncVersion,
|
||||
changes: [
|
||||
{ id: 'del-r1', action: 'delete' },
|
||||
{ id: 'del-r2', action: 'delete' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const syncBody = await syncRes.json();
|
||||
expect(syncBody.success).toBe(true);
|
||||
expect(syncBody.appliedCount).toBe(2);
|
||||
|
||||
// 4. Verify server has 0 elements
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
|
||||
// 5. Reload the page
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 6. Verify elements are still gone on server (the regression was here)
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
|
||||
test('sync button persists deletions that survive reload', async ({ page, request }) => {
|
||||
// 1. Create elements on server
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'sb-1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
|
||||
});
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'sb-2', type: 'text', x: 100, y: 300, text: 'To be deleted' },
|
||||
});
|
||||
|
||||
// 2. Load the page
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(1000); // Let elements load + sync baseline populate
|
||||
|
||||
// 3. Delete elements via delta sync (simulating UI delete + Sync button)
|
||||
const v = await getSyncVersion(request);
|
||||
await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: v,
|
||||
changes: [
|
||||
{ id: 'sb-1', action: 'delete' },
|
||||
{ id: 'sb-2', action: 'delete' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Reload
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 5. Verify no elements on server
|
||||
const count = await getServerElementCount(request);
|
||||
expect(count).toBe(0);
|
||||
|
||||
// 6. Reload again to double-check
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2 E2E ──────────────────────────────────────
|
||||
|
||||
test.describe('Delta sync v2 E2E', () => {
|
||||
test('frontend delta sync creates elements that persist', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
// Simulate what the frontend does: send a sync with upserts
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'ds-e2e-1', action: 'upsert', element: { id: 'ds-e2e-1', type: 'rectangle', x: 50, y: 50, width: 100, height: 60 } },
|
||||
{ id: 'ds-e2e-2', action: 'upsert', element: { id: 'ds-e2e-2', type: 'ellipse', x: 200, y: 50, width: 80, height: 80 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.appliedCount).toBe(2);
|
||||
|
||||
// Reload and verify they persist
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
const elements = await waitForElements(request, 2);
|
||||
const ids = elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual(['ds-e2e-1', 'ds-e2e-2']);
|
||||
});
|
||||
|
||||
test('delta sync handles mixed create+delete+update', async ({ request }) => {
|
||||
// Create initial elements
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mix-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mix-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
});
|
||||
|
||||
const v = await getSyncVersion(request);
|
||||
|
||||
// Mixed operation: delete mix-1, update mix-2, create mix-3
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: v,
|
||||
changes: [
|
||||
{ id: 'mix-1', action: 'delete' },
|
||||
{ id: 'mix-2', action: 'upsert', element: { id: 'mix-2', type: 'ellipse', x: 300, y: 100, width: 80, height: 80 } },
|
||||
{ id: 'mix-3', action: 'upsert', element: { id: 'mix-3', type: 'text', x: 50, y: 200, text: 'New' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.appliedCount).toBe(3);
|
||||
|
||||
// Verify final state
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(2);
|
||||
const ids = listBody.elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual(['mix-2', 'mix-3']);
|
||||
});
|
||||
|
||||
test('server returns MCP-created elements as serverChanges', async ({ request }) => {
|
||||
// MCP creates an element (via normal API)
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mcp-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
|
||||
// Frontend syncs from version 0
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: { lastSyncVersion: 0, changes: [] },
|
||||
});
|
||||
const body = await res.json();
|
||||
const serverIds = body.serverChanges.map((c: any) => c.id);
|
||||
expect(serverIds).toContain('mcp-el');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auto-sync behavior ─────────────────────────────────────
|
||||
|
||||
test.describe('Auto-sync toggle', () => {
|
||||
test('auto-sync button toggles state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
const autoSaveBtn = page.locator('button[title*="Auto-sync"]');
|
||||
await expect(autoSaveBtn).toBeVisible();
|
||||
|
||||
// Check initial state (should show the sun/moon icon and be clickable)
|
||||
await autoSaveBtn.click();
|
||||
// Second click toggles back
|
||||
await autoSaveBtn.click();
|
||||
// No crash = success
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version Tracking E2E ──────────────────────────────
|
||||
|
||||
test.describe('Sync version tracking', () => {
|
||||
test('sync version increases after each mutation', async ({ request }) => {
|
||||
const v0 = await getSyncVersion(request);
|
||||
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'sv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
const v1 = await getSyncVersion(request);
|
||||
expect(v1).toBeGreaterThan(v0);
|
||||
|
||||
await request.put(`${API}/api/elements/sv-1`, {
|
||||
data: { x: 50 },
|
||||
});
|
||||
const v2 = await getSyncVersion(request);
|
||||
expect(v2).toBeGreaterThan(v1);
|
||||
|
||||
await request.delete(`${API}/api/elements/sv-1`);
|
||||
const v3 = await getSyncVersion(request);
|
||||
expect(v3).toBeGreaterThan(v2);
|
||||
});
|
||||
|
||||
test('batch create increments sync version for each element', async ({ request }) => {
|
||||
const v0 = await getSyncVersion(request);
|
||||
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'bsv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'bsv-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
{ id: 'bsv-3', type: 'text', x: 50, y: 100, text: 'Test' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const v1 = await getSyncVersion(request);
|
||||
expect(v1).toBeGreaterThanOrEqual(v0 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Real-time Sync (MCP→Canvas) ────────────────────────────
|
||||
|
||||
test.describe('MCP to Canvas real-time sync', () => {
|
||||
test('element created via API appears on canvas via WebSocket', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
// Create element via API
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'rt-el', type: 'rectangle', x: 100, y: 100, width: 200, height: 100, backgroundColor: '#ff0000' },
|
||||
});
|
||||
|
||||
// Wait for canvas to receive it via WS
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify element is on the canvas (check via API since we can't easily inspect Excalidraw internals)
|
||||
const elements = await waitForElements(request, 1);
|
||||
expect(elements[0].id).toBe('rt-el');
|
||||
});
|
||||
|
||||
test('batch create appears on canvas without reload', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'rt-b1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'rt-b2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const elements = await waitForElements(request, 2);
|
||||
expect(elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('element update appears on canvas without reload', async ({ page, request }) => {
|
||||
// Pre-create
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'rt-upd', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Update
|
||||
await request.put(`${API}/api/elements/rt-upd`, {
|
||||
data: { x: 500, y: 500 },
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify update persisted
|
||||
const res = await request.get(`${API}/api/elements/rt-upd`);
|
||||
const body = await res.json();
|
||||
expect(body.element.x).toBe(500);
|
||||
expect(body.element.y).toBe(500);
|
||||
});
|
||||
|
||||
test('element delete via API clears from canvas', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'rt-del', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await request.delete(`${API}/api/elements/rt-del`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Clear Canvas E2E ───────────────────────────────────────
|
||||
|
||||
test.describe('Clear canvas persistence', () => {
|
||||
test('clearing via API removes all elements permanently', async ({ page, request }) => {
|
||||
// Create elements
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'clr-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'clr-2', type: 'text', x: 50, y: 100, text: 'Will be cleared' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Clear
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify gone
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
|
||||
// Reload
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Still gone
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshot Create + Restore ──────────────────────────────
|
||||
|
||||
test.describe('Snapshots E2E', () => {
|
||||
test('create snapshot, clear, restore, verify elements return', async ({ request }) => {
|
||||
// Create elements
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'snap-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'snap-2', type: 'text', x: 50, y: 100, text: 'Snapshot test' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Save snapshot
|
||||
const snapRes = await request.post(`${API}/api/snapshots`, {
|
||||
data: { name: 'test-snap' },
|
||||
});
|
||||
expect((await snapRes.json()).success).toBe(true);
|
||||
|
||||
// Clear
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
|
||||
// List snapshots
|
||||
const listRes = await request.get(`${API}/api/snapshots`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.snapshots.some((s: any) => s.name === 'test-snap')).toBe(true);
|
||||
|
||||
// Get snapshot
|
||||
const getRes = await request.get(`${API}/api/snapshots/test-snap`);
|
||||
const getBody = await getRes.json();
|
||||
expect(getBody.snapshot).toBeDefined();
|
||||
expect(getBody.snapshot.elements).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Settings Persistence ───────────────────────────────────
|
||||
|
||||
test.describe('Settings E2E', () => {
|
||||
test('settings persist across requests', async ({ request }) => {
|
||||
await request.put(`${API}/api/settings/test_key`, {
|
||||
data: { value: 'test_value' },
|
||||
});
|
||||
|
||||
const res = await request.get(`${API}/api/settings/test_key`);
|
||||
const body = await res.json();
|
||||
expect(body.value).toBe('test_value');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Files API E2E ──────────────────────────────────────────
|
||||
|
||||
test.describe('Files API E2E', () => {
|
||||
test('add and list files', async ({ request }) => {
|
||||
const addRes = await request.post(`${API}/api/files`, {
|
||||
data: {
|
||||
files: {
|
||||
'file-1': {
|
||||
id: 'file-1',
|
||||
mimeType: 'image/png',
|
||||
dataURL: 'data:image/png;base64,iVBOR...',
|
||||
created: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((await addRes.json()).success).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${API}/api/files`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.files['file-1']).toBeDefined();
|
||||
expect(listBody.files['file-1'].mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
test('delete file', async ({ request }) => {
|
||||
await request.post(`${API}/api/files`, {
|
||||
data: {
|
||||
files: {
|
||||
'file-del': {
|
||||
id: 'file-del',
|
||||
mimeType: 'image/png',
|
||||
dataURL: 'data:image/png;base64,abc',
|
||||
created: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const delRes = await request.delete(`${API}/api/files/file-del`);
|
||||
expect((await delRes.json()).success).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${API}/api/files`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.files['file-del']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search API E2E ─────────────────────────────────────────
|
||||
|
||||
test.describe('Search E2E', () => {
|
||||
test('search by type returns matching elements', async ({ request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'srch-r', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'srch-e', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
{ id: 'srch-t', type: 'text', x: 50, y: 100, text: 'Search me' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Filter by type
|
||||
const res = await request.get(`${API}/api/elements/search?type=rectangle`);
|
||||
const body = await res.json();
|
||||
expect(body.elements.length).toBe(1);
|
||||
expect(body.elements[0].type).toBe('rectangle');
|
||||
});
|
||||
|
||||
test('full-text search finds elements by label', async ({ request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'fts-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, label: { text: 'Authentication Service' } },
|
||||
});
|
||||
|
||||
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
|
||||
const body = await res.json();
|
||||
expect(body.elements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.elements.some((e: any) => e.id === 'fts-el')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenant API E2E ─────────────────────────────────────────
|
||||
|
||||
test.describe('Tenant management E2E', () => {
|
||||
test('list tenants returns at least default', async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/tenants`);
|
||||
const body = await res.json();
|
||||
expect(body.tenants.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('active tenant is available', async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/tenant/active`);
|
||||
const body = await res.json();
|
||||
expect(body.tenant).toBeDefined();
|
||||
expect(body.tenant.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Element Version History E2E ────────────────────────────
|
||||
|
||||
test.describe('Element version history E2E', () => {
|
||||
test('element history tracks create and update', async ({ request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
|
||||
await request.put(`${API}/api/elements/hist-el`, {
|
||||
data: { x: 500 },
|
||||
});
|
||||
|
||||
// Get element to verify it exists and is updated
|
||||
const getRes = await request.get(`${API}/api/elements/hist-el`);
|
||||
const body = await getRes.json();
|
||||
expect(body.element.x).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
cleanElementForExcalidraw,
|
||||
computeElementHash,
|
||||
isImageElement,
|
||||
isShapeContainerType,
|
||||
normalizeImageElement,
|
||||
validateAndFixBindings,
|
||||
restoreBindings,
|
||||
} from '../../frontend/src/utils/elementHelpers.js';
|
||||
|
||||
// ─── cleanElementForExcalidraw comprehensive ────────────────
|
||||
|
||||
describe('cleanElementForExcalidraw - comprehensive', () => {
|
||||
it('strips all server-only metadata fields', () => {
|
||||
const serverEl = {
|
||||
id: 'el-1',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 150,
|
||||
height: 80,
|
||||
version: 1,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
syncedAt: '2024-01-01',
|
||||
source: 'mcp',
|
||||
syncTimestamp: 12345,
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(serverEl);
|
||||
expect(cleaned).not.toHaveProperty('createdAt');
|
||||
expect(cleaned).not.toHaveProperty('updatedAt');
|
||||
expect(cleaned).not.toHaveProperty('version');
|
||||
expect(cleaned).not.toHaveProperty('syncedAt');
|
||||
expect(cleaned).not.toHaveProperty('source');
|
||||
expect(cleaned).not.toHaveProperty('syncTimestamp');
|
||||
// Core props preserved
|
||||
expect(cleaned.id).toBe('el-1');
|
||||
expect(cleaned.type).toBe('rectangle');
|
||||
expect(cleaned.x).toBe(100);
|
||||
});
|
||||
|
||||
it('preserves label text on container elements', () => {
|
||||
const el = {
|
||||
id: 'cont-1',
|
||||
type: 'rectangle',
|
||||
x: 0, y: 0, width: 200, height: 100,
|
||||
label: { text: 'My Label' },
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(el);
|
||||
expect(cleaned.label?.text || (cleaned as any).text).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves arrow binding properties', () => {
|
||||
const arrow = {
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
x: 0, y: 0,
|
||||
width: 200, height: 0,
|
||||
start: { id: 'rect-1' },
|
||||
end: { id: 'rect-2' },
|
||||
startElementId: 'rect-1',
|
||||
endElementId: 'rect-2',
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(arrow);
|
||||
// Should preserve binding references
|
||||
expect(cleaned.type).toBe('arrow');
|
||||
});
|
||||
|
||||
it('handles elements with no optional properties', () => {
|
||||
const minimal = {
|
||||
id: 'min-1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(minimal);
|
||||
expect(cleaned.id).toBe('min-1');
|
||||
expect(cleaned.type).toBe('rectangle');
|
||||
});
|
||||
|
||||
it('handles text element with originalText', () => {
|
||||
const textEl = {
|
||||
id: 'text-1',
|
||||
type: 'text',
|
||||
x: 0, y: 0,
|
||||
text: 'Hello',
|
||||
originalText: 'Hello',
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(textEl);
|
||||
expect(cleaned.type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeElementHash ─────────────────────────────────────
|
||||
|
||||
describe('computeElementHash - edge cases', () => {
|
||||
it('hash changes when element position changes', () => {
|
||||
const elements = [{ id: 'h1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, version: 1 }] as any;
|
||||
const hash1 = computeElementHash(elements);
|
||||
|
||||
const moved = [{ id: 'h1', type: 'rectangle', x: 50, y: 50, width: 100, height: 50, version: 2 }] as any;
|
||||
const hash2 = computeElementHash(moved);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('hash changes when element is deleted (removed from array)', () => {
|
||||
const full = [
|
||||
{ id: 'h1', type: 'rectangle', version: 1 },
|
||||
{ id: 'h2', type: 'ellipse', version: 1 },
|
||||
] as any;
|
||||
const partial = [{ id: 'h1', type: 'rectangle', version: 1 }] as any;
|
||||
|
||||
expect(computeElementHash(full)).not.toBe(computeElementHash(partial));
|
||||
});
|
||||
|
||||
it('hash is stable for same input', () => {
|
||||
const elements = [
|
||||
{ id: 'stable-1', type: 'rectangle', version: 1 },
|
||||
{ id: 'stable-2', type: 'ellipse', version: 1 },
|
||||
] as any;
|
||||
|
||||
expect(computeElementHash(elements)).toBe(computeElementHash(elements));
|
||||
});
|
||||
|
||||
it('hash uses id+version (type changes without version bump are not detected)', () => {
|
||||
// Hash formula is: count + join(id+version) — type is NOT included
|
||||
const rect = [{ id: 'morph', type: 'rectangle', version: 1 }] as any;
|
||||
const ellipse = [{ id: 'morph', type: 'ellipse', version: 1 }] as any;
|
||||
|
||||
// Same id+version → same hash (this is expected behavior)
|
||||
expect(computeElementHash(rect)).toBe(computeElementHash(ellipse));
|
||||
|
||||
// Version bump makes them different
|
||||
const updated = [{ id: 'morph', type: 'ellipse', version: 2 }] as any;
|
||||
expect(computeElementHash(rect)).not.toBe(computeElementHash(updated));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateAndFixBindings comprehensive ───────────────────
|
||||
|
||||
describe('validateAndFixBindings - comprehensive', () => {
|
||||
it('preserves valid container + bound text relationship', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: 'container',
|
||||
type: 'rectangle',
|
||||
boundElements: [{ id: 'bound-text', type: 'text' }],
|
||||
},
|
||||
{
|
||||
id: 'bound-text',
|
||||
type: 'text',
|
||||
containerId: 'container',
|
||||
},
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
const container = result.find((e: any) => e.id === 'container');
|
||||
const text = result.find((e: any) => e.id === 'bound-text');
|
||||
|
||||
expect(container.boundElements).toHaveLength(1);
|
||||
expect(text.containerId).toBe('container');
|
||||
});
|
||||
|
||||
it('removes orphaned boundElements references', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: 'container',
|
||||
type: 'rectangle',
|
||||
boundElements: [
|
||||
{ id: 'exists', type: 'text' },
|
||||
{ id: 'ghost', type: 'text' },
|
||||
],
|
||||
},
|
||||
{ id: 'exists', type: 'text', containerId: 'container' },
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
const container = result.find((e: any) => e.id === 'container');
|
||||
expect(container.boundElements).toHaveLength(1);
|
||||
expect(container.boundElements[0].id).toBe('exists');
|
||||
});
|
||||
|
||||
it('nullifies containerId when container does not exist', () => {
|
||||
const elements = [
|
||||
{ id: 'orphan', type: 'text', containerId: 'nonexistent' },
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
expect(result[0].containerId).toBeNull();
|
||||
});
|
||||
|
||||
it('handles arrow boundElements correctly', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: 'shape',
|
||||
type: 'rectangle',
|
||||
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
|
||||
},
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'shape' },
|
||||
},
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
const shape = result.find((e: any) => e.id === 'shape');
|
||||
expect(shape.boundElements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty boundElements array (converts to null)', () => {
|
||||
const elements = [{ id: 'empty', type: 'rectangle', boundElements: [] }];
|
||||
const result = validateAndFixBindings(elements);
|
||||
// Implementation converts empty filtered arrays to null
|
||||
expect(result[0].boundElements).toBeNull();
|
||||
});
|
||||
|
||||
it('handles null boundElements', () => {
|
||||
const elements = [{ id: 'null-bound', type: 'rectangle', boundElements: null }];
|
||||
const result = validateAndFixBindings(elements);
|
||||
expect(result[0].boundElements).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isImageElement ─────────────────────────────────────────
|
||||
|
||||
describe('isImageElement - comprehensive', () => {
|
||||
it('returns true for image type', () => {
|
||||
expect(isImageElement({ type: 'image', fileId: 'f1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for all other types', () => {
|
||||
const nonImageTypes = ['rectangle', 'ellipse', 'diamond', 'arrow', 'line', 'text', 'freedraw'];
|
||||
for (const type of nonImageTypes) {
|
||||
expect(isImageElement({ type })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true when fileId is present regardless of type', () => {
|
||||
// Some implementations check fileId as fallback
|
||||
const result = isImageElement({ type: 'image', fileId: 'some-file' });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isShapeContainerType ───────────────────────────────────
|
||||
|
||||
describe('isShapeContainerType - comprehensive', () => {
|
||||
it('returns true for all container types', () => {
|
||||
const containerTypes = ['rectangle', 'ellipse', 'diamond'];
|
||||
for (const type of containerTypes) {
|
||||
expect(isShapeContainerType(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true for arrow and line (they are container types)', () => {
|
||||
// arrow and line are included in SHAPE_CONTAINER_TYPES
|
||||
expect(isShapeContainerType('arrow')).toBe(true);
|
||||
expect(isShapeContainerType('line')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-container types', () => {
|
||||
const nonContainer = ['text', 'freedraw', 'image', 'frame'];
|
||||
for (const type of nonContainer) {
|
||||
expect(isShapeContainerType(type)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeImageElement ──────────────────────────────────
|
||||
|
||||
describe('normalizeImageElement - comprehensive', () => {
|
||||
it('fills in all required defaults for minimal image', () => {
|
||||
const minimal = {
|
||||
id: 'img-1',
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fileId: 'file-1',
|
||||
};
|
||||
|
||||
const normalized = normalizeImageElement(minimal);
|
||||
expect(normalized.type).toBe('image');
|
||||
expect(normalized.fileId).toBe('file-1');
|
||||
// Should have all required Excalidraw properties
|
||||
expect(normalized).toHaveProperty('strokeColor');
|
||||
expect(normalized).toHaveProperty('backgroundColor');
|
||||
expect(normalized).toHaveProperty('fillStyle');
|
||||
expect(normalized).toHaveProperty('opacity');
|
||||
});
|
||||
|
||||
it('preserves explicit values over defaults', () => {
|
||||
const custom = {
|
||||
id: 'img-2',
|
||||
type: 'image',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
fileId: 'file-2',
|
||||
opacity: 50,
|
||||
angle: 1.5,
|
||||
};
|
||||
|
||||
const normalized = normalizeImageElement(custom);
|
||||
expect(normalized.opacity).toBe(50);
|
||||
expect(normalized.angle).toBe(1.5);
|
||||
expect(normalized.x).toBe(50);
|
||||
expect(normalized.y).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── restoreBindings ────────────────────────────────────────
|
||||
|
||||
describe('restoreBindings - comprehensive', () => {
|
||||
it('restores startBinding and endBinding from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'arrow-1', type: 'arrow' },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'rect-1', focus: 0, gap: 5, fixedPoint: null },
|
||||
endBinding: { elementId: 'rect-2', focus: 0, gap: 5, fixedPoint: null },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding.elementId).toBe('rect-1');
|
||||
expect(result[0].endBinding.elementId).toBe('rect-2');
|
||||
});
|
||||
|
||||
it('restores boundElements on shapes', () => {
|
||||
const converted = [
|
||||
{ id: 'shape-1', type: 'rectangle' },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'shape-1',
|
||||
type: 'rectangle',
|
||||
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].boundElements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not overwrite existing bindings', () => {
|
||||
const converted = [
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'already-set', focus: 0, gap: 3, fixedPoint: null },
|
||||
},
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'original', focus: 0, gap: 5, fixedPoint: null },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding.elementId).toBe('already-set');
|
||||
});
|
||||
|
||||
it('handles element not found in originals', () => {
|
||||
const converted = [{ id: 'new-1', type: 'rectangle' }];
|
||||
const originals = [{ id: 'other', type: 'ellipse' }];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].id).toBe('new-1');
|
||||
// Should not crash
|
||||
});
|
||||
|
||||
it('restores elbowed property on arrows', () => {
|
||||
const converted = [{ id: 'elb-arrow', type: 'arrow' }];
|
||||
const originals = [
|
||||
{ id: 'elb-arrow', type: 'arrow', elbowed: true },
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].elbowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Computation Logic (simulated) ────────────────────
|
||||
// Tests the algorithm used in syncToBackend for detecting changes
|
||||
|
||||
describe('Delta computation (simulated syncToBackend logic)', () => {
|
||||
type Element = { id: string; type: string; x: number; version: number };
|
||||
|
||||
function computeDelta(
|
||||
currentElements: Element[],
|
||||
lastSynced: Map<string, Element>
|
||||
): { id: string; action: 'upsert' | 'delete'; element?: Element }[] {
|
||||
const changes: { id: string; action: 'upsert' | 'delete'; element?: Element }[] = [];
|
||||
const currentMap = new Map<string, Element>();
|
||||
|
||||
for (const el of currentElements) {
|
||||
currentMap.set(el.id, el);
|
||||
const prev = lastSynced.get(el.id);
|
||||
if (!prev || JSON.stringify(prev) !== JSON.stringify(el)) {
|
||||
changes.push({ id: el.id, action: 'upsert', element: el });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id] of lastSynced) {
|
||||
if (!currentMap.has(id)) {
|
||||
changes.push({ id, action: 'delete' });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
it('detects new elements as upserts', () => {
|
||||
const current = [{ id: 'a', type: 'rect', x: 0, version: 1 }];
|
||||
const lastSynced = new Map<string, Element>();
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(delta[0].action).toBe('upsert');
|
||||
expect(delta[0].id).toBe('a');
|
||||
});
|
||||
|
||||
it('detects removed elements as deletes', () => {
|
||||
const current: Element[] = [];
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
['b', { id: 'b', type: 'rect', x: 100, version: 1 }],
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(2);
|
||||
expect(delta.every(d => d.action === 'delete')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects updated elements as upserts', () => {
|
||||
const current = [{ id: 'a', type: 'rect', x: 50, version: 2 }];
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(delta[0].action).toBe('upsert');
|
||||
});
|
||||
|
||||
it('returns empty when nothing changed', () => {
|
||||
const el = { id: 'a', type: 'rect', x: 0, version: 1 };
|
||||
const current = [el];
|
||||
const lastSynced = new Map<string, Element>([['a', { ...el }]]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles mixed operations correctly', () => {
|
||||
const current = [
|
||||
{ id: 'a', type: 'rect', x: 50, version: 2 }, // updated
|
||||
{ id: 'c', type: 'rect', x: 200, version: 1 }, // new
|
||||
];
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
['b', { id: 'b', type: 'rect', x: 100, version: 1 }], // deleted
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(3);
|
||||
|
||||
const upserts = delta.filter(d => d.action === 'upsert');
|
||||
const deletes = delta.filter(d => d.action === 'delete');
|
||||
|
||||
expect(upserts).toHaveLength(2); // a (updated) + c (new)
|
||||
expect(deletes).toHaveLength(1); // b
|
||||
expect(deletes[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('THE BUG: empty lastSynced means no deletions detected', () => {
|
||||
// This is the exact bug scenario: elements loaded from server but lastSynced not populated
|
||||
const current: Element[] = []; // User deleted everything
|
||||
const lastSynced = new Map<string, Element>(); // Bug: was never populated
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
// With empty lastSynced, no deletions are detected - this was the regression
|
||||
expect(delta).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FIXED: populated lastSynced detects all deletions', () => {
|
||||
// After fix: lastSynced is populated on load
|
||||
const current: Element[] = []; // User deleted everything
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
['b', { id: 'b', type: 'rect', x: 100, version: 1 }],
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(2);
|
||||
expect(delta.every(d => d.action === 'delete')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user