Files
excalidraw-mcp-sentinel/tests/frontend/helpers.test.ts
T
63209f9d5a feat: add test suite, CI/CD pipeline, setup wizard, and upstream feature ports (#6)
Establish comprehensive quality infrastructure for a project that previously
had zero tests, enabling confident refactoring and community contributions
with automated guardrails. Port upstream enhancements for font normalization,
image element support, and arrow binding preservation.

🏗️ Testing infrastructure:
- Unit tests for SQLite persistence layer and element validation helpers
- Integration tests for REST API, WebSocket broadcast, and arrow binding
- E2E tests with Playwright for canvas rendering and real-time sync
- Vitest + Playwright configuration with proper isolation

👷 CI/CD pipeline:
- Auto-versioning from conventional commits on push to main
- Auto-publish to NPM and Docker Hub on GitHub release
- Matrix testing across Node 18/20/22 with pinned dependencies
- Docker health check with diagnostic logging on failure
- Preserve rollup status checks for branch protection gates

📦 Developer experience:
- Interactive setup wizard for first-time configuration
- Canvas clear confirmation and scene description tools
- Frontend helpers extracted for testability

🔧 Upstream feature ports:
- Font family normalization (string names to numeric IDs)
- Image element support with file management API
- Arrow binding preservation through server round-trips
- Vite config fix for font subsetting worker chunk names
- Idempotent database initialization for standalone Docker mode

🐛 Docker fixes:
- Set EXCALIDRAW_DB_PATH in both Dockerfiles to writable /app/data/
- Make initDb() idempotent and closeDb() reset-safe for test isolation

🎯 Provides the safety net needed for rapid iteration — every PR is
validated across 120 test cases before merge, and releases are fully
automated from commit to published package.

Co-authored-by: sanjibdevnathlabs <devnath.sanjib@gmail.com>
2026-03-13 12:08:07 +05:30

219 lines
6.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
cleanElementForExcalidraw,
validateAndFixBindings,
computeElementHash,
} from '../../frontend/src/utils/elementHelpers.js';
import type { ServerElement } from '../../frontend/src/utils/elementHelpers.js';
// ─── cleanElementForExcalidraw ───────────────────────────────
describe('cleanElementForExcalidraw', () => {
it('strips server metadata fields', () => {
const element: ServerElement = {
id: 'el1',
type: 'rectangle',
x: 10,
y: 20,
width: 100,
height: 50,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
version: 3,
syncedAt: '2024-01-02T01:00:00Z',
source: 'mcp',
syncTimestamp: '2024-01-02T01:00:00Z',
};
const cleaned = cleanElementForExcalidraw(element);
expect(cleaned).not.toHaveProperty('createdAt');
expect(cleaned).not.toHaveProperty('updatedAt');
expect(cleaned).not.toHaveProperty('version');
expect(cleaned).not.toHaveProperty('syncedAt');
expect(cleaned).not.toHaveProperty('source');
expect(cleaned).not.toHaveProperty('syncTimestamp');
});
it('preserves core element properties', () => {
const element: ServerElement = {
id: 'el2',
type: 'ellipse',
x: 50,
y: 100,
width: 80,
height: 80,
backgroundColor: '#ff0000',
strokeColor: '#000000',
strokeWidth: 2,
opacity: 0.8,
version: 1,
};
const cleaned = cleanElementForExcalidraw(element);
expect(cleaned.id).toBe('el2');
expect(cleaned.type).toBe('ellipse');
expect(cleaned.x).toBe(50);
expect(cleaned.y).toBe(100);
expect((cleaned as any).backgroundColor).toBe('#ff0000');
expect((cleaned as any).strokeColor).toBe('#000000');
expect((cleaned as any).opacity).toBe(0.8);
});
it('preserves label and text fields', () => {
const element: ServerElement = {
id: 'txt1',
type: 'text',
x: 0,
y: 0,
text: 'Hello',
fontSize: 20,
label: { text: 'Label' },
version: 1,
};
const cleaned = cleanElementForExcalidraw(element);
expect((cleaned as any).text).toBe('Hello');
expect((cleaned as any).label).toEqual({ text: 'Label' });
expect((cleaned as any).fontSize).toBe(20);
});
it('preserves arrow binding references', () => {
const element: ServerElement = {
id: 'arrow1',
type: 'arrow',
x: 0,
y: 0,
start: { id: 'box1' },
end: { id: 'box2' },
version: 1,
};
const cleaned = cleanElementForExcalidraw(element);
expect((cleaned as any).start).toEqual({ id: 'box1' });
expect((cleaned as any).end).toEqual({ id: 'box2' });
});
});
// ─── validateAndFixBindings ──────────────────────────────────
describe('validateAndFixBindings', () => {
it('keeps valid boundElements references', () => {
const elements = [
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'arrow1', type: 'arrow' }] },
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[0]!.boundElements).toEqual([{ id: 'arrow1', type: 'arrow' }]);
});
it('removes boundElements referencing non-existent elements', () => {
const elements = [
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'missing', type: 'arrow' }] },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[0]!.boundElements).toBeNull();
});
it('removes invalid binding objects', () => {
const elements = [
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [null, undefined, 'invalid', { id: 'a' }] },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[0]!.boundElements).toBeNull();
});
it('removes invalid binding types', () => {
const elements = [
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'other', type: 'invalid' }] },
{ id: 'other', type: 'rectangle', x: 0, y: 0 },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[0]!.boundElements).toBeNull();
});
it('sets non-array boundElements to null', () => {
const elements = [
{ id: 'rect1', type: 'rectangle', x: 0, y: 0, boundElements: 'not-an-array' },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[0]!.boundElements).toBeNull();
});
it('nullifies containerId when container does not exist', () => {
const elements = [
{ id: 'text1', type: 'text', x: 0, y: 0, containerId: 'missing-container' },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[0]!.containerId).toBeNull();
});
it('keeps valid containerId', () => {
const elements = [
{ id: 'container', type: 'rectangle', x: 0, y: 0 },
{ id: 'text1', type: 'text', x: 0, y: 0, containerId: 'container' },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[1]!.containerId).toBe('container');
});
it('handles empty array input', () => {
expect(validateAndFixBindings([])).toEqual([]);
});
it('handles elements with no bindings', () => {
const elements = [
{ id: 'simple', type: 'rectangle', x: 0, y: 0 },
] as any[];
const result = validateAndFixBindings(elements);
expect(result[0]!.id).toBe('simple');
});
});
// ─── computeElementHash ──────────────────────────────────────
describe('computeElementHash', () => {
it('produces consistent hash for same elements', () => {
const elements = [
{ id: 'a', version: 1 },
{ id: 'b', version: 2 },
];
const hash1 = computeElementHash(elements);
const hash2 = computeElementHash(elements);
expect(hash1).toBe(hash2);
});
it('produces different hash when element version changes', () => {
const v1 = [{ id: 'a', version: 1 }];
const v2 = [{ id: 'a', version: 2 }];
expect(computeElementHash(v1)).not.toBe(computeElementHash(v2));
});
it('produces different hash when element is added', () => {
const one = [{ id: 'a', version: 1 }];
const two = [{ id: 'a', version: 1 }, { id: 'b', version: 1 }];
expect(computeElementHash(one)).not.toBe(computeElementHash(two));
});
it('handles empty array', () => {
expect(computeElementHash([])).toBe('0');
});
it('includes element count in hash', () => {
const hash = computeElementHash([{ id: 'x', version: 1 }]);
expect(hash.startsWith('1')).toBe(true);
});
});