Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
459dbfdb3a | ||
|
|
7c59972bb1 | ||
|
|
670961ee73 | ||
|
|
2cca18153f | ||
|
|
4e410f1205 | ||
|
|
71b2a55231 | ||
|
|
25767838fa | ||
|
|
493a20054b |
@@ -104,3 +104,12 @@ frontend/ ── React + Excalidraw UI (Vite build → dist/frontend/)
|
||||
## Docker
|
||||
|
||||
Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas with frontend). `docker-compose.yml` orchestrates both with a `full` profile.
|
||||
|
||||
|
||||
## Code Search Optimization
|
||||
|
||||
When exploring or understanding code in supported languages (JS, TS, Python, Go, Rust, Java, C, C++, Ruby):
|
||||
- Use `smart_search(query, path)` instead of Grep+Glob chains for discovering functions/classes/symbols
|
||||
- Use `smart_outline(file_path)` instead of Read to understand file structure (~1-2K tokens vs ~12K+)
|
||||
- Use `smart_unfold(file_path, symbol_name)` instead of Read for viewing specific functions (~400-2K tokens)
|
||||
- Fall back to Grep for exact string/regex searches, Read for non-code files and files under 100 lines
|
||||
|
||||
+188
-9
@@ -68,6 +68,12 @@ function App(): JSX.Element {
|
||||
const isSyncingRef = useRef<boolean>(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastSyncedHashRef = useRef<string>('')
|
||||
const lastSyncVersionRef = useRef<number>(
|
||||
parseInt(localStorage.getItem('excalidraw-last-sync-version') ?? '0', 10)
|
||||
)
|
||||
const lastSyncedElementsRef = useRef<Map<string, ServerElement>>(new Map())
|
||||
const lastReceivedSyncVersionRef = useRef<number>(0)
|
||||
const isResyncingRef = useRef<boolean>(false)
|
||||
|
||||
const DEBOUNCE_MS = 3000
|
||||
|
||||
@@ -254,9 +260,83 @@ 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
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'ack', msgId, status, elementCount, expectedCount }))
|
||||
}
|
||||
|
||||
const triggerDeltaResync = async (): Promise<void> => {
|
||||
if (isResyncingRef.current) return
|
||||
isResyncingRef.current = true
|
||||
try {
|
||||
const response = await fetch('/api/elements/sync/v2', {
|
||||
method: 'POST',
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
lastSyncVersion: lastReceivedSyncVersionRef.current,
|
||||
changes: []
|
||||
})
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json() as {
|
||||
currentSyncVersion: number
|
||||
serverChanges: { id: string; action: string; element: any; sync_version: number }[]
|
||||
}
|
||||
const api = excalidrawAPIRef.current
|
||||
if (api && data.serverChanges.length > 0) {
|
||||
const scene = api.getSceneElements()
|
||||
let merged = [...scene]
|
||||
for (const sc of data.serverChanges) {
|
||||
if (sc.action === 'delete') {
|
||||
merged = merged.filter(el => el.id !== sc.id)
|
||||
} else if (sc.element) {
|
||||
const cleaned = cleanElementForExcalidraw(sc.element)
|
||||
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
|
||||
const idx = merged.findIndex(el => el.id === sc.id)
|
||||
if (idx >= 0) {
|
||||
merged[idx] = converted[0]!
|
||||
} else {
|
||||
merged.push(...converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
api.updateScene({ elements: merged, captureUpdate: CaptureUpdateAction.NEVER })
|
||||
}
|
||||
lastReceivedSyncVersionRef.current = data.currentSyncVersion
|
||||
lastSyncVersionRef.current = data.currentSyncVersion
|
||||
localStorage.setItem('excalidraw-last-sync-version', String(data.currentSyncVersion))
|
||||
console.log(`Delta resync complete: received ${data.serverChanges.length} changes, now at v${data.currentSyncVersion}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delta resync failed:', err)
|
||||
} finally {
|
||||
isResyncingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
|
||||
// Gap detection (Task 12): if a message carries sync_version, check for gaps
|
||||
if (data.sync_version !== undefined && typeof data.sync_version === 'number') {
|
||||
const expected = lastReceivedSyncVersionRef.current + 1
|
||||
if (data.sync_version > expected && lastReceivedSyncVersionRef.current > 0) {
|
||||
console.warn(`Sync gap: expected v${expected}, got v${data.sync_version}. Triggering resync.`)
|
||||
triggerDeltaResync()
|
||||
return // resync will fetch everything including this message's changes
|
||||
}
|
||||
lastReceivedSyncVersionRef.current = data.sync_version
|
||||
}
|
||||
|
||||
const api = excalidrawAPIRef.current
|
||||
if (!api) {
|
||||
sendAck(data.msgId, 'failed')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -295,6 +375,9 @@ function App(): JSX.Element {
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
}
|
||||
const scene = api.getSceneElements()
|
||||
const landed = scene.some(s => s.id === data.element!.id)
|
||||
sendAck(data.msgId, landed ? 'applied' : 'failed', landed ? 1 : 0, 1)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -309,6 +392,7 @@ function App(): JSX.Element {
|
||||
elements: updatedElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
sendAck(data.msgId, 'applied', 1, 1)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -319,6 +403,7 @@ function App(): JSX.Element {
|
||||
elements: filteredElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
sendAck(data.msgId, 'applied', 1, 1)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -341,6 +426,12 @@ function App(): JSX.Element {
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
}
|
||||
// Verify elements landed in the scene
|
||||
const scene = api.getSceneElements()
|
||||
const expectedIds = data.elements.map((e: ServerElement) => e.id)
|
||||
const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length
|
||||
const status = landedCount === expectedIds.length ? 'applied' : landedCount > 0 ? 'partial' : 'failed'
|
||||
sendAck(data.msgId, status, landedCount, expectedIds.length)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -358,12 +449,39 @@ function App(): JSX.Element {
|
||||
elements: [],
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
sendAck(data.msgId, 'applied')
|
||||
break
|
||||
|
||||
case 'export_image_request':
|
||||
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()
|
||||
@@ -461,13 +579,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`)
|
||||
}
|
||||
@@ -559,8 +677,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)
|
||||
@@ -576,6 +694,17 @@ 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
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', data.type)
|
||||
}
|
||||
@@ -732,21 +861,71 @@ function App(): JSX.Element {
|
||||
const activeElements = currentElements.filter(el => !el.isDeleted)
|
||||
const backendElements = normalizeForBackend(activeElements)
|
||||
|
||||
const response = await fetch('/api/elements/sync', {
|
||||
// Compute delta: what changed since last sync
|
||||
const changes: { id: string; action: string; element?: any }[] = []
|
||||
const currentMap = new Map<string, any>()
|
||||
for (const el of backendElements) {
|
||||
currentMap.set(el.id, el)
|
||||
const prev = lastSyncedElementsRef.current.get(el.id)
|
||||
if (!prev || JSON.stringify(prev) !== JSON.stringify(el)) {
|
||||
changes.push({ id: el.id, action: 'upsert', element: el })
|
||||
}
|
||||
}
|
||||
// Detect deletions: elements in last sync but not current
|
||||
for (const [id] of lastSyncedElementsRef.current) {
|
||||
if (!currentMap.has(id)) {
|
||||
changes.push({ id, action: 'delete' })
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('/api/elements/sync/v2', {
|
||||
method: 'POST',
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
elements: backendElements,
|
||||
timestamp: new Date().toISOString()
|
||||
lastSyncVersion: lastSyncVersionRef.current,
|
||||
changes
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse = await response.json()
|
||||
const result = await response.json() as {
|
||||
currentSyncVersion: number
|
||||
serverChanges: { id: string; action: string; element: any; sync_version: number }[]
|
||||
appliedCount: number
|
||||
}
|
||||
|
||||
// Apply server-side changes (MCP-created elements, other tabs' changes)
|
||||
if (result.serverChanges.length > 0) {
|
||||
const api = excalidrawAPIRef.current
|
||||
if (api) {
|
||||
const scene = api.getSceneElements()
|
||||
let merged = [...scene]
|
||||
for (const sc of result.serverChanges) {
|
||||
if (sc.action === 'delete') {
|
||||
merged = merged.filter(el => el.id !== sc.id)
|
||||
} else if (sc.element) {
|
||||
const cleaned = cleanElementForExcalidraw(sc.element)
|
||||
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
|
||||
const idx = merged.findIndex(el => el.id === sc.id)
|
||||
if (idx >= 0) {
|
||||
merged[idx] = converted[0]!
|
||||
} else {
|
||||
merged.push(...converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
api.updateScene({ elements: merged, captureUpdate: CaptureUpdateAction.NEVER })
|
||||
}
|
||||
}
|
||||
|
||||
// Update tracking state
|
||||
lastSyncVersionRef.current = result.currentSyncVersion
|
||||
localStorage.setItem('excalidraw-last-sync-version', String(result.currentSyncVersion))
|
||||
lastSyncedElementsRef.current = currentMap
|
||||
lastSyncedHashRef.current = computeElementHash(currentElements)
|
||||
setSyncStatus('idle')
|
||||
showToast('Saved')
|
||||
console.log(`Sync: ${result.count} elements synced`)
|
||||
console.log(`Delta sync: ${result.appliedCount} applied, ${result.serverChanges.length} received from server`)
|
||||
} else {
|
||||
setSyncStatus('idle')
|
||||
showToast('Sync failed', 3000)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.4.0",
|
||||
"version": "1.6.1",
|
||||
"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",
|
||||
|
||||
@@ -26,7 +26,7 @@ Before creating any elements, load the user's diagram preferences. These control
|
||||
| 1 (highest) | Session | In-memory (set via prompt during this conversation) | No — current session only |
|
||||
| 2 | Folder | `.claude/excalidraw-preferences.json` in the current project root | Yes — per-project |
|
||||
| 3 | Global | `~/.claude/skills/excalidraw-skill/preferences.json` | Yes — all projects |
|
||||
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 1, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
|
||||
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 5, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
|
||||
|
||||
### How to Load
|
||||
|
||||
@@ -45,11 +45,11 @@ If no preferences file exists at either location, **prompt the user before drawi
|
||||
|
||||
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
|
||||
|
||||
1. **Font family** — Which font for all text?
|
||||
- Excalifont (hand-drawn) = 1
|
||||
1. **Font family** — Which font for all text? _(IDs from `src/font-families.json`)_
|
||||
- Excalifont (hand-drawn) = 5
|
||||
- Helvetica (sans-serif) = 2
|
||||
- Cascadia (monospace) = 3
|
||||
- Comic Shanns = 4
|
||||
- Comic Shanns = 8
|
||||
- Nunito = 6
|
||||
- Lilita One = 7
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
|
||||
"_fontReference": {
|
||||
"1": "Excalifont (hand-drawn)",
|
||||
"2": "Helvetica (sans-serif)",
|
||||
"3": "Cascadia (monospace)",
|
||||
"4": "Comic Shanns",
|
||||
"5": "Liberation Sans",
|
||||
"6": "Nunito",
|
||||
"7": "Lilita One"
|
||||
},
|
||||
"_fontReference": "See src/font-families.json for canonical font ID → name mapping.",
|
||||
"defaults": {
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"roughness": 0,
|
||||
"strokeWidth": 2
|
||||
|
||||
@@ -168,6 +168,21 @@ function runMigrations(): void {
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id)`);
|
||||
|
||||
// Migration: add sync_version to elements table
|
||||
const elementCols = db.prepare("PRAGMA table_info(elements)").all() as { name: string }[];
|
||||
if (!elementCols.some(c => c.name === 'sync_version')) {
|
||||
db.exec(`ALTER TABLE elements ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_elements_sync_version ON elements(project_id, sync_version)`);
|
||||
logger.info('Migrated: added sync_version column to elements');
|
||||
}
|
||||
|
||||
// Migration: add sync_version counter to projects table
|
||||
const projectCols = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
|
||||
if (!projectCols.some(c => c.name === 'sync_version')) {
|
||||
db.exec(`ALTER TABLE projects ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0`);
|
||||
logger.info('Migrated: added sync_version counter to projects');
|
||||
}
|
||||
|
||||
// Migration: assign orphan projects (no tenant_id) to default tenant
|
||||
const orphans = db.prepare('SELECT id FROM projects WHERE tenant_id IS NULL').all() as { id: string }[];
|
||||
if (orphans.length > 0) {
|
||||
@@ -195,6 +210,44 @@ function pid(override?: string): string {
|
||||
return override ?? activeProjectId;
|
||||
}
|
||||
|
||||
// ── Sync Version ──
|
||||
|
||||
export function incrementSyncVersion(projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
db.prepare('UPDATE projects SET sync_version = sync_version + 1 WHERE id = ?').run(p);
|
||||
const row = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(p) as { sync_version: number } | undefined;
|
||||
return row?.sync_version ?? 0;
|
||||
}
|
||||
|
||||
export function getCurrentSyncVersion(projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const row = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(p) as { sync_version: number } | undefined;
|
||||
return row?.sync_version ?? 0;
|
||||
}
|
||||
|
||||
export interface ElementChange {
|
||||
id: string;
|
||||
action: 'upsert' | 'delete';
|
||||
element: ServerElement;
|
||||
sync_version: number;
|
||||
}
|
||||
|
||||
export function getChangesSince(sinceVersion: number, projectId?: string): ElementChange[] {
|
||||
const p = pid(projectId);
|
||||
const rows = db.prepare(`
|
||||
SELECT id, data, sync_version, is_deleted FROM elements
|
||||
WHERE project_id = ? AND sync_version > ?
|
||||
ORDER BY sync_version ASC
|
||||
`).all(p, sinceVersion) as { id: string; data: string; sync_version: number; is_deleted: number }[];
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
action: r.is_deleted ? 'delete' as const : 'upsert' as const,
|
||||
element: JSON.parse(r.data),
|
||||
sync_version: r.sync_version
|
||||
}));
|
||||
}
|
||||
|
||||
// Given a tenant ID, return its default project (creating one if needed)
|
||||
export function getDefaultProjectForTenant(tenantId: string): string {
|
||||
const row = db.prepare(
|
||||
@@ -227,11 +280,12 @@ export function hasElement(id: string, projectId?: string): boolean {
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function setElement(id: string, element: ServerElement, projectId?: string): void {
|
||||
export function setElement(id: string, element: ServerElement, projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const now = new Date().toISOString();
|
||||
const data = JSON.stringify(element);
|
||||
const labelText = extractLabelText(element);
|
||||
const sv = incrementSyncVersion(p);
|
||||
const existing = db.prepare(
|
||||
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
|
||||
).get(id, p) as { version: number; is_deleted: number } | undefined;
|
||||
@@ -239,21 +293,22 @@ export function setElement(id: string, element: ServerElement, projectId?: strin
|
||||
if (existing) {
|
||||
const newVersion = existing.is_deleted ? 1 : (existing.version + 1);
|
||||
db.prepare(`
|
||||
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0
|
||||
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0, sync_version = ?
|
||||
WHERE id = ? AND project_id = ?
|
||||
`).run(element.type, data, labelText, now, newVersion, id, p);
|
||||
`).run(element.type, data, labelText, now, newVersion, sv, id, p);
|
||||
|
||||
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
|
||||
updateFts(id, labelText, element.type);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(id, p, element.type, data, labelText, now, now);
|
||||
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, sync_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
`).run(id, p, element.type, data, labelText, now, now, sv);
|
||||
|
||||
recordVersion(id, 1, data, 'create', p);
|
||||
insertFts(id, labelText, element.type);
|
||||
}
|
||||
return sv;
|
||||
}
|
||||
|
||||
export function deleteElement(id: string, projectId?: string): boolean {
|
||||
@@ -265,10 +320,11 @@ export function deleteElement(id: string, projectId?: string): boolean {
|
||||
if (!existing) return false;
|
||||
|
||||
const newVersion = existing.version + 1;
|
||||
const sv = incrementSyncVersion(p);
|
||||
db.prepare(`
|
||||
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?
|
||||
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?, sync_version = ?
|
||||
WHERE id = ? AND project_id = ?
|
||||
`).run(newVersion, new Date().toISOString(), id, p);
|
||||
`).run(newVersion, new Date().toISOString(), sv, id, p);
|
||||
|
||||
recordVersion(id, newVersion, existing.data, 'delete', p);
|
||||
deleteFts(id);
|
||||
@@ -293,14 +349,15 @@ export function clearElements(projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const now = new Date().toISOString();
|
||||
const elements = getAllElements(p);
|
||||
const sv = incrementSyncVersion(p);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?
|
||||
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?, sync_version = ?
|
||||
WHERE project_id = ? AND is_deleted = 0
|
||||
`);
|
||||
|
||||
const clearTx = db.transaction(() => {
|
||||
const info = stmt.run(now, p);
|
||||
const info = stmt.run(now, sv, p);
|
||||
for (const el of elements) {
|
||||
recordVersion(el.id, (el.version || 1) + 1, JSON.stringify(el), 'delete', p);
|
||||
deleteFts(el.id);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"fonts": [
|
||||
{ "id": 5, "name": "Excalifont", "label": "Excalifont (hand-drawn)", "aliases": ["excalifont", "hand-drawn"] },
|
||||
{ "id": 2, "name": "Helvetica", "label": "Helvetica (sans-serif)", "aliases": ["helvetica", "arial", "sans-serif"] },
|
||||
{ "id": 3, "name": "Cascadia", "label": "Cascadia (monospace)", "aliases": ["cascadia", "monospace", "courier"] },
|
||||
{ "id": 8, "name": "Comic Shanns", "label": "Comic Shanns", "aliases": ["comic shanns", "comic sans"] },
|
||||
{ "id": 6, "name": "Nunito", "label": "Nunito", "aliases": ["nunito"] },
|
||||
{ "id": 7, "name": "Lilita One", "label": "Lilita One", "aliases": ["lilita one"] },
|
||||
{ "id": 9, "name": "Liberation Sans", "label": "Liberation Sans", "aliases": ["liberation sans"], "legacy": true },
|
||||
{ "id": 1, "name": "Virgil", "label": "Virgil (legacy)", "aliases": ["virgil"], "legacy": true }
|
||||
],
|
||||
"defaultFontFamily": 5
|
||||
}
|
||||
+118
-49
@@ -27,7 +27,9 @@ import {
|
||||
ExcalidrawElementType,
|
||||
validateElement,
|
||||
normalizeFontFamily,
|
||||
files as globalFiles
|
||||
files as globalFiles,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
FONT_FAMILY_DESCRIPTION,
|
||||
} from './types.js';
|
||||
import fetch from 'node-fetch';
|
||||
import { startCanvasServer, stopCanvasServer } from './server.js';
|
||||
@@ -75,7 +77,7 @@ interface ExcalidrawPreferences {
|
||||
}
|
||||
|
||||
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
|
||||
fontFamily: 1,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: 20,
|
||||
roughness: 0,
|
||||
strokeWidth: 2,
|
||||
@@ -120,9 +122,18 @@ interface ApiResponse {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface CanvasStatus {
|
||||
connectedBrowsers: number;
|
||||
ackedBy: number;
|
||||
reason?: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface SyncResponse {
|
||||
element?: ServerElement;
|
||||
elements?: ServerElement[];
|
||||
syncedToCanvas?: boolean;
|
||||
canvasStatus?: CanvasStatus;
|
||||
}
|
||||
|
||||
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
@@ -197,34 +208,50 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to sync element creation to canvas
|
||||
async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
|
||||
async function createElementOnCanvas(elementData: ServerElement): Promise<SyncResponse | null> {
|
||||
const result = await syncToCanvas('create', elementData);
|
||||
return result?.element || elementData;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to sync element update to canvas
|
||||
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
|
||||
// Helper to sync element update to canvas
|
||||
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<SyncResponse | null> {
|
||||
const result = await syncToCanvas('update', elementData);
|
||||
return result?.element || null;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to sync element deletion to canvas
|
||||
async function deleteElementOnCanvas(elementId: string): Promise<any> {
|
||||
const result = await syncToCanvas('delete', { id: elementId });
|
||||
return result;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to sync batch creation to canvas
|
||||
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
|
||||
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<SyncResponse | null> {
|
||||
const result = await syncToCanvas('batch_create', elementsData);
|
||||
return result?.elements || elementsData;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to fetch element from canvas
|
||||
@@ -459,7 +486,7 @@ const tools: Tool[] = [
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont (hand-drawn), 2=Helvetica (sans-serif), 3=Cascadia (monospace), 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
|
||||
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
|
||||
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
|
||||
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
|
||||
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
||||
@@ -690,7 +717,7 @@ const tools: Tool[] = [
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont, 2=Helvetica, 3=Cascadia, 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
|
||||
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
|
||||
startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
|
||||
endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
|
||||
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
||||
@@ -1032,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 } } : {}),
|
||||
@@ -1050,22 +1080,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const excalidrawElement = convertTextToLabel(element);
|
||||
|
||||
// Create element directly on HTTP server (no local storage)
|
||||
const canvasElement = await createElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasElement) {
|
||||
const canvasResponse = await createElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to create element: HTTP server unavailable');
|
||||
}
|
||||
|
||||
logger.info('Element created via MCP and synced to canvas', {
|
||||
id: excalidrawElement.id,
|
||||
|
||||
const synced = canvasResponse.syncedToCanvas ?? false;
|
||||
logger.info('Element created via MCP', {
|
||||
id: excalidrawElement.id,
|
||||
type: excalidrawElement.type,
|
||||
synced: !!canvasElement
|
||||
synced,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
});
|
||||
|
||||
|
||||
const statusEmoji = synced ? '✅' : '⚠️';
|
||||
const statusText = synced
|
||||
? 'Synced to canvas and confirmed by browser'
|
||||
: `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${statusEmoji} ${statusText}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1089,21 +1126,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
|
||||
|
||||
// Update element directly on HTTP server (no local storage)
|
||||
const canvasElement = await updateElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasElement) {
|
||||
const canvasResponse = await updateElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to update element: HTTP server unavailable or element not found');
|
||||
}
|
||||
|
||||
logger.info('Element updated via MCP and synced to canvas', {
|
||||
id: excalidrawElement.id,
|
||||
synced: !!canvasElement
|
||||
|
||||
const synced = canvasResponse.syncedToCanvas ?? false;
|
||||
logger.info('Element updated via MCP', {
|
||||
id: excalidrawElement.id,
|
||||
synced,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
content: [{
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
|
||||
text: `Element updated successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${synced ? '✅ Synced to canvas and confirmed' : `⚠️ Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1529,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 } } : {}),
|
||||
@@ -1547,28 +1589,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
createdElements.push(excalidrawElement);
|
||||
}
|
||||
|
||||
const canvasElements = await batchCreateElementsOnCanvas(createdElements);
|
||||
const canvasResponse = await batchCreateElementsOnCanvas(createdElements);
|
||||
|
||||
if (!canvasElements) {
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to batch create elements: HTTP server unavailable');
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
elements: canvasElements,
|
||||
count: canvasElements.length,
|
||||
syncedToCanvas: true
|
||||
elements: canvasResponse.elements ?? createdElements,
|
||||
count: (canvasResponse.elements ?? createdElements).length,
|
||||
syncedToCanvas: canvasResponse.syncedToCanvas ?? false,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
};
|
||||
|
||||
logger.info('Batch elements created via MCP and synced to canvas', {
|
||||
logger.info('Batch elements created via MCP', {
|
||||
count: result.count,
|
||||
synced: result.syncedToCanvas
|
||||
synced: result.syncedToCanvas,
|
||||
canvasStatus: result.canvasStatus
|
||||
});
|
||||
|
||||
const statusEmoji = result.syncedToCanvas ? '✅' : '⚠️';
|
||||
const statusText = result.syncedToCanvas
|
||||
? 'All elements synced to canvas and confirmed by browser'
|
||||
: `Canvas sync not confirmed (${result.canvasStatus?.reason ?? 'unknown'})`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}`
|
||||
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${statusEmoji} ${statusText}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -2142,7 +2191,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
format: 'png',
|
||||
background: params.background ?? true
|
||||
background: params.background ?? true,
|
||||
captureViewport: true
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2693,12 +2743,31 @@ async function runServer(): Promise<void> {
|
||||
const { tenantId: newTid } = applyTenant(workspacePath);
|
||||
|
||||
try {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
const putRes = await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
method: 'PUT',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ tenantId: newTid })
|
||||
});
|
||||
} catch {}
|
||||
if (!putRes.ok) {
|
||||
logger.error(`Failed to set tenant on canvas server: HTTP ${putRes.status}`);
|
||||
}
|
||||
|
||||
// Verify the canvas server accepted the tenant switch
|
||||
const verifyRes = await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (verifyRes.ok) {
|
||||
const verifyData = await verifyRes.json() as { tenant?: { id?: string } };
|
||||
if (verifyData.tenant?.id !== newTid) {
|
||||
logger.error(
|
||||
`Canvas server has stale tenant: expected "${newTid}", got "${verifyData.tenant?.id}". ` +
|
||||
`Restart the canvas server or kill the process on port ${process.env['CANVAS_PORT'] || 3000}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (tenantErr) {
|
||||
logger.error('Failed to update tenant on canvas server:', (tenantErr as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (rootsErr) {
|
||||
|
||||
+387
-66
@@ -22,10 +22,12 @@ import {
|
||||
Snapshot,
|
||||
normalizeFontFamily,
|
||||
ExcalidrawFile,
|
||||
files
|
||||
files,
|
||||
ClientConnection,
|
||||
BroadcastResult
|
||||
} from './types.js';
|
||||
import * as store from './db.js';
|
||||
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
|
||||
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getCurrentSyncVersion, getChangesSince } from './db.js';
|
||||
import { z } from 'zod';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -57,37 +59,219 @@ function resolveTenantProject(req: Request): string | undefined {
|
||||
return getDefaultProjectForTenant(tenantId);
|
||||
}
|
||||
|
||||
// WebSocket connections
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
// Broadcast to all connected clients
|
||||
function broadcast(message: WebSocketMessage): void {
|
||||
const data = JSON.stringify(message);
|
||||
clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
});
|
||||
// Resolve both tenantId and projectId for scoped broadcast.
|
||||
// Falls back to active tenant/project when header is absent.
|
||||
function resolveScope(req: Request): { tenantId: string; projectId: string } {
|
||||
const headerTenantId = req.headers['x-tenant-id'] as string | undefined;
|
||||
if (headerTenantId) {
|
||||
const projectId = getDefaultProjectForTenant(headerTenantId) ?? `${headerTenantId}-default`;
|
||||
return { tenantId: headerTenantId, projectId };
|
||||
}
|
||||
// Fallback for browser requests without header
|
||||
const tenant = dbGetActiveTenant();
|
||||
const projectId = getDefaultProjectForTenant(tenant.id) ?? `${tenant.id}-default`;
|
||||
return { tenantId: tenant.id, projectId };
|
||||
}
|
||||
|
||||
// WebSocket connection handling
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
clients.add(ws);
|
||||
logger.info('New WebSocket connection established');
|
||||
// ── Connection Registry (Task 3) ──────────────────────────────────────────
|
||||
// Scoped by tenant → project → Set<ClientConnection>
|
||||
const connections = new Map<string, Map<string, Set<ClientConnection>>>();
|
||||
// Reverse lookup: ws → ClientConnection (for fast cleanup)
|
||||
const wsToConnection = new Map<WebSocket, ClientConnection>();
|
||||
|
||||
function registerConnection(conn: ClientConnection): void {
|
||||
let tenantMap = connections.get(conn.tenantId);
|
||||
if (!tenantMap) {
|
||||
tenantMap = new Map();
|
||||
connections.set(conn.tenantId, tenantMap);
|
||||
}
|
||||
let projectSet = tenantMap.get(conn.projectId);
|
||||
if (!projectSet) {
|
||||
projectSet = new Set();
|
||||
tenantMap.set(conn.projectId, projectSet);
|
||||
}
|
||||
projectSet.add(conn);
|
||||
wsToConnection.set(conn.ws, conn);
|
||||
}
|
||||
|
||||
function unregisterConnection(ws: WebSocket): void {
|
||||
const conn = wsToConnection.get(ws);
|
||||
if (!conn) return;
|
||||
const tenantMap = connections.get(conn.tenantId);
|
||||
if (tenantMap) {
|
||||
const projectSet = tenantMap.get(conn.projectId);
|
||||
if (projectSet) {
|
||||
projectSet.delete(conn);
|
||||
if (projectSet.size === 0) tenantMap.delete(conn.projectId);
|
||||
}
|
||||
if (tenantMap.size === 0) connections.delete(conn.tenantId);
|
||||
}
|
||||
wsToConnection.delete(ws);
|
||||
}
|
||||
|
||||
function moveConnection(ws: WebSocket, newTenantId: string, newProjectId: string): void {
|
||||
unregisterConnection(ws);
|
||||
const conn = { ws, tenantId: newTenantId, projectId: newProjectId, connectedAt: Date.now(), identified: true };
|
||||
registerConnection(conn);
|
||||
}
|
||||
|
||||
function getConnectionsForScope(tenantId: string, projectId: string): Set<ClientConnection> {
|
||||
return connections.get(tenantId)?.get(projectId) ?? new Set();
|
||||
}
|
||||
|
||||
// ── Scoped Broadcast (Task 5) ─────────────────────────────────────────────
|
||||
function broadcastToScope(
|
||||
tenantId: string,
|
||||
projectId: string,
|
||||
message: WebSocketMessage,
|
||||
exclude?: WebSocket
|
||||
): BroadcastResult {
|
||||
const msgId = generateId();
|
||||
(message as any).msgId = msgId;
|
||||
|
||||
const scopeConns = getConnectionsForScope(tenantId, projectId);
|
||||
const targets = [...scopeConns].filter(c =>
|
||||
c.ws !== exclude && c.ws.readyState === WebSocket.OPEN
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return { delivered: 0, msgId, reason: 'no_clients_in_scope' };
|
||||
}
|
||||
|
||||
const data = JSON.stringify(message);
|
||||
for (const conn of targets) {
|
||||
conn.ws.send(data);
|
||||
}
|
||||
|
||||
return { delivered: targets.length, msgId };
|
||||
}
|
||||
|
||||
// ── ACK Tracking (Task 6) ─────────────────────────────────────────────────
|
||||
interface AckResult {
|
||||
acked: boolean;
|
||||
delivered: number;
|
||||
reason?: string;
|
||||
ackPayload?: { status: string; elementCount?: number; expectedCount?: number };
|
||||
}
|
||||
|
||||
interface PendingAck {
|
||||
resolve: (payload: { status: string; elementCount?: number; expectedCount?: number } | null) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pendingAcks = new Map<string, PendingAck>();
|
||||
|
||||
function resolveAck(msgId: string, payload: { status: string; elementCount?: number; expectedCount?: number }): void {
|
||||
const pending = pendingAcks.get(msgId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pendingAcks.delete(msgId);
|
||||
pending.resolve(payload);
|
||||
}
|
||||
|
||||
async function broadcastWithAck(
|
||||
tenantId: string,
|
||||
projectId: string,
|
||||
message: WebSocketMessage,
|
||||
timeoutMs: number = 3000
|
||||
): Promise<AckResult> {
|
||||
const br = broadcastToScope(tenantId, projectId, message);
|
||||
|
||||
if (br.delivered === 0) {
|
||||
return { acked: false, delivered: 0, reason: br.reason ?? 'no_clients' };
|
||||
}
|
||||
|
||||
// Wait for first ACK from any client
|
||||
const ackPayload = await new Promise<{ status: string; elementCount?: number; expectedCount?: number } | null>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingAcks.delete(br.msgId);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
pendingAcks.set(br.msgId, { resolve, timer });
|
||||
});
|
||||
|
||||
return {
|
||||
acked: ackPayload !== null,
|
||||
delivered: br.delivered,
|
||||
ackPayload: ackPayload ?? undefined,
|
||||
reason: ackPayload ? undefined : 'ack_timeout'
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
// Send current tenant info
|
||||
try {
|
||||
const tenant = dbGetActiveTenant();
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tenant_switched',
|
||||
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
|
||||
}));
|
||||
} catch {}
|
||||
|
||||
// Send current elements to new client
|
||||
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 {
|
||||
const data = JSON.stringify(message);
|
||||
for (const conn of wsToConnection.values()) {
|
||||
if (conn.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket Connection Handling (Task 4: Hello Handshake) ───────────────
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
// Register with fallback scope until hello handshake identifies the client.
|
||||
const tenant = (() => { try { return dbGetActiveTenant(); } catch { return { id: 'default', name: 'default', workspace_path: '' }; } })();
|
||||
const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default';
|
||||
const conn: ClientConnection = {
|
||||
ws,
|
||||
tenantId: tenant.id,
|
||||
projectId: fallbackProjectId,
|
||||
connectedAt: Date.now(),
|
||||
identified: false
|
||||
};
|
||||
registerConnection(conn);
|
||||
logger.info('New WebSocket connection established (awaiting hello)');
|
||||
|
||||
// Send tenant info so the FE knows where to send hello
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tenant_switched',
|
||||
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
|
||||
}));
|
||||
|
||||
// For backward compatibility: also send initial_elements immediately.
|
||||
// New FE versions will ignore this and use hello_ack instead.
|
||||
const initialMessage: InitialElementsMessage = {
|
||||
type: 'initial_elements',
|
||||
elements: store.getAllElements()
|
||||
elements: store.getAllElements(fallbackProjectId)
|
||||
};
|
||||
ws.send(JSON.stringify(initialMessage));
|
||||
|
||||
@@ -99,23 +283,57 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
}
|
||||
ws.send(JSON.stringify({ type: 'files_added', files: allFiles }));
|
||||
}
|
||||
|
||||
|
||||
// Send sync status to new client
|
||||
const syncMessage: SyncStatusMessage = {
|
||||
type: 'sync_status',
|
||||
elementCount: store.getElementCount(),
|
||||
elementCount: store.getElementCount(fallbackProjectId),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
ws.send(JSON.stringify(syncMessage));
|
||||
|
||||
|
||||
// Handle incoming messages from this client
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'hello') {
|
||||
const helloTenantId = msg.tenantId as string;
|
||||
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}`);
|
||||
|
||||
// Respond with scoped elements
|
||||
const elements = store.getAllElements(helloProjectId);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello_ack',
|
||||
tenantId: helloTenantId,
|
||||
projectId: helloProjectId,
|
||||
elements
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (msg.type === 'ack' && msg.msgId) {
|
||||
resolveAck(msg.msgId, {
|
||||
status: msg.status ?? 'applied',
|
||||
elementCount: msg.elementCount,
|
||||
expectedCount: msg.expectedCount
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('Failed to parse WS message from client:', (err as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clients.delete(ws);
|
||||
unregisterConnection(ws);
|
||||
logger.info('WebSocket connection closed');
|
||||
});
|
||||
|
||||
|
||||
ws.on('error', (error) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
clients.delete(ws);
|
||||
unregisterConnection(ws);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,7 +441,7 @@ app.get('/api/elements', (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Create new element
|
||||
app.post('/api/elements', (req: Request, res: Response) => {
|
||||
app.post('/api/elements', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const params = CreateElementSchema.parse(req.body);
|
||||
@@ -240,17 +458,26 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
version: 1
|
||||
};
|
||||
|
||||
store.setElement(id, element, projId);
|
||||
|
||||
const sv = store.setElement(id, element, projId);
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementCreatedMessage = {
|
||||
type: 'element_created',
|
||||
element: element
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
(message as any).sync_version = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
element: element
|
||||
element: element,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
ackedBy: ackResult.acked ? 1 : 0,
|
||||
reason: ackResult.reason,
|
||||
scope: `${scope.tenantId}/${scope.projectId}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating element:', error);
|
||||
@@ -262,7 +489,7 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Update element
|
||||
app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
app.put('/api/elements/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
@@ -292,17 +519,26 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
version: (existingElement.version || 0) + 1
|
||||
};
|
||||
|
||||
store.setElement(id, updatedElement, projId);
|
||||
|
||||
const sv = store.setElement(id, updatedElement, projId);
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementUpdatedMessage = {
|
||||
type: 'element_updated',
|
||||
element: updatedElement
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
(message as any).sync_version = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
element: updatedElement
|
||||
element: updatedElement,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
ackedBy: ackResult.acked ? 1 : 0,
|
||||
reason: ackResult.reason,
|
||||
scope: `${scope.tenantId}/${scope.projectId}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating element:', error);
|
||||
@@ -319,7 +555,8 @@ app.delete('/api/elements/clear', (req: Request, res: Response) => {
|
||||
const projId = resolveTenantProject(req);
|
||||
const count = store.clearElements(projId);
|
||||
|
||||
broadcast({
|
||||
const scope = resolveScope(req);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'canvas_cleared',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
@@ -361,13 +598,13 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
store.deleteElement(id, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementDeletedMessage = {
|
||||
type: 'element_deleted',
|
||||
elementId: id!
|
||||
};
|
||||
broadcast(message);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -579,7 +816,7 @@ function resolveArrowBindings(batchElements: ServerElement[], projectId?: string
|
||||
}
|
||||
|
||||
// Batch create elements
|
||||
app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
app.post('/api/elements/batch', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { elements: elementsToCreate } = req.body;
|
||||
@@ -611,19 +848,28 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
|
||||
resolveArrowBindings(createdElements, projId);
|
||||
|
||||
createdElements.forEach(el => store.setElement(el.id, el, projId));
|
||||
let latestSyncVersion = 0;
|
||||
createdElements.forEach(el => { latestSyncVersion = store.setElement(el.id, el, projId); });
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const scope = resolveScope(req);
|
||||
const message: BatchCreatedMessage = {
|
||||
type: 'elements_batch_created',
|
||||
elements: createdElements
|
||||
};
|
||||
broadcast(message);
|
||||
(message as any).sync_version = latestSyncVersion;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
elements: createdElements,
|
||||
count: createdElements.length
|
||||
count: createdElements.length,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
ackedBy: ackResult.acked ? 1 : 0,
|
||||
reason: ackResult.reason,
|
||||
scope: `${scope.tenantId}/${scope.projectId}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error batch creating elements:', error);
|
||||
@@ -651,8 +897,9 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
|
||||
hasConfig: !!config
|
||||
});
|
||||
|
||||
// Broadcast to all WebSocket clients to process the Mermaid diagram
|
||||
broadcast({
|
||||
// Broadcast to scoped WebSocket clients to process the Mermaid diagram
|
||||
const scope = resolveScope(req);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'mermaid_convert',
|
||||
mermaidDiagram,
|
||||
config: config || {},
|
||||
@@ -720,7 +967,8 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
store.bulkReplaceElements(processedElements, projId);
|
||||
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
|
||||
|
||||
broadcast({
|
||||
const scope = resolveScope(req);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'elements_synced',
|
||||
count: successCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -746,6 +994,76 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delta Sync v2 (Task 10) ──
|
||||
|
||||
app.post('/api/elements/sync/v2', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { lastSyncVersion = 0, changes = [] } = req.body;
|
||||
|
||||
if (typeof lastSyncVersion !== 'number') {
|
||||
return res.status(400).json({ success: false, error: 'lastSyncVersion must be a number' });
|
||||
}
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const feChangeIds = new Set<string>();
|
||||
|
||||
// Apply FE changes to DB
|
||||
let appliedCount = 0;
|
||||
for (const change of changes) {
|
||||
const { id, action, element } = change;
|
||||
if (!id || !action) continue;
|
||||
feChangeIds.add(id);
|
||||
|
||||
if (action === 'delete') {
|
||||
store.deleteElement(id, projId);
|
||||
appliedCount++;
|
||||
} else if (action === 'upsert' && element) {
|
||||
store.setElement(id, element, projId);
|
||||
appliedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get BE-side changes the FE hasn't seen (excluding what FE just sent)
|
||||
const allBEChanges = getChangesSince(lastSyncVersion, projId);
|
||||
const serverChanges = allBEChanges.filter(c => !feChangeIds.has(c.id));
|
||||
|
||||
const currentVersion = getCurrentSyncVersion(projId);
|
||||
|
||||
// Broadcast FE changes to other tabs in scope
|
||||
if (appliedCount > 0) {
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'elements_synced',
|
||||
count: appliedCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'delta_sync_v2',
|
||||
sync_version: currentVersion
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
currentSyncVersion: currentVersion,
|
||||
serverChanges,
|
||||
appliedCount
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Delta sync v2 error:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current sync version for a project
|
||||
app.get('/api/sync/version', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const version = getCurrentSyncVersion(projId);
|
||||
res.json({ success: true, syncVersion: version });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Files API (image element data) ──
|
||||
|
||||
// Get all files
|
||||
@@ -820,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({
|
||||
@@ -829,7 +1147,7 @@ app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (clients.size === 0) {
|
||||
if (wsToConnection.size === 0) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'No frontend client connected. Open the canvas in a browser first.'
|
||||
@@ -837,6 +1155,7 @@ app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const requestId = generateId();
|
||||
const scope = resolveScope(req);
|
||||
|
||||
const exportPromise = new Promise<{ format: string; data: string }>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -847,11 +1166,12 @@ app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
pendingExports.set(requestId, { resolve, reject, timeout });
|
||||
});
|
||||
|
||||
broadcast({
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'export_image_request',
|
||||
requestId,
|
||||
format,
|
||||
background: background ?? true
|
||||
background: background ?? true,
|
||||
captureViewport: captureViewport ?? false
|
||||
});
|
||||
|
||||
exportPromise
|
||||
@@ -928,7 +1248,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { scrollToContent, scrollToElementId, zoom, offsetX, offsetY } = req.body;
|
||||
|
||||
if (clients.size === 0) {
|
||||
if (wsToConnection.size === 0) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'No frontend client connected. Open the canvas in a browser first.'
|
||||
@@ -936,6 +1256,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const requestId = generateId();
|
||||
const scope = resolveScope(req);
|
||||
|
||||
const viewportPromise = new Promise<{ success: boolean; message: string }>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -946,7 +1267,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
|
||||
pendingViewports.set(requestId, { resolve, reject, timeout });
|
||||
});
|
||||
|
||||
broadcast({
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'set_viewport',
|
||||
requestId,
|
||||
scrollToContent,
|
||||
@@ -1183,7 +1504,7 @@ app.get('/health', (req: Request, res: Response) => {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
elements_count: store.getElementCount(projId),
|
||||
websocket_clients: clients.size
|
||||
websocket_clients: wsToConnection.size
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1198,7 +1519,7 @@ app.get('/api/sync/status', (req: Request, res: Response) => {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
|
||||
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
|
||||
},
|
||||
websocketClients: clients.size
|
||||
websocketClients: wsToConnection.size
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1267,7 +1588,7 @@ export function stopCanvasServer(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
clients.forEach(c => c.close());
|
||||
for (const conn of wsToConnection.values()) conn.ws.close();
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
+100
-6
@@ -13,6 +13,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { FONT_FAMILIES, DEFAULT_FONT_FAMILY } from './types.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -118,7 +119,7 @@ function detectInstalledAgents(): AgentDef[] {
|
||||
// ── Phase 1: Environment Check ──────────────────────────────
|
||||
|
||||
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
heading('1/3', 'Environment');
|
||||
heading('1/4', 'Environment');
|
||||
let allOk = true;
|
||||
|
||||
// Node.js version
|
||||
@@ -182,10 +183,99 @@ async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
return allOk;
|
||||
}
|
||||
|
||||
// ── Preference Setup ─────────────────────────────────────────
|
||||
|
||||
// Derived from FONT_FAMILIES in types.ts — single source of truth
|
||||
const FONT_OPTIONS = FONT_FAMILIES
|
||||
.filter(f => !f.legacy)
|
||||
.map(f => ({ value: f.id, label: f.label }));
|
||||
|
||||
const ROUGHNESS_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 0, label: 'Clean / professional' },
|
||||
{ value: 1, label: 'Hand-drawn sketch' },
|
||||
{ value: 2, label: 'Very rough' },
|
||||
];
|
||||
|
||||
function getGlobalPreferencesPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'skills', 'excalidraw-skill', 'preferences.json');
|
||||
}
|
||||
|
||||
function globalPreferencesExist(): boolean {
|
||||
return fs.existsSync(getGlobalPreferencesPath());
|
||||
}
|
||||
|
||||
function writePreferencesFile(filePath: string, prefs: { fontFamily: number; fontSize: number; roughness: number; strokeWidth: number }): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const content = {
|
||||
defaults: prefs,
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function phasePreferences(rl: readline.Interface, phaseLabel: string): Promise<void> {
|
||||
heading(phaseLabel, 'Diagram Preferences');
|
||||
|
||||
const prefsPath = getGlobalPreferencesPath();
|
||||
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
|
||||
const d = raw?.defaults;
|
||||
if (d) {
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === d.fontFamily)?.label ?? `font ${d.fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === d.roughness)?.label ?? `roughness ${d.roughness}`;
|
||||
ok(`Current: ${fontLabel}, ${roughLabel}, fontSize ${d.fontSize}, strokeWidth ${d.strokeWidth}`);
|
||||
const change = await confirm(rl, 'Change preferences?', false);
|
||||
if (!change) return;
|
||||
}
|
||||
} catch {
|
||||
warn(`Could not read ${prefsPath}, will reconfigure.`);
|
||||
}
|
||||
}
|
||||
|
||||
info('These defaults apply to every diagram (font, style, etc.).');
|
||||
info('');
|
||||
|
||||
// Font
|
||||
process.stdout.write('\n Font family:\n');
|
||||
FONT_OPTIONS.forEach((f, i) => {
|
||||
const marker = f.value === DEFAULT_FONT_FAMILY ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${f.label}${marker}\n`);
|
||||
});
|
||||
const fontAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const fontIdx = fontAnswer === '' ? 0 : parseInt(fontAnswer, 10) - 1;
|
||||
const fontFamily = (fontIdx >= 0 && fontIdx < FONT_OPTIONS.length) ? FONT_OPTIONS[fontIdx]!.value : DEFAULT_FONT_FAMILY;
|
||||
|
||||
// Roughness
|
||||
process.stdout.write('\n Diagram style:\n');
|
||||
ROUGHNESS_OPTIONS.forEach((r, i) => {
|
||||
const marker = r.value === 0 ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${r.label}${marker}\n`);
|
||||
});
|
||||
const roughAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const roughIdx = roughAnswer === '' ? 0 : parseInt(roughAnswer, 10) - 1;
|
||||
const roughness = (roughIdx >= 0 && roughIdx < ROUGHNESS_OPTIONS.length) ? ROUGHNESS_OPTIONS[roughIdx]!.value : 0;
|
||||
|
||||
const prefs = { fontFamily, fontSize: 20, roughness, strokeWidth: 2 };
|
||||
|
||||
try {
|
||||
writePreferencesFile(prefsPath, prefs);
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === fontFamily)?.label ?? `${fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === roughness)?.label ?? `${roughness}`;
|
||||
ok(`Saved: ${fontLabel}, ${roughLabel}`);
|
||||
ok(`File: ${prefsPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed to save preferences: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Skill Installation ─────────────────────────────
|
||||
|
||||
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
|
||||
heading('2/3', 'Agent Skill');
|
||||
heading('2/4', 'Agent Skill');
|
||||
|
||||
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
|
||||
if (!wantSkill) {
|
||||
@@ -347,7 +437,7 @@ function writeInstructionDirective(filePath: string, format: 'claude-md' | 'curs
|
||||
// ── Phase 3: MCP Configuration ──────────────────────────────
|
||||
|
||||
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
|
||||
heading('3/3', 'MCP Configuration');
|
||||
heading('4/4', 'MCP Configuration');
|
||||
|
||||
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
|
||||
if (!wantConfig) {
|
||||
@@ -519,7 +609,7 @@ export async function runUpdate(): Promise<void> {
|
||||
|
||||
try {
|
||||
// ── Phase 1: Detect existing skill installations ──────────
|
||||
heading('1/2', 'Skill Update');
|
||||
heading('1/3', 'Skill Update');
|
||||
|
||||
const allInstalls = findExistingSkillInstalls();
|
||||
const existing = allInstalls.filter(i => i.exists);
|
||||
@@ -612,8 +702,11 @@ export async function runUpdate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: MCP config check ────────────────────────────
|
||||
heading('2/2', 'MCP Configuration');
|
||||
// ── Phase 2: Preferences ─────────────────────────────────
|
||||
await phasePreferences(rl, '2/3');
|
||||
|
||||
// ── Phase 3: MCP config check ────────────────────────────
|
||||
heading('3/3', 'MCP Configuration');
|
||||
|
||||
for (const agent of detectedAgents) {
|
||||
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
|
||||
@@ -685,6 +778,7 @@ export async function runSetup(): Promise<void> {
|
||||
try {
|
||||
await phaseEnvironment(rl);
|
||||
await phaseSkillInstall(rl);
|
||||
await phasePreferences(rl, '3/4');
|
||||
await phaseMcpConfig(rl);
|
||||
|
||||
process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`);
|
||||
|
||||
+70
-19
@@ -193,7 +193,46 @@ export type WebSocketMessageType =
|
||||
| 'set_viewport'
|
||||
| 'tenant_switched'
|
||||
| 'files_added'
|
||||
| 'file_deleted';
|
||||
| 'file_deleted'
|
||||
| 'hello'
|
||||
| 'hello_ack'
|
||||
| 'ack';
|
||||
|
||||
// Connection registry types
|
||||
export interface ClientConnection {
|
||||
ws: import('ws').WebSocket;
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
connectedAt: number;
|
||||
identified: boolean; // true after hello handshake
|
||||
}
|
||||
|
||||
export interface BroadcastResult {
|
||||
delivered: number;
|
||||
msgId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HelloMessage extends WebSocketMessage {
|
||||
type: 'hello';
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface HelloAckMessage extends WebSocketMessage {
|
||||
type: 'hello_ack';
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
elements: ServerElement[];
|
||||
}
|
||||
|
||||
export interface AckMessage extends WebSocketMessage {
|
||||
type: 'ack';
|
||||
msgId: string;
|
||||
status: 'applied' | 'partial' | 'failed';
|
||||
elementCount?: number;
|
||||
expectedCount?: number;
|
||||
}
|
||||
|
||||
export interface InitialElementsMessage extends WebSocketMessage {
|
||||
type: 'initial_elements';
|
||||
@@ -311,24 +350,36 @@ export interface ExcalidrawFile {
|
||||
// In-memory file storage (image files are too large for SQLite row storage)
|
||||
export const files = new Map<string, ExcalidrawFile>();
|
||||
|
||||
// Font family normalization: Excalidraw expects numeric IDs, but agents
|
||||
// often send string names. Map common names to their numeric equivalents.
|
||||
const FONT_FAMILY_MAP: Record<string, number> = {
|
||||
'virgil': 1,
|
||||
'hand-drawn': 1,
|
||||
'excalifont': 1,
|
||||
'helvetica': 2,
|
||||
'arial': 2,
|
||||
'sans-serif': 2,
|
||||
'cascadia': 3,
|
||||
'monospace': 3,
|
||||
'courier': 3,
|
||||
'comic shanns': 4,
|
||||
'comic sans': 4,
|
||||
'liberation sans': 5,
|
||||
'nunito': 6,
|
||||
'lilita one': 7,
|
||||
};
|
||||
// ── Font families — single source of truth ──────────────────────────────
|
||||
// IDs match the @excalidraw/excalidraw FONT_FAMILY constant.
|
||||
// The canonical data lives in font-families.json; every other file derives from it.
|
||||
import fontData from './font-families.json' with { type: 'json' };
|
||||
|
||||
export interface FontFamilyDef {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
aliases: string[];
|
||||
legacy?: boolean; // hidden from setup menus / tool docs
|
||||
}
|
||||
|
||||
export const FONT_FAMILIES: FontFamilyDef[] = fontData.fonts as FontFamilyDef[];
|
||||
|
||||
export const DEFAULT_FONT_FAMILY: number = fontData.defaultFontFamily;
|
||||
|
||||
// Derived: description string for MCP tool schemas
|
||||
export const FONT_FAMILY_DESCRIPTION =
|
||||
'Font family: ' +
|
||||
FONT_FAMILIES.filter(f => !f.legacy).map(f => `${f.id}=${f.name}`).join(', ') +
|
||||
'. Accepts name strings too.';
|
||||
|
||||
// Derived: string → number mapping for normalization
|
||||
const FONT_FAMILY_MAP: Record<string, number> = {};
|
||||
for (const font of FONT_FAMILIES) {
|
||||
for (const alias of font.aliases) {
|
||||
FONT_FAMILY_MAP[alias] = font.id;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFontFamily(value: string | number | undefined): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
|
||||
+158
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting } from '../../src/db.js';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant } from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
@@ -27,6 +27,8 @@ function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-api-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
// Reset module-level active tenant/project to 'default' (may be stale from previous test)
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
@@ -430,3 +432,158 @@ describe('Tenant-scoped requests via X-Tenant-Id', () => {
|
||||
expect(resB.body.elements[0].type).toBe('ellipse');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version ───────────────────────────────────────────
|
||||
|
||||
describe('GET /api/sync/version', () => {
|
||||
it('returns syncVersion 0 initially', async () => {
|
||||
const res = await request(app).get('/api/sync/version');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.syncVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('syncVersion increases after element creation', async () => {
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
|
||||
|
||||
const res = await request(app).get('/api/sync/version');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.syncVersion).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2 ──────────────────────────────────────────
|
||||
|
||||
describe('POST /api/elements/sync/v2', () => {
|
||||
it('returns currentSyncVersion and empty serverChanges', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body).toHaveProperty('currentSyncVersion');
|
||||
expect(typeof res.body.currentSyncVersion).toBe('number');
|
||||
expect(Array.isArray(res.body.serverChanges)).toBe(true);
|
||||
expect(res.body.serverChanges.length).toBe(0);
|
||||
});
|
||||
|
||||
it('applies upsert changes', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{
|
||||
id: 'sv2-1',
|
||||
action: 'upsert',
|
||||
element: { id: 'sv2-1', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.appliedCount).toBe(1);
|
||||
|
||||
const getRes = await request(app).get('/api/elements/sv2-1');
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.element.id).toBe('sv2-1');
|
||||
});
|
||||
|
||||
it('applies delete changes', async () => {
|
||||
setElement('sv2-del', makeElement({ id: 'sv2-del' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'sv2-del', action: 'delete' }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.appliedCount).toBe(1);
|
||||
|
||||
const getRes = await request(app).get('/api/elements/sv2-del');
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns server changes since lastSyncVersion', async () => {
|
||||
setElement('sv1', makeElement({ id: 'sv1' }));
|
||||
setElement('sv2', makeElement({ id: 'sv2' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.serverChanges.length).toBeGreaterThanOrEqual(2);
|
||||
const ids = res.body.serverChanges.map((c: any) => c.id);
|
||||
expect(ids).toContain('sv1');
|
||||
expect(ids).toContain('sv2');
|
||||
});
|
||||
|
||||
it('rejects non-number lastSyncVersion', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 'bad', changes: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canvasStatus in mutation responses ─────────────────────
|
||||
|
||||
describe('canvasStatus in mutation responses', () => {
|
||||
it('POST /api/elements includes syncedToCanvas and canvasStatus', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.syncedToCanvas).toBe('boolean');
|
||||
expect(res.body.syncedToCanvas).toBe(false);
|
||||
expect(res.body.canvasStatus).toBeDefined();
|
||||
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
|
||||
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
|
||||
expect(res.body.canvasStatus).toHaveProperty('reason');
|
||||
expect(res.body.canvasStatus).toHaveProperty('scope');
|
||||
});
|
||||
|
||||
it('PUT /api/elements/:id includes canvasStatus', async () => {
|
||||
setElement('cs-put', makeElement({ id: 'cs-put', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/elements/cs-put')
|
||||
.send({ x: 100 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.syncedToCanvas).toBe('boolean');
|
||||
expect(res.body.canvasStatus).toBeDefined();
|
||||
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
|
||||
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
|
||||
expect(res.body.canvasStatus).toHaveProperty('reason');
|
||||
expect(res.body.canvasStatus).toHaveProperty('scope');
|
||||
});
|
||||
|
||||
it('POST /api/elements/batch includes canvasStatus', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
||||
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.syncedToCanvas).toBe('boolean');
|
||||
expect(res.body.canvasStatus).toBeDefined();
|
||||
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
|
||||
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
|
||||
expect(res.body.canvasStatus).toHaveProperty('reason');
|
||||
expect(res.body.canvasStatus).toHaveProperty('scope');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
bulkReplaceElements,
|
||||
getSetting,
|
||||
setSetting,
|
||||
incrementSyncVersion,
|
||||
getCurrentSyncVersion,
|
||||
getChangesSince,
|
||||
} from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import path from 'path';
|
||||
@@ -424,3 +427,94 @@ describe('bulkReplaceElements', () => {
|
||||
expect(getAllElements()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version ───────────────────────────────────────────
|
||||
|
||||
describe('Sync Version', () => {
|
||||
it('getCurrentSyncVersion returns 0 initially', () => {
|
||||
expect(getCurrentSyncVersion()).toBe(0);
|
||||
});
|
||||
|
||||
it('incrementSyncVersion increments and returns new version', () => {
|
||||
expect(incrementSyncVersion()).toBe(1);
|
||||
expect(incrementSyncVersion()).toBe(2);
|
||||
expect(incrementSyncVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it('setElement increments sync_version', () => {
|
||||
setElement('sv1', makeElement({ id: 'sv1' }));
|
||||
expect(getCurrentSyncVersion()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('setElement returns sync_version', () => {
|
||||
const sv = setElement('sv2', makeElement({ id: 'sv2' }));
|
||||
expect(sv).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('deleteElement increments sync_version', () => {
|
||||
setElement('del-sv', makeElement({ id: 'del-sv' }));
|
||||
const versionAfterCreate = getCurrentSyncVersion();
|
||||
deleteElement('del-sv');
|
||||
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreate);
|
||||
});
|
||||
|
||||
it('clearElements increments sync_version', () => {
|
||||
setElement('clr1', makeElement({ id: 'clr1' }));
|
||||
setElement('clr2', makeElement({ id: 'clr2' }));
|
||||
const versionAfterCreates = getCurrentSyncVersion();
|
||||
clearElements();
|
||||
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreates);
|
||||
});
|
||||
|
||||
it('getChangesSince returns empty for version 0 when no elements', () => {
|
||||
const changes = getChangesSince(0);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it('getChangesSince returns upserts after setElement', () => {
|
||||
setElement('cs1', makeElement({ id: 'cs1' }));
|
||||
setElement('cs2', makeElement({ id: 'cs2' }));
|
||||
|
||||
const changes = getChangesSince(0);
|
||||
expect(changes.length).toBe(2);
|
||||
expect(changes.every(c => c.action === 'upsert')).toBe(true);
|
||||
});
|
||||
|
||||
it('getChangesSince returns delete entries', () => {
|
||||
setElement('csd1', makeElement({ id: 'csd1' }));
|
||||
deleteElement('csd1');
|
||||
|
||||
const changes = getChangesSince(0);
|
||||
const deleteChange = changes.find(c => c.action === 'delete');
|
||||
expect(deleteChange).toBeDefined();
|
||||
});
|
||||
|
||||
it('getChangesSince filters by version', () => {
|
||||
const sv1 = setElement('fv1', makeElement({ id: 'fv1' }));
|
||||
setElement('fv2', makeElement({ id: 'fv2' }));
|
||||
|
||||
const changes = getChangesSince(sv1);
|
||||
expect(changes.length).toBe(1);
|
||||
expect(changes[0]!.id).toBe('fv2');
|
||||
});
|
||||
|
||||
it('sync_version is scoped per project', () => {
|
||||
const proj1 = createProject('SV-P1');
|
||||
const proj2 = createProject('SV-P2');
|
||||
|
||||
setActiveProject(proj1.id);
|
||||
setElement('sp1', makeElement({ id: 'sp1' }));
|
||||
const sv1 = getCurrentSyncVersion(proj1.id);
|
||||
|
||||
setActiveProject(proj2.id);
|
||||
setElement('sp2', makeElement({ id: 'sp2' }));
|
||||
setElement('sp3', makeElement({ id: 'sp3' }));
|
||||
const sv2 = getCurrentSyncVersion(proj2.id);
|
||||
|
||||
// Each project tracks its own sync_version independently
|
||||
expect(sv1).toBeGreaterThan(0);
|
||||
expect(sv2).toBeGreaterThan(0);
|
||||
// P2 had more mutations so its version should be higher than P1's
|
||||
expect(sv2).toBeGreaterThan(sv1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,3 +253,201 @@ describe('WebSocket broadcasts', () => {
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hello handshake', () => {
|
||||
it('client receives hello_ack after sending hello', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
tenantId: 'default',
|
||||
projectId: 'default',
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.type).toBe('hello_ack');
|
||||
expect(msg.tenantId).toBe('default');
|
||||
expect(msg.projectId).toBe('default');
|
||||
expect(Array.isArray(msg.elements)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('hello_ack contains elements for the requested project', async () => {
|
||||
setElement('hello-el', {
|
||||
id: 'hello-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',
|
||||
projectId: 'default',
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.elements.length).toBeGreaterThanOrEqual(1);
|
||||
const found = msg.elements.find((el: any) => el.id === 'hello-el');
|
||||
expect(found).toBeDefined();
|
||||
expect(found.type).toBe('rectangle');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scoped broadcast', () => {
|
||||
it('broadcast reaches all clients in the same default scope', async () => {
|
||||
const ws1 = await connectClient();
|
||||
const ws2 = await connectClient();
|
||||
await drainInitialMessages(ws1);
|
||||
await drainInitialMessages(ws2);
|
||||
|
||||
const promise1 = waitForMessageOfType(ws1, 'element_created');
|
||||
const promise2 = waitForMessageOfType(ws2, 'element_created');
|
||||
|
||||
await fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 30, height: 30 }),
|
||||
});
|
||||
|
||||
const [msg1, msg2] = await Promise.all([promise1, promise2]);
|
||||
expect(msg1.element.type).toBe('rectangle');
|
||||
expect(msg2.element.type).toBe('rectangle');
|
||||
// Both messages should have the same msgId since they came from the same broadcast
|
||||
expect(msg1.msgId).toBe(msg2.msgId);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACK model', () => {
|
||||
it('mutation broadcasts include msgId', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const createdPromise = waitForMessageOfType(ws, 'element_created');
|
||||
|
||||
await fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }),
|
||||
});
|
||||
|
||||
const msg = await createdPromise;
|
||||
expect(msg).toHaveProperty('msgId');
|
||||
expect(typeof msg.msgId).toBe('string');
|
||||
expect(msg.msgId.length).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('server accepts ack messages without error', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const createdPromise = waitForMessageOfType(ws, 'element_created');
|
||||
|
||||
await fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'ellipse', x: 10, y: 10, width: 40, height: 40 }),
|
||||
});
|
||||
|
||||
const msg = await createdPromise;
|
||||
|
||||
// Send ACK back — should not cause any errors or disconnection
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
|
||||
// Wait briefly to ensure server processes the ack without crashing
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify the connection is still open (readyState 1 = OPEN)
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync_version in broadcasts', () => {
|
||||
it('element_created broadcast includes sync_version', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const createdPromise = waitForMessageOfType(ws, 'element_created');
|
||||
|
||||
await fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }),
|
||||
});
|
||||
|
||||
const msg = await createdPromise;
|
||||
expect(msg).toHaveProperty('sync_version');
|
||||
expect(typeof msg.sync_version).toBe('number');
|
||||
expect(msg.sync_version).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('element_updated broadcast includes sync_version', async () => {
|
||||
setElement('sv-upd', {
|
||||
id: 'sv-upd', type: 'rectangle', x: 0, y: 0, width: 50, height: 50, version: 1,
|
||||
} as ServerElement);
|
||||
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const updatedPromise = waitForMessageOfType(ws, 'element_updated');
|
||||
|
||||
await fetch(`http://localhost:${port}/api/elements/sv-upd`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ x: 500 }),
|
||||
});
|
||||
|
||||
const msg = await updatedPromise;
|
||||
expect(msg).toHaveProperty('sync_version');
|
||||
expect(typeof msg.sync_version).toBe('number');
|
||||
expect(msg.sync_version).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('elements_batch_created broadcast includes sync_version', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const batchPromise = waitForMessageOfType(ws, 'elements_batch_created');
|
||||
|
||||
await fetch(`http://localhost:${port}/api/elements/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
||||
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const msg = await batchPromise;
|
||||
expect(msg).toHaveProperty('sync_version');
|
||||
expect(typeof msg.sync_version).toBe('number');
|
||||
expect(msg.sync_version).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -271,3 +271,191 @@ test.describe('Settings via API', () => {
|
||||
expect(body.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version API ───────────────────────────────────────
|
||||
|
||||
test.describe('Sync Version API', () => {
|
||||
test('GET /api/sync/version returns initial version', async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/sync/version`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.syncVersion).toBe('number');
|
||||
});
|
||||
|
||||
test('sync version increases after element creation', async ({ request }) => {
|
||||
const beforeRes = await request.get(`${API}/api/sync/version`);
|
||||
const beforeBody = await beforeRes.json();
|
||||
const versionBefore = beforeBody.syncVersion;
|
||||
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'sync-ver-el',
|
||||
type: 'rectangle',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 100,
|
||||
height: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const afterRes = await request.get(`${API}/api/sync/version`);
|
||||
const afterBody = await afterRes.json();
|
||||
expect(afterBody.syncVersion).toBeGreaterThan(versionBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2 API ──────────────────────────────────────
|
||||
|
||||
test.describe('Delta Sync v2 API', () => {
|
||||
test('accepts empty changes and returns current state', async ({ request }) => {
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: { lastSyncVersion: 0, changes: [] },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.currentSyncVersion).toBe('number');
|
||||
expect(Array.isArray(body.serverChanges)).toBe(true);
|
||||
});
|
||||
|
||||
test('applies upsert changes via delta sync', async ({ request }) => {
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{
|
||||
id: 'delta-upsert-1',
|
||||
action: 'upsert',
|
||||
element: {
|
||||
id: 'delta-upsert-1',
|
||||
type: 'rectangle',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 120,
|
||||
height: 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.appliedCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify the element exists via GET
|
||||
const getRes = await request.get(`${API}/api/elements/delta-upsert-1`);
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const getBody = await getRes.json();
|
||||
expect(getBody.element.id).toBe('delta-upsert-1');
|
||||
});
|
||||
|
||||
test('returns server changes for elements created via normal API', async ({ request }) => {
|
||||
// Create an element via the normal REST API
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'normal-api-el',
|
||||
type: 'ellipse',
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 80,
|
||||
height: 80,
|
||||
},
|
||||
});
|
||||
|
||||
// Now call delta sync with lastSyncVersion: 0 to get all server changes
|
||||
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: { lastSyncVersion: 0, changes: [] },
|
||||
});
|
||||
expect(syncRes.ok()).toBe(true);
|
||||
const syncBody = await syncRes.json();
|
||||
expect(syncBody.serverChanges.some((el: any) => el.id === 'normal-api-el')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canvasStatus in API responses ──────────────────────────
|
||||
|
||||
test.describe('canvasStatus in API responses', () => {
|
||||
test('element creation response includes canvasStatus', async ({ request }) => {
|
||||
const createRes = await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'status-check-el',
|
||||
type: 'rectangle',
|
||||
x: 300,
|
||||
y: 300,
|
||||
width: 150,
|
||||
height: 75,
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const body = await createRes.json();
|
||||
|
||||
// syncedToCanvas should be a boolean
|
||||
expect(typeof body.syncedToCanvas).toBe('boolean');
|
||||
|
||||
// canvasStatus object should be present with expected fields
|
||||
expect(body.canvasStatus).toBeDefined();
|
||||
expect(typeof body.canvasStatus.connectedBrowsers).toBe('number');
|
||||
expect(typeof body.canvasStatus.ackedBy).toBe('number');
|
||||
expect(typeof body.canvasStatus.reason).toBe('string');
|
||||
expect(typeof body.canvasStatus.scope).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Real-time Sync with ACK ────────────────────────────────
|
||||
|
||||
test.describe('Real-time Sync with ACK', () => {
|
||||
test('syncedToCanvas is true when browser is connected', async ({ page, request }) => {
|
||||
// Open the page and wait for WebSocket connection
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Create an element via API while browser is connected
|
||||
const createRes = await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'ack-test-rect',
|
||||
type: 'rectangle',
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#4ecdc4',
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const body = await createRes.json();
|
||||
|
||||
// Browser should have ACKed, so syncedToCanvas should be true
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
|
||||
// Also verify the element exists in the backend
|
||||
const verifyRes = await request.get(`${API}/api/elements/ack-test-rect`);
|
||||
expect(verifyRes.ok()).toBe(true);
|
||||
const verifyBody = await verifyRes.json();
|
||||
expect(verifyBody.element.id).toBe('ack-test-rect');
|
||||
});
|
||||
|
||||
test('batch create with browser connected gets ACK', async ({ page, request }) => {
|
||||
// Open the page and wait for WebSocket connection
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Batch create elements via API while browser is connected
|
||||
const batchRes = await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'ack-batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'ack-batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(batchRes.ok()).toBe(true);
|
||||
const body = await batchRes.json();
|
||||
|
||||
// Browser should have ACKed the batch broadcast
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
cleanElementForExcalidraw,
|
||||
validateAndFixBindings,
|
||||
computeElementHash,
|
||||
isImageElement,
|
||||
isShapeContainerType,
|
||||
normalizeImageElement,
|
||||
restoreBindings,
|
||||
} from '../../frontend/src/utils/elementHelpers.js';
|
||||
import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js';
|
||||
|
||||
@@ -216,3 +220,159 @@ describe('computeElementHash', () => {
|
||||
expect(hash.startsWith('1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isImageElement ─────────────────────────────────────────
|
||||
|
||||
describe('isImageElement', () => {
|
||||
it('returns true for image type', () => {
|
||||
expect(isImageElement({ type: 'image' } as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-image types', () => {
|
||||
expect(isImageElement({ type: 'rectangle' } as any)).toBe(false);
|
||||
expect(isImageElement({ type: 'text' } as any)).toBe(false);
|
||||
expect(isImageElement({ type: 'arrow' } as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isShapeContainerType ───────────────────────────────────
|
||||
|
||||
describe('isShapeContainerType', () => {
|
||||
it('returns true for container types', () => {
|
||||
expect(isShapeContainerType('rectangle')).toBe(true);
|
||||
expect(isShapeContainerType('ellipse')).toBe(true);
|
||||
expect(isShapeContainerType('diamond')).toBe(true);
|
||||
expect(isShapeContainerType('arrow')).toBe(true);
|
||||
expect(isShapeContainerType('line')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-container types', () => {
|
||||
expect(isShapeContainerType('text')).toBe(false);
|
||||
expect(isShapeContainerType('image')).toBe(false);
|
||||
expect(isShapeContainerType('freedraw')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeImageElement ──────────────────────────────────
|
||||
|
||||
describe('normalizeImageElement', () => {
|
||||
it('fills in default values for missing properties', () => {
|
||||
const el = { id: 'img1', type: 'image', x: 0, y: 0, width: 100, height: 100 };
|
||||
const result = normalizeImageElement(el);
|
||||
|
||||
expect(result.status).toBe('saved');
|
||||
expect(result.fileId).toBeNull();
|
||||
expect(result.scale).toEqual([1, 1]);
|
||||
expect(result.angle).toBe(0);
|
||||
expect(result.roughness).toBe(1);
|
||||
expect(result.opacity).toBe(100);
|
||||
expect(result.isDeleted).toBe(false);
|
||||
expect(result.locked).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves existing values', () => {
|
||||
const el = {
|
||||
id: 'img2',
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
status: 'pending',
|
||||
fileId: 'abc',
|
||||
scale: [2, 2] as [number, number],
|
||||
opacity: 50,
|
||||
};
|
||||
const result = normalizeImageElement(el);
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.fileId).toBe('abc');
|
||||
expect(result.scale).toEqual([2, 2]);
|
||||
expect(result.opacity).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── restoreBindings ────────────────────────────────────────
|
||||
|
||||
describe('restoreBindings', () => {
|
||||
it('restores startBinding and endBinding from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow1',
|
||||
type: 'arrow',
|
||||
x: 0,
|
||||
y: 0,
|
||||
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
|
||||
endBinding: { elementId: 'rect2', focus: 0, gap: 5 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding).toEqual({ elementId: 'rect1', focus: 0, gap: 5 });
|
||||
expect(result[0].endBinding).toEqual({ elementId: 'rect2', focus: 0, gap: 5 });
|
||||
});
|
||||
|
||||
it('restores boundElements from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'rect1', type: 'rectangle', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'rect1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
boundElements: [{ id: 'arrow1', type: 'arrow' }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].boundElements).toEqual([{ id: 'arrow1', type: 'arrow' }]);
|
||||
});
|
||||
|
||||
it('restores elbowed property from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, elbowed: true },
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].elbowed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not overwrite existing bindings', () => {
|
||||
const existingBinding = { elementId: 'rect99', focus: 1, gap: 10 };
|
||||
const converted = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, startBinding: existingBinding },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow1',
|
||||
type: 'arrow',
|
||||
x: 0,
|
||||
y: 0,
|
||||
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding).toEqual(existingBinding);
|
||||
});
|
||||
|
||||
it('handles elements not found in originals', () => {
|
||||
const converted = [
|
||||
{ id: 'new1', type: 'rectangle', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{ id: 'other', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'a', type: 'arrow' }] },
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0]).toEqual({ id: 'new1', type: 'rectangle', x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user