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:
co-authored by
Claude Sonnet 4.6
parent
d14d767c75
commit
9846e0ba0f
@@ -5,6 +5,28 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.0.6] - 2026-04-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
4. **Multi-instance safe**: SQLite WAL mode with `busy_timeout = 5000ms` handles concurrent access from multiple client instances.
|
||||||
|
|
||||||
### Projects within a tenant
|
### 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) |
|
| DELETE | `/api/projects/:id` | Delete a project (cascades elements) |
|
||||||
| GET | `/api/tenants` | List all tenants |
|
| GET | `/api/tenants` | List all tenants |
|
||||||
| DELETE | `/api/tenants/:id` | Delete a tenant (cascades projects and elements) |
|
| 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 |
|
| GET | `/api/tenant/active` | Get the active tenant |
|
||||||
| PUT | `/api/tenant/active` | Set the active tenant |
|
| PUT | `/api/tenant/active` | Set the active tenant |
|
||||||
| GET | `/api/settings/:key` | Read a setting |
|
| GET | `/api/settings/:key` | Read a setting |
|
||||||
|
|||||||
@@ -402,6 +402,9 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.menu-search-wrap {
|
.menu-search-wrap {
|
||||||
padding: 8px 10px 4px;
|
padding: 8px 10px 4px;
|
||||||
@@ -573,6 +576,110 @@
|
|||||||
}
|
}
|
||||||
.project-delete-no:hover { background: #dee2e6; }
|
.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 */
|
/* Clear canvas confirmation dialog */
|
||||||
.confirm-dialog {
|
.confirm-dialog {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+98
-2
@@ -134,6 +134,9 @@ function App(): JSX.Element {
|
|||||||
const newProjectInputRef = useRef<HTMLInputElement | null>(null)
|
const newProjectInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null)
|
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null)
|
||||||
const [confirmDeleteTenantId, setConfirmDeleteTenantId] = 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
|
// Keep refs in sync so closures (WebSocket handlers) always see latest values
|
||||||
useEffect(() => {
|
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> => {
|
const syncToBackend = async (): Promise<void> => {
|
||||||
if (!excalidrawAPI || isSyncingRef.current) return
|
if (!excalidrawAPI || isSyncingRef.current) return
|
||||||
|
|
||||||
@@ -1569,10 +1604,37 @@ function App(): JSX.Element {
|
|||||||
const filtered = q
|
const filtered = q
|
||||||
? tenantList.filter(t => t.name.toLowerCase().includes(q) || t.workspace_path.toLowerCase().includes(q))
|
? tenantList.filter(t => t.name.toLowerCase().includes(q) || t.workspace_path.toLowerCase().includes(q))
|
||||||
: tenantList
|
: tenantList
|
||||||
|
const selectableFiltered = filtered.filter(t => t.id !== activeTenant?.id)
|
||||||
return (
|
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-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">
|
<div className="menu-search-wrap">
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
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-yes" onClick={() => deleteTenantUI(t.id)}>Delete</button>
|
||||||
<button className="project-delete-no" onClick={() => setConfirmDeleteTenantId(null)}>Cancel</button>
|
<button className="project-delete-no" onClick={() => setConfirmDeleteTenantId(null)}>Cancel</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
@@ -1619,6 +1700,21 @@ function App(): JSX.Element {
|
|||||||
))}
|
))}
|
||||||
{filtered.length === 0 && <div className="menu-empty">No matching workspaces</div>}
|
{filtered.length === 0 && <div className="menu-empty">No matching workspaces</div>}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "excalidraw-mcp-sentinel",
|
"name": "excalidraw-mcp-sentinel",
|
||||||
"version": "1.0.3",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "excalidraw-mcp-sentinel",
|
"name": "excalidraw-mcp-sentinel",
|
||||||
"version": "1.0.3",
|
"version": "1.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "excalidraw-mcp-sentinel",
|
"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",
|
"description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -264,6 +264,96 @@ export function getDefaultProjectForTenant(tenantId: string): string {
|
|||||||
return id;
|
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 ──
|
// ── Element CRUD ──
|
||||||
|
|
||||||
export function getElement(id: string, projectId?: string): ServerElement | undefined {
|
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 {
|
export function setElement(id: string, element: ServerElement, projectId?: string): number {
|
||||||
const p = pid(projectId);
|
const p = pid(projectId);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const data = JSON.stringify(element);
|
const normalized = fillNativeFields(element);
|
||||||
const labelText = extractLabelText(element);
|
const data = JSON.stringify(normalized);
|
||||||
|
const labelText = extractLabelText(normalized);
|
||||||
const sv = incrementSyncVersion(p);
|
const sv = incrementSyncVersion(p);
|
||||||
const existing = db.prepare(
|
const existing = db.prepare(
|
||||||
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
|
'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(`
|
db.prepare(`
|
||||||
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0, sync_version = ?
|
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0, sync_version = ?
|
||||||
WHERE id = ? AND project_id = ?
|
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);
|
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
|
||||||
updateFts(id, labelText, element.type);
|
updateFts(id, labelText, normalized.type);
|
||||||
} else {
|
} else {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, sync_version)
|
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, sync_version)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
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);
|
recordVersion(id, 1, data, 'create', p);
|
||||||
insertFts(id, labelText, element.type);
|
insertFts(id, labelText, normalized.type);
|
||||||
}
|
}
|
||||||
|
repairContainerBinding(normalized, projectId);
|
||||||
return sv;
|
return sv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-50
@@ -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 {
|
function convertTextToLabel(element: ServerElement): ServerElement {
|
||||||
const { text, ...rest } = element;
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up request handler for tool calls
|
// Set up request handler for tool calls
|
||||||
@@ -2411,7 +2401,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
|||||||
const boundTextElements: Record<string, any>[] = [];
|
const boundTextElements: Record<string, any>[] = [];
|
||||||
let indexCounter = 0;
|
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 {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
angle: rest.angle ?? 0,
|
angle: rest.angle ?? 0,
|
||||||
@@ -2425,16 +2425,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
|||||||
groupIds: rest.groupIds ?? [],
|
groupIds: rest.groupIds ?? [],
|
||||||
frameId: rest.frameId ?? null,
|
frameId: rest.frameId ?? null,
|
||||||
index: rest.index ?? `a${indexCounter++}`,
|
index: rest.index ?? `a${indexCounter++}`,
|
||||||
roundness: rest.roundness ?? (
|
roundness: rest.roundness ?? (isRoundedShape ? { type: 3 } : null),
|
||||||
el.type === 'rectangle' || el.type === 'diamond' || el.type === 'ellipse'
|
|
||||||
? { type: 3 } : null
|
|
||||||
),
|
|
||||||
seed: rest.seed ?? Math.floor(Math.random() * 2147483647),
|
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),
|
versionNonce: rest.versionNonce ?? Math.floor(Math.random() * 2147483647),
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
boundElements: rest.boundElements ?? null,
|
boundElements: rest.boundElements ?? null,
|
||||||
updated: Date.now(),
|
updated: rest.updated ?? Date.now(),
|
||||||
link: rest.link ?? null,
|
link: rest.link ?? null,
|
||||||
locked: rest.locked ?? false
|
locked: rest.locked ?? false
|
||||||
};
|
};
|
||||||
@@ -2449,46 +2446,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
|||||||
...rest
|
...rest
|
||||||
} = el as any;
|
} = 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') {
|
if (el.type === 'text') {
|
||||||
base.text = text ?? '';
|
base.text = text ?? rest.text ?? '';
|
||||||
base.originalText = text ?? '';
|
base.originalText = rest.originalText ?? base.text;
|
||||||
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
||||||
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
||||||
base.textAlign = rest.textAlign ?? 'center';
|
base.textAlign = rest.textAlign ?? 'left';
|
||||||
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'top' : 'middle');
|
base.verticalAlign = rest.verticalAlign ?? (rest.containerId ? 'middle' : 'top');
|
||||||
base.autoResize = rest.autoResize ?? true;
|
base.autoResize = rest.autoResize ?? true;
|
||||||
base.lineHeight = rest.lineHeight ?? 1.25;
|
base.lineHeight = rest.lineHeight ?? 1.25;
|
||||||
base.containerId = rest.containerId ?? null;
|
base.containerId = rest.containerId ?? null;
|
||||||
cleanedExportElements.push(base);
|
cleanedExportElements.push(base);
|
||||||
continue;
|
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') {
|
if (el.type === 'arrow' || el.type === 'line') {
|
||||||
base.points = rest.points ?? [[0, 0], [100, 0]];
|
base.points = rest.points ?? [[0, 0], [100, 0]];
|
||||||
base.lastCommittedPoint = null;
|
base.lastCommittedPoint = rest.lastCommittedPoint ?? null;
|
||||||
// Preserve server-resolved bindings with fixedPoint for excalidraw.com
|
base.startBinding = rest.startBinding
|
||||||
if (rest.startBinding) {
|
? { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null }
|
||||||
base.startBinding = { ...rest.startBinding, fixedPoint: rest.startBinding.fixedPoint ?? null };
|
: null;
|
||||||
} else {
|
base.endBinding = rest.endBinding
|
||||||
base.startBinding = null;
|
? { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null }
|
||||||
}
|
: null;
|
||||||
if (rest.endBinding) {
|
|
||||||
base.endBinding = { ...rest.endBinding, fixedPoint: rest.endBinding.fixedPoint ?? null };
|
|
||||||
} else {
|
|
||||||
base.endBinding = null;
|
|
||||||
}
|
|
||||||
base.startArrowhead = rest.startArrowhead ?? null;
|
base.startArrowhead = rest.startArrowhead ?? null;
|
||||||
base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
|
base.endArrowhead = rest.endArrowhead ?? (el.type === 'arrow' ? 'arrow' : null);
|
||||||
base.elbowed = rest.elbowed ?? false;
|
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;
|
const labelText = label?.text || text;
|
||||||
if (labelText) {
|
if (labelText && !nativeBoundTextContainerIds.has(base.id)) {
|
||||||
const textId = `${base.id}-label`;
|
const textId = `${base.id}-label`;
|
||||||
// Add binding reference to parent
|
// Add binding reference to parent
|
||||||
base.boundElements = [
|
base.boundElements = [
|
||||||
|
|||||||
+126
-8
@@ -499,7 +499,7 @@ const ElementSharedFieldsSchema = z.object({
|
|||||||
fileId: z.string().optional(),
|
fileId: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
scale: z.tuple([z.number(), z.number()]).optional(),
|
scale: z.tuple([z.number(), z.number()]).optional(),
|
||||||
});
|
}).passthrough(); // preserve all native Excalidraw fields not listed above
|
||||||
|
|
||||||
const CreateElementSchema = ElementSharedFieldsSchema.extend({
|
const CreateElementSchema = ElementSharedFieldsSchema.extend({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
@@ -559,19 +559,24 @@ app.post('/api/elements', async (req: Request, res: Response) => {
|
|||||||
version: 1
|
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 scope = resolveScope(req);
|
||||||
const message: ElementCreatedMessage = {
|
const message: ElementCreatedMessage = {
|
||||||
type: 'element_created',
|
type: 'element_created',
|
||||||
element: element
|
element: container
|
||||||
};
|
};
|
||||||
message['sync_version'] = sv;
|
message['sync_version'] = sv;
|
||||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
element: element,
|
element: container,
|
||||||
|
boundTextElement: boundText ?? undefined,
|
||||||
syncedToCanvas: ackResult.acked,
|
syncedToCanvas: ackResult.acked,
|
||||||
canvasStatus: {
|
canvasStatus: {
|
||||||
connectedBrowsers: ackResult.delivered,
|
connectedBrowsers: ackResult.delivered,
|
||||||
@@ -620,19 +625,39 @@ app.put('/api/elements/:id', async (req: Request, res: Response) => {
|
|||||||
version: (existingElement.version || 0) + 1
|
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 scope = resolveScope(req);
|
||||||
const message: ElementUpdatedMessage = {
|
const message: ElementUpdatedMessage = {
|
||||||
type: 'element_updated',
|
type: 'element_updated',
|
||||||
element: updatedElement
|
element: container
|
||||||
};
|
};
|
||||||
message['sync_version'] = sv;
|
message['sync_version'] = sv;
|
||||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
element: updatedElement,
|
element: container,
|
||||||
|
boundTextElement: boundText ?? undefined,
|
||||||
syncedToCanvas: ackResult.acked,
|
syncedToCanvas: ackResult.acked,
|
||||||
canvasStatus: {
|
canvasStatus: {
|
||||||
connectedBrowsers: ackResult.delivered,
|
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
|
// Helper: resolve arrow bindings in a batch
|
||||||
function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void {
|
function resolveArrowBindings(batchElements: ServerElement[], projectId?: string): void {
|
||||||
const elementMap = new Map<string, ServerElement>();
|
const elementMap = new Map<string, ServerElement>();
|
||||||
@@ -953,7 +1040,9 @@ app.post('/api/elements/batch', async (req: Request, res: Response) => {
|
|||||||
version: 1
|
version: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
createdElements.push(element);
|
const { container, boundText } = materializeLabel(element);
|
||||||
|
createdElements.push(container);
|
||||||
|
if (boundText) createdElements.push(boundText);
|
||||||
});
|
});
|
||||||
|
|
||||||
resolveArrowBindings(createdElements, projId);
|
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) => {
|
app.get('/api/projects', (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const projects = dbListProjects();
|
const projects = dbListProjects();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface ExcalidrawElementBase {
|
|||||||
customData?: Record<string, any> | null;
|
customData?: Record<string, any> | null;
|
||||||
boundElements?: readonly ExcalidrawBoundElement[] | null;
|
boundElements?: readonly ExcalidrawBoundElement[] | null;
|
||||||
updated?: number;
|
updated?: number;
|
||||||
|
index?: string;
|
||||||
containerId?: string | null;
|
containerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,7 +46,6 @@ test.describe('Phase 2 regressions', () => {
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
label?: { text?: string };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -60,14 +59,18 @@ test.describe('Phase 2 regressions', () => {
|
|||||||
y: number;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
label?: { text?: string };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(afterReload.x).toBe(initial.x);
|
expect(afterReload.x).toBe(initial.x);
|
||||||
expect(afterReload.y).toBe(initial.y);
|
expect(afterReload.y).toBe(initial.y);
|
||||||
expect(afterReload.width).toBe(initial.width);
|
expect(afterReload.width).toBe(initial.width);
|
||||||
expect(afterReload.height).toBe(initial.height);
|
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 }) => {
|
test('new container arrival auto-injects title and subtitle text', async ({ page, request }) => {
|
||||||
|
|||||||
@@ -510,7 +510,9 @@ test.describe('Search E2E', () => {
|
|||||||
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
|
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.elements.length).toBeGreaterThanOrEqual(1);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user