feat(canvas): native field preservation, label materialization, batch workspace delete (#12)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxime Roy (new.blacc)
2026-04-06 21:12:01 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent d14d767c75
commit 9846e0ba0f
14 changed files with 1388 additions and 74 deletions
+22
View File
@@ -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
+2 -1
View File
@@ -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: &lt;name&gt;" badge. Click it to open a dropdown with all known workspaces, complete with search.
3. **UI switcher**: The canvas UI shows a "Workspace: &lt;name&gt;" 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 |
+107
View File
@@ -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;
+98 -2
View File
@@ -134,6 +134,9 @@ function App(): JSX.Element {
const newProjectInputRef = useRef<HTMLInputElement | null>(null)
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null)
const [confirmDeleteTenantId, setConfirmDeleteTenantId] = useState<string | null>(null)
const [batchSelectMode, setBatchSelectMode] = useState<boolean>(false)
const [selectedTenantIds, setSelectedTenantIds] = useState<Set<string>>(new Set())
const [confirmBatchDelete, setConfirmBatchDelete] = useState<boolean>(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<void> => {
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 (
<div className="menu-overlay" onClick={() => setMenuOpen(false)}>
<div className="menu-overlay" onClick={() => { setMenuOpen(false); setBatchSelectMode(false); setSelectedTenantIds(new Set()); setConfirmBatchDelete(false) }}>
<div className="menu-panel" onClick={e => e.stopPropagation()}>
<div className="menu-header">Workspaces</div>
<div className="menu-header">
<span>Workspaces</span>
<button
className={`batch-mode-toggle ${batchSelectMode ? 'batch-mode-active' : ''}`}
title={batchSelectMode ? 'Exit selection mode' : 'Select workspaces to delete'}
onClick={() => {
setBatchSelectMode(prev => !prev)
setSelectedTenantIds(new Set())
setConfirmBatchDelete(false)
}}
>
{batchSelectMode ? 'Done' : 'Select'}
</button>
</div>
{batchSelectMode && selectableFiltered.length > 0 && (
<div className="batch-actions-bar">
<button
className="batch-action-btn"
onClick={() => setSelectedTenantIds(new Set(selectableFiltered.map(t => t.id)))}
>Select All</button>
<button
className="batch-action-btn"
onClick={() => setSelectedTenantIds(new Set())}
>Unselect All</button>
<span className="batch-count">{selectedTenantIds.size} selected</span>
</div>
)}
<div className="menu-search-wrap">
<input
ref={searchInputRef}
@@ -1592,6 +1654,25 @@ function App(): JSX.Element {
<button className="project-delete-yes" onClick={() => deleteTenantUI(t.id)}>Delete</button>
<button className="project-delete-no" onClick={() => setConfirmDeleteTenantId(null)}>Cancel</button>
</div>
) : batchSelectMode ? (
<label className={`menu-item batch-item ${activeTenant?.id === t.id ? 'menu-item-active batch-item-disabled' : ''}`}>
<input
type="checkbox"
className="batch-checkbox"
disabled={activeTenant?.id === t.id}
checked={selectedTenantIds.has(t.id)}
onChange={() => toggleTenantSelection(t.id)}
/>
<span className="batch-item-content">
<span className="menu-item-name">{t.name}</span>
<span className="menu-item-path" title={t.workspace_path}>
{t.workspace_path.length > 40
? '...' + t.workspace_path.slice(-37)
: t.workspace_path}
</span>
</span>
{activeTenant?.id === t.id && <span className="batch-active-label">active</span>}
</label>
) : (
<>
<button
@@ -1619,6 +1700,21 @@ function App(): JSX.Element {
))}
{filtered.length === 0 && <div className="menu-empty">No matching workspaces</div>}
</div>
{batchSelectMode && selectedTenantIds.size > 0 && (
<div className="batch-delete-bar">
{confirmBatchDelete ? (
<div className="batch-delete-confirm">
<span className="batch-delete-msg">Delete {selectedTenantIds.size} workspace{selectedTenantIds.size !== 1 ? 's' : ''}?</span>
<button className="project-delete-yes" onClick={batchDeleteTenants}>Delete</button>
<button className="project-delete-no" onClick={() => setConfirmBatchDelete(false)}>Cancel</button>
</div>
) : (
<button className="batch-delete-btn" onClick={() => setConfirmBatchDelete(true)}>
Delete {selectedTenantIds.size} workspace{selectedTenantIds.size !== 1 ? 's' : ''}
</button>
)}
</div>
)}
</div>
</div>
)
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -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",
+98 -6
View File
@@ -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;
}
+36 -42
View File
@@ -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<string, any>[] = [];
let indexCounter = 0;
function makeBaseElement(el: any, rest: any): Record<string, any> {
// 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<string>(
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<string, any> {
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,16 +2446,16 @@ 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.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 ?? 'center';
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'top' : 'middle');
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;
@@ -2466,29 +2463,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
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.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;
}
// 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 = [
+126 -8
View File
@@ -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<string, ServerElement>();
@@ -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();
+1
View File
@@ -27,6 +27,7 @@ export interface ExcalidrawElementBase {
customData?: Record<string, any> | null;
boundElements?: readonly ExcalidrawBoundElement[] | null;
updated?: number;
index?: string;
containerId?: string | null;
}
+581
View File
@@ -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<string, any>) {
const res = await request(app).post('/api/elements').send(body);
expect(res.status).toBe(200);
return res.body.element as Record<string, any>;
}
async function batchCreate(elements: Record<string, any>[]) {
const res = await request(app).post('/api/elements/batch').send({ elements });
expect(res.status).toBe(200);
return res.body.elements as Record<string, any>[];
}
async function syncV2(changes: { id: string; action: string; element?: Record<string, any> }[]) {
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<string, any>) {
const res = await request(app).put(`/api/elements/${id}`).send({ id, ...updates });
expect(res.status).toBe(200);
return res.body.element as Record<string, any>;
}
function dbEl(id: string): Record<string, any> {
const el = getElement(id);
expect(el, `Element ${id} not found in DB`).toBeDefined();
return el as Record<string, any>;
}
// ── 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<string, any> = 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<string, any>[] = 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<string, any>;
}
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);
});
});
+297
View File
@@ -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<void> {
await request.delete(`${API}/api/elements/clear?confirm=true`);
}
async function waitForConnected(page: Page): Promise<void> {
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
}
async function getApiElement(request: any, id: string): Promise<Record<string, any>> {
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<Record<string, any>[]> {
const res = await request.get(`${API}/api/elements`);
expect(res.ok()).toBe(true);
return (await res.json()).elements;
}
async function triggerSync(page: Page): Promise<void> {
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');
});
});
+6 -3
View File
@@ -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 }) => {
+3 -1
View File
@@ -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);
});
});