From 9846e0ba0f4af2a1ee051b4350fc6a71fb493f8d Mon Sep 17 00:00:00 2001 From: "Maxime Roy (new.blacc)" Date: Mon, 6 Apr 2026 21:12:01 +0200 Subject: [PATCH] feat(canvas): native field preservation, label materialization, batch workspace delete (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(canvas): native field preservation, label materialization, batch workspace delete - fillNativeFields() + repairContainerBinding() in db layer ensure every element stored in SQLite is a complete, round-trippable Excalidraw element with correct containerId ↔ boundElements bidirectional binding - materializeLabel() in server.ts: shapes with label.text/text produce a native bound text element at write time (create, update, batch) so text follows its container when moved — matches VSCode Excalidraw extension behavior - Batch workspace UI: Select/Unselect All, per-row checkboxes, Delete N workspaces button with confirmation - POST /api/tenants/batch-delete: delete up to 50 tenants in one request - 42 new backend tests; 519 total passing - Bump version 1.0.6 → 1.1.0 Co-Authored-By: Claude Sonnet 4.6 * fix(tests): update e2e assertions for label materialization phase2-regressions: label is now a native bound text element (id: pos-stable-1-label) — check bound text element text instead of container.label?.text which is no longer stored. sync-flows FTS: search now matches the bound text element (fts-el-label) rather than the container — accept either id as valid match. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 22 + README.md | 3 +- frontend/index.html | 107 +++++ frontend/src/App.tsx | 100 ++++- package-lock.json | 4 +- package.json | 2 +- src/db.ts | 104 ++++- src/index.ts | 94 ++--- src/server.ts | 134 +++++- src/types.ts | 1 + tests/backend/native-fields.test.ts | 581 +++++++++++++++++++++++++++ tests/e2e/native-fields.spec.ts | 297 ++++++++++++++ tests/e2e/phase2-regressions.spec.ts | 9 +- tests/e2e/sync-flows.spec.ts | 4 +- 14 files changed, 1388 insertions(+), 74 deletions(-) create mode 100644 tests/backend/native-fields.test.ts create mode 100644 tests/e2e/native-fields.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f504c..5af0a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +## [1.1.0] - 2026-04-06 + +### Added +- Batch workspace select/delete UI: Select/Unselect All, per-row checkboxes, + Delete N workspaces button with confirmation — active workspace is disabled + from selection +- `POST /api/tenants/batch-delete` endpoint — delete up to 50 tenants in one + request with per-tenant cascade (projects, elements, snapshots) +- `fillNativeFields()` in db layer — fills all universal and type-specific + native Excalidraw fields (angle, strokeColor, roundness, seed, etc.) on + every write so elements are identical to those produced by the VSCode + Excalidraw extension +- `repairContainerBinding()` in db layer — enforces bidirectional + `containerId` ↔ `boundElements` binding on every write path (create, update, + batch, sync/v2) so text labels always follow their container when moved +- Server-side label materialization (`materializeLabel` in server.ts): MCP + `create_element`/`update_element` calls with `label.text` or `text` on a + shape now produce a native bound text element in the DB instead of an MCP + label stub — no synthetic generation required on export +- 42 new backend non-regression tests for native field preservation, container + binding repair, and label materialization (519 total) + ## [1.0.6] - 2026-04-06 ### Added diff --git a/README.md b/README.md index 74d7c67..123a689 100644 --- a/README.md +++ b/README.md @@ -497,7 +497,7 @@ Each workspace (codebase) gets an isolated canvas. The tenant is identified by a 1. **Auto-detection**: When the MCP starts, it calls `server.listRoots()` to get the actual workspace path from the MCP client. This is hashed to create a unique tenant ID. 2. **Per-request scoping**: Every HTTP request includes an `X-Tenant-Id` header. The canvas server uses this to scope all CRUD operations to the correct tenant. -3. **UI switcher**: The canvas UI shows a "Workspace: <name>" badge. Click it to open a dropdown with all known workspaces, complete with search. +3. **UI switcher**: The canvas UI shows a "Workspace: <name>" badge. Click it to open a dropdown with all known workspaces, complete with search and bulk management. Use **Select** to enter multi-select mode, check individual workspaces, then **Delete N workspaces** to batch-remove them (with confirmation). **Select All** / **Unselect All** shortcuts are available in selection mode. 4. **Multi-instance safe**: SQLite WAL mode with `busy_timeout = 5000ms` handles concurrent access from multiple client instances. ### Projects within a tenant @@ -759,6 +759,7 @@ The canvas server exposes a REST API alongside the WebSocket interface: | DELETE | `/api/projects/:id` | Delete a project (cascades elements) | | GET | `/api/tenants` | List all tenants | | DELETE | `/api/tenants/:id` | Delete a tenant (cascades projects and elements) | +| POST | `/api/tenants/batch-delete` | Delete multiple tenants in one request — body: `{ ids: string[] }` (max 50) | | GET | `/api/tenant/active` | Get the active tenant | | PUT | `/api/tenant/active` | Set the active tenant | | GET | `/api/settings/:key` | Read a setting | diff --git a/frontend/index.html b/frontend/index.html index c0f4149..8fc631e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -402,6 +402,9 @@ text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #f0f0f0; + display: flex; + align-items: center; + justify-content: space-between; } .menu-search-wrap { padding: 8px 10px 4px; @@ -573,6 +576,110 @@ } .project-delete-no:hover { background: #dee2e6; } + /* Batch selection mode */ + .batch-mode-toggle { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border: 1px solid #ddd; + border-radius: 4px; + background: #f8f9fa; + color: #555; + cursor: pointer; + text-transform: none; + letter-spacing: 0; + transition: background 0.15s, border-color 0.15s; + } + .batch-mode-toggle:hover { background: #e9ecef; border-color: #ccc; } + .batch-mode-active { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; } + .batch-mode-active:hover { background: #c8e6c9; } + .batch-actions-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-bottom: 1px solid #f0f0f0; + background: #fafafa; + } + .batch-action-btn { + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + color: #555; + cursor: pointer; + transition: background 0.15s; + } + .batch-action-btn:hover { background: #e9ecef; } + .batch-count { + font-size: 11px; + color: #888; + margin-left: auto; + } + .batch-item { + display: flex; + flex-direction: row; + align-items: center; + cursor: pointer; + gap: 10px; + } + .batch-item-disabled { + opacity: 0.5; + cursor: default; + } + .batch-checkbox { + width: 16px; + height: 16px; + flex-shrink: 0; + accent-color: #e03131; + cursor: pointer; + } + .batch-item-disabled .batch-checkbox { cursor: default; } + .batch-item-content { + display: flex; + flex-direction: column; + min-width: 0; + } + .batch-active-label { + font-size: 10px; + font-weight: 600; + color: #4caf50; + text-transform: uppercase; + margin-left: auto; + flex-shrink: 0; + } + .batch-delete-bar { + padding: 8px 12px; + border-top: 1px solid #f0f0f0; + background: #fff5f5; + } + .batch-delete-btn { + width: 100%; + padding: 8px; + font-size: 13px; + font-weight: 600; + background: #e03131; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; + } + .batch-delete-btn:hover { background: #c92a2a; } + .batch-delete-confirm { + display: flex; + align-items: center; + gap: 8px; + } + .batch-delete-msg { + flex: 1; + font-size: 13px; + color: #c92a2a; + font-weight: 500; + } + /* Clear canvas confirmation dialog */ .confirm-dialog { position: absolute; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc2e84a..8de39c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -134,6 +134,9 @@ function App(): JSX.Element { const newProjectInputRef = useRef(null) const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState(null) const [confirmDeleteTenantId, setConfirmDeleteTenantId] = useState(null) + const [batchSelectMode, setBatchSelectMode] = useState(false) + const [selectedTenantIds, setSelectedTenantIds] = useState>(new Set()) + const [confirmBatchDelete, setConfirmBatchDelete] = useState(false) // Keep refs in sync so closures (WebSocket handlers) always see latest values useEffect(() => { @@ -1325,6 +1328,38 @@ function App(): JSX.Element { } } + const batchDeleteTenants = async () => { + const ids = Array.from(selectedTenantIds) + try { + const res = await fetch('/api/tenants/batch-delete', { + method: 'POST', + headers: tenantHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ ids }) + }) + const data = await res.json() + const deleted = data.deletedCount ?? 0 + const deletedIds = new Set((data.results ?? []).filter((r: { deleted: boolean }) => r.deleted).map((r: { id: string }) => r.id)) + setTenantList(prev => prev.filter(t => !deletedIds.has(t.id))) + setSelectedTenantIds(new Set()) + setConfirmBatchDelete(false) + setBatchSelectMode(false) + showToast(`${deleted} workspace${deleted !== 1 ? 's' : ''} deleted`) + } catch (err) { + console.error('Batch delete failed:', err) + showToast('Batch delete failed', 4000) + setConfirmBatchDelete(false) + } + } + + const toggleTenantSelection = (id: string) => { + setSelectedTenantIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + const syncToBackend = async (): Promise => { if (!excalidrawAPI || isSyncingRef.current) return @@ -1569,10 +1604,37 @@ function App(): JSX.Element { const filtered = q ? tenantList.filter(t => t.name.toLowerCase().includes(q) || t.workspace_path.toLowerCase().includes(q)) : tenantList + const selectableFiltered = filtered.filter(t => t.id !== activeTenant?.id) return ( -
setMenuOpen(false)}> +
{ setMenuOpen(false); setBatchSelectMode(false); setSelectedTenantIds(new Set()); setConfirmBatchDelete(false) }}>
e.stopPropagation()}> -
Workspaces
+
+ Workspaces + +
+ {batchSelectMode && selectableFiltered.length > 0 && ( +
+ + + {selectedTenantIds.size} selected +
+ )}
deleteTenantUI(t.id)}>Delete
+ ) : batchSelectMode ? ( + ) : ( <>
}
+ {batchSelectMode && selectedTenantIds.size > 0 && ( +
+ {confirmBatchDelete ? ( +
+ Delete {selectedTenantIds.size} workspace{selectedTenantIds.size !== 1 ? 's' : ''}? + + +
+ ) : ( + + )} +
+ )}
) diff --git a/package-lock.json b/package-lock.json index 65f8e7c..20428eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "excalidraw-mcp-sentinel", - "version": "1.0.3", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "excalidraw-mcp-sentinel", - "version": "1.0.3", + "version": "1.1.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b2bfb76..a568fc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "excalidraw-mcp-sentinel", - "version": "1.0.6", + "version": "1.1.0", "description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests", "main": "dist/index.js", "type": "module", diff --git a/src/db.ts b/src/db.ts index 6b11cf5..6391d54 100644 --- a/src/db.ts +++ b/src/db.ts @@ -264,6 +264,96 @@ export function getDefaultProjectForTenant(tenantId: string): string { return id; } +// ── Native field normalization ── + +// Fill any missing native Excalidraw fields so every element stored in the DB +// is a complete, round-trippable Excalidraw element — not just an MCP partial. +function fillNativeFields(element: ServerElement): ServerElement { + const el = element as any; + + // ── Universal fields ────────────────────────────────────────────────────── + el.angle = el.angle ?? 0; + el.strokeColor = el.strokeColor ?? '#1e1e1e'; + el.backgroundColor = el.backgroundColor ?? 'transparent'; + el.fillStyle = el.fillStyle ?? 'solid'; + el.strokeWidth = el.strokeWidth ?? 2; + el.strokeStyle = el.strokeStyle ?? 'solid'; + el.roughness = el.roughness ?? 1; + el.opacity = el.opacity ?? 100; + el.groupIds = el.groupIds ?? []; + el.frameId = el.frameId ?? null; + el.seed = el.seed ?? Math.floor(Math.random() * 2147483647); + el.versionNonce = el.versionNonce ?? Math.floor(Math.random() * 2147483647); + el.isDeleted = el.isDeleted ?? false; + el.updated = el.updated ?? Date.now(); + el.link = el.link ?? null; + el.locked = el.locked ?? false; + el.boundElements = el.boundElements ?? null; + + // index: preserve existing; generate a stable sortable value if absent + if (!el.index) { + el.index = `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`; + } + + // roundness: Excalidraw default is rounded (type 3) for closed shapes + if (el.roundness === undefined) { + const rounded = el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse'; + el.roundness = rounded ? { type: 3 } : null; + } + + // ── Type-specific fields ────────────────────────────────────────────────── + if (el.type === 'text') { + el.text = el.text ?? ''; + el.originalText = el.originalText ?? el.text; + el.fontSize = el.fontSize ?? 20; + el.fontFamily = el.fontFamily ?? 5; // Nunito + el.textAlign = el.textAlign ?? 'left'; + el.verticalAlign = el.verticalAlign ?? (el.containerId ? 'middle' : 'top'); + el.autoResize = el.autoResize ?? true; + el.lineHeight = el.lineHeight ?? 1.25; + el.containerId = el.containerId ?? null; + } else if (el.type === 'arrow' || el.type === 'line') { + el.points = el.points ?? [[0, 0], [100, 0]]; + el.lastCommittedPoint = el.lastCommittedPoint ?? null; + el.startBinding = el.startBinding ?? null; + el.endBinding = el.endBinding ?? null; + el.startArrowhead = el.startArrowhead ?? null; + el.endArrowhead = el.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null); + el.elbowed = el.elbowed ?? false; + } else if (el.type === 'image') { + el.status = el.status ?? 'pending'; + el.scale = el.scale ?? [1, 1]; + } else if (el.type === 'freedraw') { + el.points = el.points ?? []; + el.pressures = el.pressures ?? []; + el.simulatePressure = el.simulatePressure ?? true; + el.lastCommittedPoint = el.lastCommittedPoint ?? null; + } + + return el as ServerElement; +} + +// When a text element with containerId is saved, ensure the container's +// boundElements array references it back. Both sides must be consistent +// for Excalidraw to treat the text as embedded in the shape. +function repairContainerBinding(element: ServerElement, projectId?: string): void { + if (element.type !== 'text') return; + const cid = (element as any).containerId as string | null | undefined; + if (!cid) return; + const container = getElement(cid, projectId); + if (!container) return; + const existing: any[] = Array.isArray((container as any).boundElements) + ? (container as any).boundElements as any[] + : []; + if (existing.some((b: any) => b.id === element.id)) return; + // Update container directly — container.type is never 'text' so this + // cannot recurse back into repairContainerBinding. + setElement(cid, { + ...container, + boundElements: [...existing, { type: 'text', id: element.id }] + } as ServerElement, projectId); +} + // ── Element CRUD ── export function getElement(id: string, projectId?: string): ServerElement | undefined { @@ -283,8 +373,9 @@ export function hasElement(id: string, projectId?: string): boolean { 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 normalized = fillNativeFields(element); + const data = JSON.stringify(normalized); + const labelText = extractLabelText(normalized); const sv = incrementSyncVersion(p); const existing = db.prepare( 'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?' @@ -295,19 +386,20 @@ export function setElement(id: string, element: ServerElement, projectId?: strin db.prepare(` 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, sv, id, p); + `).run(normalized.type, data, labelText, now, newVersion, sv, id, p); recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p); - updateFts(id, labelText, element.type); + updateFts(id, labelText, normalized.type); } else { db.prepare(` 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); + `).run(id, p, normalized.type, data, labelText, now, now, sv); recordVersion(id, 1, data, 'create', p); - insertFts(id, labelText, element.type); + insertFts(id, labelText, normalized.type); } + repairContainerBinding(normalized, projectId); return sv; } diff --git a/src/index.ts b/src/index.ts index 112b3fa..1e9c6a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1050,21 +1050,11 @@ const server = new Server( } ); -// Helper function to convert text property to label format for Excalidraw +// Helper function: previously converted text → label format for Excalidraw. +// Now a no-op because the canvas REST API materializes label/text into native +// bound text elements at write time (materializeLabel in server.ts). function convertTextToLabel(element: ServerElement): ServerElement { - const { text, ...rest } = element; - // text === undefined means the caller didn't touch the text field — leave as-is - if (text === undefined) return element; - // Standalone text elements keep text as a direct property - if (element.type === 'text') return element; - // All container/shape/arrow elements: map text → label.text (empty string clears it) - // Default containers to top-center alignment for title/subtitle layout - const isArrow = element.type === 'arrow' || element.type === 'line'; - return { - ...rest, - verticalAlign: (rest as any).verticalAlign ?? (isArrow ? 'middle' : 'top'), - label: { text } - } as ServerElement; + return element; } // Set up request handler for tool calls @@ -2411,7 +2401,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) const boundTextElements: Record[] = []; let indexCounter = 0; - function makeBaseElement(el: any, rest: any): Record { + // Build a set of element IDs that are already native bound-text elements + // (i.e. stored with containerId). For their containers, skip label→text + // generation so we don't create duplicate text elements. + const nativeBoundTextContainerIds = new Set( + urlExportElements + .filter((e: any) => e.type === 'text' && e.containerId) + .map((e: any) => e.containerId as string) + ); + + function makeBaseElement(el: any, rest: any, storedVersion?: number): Record { + const isRoundedShape = el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse'; return { ...rest, angle: rest.angle ?? 0, @@ -2425,16 +2425,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) groupIds: rest.groupIds ?? [], frameId: rest.frameId ?? null, index: rest.index ?? `a${indexCounter++}`, - roundness: rest.roundness ?? ( - el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse' - ? { type: 3 } : null - ), + roundness: rest.roundness ?? (isRoundedShape ? { type: 3 } : null), seed: rest.seed ?? Math.floor(Math.random() * 2147483647), - version: rest.version ?? 1, + version: storedVersion ?? rest.version ?? 1, versionNonce: rest.versionNonce ?? Math.floor(Math.random() * 2147483647), isDeleted: false, boundElements: rest.boundElements ?? null, - updated: Date.now(), + updated: rest.updated ?? Date.now(), link: rest.link ?? null, locked: rest.locked ?? false }; @@ -2449,46 +2446,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) ...rest } = el as any; - const base = makeBaseElement(el, rest); + const base = makeBaseElement(el, rest, _ver); - // Standalone text elements: keep text directly + // Text elements: trust stored native fields, fill gaps only if (el.type === 'text') { - base.text = text ?? ''; - base.originalText = text ?? ''; - base.fontSize = rest.fontSize ?? USER_PREFS.fontSize; - base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily; - base.textAlign = rest.textAlign ?? 'center'; - base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'top' : 'middle'); - base.autoResize = rest.autoResize ?? true; - base.lineHeight = rest.lineHeight ?? 1.25; - base.containerId = rest.containerId ?? null; + base.text = text ?? rest.text ?? ''; + base.originalText = rest.originalText ?? base.text; + base.fontSize = rest.fontSize ?? USER_PREFS.fontSize; + base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily; + base.textAlign = rest.textAlign ?? 'left'; + base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'middle' : 'top'); + base.autoResize = rest.autoResize ?? true; + base.lineHeight = rest.lineHeight ?? 1.25; + base.containerId = rest.containerId ?? null; cleanedExportElements.push(base); continue; } - // Arrows: server already resolved bindings (start/end → startBinding/endBinding + positions) + // Arrows/lines: trust stored fields, fill gaps only if (el.type === 'arrow' || el.type === 'line') { - base.points = rest.points ?? [[0, 0], [100, 0]]; - base.lastCommittedPoint = null; - // Preserve server-resolved bindings with fixedPoint for excalidraw.com - if (rest.startBinding) { - base.startBinding = { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null }; - } else { - base.startBinding = null; - } - if (rest.endBinding) { - base.endBinding = { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null }; - } else { - base.endBinding = null; - } + base.points = rest.points ?? [[0, 0], [100, 0]]; + base.lastCommittedPoint = rest.lastCommittedPoint ?? null; + base.startBinding = rest.startBinding + ? { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null } + : null; + base.endBinding = rest.endBinding + ? { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null } + : null; base.startArrowhead = rest.startArrowhead ?? null; - base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null); - base.elbowed = rest.elbowed ?? false; + base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null); + base.elbowed = rest.elbowed ?? false; } - // Generate bound text element for label on shapes and arrows + // Generate bound text element for label on shapes and arrows. + // Skip if the shape already has a native bound text element stored + // (containerId-based) — generating one here would create a duplicate. const labelText = label?.text || text; - if (labelText) { + if (labelText && !nativeBoundTextContainerIds.has(base.id)) { const textId = `${base.id}-label`; // Add binding reference to parent base.boundElements = [ diff --git a/src/server.ts b/src/server.ts index 5142a22..3f7fd1f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -499,7 +499,7 @@ const ElementSharedFieldsSchema = z.object({ fileId: z.string().optional(), status: z.string().optional(), scale: z.tuple([z.number(), z.number()]).optional(), -}); +}).passthrough(); // preserve all native Excalidraw fields not listed above const CreateElementSchema = ElementSharedFieldsSchema.extend({ id: z.string().optional(), @@ -559,19 +559,24 @@ app.post('/api/elements', async (req: Request, res: Response) => { version: 1 }; - const sv = store.setElement(id, element, projId); + const { container, boundText } = materializeLabel(element); + const sv = store.setElement(container.id, container, projId); + if (boundText) { + store.setElement(boundText.id, boundText, projId); + } const scope = resolveScope(req); const message: ElementCreatedMessage = { type: 'element_created', - element: element + element: container }; message['sync_version'] = sv; const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message); res.json({ success: true, - element: element, + element: container, + boundTextElement: boundText ?? undefined, syncedToCanvas: ackResult.acked, canvasStatus: { connectedBrowsers: ackResult.delivered, @@ -620,19 +625,39 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => { version: (existingElement.version || 0) + 1 }; - const sv = store.setElement(id, updatedElement, projId); + // Find existing bound text ID so we update rather than create a duplicate + const existingBound = (existingElement as any).boundElements as Array<{ id: string; type: string }> | null; + const existingBoundTextId = existingBound?.find((b) => b.type === 'text')?.id; + + const { container, boundText } = materializeLabel(updatedElement, existingBoundTextId); + const sv = store.setElement(id, container, projId); + + if (boundText) { + const existingBT = existingBoundTextId ? store.getElement(existingBoundTextId, projId) : null; + const btToSave = existingBT + ? { + ...existingBT, + text: boundText.text, + originalText: boundText.originalText, + updatedAt: boundText.updatedAt, + version: (existingBT.version || 0) + 1 + } + : boundText; + store.setElement(btToSave.id, btToSave as ServerElement, projId); + } const scope = resolveScope(req); const message: ElementUpdatedMessage = { type: 'element_updated', - element: updatedElement + element: container }; message['sync_version'] = sv; const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message); res.json({ success: true, - element: updatedElement, + element: container, + boundTextElement: boundText ?? undefined, syncedToCanvas: ackResult.acked, canvasStatus: { connectedBrowsers: ackResult.delivered, @@ -847,6 +872,68 @@ function computeEdgePoint( } } +// Helper: materialize a shape's label/text into a native bound text element. +// When a container shape arrives with `label.text` or a `text` field, we create +// a proper Excalidraw bound-text element (containerId ↔ boundElements) instead +// of storing the MCP label format. Returns the cleaned container and the new +// bound-text element (null if nothing to materialize). +function materializeLabel( + element: ServerElement, + existingBoundTextId?: string +): { container: ServerElement; boundText: ServerElement | null } { + const NON_CONTAINER_TYPES = new Set(['text', 'arrow', 'line', 'freedraw', 'image']); + if (NON_CONTAINER_TYPES.has(element.type ?? '')) { + return { container: element, boundText: null }; + } + + // Accept both { label: { text } } (MCP format) and { text } (direct) formats + const labelText: string | undefined = + ((element as any).label as { text?: string } | undefined)?.text ?? + (element.type !== 'text' ? (element as any).text as string | undefined : undefined); + + if (!labelText) { + return { container: element, boundText: null }; + } + + const boundTextId = existingBoundTextId ?? `${element.id}-label`; + + const boundText: ServerElement = { + id: boundTextId, + type: 'text', + x: element.x ?? 0, + y: element.y ?? 0, + width: element.width ?? 200, + height: element.height ?? 80, + text: labelText, + originalText: labelText, + fontSize: 20, + fontFamily: 5, + textAlign: 'center', + verticalAlign: 'middle', + autoResize: true, + lineHeight: 1.25, + containerId: element.id, + strokeColor: (element as any).strokeColor ?? '#1e1e1e', + opacity: (element as any).opacity ?? 100, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + } as unknown as ServerElement; + + // Strip label/text from container, set boundElements + const { label: _label, text: _text, ...containerRest } = element as any; + const existingBound: Array<{ id: string; type: string }> = containerRest.boundElements ?? []; + const alreadyBound = existingBound.some((b) => b.id === boundTextId); + const container: ServerElement = { + ...containerRest, + boundElements: alreadyBound + ? existingBound + : [...existingBound, { id: boundTextId, type: 'text' }], + }; + + return { container, boundText }; +} + // Helper: resolve arrow bindings in a batch function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void { const elementMap = new Map(); @@ -953,7 +1040,9 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => { version: 1 }; - createdElements.push(element); + const { container, boundText } = materializeLabel(element); + createdElements.push(container); + if (boundText) createdElements.push(boundText); }); resolveArrowBindings(createdElements, projId); @@ -1602,6 +1691,35 @@ app.delete('/api/tenants/:id', (req: Request, res: Response) => { } }); +app.post('/api/tenants/batch-delete', destructiveRateLimit, (req: Request, res: Response) => { + try { + const { ids } = req.body as { ids?: string[] }; + if (!Array.isArray(ids) || ids.length === 0) { + res.status(400).json({ success: false, error: 'ids must be a non-empty array' }); + return; + } + if (ids.length > 50) { + res.status(400).json({ success: false, error: 'Cannot delete more than 50 tenants at once' }); + return; + } + const results: { id: string; deleted: boolean; error?: string }[] = []; + for (const id of ids) { + try { + dbDeleteTenant(id); + broadcast({ type: 'tenant_deleted', tenantId: id } as any); + results.push({ id, deleted: true }); + } catch (err) { + results.push({ id, deleted: false, error: (err as Error).message }); + } + } + const deletedCount = results.filter(r => r.deleted).length; + res.json({ success: true, deletedCount, results }); + } catch (error) { + logger.error('Error batch-deleting tenants:', error); + res.status(500).json({ success: false, error: (error as Error).message }); + } +}); + app.get('/api/projects', (req: Request, res: Response) => { try { const projects = dbListProjects(); diff --git a/src/types.ts b/src/types.ts index 0a705b4..8edeefe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export interface ExcalidrawElementBase { customData?: Record | null; boundElements?: readonly ExcalidrawBoundElement[] | null; updated?: number; + index?: string; containerId?: string | null; } diff --git a/tests/backend/native-fields.test.ts b/tests/backend/native-fields.test.ts new file mode 100644 index 0000000..2bab196 --- /dev/null +++ b/tests/backend/native-fields.test.ts @@ -0,0 +1,581 @@ +/** + * Non-regression tests for native Excalidraw field preservation. + * + * Covers: + * - Universal fields populated on every write (seed, versionNonce, index, etc.) + * - Type-specific fields: text, arrow, line, image, freedraw + * - roundness defaults: { type: 3 } for closed shapes, null for others + * - Zod passthrough: unknown native fields not stripped by schema + * - repairContainerBinding: both sides of containerId ↔ boundElements kept in sync + * across all write paths (create, batch-create, update, sync/v2) + * - Export: stored version/updated preserved; no duplicate text on export + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { + initDb, closeDb, + getElement, getAllElements, + setActiveTenant, +} from '../../src/db.js'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +let dbPath: string; +let app: any; + +const UNIVERSAL_FIELDS = [ + 'angle', 'strokeColor', 'backgroundColor', 'fillStyle', + 'strokeWidth', 'strokeStyle', 'roughness', 'opacity', + 'groupIds', 'frameId', 'seed', 'versionNonce', + 'isDeleted', 'updated', 'link', 'locked', 'boundElements', 'index', +]; + +beforeEach(async () => { + dbPath = path.join( + os.tmpdir(), + `excalidraw-native-fields-${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 {} + } +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +async function createElement(body: Record) { + const res = await request(app).post('/api/elements').send(body); + expect(res.status).toBe(200); + return res.body.element as Record; +} + +async function batchCreate(elements: Record[]) { + const res = await request(app).post('/api/elements/batch').send({ elements }); + expect(res.status).toBe(200); + return res.body.elements as Record[]; +} + +async function syncV2(changes: { id: string; action: string; element?: Record }[]) { + const res = await request(app).post('/api/elements/sync/v2').send({ + lastSyncVersion: 0, + changes, + }); + expect(res.status).toBe(200); +} + +async function updateElement(id: string, updates: Record) { + const res = await request(app).put(`/api/elements/${id}`).send({ id, ...updates }); + expect(res.status).toBe(200); + return res.body.element as Record; +} + +function dbEl(id: string): Record { + const el = getElement(id); + expect(el, `Element ${id} not found in DB`).toBeDefined(); + return el as Record; +} + +// ── Universal fields ────────────────────────────────────────────────────────── + +describe('universal fields — filled on create', () => { + it('populates all universal fields with correct default values for a minimal rectangle', async () => { + await createElement({ type: 'rectangle', id: 'u-rect', x: 0, y: 0, width: 100, height: 50 }); + const el = dbEl('u-rect'); + + // Presence + for (const field of UNIVERSAL_FIELDS) { + expect(el, `field "${field}" missing`).toHaveProperty(field); + } + + // Specific default values + expect(el.angle).toBe(0); + expect(el.strokeColor).toBe('#1e1e1e'); + expect(el.backgroundColor).toBe('transparent'); + expect(el.fillStyle).toBe('solid'); + expect(el.strokeWidth).toBe(2); + expect(el.strokeStyle).toBe('solid'); + expect(el.roughness).toBe(1); + expect(el.opacity).toBe(100); + expect(el.groupIds).toEqual([]); + expect(el.frameId).toBeNull(); + expect(el.link).toBeNull(); + expect(el.locked).toBe(false); + expect(el.isDeleted).toBe(false); + expect(el.boundElements).toBeNull(); + expect(typeof el.seed).toBe('number'); + expect(typeof el.versionNonce).toBe('number'); + expect(typeof el.updated).toBe('number'); + expect(typeof el.index).toBe('string'); + expect(el.index.length).toBeGreaterThan(0); + }); + + it('populates universal fields via batch create', async () => { + await batchCreate([{ type: 'rectangle', id: 'u-batch', x: 0, y: 0, width: 100, height: 50 }]); + const el = dbEl('u-batch'); + expect(typeof el.seed).toBe('number'); + expect(typeof el.index).toBe('string'); + expect(el.isDeleted).toBe(false); + }); + + it('populates universal fields via sync/v2 upsert', async () => { + await syncV2([{ + id: 'u-sync', action: 'upsert', + element: { id: 'u-sync', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 }, + }]); + const el = dbEl('u-sync'); + expect(typeof el.seed).toBe('number'); + expect(typeof el.index).toBe('string'); + expect(el.isDeleted).toBe(false); + }); + + it('does not overwrite existing seed/versionNonce/index on update', async () => { + await createElement({ type: 'rectangle', id: 'u-stable', x: 0, y: 0, width: 100, height: 50 }); + const before = dbEl('u-stable'); + await updateElement('u-stable', { x: 50 }); + const after = dbEl('u-stable'); + expect(after.seed).toBe(before.seed); + expect(after.index).toBe(before.index); + }); + + it('preserves caller-supplied seed and index', async () => { + await createElement({ + type: 'rectangle', id: 'u-supplied', x: 0, y: 0, width: 100, height: 50, + seed: 12345678, index: 'aZZ', + }); + const el = dbEl('u-supplied'); + expect(el.seed).toBe(12345678); + expect(el.index).toBe('aZZ'); + }); +}); + +// ── roundness ───────────────────────────────────────────────────────────────── + +describe('roundness defaults', () => { + it.each(['rectangle', 'diamond', 'ellipse'])( + '%s gets roundness { type: 3 } by default', + async (type) => { + await createElement({ type, id: `rnd-${type}`, x: 0, y: 0, width: 100, height: 50 }); + const el = dbEl(`rnd-${type}`); + expect(el.roundness).toEqual({ type: 3 }); + } + ); + + it.each(['arrow', 'line', 'text'])( + '%s gets roundness null by default', + async (type) => { + const extra: Record = type === 'text' ? { text: 'hi' } : {}; + await createElement({ type, id: `rnd-${type}`, x: 0, y: 0, width: 100, height: 50, ...extra }); + const el = dbEl(`rnd-${type}`); + expect(el.roundness).toBeNull(); + } + ); + + it('preserves explicit roundness: null on a rectangle', async () => { + await createElement({ + type: 'rectangle', id: 'rnd-explicit-null', x: 0, y: 0, width: 100, height: 50, + roundness: null, + }); + const el = dbEl('rnd-explicit-null'); + expect(el.roundness).toBeNull(); + }); +}); + +// ── Type-specific: text ─────────────────────────────────────────────────────── + +describe('text element — type-specific fields', () => { + it('fills all type-specific fields with correct default values', async () => { + await createElement({ type: 'text', id: 'txt-1', x: 0, y: 0, text: 'hello' }); + const el = dbEl('txt-1'); + expect(el.text).toBe('hello'); + expect(el.originalText).toBe('hello'); + expect(el.fontSize).toBe(20); + expect(el.fontFamily).toBe(5); + expect(el.textAlign).toBe('left'); + expect(el.verticalAlign).toBe('top'); // no containerId + expect(el.autoResize).toBe(true); + expect(el.lineHeight).toBe(1.25); + expect(el.containerId).toBeNull(); + }); + + it('defaults text to empty string when omitted', async () => { + await createElement({ type: 'text', id: 'txt-empty', x: 0, y: 0 }); + const el = dbEl('txt-empty'); + expect(el.text).toBe(''); + expect(el.originalText).toBe(''); + }); + + it('sets verticalAlign to "middle" when containerId is present', async () => { + await createElement({ type: 'rectangle', id: 'txt-container', x: 0, y: 0, width: 200, height: 80 }); + await createElement({ + type: 'text', id: 'txt-bound', x: 10, y: 30, text: 'bound', + containerId: 'txt-container', + }); + const el = dbEl('txt-bound'); + expect(el.verticalAlign).toBe('middle'); + }); + + it('preserves caller-supplied autoResize: false and lineHeight', async () => { + await createElement({ + type: 'text', id: 'txt-custom', x: 0, y: 0, text: 'hi', + autoResize: false, lineHeight: 1.5, + }); + const el = dbEl('txt-custom'); + expect(el.autoResize).toBe(false); + expect(el.lineHeight).toBe(1.5); + }); +}); + +// ── Type-specific: arrow ────────────────────────────────────────────────────── + +describe('arrow element — type-specific fields', () => { + it('fills points, lastCommittedPoint, startBinding, endBinding, endArrowhead, elbowed', async () => { + await createElement({ type: 'arrow', id: 'arr-1', x: 0, y: 0, width: 100, height: 0 }); + const el = dbEl('arr-1'); + expect(Array.isArray(el.points)).toBe(true); + expect(el.lastCommittedPoint).toBeNull(); + expect(el.startBinding).toBeNull(); + expect(el.endBinding).toBeNull(); + expect(el.endArrowhead).toBe('arrow'); + expect(el.startArrowhead).toBeNull(); + expect(el.elbowed).toBe(false); + }); +}); + +describe('line element — type-specific fields', () => { + it('fills all type-specific fields with correct default values', async () => { + await createElement({ type: 'line', id: 'line-1', x: 0, y: 0, width: 100, height: 0 }); + const el = dbEl('line-1'); + expect(Array.isArray(el.points)).toBe(true); + expect(el.lastCommittedPoint).toBeNull(); + expect(el.startBinding).toBeNull(); + expect(el.endBinding).toBeNull(); + expect(el.startArrowhead).toBeNull(); + expect(el.endArrowhead).toBeNull(); // null for line, 'arrow' only for arrow type + expect(el.elbowed).toBe(false); + }); +}); + +// ── Type-specific: image ────────────────────────────────────────────────────── + +describe('image element — type-specific fields', () => { + it('fills status and scale', async () => { + await createElement({ type: 'image', id: 'img-1', x: 0, y: 0, width: 100, height: 100 }); + const el = dbEl('img-1'); + expect(el.status).toBe('pending'); + expect(el.scale).toEqual([1, 1]); + }); +}); + +// ── Type-specific: freedraw ─────────────────────────────────────────────────── + +describe('freedraw element — type-specific fields', () => { + it('fills points, pressures, simulatePressure, lastCommittedPoint', async () => { + await createElement({ type: 'freedraw', id: 'fd-1', x: 0, y: 0, width: 10, height: 10 }); + const el = dbEl('fd-1'); + expect(Array.isArray(el.points)).toBe(true); + expect(Array.isArray(el.pressures)).toBe(true); + expect(el.simulatePressure).toBe(true); + expect(el.lastCommittedPoint).toBeNull(); + }); +}); + +// ── Zod passthrough ─────────────────────────────────────────────────────────── + +describe('Zod schema passthrough — unknown native fields preserved', () => { + it('preserves extra Excalidraw fields not in schema (e.g. customData)', async () => { + await createElement({ + type: 'rectangle', id: 'pass-1', x: 0, y: 0, width: 100, height: 50, + customData: { myKey: 'myValue' }, + }); + const el = dbEl('pass-1'); + expect(el.customData).toEqual({ myKey: 'myValue' }); + }); + + it('preserves autoResize passed to a non-text element without stripping', async () => { + // autoResize is not in the shared schema explicitly — should pass through + await createElement({ + type: 'rectangle', id: 'pass-2', x: 0, y: 0, width: 100, height: 50, + autoResize: true, + }); + const el = dbEl('pass-2'); + expect(el.autoResize).toBe(true); + }); +}); + +// ── repairContainerBinding ──────────────────────────────────────────────────── + +describe('repairContainerBinding — bidirectional binding enforced on all write paths', () => { + it('POST /api/elements: text with containerId repairs container.boundElements', async () => { + await createElement({ type: 'rectangle', id: 'rb-box', x: 0, y: 0, width: 200, height: 80 }); + await createElement({ + type: 'text', id: 'rb-txt', x: 10, y: 30, text: 'hi', + containerId: 'rb-box', + }); + const box = dbEl('rb-box'); + expect(Array.isArray(box.boundElements)).toBe(true); + expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-txt')).toBe(true); + }); + + it('batch create: repairs binding for all text elements in the batch', async () => { + await batchCreate([ + { id: 'rb-b-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { id: 'rb-b-txt', type: 'text', x: 10, y: 30, text: 'hi', containerId: 'rb-b-box' }, + ]); + const box = dbEl('rb-b-box'); + expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-b-txt')).toBe(true); + }); + + it('sync/v2: repairs binding when text with containerId is upserted', async () => { + await syncV2([ + { id: 'rb-s-box', action: 'upsert', + element: { id: 'rb-s-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 } }, + { id: 'rb-s-txt', action: 'upsert', + element: { id: 'rb-s-txt', type: 'text', x: 10, y: 30, text: 'hi', containerId: 'rb-s-box' } }, + ]); + const box = dbEl('rb-s-box'); + expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-s-txt')).toBe(true); + }); + + it('PUT /api/elements: repairs binding when containerId is added via update', async () => { + await createElement({ type: 'rectangle', id: 'rb-u-box', x: 0, y: 0, width: 200, height: 80 }); + await createElement({ type: 'text', id: 'rb-u-txt', x: 10, y: 30, text: 'hi' }); + // containerId added via update + await updateElement('rb-u-txt', { containerId: 'rb-u-box' }); + const box = dbEl('rb-u-box'); + expect((box.boundElements as any[]).some((b: any) => b.id === 'rb-u-txt')).toBe(true); + }); + + it('does not duplicate boundElements entry if already present', async () => { + await createElement({ type: 'rectangle', id: 'rb-dup-box', x: 0, y: 0, width: 200, height: 80 }); + await createElement({ + type: 'text', id: 'rb-dup-txt', x: 10, y: 30, text: 'hi', + containerId: 'rb-dup-box', + }); + // Update the text again — binding should not be duplicated + await updateElement('rb-dup-txt', { x: 20 }); + const box = dbEl('rb-dup-box'); + const refs = (box.boundElements as any[]).filter((b: any) => b.id === 'rb-dup-txt'); + expect(refs.length).toBe(1); + }); + + it('text without containerId does not touch any container', async () => { + await createElement({ type: 'rectangle', id: 'rb-free-box', x: 0, y: 0, width: 200, height: 80 }); + await createElement({ type: 'text', id: 'rb-free-txt', x: 10, y: 30, text: 'standalone' }); + const box = dbEl('rb-free-box'); + // boundElements should remain null / empty — not modified + const refs = (box.boundElements as any[] | null) ?? []; + expect(refs.filter((b: any) => b.id === 'rb-free-txt').length).toBe(0); + }); +}); + +// ── Export: version and updated preserved ──────────────────────────────────── + +describe('version and updated preserved in DB (export source)', () => { + it('stores the correct version after updates', async () => { + await createElement({ type: 'rectangle', id: 'ver-1', x: 0, y: 0, width: 100, height: 50 }); + await updateElement('ver-1', { x: 10 }); + await updateElement('ver-1', { x: 20 }); + const el = dbEl('ver-1'); + expect(el.version).toBeGreaterThanOrEqual(2); + }); + + it('stores a numeric updated timestamp', async () => { + const before = Date.now(); + await createElement({ type: 'rectangle', id: 'upd-1', x: 0, y: 0, width: 100, height: 50 }); + const after = Date.now(); + const el = dbEl('upd-1'); + expect(typeof el.updated).toBe('number'); + expect(el.updated).toBeGreaterThanOrEqual(before); + expect(el.updated).toBeLessThanOrEqual(after + 5); + }); + + it('preserves caller-supplied updated timestamp', async () => { + const ts = 1700000000000; + await createElement({ + type: 'rectangle', id: 'upd-2', x: 0, y: 0, width: 100, height: 50, + updated: ts, + }); + const el = dbEl('upd-2'); + expect(el.updated).toBe(ts); + }); +}); + +// ── No duplicate bound text on export ──────────────────────────────────────── + +describe('GET /api/elements — no duplicate text from native bound elements', () => { + it('returns both container and its native bound text without duplication', async () => { + await batchCreate([ + { id: 'exp-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { + id: 'exp-txt', type: 'text', x: 10, y: 30, text: 'label', + containerId: 'exp-box', + }, + ]); + + const res = await request(app).get('/api/elements'); + expect(res.status).toBe(200); + const elements: Record[] = res.body.elements; + + const textEls = elements.filter(e => e.type === 'text'); + const labelEls = textEls.filter(e => e.id === 'exp-txt' || e.id === 'exp-box-label'); + // Only one text element should exist — the native one, not a generated duplicate + expect(labelEls.length).toBe(1); + expect(labelEls[0].id).toBe('exp-txt'); + }); +}); + +// ── Label materialization ───────────────────────────────────────────────────── + +describe('materializeLabel — POST /api/elements with label.text or text on a shape', () => { + function dbEl(id: string) { + return getElement(id) as Record; + } + + it('stores a native bound text element when shape is created with label.text', async () => { + const res = await request(app).post('/api/elements').send({ + type: 'rectangle', id: 'ml-rect', x: 0, y: 0, width: 200, height: 80, + label: { text: 'Hello' }, + }); + expect(res.status).toBe(200); + + // Container must NOT have label field + const container = dbEl('ml-rect'); + expect(container.label).toBeUndefined(); + + // Bound text must exist in DB + const bt = dbEl('ml-rect-label'); + expect(bt).toBeTruthy(); + expect(bt.type).toBe('text'); + expect(bt.text).toBe('Hello'); + expect(bt.containerId).toBe('ml-rect'); + }); + + it('stores a native bound text element when shape is created with text field', async () => { + await request(app).post('/api/elements').send({ + type: 'ellipse', id: 'ml-ell', x: 0, y: 0, width: 100, height: 60, + text: 'World', + }); + + const container = dbEl('ml-ell'); + expect((container as any).text).toBeUndefined(); + + const bt = dbEl('ml-ell-label'); + expect(bt).toBeTruthy(); + expect(bt.text).toBe('World'); + expect(bt.containerId).toBe('ml-ell'); + }); + + it('container boundElements includes reference to the bound text', async () => { + await request(app).post('/api/elements').send({ + type: 'diamond', id: 'ml-dia', x: 0, y: 0, width: 120, height: 80, + label: { text: 'Decision' }, + }); + + const container = dbEl('ml-dia'); + const bound = container.boundElements as Array<{ id: string; type: string }>; + expect(Array.isArray(bound)).toBe(true); + expect(bound.some(b => b.id === 'ml-dia-label' && b.type === 'text')).toBe(true); + }); + + it('bound text has correct native fields (containerId, verticalAlign, autoResize, lineHeight)', async () => { + await request(app).post('/api/elements').send({ + type: 'rectangle', id: 'ml-fields', x: 0, y: 0, width: 200, height: 80, + label: { text: 'Check fields' }, + }); + + const bt = dbEl('ml-fields-label'); + expect(bt.containerId).toBe('ml-fields'); + expect(bt.verticalAlign).toBe('middle'); + expect(bt.autoResize).toBe(true); + expect(bt.lineHeight).toBe(1.25); + expect(bt.textAlign).toBe('center'); + }); + + it('response includes boundTextElement in the API response', async () => { + const res = await request(app).post('/api/elements').send({ + type: 'rectangle', id: 'ml-resp', x: 0, y: 0, width: 200, height: 80, + label: { text: 'Response test' }, + }); + expect(res.status).toBe(200); + expect(res.body.boundTextElement).toBeTruthy(); + expect(res.body.boundTextElement.text).toBe('Response test'); + }); + + it('shapes without text are not affected (no extra element created)', async () => { + await request(app).post('/api/elements').send({ + type: 'rectangle', id: 'ml-notxt', x: 0, y: 0, width: 100, height: 50, + }); + + const container = dbEl('ml-notxt'); + expect(container).toBeTruthy(); + // No synthetic bound text should be stored + expect(dbEl('ml-notxt-label')).toBeFalsy(); + }); + + it('text elements themselves are not materialized (only shapes)', async () => { + await request(app).post('/api/elements').send({ + type: 'text', id: 'ml-txt-el', x: 0, y: 0, width: 100, height: 40, + text: 'standalone', + }); + + const el = dbEl('ml-txt-el'); + expect(el.type).toBe('text'); + // text field preserved on text elements + expect(el.text).toBe('standalone'); + }); + + it('batch create: materializes label for all shapes in the batch', async () => { + const res = await request(app).post('/api/elements/batch').send({ + elements: [ + { id: 'ml-b1', type: 'rectangle', x: 0, y: 0, width: 200, height: 80, label: { text: 'Box A' } }, + { id: 'ml-b2', type: 'ellipse', x: 300, y: 0, width: 150, height: 80, label: { text: 'Box B' } }, + ], + }); + expect(res.status).toBe(200); + + expect(dbEl('ml-b1-label').text).toBe('Box A'); + expect(dbEl('ml-b2-label').text).toBe('Box B'); + expect(dbEl('ml-b1').label).toBeUndefined(); + expect(dbEl('ml-b2').label).toBeUndefined(); + }); + + it('PUT /api/elements: updating label.text updates the bound text element', async () => { + await request(app).post('/api/elements').send({ + type: 'rectangle', id: 'ml-upd', x: 0, y: 0, width: 200, height: 80, + label: { text: 'Original' }, + }); + + const res = await request(app).put('/api/elements/ml-upd').send({ + id: 'ml-upd', label: { text: 'Updated' }, + }); + expect(res.status).toBe(200); + + const bt = dbEl('ml-upd-label'); + expect(bt.text).toBe('Updated'); + expect(bt.originalText).toBe('Updated'); + }); + + it('PUT /api/elements: updating label does not create a duplicate bound text', async () => { + await request(app).post('/api/elements').send({ + type: 'rectangle', id: 'ml-nodup', x: 0, y: 0, width: 200, height: 80, + label: { text: 'First' }, + }); + await request(app).put('/api/elements/ml-nodup').send({ + id: 'ml-nodup', label: { text: 'Second' }, + }); + + const container = dbEl('ml-nodup'); + const bound = container.boundElements as Array<{ id: string; type: string }>; + const textRefs = bound.filter(b => b.type === 'text'); + expect(textRefs.length).toBe(1); + }); +}); diff --git a/tests/e2e/native-fields.spec.ts b/tests/e2e/native-fields.spec.ts new file mode 100644 index 0000000..1c0bd75 --- /dev/null +++ b/tests/e2e/native-fields.spec.ts @@ -0,0 +1,297 @@ +/** + * E2E non-regression tests for native Excalidraw field preservation. + * + * These tests cover scenarios that only manifest with a live browser + WebSocket + * sync cycle — specifically, that the frontend's normalizeForBackend function + * does not strip or corrupt native fields when elements are synced back to the + * server after the page connects. + * + * Coverage: + * - Native fields (seed, versionNonce, index, roundness) preserved through + * a frontend sync round-trip + * - Container binding (containerId ↔ boundElements) survives page load + sync + * - No duplicate text elements after frontend sync when native bound text exists + * - WebSocket initial_elements delivers complete native fields to the browser + */ + +import { test, expect, type Page } from '@playwright/test'; + +const API = 'http://127.0.0.1:3100'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function resetCanvas(request: any): Promise { + await request.delete(`${API}/api/elements/clear?confirm=true`); +} + +async function waitForConnected(page: Page): Promise { + await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 }); +} + +async function getApiElement(request: any, id: string): Promise> { + const res = await request.get(`${API}/api/elements/${id}`); + expect(res.ok()).toBe(true); + return (await res.json()).element; +} + +async function getAllApiElements(request: any): Promise[]> { + const res = await request.get(`${API}/api/elements`); + expect(res.ok()).toBe(true); + return (await res.json()).elements; +} + +async function triggerSync(page: Page): Promise { + await page.getByRole('button', { name: /^Sync$/ }).click(); + await page.waitForTimeout(600); +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +test.beforeEach(async ({ request }) => { + await resetCanvas(request); +}); + +// ── Native fields survive frontend sync round-trip ──────────────────────────── + +test.describe('native fields — preserved through frontend sync round-trip', () => { + test('seed, versionNonce, index unchanged after page connects and syncs', async ({ page, request }) => { + // Create element with explicit native fields via API + await request.post(`${API}/api/elements`, { + data: { + id: 'nf-stable', + type: 'rectangle', + x: 100, y: 100, width: 200, height: 80, + seed: 98765432, + index: 'aFixedIndex', + }, + }); + + const before = await getApiElement(request, 'nf-stable'); + expect(before.seed).toBe(98765432); + expect(before.index).toBe('aFixedIndex'); + + await page.goto('/'); + await waitForConnected(page); + await triggerSync(page); + + const after = await getApiElement(request, 'nf-stable'); + expect(after.seed).toBe(before.seed); + expect(after.index).toBe(before.index); + expect(after.versionNonce).toBeDefined(); + }); + + test('roundness preserved through page load + sync', async ({ page, request }) => { + await request.post(`${API}/api/elements`, { + data: { + id: 'nf-roundness', + type: 'rectangle', + x: 100, y: 100, width: 200, height: 80, + roundness: { type: 3 }, + }, + }); + + await page.goto('/'); + await waitForConnected(page); + await triggerSync(page); + + const after = await getApiElement(request, 'nf-roundness'); + expect(after.roundness).toMatchObject({ type: 3 }); + }); + + test('strokeColor, backgroundColor, opacity preserved through sync', async ({ page, request }) => { + await request.post(`${API}/api/elements`, { + data: { + id: 'nf-style', + type: 'rectangle', + x: 0, y: 0, width: 150, height: 60, + strokeColor: '#e03131', + backgroundColor: '#ffc9c9', + opacity: 75, + }, + }); + + await page.goto('/'); + await waitForConnected(page); + await triggerSync(page); + + const after = await getApiElement(request, 'nf-style'); + expect(after.strokeColor).toBe('#e03131'); + expect(after.backgroundColor).toBe('#ffc9c9'); + expect(after.opacity).toBe(75); + }); +}); + +// ── Container binding survives frontend sync ────────────────────────────────── + +test.describe('container binding — survives page load and sync', () => { + test('containerId and boundElements intact after page connects', async ({ page, request }) => { + await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { id: 'cb-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { id: 'cb-txt', type: 'text', x: 10, y: 30, text: 'label', containerId: 'cb-box' }, + ], + }, + }); + + // Verify DB binding is correct before page load + const boxBefore = await getApiElement(request, 'cb-box'); + expect((boxBefore.boundElements ?? []).some((b: any) => b.id === 'cb-txt')).toBe(true); + + await page.goto('/'); + await waitForConnected(page); + await page.waitForTimeout(800); + + // Binding must survive the page connecting (which triggers initial sync) + const boxAfter = await getApiElement(request, 'cb-box'); + const txtAfter = await getApiElement(request, 'cb-txt'); + + expect((boxAfter.boundElements ?? []).some((b: any) => b.id === 'cb-txt')).toBe(true); + expect(txtAfter.containerId).toBe('cb-box'); + }); + + test('binding intact after explicit sync button press', async ({ page, request }) => { + await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { id: 'cb-sync-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { id: 'cb-sync-txt', type: 'text', x: 10, y: 30, text: 'synced', containerId: 'cb-sync-box' }, + ], + }, + }); + + await page.goto('/'); + await waitForConnected(page); + await triggerSync(page); + + const box = await getApiElement(request, 'cb-sync-box'); + const txt = await getApiElement(request, 'cb-sync-txt'); + + expect((box.boundElements ?? []).some((b: any) => b.id === 'cb-sync-txt')).toBe(true); + expect(txt.containerId).toBe('cb-sync-box'); + }); + + test('binding survives page reload', async ({ page, request }) => { + await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { id: 'cb-rel-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { id: 'cb-rel-txt', type: 'text', x: 10, y: 30, text: 'reload', containerId: 'cb-rel-box' }, + ], + }, + }); + + await page.goto('/'); + await waitForConnected(page); + await page.reload(); + await waitForConnected(page); + await page.waitForTimeout(500); + + const box = await getApiElement(request, 'cb-rel-box'); + expect((box.boundElements ?? []).some((b: any) => b.id === 'cb-rel-txt')).toBe(true); + }); +}); + +// ── No duplicate text elements ──────────────────────────────────────────────── + +test.describe('no duplicate text — native bound text not duplicated by sync', () => { + test('only one text element exists after page connects when native binding is used', async ({ page, request }) => { + await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { id: 'dup-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { id: 'dup-txt', type: 'text', x: 10, y: 30, text: 'unique', containerId: 'dup-box' }, + ], + }, + }); + + await page.goto('/'); + await waitForConnected(page); + await triggerSync(page); + + const elements = await getAllApiElements(request); + const textEls = elements.filter(e => e.type === 'text'); + + // Only the native text element should exist — no generated duplicate + expect(textEls.length).toBe(1); + expect(textEls[0].id).toBe('dup-txt'); + expect(textEls[0].containerId).toBe('dup-box'); + }); + + test('text content not duplicated across multiple syncs', async ({ page, request }) => { + await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { id: 'multi-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { id: 'multi-txt', type: 'text', x: 10, y: 30, text: 'once', containerId: 'multi-box' }, + ], + }, + }); + + await page.goto('/'); + await waitForConnected(page); + // Sync multiple times + await triggerSync(page); + await triggerSync(page); + + const elements = await getAllApiElements(request); + const textEls = elements.filter(e => e.type === 'text'); + expect(textEls.length).toBe(1); + }); +}); + +// ── WebSocket initial_elements delivers complete native fields ───────────────── + +test.describe('WebSocket initial_elements — complete native fields delivered', () => { + test('elements served on connect have seed, index, versionNonce, boundElements', async ({ page, request }) => { + await request.post(`${API}/api/elements/batch`, { + data: { + elements: [ + { id: 'ws-box', type: 'rectangle', x: 0, y: 0, width: 200, height: 80 }, + { id: 'ws-txt', type: 'text', x: 10, y: 30, text: 'ws', containerId: 'ws-box' }, + ], + }, + }); + + // Intercept the initial_elements WS message via addInitScript (runs before page JS) + await page.addInitScript(() => { + const NativeWS = window.WebSocket; + (window as any).__initialElements = null; + const Wrapped = function(this: any, url: string | URL, protocols?: string | string[]) { + const ws = protocols !== undefined ? new NativeWS(url, protocols) : new NativeWS(url); + ws.addEventListener('message', (event) => { + try { + const msg = JSON.parse(event.data as string); + if (msg.type === 'initial_elements') { + (window as any).__initialElements = msg.elements ?? []; + } + } catch {} + }); + return ws; + } as any; + Wrapped.prototype = NativeWS.prototype; + Object.assign(Wrapped, NativeWS); + window.WebSocket = Wrapped; + }); + + await page.goto('/'); + await waitForConnected(page); + await page.waitForTimeout(300); + + const wsElements: any[] = await page.evaluate(() => (window as any).__initialElements ?? []); + + // If WS capture worked, assert on WS payload; otherwise fall back to API + const source = wsElements.length > 0 ? wsElements : await getAllApiElements(request); + + const box = source.find((e: any) => e.id === 'ws-box'); + const txt = source.find((e: any) => e.id === 'ws-txt'); + + expect(box).toBeDefined(); + expect(txt).toBeDefined(); + expect(typeof box.seed).toBe('number'); + expect(typeof box.index).toBe('string'); + expect(typeof box.versionNonce).toBe('number'); + expect((box.boundElements ?? []).some((b: any) => b.id === 'ws-txt')).toBe(true); + expect(txt.containerId).toBe('ws-box'); + }); +}); diff --git a/tests/e2e/phase2-regressions.spec.ts b/tests/e2e/phase2-regressions.spec.ts index 7602e2f..ba7a282 100644 --- a/tests/e2e/phase2-regressions.spec.ts +++ b/tests/e2e/phase2-regressions.spec.ts @@ -46,7 +46,6 @@ test.describe('Phase 2 regressions', () => { y: number; width: number; height: number; - label?: { text?: string }; }; await page.reload(); @@ -60,14 +59,18 @@ test.describe('Phase 2 regressions', () => { y: number; width: number; height: number; - label?: { text?: string }; }; expect(afterReload.x).toBe(initial.x); expect(afterReload.y).toBe(initial.y); expect(afterReload.width).toBe(initial.width); expect(afterReload.height).toBe(initial.height); - expect(afterReload.label?.text).toBe('Stable Label'); + // label is materialized into a native bound text element on create; + // verify the bound text element persists with correct text after reload + const btRes = await request.get(`${API}/api/elements/pos-stable-1-label`); + expect(btRes.ok()).toBe(true); + const bt = (await btRes.json()).element as { text: string }; + expect(bt.text).toBe('Stable Label'); }); test('new container arrival auto-injects title and subtitle text', async ({ page, request }) => { diff --git a/tests/e2e/sync-flows.spec.ts b/tests/e2e/sync-flows.spec.ts index 0e8475d..6d0db6c 100644 --- a/tests/e2e/sync-flows.spec.ts +++ b/tests/e2e/sync-flows.spec.ts @@ -510,7 +510,9 @@ test.describe('Search E2E', () => { 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); + // label is materialized into a native bound text element (id: 'fts-el-label') + // so FTS matches the bound text element; the container id or bound text id are both valid + expect(body.elements.some((e: any) => e.id === 'fts-el' || e.id === 'fts-el-label')).toBe(true); }); });