Files
excalidraw-mcp-sentinel/tests/e2e/sync-flows.spec.ts
T
9846e0ba0f 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>
2026-04-06 21:12:01 +02:00

554 lines
19 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
const API = 'http://127.0.0.1:3100';
async function resetCanvas(request: any): Promise<void> {
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
await Promise.all(
(listBody.elements ?? []).map((element: { id: string }) =>
request.delete(`${API}/api/elements/${element.id}`)
)
);
}
test.beforeEach(async ({ request }) => {
await resetCanvas(request);
});
// ─── Helpers ────────────────────────────────────────────────
async function waitForConnected(page: Page): Promise<void> {
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
}
async function waitForElements(request: any, expectedCount: number, timeoutMs = 5000): Promise<any[]> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await request.get(`${API}/api/elements`);
const body = await res.json();
if (body.count === expectedCount) return body.elements;
await new Promise(r => setTimeout(r, 200));
}
throw new Error(`Timed out waiting for ${expectedCount} elements`);
}
async function getServerElementCount(request: any): Promise<number> {
const res = await request.get(`${API}/api/elements`);
const body = await res.json();
return body.count;
}
async function getSyncVersion(request: any): Promise<number> {
const res = await request.get(`${API}/api/sync/version`);
const body = await res.json();
return body.syncVersion;
}
// ─── THE Critical Regression Test ───────────────────────────
// This is the exact scenario that was broken: delete in UI → sync → reload → elements gone
test.describe('Delete + Sync + Reload persistence', () => {
test('elements deleted via API stay gone after page reload', async ({ page, request }) => {
// 1. Create elements on server
await request.post(`${API}/api/elements`, {
data: { id: 'del-r1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'del-r2', type: 'ellipse', x: 400, y: 100, width: 150, height: 150 },
});
// 2. Load the page, verify elements loaded
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500); // Let elements render
// 3. Delete them via sync/v2 (simulating what the Sync button does after UI deletion)
const syncVersion = await getSyncVersion(request);
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: syncVersion,
changes: [
{ id: 'del-r1', action: 'delete' },
{ id: 'del-r2', action: 'delete' },
],
},
});
const syncBody = await syncRes.json();
expect(syncBody.success).toBe(true);
expect(syncBody.appliedCount).toBe(2);
// 4. Verify server has 0 elements
expect(await getServerElementCount(request)).toBe(0);
// 5. Reload the page
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// 6. Verify elements are still gone on server (the regression was here)
expect(await getServerElementCount(request)).toBe(0);
});
test('sync button persists deletions that survive reload', async ({ page, request }) => {
// 1. Create elements on server
await request.post(`${API}/api/elements`, {
data: { id: 'sb-1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'sb-2', type: 'text', x: 100, y: 300, text: 'To be deleted' },
});
// 2. Load the page
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(1000); // Let elements load + sync baseline populate
// 3. Delete elements via delta sync (simulating UI delete + Sync button)
const v = await getSyncVersion(request);
await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: v,
changes: [
{ id: 'sb-1', action: 'delete' },
{ id: 'sb-2', action: 'delete' },
],
},
});
// 4. Reload
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// 5. Verify no elements on server
const count = await getServerElementCount(request);
expect(count).toBe(0);
// 6. Reload again to double-check
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Delta Sync v2 E2E ──────────────────────────────────────
test.describe('Delta sync v2 E2E', () => {
test('frontend delta sync creates elements that persist', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
// Simulate what the frontend does: send a sync with upserts
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: 0,
changes: [
{ id: 'ds-e2e-1', action: 'upsert', element: { id: 'ds-e2e-1', type: 'rectangle', x: 50, y: 50, width: 100, height: 60 } },
{ id: 'ds-e2e-2', action: 'upsert', element: { id: 'ds-e2e-2', type: 'ellipse', x: 200, y: 50, width: 80, height: 80 } },
],
},
});
const body = await res.json();
expect(body.appliedCount).toBe(2);
// Reload and verify they persist
await page.reload();
await waitForConnected(page);
const elements = await waitForElements(request, 2);
const ids = elements.map((e: any) => e.id).sort();
expect(ids).toEqual(['ds-e2e-1', 'ds-e2e-2']);
});
test('delta sync handles mixed create+delete+update', async ({ request }) => {
// Create initial elements
await request.post(`${API}/api/elements`, {
data: { id: 'mix-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await request.post(`${API}/api/elements`, {
data: { id: 'mix-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
});
const v = await getSyncVersion(request);
// Mixed operation: delete mix-1, update mix-2, create mix-3
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: {
lastSyncVersion: v,
changes: [
{ id: 'mix-1', action: 'delete' },
{ id: 'mix-2', action: 'upsert', element: { id: 'mix-2', type: 'ellipse', x: 300, y: 100, width: 80, height: 80 } },
{ id: 'mix-3', action: 'upsert', element: { id: 'mix-3', type: 'text', x: 50, y: 200, text: 'New' } },
],
},
});
const body = await res.json();
expect(body.appliedCount).toBe(3);
// Verify final state
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
expect(listBody.count).toBe(2);
const ids = listBody.elements.map((e: any) => e.id).sort();
expect(ids).toEqual(['mix-2', 'mix-3']);
});
test('server returns MCP-created elements as serverChanges', async ({ request }) => {
// MCP creates an element (via normal API)
await request.post(`${API}/api/elements`, {
data: { id: 'mcp-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
// Frontend syncs from version 0
const res = await request.post(`${API}/api/elements/sync/v2`, {
data: { lastSyncVersion: 0, changes: [] },
});
const body = await res.json();
const serverIds = body.serverChanges.map((c: any) => c.id);
expect(serverIds).toContain('mcp-el');
});
});
// ─── Auto-sync behavior ─────────────────────────────────────
test.describe('Auto-sync toggle', () => {
test('auto-sync button toggles state', async ({ page }) => {
await page.goto('/');
await waitForConnected(page);
const autoSaveBtn = page.locator('button[title*="Auto-sync"]');
await expect(autoSaveBtn).toBeVisible();
// Check initial state (should show the sun/moon icon and be clickable)
await autoSaveBtn.click();
// Second click toggles back
await autoSaveBtn.click();
// No crash = success
});
});
// ─── Sync Version Tracking E2E ──────────────────────────────
test.describe('Sync version tracking', () => {
test('sync version increases after each mutation', async ({ request }) => {
const v0 = await getSyncVersion(request);
await request.post(`${API}/api/elements`, {
data: { id: 'sv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
const v1 = await getSyncVersion(request);
expect(v1).toBeGreaterThan(v0);
await request.put(`${API}/api/elements/sv-1`, {
data: { x: 50 },
});
const v2 = await getSyncVersion(request);
expect(v2).toBeGreaterThan(v1);
await request.delete(`${API}/api/elements/sv-1`);
const v3 = await getSyncVersion(request);
expect(v3).toBeGreaterThan(v2);
});
test('batch create increments sync version for each element', async ({ request }) => {
const v0 = await getSyncVersion(request);
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'bsv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'bsv-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
{ id: 'bsv-3', type: 'text', x: 50, y: 100, text: 'Test' },
],
},
});
const v1 = await getSyncVersion(request);
expect(v1).toBeGreaterThanOrEqual(v0 + 3);
});
});
// ─── Real-time Sync (MCP→Canvas) ────────────────────────────
test.describe('MCP to Canvas real-time sync', () => {
test('element created via API appears on canvas via WebSocket', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
// Create element via API
await request.post(`${API}/api/elements`, {
data: { id: 'rt-el', type: 'rectangle', x: 100, y: 100, width: 200, height: 100, backgroundColor: '#ff0000' },
});
// Wait for canvas to receive it via WS
await page.waitForTimeout(1000);
// Verify element is on the canvas (check via API since we can't easily inspect Excalidraw internals)
const elements = await waitForElements(request, 1);
expect(elements[0].id).toBe('rt-el');
});
test('batch create appears on canvas without reload', async ({ page, request }) => {
await page.goto('/');
await waitForConnected(page);
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'rt-b1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'rt-b2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
],
},
});
await page.waitForTimeout(1000);
const elements = await waitForElements(request, 2);
expect(elements).toHaveLength(2);
});
test('element update appears on canvas without reload', async ({ page, request }) => {
// Pre-create
await request.post(`${API}/api/elements`, {
data: { id: 'rt-upd', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
// Update
await request.put(`${API}/api/elements/rt-upd`, {
data: { x: 500, y: 500 },
});
await page.waitForTimeout(1000);
// Verify update persisted
const res = await request.get(`${API}/api/elements/rt-upd`);
const body = await res.json();
expect(body.element.x).toBe(500);
expect(body.element.y).toBe(500);
});
test('element delete via API clears from canvas', async ({ page, request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'rt-del', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
await request.delete(`${API}/api/elements/rt-del`);
await page.waitForTimeout(500);
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Clear Canvas E2E ───────────────────────────────────────
test.describe('Clear canvas persistence', () => {
test('clearing via API removes all elements permanently', async ({ page, request }) => {
// Create elements
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'clr-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'clr-2', type: 'text', x: 50, y: 100, text: 'Will be cleared' },
],
},
});
await page.goto('/');
await waitForConnected(page);
await page.waitForTimeout(500);
// Clear
await request.delete(`${API}/api/elements/clear?confirm=true`);
await page.waitForTimeout(500);
// Verify gone
expect(await getServerElementCount(request)).toBe(0);
// Reload
await page.reload();
await waitForConnected(page);
await page.waitForTimeout(500);
// Still gone
expect(await getServerElementCount(request)).toBe(0);
});
});
// ─── Snapshot Create + Restore ──────────────────────────────
test.describe('Snapshots E2E', () => {
test('create snapshot, clear, restore, verify elements return', async ({ request }) => {
// Create elements
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'snap-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'snap-2', type: 'text', x: 50, y: 100, text: 'Snapshot test' },
],
},
});
// Save snapshot
const snapRes = await request.post(`${API}/api/snapshots`, {
data: { name: 'test-snap' },
});
expect((await snapRes.json()).success).toBe(true);
// Clear
await request.delete(`${API}/api/elements/clear?confirm=true`);
expect(await getServerElementCount(request)).toBe(0);
// List snapshots
const listRes = await request.get(`${API}/api/snapshots`);
const listBody = await listRes.json();
expect(listBody.snapshots.some((s: any) => s.name === 'test-snap')).toBe(true);
// Get snapshot
const getRes = await request.get(`${API}/api/snapshots/test-snap`);
const getBody = await getRes.json();
expect(getBody.snapshot).toBeDefined();
expect(getBody.snapshot.elements).toHaveLength(2);
});
});
// ─── Settings Persistence ───────────────────────────────────
test.describe('Settings E2E', () => {
test('settings persist across requests', async ({ request }) => {
await request.put(`${API}/api/settings/test_key`, {
data: { value: 'test_value' },
});
const res = await request.get(`${API}/api/settings/test_key`);
const body = await res.json();
expect(body.value).toBe('test_value');
});
});
// ─── Files API E2E ──────────────────────────────────────────
test.describe('Files API E2E', () => {
test('add and list files', async ({ request }) => {
const addRes = await request.post(`${API}/api/files`, {
data: {
files: {
'file-1': {
id: 'file-1',
mimeType: 'image/png',
dataURL: 'data:image/png;base64,iVBOR...',
created: Date.now(),
},
},
},
});
expect((await addRes.json()).success).toBe(true);
const listRes = await request.get(`${API}/api/files`);
const listBody = await listRes.json();
expect(listBody.files['file-1']).toBeDefined();
expect(listBody.files['file-1'].mimeType).toBe('image/png');
});
test('delete file', async ({ request }) => {
await request.post(`${API}/api/files`, {
data: {
files: {
'file-del': {
id: 'file-del',
mimeType: 'image/png',
dataURL: 'data:image/png;base64,abc',
created: Date.now(),
},
},
},
});
const delRes = await request.delete(`${API}/api/files/file-del`);
expect((await delRes.json()).success).toBe(true);
const listRes = await request.get(`${API}/api/files`);
const listBody = await listRes.json();
expect(listBody.files['file-del']).toBeUndefined();
});
});
// ─── Search API E2E ─────────────────────────────────────────
test.describe('Search E2E', () => {
test('search by type returns matching elements', async ({ request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'srch-r', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'srch-e', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
{ id: 'srch-t', type: 'text', x: 50, y: 100, text: 'Search me' },
],
},
});
// Filter by type
const res = await request.get(`${API}/api/elements/search?type=rectangle`);
const body = await res.json();
expect(body.elements.length).toBe(1);
expect(body.elements[0].type).toBe('rectangle');
});
test('full-text search finds elements by label', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'fts-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, label: { text: 'Authentication Service' } },
});
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
const body = await res.json();
expect(body.elements.length).toBeGreaterThanOrEqual(1);
// 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);
});
});
// ─── Tenant API E2E ─────────────────────────────────────────
test.describe('Tenant management E2E', () => {
test('list tenants returns at least default', async ({ request }) => {
const res = await request.get(`${API}/api/tenants`);
const body = await res.json();
expect(body.tenants.length).toBeGreaterThanOrEqual(1);
});
test('active tenant is available', async ({ request }) => {
const res = await request.get(`${API}/api/tenant/active`);
const body = await res.json();
expect(body.tenant).toBeDefined();
expect(body.tenant.id).toBeDefined();
});
});
// ─── Element Version History E2E ────────────────────────────
test.describe('Element version history E2E', () => {
test('element history tracks create and update', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await request.put(`${API}/api/elements/hist-el`, {
data: { x: 500 },
});
// Get element to verify it exists and is updated
const getRes = await request.get(`${API}/api/elements/hist-el`);
const body = await getRes.json();
expect(body.element.x).toBe(500);
});
});