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
+45
View File
@@ -410,6 +410,51 @@
color: #aaa;
font-size: 13px;
}
/* Clear canvas confirmation dialog */
.confirm-dialog {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 360px;
background: #fff;
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,0.22);
padding: 24px;
}
.confirm-title {
font-size: 18px;
font-weight: 700;
color: #333;
margin-bottom: 8px;
}
.confirm-msg {
font-size: 14px;
color: #666;
margin: 0 0 16px;
line-height: 1.4;
}
.confirm-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #555;
cursor: pointer;
margin-bottom: 20px;
font-weight: 400;
}
.confirm-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
</head>
<body>
+151 -150
View File
@@ -10,45 +10,18 @@ import {
import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter'
import type { MermaidConfig } from '@excalidraw/mermaid-to-excalidraw'
import {
cleanElementForExcalidraw,
validateAndFixBindings,
computeElementHash,
isImageElement,
normalizeImageElement,
restoreBindings
} from './utils/elementHelpers'
import type { ServerElement } from './utils/elementHelpers'
// Type definitions
type ExcalidrawAPIRefValue = ExcalidrawImperativeAPI;
interface ServerElement {
id: string;
type: string;
x: number;
y: number;
width?: number;
height?: number;
backgroundColor?: string;
strokeColor?: string;
strokeWidth?: number;
roughness?: number;
opacity?: number;
text?: string;
fontSize?: number;
fontFamily?: string | number;
label?: {
text: string;
};
createdAt?: string;
updatedAt?: string;
version?: number;
syncedAt?: string;
source?: string;
syncTimestamp?: string;
boundElements?: any[] | null;
containerId?: string | null;
locked?: boolean;
// Arrow element binding
start?: { id: string };
end?: { id: string };
strokeStyle?: string;
endArrowhead?: string;
startArrowhead?: string;
}
interface WebSocketMessage {
type: string;
element?: ServerElement;
@@ -59,6 +32,8 @@ interface WebSocketMessage {
source?: string;
mermaidDiagram?: string;
config?: MermaidConfig;
files?: Record<string, any>;
[key: string]: any;
}
interface ApiResponse {
@@ -72,68 +47,6 @@ interface ApiResponse {
type SyncStatus = 'idle' | 'syncing';
// Helper function to clean elements for Excalidraw
const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
const {
createdAt,
updatedAt,
version,
syncedAt,
source,
syncTimestamp,
...cleanElement
} = element;
return cleanElement;
}
// Helper function to validate and fix element binding data
const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
const elementMap = new Map(elements.map(el => [el.id!, el]));
return elements.map(element => {
const fixedElement = { ...element };
// Validate and fix boundElements
if (fixedElement.boundElements) {
if (Array.isArray(fixedElement.boundElements)) {
fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => {
// Ensure binding has required properties
if (!binding || typeof binding !== 'object') return false;
if (!binding.id || !binding.type) return false;
// Ensure the referenced element exists
const referencedElement = elementMap.get(binding.id);
if (!referencedElement) return false;
// Validate binding type
if (!['text', 'arrow'].includes(binding.type)) return false;
return true;
});
// Remove boundElements if empty
if (fixedElement.boundElements.length === 0) {
fixedElement.boundElements = null;
}
} else {
// Invalid boundElements format, set to null
fixedElement.boundElements = null;
}
}
// Validate and fix containerId
if (fixedElement.containerId) {
const containerElement = elementMap.get(fixedElement.containerId);
if (!containerElement) {
// Container doesn't exist, remove containerId
fixedElement.containerId = null;
}
}
return fixedElement;
});
}
interface TenantInfo {
id: string;
name: string;
@@ -142,6 +55,7 @@ interface TenantInfo {
function App(): JSX.Element {
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
const excalidrawAPIRef = useRef<ExcalidrawAPIRefValue | null>(null)
const [isConnected, setIsConnected] = useState<boolean>(false)
const websocketRef = useRef<WebSocket | null>(null)
@@ -165,7 +79,10 @@ function App(): JSX.Element {
const [tenantSearch, setTenantSearch] = useState<string>('')
const searchInputRef = useRef<HTMLInputElement | null>(null)
// Keep ref in sync so closures (WebSocket handlers) always see latest tenant
// Keep refs in sync so closures (WebSocket handlers) always see latest values
useEffect(() => {
excalidrawAPIRef.current = excalidrawAPI
}, [excalidrawAPI])
useEffect(() => {
activeTenantIdRef.current = activeTenant?.id ?? null
}, [activeTenant])
@@ -203,15 +120,6 @@ function App(): JSX.Element {
}
}, [excalidrawAPI, isConnected])
const computeElementHash = (elements: readonly { id: string; version: number }[]): string => {
let h = String(elements.length)
for (let i = 0; i < elements.length; i++) {
h += elements[i].id
h += elements[i].version
}
return h
}
// Persist auto-save preference and cancel pending timer when toggled off
const toggleAutoSave = () => {
setAutoSave(prev => {
@@ -250,25 +158,55 @@ function App(): JSX.Element {
}, DEBOUNCE_MS)
}
const convertElementsPreservingImageProps = (
cleanedElements: any[]
): any[] => {
const imageElements = cleanedElements.filter(isImageElement)
const nonImageElements = cleanedElements.filter(el => !isImageElement(el))
let convertedNonImage: any[] = []
if (nonImageElements.length > 0) {
convertedNonImage = convertToExcalidrawElements(nonImageElements, { regenerateIds: false }) as any[]
convertedNonImage = restoreBindings(convertedNonImage, nonImageElements)
}
const normalizedImages = imageElements.map(normalizeImageElement)
return [...convertedNonImage, ...normalizedImages]
}
const loadExistingElements = async (): Promise<void> => {
try {
const response = await fetch('/api/elements', { headers: tenantHeaders() })
const result: ApiResponse = await response.json()
if (result.success && result.elements && result.elements.length > 0) {
if (result.success && result.elements) {
if (result.elements.length === 0) {
excalidrawAPI?.updateScene({ elements: [] })
return
}
const cleanedElements = result.elements.map(cleanElementForExcalidraw)
// Elements with containerId are in Excalidraw native format (from a
// previous sync before the normalization fix). Pass them directly —
// convertToExcalidrawElements would re-create bound text and break layout.
const hasNativeFormat = cleanedElements.some((el: any) => el.containerId)
if (hasNativeFormat) {
const validated = validateAndFixBindings(cleanedElements)
excalidrawAPI?.updateScene({ elements: validated as any })
} else {
const convertedElements = convertToExcalidrawElements(cleanedElements, { regenerateIds: false })
const convertedElements = convertElementsPreservingImageProps(cleanedElements)
excalidrawAPI?.updateScene({ elements: convertedElements })
}
}
// Also load files (image data)
try {
const filesRes = await fetch('/api/files', { headers: tenantHeaders() })
const filesData = await filesRes.json()
if (filesData.success && filesData.files) {
const fileValues = Object.values(filesData.files) as any[]
if (fileValues.length > 0 && excalidrawAPI) {
excalidrawAPI.addFiles(fileValues)
}
}
} catch {}
} catch (error) {
console.error('Error loading existing elements:', error)
}
@@ -317,22 +255,21 @@ function App(): JSX.Element {
}
const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
if (!excalidrawAPI) {
const api = excalidrawAPIRef.current
if (!api) {
return
}
try {
const currentElements = excalidrawAPI.getSceneElements()
console.log('Current elements:', currentElements);
const currentElements = api.getSceneElements()
switch (data.type) {
case 'initial_elements':
if (data.elements && data.elements.length > 0) {
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
const validatedElements = validateAndFixBindings(cleanedElements)
// Preserve server IDs so later update/delete websocket events can match by id.
const convertedElements = convertToExcalidrawElements(validatedElements, { regenerateIds: false })
excalidrawAPI.updateScene({
const convertedElements = convertElementsPreservingImageProps(validatedElements)
api.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
@@ -344,18 +281,16 @@ function App(): JSX.Element {
const cleanedNewElement = cleanElementForExcalidraw(data.element)
const hasBindings = (cleanedNewElement as any).start || (cleanedNewElement as any).end
if (hasBindings) {
// Bound arrow: re-convert all elements together so bindings resolve
const allElements = [...currentElements, cleanedNewElement] as any[]
const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false })
excalidrawAPI.updateScene({
api.updateScene({
elements: convertedAll,
captureUpdate: CaptureUpdateAction.NEVER
})
} else {
// Preserve server IDs so later update/delete websocket events can match by id.
const newElement = convertToExcalidrawElements([cleanedNewElement], { regenerateIds: false })
const updatedElementsAfterCreate = [...currentElements, ...newElement]
excalidrawAPI.updateScene({
api.updateScene({
elements: updatedElementsAfterCreate,
captureUpdate: CaptureUpdateAction.NEVER
})
@@ -366,12 +301,11 @@ function App(): JSX.Element {
case 'element_updated':
if (data.element) {
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
// Preserve server IDs so we can replace the existing element by id.
const convertedUpdatedElement = convertToExcalidrawElements([cleanedUpdatedElement], { regenerateIds: false })[0]
const updatedElements = currentElements.map(el =>
el.id === data.element!.id ? convertedUpdatedElement : el
)
excalidrawAPI.updateScene({
api.updateScene({
elements: updatedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
@@ -381,7 +315,7 @@ function App(): JSX.Element {
case 'element_deleted':
if (data.elementId) {
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
excalidrawAPI.updateScene({
api.updateScene({
elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER
})
@@ -393,18 +327,16 @@ function App(): JSX.Element {
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
const hasBoundArrows = cleanedBatchElements.some((el: any) => el.start || el.end)
if (hasBoundArrows) {
// Convert ALL elements together so arrow bindings resolve to target shapes
const allElements = [...currentElements, ...cleanedBatchElements] as any[]
const convertedAll = convertToExcalidrawElements(allElements, { regenerateIds: false })
excalidrawAPI.updateScene({
const convertedAll = convertElementsPreservingImageProps(allElements)
api.updateScene({
elements: convertedAll,
captureUpdate: CaptureUpdateAction.NEVER
})
} else {
// Preserve server IDs so later update/delete websocket events can match by id.
const batchElements = convertToExcalidrawElements(cleanedBatchElements, { regenerateIds: false })
const batchElements = convertElementsPreservingImageProps(cleanedBatchElements)
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
excalidrawAPI.updateScene({
api.updateScene({
elements: updatedElementsAfterBatch,
captureUpdate: CaptureUpdateAction.NEVER
})
@@ -414,7 +346,6 @@ function App(): JSX.Element {
case 'elements_synced':
console.log(`Sync confirmed by server: ${data.count} elements`)
// Sync confirmation already handled by HTTP response
break
case 'sync_status':
@@ -423,7 +354,7 @@ function App(): JSX.Element {
case 'canvas_cleared':
console.log('Canvas cleared by server')
excalidrawAPI.updateScene({
api.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
@@ -433,9 +364,9 @@ function App(): JSX.Element {
console.log('Received image export request', data)
if (data.requestId) {
try {
const elements = excalidrawAPI.getSceneElements()
const appState = excalidrawAPI.getAppState()
const files = excalidrawAPI.getFiles()
const elements = api.getSceneElements()
const appState = api.getAppState()
const files = api.getFiles()
if (data.format === 'svg') {
const svg = await exportToSvg({
@@ -528,20 +459,19 @@ function App(): JSX.Element {
if (data.requestId) {
try {
if (data.scrollToContent) {
const allElements = excalidrawAPI.getSceneElements()
const allElements = api.getSceneElements()
if (allElements.length > 0) {
excalidrawAPI.scrollToContent(allElements, { fitToViewport: true, animate: true })
api.scrollToContent(allElements, { fitToViewport: true, animate: true })
}
} else if (data.scrollToElementId) {
const allElements = excalidrawAPI.getSceneElements()
const allElements = api.getSceneElements()
const targetElement = allElements.find(el => el.id === data.scrollToElementId)
if (targetElement) {
excalidrawAPI.scrollToContent([targetElement], { fitToViewport: false, animate: true })
api.scrollToContent([targetElement], { fitToViewport: false, animate: true })
} else {
throw new Error(`Element ${data.scrollToElementId} not found`)
}
} else {
// Direct zoom/scroll control
const appState: any = {}
if (data.zoom !== undefined) {
appState.zoom = { value: data.zoom }
@@ -553,7 +483,7 @@ function App(): JSX.Element {
appState.scrollY = data.offsetY
}
if (Object.keys(appState).length > 0) {
excalidrawAPI.updateScene({ appState })
api.updateScene({ appState })
}
}
@@ -593,13 +523,13 @@ function App(): JSX.Element {
if (result.elements && result.elements.length > 0) {
const convertedElements = convertToExcalidrawElements(result.elements, { regenerateIds: false })
excalidrawAPI.updateScene({
api.updateScene({
elements: convertedElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY
})
if (result.files) {
excalidrawAPI.addFiles(Object.values(result.files))
api.addFiles(Object.values(result.files))
}
console.log('Mermaid diagram converted successfully:', result.elements.length, 'elements')
@@ -613,6 +543,18 @@ function App(): JSX.Element {
}
break
case 'files_added':
if (data.files && typeof data.files === 'object') {
const fileValues = Object.values(data.files) as any[]
if (fileValues.length > 0) {
api.addFiles(fileValues)
}
}
break
case 'file_deleted':
break
case 'tenant_switched':
console.log('Tenant switched:', data.tenant)
if (data.tenant) {
@@ -622,7 +564,7 @@ function App(): JSX.Element {
if (incoming.id !== activeTenantIdRef.current) {
activeTenantIdRef.current = incoming.id
setActiveTenant(incoming)
excalidrawAPI.updateScene({
api.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
@@ -819,7 +761,46 @@ function App(): JSX.Element {
}
}
const clearCanvas = async (): Promise<void> => {
// Clear canvas confirmation state (UI button only)
const [showClearConfirm, setShowClearConfirm] = useState(false)
const [clearSkipConfirm, setClearSkipConfirm] = useState(false)
const [dontAskAgain, setDontAskAgain] = useState(false)
// Load "skip confirm" preference from backend on mount
useEffect(() => {
fetch('/api/settings/clear_canvas_skip_confirm')
.then(r => r.json())
.then(data => {
if (data.value === 'true') setClearSkipConfirm(true)
})
.catch(() => {})
}, [])
const handleClearCanvasClick = () => {
if (clearSkipConfirm) {
performClearCanvas()
} else {
setDontAskAgain(false)
setShowClearConfirm(true)
}
}
const handleClearConfirm = async () => {
if (dontAskAgain) {
setClearSkipConfirm(true)
try {
await fetch('/api/settings/clear_canvas_skip_confirm', {
method: 'PUT',
headers: tenantHeaders(),
body: JSON.stringify({ value: 'true' })
})
} catch {}
}
setShowClearConfirm(false)
performClearCanvas()
}
const performClearCanvas = async (): Promise<void> => {
if (excalidrawAPI) {
try {
const response = await fetch('/api/elements', { headers: tenantHeaders() })
@@ -832,14 +813,12 @@ function App(): JSX.Element {
await Promise.all(deletePromises)
}
// Clear the frontend canvas
excalidrawAPI.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.IMMEDIATELY
})
} catch (error) {
console.error('Error clearing canvas:', error)
// Still clear frontend even if backend fails
excalidrawAPI.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.IMMEDIATELY
@@ -899,7 +878,7 @@ function App(): JSX.Element {
</button>
</div>
<button className="btn-secondary" onClick={clearCanvas}>Clear Canvas</button>
<button className="btn-secondary" onClick={handleClearCanvasClick}>Clear Canvas</button>
</div>
</div>
@@ -946,6 +925,28 @@ function App(): JSX.Element {
)
})()}
{/* Clear canvas confirmation modal (UI button only) */}
{showClearConfirm && (
<div className="menu-overlay" onClick={() => setShowClearConfirm(false)}>
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
<div className="confirm-title">Clear Canvas</div>
<p className="confirm-msg">This will permanently delete all elements. Continue?</p>
<label className="confirm-checkbox-label">
<input
type="checkbox"
checked={dontAskAgain}
onChange={e => setDontAskAgain(e.target.checked)}
/>
Don't ask again
</label>
<div className="confirm-actions">
<button className="btn-secondary" onClick={() => setShowClearConfirm(false)}>Cancel</button>
<button className="btn-danger" onClick={handleClearConfirm}>Clear</button>
</div>
</div>
</div>
)}
{/* Canvas Container */}
<div className="canvas-container">
<Excalidraw
+163
View File
@@ -0,0 +1,163 @@
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
export interface ServerElement {
id: string;
type: string;
x: number;
y: number;
width?: number;
height?: number;
backgroundColor?: string;
strokeColor?: string;
strokeWidth?: number;
roughness?: number;
opacity?: number;
text?: string;
fontSize?: number;
fontFamily?: string | number;
label?: { text: string };
createdAt?: string;
updatedAt?: string;
version?: number;
syncedAt?: string;
source?: string;
syncTimestamp?: string;
boundElements?: any[] | null;
containerId?: string | null;
locked?: boolean;
start?: { id: string };
end?: { id: string };
strokeStyle?: string;
endArrowhead?: string;
startArrowhead?: string;
startBinding?: any;
endBinding?: any;
// Image element properties
fileId?: string;
status?: string;
scale?: [number, number];
}
export const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
const {
createdAt,
updatedAt,
version,
syncedAt,
source,
syncTimestamp,
...cleanElement
} = element;
return cleanElement;
};
export const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
const elementMap = new Map(elements.map(el => [el.id!, el]));
return elements.map(element => {
const fixedElement = { ...element };
if (fixedElement.boundElements) {
if (Array.isArray(fixedElement.boundElements)) {
fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => {
if (!binding || typeof binding !== 'object') return false;
if (!binding.id || !binding.type) return false;
const referencedElement = elementMap.get(binding.id);
if (!referencedElement) return false;
if (!['text', 'arrow'].includes(binding.type)) return false;
return true;
});
if (fixedElement.boundElements.length === 0) {
fixedElement.boundElements = null;
}
} else {
fixedElement.boundElements = null;
}
}
if (fixedElement.containerId) {
const containerElement = elementMap.get(fixedElement.containerId);
if (!containerElement) {
fixedElement.containerId = null;
}
}
return fixedElement;
});
};
export const isImageElement = (el: Partial<ExcalidrawElement>): boolean => {
return el.type === 'image';
};
const SHAPE_CONTAINER_TYPES = new Set([
'rectangle', 'ellipse', 'diamond', 'arrow', 'line'
]);
export const isShapeContainerType = (type: string): boolean => {
return SHAPE_CONTAINER_TYPES.has(type);
};
export const normalizeImageElement = (el: any): any => {
return {
...el,
type: 'image',
status: el.status || 'saved',
fileId: el.fileId || null,
scale: el.scale || [1, 1],
angle: el.angle ?? 0,
strokeColor: el.strokeColor ?? 'transparent',
backgroundColor: el.backgroundColor ?? 'transparent',
fillStyle: el.fillStyle ?? 'hachure',
strokeWidth: el.strokeWidth ?? 1,
strokeStyle: el.strokeStyle ?? 'solid',
roughness: el.roughness ?? 1,
opacity: el.opacity ?? 100,
groupIds: el.groupIds ?? [],
roundness: el.roundness ?? null,
isDeleted: el.isDeleted ?? false,
boundElements: el.boundElements ?? null,
locked: el.locked ?? false,
link: el.link ?? null,
};
};
export const restoreBindings = (
convertedElements: any[],
originalElements: any[]
): any[] => {
const originalMap = new Map<string, any>();
for (const el of originalElements) {
if (el.id) originalMap.set(el.id, el);
}
return convertedElements.map(el => {
const orig = originalMap.get(el.id);
if (!orig) return el;
const patched = { ...el };
if (orig.startBinding !== undefined && !patched.startBinding) {
patched.startBinding = orig.startBinding;
}
if (orig.endBinding !== undefined && !patched.endBinding) {
patched.endBinding = orig.endBinding;
}
if (orig.boundElements !== undefined && !patched.boundElements) {
patched.boundElements = orig.boundElements;
}
if (orig.elbowed !== undefined && patched.elbowed === undefined) {
patched.elbowed = orig.elbowed;
}
return patched;
});
};
export const computeElementHash = (elements: readonly { id: string; version: number }[]): string => {
let h = String(elements.length);
for (let i = 0; i < elements.length; i++) {
h += elements[i]!.id;
h += elements[i]!.version;
}
return h;
};