Compare commits

...
20 Commits
Author SHA1 Message Date
sanjibdevnathlabs-release-bot[bot] a1977d86f9 chore(release): v1.6.2 2026-03-18 04:39:37 +00:00
Sanjib DevnathandGitHub 2e743c1356 🐛 fix(sync): resolve delete persistence regression and harden data-safety invariants (#13)
Deletions made in the UI were silently lost on page reload because the sync
baseline (lastSyncedElementsRef) was never populated after initial load,
making the delta algorithm unable to detect removed elements. Additionally,
import_scene and restore_snapshot used a non-atomic clear+create pattern
that could permanently lose all canvas data if the batch create failed
after clearing, and duplicate_elements copied stale binding references
pointing to original element IDs instead of remapped duplicates.

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

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

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

🎯 Eliminates the most critical data-integrity risks: deletions now
persist reliably, destructive operations are rollback-safe, and the
full test suite provides regression coverage for every sync path.
2026-03-18 10:07:07 +05:30
sanjibdevnathlabs-release-bot[bot] 459dbfdb3a chore(release): v1.6.1 2026-03-18 03:00:11 +00:00
7c59972bb1 🐛 fix(mcp): resolve race conditions, sync failures, and preference regressions (#12)
Fix 6 bugs discovered during MCP tool usage:

1. syncToCanvas error handling: Distinguish network errors (return null)
   from API errors (re-throw with actual message). Fixes misleading
   "HTTP server unavailable" on batch_create_elements.

2. USER_PREFS fallbacks: create_element and batch_create_elements now
   apply fontFamily/roughness/fontSize/strokeWidth from preferences.json
   when not explicitly provided by the caller.

3. Hello handshake: Frontend sends `hello` on tenant_switched and handles
   `hello_ack`. Server resolves projectId from tenantId when absent.
   Fixes WS connections being registered under wrong scope.

4. Serialized broadcasts: Add serializedBroadcastWithAck() that queues
   broadcasts per tenant/project scope. Prevents race condition where
   parallel MCP create_element calls produce overlapping WS messages
   that clobber each other in the frontend.

5. Viewport screenshot: get_canvas_screenshot passes captureViewport=true,
   frontend captures DOM canvas via toDataURL() instead of exportToBlob()
   which always rendered the full scene bounding box.

6. Viewport animate:false: set_viewport uses animate:false for instant
   positioning, preventing mid-animation screenshot captures.

Tests: 14 new tests (8 API, 6 WS) + 9 E2E specs covering all fixes.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 08:28:00 +05:30
sanjibdevnathlabs-release-bot[bot] 670961ee73 chore(release): v1.6.0 2026-03-17 18:16:39 +00:00
2cca18153f feat(sync): implement scoped sync architecture with ACK model and comprehensive tests (#11)
Implement a complete sync architecture overhaul (12 tasks) replacing the flat
WebSocket broadcast with scoped, acknowledged delivery:

**Backend (server.ts, db.ts, types.ts, index.ts):**
- Scoped connection registry: Map<tenant, Map<project, Set<ClientConnection>>>
- Hello handshake: WS clients identify tenant/project, server responds with scoped elements
- broadcastToScope() replaces global broadcast for element mutations
- broadcastWithAck() waits for browser ACK before returning syncedToCanvas status
- sync_version: monotonic counter per project, stamped on every mutation
- Delta sync v2: POST /api/elements/sync/v2 for incremental sync with version tracking
- GET /api/sync/version endpoint
- Honest syncedToCanvas + canvasStatus in all mutation responses
- Fixed silent try/catch in tenant switch verification

**Frontend (App.tsx):**
- ACK sending after every updateScene() with element verification
- Delta sync v2 integration in syncToBackend()
- Gap detection: triggers resync when sync_version gaps are detected
- lastSyncVersion tracking via refs + localStorage persistence

**Tests (40 new tests, 168 total):**
- db.test.ts: +11 tests for sync_version CRUD, scoping, getChangesSince
- ws.test.ts: +8 tests for hello handshake, scoped broadcast, ACK model
- api.test.ts: +10 tests for sync/v2, sync/version, canvasStatus responses
- helpers.test.ts: +11 tests for isImageElement, normalizeImageElement, restoreBindings
- canvas.spec.ts: +8 e2e tests including full ACK pipeline verification
- Fixed stale tenant state bug in api.test.ts beforeEach

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 23:44:17 +05:30
sanjibdevnathlabs-release-bot[bot] 4e410f1205 chore(release): v1.5.1 2026-03-17 13:27:27 +00:00
sanjibdevnathlabsandClaude Opus 4.6 71b2a55231 ♻️ refactor(fonts): extract font families to shared JSON single source of truth
Font family IDs were duplicated across 5 files (types.ts, setup.ts,
index.ts, SKILL.md, preferences.example.json) with inconsistent
mappings — Comic Shanns was 4 in some places but actually 8 in
Excalidraw source. This caused wrong fonts to render on canvas.

Fix: create src/font-families.json as the canonical font data, import
it in types.ts, and derive all other references from it. Static docs
now point to the JSON file instead of duplicating the mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:55:11 +05:30
sanjibdevnathlabs-release-bot[bot] 25767838fa chore(release): v1.5.0 2026-03-17 12:12:15 +00:00
sanjibdevnathlabsandClaude Opus 4.6 493a20054b feat(setup): add interactive diagram preferences to setup and update flows
Users are now prompted to choose their preferred font family and roughness
style during both `setup` and `update`. Preferences are saved to
~/.claude/skills/excalidraw-skill/preferences.json, which the MCP server
already reads at startup via loadPreferences(). This ensures third-party
users who install via npx get preferences configured before first use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:39:58 +05:30
sanjibdevnathlabs-release-bot[bot] aa29ddbf13 chore(release): v1.4.0 2026-03-17 09:02:29 +00:00
sanjibdevnathlabsandClaude Opus 4.6 9114e81f02 feat(skill): add user-configurable diagram preferences system
Add a preference system that lets users configure default font, roughness,
fontSize, and strokeWidth — with three scopes (session/folder/global).

Skill layer (Step 1 in SKILL.md):
- Reads .claude/excalidraw-preferences.json (folder) then
  ~/.claude/skills/excalidraw-skill/preferences.json (global)
- If neither exists, prompts user interactively on first use
- Session-only scope keeps preferences in-memory without saving

Server layer (index.ts):
- loadPreferences() reads the same files at startup
- Replaces hardcoded fontFamily ?? 1 with USER_PREFS.fontFamily
- Folder-level preferences override global; user values override both

Also ships preferences.example.json as a template (preferences.json
is gitignored).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:27:34 +05:30
sanjibdevnathlabs-release-bot[bot] fac1c7a267 chore(release): v1.3.0 2026-03-13 18:19:16 +00:00
sanjibdevnathlabs 9311561227 feat(skill): add auto-triggering for excalidraw-skill via CLAUDE.md directives
The excalidraw-skill was not being auto-invoked when users prompted
Claude to draw diagrams, despite being installed. Claude would call
Excalidraw MCP tools directly, bypassing the skill's critical sizing
formulas and verification workflow — producing broken diagrams with
invisible arrows, truncated text, and overlapping elements. The root
cause is that Claude Code's skill system is advisory: when direct MCP
tools or built-in Bash instructions are available, Claude skips skill
consultation entirely.

🔧 Skill description rewrite:
- Lead with "MANDATORY prerequisite" to assert priority over raw MCP tools
- Add user-intent trigger keywords (draw, visualize, sketch, diagram)
- Name specific consequences of skipping the skill
- List common diagram types for semantic matching

🏗️ Setup/update auto-directives:
- Write marked CLAUDE.md sections during skill install and update
- Write Cursor .mdc rules with alwaysApply for Cursor users
- Use HTML comment markers for idempotent append-or-replace on updates
- Non-fatal directive writing — skill installs even if directive fails

🎯 Two-layer defense ensures reliable skill triggering: the description
catches semantic matching, while the CLAUDE.md directive provides an
authoritative instruction that Claude cannot deprioritize in favor of
raw tool access.
2026-03-13 23:46:59 +05:30
sanjibdevnathlabs-release-bot[bot] 6db9227b59 chore(release): v1.2.1 2026-03-13 17:26:09 +00:00
sanjibdevnathlabs 6791a4171f 🐛 fix(cli): resolve Claude Code re-registration failure and add @latest auto-update
The `claude mcp add` command fails with "already exists" when the server
is already registered. Additionally, configs written by setup/update used
a bare package name without @latest, causing npx to serve stale cached
versions indefinitely.

🔧 CLI registration:
- Remove existing entry before re-adding for Claude Code (both setup and update flows)
- Silently ignore removal errors when entry doesn't exist yet

 Auto-update via @latest:
- Write @latest suffix in JSON configs, CLI commands, and manual instructions
- Smart detection in update flow: skip if config already has @latest,
  offer migration if pinned, offer creation if missing

🎯 Users who run setup or update now get configs that always fetch the
newest version on MCP client restart, eliminating the stale-cache problem
without requiring manual npm cache clearing.
2026-03-13 22:53:39 +05:30
sanjibdevnathlabs-release-bot[bot] 956e33398c chore(release): v1.2.0 2026-03-13 17:09:58 +00:00
Sanjib DevnathandGitHub 86abe92fa2 feat(cli): add interactive update command for skill and config updates (#10)
Existing users who update the npm package often forget to update the agent
skill files, leaving their AI agent working with stale workflow guidance
(sizing rules, color palettes, anti-patterns). The new `update` subcommand
solves this by detecting and updating all skill installations automatically.

🔧 Interactive update wizard:
- Scan all agent directories (Cursor, Claude Code, Codex CLI) across global
  and local scopes for existing excalidraw-skill installations
- Batch-update found installations with a single Y/n confirmation
- Offer to install the skill for detected agents that don't have it yet
- Optionally re-apply MCP config (defaults to no for non-breaking updates)

📝 Documentation:
- Rewrite README "Updating" section to lead with the interactive command
- Add collapsible example session showing the update flow
- Retain manual update methods as fallback for each installation path

🎯 Ensures skill files stay in sync with MCP tools across releases,
preventing the subtle drift where new tool capabilities exist but the
agent's workflow guidance doesn't reference them.
2026-03-13 22:37:58 +05:30
sanjibdevnathlabs-release-bot[bot] c16e1f4cc3 chore(release): v1.1.5 2026-03-13 16:05:31 +00:00
sanjibdevnathlabs 8144c7cd6e 📝 docs(skill): add geometric thinking principles and technique catalogs
Excalidraw skill lacked guidance on building complex visual shapes from
basic primitives, leading to flat single-shape attempts for structures
like roofs and walls. Adds coordinate geometry foundations so agents
produce realistic illustrative diagrams alongside technical ones.

🔧 Core geometric principles:
- Compose complex shapes from many small primitives with tessellation
- Eliminate gaps using interleaving offset rows at half-width spacing
- Parametric positioning formulas for centering, circular, triangular, and isometric layouts
- Scale primitive dimensions proportionally to container size

📦 Technique catalog reference:
- Illustrative elements (roofs, walls, clouds, trees, fences, windows)
- Sugiyama-inspired flow diagram layout algorithm
- Zone-grid architecture diagram layout
- Isometric 2.5D projection formulas
- Repeating pattern formulas with brick-pattern offsets

🧹 Consolidate tenants/projects/search into single compact table to
keep SKILL.md under the 500-line skill guideline
2026-03-13 21:33:21 +05:30
31 changed files with 6503 additions and 227 deletions
+3
View File
@@ -15,6 +15,9 @@ public/dist/
.cursor/
.claude/
# User preferences (only the example ships)
skills/excalidraw-skill/preferences.json
# Development artifacts
*.excalidraw
+9
View File
@@ -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
+114
View File
@@ -41,6 +41,7 @@ Click the workspace badge to switch between isolated canvases — each workspace
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Verify Installation](#verify-installation)
- [Updating](#updating)
- [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp)
- [What Changed From Upstream](#what-changed-from-upstream)
- [Architecture](#architecture)
@@ -280,6 +281,119 @@ open http://localhost:3000
If the health check fails, see [Troubleshooting](#troubleshooting).
## Updating
Already installed a previous version? The interactive update wizard is the easiest way to update everything — MCP server **and** agent skills — in one go.
### Interactive Update (recommended)
```bash
npx @sanjibdevnath/mcp-excalidraw-local@latest update
```
<details>
<summary>Example session</summary>
```
$ npx @sanjibdevnath/mcp-excalidraw-local@latest update
Excalidraw MCP — Update v1.2.0
[1/2] Skill Update
Found 2 existing skill installation(s):
[1] Cursor (global) — ~/.cursor/skills/excalidraw-skill
[2] Claude Code (global) — ~/.claude/skills/excalidraw-skill
Update all 2 installation(s) to v1.2.0? [Y/n]: Y
✔ Updated Cursor (global) — ~/.cursor/skills/excalidraw-skill
✔ Updated Claude Code (global) — ~/.claude/skills/excalidraw-skill
2/2 skill(s) updated.
[2/2] MCP Configuration
Re-apply MCP server config? (overwrites existing entry) [y/N]: N
MCP config unchanged.
Update complete! Restart your MCP client to pick up changes.
```
</details>
The update wizard:
1. **Finds all existing skill installations** across Cursor, Claude Code, and Codex CLI (both global and local scopes)
2. **Updates them in-place** with the latest skill files (SKILL.md, cheatsheet, geometric-thinking reference, helper scripts)
3. **Offers to install** the skill for any detected agent that doesn't have it yet
4. **Optionally re-applies MCP config** if needed
> **Why this matters:** The agent skill contains workflow guidance, sizing rules, color palettes, and anti-patterns that evolve alongside the MCP tools. Updating the MCP server without updating the skill means your AI agent is working with stale instructions.
### Manual Update by Installation Method
If you prefer to update manually, follow the steps for your installation method, then restart your MCP client.
#### npx users
If your MCP config uses `npx -y @sanjibdevnath/mcp-excalidraw-local`, npx caches the package locally and won't automatically fetch new versions.
**Option A — Clear the cache (one-time):**
```bash
npm cache clean --force
```
Then restart your MCP client. npx will download the latest version on next launch.
**Option B — Pin to `@latest` in your MCP config (permanent fix):**
Update the `args` in your MCP config to include `@latest`:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local@latest"],
"env": { "CANVAS_PORT": "3000" }
}
}
}
```
This ensures npx always checks for the newest published version.
#### From-source users
```bash
cd mcp-excalidraw-local
git pull origin main
npm install
npm run build
```
#### Docker users
```bash
docker pull sanjibdevnath/mcp-excalidraw-local:latest
docker pull sanjibdevnath/mcp-excalidraw-local-canvas:latest
```
Then recreate your containers (`docker compose up -d` or `docker run` again).
#### Updating the agent skill manually
If you skipped the interactive update, copy the skill files yourself:
```bash
cp -R skills/excalidraw-skill ~/.cursor/skills/excalidraw-skill
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
```
### Verify the update
```bash
# Check the running version
curl -s http://localhost:3000/health
# Or check the installed package version
npx @sanjibdevnath/mcp-excalidraw-local --version
```
## How We Differ from the Official Excalidraw MCP
Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mcp) — it's great for quick, prompt-to-diagram generation rendered inline in chat. We solve a different problem.
+238 -10
View File
@@ -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
@@ -179,10 +185,11 @@ function App(): JSX.Element {
try {
const response = await fetch('/api/elements', { headers: tenantHeaders() })
const result: ApiResponse = await response.json()
if (result.success && result.elements) {
if (result.elements.length === 0) {
excalidrawAPI?.updateScene({ elements: [] })
lastSyncedElementsRef.current = new Map()
return
}
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
@@ -194,6 +201,30 @@ function App(): JSX.Element {
const convertedElements = convertElementsPreservingImageProps(cleanedElements)
excalidrawAPI?.updateScene({ elements: convertedElements })
}
// Populate sync baseline so deletions are detected on next sync
const baselineMap = new Map<string, ServerElement>()
for (const el of result.elements) {
baselineMap.set(el.id, el)
}
lastSyncedElementsRef.current = baselineMap
}
// Fetch current sync version so delta sync works correctly
try {
const versionRes = await fetch('/api/sync/version', { headers: tenantHeaders() })
const versionData = await versionRes.json()
if (versionData.success && typeof versionData.syncVersion === 'number') {
lastSyncVersionRef.current = versionData.syncVersion
lastReceivedSyncVersionRef.current = versionData.syncVersion
localStorage.setItem('excalidraw-last-sync-version', String(versionData.syncVersion))
}
} catch {}
// Set hash baseline so auto-sync doesn't immediately re-sync unchanged content
if (excalidrawAPI) {
const sceneElements = excalidrawAPI.getSceneElements()
lastSyncedHashRef.current = computeElementHash(sceneElements)
}
// Also load files (image data)
@@ -254,9 +285,93 @@ 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))
// Update sync baseline so deletion detection works after resync
if (api) {
const activeElements = api.getSceneElements().filter(el => !el.isDeleted)
const baselineMap = new Map<string, any>()
for (const el of normalizeForBackend(activeElements)) {
baselineMap.set(el.id, el)
}
lastSyncedElementsRef.current = baselineMap
lastSyncedHashRef.current = computeElementHash(api.getSceneElements())
}
console.log(`Delta resync complete: received ${data.serverChanges.length} changes, now at v${data.currentSyncVersion}`)
}
} catch (err) {
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
}
@@ -273,6 +388,12 @@ function App(): JSX.Element {
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
// Update sync baseline for deletion detection
const initBaseline = new Map<string, any>()
for (const el of data.elements) {
initBaseline.set(el.id, el)
}
lastSyncedElementsRef.current = initBaseline
}
break
@@ -295,6 +416,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 +433,7 @@ function App(): JSX.Element {
elements: updatedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
sendAck(data.msgId, 'applied', 1, 1)
}
break
@@ -319,6 +444,7 @@ function App(): JSX.Element {
elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER
})
sendAck(data.msgId, 'applied', 1, 1)
}
break
@@ -341,6 +467,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 +490,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 +620,13 @@ function App(): JSX.Element {
if (data.scrollToContent) {
const allElements = api.getSceneElements()
if (allElements.length > 0) {
api.scrollToContent(allElements, { fitToViewport: true, animate: true })
api.scrollToContent(allElements, { fitToViewport: true, animate: false })
}
} else if (data.scrollToElementId) {
const allElements = api.getSceneElements()
const targetElement = allElements.find(el => el.id === data.scrollToElementId)
if (targetElement) {
api.scrollToContent([targetElement], { fitToViewport: false, animate: true })
api.scrollToContent([targetElement], { fitToViewport: false, animate: false })
} else {
throw new Error(`Element ${data.scrollToElementId} not found`)
}
@@ -559,8 +718,8 @@ function App(): JSX.Element {
console.log('Tenant switched:', data.tenant)
if (data.tenant) {
const incoming = data.tenant as TenantInfo
// Only reload if the switch came from an external source (MCP tool)
// and we aren't already on that tenant (UI-driven switch handles its own reload)
// Send hello to register WS connection under the correct tenant scope
sendHello(incoming.id)
if (incoming.id !== activeTenantIdRef.current) {
activeTenantIdRef.current = incoming.id
setActiveTenant(incoming)
@@ -576,6 +735,25 @@ function App(): JSX.Element {
}
break
case 'hello_ack':
console.log('Hello acknowledged by server:', data.tenantId, data.projectId)
if (data.elements && Array.isArray(data.elements) && data.elements.length > 0) {
const converted = convertToExcalidrawElements(data.elements)
api.updateScene({
elements: converted,
captureUpdate: CaptureUpdateAction.NEVER
})
// Update sync baseline for deletion detection
const helloBaseline = new Map<string, any>()
for (const el of data.elements) {
helloBaseline.set(el.id, el)
}
lastSyncedElementsRef.current = helloBaseline
} else if (data.elements && data.elements.length === 0) {
lastSyncedElementsRef.current = new Map()
}
break
default:
console.log('Unknown WebSocket message type:', data.type)
}
@@ -732,21 +910,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)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.4",
"version": "1.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.4",
"version": "1.6.2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.4",
"version": "1.6.2",
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
"main": "dist/index.js",
"type": "module",
@@ -19,6 +19,7 @@
"production": "npm run build && npm run canvas",
"prepublishOnly": "npm run build",
"setup": "node dist/index.js setup",
"update": "node dist/index.js update",
"type-check": "npx tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
+194 -46
View File
@@ -1,6 +1,6 @@
---
name: excalidraw-skill
description: Programmatic canvas toolkit for creating, editing, and refining Excalidraw diagrams via MCP tools (32 tools) or REST API with real-time canvas sync, multi-tenant workspace isolation, SQLite persistence, project management, full-text search, and element version history. Use when an agent needs to draw or lay out diagrams on a live canvas, iteratively refine diagrams using screenshots, manage workspaces/tenants and projects, export/import .excalidraw files or PNG/SVG images, search elements, view change history, save/restore canvas snapshots, or perform element-level CRUD. Canvas server port is configurable via CANVAS_PORT env var (default 3000).
description: MANDATORY prerequisite for ALL Excalidraw MCP tool usage. Read this skill BEFORE calling any Excalidraw tool (batch_create_elements, create_element, create_from_mermaid, update_element, etc.) — without this skill's sizing formulas, two-batch ordering (shapes first, arrows second), and write-check-review verification cycle, diagrams will have invisible arrows, truncated text, and overlapping elements. Use whenever the user asks to draw, create, visualize, sketch, or diagram anything — flowcharts, architecture diagrams, system designs, org charts, sequence flows, decision trees, network topologies, ER diagrams, mind maps, or any visual on Excalidraw canvas. Also covers diagram refinement, PNG/SVG export, project/workspace management, and all canvas interactions.
---
# Excalidraw Skill
@@ -15,6 +15,79 @@ Run these checks **in order**:
See `references/cheatsheet.md` for the full MCP-vs-REST mapping and REST API gotchas.
## Step 1: Load User Preferences
Before creating any elements, load the user's diagram preferences. These control default font, roughness, stroke width, etc.
### Preference Resolution Order (most specific wins)
| Priority | Scope | Location | Persists |
|----------|-------|----------|----------|
| 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: 5, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
### How to Load
1. **Check folder-level first**: Read `.claude/excalidraw-preferences.json` from the current working directory (or project root). If it exists and has `defaults`, use those values.
2. **Fall back to global**: Read `~/.claude/skills/excalidraw-skill/preferences.json`. If it exists and has `defaults`, use those values.
3. **If neither exists** → run the **First-Time Setup** prompt below.
4. **Merge**: Folder preferences override global; global overrides hardcoded. Only override fields that are explicitly set.
### First-Time Setup (Interactive)
If no preferences file exists at either location, **prompt the user before drawing anything**:
> **Excalidraw Preferences Setup**
>
> I don't have any saved diagram preferences yet. Let me set up your defaults so every diagram looks the way you want.
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
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 = 8
- Nunito = 6
- Lilita One = 7
2. **Roughness** — Diagram style?
- Clean/professional (roughness: 0) — recommended
- Hand-drawn sketch (roughness: 1)
- Very rough (roughness: 2)
3. **Scope** — Where to save?
- **This session only** — don't save to disk, just use for this conversation
- **This project** — save to `.claude/excalidraw-preferences.json` in project root
- **Global (all projects)** — save to `~/.claude/skills/excalidraw-skill/preferences.json`
Then save the preferences JSON to the chosen location:
```json
{
"defaults": {
"fontFamily": <user_choice>,
"fontSize": 20,
"roughness": <user_choice>,
"strokeWidth": 2
}
}
```
For session-only scope, just hold the values in memory and apply them to every element in this conversation.
### Applying Preferences
Once loaded, apply `defaults` to **every element** that supports the property:
- `fontFamily` → all text-containing elements (text, rectangles with labels, diamonds, ellipses, arrows with labels)
- `fontSize` → text elements and labels (unless the element explicitly overrides it)
- `roughness` → all elements
- `strokeWidth` → arrows and lines
User-specified values in individual element calls always override preferences.
## Core Principles (Read Before Any Diagram)
These principles were learned through extensive iterative use. Violating them produces bad diagrams.
@@ -288,6 +361,118 @@ Layout:
Title: fontSize=24 above each zone
```
## Geometric Thinking (Critical — For All Diagram Types)
Excalidraw only offers basic primitives: rectangles, diamonds, ellipses, lines, arrows, text. Building anything beyond simple box-and-arrow diagrams requires **geometric composition** — combining many small primitives using coordinate math to form complex shapes, textures, and layouts.
### Principle 1: Compose Complex Shapes from Many Small Primitives
**Never use one large primitive where many small ones create a better result.**
A single large diamond looks like a flat rhombus. But 45 small diamonds arranged in a triangular grid with tessellating offset rows looks like a tiled roof. The technique:
1. **Identify the target shape** (triangle, circle, arc, wave, etc.)
2. **Choose a small primitive** that can tile/fill that shape (diamond for tiles, rectangle for bricks, ellipse for clouds)
3. **Compute a grid of positions** that fills the target shape's boundary
4. **Apply row-by-row reduction** for tapered shapes (triangles, cones)
5. **Add interleaving offset rows** to eliminate gaps (tessellation)
```
Example — Triangular tiled roof (building width=380, center_x=1090):
Tile size: 52w × 36h
Row spacing: 28px vertical (= tile height)
Interleave offset: 26px horizontal (= tile width / 2)
Interleave rows at: midpoint y between main rows
Main rows (reduce by 2 tiles per row):
Row 1: 9 tiles at y=130 | ◇◇◇◇◇◇◇◇◇
Row 2: 7 tiles at y=102 | ◇◇◇◇◇◇◇
Row 3: 5 tiles at y=74 | ◇◇◇◇◇
Row 4: 3 tiles at y=46 | ◇◇◇
Row 5: 1 tile at y=18 | ◇
Interleaving rows (offset by half tile width, fill gaps):
Inter 1: 8 tiles at y=116 | ◇◇◇◇◇◇◇◇
Inter 2: 6 tiles at y=88 | ◇◇◇◇◇◇
Inter 3: 4 tiles at y=60 | ◇◇◇◇
Inter 4: 2 tiles at y=32 | ◇◇
Total: 45 tiles → seamless triangular roof
```
### Principle 2: Tessellation — Eliminate Gaps with Offset Rows
When same-row primitives leave triangular/pointed gaps, add **interleaving rows** offset by half the primitive width:
```
Gap pattern (diamonds side-by-side): Filled with interleaving row:
/\ /\ /\ /\ /\ /\ /\ /\
/ \/ \/ \/ \ / \/ \/ \/ \
\ /\ /\ /\ / \ /\/\/\/\/\/\ /
\/ \/ \/ \/ ◇◇◇◇◇◇◇◇◇ ← offset row
/\ /\ /\ /\
```
**Formula for interleaving:**
```
main_row_y[n] = base_y - n * row_spacing
inter_row_y[n] = (main_row_y[n] + main_row_y[n+1]) / 2
inter_row_x_offset = tile_width / 2
inter_row_count = main_row_count[n] - 1
```
### Principle 3: Parametric Positioning — Use Formulas, Not Guessing
Compute element positions mathematically. Common formulas:
**Centering N items in a container:**
```
item_x[i] = container_x + (container_width - N * item_width) / (N + 1) * (i + 1) + i * item_width
```
**Circular arrangement (N items around center):**
```
angle[i] = (2π / N) * i + rotation_offset
x[i] = center_x + radius * cos(angle[i]) - item_width / 2
y[i] = center_y + radius * sin(angle[i]) - item_height / 2
```
**Triangular reduction (pyramid/roof):**
```
count[row] = base_count - 2 * row
x_start[row] = center_x - (count[row] * tile_width) / 2
y[row] = base_y - row * row_spacing
```
**Isometric projection (2.5D diagrams):**
```
screen_x = (grid_x - grid_y) * tile_width / 2
screen_y = (grid_x + grid_y) * tile_height / 2
```
### Principle 4: Scale Primitives to Context
When applying a technique to different-sized containers, **scale the primitive size proportionally**:
```
Hut (body width 290px) → tiles 40w × 28h, 9 per base row
Building (body width 380px) → tiles 52w × 36h, 9 per base row
```
Maintain the same count-per-row for visual consistency; adjust individual tile dimensions.
### Technique Catalogs
For detailed recipes and formulas for specific diagram types, see [geometric-thinking.md](references/geometric-thinking.md):
- **Illustrative diagrams**: 11 visual element recipes (roofs, walls, clouds, trees, fences, water, smoke, windows, stairs)
- **Flow diagrams**: Sugiyama-inspired 4-step hierarchical layout (layer assignment → ordering → coordinates → edge routing)
- **Architecture diagrams**: Zone-grid layout with service grid-packing and dependency layering
- **Isometric/2.5D diagrams**: Screen projection formulas for depth illusion
- **Repeating patterns**: Generic repeat and brick-pattern offset formulas
- **Anti-patterns**: 6 common geometric composition mistakes and fixes
## Workflow: Iterative Refinement
```
@@ -303,55 +488,18 @@ create shapes (batch 1)
For multi-diagram canvases, offset each new diagram by 300px+ from the previous one's bounding box.
## Workflow: Multi-Tenancy (Workspaces)
## Workflow: Tenants, Projects & Search
The MCP is multi-tenant. Each Cursor workspace automatically gets its own tenant (identified by a SHA-256 hash of the workspace path). All elements, projects, and snapshots are scoped to the active tenant.
### Automatic Tenant Detection
On MCP startup, the server:
1. Creates a tenant from `process.cwd()` (initial guess)
2. After connecting, calls `server.listRoots()` to get the real workspace path from Cursor
3. If different, re-creates/switches to the correct tenant and notifies the canvas
This means globally-configured MCPs (`~/.cursor/mcp.json`) correctly detect the per-window workspace — no manual setup needed.
### Tenant Operations
Multi-tenant: each Cursor workspace auto-gets its own tenant (SHA-256 hash of workspace path). Globally-configured MCPs detect per-window workspace automatically.
| Task | Tool | Notes |
|------|------|-------|
| See all workspaces | `list_tenants` | Returns id, name, workspace_path, created_at |
| Switch workspace | `switch_tenant` with `tenantId` | Canvas reloads that tenant's elements via WebSocket |
| Check current tenant | (from describe_scene or frontend header) | Shows "Workspace: [name]" in canvas |
### Multiple Cursor Instances
Each instance sends its own `X-Tenant-Id` header on every HTTP/MCP request. SQLite uses `busy_timeout` for concurrent write safety. No state conflicts between windows.
## Workflow: Projects (Within a Tenant)
Projects group diagrams within a tenant. Each tenant has a "Default Project" created automatically. Use projects to organize different diagram sets (e.g., "Architecture", "User Flows", "Sprint Planning").
| Task | Tool | Notes |
|------|------|-------|
| List projects | `list_projects` | Shows all projects in active tenant |
| Switch project | `switch_project` with `projectId` | Elements change to that project's set |
| Create new project | `switch_project` with `createName` | Creates and switches in one call |
## Workflow: Search & History
### Full-Text Search
`search_elements` with `query` — searches across element labels and text content in the active project. Useful for finding specific elements in large diagrams.
### Element Version History
`element_history` — view create/update/delete operations for:
- A specific element: pass `elementId`
- Entire active project: omit `elementId`
- Control result count with `limit` (default 50)
Use history to debug unexpected changes or audit what was modified.
| List workspaces | `list_tenants` | Returns id, name, workspace_path |
| Switch workspace | `switch_tenant` with `tenantId` | Canvas reloads tenant's elements |
| List projects | `list_projects` | Projects group diagrams within a tenant |
| Switch/create project | `switch_project` | Pass `projectId` or `createName` |
| Full-text search | `search_elements` with `query` | Searches labels and text content |
| Version history | `element_history` | Pass `elementId` or omit for project-wide |
## Workflow: Refine An Existing Diagram
@@ -0,0 +1,10 @@
{
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
"_fontReference": "See src/font-families.json for canonical font ID → name mapping.",
"defaults": {
"fontFamily": 5,
"fontSize": 20,
"roughness": 0,
"strokeWidth": 2
}
}
@@ -94,16 +94,15 @@
|---------|----------------|-----------------|
| Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` |
| Arrow binding | `"startElementId": "id"` / `"endElementId": "id"` | `"start": {"id": "id"}` / `"end": {"id": "id"}` |
| `fontFamily` | String `"1"` or omit | String `"1"` or omit (never a number) |
| `fontFamily` | Number or string — use value from user preferences (see Step 1 in SKILL.md) | String — use value from user preferences |
| Tenant scoping | Auto (uses active tenant) | Include `X-Tenant-Id` header on every request |
### Element Creation Best Practices
- **Always set `roughness: 0`** for clean, professional diagrams (default is hand-drawn).
- **Always set `strokeWidth: 2`** on arrows for visibility.
- **Always apply user preferences** — load from Step 1 in SKILL.md and apply `fontFamily`, `roughness`, `fontSize`, `strokeWidth` to every element.
- **Create shapes first, arrows second** (two separate `batch_create_elements` calls).
- **Assign custom `id`** to every shape so arrows can reference it.
- **Size shapes for their text** — Virgil font is ~30% wider than standard. Use sizing formulas from SKILL.md.
- **Size shapes for their text** — use sizing formulas from SKILL.md.
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects — normalized automatically.
- **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points. **Elbowed arrows**: Use `"elbowed": true`.
@@ -0,0 +1,131 @@
# Geometric Thinking — Detailed Technique Catalogs
## Technique Catalog: Illustrative Diagrams
| Visual Element | Primitive Composition | Key Parameters |
|---------------|----------------------|----------------|
| **Tiled roof** | Grid of diamonds, rows reduce by 2, interleave offset rows | tile_size, row_count, center_x |
| **Brick wall** | Grid of rectangles, alternating rows offset by half-width | brick_w, brick_h, mortar_gap |
| **Cloud** | 5-7 overlapping ellipses of varying sizes | center, radii, overlap% |
| **Tree** | Rectangle trunk + 3-4 overlapping ellipses (canopy) | trunk_w, canopy_r |
| **Fence** | Repeated thin rectangles with pointed-top triangles | post_spacing, post_h |
| **Road/path** | Two parallel lines + dashed center line | width, dash_pattern |
| **Water/waves** | Repeating sine-curve approximated by overlapping ellipses | amplitude, wavelength |
| **Sun rays** | Central ellipse + rotated lines at equal angles | ray_count, ray_length |
| **Smoke/steam** | 3+ ellipses of increasing size, ascending and drifting | size_step, drift_x |
| **Window (classic)** | Rectangle + 2 thin rectangle cross-bars (H and V) | win_w, win_h, bar_thickness=3 |
| **Stairs** | Stacked rectangles, each offset right and down | step_w, step_h, count |
## Technique Catalog: Flow Diagrams (Sugiyama-Inspired Layout)
For flowcharts and directed graphs, use the Sugiyama hierarchical layout algorithm:
**Step 1 — Layer Assignment:**
Assign each node to a horizontal layer. Entry nodes at top (layer 0), each subsequent step increases layer number. Nodes in the same layer share the same Y coordinate.
```
layer_y[n] = start_y + n * (node_height + vertical_gap)
vertical_gap: 120px minimum (for visible arrows)
```
**Step 2 — Ordering Within Layers:**
Position nodes within each layer to minimize edge crossings. Place each node at the average X of its connected neighbors in the layer above.
```
node_x = average(connected_parent_x_positions)
If two nodes overlap: spread by node_width + horizontal_gap
```
**Step 3 — Coordinate Assignment:**
Center nodes around the diagram midpoint. Distribute evenly within each layer.
```
layer_width = count * node_width + (count - 1) * horizontal_gap
first_x = center_x - layer_width / 2
node_x[i] = first_x + i * (node_width + horizontal_gap)
```
**Step 4 — Edge Routing:**
Create arrows after all shapes. Use `startElementId`/`endElementId` for binding.
- Same-layer connections: horizontal arrows
- Cross-layer connections: vertical arrows
- Multi-layer spans: consider adding routing waypoints
**Decision branches:**
```
YES path → horizontal right to answer box (same layer)
NO path → vertical down to next decision (next layer)
```
## Technique Catalog: Architecture Diagrams (Zone-Grid Layout)
**Zone-based layout** for microservices, infrastructure, and system architecture:
**Step 1 — Define zones as large translucent rectangles:**
```
zone_width = max(services_count * (service_w + gap) + padding * 2, 400)
zone_height = rows * (service_h + gap) + padding * 2 + title_height
zone_bg = "#e9ecef", opacity = 30
```
**Step 2 — Grid-pack services within zones:**
```
col = service_index % cols_per_row
row = service_index / cols_per_row (integer division)
service_x = zone_x + padding + col * (service_w + gap)
service_y = zone_y + title_height + padding + row * (service_h + gap)
```
**Step 3 — Connect zones with arrows:**
- Solid arrows for synchronous calls
- Dashed arrows (`strokeStyle: "dashed"`) for async/event-driven
- Label arrows with protocol/method (REST, gRPC, Kafka, etc.)
**Step 4 — Layer zones top-to-bottom by dependency depth:**
```
Client layer: y = 0
API Gateway: y = zone_height + 80
Services: y = 2 * (zone_height + 80)
Data stores: y = 3 * (zone_height + 80)
```
## Technique Catalog: Isometric / 2.5D Diagrams
For infrastructure and deployment diagrams with depth:
```
Isometric grid formulas:
screen_x = origin_x + (col - row) * cell_width / 2
screen_y = origin_y + (col + row) * cell_height / 2
For a server rack at grid position (2, 3):
screen_x = 500 + (2 - 3) * 60 / 2 = 470
screen_y = 100 + (2 + 3) * 30 / 2 = 175
```
Use diamonds for floor tiles, parallelogram-approximated rectangles for side faces. Stack elements vertically (subtract from y) to show height.
## Technique Catalog: Repeating Patterns
For any repeating visual pattern (fences, grids, timelines, Gantt bars):
```
Generic repeat formula:
element_x[i] = start_x + i * (element_width + gap)
element_y[i] = start_y (same for horizontal repeat)
With alternating offset (brick pattern):
offset = (row % 2) * (element_width / 2 + gap / 2)
element_x[i] = start_x + offset + i * (element_width + gap)
```
## Anti-Patterns for Geometric Composition
| Mistake | Why It Fails | Do This Instead |
|---------|-------------|-----------------|
| Single large primitive for complex shape | Looks flat, unrealistic | Compose from many small primitives |
| Same-row diamonds without interleaving | Visible triangular gaps | Add offset rows at midpoint Y |
| Guessing coordinates | Misaligned elements, uneven spacing | Use parametric formulas |
| Same tile size for all containers | Looks wrong at different scales | Scale tile size to container width |
| Too few primitives | Sparse, gappy appearance | Use enough tiles to achieve ≥80% coverage |
| Forgetting z-order | Background elements cover foreground | Create back-to-front: background first, details last |
+67 -10
View File
@@ -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);
+13
View File
@@ -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
}
+248 -62
View File
@@ -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';
@@ -65,6 +67,47 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
const ENABLE_CANVAS_SYNC = true;
// User preferences for element defaults (font, roughness, etc.)
// Resolution: folder-level .claude/excalidraw-preferences.json > global ~/.claude/skills/excalidraw-skill/preferences.json > hardcoded
interface ExcalidrawPreferences {
fontFamily: number;
fontSize: number;
roughness: number;
strokeWidth: number;
}
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: 20,
roughness: 0,
strokeWidth: 2,
};
function loadPreferences(): ExcalidrawPreferences {
const locations = [
path.join(process.cwd(), '.claude', 'excalidraw-preferences.json'),
path.join(process.env.HOME || '~', '.claude', 'skills', 'excalidraw-skill', 'preferences.json'),
];
for (const loc of locations) {
try {
if (fs.existsSync(loc)) {
const raw = JSON.parse(fs.readFileSync(loc, 'utf-8'));
if (raw?.defaults) {
logger.info(`Loaded user preferences from ${loc}`);
return { ...HARDCODED_DEFAULTS, ...raw.defaults };
}
}
} catch (e) {
logger.warn(`Failed to read preferences from ${loc}: ${e}`);
}
}
return HARDCODED_DEFAULTS;
}
const USER_PREFS = loadPreferences();
// One-time tokens for clear_canvas confirmation (token → expiry timestamp)
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
@@ -79,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> {
@@ -156,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
@@ -418,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' },
@@ -649,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' },
@@ -991,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 } } : {}),
@@ -1009,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}`
}]
};
}
@@ -1048,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'})`}`
}]
};
}
@@ -1488,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 } } : {}),
@@ -1506,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}`
}]
};
}
@@ -1751,10 +1841,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
} catch {}
}
if (params.mode === 'replace') {
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
}
// Batch create the imported elements
const elementsToCreate = importElements.map(el => ({
...el,
@@ -1764,7 +1850,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
version: 1
}));
const canvasElements = await batchCreateElementsOnCanvas(elementsToCreate);
if (params.mode === 'replace') {
// Backup current elements before clearing to prevent data loss
const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() });
const backupData = await backupResp.json() as ApiResponse;
const backupElements = backupData.elements || [];
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
try {
await batchCreateElementsOnCanvas(elementsToCreate);
} catch (createError) {
// Restore backup atomically to prevent data loss
logger.error('Import failed after clear, restoring backup:', (createError as Error).message);
if (backupElements.length > 0) {
await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, {
method: 'POST',
headers: canvasHeaders(),
body: JSON.stringify({ elements: backupElements })
});
}
throw new Error(`Import failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`);
}
} else {
await batchCreateElementsOnCanvas(elementsToCreate);
}
return {
content: [{
@@ -1836,24 +1946,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
logger.info('Duplicating elements via MCP', { count: params.elementIds.length });
const duplicates: ServerElement[] = [];
// Build ID map first so binding references can be remapped
const idMap = new Map<string, string>();
const originals: ServerElement[] = [];
for (const id of params.elementIds) {
const original = await getElementFromCanvas(id);
if (!original) {
logger.warn(`Element ${id} not found, skipping duplicate`);
continue;
}
const newId = generateId();
idMap.set(id, newId);
originals.push(original);
}
const duplicates: ServerElement[] = [];
for (const original of originals) {
const { createdAt, updatedAt, version, syncedAt, source, syncTimestamp, ...rest } = original;
const duplicate: ServerElement = {
...rest,
id: generateId(),
id: idMap.get(original.id) || generateId(),
x: original.x + offsetX,
y: original.y + offsetY,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
};
// Remap binding references to point to duplicated elements
const dup = duplicate as any;
if (dup.startElementId && idMap.has(dup.startElementId)) {
dup.startElementId = idMap.get(dup.startElementId);
}
if (dup.endElementId && idMap.has(dup.endElementId)) {
dup.endElementId = idMap.get(dup.endElementId);
}
if (dup.start?.id && idMap.has(dup.start.id)) {
dup.start = { ...dup.start, id: idMap.get(dup.start.id) };
}
if (dup.end?.id && idMap.has(dup.end.id)) {
dup.end = { ...dup.end, id: idMap.get(dup.end.id) };
}
if (Array.isArray(dup.boundElements)) {
dup.boundElements = dup.boundElements.map((be: any) => ({
...be,
id: idMap.has(be.id) ? idMap.get(be.id) : be.id
}));
}
if (dup.containerId && idMap.has(dup.containerId)) {
dup.containerId = idMap.get(dup.containerId);
}
duplicates.push(duplicate);
}
@@ -1908,10 +2051,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } };
// Backup current elements before clearing to prevent data loss
const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() });
const backupData = await backupResp.json() as ApiResponse;
const backupElements = backupData.elements || [];
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
// Restore elements
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
// Restore elements from snapshot
try {
await batchCreateElementsOnCanvas(data.snapshot.elements);
} catch (createError) {
// Restore backup atomically to prevent data loss
logger.error('Snapshot restore failed after clear, restoring backup:', (createError as Error).message);
if (backupElements.length > 0) {
await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, {
method: 'POST',
headers: canvasHeaders(),
body: JSON.stringify({ elements: backupElements })
});
}
throw new Error(`Snapshot restore failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`);
}
return {
content: [{
@@ -2101,7 +2262,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
headers: canvasHeaders(),
body: JSON.stringify({
format: 'png',
background: params.background ?? true
background: params.background ?? true,
captureViewport: true
})
});
@@ -2199,8 +2361,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (el.type === 'text') {
base.text = text ?? '';
base.originalText = text ?? '';
base.fontSize = rest.fontSize ?? 20;
base.fontFamily = rest.fontFamily ?? 1;
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
base.textAlign = rest.textAlign ?? 'center';
base.verticalAlign = rest.verticalAlign ?? 'middle';
base.autoResize = rest.autoResize ?? true;
@@ -2294,8 +2456,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
locked: false,
text: labelText,
originalText: labelText,
fontSize: isArrow ? 14 : (rest.fontSize ?? 16),
fontFamily: rest.fontFamily ?? 1,
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
textAlign: 'center',
verticalAlign: 'middle',
autoResize: true,
@@ -2652,12 +2814,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) {
@@ -2727,12 +2908,17 @@ if (isMainModule()) {
process.stderr.write(`Setup failed: ${(error as Error).message}\n`);
process.exit(1);
});
} else if (arg === 'update') {
import('./setup.js').then(m => m.runUpdate()).catch(error => {
process.stderr.write(`Update failed: ${(error as Error).message}\n`);
process.exit(1);
});
} else if (arg === '--help' || arg === '-h' || arg === '--version' || arg === '-v') {
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
if (arg === '--help' || arg === '-h') {
process.stdout.write(`${pkg.name} v${pkg.version}\n\nUsage:\n mcp-excalidraw-local Start MCP server (stdio transport)\n mcp-excalidraw-local setup Interactive setup wizard\n mcp-excalidraw-local --help Show this help\n mcp-excalidraw-local --version Show version\n`);
process.stdout.write(`${pkg.name} v${pkg.version}\n\nUsage:\n mcp-excalidraw-local Start MCP server (stdio transport)\n mcp-excalidraw-local setup Interactive setup wizard\n mcp-excalidraw-local update Update agent skills and MCP config\n mcp-excalidraw-local --help Show this help\n mcp-excalidraw-local --version Show version\n`);
} else {
process.stdout.write(`${pkg.version}\n`);
}
+387 -66
View File
@@ -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());
});
}
+421 -6
View File
@@ -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);
@@ -55,7 +56,13 @@ interface AgentDef {
skillBasePaths: { global: string; local: string };
mcpConfigType: 'json-file' | 'cli-command';
mcpConfigPath?: string;
mcpCliRemove?: string;
mcpCliCommand?: string;
instructionConfig?: {
global: string;
local: string;
format: 'claude-md' | 'cursor-mdc';
};
}
function getAgents(): AgentDef[] {
@@ -70,6 +77,11 @@ function getAgents(): AgentDef[] {
},
mcpConfigType: 'json-file',
mcpConfigPath: path.join(home, '.cursor', 'mcp.json'),
instructionConfig: {
global: path.join(home, '.cursor', 'rules', 'excalidraw.mdc'),
local: path.join(process.cwd(), '.cursor', 'rules', 'excalidraw.mdc'),
format: 'cursor-mdc',
},
},
{
name: 'Claude Code',
@@ -79,7 +91,13 @@ function getAgents(): AgentDef[] {
local: path.join(process.cwd(), '.claude', 'skills'),
},
mcpConfigType: 'cli-command',
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local',
mcpCliRemove: 'claude mcp remove excalidraw-canvas --scope user',
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local@latest',
instructionConfig: {
global: path.join(home, '.claude', 'CLAUDE.md'),
local: path.join(process.cwd(), 'CLAUDE.md'),
format: 'claude-md',
},
},
{
name: 'Codex CLI',
@@ -101,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
@@ -165,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) {
@@ -224,6 +331,17 @@ async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
fs.mkdirSync(destDir, { recursive: true });
copyDirSync(skillSource, destDir);
ok(`Installed to ${destDir}`);
// Write instruction directive to ensure skill auto-triggers
if (agent.instructionConfig) {
const instrPath = agent.instructionConfig[scope];
try {
writeInstructionDirective(instrPath, agent.instructionConfig.format);
ok(`Skill directive added to ${instrPath}`);
} catch (instrErr) {
warn(`Could not write directive to ${instrPath}: ${(instrErr as Error).message}`);
}
}
} catch (err) {
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
}
@@ -243,10 +361,83 @@ function copyDirSync(src: string, dest: string): void {
}
}
// ── Instruction Directives ──────────────────────────────────
const DIRECTIVE_MARKER_START = '<!-- excalidraw-mcp:start -->';
const DIRECTIVE_MARKER_END = '<!-- excalidraw-mcp:end -->';
const EXCALIDRAW_DIRECTIVE_BODY =
'When Excalidraw MCP tools are available (batch_create_elements, create_element, ' +
'create_from_mermaid, etc.), ALWAYS invoke the `/excalidraw-skill` skill FIRST ' +
'before calling any Excalidraw tool. The skill contains essential sizing formulas, ' +
'batch ordering rules (shapes first, arrows second), and a mandatory ' +
'write-check-review verification cycle. Using Excalidraw tools without consulting ' +
'this skill produces broken diagrams — invisible arrows, truncated text, ' +
'overlapping elements.';
function buildClaudeMdSection(): string {
return [
DIRECTIVE_MARKER_START,
'## Excalidraw Canvas — Skill Directive',
'',
EXCALIDRAW_DIRECTIVE_BODY,
DIRECTIVE_MARKER_END,
].join('\n');
}
function buildCursorMdc(): string {
return [
'---',
'description: Always consult excalidraw-skill before using Excalidraw MCP tools',
'globs:',
'alwaysApply: true',
'---',
'',
'## Excalidraw Canvas — Skill Directive',
'',
EXCALIDRAW_DIRECTIVE_BODY,
'',
].join('\n');
}
function writeInstructionDirective(filePath: string, format: 'claude-md' | 'cursor-mdc'): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (format === 'cursor-mdc') {
// Cursor .mdc files are standalone — write/overwrite the whole file
fs.writeFileSync(filePath, buildCursorMdc(), 'utf-8');
return;
}
// For claude-md: append or replace the marked section
let content = '';
if (fs.existsSync(filePath)) {
content = fs.readFileSync(filePath, 'utf-8');
}
const section = buildClaudeMdSection();
const startIdx = content.indexOf(DIRECTIVE_MARKER_START);
const endIdx = content.indexOf(DIRECTIVE_MARKER_END);
if (startIdx !== -1 && endIdx !== -1) {
// Replace existing section
content = content.slice(0, startIdx) + section + content.slice(endIdx + DIRECTIVE_MARKER_END.length);
} else {
// Append with spacing
const trimmed = content.trimEnd();
content = trimmed + (trimmed ? '\n\n' : '') + section + '\n';
}
fs.writeFileSync(filePath, content, 'utf-8');
}
// ── 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) {
@@ -285,6 +476,9 @@ async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
}
try {
if (agent.mcpCliRemove) {
try { execSync(agent.mcpCliRemove, { stdio: 'pipe' }); } catch { /* ignore if not found */ }
}
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
ok(`Registered 'excalidraw-canvas' via ${agent.name} CLI`);
} catch (err) {
@@ -299,7 +493,7 @@ async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
function mergeJsonConfig(configPath: string): void {
const mcpEntry = {
command: 'npx',
args: ['-y', '@sanjibdevnath/mcp-excalidraw-local'],
args: ['-y', '@sanjibdevnath/mcp-excalidraw-local@latest'],
env: { CANVAS_PORT: '3000' },
};
@@ -331,6 +525,23 @@ function mergeJsonConfig(configPath: string): void {
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
}
function checkJsonConfigStatus(configPath: string): 'up-to-date' | 'needs-update' | 'not-found' {
if (!fs.existsSync(configPath)) return 'not-found';
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw);
const entry = config?.mcpServers?.['excalidraw-canvas'];
if (!entry) return 'not-found';
const args: string[] = entry.args ?? [];
const hasLatest = args.some((a: string) => a.includes('@latest'));
return hasLatest ? 'up-to-date' : 'needs-update';
} catch {
return 'not-found';
}
}
function printManualConfig(): void {
process.stdout.write(`
Manual config (JSON):
@@ -338,7 +549,7 @@ function printManualConfig(): void {
"mcpServers": {
"excalidraw-canvas": {
"command": "npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local@latest"],
"env": { "CANVAS_PORT": "3000" }
}
}
@@ -346,6 +557,209 @@ function printManualConfig(): void {
`);
}
// ── Update ───────────────────────────────────────────────────
interface SkillInstallation {
agent: AgentDef;
scope: 'global' | 'local';
path: string;
exists: boolean;
}
function getPackageVersion(): string {
try {
const pkgPath = path.resolve(__dirname, '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
return pkg.version ?? 'unknown';
} catch {
return 'unknown';
}
}
function findExistingSkillInstalls(): SkillInstallation[] {
const agents = getAgents();
const installs: SkillInstallation[] = [];
for (const agent of agents) {
for (const scope of ['global', 'local'] as const) {
const skillDir = path.join(agent.skillBasePaths[scope], 'excalidraw-skill');
installs.push({
agent,
scope,
path: skillDir,
exists: fs.existsSync(path.join(skillDir, 'SKILL.md')),
});
}
}
return installs;
}
export async function runUpdate(): Promise<void> {
if (!process.stdin.isTTY) {
process.stderr.write('Error: Update requires an interactive terminal.\n');
process.exit(1);
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const version = getPackageVersion();
process.stdout.write(`\n ${BOLD}Excalidraw MCP — Update${RESET} ${DIM}v${version}${RESET}\n`);
try {
// ── Phase 1: Detect existing skill installations ──────────
heading('1/3', 'Skill Update');
const allInstalls = findExistingSkillInstalls();
const existing = allInstalls.filter(i => i.exists);
const missing = allInstalls.filter(i => !i.exists);
const detectedAgents = detectInstalledAgents();
const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill');
if (!fs.existsSync(skillSource)) {
fail(`Skill source not found at ${skillSource}`);
fail('This can happen with corrupted installs. Try: npx @sanjibdevnath/mcp-excalidraw-local@latest setup');
rl.close();
return;
}
if (existing.length > 0) {
process.stdout.write(`\n Found ${CYAN}${existing.length}${RESET} existing skill installation(s):\n`);
existing.forEach((inst, i) => {
const label = `${inst.agent.name} (${inst.scope})`;
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${label}${DIM}${inst.path}${RESET}\n`);
});
const doUpdate = await confirm(rl, `\n Update all ${existing.length} installation(s) to v${version}?`);
if (doUpdate) {
let updated = 0;
for (const inst of existing) {
try {
copyDirSync(skillSource, inst.path);
ok(`Updated ${inst.agent.name} (${inst.scope}) — ${inst.path}`);
updated++;
// Update instruction directive
if (inst.agent.instructionConfig) {
const instrPath = inst.agent.instructionConfig[inst.scope];
try {
writeInstructionDirective(instrPath, inst.agent.instructionConfig.format);
ok(`Skill directive updated in ${instrPath}`);
} catch (instrErr) {
warn(`Could not update directive in ${instrPath}: ${(instrErr as Error).message}`);
}
}
} catch (err) {
fail(`Failed to update ${inst.path}: ${(err as Error).message}`);
}
}
process.stdout.write(`\n ${GREEN}${updated}/${existing.length}${RESET} skill(s) updated.\n`);
} else {
info(`${DIM}Skipped skill update.${RESET}`);
}
} else {
info('No existing skill installations found.');
}
// Offer to install for detected agents that don't have the skill
const agentsWithoutSkill = detectedAgents.filter(agent =>
!existing.some(inst => inst.agent.name === agent.name),
);
if (agentsWithoutSkill.length > 0) {
process.stdout.write(`\n Agents without the skill:\n`);
agentsWithoutSkill.forEach((a, i) => {
process.stdout.write(` ${YELLOW}[${i + 1}]${RESET} ${a.name}\n`);
});
const doInstall = await confirm(rl, 'Install the skill for these agents?');
if (doInstall) {
for (const agent of agentsWithoutSkill) {
const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `);
const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global';
const destDir = path.join(agent.skillBasePaths[scope], 'excalidraw-skill');
try {
fs.mkdirSync(destDir, { recursive: true });
copyDirSync(skillSource, destDir);
ok(`Installed to ${destDir}`);
// Write instruction directive
if (agent.instructionConfig) {
const instrPath = agent.instructionConfig[scope];
try {
writeInstructionDirective(instrPath, agent.instructionConfig.format);
ok(`Skill directive added to ${instrPath}`);
} catch (instrErr) {
warn(`Could not write directive to ${instrPath}: ${(instrErr as Error).message}`);
}
}
} catch (err) {
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
}
}
}
}
// ── 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) {
const status = checkJsonConfigStatus(agent.mcpConfigPath);
if (status === 'up-to-date') {
ok(`${agent.name} — already uses @latest, auto-updates on restart.`);
} else if (status === 'needs-update') {
const doIt = await confirm(rl, `${agent.name} — config uses a pinned version. Migrate to @latest?`);
if (doIt) {
try {
mergeJsonConfig(agent.mcpConfigPath);
ok(`Migrated to @latest in ${agent.mcpConfigPath}`);
} catch (err) {
fail(`Failed: ${(err as Error).message}`);
}
}
} else {
const doIt = await confirm(rl, `${agent.name} — no MCP config found. Add it?`);
if (doIt) {
try {
mergeJsonConfig(agent.mcpConfigPath);
ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`);
} catch (err) {
fail(`Failed: ${(err as Error).message}`);
}
}
}
} else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) {
info(`${agent.name} — config managed via CLI (cannot auto-detect version).`);
const doIt = await confirm(rl, `${agent.name} — re-register with @latest?`, false);
if (doIt) {
try {
if (agent.mcpCliRemove) {
try { execSync(agent.mcpCliRemove, { stdio: 'pipe' }); } catch { /* ignore if not found */ }
}
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
ok(`Re-registered 'excalidraw-canvas' via ${agent.name} CLI`);
} catch (err) {
fail(`CLI registration failed: ${(err as Error).message}`);
}
} else {
info(`${DIM}Skipped.${RESET}`);
}
}
}
process.stdout.write(`\n ${GREEN}${BOLD}Update complete!${RESET} Restart your MCP client to pick up changes.\n\n`);
} finally {
rl.close();
}
}
// ── Main ─────────────────────────────────────────────────────
export async function runSetup(): Promise<void> {
@@ -364,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
View File
@@ -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
View File
@@ -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');
});
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, setActiveTenant } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let app: any;
function makeRect(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `rect-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 0,
y: 0,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
function makeEllipse(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `ell-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'ellipse',
x: 0,
y: 0,
width: 120,
height: 120,
version: 1,
...overrides,
};
}
function makeDiamond(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `dia-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'diamond',
x: 0,
y: 0,
width: 100,
height: 100,
version: 1,
...overrides,
};
}
function makeArrow(id: string, startId?: string, endId?: string): any {
return {
id,
type: 'arrow',
x: 0,
y: 0,
width: 100,
height: 0,
...(startId ? { start: { id: startId } } : {}),
...(endId ? { end: { id: endId } } : {}),
};
}
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-arrow-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Arrow Binding Resolution via Batch Create ──────────────
describe('Arrow binding resolution - rectangles', () => {
it('resolves arrow between two rectangles', async () => {
const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'r2', x: 300, y: 0, width: 100, height: 50 });
const arrow = makeArrow('a1', 'r1', 'r2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
expect(res.body.success).toBe(true);
const createdArrow = res.body.elements.find((e: any) => e.id === 'a1');
expect(createdArrow).toBeDefined();
// Arrow should have computed start/end points
expect(typeof createdArrow.x).toBe('number');
expect(typeof createdArrow.y).toBe('number');
expect(typeof createdArrow.width).toBe('number');
expect(typeof createdArrow.height).toBe('number');
});
it('arrow points are positioned between the two rectangles', async () => {
const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'r2', x: 400, y: 0, width: 100, height: 50 });
const arrow = makeArrow('a1', 'r1', 'r2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
const a = res.body.elements.find((e: any) => e.id === 'a1');
// Arrow should have reasonable coordinates between the two shapes
// The exact positions depend on edge-point computation; just verify it's between the two shape centers
expect(a.x).toBeGreaterThanOrEqual(0);
expect(a.x + a.width).toBeLessThanOrEqual(600);
});
});
describe('Arrow binding resolution - ellipses', () => {
it('resolves arrow between two ellipses', async () => {
const e1 = makeEllipse({ id: 'e1', x: 0, y: 0, width: 80, height: 80 });
const e2 = makeEllipse({ id: 'e2', x: 300, y: 0, width: 80, height: 80 });
const arrow = makeArrow('ae1', 'e1', 'e2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [e1, e2, arrow] });
expect(res.body.success).toBe(true);
const a = res.body.elements.find((e: any) => e.id === 'ae1');
expect(a).toBeDefined();
});
});
describe('Arrow binding resolution - diamonds', () => {
it('resolves arrow between two diamonds', async () => {
const d1 = makeDiamond({ id: 'd1', x: 0, y: 0, width: 100, height: 100 });
const d2 = makeDiamond({ id: 'd2', x: 300, y: 0, width: 100, height: 100 });
const arrow = makeArrow('ad1', 'd1', 'd2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [d1, d2, arrow] });
expect(res.body.success).toBe(true);
const a = res.body.elements.find((e: any) => e.id === 'ad1');
expect(a).toBeDefined();
});
});
describe('Arrow binding resolution - mixed shapes', () => {
it('resolves arrow from rectangle to ellipse', async () => {
const r = makeRect({ id: 'mr', x: 0, y: 0, width: 100, height: 50 });
const e = makeEllipse({ id: 'me', x: 300, y: 0, width: 80, height: 80 });
const arrow = makeArrow('ma1', 'mr', 'me');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r, e, arrow] });
expect(res.body.success).toBe(true);
});
it('resolves arrow from diamond to rectangle', async () => {
const d = makeDiamond({ id: 'md', x: 0, y: 0, width: 100, height: 100 });
const r = makeRect({ id: 'mr2', x: 300, y: 0, width: 150, height: 80 });
const arrow = makeArrow('ma2', 'md', 'mr2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [d, r, arrow] });
expect(res.body.success).toBe(true);
});
});
describe('Arrow binding resolution - edge cases', () => {
it('arrow with only start binding', async () => {
const r = makeRect({ id: 'so', x: 0, y: 0, width: 100, height: 50 });
const arrow = makeArrow('sa1', 'so', undefined);
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r, arrow] });
expect(res.body.success).toBe(true);
});
it('arrow with only end binding', async () => {
const r = makeRect({ id: 'eo', x: 300, y: 0, width: 100, height: 50 });
const arrow = makeArrow('ea1', undefined, 'eo');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r, arrow] });
expect(res.body.success).toBe(true);
});
it('arrow referencing non-existent element does not crash', async () => {
const arrow = makeArrow('ghost-arrow', 'nonexistent-1', 'nonexistent-2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [arrow] });
expect(res.body.success).toBe(true);
});
it('arrow between overlapping shapes (same center)', async () => {
const r1 = makeRect({ id: 'ov1', x: 100, y: 100, width: 100, height: 50 });
const r2 = makeRect({ id: 'ov2', x: 100, y: 100, width: 100, height: 50 });
const arrow = makeArrow('ova', 'ov1', 'ov2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
// Should not crash even with identical centers (dx=0, dy=0)
expect(res.body.success).toBe(true);
});
it('arrow between vertically aligned shapes', async () => {
const r1 = makeRect({ id: 'vr1', x: 100, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'vr2', x: 100, y: 300, width: 100, height: 50 });
const arrow = makeArrow('va', 'vr1', 'vr2');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, arrow] });
expect(res.body.success).toBe(true);
const a = res.body.elements.find((e: any) => e.id === 'va');
// Arrow should connect shapes that are vertically aligned — just verify it exists and has valid dimensions
expect(typeof a.width).toBe('number');
expect(typeof a.height).toBe('number');
});
it('cross-batch arrow referencing pre-existing element', async () => {
// Create a shape first
setElement('pre-existing', makeRect({ id: 'pre-existing', x: 0, y: 0, width: 100, height: 50 }));
// Batch create an arrow referencing the pre-existing shape
const r2 = makeRect({ id: 'batch-r', x: 300, y: 0, width: 100, height: 50 });
const arrow = makeArrow('cross-arrow', 'pre-existing', 'batch-r');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r2, arrow] });
expect(res.body.success).toBe(true);
});
it('multiple arrows between same two shapes', async () => {
const r1 = makeRect({ id: 'multi-r1', x: 0, y: 0, width: 100, height: 50 });
const r2 = makeRect({ id: 'multi-r2', x: 300, y: 0, width: 100, height: 50 });
const a1 = makeArrow('multi-a1', 'multi-r1', 'multi-r2');
const a2 = makeArrow('multi-a2', 'multi-r2', 'multi-r1');
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: [r1, r2, a1, a2] });
expect(res.body.success).toBe(true);
expect(res.body.elements).toHaveLength(4);
});
});
+351
View File
@@ -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();
});
});
+195
View File
@@ -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);
});
});
+94
View File
@@ -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);
});
});
+522
View File
@@ -0,0 +1,522 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, getAllElements, setActiveTenant, getCurrentSyncVersion } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let app: any;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Clear Canvas Token Flow (via REST) ─────────────────────
// Simulates the clear_canvas MCP tool's token-based confirmation
describe('Clear canvas confirmation flow', () => {
it('DELETE /api/elements/clear removes all elements', async () => {
setElement('cl-1', makeElement({ id: 'cl-1' }));
setElement('cl-2', makeElement({ id: 'cl-2' }));
expect(getAllElements()).toHaveLength(2);
const res = await request(app).delete('/api/elements/clear');
expect(res.body.success).toBe(true);
expect(res.body.count).toBeDefined();
expect(getAllElements()).toHaveLength(0);
});
it('clear on empty canvas returns zero count', async () => {
const res = await request(app).delete('/api/elements/clear');
expect(res.body.success).toBe(true);
});
it('cleared elements stay gone on subsequent GET requests', async () => {
setElement('stay-gone', makeElement({ id: 'stay-gone' }));
await request(app).delete('/api/elements/clear');
for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(0);
}
});
});
// ─── Import Scene (Replace Mode) ────────────────────────────
// Tests the REST layer that import_scene MCP tool uses
describe('Import scene - replace mode via sync', () => {
it('POST /api/elements/sync replaces all elements atomically', async () => {
setElement('old-1', makeElement({ id: 'old-1' }));
setElement('old-2', makeElement({ id: 'old-2' }));
const newElements = [
makeElement({ id: 'new-1', x: 0 }),
makeElement({ id: 'new-2', x: 100 }),
makeElement({ id: 'new-3', x: 200 }),
];
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: newElements });
expect(res.body.success).toBe(true);
const elements = getAllElements();
expect(elements).toHaveLength(3);
const ids = elements.map(e => e.id).sort();
expect(ids).toEqual(['new-1', 'new-2', 'new-3']);
});
it('POST /api/elements/sync with empty array clears all', async () => {
setElement('will-be-replaced', makeElement({ id: 'will-be-replaced' }));
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: [] });
expect(res.body.success).toBe(true);
expect(getAllElements()).toHaveLength(0);
});
it('old elements do not reappear after replace', async () => {
setElement('ghost', makeElement({ id: 'ghost' }));
await request(app)
.post('/api/elements/sync')
.send({ elements: [makeElement({ id: 'replacement' })] });
// Multiple GET requests should consistently show only the replacement
for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(1);
expect(res.body.elements[0].id).toBe('replacement');
}
});
});
// ─── Import Scene (Merge Mode) ──────────────────────────────
describe('Import scene - merge mode via batch', () => {
it('POST /api/elements/batch adds without removing existing', async () => {
setElement('existing', makeElement({ id: 'existing', x: 0 }));
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
makeElement({ id: 'imported-1', x: 100 }),
makeElement({ id: 'imported-2', x: 200 }),
],
});
expect(res.body.success).toBe(true);
const elements = getAllElements();
expect(elements).toHaveLength(3);
const ids = elements.map(e => e.id).sort();
expect(ids).toEqual(['existing', 'imported-1', 'imported-2']);
});
});
// ─── Restore Snapshot ───────────────────────────────────────
describe('Snapshot create and restore flow', () => {
it('save snapshot, clear, verify snapshot still exists', async () => {
setElement('snap-1', makeElement({ id: 'snap-1' }));
setElement('snap-2', makeElement({ id: 'snap-2' }));
// Save snapshot
const snapRes = await request(app)
.post('/api/snapshots')
.send({ name: 'before-clear' });
expect(snapRes.body.success).toBe(true);
// Clear
await request(app).delete('/api/elements/clear');
expect(getAllElements()).toHaveLength(0);
// Snapshot should still contain the elements
const getRes = await request(app).get('/api/snapshots/before-clear');
expect(getRes.body.success).toBe(true);
expect(getRes.body.snapshot.elements).toHaveLength(2);
});
it('restore via sync endpoint preserves all snapshot elements', async () => {
const elements = [
makeElement({ id: 'rs-1', x: 0 }),
makeElement({ id: 'rs-2', x: 100 }),
];
for (const el of elements) setElement(el.id, el);
// Save snapshot
await request(app).post('/api/snapshots').send({ name: 'restore-test' });
// Clear and add different elements
await request(app).delete('/api/elements/clear');
setElement('different', makeElement({ id: 'different' }));
// Get snapshot
const snapRes = await request(app).get('/api/snapshots/restore-test');
const snapshotElements = snapRes.body.snapshot.elements;
// Restore via sync (atomic replace)
const syncRes = await request(app)
.post('/api/elements/sync')
.send({ elements: snapshotElements });
expect(syncRes.body.success).toBe(true);
// Verify restored state
const final = getAllElements();
expect(final).toHaveLength(2);
const ids = final.map(e => e.id).sort();
expect(ids).toEqual(['rs-1', 'rs-2']);
});
it('restore non-existent snapshot returns 404', async () => {
const res = await request(app).get('/api/snapshots/nonexistent');
expect(res.status).toBe(404);
});
it('snapshot overwrites with same name', async () => {
setElement('v1-el', makeElement({ id: 'v1-el' }));
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
setElement('v2-el', makeElement({ id: 'v2-el' }));
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
const res = await request(app).get('/api/snapshots/overwrite-test');
expect(res.body.snapshot.elements).toHaveLength(2); // Both elements
});
});
// ─── Duplicate Elements ─────────────────────────────────────
describe('Duplicate elements via API', () => {
it('duplicating elements creates new IDs', async () => {
setElement('dup-src', makeElement({ id: 'dup-src', x: 0, y: 0 }));
// Get the original
const getRes = await request(app).get('/api/elements/dup-src');
expect(getRes.body.success).toBe(true);
// Create a duplicate via batch (simulating what duplicate_elements does)
const original = getRes.body.element;
const duplicate = {
...original,
id: 'dup-copy',
x: original.x + 20,
y: original.y + 20,
};
const batchRes = await request(app)
.post('/api/elements/batch')
.send({ elements: [duplicate] });
expect(batchRes.body.success).toBe(true);
expect(getAllElements()).toHaveLength(2);
});
it('duplicated arrow with remapped bindings points to duplicated shapes', async () => {
// Create shape + arrow
const rect = makeElement({ id: 'dup-rect', x: 0, y: 0, width: 100, height: 50 });
const rect2 = makeElement({ id: 'dup-rect2', x: 300, y: 0, width: 100, height: 50 });
setElement('dup-rect', rect);
setElement('dup-rect2', rect2);
// Create arrow binding references
const arrow = {
id: 'dup-arrow',
type: 'arrow',
x: 100, y: 25,
width: 200, height: 0,
start: { id: 'dup-rect' },
end: { id: 'dup-rect2' },
};
// Simulate duplication with ID remapping
const idMap = new Map([
['dup-rect', 'copy-rect'],
['dup-rect2', 'copy-rect2'],
['dup-arrow', 'copy-arrow'],
]);
const dupArrow: any = {
...arrow,
id: 'copy-arrow',
x: arrow.x + 20,
y: arrow.y + 20,
start: { id: idMap.get(arrow.start.id) || arrow.start.id },
end: { id: idMap.get(arrow.end.id) || arrow.end.id },
};
expect(dupArrow.start.id).toBe('copy-rect');
expect(dupArrow.end.id).toBe('copy-rect2');
// Create the duplicated shapes and arrow
const batchRes = await request(app)
.post('/api/elements/batch')
.send({
elements: [
makeElement({ id: 'copy-rect', x: 20, y: 20, width: 100, height: 50 }),
makeElement({ id: 'copy-rect2', x: 320, y: 20, width: 100, height: 50 }),
dupArrow,
],
});
expect(batchRes.body.success).toBe(true);
const createdArrow = batchRes.body.elements.find((e: any) => e.id === 'copy-arrow');
expect(createdArrow).toBeDefined();
});
});
// ─── Mermaid Conversion Relay ───────────────────────────────
describe('Mermaid conversion relay', () => {
it('POST /api/elements/from-mermaid accepts valid diagram', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({
mermaidDiagram: 'graph TD\n A-->B',
config: {},
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.mermaidDiagram).toBe('graph TD\n A-->B');
expect(res.body.message).toContain('frontend');
});
it('rejects empty mermaid diagram', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({ mermaidDiagram: '' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects missing mermaid diagram', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({});
expect(res.status).toBe(400);
});
it('accepts diagram with config options', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({
mermaidDiagram: 'sequenceDiagram\n A->>B: Hello',
config: { theme: 'dark' },
});
expect(res.body.success).toBe(true);
expect(res.body.config).toEqual({ theme: 'dark' });
});
});
// ─── Image Export Relay ─────────────────────────────────────
describe('Image export relay', () => {
it('POST /api/export/image without connected browser returns 503', async () => {
const res = await request(app)
.post('/api/export/image')
.send({ format: 'png', background: true });
expect(res.status).toBe(503);
expect(res.body.success).toBe(false);
});
it('POST /api/export/image accepts captureViewport parameter', async () => {
const res = await request(app)
.post('/api/export/image')
.send({ format: 'png', background: true, captureViewport: true });
// Will be 503 since no browser, but should not 400 on the parameter
expect(res.status).toBe(503);
});
});
// ─── Viewport Relay ─────────────────────────────────────────
describe('Viewport relay', () => {
it('POST /api/viewport without connected browser returns 503', async () => {
const res = await request(app)
.post('/api/viewport')
.send({ action: 'scrollToContent' });
expect(res.status).toBe(503);
});
it('accepts various viewport actions', async () => {
for (const action of ['scrollToContent', 'zoomToFit']) {
const res = await request(app)
.post('/api/viewport')
.send({ action });
// 503 expected (no browser), but validates the action is accepted
expect(res.status).toBe(503);
}
});
});
// ─── Files API ──────────────────────────────────────────────
describe('Files API comprehensive', () => {
it('GET /api/files returns empty initially', async () => {
const res = await request(app).get('/api/files');
expect(res.body.success).toBe(true);
expect(Object.keys(res.body.files)).toHaveLength(0);
});
it('POST /api/files adds files and GET returns them', async () => {
await request(app)
.post('/api/files')
.send({
files: {
'f1': { id: 'f1', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
'f2': { id: 'f2', mimeType: 'image/jpeg', dataURL: 'data:image/jpeg;base64,xyz', created: Date.now() },
},
});
const res = await request(app).get('/api/files');
expect(Object.keys(res.body.files)).toHaveLength(2);
expect(res.body.files['f1'].mimeType).toBe('image/png');
expect(res.body.files['f2'].mimeType).toBe('image/jpeg');
});
it('DELETE /api/files/:id removes the file', async () => {
await request(app)
.post('/api/files')
.send({
files: {
'del-f': { id: 'del-f', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
},
});
const delRes = await request(app).delete('/api/files/del-f');
expect(delRes.body.success).toBe(true);
const listRes = await request(app).get('/api/files');
expect(listRes.body.files['del-f']).toBeUndefined();
});
it('DELETE /api/files/:id for non-existent file returns 404', async () => {
const res = await request(app).delete('/api/files/nonexistent');
expect(res.status).toBe(404);
});
it('POST /api/files rejects non-object body', async () => {
const res = await request(app)
.post('/api/files')
.send({ files: 'not-an-object' });
expect(res.status).toBe(400);
});
});
// ─── Sync Status ────────────────────────────────────────────
describe('Sync status endpoint', () => {
it('GET /api/sync/status returns element count', async () => {
setElement('ss-1', makeElement({ id: 'ss-1' }));
setElement('ss-2', makeElement({ id: 'ss-2' }));
const res = await request(app).get('/api/sync/status');
expect(res.body.success).toBe(true);
expect(res.body.elementCount).toBe(2);
});
});
// ─── Element Version History ────────────────────────────────
describe('Element version history via API', () => {
it('element has version after creation and update', async () => {
const createRes = await request(app)
.post('/api/elements')
.send({ id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
expect(createRes.body.success).toBe(true);
const updateRes = await request(app)
.put('/api/elements/hist-el')
.send({ x: 500 });
expect(updateRes.body.success).toBe(true);
const getRes = await request(app).get('/api/elements/hist-el');
expect(getRes.body.element.x).toBe(500);
});
});
// ─── Error Handling ─────────────────────────────────────────
describe('API error handling', () => {
it('POST /api/elements with invalid JSON returns 400', async () => {
const res = await request(app)
.post('/api/elements')
.set('Content-Type', 'application/json')
.send('not-json');
// Express body-parser returns 400 or 500 on parse failure depending on version
expect([400, 500]).toContain(res.status);
});
it('PUT /api/elements/:id on non-existent element returns 404', async () => {
const res = await request(app)
.put('/api/elements/nonexistent')
.send({ x: 100 });
expect(res.status).toBe(404);
});
it('DELETE /api/elements/:id on non-existent returns 404', async () => {
const res = await request(app)
.delete('/api/elements/nonexistent');
expect(res.status).toBe(404);
});
it('POST /api/elements/batch rejects non-array elements', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: 'not-an-array' });
expect(res.status).toBe(400);
});
it('POST /api/snapshots rejects missing name', async () => {
const res = await request(app)
.post('/api/snapshots')
.send({});
expect(res.status).toBe(400);
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let app: any;
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-security-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Input Validation ───────────────────────────────────────
describe('Input validation - element creation', () => {
it('rejects element with missing type', async () => {
const res = await request(app)
.post('/api/elements')
.send({ x: 0, y: 0, width: 100, height: 50 });
expect(res.status).toBe(400);
});
it('rejects element with invalid type', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'malicious<script>', x: 0, y: 0, width: 100, height: 50 });
expect(res.status).toBe(400);
});
it('rejects element with negative dimensions gracefully', async () => {
// Server should handle negative dimensions without crashing
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: -100, height: -50 });
// May succeed (Excalidraw allows negative) or fail validation — either is acceptable
expect([200, 400]).toContain(res.status);
});
it('handles very large coordinates without crashing', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 1e15, y: 1e15, width: 100, height: 50 });
// Should not crash the server
expect([200, 400]).toContain(res.status);
});
});
describe('Input validation - batch operations', () => {
it('rejects batch with non-array elements', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: { not: 'an array' } });
expect(res.status).toBe(400);
});
it('rejects batch with null elements', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: null });
expect(res.status).toBe(400);
});
it('handles extremely large batch without crash', async () => {
const elements = Array.from({ length: 100 }, (_, i) => ({
id: `bulk-${i}`,
type: 'rectangle',
x: i * 10,
y: 0,
width: 8,
height: 8,
}));
const res = await request(app)
.post('/api/elements/batch')
.send({ elements });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('Input validation - sync endpoints', () => {
it('POST /api/elements/sync rejects non-array elements', async () => {
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: 'not-array' });
expect(res.status).toBe(400);
});
it('POST /api/elements/sync/v2 rejects non-number lastSyncVersion', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 'not-a-number', changes: [] });
expect(res.status).toBe(400);
});
it('POST /api/elements/sync/v2 handles missing changes gracefully', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: 0 });
// Should use default empty array
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('Input validation - settings', () => {
it('PUT /api/settings/:key rejects missing value', async () => {
const res = await request(app)
.put('/api/settings/test')
.send({});
expect(res.status).toBe(400);
});
it('GET /api/settings/:key returns null for missing key', async () => {
const res = await request(app).get('/api/settings/nonexistent');
expect(res.body.value).toBeNull();
});
});
describe('Input validation - tenant operations', () => {
it('PUT /api/tenant/active rejects missing tenantId', async () => {
const res = await request(app)
.put('/api/tenant/active')
.send({});
expect(res.status).toBe(400);
});
it('PUT /api/tenant/active rejects non-existent tenant', async () => {
const res = await request(app)
.put('/api/tenant/active')
.send({ tenantId: 'nonexistent-tenant-xyz' });
expect(res.status).toBe(400);
});
});
describe('Input validation - search', () => {
it('GET /api/elements/search with no params returns all elements', async () => {
const res = await request(app).get('/api/elements/search');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('GET /api/elements/search handles special characters in query', async () => {
const res = await request(app).get('/api/elements/search?q=%22OR%201%3D1');
// FTS5 may reject special chars with 500 — acceptable as long as server doesn't crash
expect([200, 400, 500]).toContain(res.status);
});
});
describe('Input validation - mermaid', () => {
it('rejects non-string mermaid diagram', async () => {
const res = await request(app)
.post('/api/elements/from-mermaid')
.send({ mermaidDiagram: 12345 });
expect(res.status).toBe(400);
});
});
// ─── Header Handling ────────────────────────────────────────
describe('X-Tenant-Id header handling', () => {
it('invalid X-Tenant-Id gracefully falls back', async () => {
const res = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'nonexistent-tenant');
// Should either return empty elements or error — 500 is acceptable for unknown tenant
expect([200, 400, 404, 500]).toContain(res.status);
});
});
// ─── Content-Type Handling ──────────────────────────────────
describe('Content-Type edge cases', () => {
it('POST with no content-type header handles gracefully', async () => {
const res = await request(app)
.post('/api/elements')
.send('');
// Should not crash
expect([200, 400]).toContain(res.status);
});
});
+540
View File
@@ -0,0 +1,540 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, getAllElements, deleteElement, clearElements, getCurrentSyncVersion, getChangesSince, setActiveTenant } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let app: any;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-sync-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
setActiveTenant('default');
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Delta Sync v2: Deletion Flows ──────────────────────────
describe('Delta sync v2 - deletion flows', () => {
it('deletes elements when client sends action:delete', async () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
setElement('c', makeElement({ id: 'c' }));
const v0 = getCurrentSyncVersion();
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [
{ id: 'a', action: 'delete' },
{ id: 'b', action: 'delete' },
],
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.appliedCount).toBe(2);
const remaining = getAllElements();
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe('c');
});
it('deletes all elements when client sends delete for every element', async () => {
setElement('x', makeElement({ id: 'x' }));
setElement('y', makeElement({ id: 'y' }));
setElement('z', makeElement({ id: 'z' }));
const v0 = getCurrentSyncVersion();
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [
{ id: 'x', action: 'delete' },
{ id: 'y', action: 'delete' },
{ id: 'z', action: 'delete' },
],
});
expect(res.body.appliedCount).toBe(3);
expect(getAllElements()).toHaveLength(0);
});
it('delete for non-existent element does not crash', async () => {
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [{ id: 'ghost', action: 'delete' }],
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('deleted elements do not reappear on subsequent GET /api/elements', async () => {
setElement('persist-1', makeElement({ id: 'persist-1' }));
setElement('persist-2', makeElement({ id: 'persist-2' }));
const v0 = getCurrentSyncVersion();
await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [{ id: 'persist-1', action: 'delete' }],
});
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(1);
expect(res.body.elements[0].id).toBe('persist-2');
});
it('deleted elements do not reappear after multiple reload cycles', async () => {
setElement('reload-1', makeElement({ id: 'reload-1' }));
setElement('reload-2', makeElement({ id: 'reload-2' }));
const v0 = getCurrentSyncVersion();
// Simulate: frontend syncs deletions
await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [
{ id: 'reload-1', action: 'delete' },
{ id: 'reload-2', action: 'delete' },
],
});
// Simulate: multiple page reloads fetching elements
for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(0);
expect(res.body.elements).toEqual([]);
}
});
});
// ─── Delta Sync v2: Mixed Operations ────────────────────────
describe('Delta sync v2 - mixed operations', () => {
it('handles mixed upserts and deletes in single sync', async () => {
setElement('a', makeElement({ id: 'a', x: 0 }));
setElement('b', makeElement({ id: 'b', x: 100 }));
const v0 = getCurrentSyncVersion();
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [
{ id: 'a', action: 'delete' },
{ id: 'c', action: 'upsert', element: makeElement({ id: 'c', x: 200 }) },
{ id: 'b', action: 'upsert', element: makeElement({ id: 'b', x: 150 }) },
],
});
expect(res.body.appliedCount).toBe(3);
const remaining = getAllElements();
expect(remaining).toHaveLength(2);
const ids = remaining.map(e => e.id).sort();
expect(ids).toEqual(['b', 'c']);
const b = remaining.find(e => e.id === 'b')!;
expect(b.x).toBe(150);
});
it('upsert after delete re-creates the element', async () => {
setElement('revive', makeElement({ id: 'revive', x: 0 }));
const v0 = getCurrentSyncVersion();
// Delete it
await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [{ id: 'revive', action: 'delete' }],
});
expect(getAllElements()).toHaveLength(0);
// Re-create it
const v1 = getCurrentSyncVersion();
await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v1,
changes: [{ id: 'revive', action: 'upsert', element: makeElement({ id: 'revive', x: 999 }) }],
});
const elements = getAllElements();
expect(elements).toHaveLength(1);
expect(elements[0].id).toBe('revive');
expect(elements[0].x).toBe(999);
});
});
// ─── Delta Sync v2: Bidirectional ───────────────────────────
describe('Delta sync v2 - bidirectional sync', () => {
it('returns server-side changes not sent by client', async () => {
// Server has elements from MCP
setElement('mcp-1', makeElement({ id: 'mcp-1' }));
setElement('mcp-2', makeElement({ id: 'mcp-2' }));
// Client syncs from version 0 with its own new element
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [
{ id: 'fe-1', action: 'upsert', element: makeElement({ id: 'fe-1' }) },
],
});
expect(res.body.success).toBe(true);
// Server should return mcp-1 and mcp-2 as changes the client hasn't seen
const serverChangeIds = res.body.serverChanges.map((c: any) => c.id).sort();
expect(serverChangeIds).toEqual(['mcp-1', 'mcp-2']);
// fe-1 should NOT be in serverChanges (client already knows about it)
expect(serverChangeIds).not.toContain('fe-1');
});
it('server-side deletes appear as delete actions in serverChanges', async () => {
setElement('srv-del', makeElement({ id: 'srv-del' }));
const v0 = getCurrentSyncVersion();
// Server-side delete (simulating MCP delete_element)
deleteElement('srv-del');
const v1 = getCurrentSyncVersion();
// Client syncs from before the delete
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: v0, changes: [] });
const deleteChange = res.body.serverChanges.find((c: any) => c.id === 'srv-del');
expect(deleteChange).toBeDefined();
expect(deleteChange.action).toBe('delete');
});
it('excludes client-sent IDs from serverChanges', async () => {
setElement('shared', makeElement({ id: 'shared', x: 0 }));
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [
{ id: 'shared', action: 'upsert', element: makeElement({ id: 'shared', x: 50 }) },
],
});
// 'shared' should NOT appear in serverChanges since the client sent it
const serverIds = res.body.serverChanges.map((c: any) => c.id);
expect(serverIds).not.toContain('shared');
});
});
// ─── Delta Sync v2: Multiple Rounds ─────────────────────────
describe('Delta sync v2 - multiple rounds', () => {
it('tracks sync version across multiple sync rounds', async () => {
// Round 1: create elements
const r1 = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a' }) },
{ id: 'r1-b', action: 'upsert', element: makeElement({ id: 'r1-b' }) },
],
});
expect(r1.body.currentSyncVersion).toBeGreaterThan(0);
const v1 = r1.body.currentSyncVersion;
// Round 2: update one, delete one, create one
const r2 = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v1,
changes: [
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a', x: 999 }) },
{ id: 'r1-b', action: 'delete' },
{ id: 'r2-c', action: 'upsert', element: makeElement({ id: 'r2-c' }) },
],
});
expect(r2.body.currentSyncVersion).toBeGreaterThan(v1);
expect(r2.body.appliedCount).toBe(3);
// No new server-side changes should be returned
expect(r2.body.serverChanges).toHaveLength(0);
// Verify final state
const elements = getAllElements();
expect(elements).toHaveLength(2);
const ids = elements.map(e => e.id).sort();
expect(ids).toEqual(['r1-a', 'r2-c']);
expect(elements.find(e => e.id === 'r1-a')!.x).toBe(999);
});
it('empty sync returns current version without changes', async () => {
setElement('existing', makeElement({ id: 'existing' }));
const v0 = getCurrentSyncVersion();
const res = await request(app)
.post('/api/elements/sync/v2')
.send({ lastSyncVersion: v0, changes: [] });
expect(res.body.success).toBe(true);
expect(res.body.appliedCount).toBe(0);
expect(res.body.serverChanges).toHaveLength(0);
expect(res.body.currentSyncVersion).toBe(v0);
});
});
// ─── Sync Version Monotonicity ──────────────────────────────
describe('Sync version monotonicity', () => {
it('sync version always increases after mutations', async () => {
const versions: number[] = [];
// Create
setElement('mono-a', makeElement({ id: 'mono-a' }));
versions.push(getCurrentSyncVersion());
// Update via sync
await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [{ id: 'mono-a', action: 'upsert', element: makeElement({ id: 'mono-a', x: 50 }) }],
});
versions.push(getCurrentSyncVersion());
// Delete via sync
await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: versions[versions.length - 1],
changes: [{ id: 'mono-a', action: 'delete' }],
});
versions.push(getCurrentSyncVersion());
// Create via API
await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
versions.push(getCurrentSyncVersion());
// Every version should be strictly greater than the previous
for (let i = 1; i < versions.length; i++) {
expect(versions[i]).toBeGreaterThan(versions[i - 1]!);
}
});
it('getChangesSince correctly filters by version', async () => {
setElement('cs-a', makeElement({ id: 'cs-a' }));
const v1 = getCurrentSyncVersion();
setElement('cs-b', makeElement({ id: 'cs-b' }));
const v2 = getCurrentSyncVersion();
setElement('cs-c', makeElement({ id: 'cs-c' }));
const v3 = getCurrentSyncVersion();
// Changes since v1 should include cs-b and cs-c but not cs-a
const changes = getChangesSince(v1);
const ids = changes.map(c => c.id).sort();
expect(ids).toEqual(['cs-b', 'cs-c']);
// Changes since v2 should only include cs-c
const changes2 = getChangesSince(v2);
expect(changes2).toHaveLength(1);
expect(changes2[0].id).toBe('cs-c');
// Changes since v3 should be empty
expect(getChangesSince(v3)).toHaveLength(0);
});
});
// ─── Concurrent Sync Requests ───────────────────────────────
describe('Concurrent sync requests', () => {
it('parallel sync requests all complete without data loss', async () => {
const promises = Array.from({ length: 5 }, (_, i) =>
request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [
{ id: `par-${i}`, action: 'upsert', element: makeElement({ id: `par-${i}`, x: i * 100 }) },
],
})
);
const results = await Promise.all(promises);
for (const r of results) {
expect(r.body.success).toBe(true);
expect(r.body.appliedCount).toBe(1);
}
const elements = getAllElements();
expect(elements).toHaveLength(5);
});
it('parallel deletes all take effect', async () => {
for (let i = 0; i < 5; i++) {
setElement(`pd-${i}`, makeElement({ id: `pd-${i}` }));
}
const v0 = getCurrentSyncVersion();
const promises = Array.from({ length: 5 }, (_, i) =>
request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [{ id: `pd-${i}`, action: 'delete' }],
})
);
await Promise.all(promises);
expect(getAllElements()).toHaveLength(0);
});
});
// ─── Sync After Clear ───────────────────────────────────────
describe('Sync after clear', () => {
it('elements created after clear persist correctly', async () => {
setElement('pre-clear', makeElement({ id: 'pre-clear' }));
await request(app).delete('/api/elements/clear');
expect(getAllElements()).toHaveLength(0);
const v0 = getCurrentSyncVersion();
const res = await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: v0,
changes: [
{ id: 'post-clear', action: 'upsert', element: makeElement({ id: 'post-clear' }) },
],
});
expect(res.body.appliedCount).toBe(1);
expect(getAllElements()).toHaveLength(1);
expect(getAllElements()[0].id).toBe('post-clear');
});
it('sync from version 0 after clear returns clear as delete changes', async () => {
setElement('was-here', makeElement({ id: 'was-here' }));
clearElements();
// Sync from 0 should see the element as a delete
const changes = getChangesSince(0);
const deleteChange = changes.find(c => c.id === 'was-here');
expect(deleteChange).toBeDefined();
expect(deleteChange!.action).toBe('delete');
});
});
// ─── Overwrite Sync (Legacy) ────────────────────────────────
describe('POST /api/elements/sync (legacy overwrite)', () => {
it('replaces all elements and deleted ones stay gone on GET', async () => {
setElement('old-1', makeElement({ id: 'old-1' }));
setElement('old-2', makeElement({ id: 'old-2' }));
const res = await request(app)
.post('/api/elements/sync')
.send({
elements: [makeElement({ id: 'new-1' })],
});
expect(res.body.success).toBe(true);
const elements = getAllElements();
expect(elements).toHaveLength(1);
expect(elements[0].id).toBe('new-1');
// Old elements should not be returned
const listRes = await request(app).get('/api/elements');
expect(listRes.body.count).toBe(1);
expect(listRes.body.elements[0].id).toBe('new-1');
});
it('overwrite with empty array clears all elements', async () => {
setElement('gone', makeElement({ id: 'gone' }));
await request(app)
.post('/api/elements/sync')
.send({ elements: [] });
expect(getAllElements()).toHaveLength(0);
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(0);
});
});
// ─── GET /api/sync/version consistency ──────────────────────
describe('GET /api/sync/version', () => {
it('matches internal getCurrentSyncVersion', async () => {
setElement('sv-check', makeElement({ id: 'sv-check' }));
const internal = getCurrentSyncVersion();
const res = await request(app).get('/api/sync/version');
expect(res.body.syncVersion).toBe(internal);
});
it('increases after sync/v2 applies changes', async () => {
const r1 = await request(app).get('/api/sync/version');
const v1 = r1.body.syncVersion;
await request(app)
.post('/api/elements/sync/v2')
.send({
lastSyncVersion: 0,
changes: [{ id: 'bump', action: 'upsert', element: makeElement({ id: 'bump' }) }],
});
const r2 = await request(app).get('/api/sync/version');
expect(r2.body.syncVersion).toBeGreaterThan(v1);
});
});
+403
View File
@@ -0,0 +1,403 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { initDb, closeDb, setElement, getAllElements, setActiveTenant, ensureTenant, setActiveProject, getActiveProjectId, getCurrentSyncVersion, getChangesSince, clearElements } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import WebSocket from 'ws';
import request from 'supertest';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let port: number;
let startCanvasServer: () => Promise<void>;
let stopCanvasServer: () => Promise<void>;
let app: any;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
function connectClient(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
}
function waitForMessageOfType(ws: WebSocket, type: string, timeoutMs = 5000): Promise<any> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout waiting for message type: ${type}`)), timeoutMs);
const handler = (data: WebSocket.RawData) => {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timer);
ws.off('message', handler);
resolve(msg);
}
};
ws.on('message', handler);
});
}
function drainInitialMessages(ws: WebSocket): Promise<void> {
return new Promise((resolve) => {
let count = 0;
const handler = () => {
count++;
if (count >= 3) {
ws.off('message', handler);
resolve();
}
};
ws.on('message', handler);
setTimeout(() => {
ws.off('message', handler);
resolve();
}, 2000);
});
}
/** Connect and wait until initial messages are drained. */
async function connectAndDrain(): Promise<WebSocket> {
const ws = await connectClient();
await drainInitialMessages(ws);
return ws;
}
/** Send hello and wait for hello_ack. */
async function sendHelloAndWait(ws: WebSocket, tenantId: string): Promise<any> {
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 8000);
ws.send(JSON.stringify({ type: 'hello', tenantId }));
return ackPromise;
}
function collectMessagesFor(ws: WebSocket, durationMs: number): Promise<any[]> {
return new Promise((resolve) => {
const msgs: any[] = [];
const handler = (data: WebSocket.RawData) => msgs.push(JSON.parse(data.toString()));
ws.on('message', handler);
setTimeout(() => {
ws.off('message', handler);
resolve(msgs);
}, durationMs);
});
}
beforeAll(async () => {
port = 3300 + Math.floor(Math.random() * 100);
process.env.CANVAS_PORT = String(port);
process.env.HOST = 'localhost';
dbPath = path.join(os.tmpdir(), `excalidraw-isolation-test-${Date.now()}.db`);
initDb(dbPath);
const mod = await import('../../src/server.js');
app = mod.default;
startCanvasServer = mod.startCanvasServer;
stopCanvasServer = mod.stopCanvasServer;
await startCanvasServer();
});
afterAll(async () => {
await stopCanvasServer();
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
beforeEach(() => {
setActiveTenant('default');
});
// ─── Element Isolation per Tenant ───────────────────────────
describe('Element isolation per tenant', () => {
it('elements in tenant A are not visible to tenant B', async () => {
ensureTenant('tenant-a', 'Tenant A', '/path/a');
ensureTenant('tenant-b', 'Tenant B', '/path/b');
// Create element in tenant A
setActiveTenant('tenant-a');
const projA = getActiveProjectId();
setElement('el-a', makeElement({ id: 'el-a' }), projA);
// Create element in tenant B
setActiveTenant('tenant-b');
const projB = getActiveProjectId();
setElement('el-b', makeElement({ id: 'el-b' }), projB);
// Verify isolation via API
const resA = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'tenant-a');
expect(resA.body.count).toBe(1);
expect(resA.body.elements[0].id).toBe('el-a');
const resB = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'tenant-b');
expect(resB.body.count).toBe(1);
expect(resB.body.elements[0].id).toBe('el-b');
});
it('deleting elements in tenant A does not affect tenant B', async () => {
ensureTenant('del-a', 'Del A', '/path/del-a');
ensureTenant('del-b', 'Del B', '/path/del-b');
setActiveTenant('del-a');
const projA = getActiveProjectId();
setElement('del-el-a', makeElement({ id: 'del-el-a' }), projA);
setActiveTenant('del-b');
const projB = getActiveProjectId();
setElement('del-el-b', makeElement({ id: 'del-el-b' }), projB);
// Delete from tenant A via API
await request(app)
.delete('/api/elements/del-el-a')
.set('X-Tenant-Id', 'del-a');
// Tenant A should be empty
const resA = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'del-a');
expect(resA.body.count).toBe(0);
// Tenant B should still have its element
const resB = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'del-b');
expect(resB.body.count).toBe(1);
expect(resB.body.elements[0].id).toBe('del-el-b');
});
it('clear in tenant A does not affect tenant B', async () => {
ensureTenant('clr-a', 'Clr A', '/path/clr-a');
ensureTenant('clr-b', 'Clr B', '/path/clr-b');
setActiveTenant('clr-a');
setElement('clr-el-a', makeElement({ id: 'clr-el-a' }), getActiveProjectId());
setActiveTenant('clr-b');
setElement('clr-el-b', makeElement({ id: 'clr-el-b' }), getActiveProjectId());
// Clear tenant A
await request(app)
.delete('/api/elements/clear')
.set('X-Tenant-Id', 'clr-a');
const resA = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'clr-a');
expect(resA.body.count).toBe(0);
const resB = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'clr-b');
expect(resB.body.count).toBe(1);
});
});
// ─── Sync Version Isolation per Tenant ──────────────────────
describe('Sync version isolation', () => {
it('sync versions are independent per tenant/project', async () => {
ensureTenant('sv-a', 'SV A', '/path/sv-a');
ensureTenant('sv-b', 'SV B', '/path/sv-b');
// Create in tenant A
setActiveTenant('sv-a');
const projA = getActiveProjectId();
setElement('sv-el-a', makeElement({ id: 'sv-el-a' }), projA);
const vA = getCurrentSyncVersion(projA);
// Create in tenant B
setActiveTenant('sv-b');
const projB = getActiveProjectId();
setElement('sv-el-b', makeElement({ id: 'sv-el-b' }), projB);
const vB = getCurrentSyncVersion(projB);
// Both should have version 1 (independent counters)
expect(vA).toBe(1);
expect(vB).toBe(1);
});
it('delta sync v2 is scoped to the requesting tenant', async () => {
ensureTenant('ds-a', 'DS A', '/path/ds-a');
ensureTenant('ds-b', 'DS B', '/path/ds-b');
// Create in tenant A via API
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'ds-a')
.send({ id: 'ds-el-a', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
// Create in tenant B via API
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'ds-b')
.send({ id: 'ds-el-b', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
// Sync for tenant A from version 0
const resA = await request(app)
.post('/api/elements/sync/v2')
.set('X-Tenant-Id', 'ds-a')
.send({ lastSyncVersion: 0, changes: [] });
const idsA = resA.body.serverChanges.map((c: any) => c.id);
expect(idsA).toContain('ds-el-a');
expect(idsA).not.toContain('ds-el-b');
// Sync for tenant B from version 0
const resB = await request(app)
.post('/api/elements/sync/v2')
.set('X-Tenant-Id', 'ds-b')
.send({ lastSyncVersion: 0, changes: [] });
const idsB = resB.body.serverChanges.map((c: any) => c.id);
expect(idsB).toContain('ds-el-b');
expect(idsB).not.toContain('ds-el-a');
});
});
// ─── WebSocket Tenant Isolation ─────────────────────────────
describe('WebSocket tenant-scoped broadcasts', () => {
it('broadcast for tenant A does NOT reach client registered to tenant B', async () => {
ensureTenant('ws-a', 'WS A', '/path/ws-a');
ensureTenant('ws-b', 'WS B', '/path/ws-b');
const wsA = await connectAndDrain();
const wsB = await connectAndDrain();
await sendHelloAndWait(wsA, 'ws-a');
await sendHelloAndWait(wsB, 'ws-b');
// Start collecting messages on client B
const bMessages = collectMessagesFor(wsB, 2000);
// Create element in tenant A scope
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'ws-a')
.send({ id: 'ws-only-a', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
const received = await bMessages;
// Client B should NOT receive the element_created for tenant A
const created = received.filter(m => m.type === 'element_created' && m.element?.id === 'ws-only-a');
expect(created).toHaveLength(0);
wsA.close();
wsB.close();
});
it('broadcast for tenant A reaches all clients registered to tenant A', async () => {
ensureTenant('ws-multi', 'WS Multi', '/path/ws-multi');
const ws1 = await connectAndDrain();
const ws2 = await connectAndDrain();
await sendHelloAndWait(ws1, 'ws-multi');
await sendHelloAndWait(ws2, 'ws-multi');
const p1 = waitForMessageOfType(ws1, 'element_created');
const p2 = waitForMessageOfType(ws2, 'element_created');
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'ws-multi')
.send({ id: 'ws-shared', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
const [m1, m2] = await Promise.all([p1, p2]);
expect(m1.element.id).toBe('ws-shared');
expect(m2.element.id).toBe('ws-shared');
ws1.close();
ws2.close();
});
});
// ─── Hello Handshake Isolation ──────────────────────────────
describe('Hello handshake returns scoped elements', () => {
it('hello with tenantId returns only that tenant elements', async () => {
ensureTenant('hello-a', 'Hello A', '/path/hello-a');
ensureTenant('hello-b', 'Hello B', '/path/hello-b');
// Populate both tenants
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'hello-a')
.send({ id: 'ha-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'hello-b')
.send({ id: 'hb-el', type: 'ellipse', x: 0, y: 0, width: 80, height: 80 });
const ws = await connectAndDrain();
const ack = await sendHelloAndWait(ws, 'hello-a');
expect(ack.tenantId).toBe('hello-a');
expect(ack.elements).toBeDefined();
const elementIds = ack.elements.map((e: any) => e.id);
expect(elementIds).toContain('ha-el');
expect(elementIds).not.toContain('hb-el');
ws.close();
});
});
// ─── Tenant Switch via API ──────────────────────────────────
describe('Tenant switch via API', () => {
it('PUT /api/tenant/active switches context and broadcasts', async () => {
ensureTenant('switch-to', 'Switch To', '/path/switch-to');
const ws = await connectAndDrain();
const switchPromise = waitForMessageOfType(ws, 'tenant_switched', 8000);
await request(app)
.put('/api/tenant/active')
.send({ tenantId: 'switch-to' });
const msg = await switchPromise;
expect(msg.tenant).toBeDefined();
expect(msg.tenant.id).toBe('switch-to');
ws.close();
});
it('GET /api/elements after tenant switch returns new tenant elements', async () => {
ensureTenant('ctx-old', 'Old', '/path/old');
ensureTenant('ctx-new', 'New', '/path/new');
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'ctx-new')
.send({ id: 'new-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
// Switch to new tenant
await request(app)
.put('/api/tenant/active')
.send({ tenantId: 'ctx-new' });
// Elements should be from the new tenant
const res = await request(app).get('/api/elements');
const ids = res.body.elements.map((e: any) => e.id);
expect(ids).toContain('new-el');
});
});
+198
View File
@@ -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();
});
});
+247
View File
@@ -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);
});
});
+188
View File
@@ -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);
});
});
+541
View File
@@ -0,0 +1,541 @@
import { test, expect, type Page } from '@playwright/test';
const API = 'http://localhost:3100';
test.beforeEach(async ({ request }) => {
await request.delete(`${API}/api/elements/clear`);
});
// ─── Helpers ────────────────────────────────────────────────
async function waitForConnected(page: Page): Promise<void> {
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
}
async function waitForElements(request: any, expectedCount: number, timeoutMs = 5000): Promise<any[]> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await request.get(`${API}/api/elements`);
const body = await res.json();
if (body.count === expectedCount) return body.elements;
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Timed out waiting for ${expectedCount} elements`);
}
async function getServerElementCount(request: any): Promise<number> {
const res = await request.get(`${API}/api/elements`);
const body = await res.json();
return body.count;
}
async function getSyncVersion(request: any): Promise<number> {
const res = await request.get(`${API}/api/sync/version`);
const body = await res.json();
return body.syncVersion;
}
// ─── THE Critical Regression Test ───────────────────────────
// This is the exact scenario that was broken: delete in UI → sync → reload → elements gone
test.describe('Delete + Sync + Reload persistence', () => {
test('elements deleted via API stay gone after page reload', async ({ page, request }) => {
// 1. Create elements on server
await request.post(`${API}/api/elements`, {
data: { id: 'del-r1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'del-r2', type: 'ellipse', x: 400, y: 100, width: 150, height: 150 },
});
// 2. Load the page, verify elements loaded
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500); // Let elements render
// 3. Delete them via sync/v2 (simulating what the Sync button does after UI deletion)
const syncVersion = await getSyncVersion(request);
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: syncVersion,
changes: [
{ id: 'del-r1', action: 'delete' },
{ id: 'del-r2', action: 'delete' },
],
},
});
const syncBody = await syncRes.json();
expect(syncBody.success).toBe(true);
expect(syncBody.appliedCount).toBe(2);
// 4. Verify server has 0 elements
expect(await getServerElementCount(request)).toBe(0);
// 5. Reload the page
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// 6. Verify elements are still gone on server (the regression was here)
expect(await getServerElementCount(request)).toBe(0);
});
test('sync button persists deletions that survive reload', async ({ page, request }) => {
// 1. Create elements on server
await request.post(`${API}/api/elements`, {
data: { id: 'sb-1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'sb-2', type: 'text', x: 100, y: 300, text: 'To be deleted' },
});
// 2. Load the page
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(1000); // Let elements load + sync baseline populate
// 3. Delete elements via delta sync (simulating UI delete + Sync button)
const v = await getSyncVersion(request);
await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: v,
changes: [
{ id: 'sb-1', action: 'delete' },
{ id: 'sb-2', action: 'delete' },
],
},
});
// 4. Reload
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// 5. Verify no elements on server
const count = await getServerElementCount(request);
expect(count).toBe(0);
// 6. Reload again to double-check
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Delta Sync v2 E2E ──────────────────────────────────────
test.describe('Delta sync v2 E2E', () => {
test('frontend delta sync creates elements that persist', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
// Simulate what the frontend does: send a sync with upserts
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: 0,
changes: [
{ id: 'ds-e2e-1', action: 'upsert', element: { id: 'ds-e2e-1', type: 'rectangle', x: 50, y: 50, width: 100, height: 60 } },
{ id: 'ds-e2e-2', action: 'upsert', element: { id: 'ds-e2e-2', type: 'ellipse', x: 200, y: 50, width: 80, height: 80 } },
],
},
});
const body = await res.json();
expect(body.appliedCount).toBe(2);
// Reload and verify they persist
await page.reload();
await waitForConnected(page);
const elements = await waitForElements(request, 2);
const ids = elements.map((e: any) => e.id).sort();
expect(ids).toEqual(['ds-e2e-1', 'ds-e2e-2']);
});
test('delta sync handles mixed create+delete+update', async ({ request }) => {
// Create initial elements
await request.post(`${API}/api/elements`, {
data: { id: 'mix-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'mix-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
});
const v = await getSyncVersion(request);
// Mixed operation: delete mix-1, update mix-2, create mix-3
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: v,
changes: [
{ id: 'mix-1', action: 'delete' },
{ id: 'mix-2', action: 'upsert', element: { id: 'mix-2', type: 'ellipse', x: 300, y: 100, width: 80, height: 80 } },
{ id: 'mix-3', action: 'upsert', element: { id: 'mix-3', type: 'text', x: 50, y: 200, text: 'New' } },
],
},
});
const body = await res.json();
expect(body.appliedCount).toBe(3);
// Verify final state
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
expect(listBody.count).toBe(2);
const ids = listBody.elements.map((e: any) => e.id).sort();
expect(ids).toEqual(['mix-2', 'mix-3']);
});
test('server returns MCP-created elements as serverChanges', async ({ request }) => {
// MCP creates an element (via normal API)
await request.post(`${API}/api/elements`, {
data: { id: 'mcp-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
// Frontend syncs from version 0
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: { lastSyncVersion: 0, changes: [] },
});
const body = await res.json();
const serverIds = body.serverChanges.map((c: any) => c.id);
expect(serverIds).toContain('mcp-el');
});
});
// ─── Auto-sync behavior ─────────────────────────────────────
test.describe('Auto-sync toggle', () => {
test('auto-sync button toggles state', async ({ page }) => {
await page.goto('/');
await waitForConnected(page);
const autoSaveBtn = page.locator('button[title*="Auto-sync"]');
await expect(autoSaveBtn).toBeVisible();
// Check initial state (should show the sun/moon icon and be clickable)
await autoSaveBtn.click();
// Second click toggles back
await autoSaveBtn.click();
// No crash = success
});
});
// ─── Sync Version Tracking E2E ──────────────────────────────
test.describe('Sync version tracking', () => {
test('sync version increases after each mutation', async ({ request }) => {
const v0 = await getSyncVersion(request);
await request.post(`${API}/api/elements`, {
data: { id: 'sv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
const v1 = await getSyncVersion(request);
expect(v1).toBeGreaterThan(v0);
await request.put(`${API}/api/elements/sv-1`, {
data: { x: 50 },
});
const v2 = await getSyncVersion(request);
expect(v2).toBeGreaterThan(v1);
await request.delete(`${API}/api/elements/sv-1`);
const v3 = await getSyncVersion(request);
expect(v3).toBeGreaterThan(v2);
});
test('batch create increments sync version for each element', async ({ request }) => {
const v0 = await getSyncVersion(request);
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'bsv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'bsv-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
{ id: 'bsv-3', type: 'text', x: 50, y: 100, text: 'Test' },
],
},
});
const v1 = await getSyncVersion(request);
expect(v1).toBeGreaterThanOrEqual(v0 + 3);
});
});
// ─── Real-time Sync (MCP→Canvas) ────────────────────────────
test.describe('MCP to Canvas real-time sync', () => {
test('element created via API appears on canvas via WebSocket', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
// Create element via API
await request.post(`${API}/api/elements`, {
data: { id: 'rt-el', type: 'rectangle', x: 100, y: 100, width: 200, height: 100, backgroundColor: '#ff0000' },
});
// Wait for canvas to receive it via WS
await page.waitForTimeout(1000);
// Verify element is on the canvas (check via API since we can't easily inspect Excalidraw internals)
const elements = await waitForElements(request, 1);
expect(elements[0].id).toBe('rt-el');
});
test('batch create appears on canvas without reload', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'rt-b1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'rt-b2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
],
},
});
await page.waitForTimeout(1000);
const elements = await waitForElements(request, 2);
expect(elements).toHaveLength(2);
});
test('element update appears on canvas without reload', async ({ page, request }) => {
// Pre-create
await request.post(`${API}/api/elements`, {
data: { id: 'rt-upd', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
// Update
await request.put(`${API}/api/elements/rt-upd`, {
data: { x: 500, y: 500 },
});
await page.waitForTimeout(1000);
// Verify update persisted
const res = await request.get(`${API}/api/elements/rt-upd`);
const body = await res.json();
expect(body.element.x).toBe(500);
expect(body.element.y).toBe(500);
});
test('element delete via API clears from canvas', async ({ page, request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'rt-del', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
await request.delete(`${API}/api/elements/rt-del`);
await page.waitForTimeout(500);
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Clear Canvas E2E ───────────────────────────────────────
test.describe('Clear canvas persistence', () => {
test('clearing via API removes all elements permanently', async ({ page, request }) => {
// Create elements
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'clr-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'clr-2', type: 'text', x: 50, y: 100, text: 'Will be cleared' },
],
},
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
// Clear
await request.delete(`${API}/api/elements/clear`);
await page.waitForTimeout(500);
// Verify gone
expect(await getServerElementCount(request)).toBe(0);
// Reload
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// Still gone
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Snapshot Create + Restore ──────────────────────────────
test.describe('Snapshots E2E', () => {
test('create snapshot, clear, restore, verify elements return', async ({ request }) => {
// Create elements
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'snap-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'snap-2', type: 'text', x: 50, y: 100, text: 'Snapshot test' },
],
},
});
// Save snapshot
const snapRes = await request.post(`${API}/api/snapshots`, {
data: { name: 'test-snap' },
});
expect((await snapRes.json()).success).toBe(true);
// Clear
await request.delete(`${API}/api/elements/clear`);
expect(await getServerElementCount(request)).toBe(0);
// List snapshots
const listRes = await request.get(`${API}/api/snapshots`);
const listBody = await listRes.json();
expect(listBody.snapshots.some((s: any) => s.name === 'test-snap')).toBe(true);
// Get snapshot
const getRes = await request.get(`${API}/api/snapshots/test-snap`);
const getBody = await getRes.json();
expect(getBody.snapshot).toBeDefined();
expect(getBody.snapshot.elements).toHaveLength(2);
});
});
// ─── Settings Persistence ───────────────────────────────────
test.describe('Settings E2E', () => {
test('settings persist across requests', async ({ request }) => {
await request.put(`${API}/api/settings/test_key`, {
data: { value: 'test_value' },
});
const res = await request.get(`${API}/api/settings/test_key`);
const body = await res.json();
expect(body.value).toBe('test_value');
});
});
// ─── Files API E2E ──────────────────────────────────────────
test.describe('Files API E2E', () => {
test('add and list files', async ({ request }) => {
const addRes = await request.post(`${API}/api/files`, {
data: {
files: {
'file-1': {
id: 'file-1',
mimeType: 'image/png',
dataURL: 'data:image/png;base64,iVBOR...',
created: Date.now(),
},
},
},
});
expect((await addRes.json()).success).toBe(true);
const listRes = await request.get(`${API}/api/files`);
const listBody = await listRes.json();
expect(listBody.files['file-1']).toBeDefined();
expect(listBody.files['file-1'].mimeType).toBe('image/png');
});
test('delete file', async ({ request }) => {
await request.post(`${API}/api/files`, {
data: {
files: {
'file-del': {
id: 'file-del',
mimeType: 'image/png',
dataURL: 'data:image/png;base64,abc',
created: Date.now(),
},
},
},
});
const delRes = await request.delete(`${API}/api/files/file-del`);
expect((await delRes.json()).success).toBe(true);
const listRes = await request.get(`${API}/api/files`);
const listBody = await listRes.json();
expect(listBody.files['file-del']).toBeUndefined();
});
});
// ─── Search API E2E ─────────────────────────────────────────
test.describe('Search E2E', () => {
test('search by type returns matching elements', async ({ request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'srch-r', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'srch-e', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
{ id: 'srch-t', type: 'text', x: 50, y: 100, text: 'Search me' },
],
},
});
// Filter by type
const res = await request.get(`${API}/api/elements/search?type=rectangle`);
const body = await res.json();
expect(body.elements.length).toBe(1);
expect(body.elements[0].type).toBe('rectangle');
});
test('full-text search finds elements by label', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'fts-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, label: { text: 'Authentication Service' } },
});
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
const body = await res.json();
expect(body.elements.length).toBeGreaterThanOrEqual(1);
expect(body.elements.some((e: any) => e.id === 'fts-el')).toBe(true);
});
});
// ─── Tenant API E2E ─────────────────────────────────────────
test.describe('Tenant management E2E', () => {
test('list tenants returns at least default', async ({ request }) => {
const res = await request.get(`${API}/api/tenants`);
const body = await res.json();
expect(body.tenants.length).toBeGreaterThanOrEqual(1);
});
test('active tenant is available', async ({ request }) => {
const res = await request.get(`${API}/api/tenant/active`);
const body = await res.json();
expect(body.tenant).toBeDefined();
expect(body.tenant.id).toBeDefined();
});
});
// ─── Element Version History E2E ────────────────────────────
test.describe('Element version history E2E', () => {
test('element history tracks create and update', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await request.put(`${API}/api/elements/hist-el`, {
data: { x: 500 },
});
// Get element to verify it exists and is updated
const getRes = await request.get(`${API}/api/elements/hist-el`);
const body = await getRes.json();
expect(body.element.x).toBe(500);
});
});
+160
View File
@@ -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 });
});
});
+518
View File
@@ -0,0 +1,518 @@
import { describe, it, expect } from 'vitest';
import {
cleanElementForExcalidraw,
computeElementHash,
isImageElement,
isShapeContainerType,
normalizeImageElement,
validateAndFixBindings,
restoreBindings,
} from '../../frontend/src/utils/elementHelpers.js';
// ─── cleanElementForExcalidraw comprehensive ────────────────
describe('cleanElementForExcalidraw - comprehensive', () => {
it('strips all server-only metadata fields', () => {
const serverEl = {
id: 'el-1',
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
syncedAt: '2024-01-01',
source: 'mcp',
syncTimestamp: 12345,
};
const cleaned = cleanElementForExcalidraw(serverEl);
expect(cleaned).not.toHaveProperty('createdAt');
expect(cleaned).not.toHaveProperty('updatedAt');
expect(cleaned).not.toHaveProperty('version');
expect(cleaned).not.toHaveProperty('syncedAt');
expect(cleaned).not.toHaveProperty('source');
expect(cleaned).not.toHaveProperty('syncTimestamp');
// Core props preserved
expect(cleaned.id).toBe('el-1');
expect(cleaned.type).toBe('rectangle');
expect(cleaned.x).toBe(100);
});
it('preserves label text on container elements', () => {
const el = {
id: 'cont-1',
type: 'rectangle',
x: 0, y: 0, width: 200, height: 100,
label: { text: 'My Label' },
};
const cleaned = cleanElementForExcalidraw(el);
expect(cleaned.label?.text || (cleaned as any).text).toBeDefined();
});
it('preserves arrow binding properties', () => {
const arrow = {
id: 'arrow-1',
type: 'arrow',
x: 0, y: 0,
width: 200, height: 0,
start: { id: 'rect-1' },
end: { id: 'rect-2' },
startElementId: 'rect-1',
endElementId: 'rect-2',
};
const cleaned = cleanElementForExcalidraw(arrow);
// Should preserve binding references
expect(cleaned.type).toBe('arrow');
});
it('handles elements with no optional properties', () => {
const minimal = {
id: 'min-1',
type: 'rectangle',
x: 0,
y: 0,
width: 100,
height: 50,
};
const cleaned = cleanElementForExcalidraw(minimal);
expect(cleaned.id).toBe('min-1');
expect(cleaned.type).toBe('rectangle');
});
it('handles text element with originalText', () => {
const textEl = {
id: 'text-1',
type: 'text',
x: 0, y: 0,
text: 'Hello',
originalText: 'Hello',
fontSize: 20,
fontFamily: 1,
};
const cleaned = cleanElementForExcalidraw(textEl);
expect(cleaned.type).toBe('text');
});
});
// ─── computeElementHash ─────────────────────────────────────
describe('computeElementHash - edge cases', () => {
it('hash changes when element position changes', () => {
const elements = [{ id: 'h1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, version: 1 }] as any;
const hash1 = computeElementHash(elements);
const moved = [{ id: 'h1', type: 'rectangle', x: 50, y: 50, width: 100, height: 50, version: 2 }] as any;
const hash2 = computeElementHash(moved);
expect(hash1).not.toBe(hash2);
});
it('hash changes when element is deleted (removed from array)', () => {
const full = [
{ id: 'h1', type: 'rectangle', version: 1 },
{ id: 'h2', type: 'ellipse', version: 1 },
] as any;
const partial = [{ id: 'h1', type: 'rectangle', version: 1 }] as any;
expect(computeElementHash(full)).not.toBe(computeElementHash(partial));
});
it('hash is stable for same input', () => {
const elements = [
{ id: 'stable-1', type: 'rectangle', version: 1 },
{ id: 'stable-2', type: 'ellipse', version: 1 },
] as any;
expect(computeElementHash(elements)).toBe(computeElementHash(elements));
});
it('hash uses id+version (type changes without version bump are not detected)', () => {
// Hash formula is: count + join(id+version) — type is NOT included
const rect = [{ id: 'morph', type: 'rectangle', version: 1 }] as any;
const ellipse = [{ id: 'morph', type: 'ellipse', version: 1 }] as any;
// Same id+version → same hash (this is expected behavior)
expect(computeElementHash(rect)).toBe(computeElementHash(ellipse));
// Version bump makes them different
const updated = [{ id: 'morph', type: 'ellipse', version: 2 }] as any;
expect(computeElementHash(rect)).not.toBe(computeElementHash(updated));
});
});
// ─── validateAndFixBindings comprehensive ───────────────────
describe('validateAndFixBindings - comprehensive', () => {
it('preserves valid container + bound text relationship', () => {
const elements = [
{
id: 'container',
type: 'rectangle',
boundElements: [{ id: 'bound-text', type: 'text' }],
},
{
id: 'bound-text',
type: 'text',
containerId: 'container',
},
];
const result = validateAndFixBindings(elements);
const container = result.find((e: any) => e.id === 'container');
const text = result.find((e: any) => e.id === 'bound-text');
expect(container.boundElements).toHaveLength(1);
expect(text.containerId).toBe('container');
});
it('removes orphaned boundElements references', () => {
const elements = [
{
id: 'container',
type: 'rectangle',
boundElements: [
{ id: 'exists', type: 'text' },
{ id: 'ghost', type: 'text' },
],
},
{ id: 'exists', type: 'text', containerId: 'container' },
];
const result = validateAndFixBindings(elements);
const container = result.find((e: any) => e.id === 'container');
expect(container.boundElements).toHaveLength(1);
expect(container.boundElements[0].id).toBe('exists');
});
it('nullifies containerId when container does not exist', () => {
const elements = [
{ id: 'orphan', type: 'text', containerId: 'nonexistent' },
];
const result = validateAndFixBindings(elements);
expect(result[0].containerId).toBeNull();
});
it('handles arrow boundElements correctly', () => {
const elements = [
{
id: 'shape',
type: 'rectangle',
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
},
{
id: 'arrow-1',
type: 'arrow',
startBinding: { elementId: 'shape' },
},
];
const result = validateAndFixBindings(elements);
const shape = result.find((e: any) => e.id === 'shape');
expect(shape.boundElements).toHaveLength(1);
});
it('handles empty boundElements array (converts to null)', () => {
const elements = [{ id: 'empty', type: 'rectangle', boundElements: [] }];
const result = validateAndFixBindings(elements);
// Implementation converts empty filtered arrays to null
expect(result[0].boundElements).toBeNull();
});
it('handles null boundElements', () => {
const elements = [{ id: 'null-bound', type: 'rectangle', boundElements: null }];
const result = validateAndFixBindings(elements);
expect(result[0].boundElements).toBeNull();
});
});
// ─── isImageElement ─────────────────────────────────────────
describe('isImageElement - comprehensive', () => {
it('returns true for image type', () => {
expect(isImageElement({ type: 'image', fileId: 'f1' })).toBe(true);
});
it('returns false for all other types', () => {
const nonImageTypes = ['rectangle', 'ellipse', 'diamond', 'arrow', 'line', 'text', 'freedraw'];
for (const type of nonImageTypes) {
expect(isImageElement({ type })).toBe(false);
}
});
it('returns true when fileId is present regardless of type', () => {
// Some implementations check fileId as fallback
const result = isImageElement({ type: 'image', fileId: 'some-file' });
expect(result).toBe(true);
});
});
// ─── isShapeContainerType ───────────────────────────────────
describe('isShapeContainerType - comprehensive', () => {
it('returns true for all container types', () => {
const containerTypes = ['rectangle', 'ellipse', 'diamond'];
for (const type of containerTypes) {
expect(isShapeContainerType(type)).toBe(true);
}
});
it('returns true for arrow and line (they are container types)', () => {
// arrow and line are included in SHAPE_CONTAINER_TYPES
expect(isShapeContainerType('arrow')).toBe(true);
expect(isShapeContainerType('line')).toBe(true);
});
it('returns false for non-container types', () => {
const nonContainer = ['text', 'freedraw', 'image', 'frame'];
for (const type of nonContainer) {
expect(isShapeContainerType(type)).toBe(false);
}
});
});
// ─── normalizeImageElement ──────────────────────────────────
describe('normalizeImageElement - comprehensive', () => {
it('fills in all required defaults for minimal image', () => {
const minimal = {
id: 'img-1',
type: 'image',
x: 0,
y: 0,
width: 100,
height: 100,
fileId: 'file-1',
};
const normalized = normalizeImageElement(minimal);
expect(normalized.type).toBe('image');
expect(normalized.fileId).toBe('file-1');
// Should have all required Excalidraw properties
expect(normalized).toHaveProperty('strokeColor');
expect(normalized).toHaveProperty('backgroundColor');
expect(normalized).toHaveProperty('fillStyle');
expect(normalized).toHaveProperty('opacity');
});
it('preserves explicit values over defaults', () => {
const custom = {
id: 'img-2',
type: 'image',
x: 50,
y: 50,
width: 200,
height: 150,
fileId: 'file-2',
opacity: 50,
angle: 1.5,
};
const normalized = normalizeImageElement(custom);
expect(normalized.opacity).toBe(50);
expect(normalized.angle).toBe(1.5);
expect(normalized.x).toBe(50);
expect(normalized.y).toBe(50);
});
});
// ─── restoreBindings ────────────────────────────────────────
describe('restoreBindings - comprehensive', () => {
it('restores startBinding and endBinding from originals', () => {
const converted = [
{ id: 'arrow-1', type: 'arrow' },
];
const originals = [
{
id: 'arrow-1',
type: 'arrow',
startBinding: { elementId: 'rect-1', focus: 0, gap: 5, fixedPoint: null },
endBinding: { elementId: 'rect-2', focus: 0, gap: 5, fixedPoint: null },
},
];
const result = restoreBindings(converted, originals);
expect(result[0].startBinding.elementId).toBe('rect-1');
expect(result[0].endBinding.elementId).toBe('rect-2');
});
it('restores boundElements on shapes', () => {
const converted = [
{ id: 'shape-1', type: 'rectangle' },
];
const originals = [
{
id: 'shape-1',
type: 'rectangle',
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
},
];
const result = restoreBindings(converted, originals);
expect(result[0].boundElements).toHaveLength(1);
});
it('does not overwrite existing bindings', () => {
const converted = [
{
id: 'arrow-1',
type: 'arrow',
startBinding: { elementId: 'already-set', focus: 0, gap: 3, fixedPoint: null },
},
];
const originals = [
{
id: 'arrow-1',
type: 'arrow',
startBinding: { elementId: 'original', focus: 0, gap: 5, fixedPoint: null },
},
];
const result = restoreBindings(converted, originals);
expect(result[0].startBinding.elementId).toBe('already-set');
});
it('handles element not found in originals', () => {
const converted = [{ id: 'new-1', type: 'rectangle' }];
const originals = [{ id: 'other', type: 'ellipse' }];
const result = restoreBindings(converted, originals);
expect(result[0].id).toBe('new-1');
// Should not crash
});
it('restores elbowed property on arrows', () => {
const converted = [{ id: 'elb-arrow', type: 'arrow' }];
const originals = [
{ id: 'elb-arrow', type: 'arrow', elbowed: true },
];
const result = restoreBindings(converted, originals);
expect(result[0].elbowed).toBe(true);
});
});
// ─── Delta Computation Logic (simulated) ────────────────────
// Tests the algorithm used in syncToBackend for detecting changes
describe('Delta computation (simulated syncToBackend logic)', () => {
type Element = { id: string; type: string; x: number; version: number };
function computeDelta(
currentElements: Element[],
lastSynced: Map<string, Element>
): { id: string; action: 'upsert' | 'delete'; element?: Element }[] {
const changes: { id: string; action: 'upsert' | 'delete'; element?: Element }[] = [];
const currentMap = new Map<string, Element>();
for (const el of currentElements) {
currentMap.set(el.id, el);
const prev = lastSynced.get(el.id);
if (!prev || JSON.stringify(prev) !== JSON.stringify(el)) {
changes.push({ id: el.id, action: 'upsert', element: el });
}
}
for (const [id] of lastSynced) {
if (!currentMap.has(id)) {
changes.push({ id, action: 'delete' });
}
}
return changes;
}
it('detects new elements as upserts', () => {
const current = [{ id: 'a', type: 'rect', x: 0, version: 1 }];
const lastSynced = new Map<string, Element>();
const delta = computeDelta(current, lastSynced);
expect(delta).toHaveLength(1);
expect(delta[0].action).toBe('upsert');
expect(delta[0].id).toBe('a');
});
it('detects removed elements as deletes', () => {
const current: Element[] = [];
const lastSynced = new Map<string, Element>([
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
['b', { id: 'b', type: 'rect', x: 100, version: 1 }],
]);
const delta = computeDelta(current, lastSynced);
expect(delta).toHaveLength(2);
expect(delta.every(d => d.action === 'delete')).toBe(true);
});
it('detects updated elements as upserts', () => {
const current = [{ id: 'a', type: 'rect', x: 50, version: 2 }];
const lastSynced = new Map<string, Element>([
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
]);
const delta = computeDelta(current, lastSynced);
expect(delta).toHaveLength(1);
expect(delta[0].action).toBe('upsert');
});
it('returns empty when nothing changed', () => {
const el = { id: 'a', type: 'rect', x: 0, version: 1 };
const current = [el];
const lastSynced = new Map<string, Element>([['a', { ...el }]]);
const delta = computeDelta(current, lastSynced);
expect(delta).toHaveLength(0);
});
it('handles mixed operations correctly', () => {
const current = [
{ id: 'a', type: 'rect', x: 50, version: 2 }, // updated
{ id: 'c', type: 'rect', x: 200, version: 1 }, // new
];
const lastSynced = new Map<string, Element>([
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
['b', { id: 'b', type: 'rect', x: 100, version: 1 }], // deleted
]);
const delta = computeDelta(current, lastSynced);
expect(delta).toHaveLength(3);
const upserts = delta.filter(d => d.action === 'upsert');
const deletes = delta.filter(d => d.action === 'delete');
expect(upserts).toHaveLength(2); // a (updated) + c (new)
expect(deletes).toHaveLength(1); // b
expect(deletes[0].id).toBe('b');
});
it('THE BUG: empty lastSynced means no deletions detected', () => {
// This is the exact bug scenario: elements loaded from server but lastSynced not populated
const current: Element[] = []; // User deleted everything
const lastSynced = new Map<string, Element>(); // Bug: was never populated
const delta = computeDelta(current, lastSynced);
// With empty lastSynced, no deletions are detected - this was the regression
expect(delta).toHaveLength(0);
});
it('FIXED: populated lastSynced detects all deletions', () => {
// After fix: lastSynced is populated on load
const current: Element[] = []; // User deleted everything
const lastSynced = new Map<string, Element>([
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
['b', { id: 'b', type: 'rect', x: 100, version: 1 }],
]);
const delta = computeDelta(current, lastSynced);
expect(delta).toHaveLength(2);
expect(delta.every(d => d.action === 'delete')).toBe(true);
});
});