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>
This commit is contained in:
Sanjib Devnath
2026-03-13 12:08:07 +05:30
committed by GitHub
co-authored by sanjibdevnathlabs
parent 4c50472ee4
commit 63209f9d5a
28 changed files with 4850 additions and 713 deletions
+225 -41
View File
@@ -25,7 +25,9 @@ import {
EXCALIDRAW_ELEMENT_TYPES,
ServerElement,
ExcalidrawElementType,
validateElement
validateElement,
normalizeFontFamily,
files as globalFiles
} from './types.js';
import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js';
@@ -63,6 +65,10 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
const ENABLE_CANVAS_SYNC = true;
// One-time tokens for clear_canvas confirmation (token → expiry timestamp)
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
// API Response types
interface ApiResponse {
success: boolean;
@@ -247,7 +253,7 @@ const ElementSchema = z.object({
opacity: z.number().optional(),
text: z.string().optional(),
fontSize: z.number().optional(),
fontFamily: z.string().optional(),
fontFamily: z.union([z.string(), z.number()]).optional(),
groupIds: z.array(z.string()).optional(),
locked: z.boolean().optional(),
strokeStyle: z.string().optional(),
@@ -258,6 +264,9 @@ const ElementSchema = z.object({
endElementId: z.string().optional(),
endArrowhead: z.string().optional(),
startArrowhead: z.string().optional(),
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
});
const ElementIdSchema = z.object({
@@ -409,7 +418,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont (hand-drawn), 2=Helvetica (sans-serif), 3=Cascadia (monospace), 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -640,7 +649,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: 'string' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont, 2=Helvetica, 3=Cascadia, 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -666,10 +675,15 @@ const tools: Tool[] = [
},
{
name: 'clear_canvas',
description: 'Clear all elements from the canvas',
description: 'DESTRUCTIVE: Permanently deletes ALL elements from the canvas. Two-step process: (1) Call WITHOUT clearToken to get a preview of what will be deleted — present this to the user and ask for confirmation. (2) Call WITH the returned clearToken to execute the clear. Prefer placing new diagrams alongside existing ones (call describe_scene first).',
inputSchema: {
type: 'object',
properties: {}
properties: {
clearToken: {
type: 'string',
description: 'One-time token returned by the preview step. Pass this to confirm and execute the clear. Omit on first call to get the preview.'
}
}
}
},
{
@@ -973,11 +987,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const { startElementId, endElementId, id: customId, ...elementProps } = params;
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
const element: ServerElement = {
id,
...elementProps,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
// Convert binding IDs to Excalidraw's start/end format
...(startElementId ? { start: { id: startElementId } } : {}),
...(endElementId ? { end: { id: endElementId } } : {}),
createdAt: new Date().toISOString(),
@@ -1020,10 +1035,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (!id) throw new Error('Element ID is required');
// Build update payload with timestamp and version increment
const normalizedFont = normalizeFontFamily(updates.fontFamily);
const updatePayload: Partial<ServerElement> & { id: string } = {
id,
...updates,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
points: rawPoints ? normalizePoints(rawPoints) : undefined,
updatedAt: new Date().toISOString()
};
@@ -1468,11 +1484,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
for (const elementData of params.elements) {
const { startElementId, endElementId, id: customId, ...elementProps } = elementData;
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
const element: ServerElement = {
id,
...elementProps,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
// Convert binding IDs to Excalidraw's start/end format
...(startElementId ? { start: { id: startElementId } } : {}),
...(endElementId ? { end: { id: endElementId } } : {}),
createdAt: new Date().toISOString(),
@@ -1530,23 +1547,99 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
}
case 'clear_canvas': {
logger.info('Clearing canvas via MCP');
const clearParams = z.object({ clearToken: z.string().optional() }).parse(args);
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
if (!clearParams.clearToken) {
// Step 1: Preview — show what will be deleted and return a one-time token
const previewResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
headers: canvasHeaders()
});
if (!previewResp.ok) throw new Error('Failed to fetch elements for preview');
const previewData = await previewResp.json() as ApiResponse;
const elements = previewData.elements || [];
const count = elements.length;
if (count === 0) {
return {
content: [{ type: 'text', text: 'The canvas is already empty. Nothing to clear.' }]
};
}
// Build a summary of what exists
const typeCounts: Record<string, number> = {};
for (const el of elements) {
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
}
const typesSummary = Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ');
// Generate one-time token
const token = `clr_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
pendingClearTokens.set(token, { expiresAt: Date.now() + CLEAR_TOKEN_TTL_MS, elementCount: count });
// Prune expired tokens
for (const [k, v] of pendingClearTokens) {
if (v.expiresAt < Date.now()) pendingClearTokens.delete(k);
}
return {
content: [{
type: 'text',
text: [
`⚠️ CLEAR CANVAS — confirmation required`,
``,
`This will permanently delete **${count} element${count !== 1 ? 's' : ''}**: ${typesSummary}`,
``,
`Ask the user: "Do you want me to clear all ${count} elements from the canvas?"`,
``,
`If the user confirms, call clear_canvas again with clearToken: "${token}"`,
`If the user declines, do NOT call clear_canvas again.`,
].join('\n')
}]
};
}
// Step 2: Execute clear with a valid token
const tokenData = pendingClearTokens.get(clearParams.clearToken);
if (!tokenData) {
return {
content: [{
type: 'text',
text: 'Clear canvas rejected: invalid or expired clearToken. Call clear_canvas without clearToken first to get a fresh preview and token.'
}],
isError: true
};
}
if (tokenData.expiresAt < Date.now()) {
pendingClearTokens.delete(clearParams.clearToken);
return {
content: [{
type: 'text',
text: 'Clear canvas rejected: clearToken has expired. Call clear_canvas without clearToken to get a new one.'
}],
isError: true
};
}
// Token valid — consume it and clear
pendingClearTokens.delete(clearParams.clearToken);
logger.info('Clearing canvas via MCP (user confirmed via token)');
const clearResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
method: 'DELETE',
headers: canvasHeaders()
});
if (!response.ok) {
throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`);
if (!clearResponse.ok) {
throw new Error(`Failed to clear canvas: ${clearResponse.status} ${clearResponse.statusText}`);
}
const data = await response.json() as ApiResponse;
const clearData = await clearResponse.json() as ApiResponse;
return {
content: [{
type: 'text',
text: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}`
text: `Canvas cleared.\n\n${JSON.stringify(clearData, null, 2)}`
}]
};
}
@@ -1568,6 +1661,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
const data = await response.json() as ApiResponse;
const sceneElements = data.elements || [];
// Collect files from the in-memory store
const exportFiles: Record<string, any> = {};
for (const [id, file] of globalFiles) {
exportFiles[id] = file;
}
const excalidrawScene = {
type: 'excalidraw',
version: 2,
@@ -1576,7 +1675,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
appState: {
viewBackgroundColor: '#ffffff',
gridSize: null
}
},
files: exportFiles
};
const jsonString = JSON.stringify(excalidrawScene, null, 2);
@@ -1587,7 +1687,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
return {
content: [{
type: 'text',
text: `Scene exported to ${safePath} (${sceneElements.length} elements)`
text: `Scene exported to ${safePath} (${sceneElements.length} elements, ${Object.keys(exportFiles).length} files)`
}]
};
}
@@ -1629,6 +1729,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
throw new Error('No elements found in the import data');
}
// Import files if present
const importedFiles = sceneData.files;
if (importedFiles && typeof importedFiles === 'object') {
for (const [id, fileData] of Object.entries(importedFiles)) {
const file = fileData as any;
globalFiles.set(id, {
id,
mimeType: file.mimeType || 'image/png',
dataURL: file.dataURL,
created: file.created || Date.now(),
});
}
// Push files to canvas server
try {
await fetch(`${EXPRESS_SERVER_URL}/api/files`, {
method: 'POST',
headers: canvasHeaders(),
body: JSON.stringify({ files: importedFiles })
});
} catch {}
}
if (params.mode === 'replace') {
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
}
@@ -1814,7 +1936,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (allElements.length === 0) {
return {
content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }]
content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.\n\nSuggested placement for a new diagram: x=0, y=0' }]
};
}
@@ -1824,7 +1946,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
}
// Bounding box
// Overall bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of allElements) {
minX = Math.min(minX, el.x);
@@ -1833,6 +1955,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
maxY = Math.max(maxY, el.y + (el.height || 0));
}
// ── Diagram zone detection ──
// Build a map of groupId → elements
const groupMap: Record<string, ServerElement[]> = {};
const ungroupedElements: ServerElement[] = [];
for (const el of allElements) {
if (el.groupIds && el.groupIds.length > 0) {
for (const gid of el.groupIds) {
if (!groupMap[gid]) groupMap[gid] = [];
groupMap[gid]!.push(el);
}
} else {
ungroupedElements.push(el);
}
}
interface DiagramZone {
groupId: string;
label: string | null;
bbox: { minX: number; minY: number; maxX: number; maxY: number };
elementCount: number;
}
const zones: DiagramZone[] = [];
for (const [gid, elements] of Object.entries(groupMap)) {
let zMinX = Infinity, zMinY = Infinity, zMaxX = -Infinity, zMaxY = -Infinity;
let label: string | null = null;
for (const el of elements) {
zMinX = Math.min(zMinX, el.x);
zMinY = Math.min(zMinY, el.y);
zMaxX = Math.max(zMaxX, el.x + (el.width || 0));
zMaxY = Math.max(zMaxY, el.y + (el.height || 0));
// Use the first text element or label as the zone name
if (!label) {
if (el.type === 'text' && el.text) label = el.text;
else if (el.label?.text) label = el.label.text;
}
}
zones.push({
groupId: gid,
label,
bbox: { minX: zMinX, minY: zMinY, maxX: zMaxX, maxY: zMaxY },
elementCount: elements.length,
});
}
// Suggest next placement: 300px to the right of the overall bounding box
const suggestedX = Math.round(maxX + 300);
const suggestedY = Math.round(minY);
// Build element descriptions sorted top-to-bottom, left-to-right
const sorted = [...allElements].sort((a, b) => {
const rowDiff = Math.floor(a.y / 50) - Math.floor(b.y / 50);
@@ -1879,7 +2053,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
lines.push(`## Canvas Description`);
lines.push(`Total elements: ${allElements.length}`);
lines.push(`Types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ')}`);
lines.push(`Bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`);
lines.push(`Canvas bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`);
// Diagram zones section
if (zones.length > 0) {
lines.push('');
lines.push('### Diagram Zones (grouped):');
for (const zone of zones) {
const w = Math.round(zone.bbox.maxX - zone.bbox.minX);
const h = Math.round(zone.bbox.maxY - zone.bbox.minY);
const name = zone.label ? `"${zone.label}"` : '(unnamed)';
lines.push(` Group ${zone.groupId}: ${name} | bbox (${Math.round(zone.bbox.minX)}, ${Math.round(zone.bbox.minY)}) to (${Math.round(zone.bbox.maxX)}, ${Math.round(zone.bbox.maxY)}) = ${w}x${h} | ${zone.elementCount} elements`);
}
}
if (ungroupedElements.length > 0 && zones.length > 0) {
lines.push(` + ${ungroupedElements.length} ungrouped elements`);
}
lines.push('');
lines.push(`### Suggested placement for new diagram: x=${suggestedX}, y=${suggestedY}`);
lines.push('');
lines.push('### Elements (top-to-bottom, left-to-right):');
lines.push(...elementDescs);
@@ -1890,23 +2084,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
lines.push(...connectionDescs);
}
// Groups
const groupedElements = allElements.filter(el => el.groupIds && el.groupIds.length > 0);
if (groupedElements.length > 0) {
const groupMap: Record<string, string[]> = {};
for (const el of groupedElements) {
for (const gid of (el.groupIds || [])) {
if (!groupMap[gid]) groupMap[gid] = [];
groupMap[gid]!.push(el.id);
}
}
lines.push('');
lines.push('### Groups:');
for (const [gid, ids] of Object.entries(groupMap)) {
lines.push(` Group ${gid}: [${ids.join(', ')}]`);
}
}
return {
content: [{ type: 'text', text: lines.join('\n') }]
};
@@ -2533,10 +2710,17 @@ if (process.env.DEBUG === 'true') {
// Start the server if this file is run directly
if (fileURLToPath(import.meta.url) === process.argv[1]) {
runServer().catch(error => {
logger.error('Failed to start server:', error);
process.exit(1);
});
if (process.argv[2] === 'setup') {
import('./setup.js').then(m => m.runSetup()).catch(error => {
process.stderr.write(`Setup failed: ${(error as Error).message}\n`);
process.exit(1);
});
} else {
runServer().catch(error => {
logger.error('Failed to start server:', error);
process.exit(1);
});
}
}
export default runServer;