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
+54 -19
View File
@@ -2,22 +2,24 @@ name: CI
on:
push:
branches: [ main, develop ]
branches: [main, develop]
pull_request:
branches: [ main, develop ]
branches: [main, develop]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
name: Build and Type Check
name: Build & Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
@@ -28,36 +30,34 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
- name: Type check
run: npm run type-check
- name: Build project
- name: Build
run: npm run build
- name: Unit & integration tests
run: npm test
- name: Check build artifacts
run: |
echo "Checking if build artifacts exist..."
test -f dist/index.js || (echo "dist/index.js not found" && exit 1)
test -f dist/server.js || (echo "dist/server.js not found" && exit 1)
test -d dist/frontend || (echo "dist/frontend not found" && exit 1)
echo "All build artifacts present!"
test -f dist/index.js
test -f dist/server.js
test -d dist/frontend
- name: Upload build artifacts
if: matrix.node-version == '20.x'
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: |
dist/
path: dist/
retention-days: 7
lint-check:
name: Lint Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -71,11 +71,46 @@ jobs:
- name: Check for TypeScript errors
run: npm run type-check
e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Install Playwright browsers
run: ./node_modules/.bin/playwright install --with-deps chromium
- name: Run E2E tests
run: npm run test:e2e
env:
EXCALIDRAW_DB_PATH: /tmp/excalidraw-e2e-ci.db
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
ci-status:
runs-on: ubuntu-latest
continue-on-error: false
name: CI Status Check
needs: [build-and-test, lint-check]
needs: [build-and-test, lint-check, e2e]
if: always()
permissions:
statuses: write
+54 -64
View File
@@ -1,144 +1,134 @@
name: Docker Build & Push
name: Docker Build
# PR validation and manual builds. Production pushes are handled by release.yml.
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
pull_request:
branches: [ main ]
branches: [main]
paths:
- 'Dockerfile*'
- 'src/**'
- 'frontend/**'
- 'package.json'
workflow_dispatch:
inputs:
push:
description: 'Push images to Docker Hub'
required: true
type: boolean
default: false
env:
REGISTRY: docker.io
IMAGE_NAME_MCP: sanjibdevnath/mcp-excalidraw-local
IMAGE_NAME_CANVAS: sanjibdevnath/mcp-excalidraw-local-canvas
jobs:
build-and-push-mcp:
name: Build and Push MCP Server Image
build-mcp:
name: Build MCP Server image
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for MCP Server
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}
images: ${{ env.IMAGE_NAME_MCP }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push MCP Server image
- name: Build MCP Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
build-and-push-canvas:
name: Build and Push Canvas Server Image
build-canvas:
name: Build Canvas Server image
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Canvas Server
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}
images: ${{ env.IMAGE_NAME_CANVAS }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Canvas Server image
- name: Build Canvas Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.canvas
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
test-docker-images:
name: Test Docker Images
needs: [build-and-push-mcp, build-and-push-canvas]
if: github.event_name != 'pull_request'
test-images:
name: Test Docker images
needs: [build-mcp, build-canvas]
runs-on: ubuntu-latest
steps:
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v4
- name: Test Canvas Server image
- name: Build and test Canvas image locally
run: |
docker pull ${{ env.IMAGE_NAME_CANVAS }}:latest
docker run -d -p 3000:3000 --name test-canvas ${{ env.IMAGE_NAME_CANVAS }}:latest
sleep 10
curl -f http://localhost:3000/health || exit 1
docker build -f Dockerfile.canvas -t canvas-test .
docker run -d -p 3000:3000 --name test-canvas canvas-test
HEALTHY=false
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/health > /dev/null 2>&1; then
echo "Health check passed on attempt $i"
HEALTHY=true
break
fi
echo "Waiting for server... ($i/30)"
sleep 1
done
if [ "$HEALTHY" != "true" ]; then
echo "::error::Health check failed after 30 attempts"
docker logs test-canvas
docker stop test-canvas || true
exit 1
fi
curl -sf http://localhost:3000/health | jq .
docker stop test-canvas
- name: Test MCP Server image
run: |
docker pull ${{ env.IMAGE_NAME_MCP }}:latest
echo "MCP Server image pulled successfully"
docker-status:
runs-on: ubuntu-latest
continue-on-error: false
name: Docker Build Status Check
needs: [build-and-push-mcp, build-and-push-canvas]
needs: [build-mcp, build-canvas, test-images]
if: always()
permissions:
statuses: write
+34 -62
View File
@@ -1,26 +1,30 @@
name: Publish to NPM
name: Publish to NPM (manual)
# Fallback for manual publishing. The primary publish path is release.yml.
# Use this for publishing beta/next tags or re-publishing a failed release.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g., latest, beta, next)'
description: 'NPM dist-tag (latest, beta, next)'
required: true
default: 'latest'
type: choice
options:
- latest
- beta
- next
jobs:
publish:
name: Publish to NPM Registry
name: Publish to NPM (${{ github.event.inputs.tag }})
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -32,75 +36,43 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run type check
- name: Type check
run: npm run type-check
- name: Build project
- name: Build
run: npm run build
- name: Unit & integration tests
run: npm test
- name: Verify build artifacts
run: |
echo "Verifying build artifacts..."
test -f dist/index.js || (echo "ERROR: dist/index.js not found" && exit 1)
test -f dist/server.js || (echo "ERROR: dist/server.js not found" && exit 1)
test -d dist/frontend || (echo "ERROR: dist/frontend not found" && exit 1)
echo "All required artifacts present!"
test -f dist/index.js
test -f dist/server.js
test -d dist/frontend
- name: Get package version
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Get version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Check if version exists on NPM
id: check-version
id: check
run: |
if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.package-version.outputs.version }} version 2>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.version.outputs.version }} version 2>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Version ${{ steps.package-version.outputs.version }} does not exist on NPM"
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to NPM (Release)
if: github.event_name == 'release' && steps.check-version.outputs.exists == 'false'
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to NPM (Manual)
if: github.event_name == 'workflow_dispatch' && steps.check-version.outputs.exists == 'false'
- name: Publish to NPM
if: steps.check.outputs.exists == 'false'
run: npm publish --tag ${{ github.event.inputs.tag }} --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Skip publishing (version exists)
if: steps.check-version.outputs.exists == 'true'
- name: Skip (version exists)
if: steps.check.outputs.exists == 'true'
run: |
echo "⚠️ Skipping publish - version ${{ steps.package-version.outputs.version }} already exists on NPM"
echo "Please bump the version in package.json before publishing"
- name: Create GitHub Release Assets
if: github.event_name == 'release'
run: |
tar -czf mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz dist/
- name: Upload Release Assets
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
with:
files: |
mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify:
name: Publish Notification
needs: publish
runs-on: ubuntu-latest
if: success()
steps:
- name: Success notification
run: |
echo "✅ Package successfully published to NPM!"
echo "View at: https://www.npmjs.com/package/@sanjibdevnath/mcp-excalidraw-local"
echo "⚠️ Version ${{ steps.version.outputs.version }} already on NPM"
echo "Bump the version in package.json first"
exit 1
+282
View File
@@ -0,0 +1,282 @@
name: Release & Publish
on:
push:
branches: [main]
paths-ignore:
- '*.md'
- 'docs/**'
- '.github/workflows/ci.yml'
permissions:
contents: write
id-token: write
jobs:
# Gate: only release if CI passed and there are releasable commits
check:
name: Check for releasable commits
runs-on: ubuntu-latest
outputs:
bump: ${{ steps.bump.outputs.bump }}
new_version: ${{ steps.bump.outputs.new_version }}
changelog: ${{ steps.bump.outputs.changelog }}
should_release: ${{ steps.bump.outputs.should_release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version bump from conventional commits
id: bump
run: |
# Find the latest semver tag
LATEST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -n1)
if [ -z "$LATEST_TAG" ]; then
LATEST_TAG=$(git rev-list --max-parents=0 HEAD)
echo "No tags found, using first commit"
fi
echo "Latest tag: $LATEST_TAG"
# Get commit messages since last tag
COMMITS=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s")
if [ -z "$COMMITS" ]; then
echo "No new commits since $LATEST_TAG"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Skip if the only commit is a version bump
NON_RELEASE_COMMITS=$(echo "$COMMITS" | grep -v "^chore(release):" || true)
if [ -z "$NON_RELEASE_COMMITS" ]; then
echo "Only release commits found, skipping"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Commits since $LATEST_TAG:"
echo "$COMMITS"
# Determine bump type from conventional commit prefixes
BUMP="patch"
if echo "$COMMITS" | grep -qiE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:"; then
BUMP="major"
elif echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?:"; then
BUMP="minor"
elif echo "$COMMITS" | grep -qiE "^✨"; then
BUMP="minor"
fi
# Read current version and compute new one
CURRENT=$(node -p "require('./package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
case "$BUMP" in
major) NEW_VERSION="$((MAJOR+1)).0.0" ;;
minor) NEW_VERSION="$MAJOR.$((MINOR+1)).0" ;;
patch) NEW_VERSION="$MAJOR.$MINOR.$((PATCH+1))" ;;
esac
echo "Current: $CURRENT → New: $NEW_VERSION ($BUMP)"
# Build changelog from conventional commits
CHANGELOG=""
BREAKING=$(echo "$COMMITS" | grep -iE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:" || true)
FEATURES=$(echo "$COMMITS" | grep -iE "^(feat|feature|✨)" || true)
FIXES=$(echo "$COMMITS" | grep -iE "^(fix|🐛)" || true)
OTHERS=$(echo "$COMMITS" | grep -viE "^(feat|feature|✨|fix|🐛|chore\(release\))" | grep -viE "BREAKING" || true)
if [ -n "$BREAKING" ]; then
CHANGELOG="$CHANGELOG
### ⚠️ Breaking Changes
$(echo "$BREAKING" | sed 's/^/- /')"
fi
if [ -n "$FEATURES" ]; then
CHANGELOG="$CHANGELOG
### ✨ Features
$(echo "$FEATURES" | sed 's/^/- /')"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="$CHANGELOG
### 🐛 Bug Fixes
$(echo "$FIXES" | sed 's/^/- /')"
fi
if [ -n "$OTHERS" ]; then
CHANGELOG="$CHANGELOG
### 📦 Other Changes
$(echo "$OTHERS" | sed 's/^/- /')"
fi
echo "bump=$BUMP" >> "$GITHUB_OUTPUT"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "should_release=true" >> "$GITHUB_OUTPUT"
# Multi-line output for changelog
{
echo "changelog<<CHANGELOG_EOF"
echo "$CHANGELOG"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
test:
name: Pre-release tests
needs: check
if: needs.check.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
- name: Unit & integration tests
run: npm test
release:
name: Version bump & release
needs: [check, test]
if: needs.check.outputs.should_release == 'true'
runs-on: ubuntu-latest
outputs:
version: ${{ needs.check.outputs.new_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump version in package.json
run: |
npm version ${{ needs.check.outputs.new_version }} --no-git-tag-version
git add package.json package-lock.json
git commit -m "chore(release): v${{ needs.check.outputs.new_version }}"
git tag "v${{ needs.check.outputs.new_version }}"
- name: Push version commit and tag
run: |
git push origin main
git push origin "v${{ needs.check.outputs.new_version }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.check.outputs.new_version }}
name: v${{ needs.check.outputs.new_version }}
body: |
## What's Changed in v${{ needs.check.outputs.new_version }}
${{ needs.check.outputs.changelog }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/v${{ needs.check.outputs.new_version }}...v${{ needs.check.outputs.new_version }}
---
```
npm install @sanjibdevnath/mcp-excalidraw-local@${{ needs.check.outputs.new_version }}
```
draft: false
prerelease: false
publish-npm:
name: Publish to NPM
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Check if version already on NPM
id: check-npm
run: |
VERSION=$(node -p "require('./package.json').version")
if npm view @sanjibdevnath/mcp-excalidraw-local@$VERSION version 2>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to NPM
if: steps.check-npm.outputs.exists == 'false'
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Published
if: steps.check-npm.outputs.exists == 'false'
run: echo "✅ Published v$(node -p "require('./package.json').version") to NPM"
- name: Skipped (already exists)
if: steps.check-npm.outputs.exists == 'true'
run: echo "⚠️ Version already on NPM, skipping"
publish-docker:
name: Build & push Docker images
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.release.outputs.version }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push MCP Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
sanjibdevnath/mcp-excalidraw-local:latest
sanjibdevnath/mcp-excalidraw-local:v${{ needs.release.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Build and push Canvas Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.canvas
push: true
tags: |
sanjibdevnath/mcp-excalidraw-local-canvas:latest
sanjibdevnath/mcp-excalidraw-local-canvas:v${{ needs.release.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
+5
View File
@@ -18,5 +18,10 @@ public/dist/
# Development artifacts
*.excalidraw
# Test artifacts
test-results/
playwright-report/
coverage/
docs/*
!docs/screenshots/
+2 -1
View File
@@ -33,12 +33,13 @@ RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
COPY --from=builder /app/dist ./dist
RUN chown -R nodejs:nodejs /app
RUN mkdir -p /app/data && chown -R nodejs:nodejs /app
USER nodejs
ENV NODE_ENV=production
ENV EXPRESS_SERVER_URL=http://localhost:3000
ENV ENABLE_CANVAS_SYNC=true
ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db
CMD ["node", "dist/index.js"]
+2 -1
View File
@@ -46,12 +46,13 @@ RUN apt-get purge -y python3 make g++ && apt-get autoremove -y
COPY --from=backend-builder /app/dist ./dist
COPY --from=frontend-builder /app/dist/frontend ./dist/frontend
RUN chown -R nodejs:nodejs /app
RUN mkdir -p /app/data && chown -R nodejs:nodejs /app
USER nodejs
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
ENV EXCALIDRAW_DB_PATH=/app/data/excalidraw.db
EXPOSE 3000
+331 -216
View File
@@ -37,14 +37,13 @@ Click the workspace badge to switch between isolated canvases — each workspace
## Table of Contents
- [Screenshots](#screenshots)
- [What It Is](#what-it-is)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Verify Installation](#verify-installation)
- [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp)
- [What Changed From Upstream](#what-changed-from-upstream)
- [What's New](#whats-new)
- [Architecture](#architecture)
- [Quick Start](#quick-start)
- [Quick Start (Docker)](#quick-start-docker)
- [Configuration](#configuration)
- [Environment Variables](#environment-variables)
- [Multi-Tenancy (Workspaces)](#multi-tenancy-workspaces)
- [Agent Skill (Optional)](#agent-skill-optional)
@@ -55,15 +54,233 @@ Click the workspace badge to switch between isolated canvases — each workspace
- [Development](#development)
- [Credits](#credits)
## What It Is
## Prerequisites
This MCP server gives AI agents a full canvas toolkit to build, inspect, and iteratively refine Excalidraw diagrams — including the ability to see what they drew.
| Requirement | Why | Check |
|---|---|---|
| **Node.js >= 18** (LTS 20 or 22 recommended) | Runtime | `node --version` |
| **C++ build tools** | `better-sqlite3` compiles native bindings | See below |
| **npm** (bundled with Node.js) | Package manager | `npm --version` |
The repo contains a single Node.js process that runs:
### C++ build tools by platform
- **MCP server** (stdio): 32 tools for element CRUD, layout, scene awareness, file I/O, snapshots, search, multi-tenancy, and more
- **Canvas server** (embedded): web UI + REST API + WebSocket updates at `http://localhost:<CANVAS_PORT>`
- **SQLite database**: persistent storage at `~/.excalidraw-mcp/excalidraw.db`
**macOS:**
```bash
xcode-select --install
```
**Ubuntu / Debian:**
```bash
sudo apt install build-essential python3
```
**Windows:**
```bash
npm install --global windows-build-tools
```
> `better-sqlite3` ships prebuilt binaries for most Node LTS versions. The build tools are only needed when a prebuilt binary isn't available for your platform/Node combination.
## Quick Start
### Path A: Interactive Setup (recommended for first-time users)
The setup wizard checks your environment, optionally installs the agent skill, and configures MCP clients — all interactively. Every step is skippable.
```bash
npx @sanjibdevnath/mcp-excalidraw-local setup
```
<details>
<summary>Example session</summary>
```
$ npx @sanjibdevnath/mcp-excalidraw-local setup
Excalidraw MCP — Setup
[1/3] Environment
✔ Node.js v22.12.0 .................. OK
✔ better-sqlite3 bindings ........... OK
✔ Frontend build .................... OK
[2/3] Agent Skill
Install the Excalidraw agent skill? [Y/n]: Y
Detected agents:
[1] Cursor (~/.cursor)
[2] Claude Code (~/.claude)
Which agents? (comma-separated, 'all', or 'skip'): all
Cursor — scope? [G]lobal / [l]ocal: G
✔ Installed to ~/.cursor/skills/excalidraw-skill/
Claude Code — scope? [G]lobal / [l]ocal: G
✔ Installed to ~/.claude/skills/excalidraw-skill/
[3/3] MCP Configuration
Add MCP server to agent configs automatically? [Y/n]: Y
Cursor — add to ~/.cursor/mcp.json? [Y/n]: Y
✔ Added 'excalidraw-canvas' to ~/.cursor/mcp.json
Claude Code — register via CLI? [Y/n]: Y
✔ Registered 'excalidraw-canvas' via Claude Code CLI
Done! Open http://localhost:3000 to verify the canvas.
```
</details>
> **The setup is fully optional.** If you prefer to configure everything manually, skip to Path B or C below.
### Path B: From Source
```bash
git clone https://github.com/sanjibdevnathlabs/mcp-excalidraw-local.git
cd mcp-excalidraw-local
npm install
npm run build
```
Then configure your MCP client — see [Configuration](#configuration).
To run manually (outside an MCP client):
```bash
node dist/index.js
```
Open `http://localhost:3000` in your browser.
### Path C: Docker
Canvas server:
```bash
docker run -d -p 3000:3000 --name mcp-excalidraw-canvas sanjibdevnath/mcp-excalidraw-local-canvas:latest
```
MCP server (stdio) is typically launched by your MCP client:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "CANVAS_PORT=3000",
"sanjibdevnath/mcp-excalidraw-local:latest"
]
}
}
}
```
> **Note:** For Docker on Linux, add `--add-host=host.docker.internal:host-gateway`.
## Configuration
This is a standard MCP server communicating over **stdio**. It works with any MCP-compatible client.
### Cursor
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (per-project):
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
"env": {
"CANVAS_PORT": "3000"
}
}
}
}
```
Or, if installed from source:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "node",
"args": ["/absolute/path/to/mcp-excalidraw-local/dist/index.js"],
"env": {
"CANVAS_PORT": "3000"
}
}
}
}
```
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
"env": {
"CANVAS_PORT": "3000"
}
}
}
}
```
### Claude Code
```bash
claude mcp add excalidraw-canvas --scope user \
-e CANVAS_PORT=3000 \
-- npx -y @sanjibdevnath/mcp-excalidraw-local
```
### Codex CLI
Add to `~/.codex/mcp.json`:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
"env": {
"CANVAS_PORT": "3000"
}
}
}
}
```
### Key points
- **Single process** — The canvas server is embedded. No separate terminal or process needed.
- **Browser required for screenshots** — `export_to_image` and `get_canvas_screenshot` rely on the frontend. Open `http://localhost:3000` in a browser.
## Verify Installation
After configuring your MCP client, verify everything works:
```bash
# 1. Check the canvas server is running
curl http://localhost:3000/health
# 2. Open the canvas in your browser
open http://localhost:3000
# 3. In your AI agent, ask it to:
# "Create a blue rectangle labeled 'Hello World' on the Excalidraw canvas"
```
If the health check fails, see [Troubleshooting](#troubleshooting).
## How We Differ from the Official Excalidraw MCP
@@ -89,8 +306,6 @@ Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mc
| **Multi-agent** | Single user | Multiple agents can draw on the same canvas concurrently |
| **Works without MCP** | No | Yes — REST API fallback via agent skill |
**TL;DR** — The official MCP generates diagrams. We give AI agents a full canvas toolkit to build, inspect, and iteratively refine diagrams — including the ability to see what they drew.
## What Changed From Upstream
This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw) with the following enhancements:
@@ -105,51 +320,6 @@ This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_exca
| **MCP tools** | 26 | 32 (added search, history, tenants, projects) |
| **Workspace switcher** | None | Dropdown with search in canvas UI |
| **Sync normalization** | Bound text breaks on reload | Elements normalized to MCP format before storage |
| **Projects** | None | Multiple projects per tenant |
| **Element history** | None | Full version history per element |
| **Search** | None | Full-text search across elements |
### New MCP Tools (6 added)
| Tool | Description |
|---|---|
| `search_elements` | Full-text search across element labels and text |
| `element_history` | View version history for any element |
| `list_projects` | List projects within the active tenant |
| `switch_project` | Switch between projects |
| `list_tenants` | List all workspace tenants |
| `switch_tenant` | Switch the active workspace tenant |
## What's New
### v1.0 — This Fork (Persistence & Multi-Tenancy)
- **SQLite persistence**: Elements, projects, tenants, snapshots, and element versions stored in `~/.excalidraw-mcp/excalidraw.db` with WAL mode and `busy_timeout` for multi-process safety
- **Multi-tenancy**: Each workspace gets an isolated canvas. Tenant auto-detected from workspace path via `server.listRoots()`. UI dropdown with search for switching workspaces
- **Embedded canvas**: Canvas server runs inside the MCP process — single `node dist/index.js` starts everything, stops together
- **Auto-sync with debounce**: Canvas changes are automatically persisted after 3s of inactivity. Manual sync button as fallback. Toggle auto-sync on/off
- **Configurable port**: `CANVAS_PORT` env var (default `3000`)
- **Sync normalization**: Excalidraw's internal bound-text representation is normalized to MCP format before storage, preventing text overflow/detachment on reload
- **6 new MCP tools**: `search_elements`, `element_history`, `list_projects`, `switch_project`, `list_tenants`, `switch_tenant`
- **Updated agent skill**: Comprehensive workflow playbook with iterative write-check-review cycle, sizing rules, anti-patterns, and quality checklist
- **Workspace switcher UI**: Click "Workspace: ..." badge to search and switch between workspaces
### v2.0 — Canvas Toolkit (upstream)
- 13 new MCP tools (26 total): `get_element`, `clear_canvas`, `export_scene`, `import_scene`, `export_to_image`, `duplicate_elements`, `snapshot_scene`, `restore_snapshot`, `describe_scene`, `get_canvas_screenshot`, `read_diagram_guide`, `export_to_excalidraw_url`, `set_viewport`
- **Closed feedback loop**: AI can now inspect the canvas (`describe_scene`) and see it (`get_canvas_screenshot` returns an image) — enabling iterative refinement
- **Design guide**: `read_diagram_guide` returns best-practice color palettes, sizing rules, layout patterns, and anti-patterns
- **Viewport control**: `set_viewport` with `scrollToContent`, `scrollToElementId`, or manual zoom/offset
- **File I/O**: export/import full `.excalidraw` JSON files
- **Snapshots**: save and restore named canvas states
- **Skill fallback**: Agent skill auto-detects MCP vs REST API mode
- Fixed all previously known issues: `align_elements` / `distribute_elements` fully implemented, points type normalization, removed invalid `label` type, `ungroup_elements` now errors on failure
### v1.x (upstream)
- Agent skill: `skills/excalidraw-skill/` (portable instructions + helper scripts for export/import and repeatable CRUD)
- Better testing loop: MCP Inspector CLI examples + browser screenshot checks
- Bugfixes: batch create now preserves element ids (fixes update/delete after batch); frontend entrypoint fixed
## Architecture
@@ -159,111 +329,6 @@ This fork extends [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_exca
- **SQLite**: Stored at `~/.excalidraw-mcp/excalidraw.db` by default. WAL mode + `busy_timeout` for multi-process safety.
- **Multi-tenancy**: Each workspace gets an isolated tenant (SHA-256 hash of workspace path). The UI shows a workspace switcher dropdown with search.
## Quick Start
### Option A: NPM (recommended)
```bash
npx @sanjibdevnath/mcp-excalidraw-local
```
Or install globally:
```bash
npm install -g @sanjibdevnath/mcp-excalidraw-local
mcp-excalidraw-local
```
### Option B: From source
**Prerequisites:** Node >= 18, npm or pnpm
```bash
git clone https://github.com/sanjibdevnathlabs/mcp-excalidraw-local.git
cd mcp-excalidraw-local
# Install dependencies (pnpm or npm)
pnpm install
pnpm rebuild better-sqlite3 esbuild
# Build frontend + server
pnpm run build
```
The MCP server is typically started by your MCP client — see [Configuration](#configuration). To run manually:
```bash
node dist/index.js
```
This starts the MCP server (stdio) **and** the canvas server. Open `http://localhost:3000` in your browser.
## Quick Start (Docker)
Canvas server:
```bash
docker run -d -p 3000:3000 --name mcp-excalidraw-canvas sanjibdevnath/mcp-excalidraw-local-canvas:latest
```
MCP server (stdio) is typically launched by your MCP client. If you want a local container, use `sanjibdevnath/mcp-excalidraw-local:latest`.
## Configuration
This is a standard MCP server communicating over **stdio**. It works with any MCP-compatible client (Cursor, Claude Desktop, Claude Code, Codex CLI, OpenCode, Gemini, or any other agent that supports the Model Context Protocol).
### JSON config (most clients)
Add this to your client's MCP configuration file:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "node",
"args": ["/absolute/path/to/mcp-excalidraw-local/dist/index.js"],
"env": {
"CANVAS_PORT": "3000"
}
}
}
}
```
Replace `/absolute/path/to/mcp-excalidraw-local` with the actual path where you cloned and built the repo.
### CLI-based registration
```bash
# Example for Claude Code
claude mcp add excalidraw-canvas --scope user \
-e CANVAS_PORT=3000 \
-- node /absolute/path/to/mcp-excalidraw-local/dist/index.js
```
### Docker
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "CANVAS_PORT=3000",
"sanjibdevnath/mcp-excalidraw-local:latest"
]
}
}
}
```
> **Note:** For Docker on Linux, you may need `--add-host=host.docker.internal:host-gateway`.
### Key points
- **Single process** — The canvas server is embedded. No separate terminal or process needed.
- **Browser required for screenshots** — `export_to_image` and `get_canvas_screenshot` rely on the frontend. Open `http://localhost:3000` in a browser.
## Environment Variables
| Variable | Description | Default |
@@ -272,7 +337,6 @@ claude mcp add excalidraw-canvas --scope user \
| `EXCALIDRAW_DB_PATH` | Path to the SQLite database file | `~/.excalidraw-mcp/excalidraw.db` |
| `EXCALIDRAW_EXPORT_DIR` | Allowed directory for file exports | `process.cwd()` |
| `EXPRESS_SERVER_URL` | Canvas server URL (only if running canvas separately) | `http://localhost:3000` |
| `ENABLE_CANVAS_SYNC` | Enable real-time canvas sync | `true` |
## Multi-Tenancy (Workspaces)
@@ -297,50 +361,31 @@ This repo includes a skill at `skills/excalidraw-skill/` that provides:
- **Cheatsheet** (`references/cheatsheet.md`): MCP tool and REST API reference for all 32 tools
- **Helper scripts** (`scripts/*.cjs`): export, import, clear, healthcheck, CRUD operations
The skill complements the MCP server by giving your AI agent structured workflows to follow.
### Install via Setup Wizard
### Install the Skill
The easiest way to install the skill:
```bash
npx @sanjibdevnath/mcp-excalidraw-local setup
```
The wizard detects your installed agents and lets you choose which ones get the skill.
### Install Manually
Copy the skill folder to your agent's skill directory:
```bash
# Claude Code
mkdir -p ~/.claude/skills
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
# Cursor
mkdir -p ~/.cursor/skills
cp -R skills/excalidraw-skill ~/.cursor/skills/excalidraw-skill
# Claude Code
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
# Codex CLI
mkdir -p ~/.codex/skills
cp -R skills/excalidraw-skill ~/.codex/skills/excalidraw-skill
# Or any agent that supports a skills directory
cp -R skills/excalidraw-skill /path/to/your/agent/skills/
```
To update an existing installation, remove the old folder first then re-copy.
### Use the Skill Scripts
All scripts respect `EXPRESS_SERVER_URL` (default `http://localhost:3000`) or accept `--url`.
```bash
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/healthcheck.cjs
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/export-elements.cjs --out diagram.elements.json
EXPRESS_SERVER_URL=http://127.0.0.1:3000 node skills/excalidraw-skill/scripts/import-elements.cjs --in diagram.elements.json --mode batch
```
### When the Skill Is Useful
- **Repository workflow**: export elements as JSON, commit it, and re-import later
- **Reliable refactors**: clear + re-import in `sync` mode to make canvas match a file
- **Automated smoke tests**: create/update/delete a known element to validate a deployment
- **Repeatable diagrams**: keep a library of element JSON snippets and import them
See `skills/excalidraw-skill/SKILL.md` and `skills/excalidraw-skill/references/cheatsheet.md`.
## MCP Tools (32 Total)
| Category | Tools |
@@ -385,28 +430,95 @@ npx @modelcontextprotocol/inspector --cli \
--tool-arg width=300 --tool-arg height=200
```
### Frontend Screenshots
If you use a browser automation tool for UI checks:
```bash
# Open the canvas and take a screenshot for verification
open http://127.0.0.1:3000
# Or use agent-browser, Playwright, Puppeteer, etc.
```
## Troubleshooting
- **Canvas not loading**: Ensure `CANVAS_PORT` isn't occupied by another process. Check `lsof -i :3000`.
- **Canvas not updating**: Confirm the MCP process is running and the browser is connected (check the status dot in the header).
- **Wrong workspace shown**: The MCP uses `server.listRoots()` to detect the workspace. Restart your MCP client if the workspace changed.
- **Elements missing after restart**: Check `~/.excalidraw-mcp/excalidraw.db` exists. If you previously ran the upstream (in-memory) version, data wasn't persisted.
- **Port conflict with multiple instances**: Set different `CANVAS_PORT` values for each workspace, or rely on multi-tenancy (same port, different tenants).
- **Updates/deletes fail after batch creation**: Ensure you are on a build that includes the batch id preservation fix.
### `better-sqlite3` compilation failure
This is the most common installation issue. `better-sqlite3` is a native Node.js module that requires C++ build tools.
**Symptoms:**
- `npm install` fails with `gyp ERR!` or `prebuild-install` errors
- `npx` command fails during installation
- Error: `Cannot find module 'better-sqlite3'` at runtime
**Fix:**
1. Install build tools for your platform (see [Prerequisites](#prerequisites))
2. Rebuild the module:
```bash
npm rebuild better-sqlite3
```
3. Or run the setup wizard which handles this automatically:
```bash
npx @sanjibdevnath/mcp-excalidraw-local setup
```
### EADDRINUSE (port already in use)
**Symptom:** Error `listen EADDRINUSE: address already in use :::3000`
**Fix:**
```bash
# Find what's using the port
lsof -i :3000
# Either kill the process or use a different port
CANVAS_PORT=3001 node dist/index.js
```
> The MCP server automatically detects and reuses an existing healthy canvas server on the same port, so this error is rare.
### Canvas not loading / "Frontend not found"
**Symptom:** Browser shows "Frontend not found" or blank page at `http://localhost:3000`
**Fix:**
```bash
npm run build # builds both frontend and server
node dist/index.js # restart
```
### NVM / path issues with npx
**Symptom:** `npx @sanjibdevnath/mcp-excalidraw-local` hangs or uses the wrong Node version.
**Fix:**
```bash
# Ensure you're using a supported Node version
nvm use 20 # or 22
# Clear npm cache if npx is stale
npm cache clean --force
# Try with explicit node path in your MCP config
which node # copy this path
```
Then use the full path in your MCP config:
```json
{
"mcpServers": {
"excalidraw-canvas": {
"command": "/Users/you/.nvm/versions/node/v22.12.0/bin/npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
"env": { "CANVAS_PORT": "3000" }
}
}
}
```
### Canvas not updating / elements not syncing
**Fix:**
- Confirm the MCP process is running and the browser is connected (check the green status dot in the header)
- Click the "Sync" button in the canvas header for a manual sync
### Wrong workspace shown
The MCP uses `server.listRoots()` to detect the workspace. Restart your MCP client if the workspace changed.
## Known Issues / TODO
All previously listed bugs from the upstream have been fixed. Remaining items:
- [ ] **Image export requires a browser**: `export_to_image` and `get_canvas_screenshot` rely on the frontend rendering. The canvas UI must be open in a browser.
- [ ] **`export_to_excalidraw_url` blocked**: Organizations that block `excalidraw.com` cannot use shareable URL export. Use `export_scene` for local `.excalidraw` files instead.
@@ -416,13 +528,13 @@ Contributions welcome!
```bash
# Type check
pnpm run type-check
npm run type-check
# Full build (frontend + server)
pnpm run build
npm run build
# Dev mode (watch)
pnpm run dev
npm run dev
```
### Database
@@ -442,10 +554,13 @@ The canvas server exposes a REST API alongside the WebSocket interface:
| POST | `/api/elements` | Create an element |
| PUT | `/api/elements/:id` | Update an element |
| DELETE | `/api/elements/:id` | Delete an element |
| DELETE | `/api/elements/clear` | Clear all elements |
| POST | `/api/elements/sync` | Sync all elements (bulk upsert) |
| GET | `/api/tenants` | List all tenants |
| GET | `/api/tenant/active` | Get the active tenant |
| PUT | `/api/tenant/active` | Set the active tenant |
| GET | `/api/settings/:key` | Read a setting |
| PUT | `/api/settings/:key` | Write a setting |
All endpoints accept an `X-Tenant-Id` header for per-request tenant scoping.
+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;
};
+1190 -141
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -18,12 +18,21 @@
"dev:server": "npx tsc --watch",
"production": "npm run build && npm run canvas",
"prepublishOnly": "npm run build",
"type-check": "npx tsc --noEmit"
"postinstall": "prebuild-install --runtime napi || node-gyp rebuild --directory node_modules/better-sqlite3 2>/dev/null || true",
"setup": "node dist/index.js setup",
"type-check": "npx tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run tests/backend/db.test.ts tests/frontend/helpers.test.ts",
"test:api": "vitest run tests/backend/api.test.ts",
"test:ws": "vitest run tests/backend/ws.test.ts",
"test:e2e": "npx playwright test",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.0",
"@excalidraw/mermaid-to-excalidraw": "^1.1.3",
"@modelcontextprotocol/sdk": "latest",
"@modelcontextprotocol/sdk": "^1.26.0",
"better-sqlite3": "^12.6.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
@@ -38,17 +47,22 @@
"zod-to-json-schema": "^3.22.3"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.19.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/supertest": "^7.2.0",
"@types/ws": "^8.5.10",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^4.1.0",
"concurrently": "^9.2.0",
"supertest": "^7.2.2",
"typescript": "^5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vitest": "^4.1.0"
},
"keywords": [
"mcp",
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30000,
expect: { timeout: 5000 },
fullyParallel: false,
retries: 0,
workers: 1,
reporter: 'list',
use: {
baseURL: 'http://localhost:3100',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'node tests/e2e/start-server.js',
port: 3100,
timeout: 15000,
reuseExistingServer: !process.env.CI,
env: {
CANVAS_PORT: '3100',
HOST: 'localhost',
EXCALIDRAW_DB_PATH: '/tmp/excalidraw-e2e-test.db',
},
},
});
+45 -1
View File
@@ -125,6 +125,50 @@ Example (diamonds h=160, gap needed ≥ 120):
If the gap is < 80px, arrows will be too short to see — especially with labels like "YES"/"NO".
## Workflow: Multi-Diagram Canvas (Spatial Organization)
Excalidraw's infinite canvas supports multiple diagrams coexisting side by side. **Never clear the canvas** to make room — place new diagrams spatially offset from existing ones.
### Before Drawing Anything
Always call `describe_scene` first. It reports:
- **Diagram Zones**: grouped elements with bounding boxes, labels, and element counts
- **Canvas bounding box**: the overall occupied area
- **Suggested placement**: a recommended `(x, y)` for the next diagram (300px right of existing content)
### Creating a New Diagram Alongside Existing Ones
1. **`describe_scene`** — read the suggested placement coordinates
2. **Offset your new diagram** to the suggested area (or 300px+ from the rightmost edge)
3. **Add a title text element** above your diagram (fontSize 24-28) as an identifier
4. **Create shapes** (Batch 1) with coordinates offset to the new area
5. **Create arrows** (Batch 2)
6. **`group_elements`** — group ALL elements of your new diagram together
7. **`set_viewport`** with `scrollToContent: true` to see everything
8. **Screenshot** to verify
### Example Layout
```
"Architecture" (group A) "User Flow" (group B)
x=0 to x=1100 x=1400 to x=2500
┌─────────────────────┐ ┌─────────────────────┐
│ │ 300px │ │
│ Diagram A │ gap │ Diagram B │
│ │ │ │
└─────────────────────┘ └─────────────────────┘
```
### Rules
- **Never call `clear_canvas`** unless the user explicitly asks to wipe everything
- **Always `describe_scene` before drawing** to see existing content
- **Group every diagram** so `describe_scene` reports it as a named zone
- **Add a title text element** as the first element of each diagram — this becomes the zone label
- **Use `search_elements`** to find specific diagrams by their title text
- **Use `set_viewport` with `scrollToElementId`** to navigate to a specific diagram
## Workflow: Draw A Diagram
### Phase 1: Plan
@@ -345,7 +389,7 @@ Use history to debug unexpected changes or audit what was modified.
| Using `create_from_mermaid` for final diagrams | Overlapping text, poor layout | Use `batch_create_elements` with coordinates |
| Shapes too small for text | Truncation, especially in diamonds | Use sizing formulas above |
| No gap between connected shapes | Arrows become invisible (0px length) | Maintain 120px+ vertical gap |
| Clearing canvas between diagrams | Loses previous work | Place diagrams side-by-side |
| Clearing canvas between diagrams | Loses previous work, requires `confirm: true` | Place diagrams side-by-side using spatial offset; call `describe_scene` first |
| Skipping screenshot verification | Invisible defects compound | Screenshot after EVERY batch |
| Shapes + arrows in one batch | Binding errors | Shapes first, arrows second |
| Default roughness (hand-drawn look) | Unprofessional for technical diagrams | Set `roughness: 0` on all elements |
+19
View File
@@ -44,6 +44,8 @@ function generateId(): string {
}
export function initDb(dbPath?: string): void {
if (db) return; // Already initialized
const resolvedPath = dbPath
|| process.env.EXCALIDRAW_DB_PATH
|| path.join(os.homedir(), '.excalidraw-mcp', 'excalidraw.db');
@@ -129,6 +131,11 @@ function runMigrations(): void {
UNIQUE(project_id, name)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_elements_project ON elements(project_id);
CREATE INDEX IF NOT EXISTS idx_elements_type ON elements(project_id, type);
CREATE INDEX IF NOT EXISTS idx_elements_deleted ON elements(project_id, is_deleted);
@@ -501,9 +508,21 @@ export function bulkReplaceElements(elements: ServerElement[], projectId?: strin
return tx();
}
// ── Settings ──
export function getSetting(key: string): string | undefined {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value;
}
export function setSetting(key: string, value: string): void {
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
}
export function closeDb(): void {
if (db) {
db.close();
db = undefined as any;
logger.info('SQLite database closed');
}
}
+222 -38
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`, {
method: 'DELETE',
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 (!response.ok) {
throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`);
if (count === 0) {
return {
content: [{ type: 'text', text: 'The canvas is already empty. Nothing to clear.' }]
};
}
const data = await response.json() as ApiResponse;
// 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: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}`
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 (!clearResponse.ok) {
throw new Error(`Failed to clear canvas: ${clearResponse.status} ${clearResponse.statusText}`);
}
const clearData = await clearResponse.json() as ApiResponse;
return {
content: [{
type: 'text',
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]) {
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;
+137 -10
View File
@@ -18,10 +18,13 @@ import {
BatchCreatedMessage,
SyncStatusMessage,
InitialElementsMessage,
Snapshot
Snapshot,
normalizeFontFamily,
ExcalidrawFile,
files
} from './types.js';
import * as store from './db.js';
import { listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
import { z } from 'zod';
import WebSocket from 'ws';
@@ -87,6 +90,15 @@ wss.on('connection', (ws: WebSocket) => {
};
ws.send(JSON.stringify(initialMessage));
// Send any stored files (image data)
if (files.size > 0) {
const allFiles: Record<string, ExcalidrawFile> = {};
for (const [id, file] of files) {
allFiles[id] = file;
}
ws.send(JSON.stringify({ type: 'files_added', files: allFiles }));
}
// Send sync status to new client
const syncMessage: SyncStatusMessage = {
type: 'sync_status',
@@ -108,7 +120,7 @@ wss.on('connection', (ws: WebSocket) => {
// Schema validation
const CreateElementSchema = z.object({
id: z.string().optional(), // Allow passing ID for MCP sync
id: z.string().optional(),
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
x: z.number(),
y: z.number(),
@@ -121,11 +133,12 @@ const CreateElementSchema = z.object({
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
originalText: z.string().optional(),
label: z.object({
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(),
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
@@ -136,7 +149,14 @@ const CreateElementSchema = z.object({
end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(),
endArrowhead: z.string().nullable().optional(),
startBinding: z.any().nullable().optional(),
endBinding: z.any().nullable().optional(),
boundElements: z.any().nullable().optional(),
elbowed: z.boolean().optional(),
// Image element properties
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
});
const UpdateElementSchema = z.object({
@@ -153,11 +173,12 @@ const UpdateElementSchema = z.object({
roughness: z.number().optional(),
opacity: z.number().optional(),
text: z.string().optional(),
originalText: z.string().optional(),
label: z.object({
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(),
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
@@ -170,7 +191,13 @@ const UpdateElementSchema = z.object({
end: z.object({ id: z.string() }).optional(),
startArrowhead: z.string().nullable().optional(),
endArrowhead: z.string().nullable().optional(),
startBinding: z.any().nullable().optional(),
endBinding: z.any().nullable().optional(),
boundElements: z.any().nullable().optional(),
elbowed: z.boolean().optional(),
fileId: z.string().optional(),
status: z.string().optional(),
scale: z.tuple([z.number(), z.number()]).optional(),
});
// API Routes
@@ -202,9 +229,11 @@ app.post('/api/elements', (req: Request, res: Response) => {
logger.info('Creating element via API', { type: params.type });
const id = params.id || generateId();
const normalizedFont = normalizeFontFamily(params.fontFamily);
const element: ServerElement = {
id,
...params,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
@@ -253,9 +282,11 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
});
}
const normalizedFont = normalizeFontFamily(updates.fontFamily);
const updatedElement: ServerElement = {
...existingElement,
...updates,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
updatedAt: new Date().toISOString(),
version: (existingElement.version || 0) + 1
};
@@ -526,11 +557,9 @@ function resolveArrowBindings(batchElements: ServerElement[], projectId?: string
el.y = finalStart.y;
el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]];
// Remove start/end refs (they were used for computation only)
delete (el as any).start;
delete (el as any).end;
// Set binding metadata for Excalidraw
// Keep start/end refs on the element — the frontend's
// convertToExcalidrawElements uses them to compute proper bindings
// (focus, gap, fixedPoint). Also set basic binding metadata for export.
if (startEl) {
(el as any).startBinding = {
elementId: startEl.id,
@@ -566,9 +595,11 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
elementsToCreate.forEach(elementData => {
const params = CreateElementSchema.parse(elementData);
const id = params.id || generateId();
const normalizedFont = normalizeFontFamily(params.fontFamily);
const element: ServerElement = {
id,
...params,
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1
@@ -714,6 +745,70 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
}
});
// ── Files API (image element data) ──
// Get all files
app.get('/api/files', (_req: Request, res: Response) => {
try {
const allFiles: Record<string, ExcalidrawFile> = {};
for (const [id, file] of files) {
allFiles[id] = file;
}
res.json({ success: true, files: allFiles });
} catch (error) {
logger.error('Error fetching files:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// Add files (image data)
app.post('/api/files', (req: Request, res: Response) => {
try {
const incoming = req.body.files;
if (!incoming || typeof incoming !== 'object') {
return res.status(400).json({ success: false, error: 'files object is required' });
}
const addedIds: string[] = [];
for (const [id, fileData] of Object.entries(incoming)) {
const file = fileData as ExcalidrawFile;
files.set(id, {
id,
mimeType: file.mimeType || 'image/png',
dataURL: file.dataURL,
created: file.created || Date.now(),
});
addedIds.push(id);
}
broadcast({
type: 'files_added',
files: incoming
});
res.json({ success: true, addedIds, count: addedIds.length });
} catch (error) {
logger.error('Error adding files:', error);
res.status(400).json({ success: false, error: (error as Error).message });
}
});
// Delete a file
app.delete('/api/files/:id', (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!files.has(id!)) {
return res.status(404).json({ success: false, error: `File ${id} not found` });
}
files.delete(id!);
broadcast({ type: 'file_deleted', fileId: id });
res.json({ success: true, message: `File ${id} deleted` });
} catch (error) {
logger.error('Error deleting file:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// Image export: request (MCP -> Express -> WebSocket -> Frontend)
interface PendingExport {
resolve: (data: { format: string; data: string }) => void;
@@ -1052,6 +1147,34 @@ app.put('/api/tenant/active', (req: Request, res: Response) => {
}
});
// ── Settings API ──
app.get('/api/settings/:key', (req: Request, res: Response) => {
try {
const { key } = req.params;
const value = store.getSetting(key!);
res.json({ success: true, key, value: value ?? null });
} catch (error) {
logger.error('Error reading setting:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.put('/api/settings/:key', (req: Request, res: Response) => {
try {
const { key } = req.params;
const { value } = req.body;
if (value === undefined || value === null) {
return res.status(400).json({ success: false, error: 'value is required' });
}
store.setSetting(key!, String(value));
res.json({ success: true, key, value: String(value) });
} catch (error) {
logger.error('Error writing setting:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
// Health check endpoint
app.get('/health', (req: Request, res: Response) => {
const projId = resolveTenantProject(req);
@@ -1099,6 +1222,10 @@ export function isCanvasServerOwned(): boolean {
}
export async function startCanvasServer(): Promise<void> {
// Ensure the database is initialized when running standalone (e.g. Docker: `node dist/server.js`).
// When launched via index.ts (MCP entry point), initDb() is a no-op on the second call.
initDb();
// Pre-flight: check if an existing healthy canvas server is already on this port.
// We do this BEFORE calling httpServer.listen() because Node's listen() can emit
// EADDRINUSE as an uncaught exception that bypasses our error handler.
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
/**
* Interactive setup wizard for mcp-excalidraw-local.
* Runs via: npx @sanjibdevnath/mcp-excalidraw-local setup
*
* Uses only Node.js built-ins — no third-party dependencies.
* Every phase is optional and skippable.
*/
import * as readline from 'readline';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ── Helpers ──────────────────────────────────────────────────
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const CYAN = '\x1b[36m';
const RESET = '\x1b[0m';
function ok(msg: string) { process.stdout.write(` ${GREEN}${RESET} ${msg}\n`); }
function fail(msg: string) { process.stdout.write(` ${RED}${RESET} ${msg}\n`); }
function warn(msg: string) { process.stdout.write(` ${YELLOW}${RESET} ${msg}\n`); }
function info(msg: string) { process.stdout.write(` ${msg}\n`); }
function heading(phase: string, title: string) {
process.stdout.write(`\n ${BOLD}[${phase}] ${title}${RESET}\n`);
}
function ask(rl: readline.Interface, prompt: string): Promise<string> {
return new Promise(resolve => rl.question(` ${prompt}`, resolve));
}
async function confirm(rl: readline.Interface, prompt: string, defaultYes = true): Promise<boolean> {
const hint = defaultYes ? '[Y/n]' : '[y/N]';
const answer = (await ask(rl, `${prompt} ${hint}: `)).trim().toLowerCase();
if (answer === '') return defaultYes;
return answer === 'y' || answer === 'yes';
}
// ── Agent Definitions ────────────────────────────────────────
interface AgentDef {
name: string;
detectPaths: string[];
skillBasePaths: { global: string; local: string };
mcpConfigType: 'json-file' | 'cli-command';
mcpConfigPath?: string;
mcpCliCommand?: string;
}
function getAgents(): AgentDef[] {
const home = os.homedir();
return [
{
name: 'Cursor',
detectPaths: [path.join(home, '.cursor')],
skillBasePaths: {
global: path.join(home, '.cursor', 'skills'),
local: path.join(process.cwd(), '.cursor', 'skills'),
},
mcpConfigType: 'json-file',
mcpConfigPath: path.join(home, '.cursor', 'mcp.json'),
},
{
name: 'Claude Code',
detectPaths: [path.join(home, '.claude')],
skillBasePaths: {
global: path.join(home, '.claude', 'skills'),
local: path.join(process.cwd(), '.claude', 'skills'),
},
mcpConfigType: 'cli-command',
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local',
},
{
name: 'Codex CLI',
detectPaths: [path.join(home, '.codex')],
skillBasePaths: {
global: path.join(home, '.codex', 'skills'),
local: path.join(process.cwd(), '.codex', 'skills'),
},
mcpConfigType: 'json-file',
mcpConfigPath: path.join(home, '.codex', 'mcp.json'),
},
];
}
function detectInstalledAgents(): AgentDef[] {
return getAgents().filter(a => a.detectPaths.some(p => fs.existsSync(p)));
}
// ── Phase 1: Environment Check ──────────────────────────────
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
heading('1/3', 'Environment');
let allOk = true;
// Node.js version
const nodeVersion = process.version;
const major = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
if (major >= 18) {
ok(`Node.js ${nodeVersion} ${'.' .repeat(Math.max(0, 24 - nodeVersion.length))} OK`);
} else {
fail(`Node.js ${nodeVersion} — requires >= 18.0.0`);
allOk = false;
}
// better-sqlite3 bindings
try {
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
ok('better-sqlite3 bindings ........... OK');
} catch {
fail('better-sqlite3 bindings ........... FAILED');
const doFix = await confirm(rl, 'Native module needs rebuild. Fix now?');
if (doFix) {
try {
info(`${DIM}Running npm rebuild better-sqlite3...${RESET}`);
execSync('npm rebuild better-sqlite3', {
stdio: 'inherit',
cwd: path.resolve(__dirname, '..'),
});
// Verify
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
ok('Rebuild successful');
} catch {
fail('Rebuild failed. Try manually:');
info(` cd ${path.resolve(__dirname, '..')}`);
info(' npm rebuild better-sqlite3');
info('');
info('Prerequisites:');
if (process.platform === 'darwin') {
info(' xcode-select --install');
} else if (process.platform === 'linux') {
info(' sudo apt install build-essential python3');
} else {
info(' npm install --global windows-build-tools');
}
allOk = false;
}
} else {
info('Manual fix: npm rebuild better-sqlite3');
allOk = false;
}
}
// Frontend build
const frontendIndex = path.resolve(__dirname, '..', 'dist', 'frontend', 'index.html');
if (fs.existsSync(frontendIndex)) {
ok('Frontend build .................... OK');
} else {
warn('Frontend build .................... NOT FOUND');
info(`Expected: ${frontendIndex}`);
info('Run: npm run build');
}
return allOk;
}
// ── Phase 2: Skill Installation ─────────────────────────────
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
heading('2/3', 'Agent Skill');
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
if (!wantSkill) {
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually if needed)${RESET}`);
return;
}
const agents = detectInstalledAgents();
if (agents.length === 0) {
warn('No supported agents detected (Cursor, Claude Code, Codex CLI).');
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually when ready)${RESET}`);
return;
}
process.stdout.write('\n Detected agents:\n');
agents.forEach((a, i) => {
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${a.name}\n`);
});
const selection = await ask(rl, "Which agents? (comma-separated, 'all', or 'skip'): ");
const trimmed = selection.trim().toLowerCase();
if (trimmed === 'skip' || trimmed === '') return;
let selectedAgents: AgentDef[];
if (trimmed === 'all') {
selectedAgents = agents;
} else {
const indices = trimmed.split(',').map(s => parseInt(s.trim(), 10) - 1);
selectedAgents = indices
.filter(i => i >= 0 && i < agents.length)
.map(i => agents[i]!);
}
if (selectedAgents.length === 0) {
warn('No valid agents selected.');
return;
}
const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill');
if (!fs.existsSync(skillSource)) {
fail(`Skill source not found at ${skillSource}`);
return;
}
for (const agent of selectedAgents) {
const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `);
const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global';
const destBase = agent.skillBasePaths[scope];
const destDir = path.join(destBase, 'excalidraw-skill');
try {
fs.mkdirSync(destDir, { recursive: true });
copyDirSync(skillSource, destDir);
ok(`Installed to ${destDir}`);
} catch (err) {
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
}
}
}
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
// ── Phase 3: MCP Configuration ──────────────────────────────
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
heading('3/3', 'MCP Configuration');
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
if (!wantConfig) {
printManualConfig();
return;
}
const agents = detectInstalledAgents();
if (agents.length === 0) {
warn('No supported agents detected.');
printManualConfig();
return;
}
for (const agent of agents) {
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
const doIt = await confirm(rl, `${agent.name} — add to ${agent.mcpConfigPath}?`);
if (!doIt) {
info(`${DIM}Skipped. Add manually later.${RESET}`);
continue;
}
try {
mergeJsonConfig(agent.mcpConfigPath);
ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`);
} catch (err) {
fail(`Failed: ${(err as Error).message}`);
info('Add manually:');
printManualConfig();
}
} else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) {
const doIt = await confirm(rl, `${agent.name} — register via CLI?`);
if (!doIt) {
info(`${DIM}Skipped.${RESET}`);
continue;
}
try {
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
ok(`Registered 'excalidraw-canvas' via ${agent.name} CLI`);
} catch (err) {
fail(`CLI registration failed: ${(err as Error).message}`);
info('Register manually:');
info(` ${agent.mcpCliCommand}`);
}
}
}
}
function mergeJsonConfig(configPath: string): void {
const mcpEntry = {
command: 'npx',
args: ['-y', '@sanjibdevnath/mcp-excalidraw-local'],
env: { CANVAS_PORT: '3000' },
};
let existing: any = {};
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf-8');
existing = JSON.parse(raw);
}
if (!existing.mcpServers) {
existing.mcpServers = {};
}
if (existing.mcpServers['excalidraw-canvas']) {
process.stdout.write(` ${YELLOW}Entry 'excalidraw-canvas' already exists — overwriting.${RESET}\n`);
}
existing.mcpServers['excalidraw-canvas'] = mcpEntry;
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
}
function printManualConfig(): void {
process.stdout.write(`
Manual config (JSON):
${DIM}{
"mcpServers": {
"excalidraw-canvas": {
"command": "npx",
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
"env": { "CANVAS_PORT": "3000" }
}
}
}${RESET}
`);
}
// ── Main ─────────────────────────────────────────────────────
export async function runSetup(): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
process.stdout.write(`\n ${BOLD}Excalidraw MCP — Setup${RESET}\n`);
try {
await phaseEnvironment(rl);
await phaseSkillInstall(rl);
await phaseMcpConfig(rl);
process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`);
} finally {
rl.close();
}
}
+53 -3
View File
@@ -106,7 +106,7 @@ export interface ExcalidrawBinding {
fixedPoint?: readonly [number, number] | null;
}
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw';
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'image';
// Excalidraw element types
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
@@ -116,7 +116,8 @@ export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
ARROW: 'arrow',
TEXT: 'text',
FREEDRAW: 'freedraw',
LINE: 'line'
LINE: 'line',
IMAGE: 'image'
} as const;
// Server-side element with metadata
@@ -136,9 +137,16 @@ export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
text: string;
};
points?: any;
originalText?: string;
// Arrow element binding: connect arrows to shapes by element ID
start?: { id: string };
end?: { id: string };
startBinding?: ExcalidrawBinding | null;
endBinding?: ExcalidrawBinding | null;
// Image element properties
fileId?: string;
status?: string;
scale?: [number, number];
}
// API Response types
@@ -183,7 +191,9 @@ export type WebSocketMessageType =
| 'canvas_cleared'
| 'export_image_request'
| 'set_viewport'
| 'tenant_switched';
| 'tenant_switched'
| 'files_added'
| 'file_deleted';
export interface InitialElementsMessage extends WebSocketMessage {
type: 'initial_elements';
@@ -289,6 +299,46 @@ export interface Snapshot {
createdAt: string;
}
// Excalidraw file (image) data — stored in-memory alongside element data
export interface ExcalidrawFile {
mimeType: string;
id: string;
dataURL: string;
created: number;
lastRetrieved?: number;
}
// In-memory file storage (image files are too large for SQLite row storage)
export const files = new Map<string, ExcalidrawFile>();
// Font family normalization: Excalidraw expects numeric IDs, but agents
// often send string names. Map common names to their numeric equivalents.
const FONT_FAMILY_MAP: Record<string, number> = {
'virgil': 1,
'hand-drawn': 1,
'excalifont': 1,
'helvetica': 2,
'arial': 2,
'sans-serif': 2,
'cascadia': 3,
'monospace': 3,
'courier': 3,
'comic shanns': 4,
'comic sans': 4,
'liberation sans': 5,
'nunito': 6,
'lilita one': 7,
};
export function normalizeFontFamily(value: string | number | undefined): number | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === 'number') return value;
const mapped = FONT_FAMILY_MAP[value.toLowerCase().trim()];
if (mapped !== undefined) return mapped;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? 1 : parsed;
}
// Storage is now handled by src/db.ts (SQLite).
// The Map exports below are kept only for backward compatibility with
// standalone server.ts usage; they are NOT used when the DB is active.
+432
View File
@@ -0,0 +1,432 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
// Dynamic import to ensure DB is initialized before module-level code in server.ts runs
let app: any;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
beforeEach(async () => {
dbPath = path.join(os.tmpdir(), `excalidraw-api-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
const mod = await import('../../src/server.js');
app = mod.default;
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Health ──────────────────────────────────────────────────
describe('GET /health', () => {
it('returns healthy status', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('healthy');
expect(res.body).toHaveProperty('elements_count');
expect(res.body).toHaveProperty('timestamp');
});
});
// ─── Elements CRUD ───────────────────────────────────────────
describe('GET /api/elements', () => {
it('returns empty list initially', async () => {
const res = await request(app).get('/api/elements');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.elements).toEqual([]);
expect(res.body.count).toBe(0);
});
it('returns elements after creation', async () => {
setElement('e1', makeElement({ id: 'e1' }));
setElement('e2', makeElement({ id: 'e2' }));
const res = await request(app).get('/api/elements');
expect(res.body.count).toBe(2);
});
});
describe('POST /api/elements', () => {
it('creates an element and returns it', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle', x: 10, y: 20, width: 100, height: 50 });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.element.type).toBe('rectangle');
expect(res.body.element.x).toBe(10);
expect(res.body.element).toHaveProperty('id');
});
it('accepts a custom id', async () => {
const res = await request(app)
.post('/api/elements')
.send({ id: 'custom-id', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 });
expect(res.body.element.id).toBe('custom-id');
});
it('rejects invalid element type', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'invalid-type', x: 0, y: 0 });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('rejects missing required fields', async () => {
const res = await request(app)
.post('/api/elements')
.send({ type: 'rectangle' });
expect(res.status).toBe(400);
});
});
describe('GET /api/elements/:id', () => {
it('returns element by id', async () => {
setElement('find-me', makeElement({ id: 'find-me', type: 'diamond' }));
const res = await request(app).get('/api/elements/find-me');
expect(res.status).toBe(200);
expect(res.body.element.id).toBe('find-me');
expect(res.body.element.type).toBe('diamond');
});
it('returns 404 for missing element', async () => {
const res = await request(app).get('/api/elements/nonexistent');
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
});
});
describe('PUT /api/elements/:id', () => {
it('updates an existing element', async () => {
setElement('up1', makeElement({ id: 'up1', x: 0 }));
const res = await request(app)
.put('/api/elements/up1')
.send({ x: 500, y: 600 });
expect(res.status).toBe(200);
expect(res.body.element.x).toBe(500);
expect(res.body.element.y).toBe(600);
});
it('returns 404 for non-existent element', async () => {
const res = await request(app)
.put('/api/elements/missing')
.send({ x: 1 });
expect(res.status).toBe(404);
});
});
describe('DELETE /api/elements/:id', () => {
it('deletes an existing element', async () => {
setElement('del1', makeElement({ id: 'del1' }));
const res = await request(app).delete('/api/elements/del1');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const getRes = await request(app).get('/api/elements/del1');
expect(getRes.status).toBe(404);
});
it('returns 404 for non-existent element', async () => {
const res = await request(app).delete('/api/elements/missing');
expect(res.status).toBe(404);
});
});
describe('DELETE /api/elements/clear', () => {
it('clears all elements', async () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
const res = await request(app).delete('/api/elements/clear');
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
const listRes = await request(app).get('/api/elements');
expect(listRes.body.count).toBe(0);
});
it('returns 0 count when already empty', async () => {
const res = await request(app).delete('/api/elements/clear');
expect(res.body.count).toBe(0);
});
});
// ─── Batch Create ────────────────────────────────────────────
describe('POST /api/elements/batch', () => {
it('creates multiple elements at once', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ type: 'ellipse', x: 200, y: 200, width: 80, height: 80 },
{ type: 'text', x: 50, y: 50, text: 'Hello' },
],
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(3);
expect(res.body.elements.length).toBe(3);
});
it('rejects non-array input', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({ elements: 'not-an-array' });
expect(res.status).toBe(400);
});
it('resolves arrow bindings between batch elements', async () => {
const res = await request(app)
.post('/api/elements/batch')
.send({
elements: [
{ id: 'box1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'box2', type: 'rectangle', x: 300, y: 0, width: 100, height: 50 },
{ id: 'arr1', type: 'arrow', x: 0, y: 0, start: { id: 'box1' }, end: { id: 'box2' } },
],
});
expect(res.status).toBe(200);
const arrow = res.body.elements.find((e: any) => e.id === 'arr1');
expect(arrow).toBeDefined();
expect(arrow.points).toBeDefined();
expect(arrow.startBinding).toBeDefined();
expect(arrow.endBinding).toBeDefined();
});
});
// ─── Search ──────────────────────────────────────────────────
describe('GET /api/elements/search', () => {
it('filters by type query param', async () => {
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
const res = await request(app).get('/api/elements/search?type=rectangle');
expect(res.body.count).toBe(1);
expect(res.body.elements[0].type).toBe('rectangle');
});
it('full-text search via q param', async () => {
setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } }));
setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } }));
const res = await request(app).get('/api/elements/search?q=Hello');
expect(res.body.count).toBe(1);
expect(res.body.elements[0].id).toBe('t1');
});
});
// ─── Sync ────────────────────────────────────────────────────
describe('POST /api/elements/sync', () => {
it('replaces all elements from frontend', async () => {
setElement('old', makeElement({ id: 'old' }));
const res = await request(app)
.post('/api/elements/sync')
.send({
elements: [
{ id: 'new1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 },
{ id: 'new2', type: 'ellipse', x: 50, y: 50, width: 20, height: 20 },
],
timestamp: new Date().toISOString(),
});
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
const listRes = await request(app).get('/api/elements');
expect(listRes.body.count).toBe(2);
expect(listRes.body.elements.map((e: any) => e.id).sort()).toEqual(['new1', 'new2']);
});
it('rejects non-array elements', async () => {
const res = await request(app)
.post('/api/elements/sync')
.send({ elements: 'nope', timestamp: new Date().toISOString() });
expect(res.status).toBe(400);
});
});
// ─── Snapshots ───────────────────────────────────────────────
describe('Snapshots API', () => {
it('POST creates and GET lists snapshots', async () => {
setElement('se1', makeElement({ id: 'se1' }));
const createRes = await request(app)
.post('/api/snapshots')
.send({ name: 'my-snap' });
expect(createRes.status).toBe(200);
expect(createRes.body.name).toBe('my-snap');
expect(createRes.body.elementCount).toBe(1);
const listRes = await request(app).get('/api/snapshots');
expect(listRes.body.count).toBe(1);
});
it('GET /api/snapshots/:name returns a specific snapshot', async () => {
setElement('s1', makeElement({ id: 's1' }));
await request(app).post('/api/snapshots').send({ name: 'get-snap' });
const res = await request(app).get('/api/snapshots/get-snap');
expect(res.status).toBe(200);
expect(res.body.snapshot.name).toBe('get-snap');
});
it('GET /api/snapshots/:name returns 404 for missing', async () => {
const res = await request(app).get('/api/snapshots/nonexistent');
expect(res.status).toBe(404);
});
it('POST rejects missing name', async () => {
const res = await request(app).post('/api/snapshots').send({});
expect(res.status).toBe(400);
});
});
// ─── Tenants API ─────────────────────────────────────────────
describe('Tenants API', () => {
it('GET /api/tenants returns tenant list', async () => {
const res = await request(app).get('/api/tenants');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.tenants)).toBe(true);
expect(res.body.tenants.length).toBeGreaterThanOrEqual(1);
});
it('GET /api/tenant/active returns current tenant', async () => {
const res = await request(app).get('/api/tenant/active');
expect(res.status).toBe(200);
expect(res.body.tenant.id).toBe('default');
});
it('PUT /api/tenant/active switches tenant', async () => {
const { ensureTenant } = await import('../../src/db.js');
ensureTenant('switch-test', 'Switch Test', '/test');
const res = await request(app)
.put('/api/tenant/active')
.send({ tenantId: 'switch-test' });
expect(res.status).toBe(200);
expect(res.body.tenant.id).toBe('switch-test');
});
it('PUT /api/tenant/active rejects missing tenantId', async () => {
const res = await request(app).put('/api/tenant/active').send({});
expect(res.status).toBe(400);
});
});
// ─── Settings API ────────────────────────────────────────────
describe('Settings API', () => {
it('GET returns null for missing key', async () => {
const res = await request(app).get('/api/settings/missing_key');
expect(res.status).toBe(200);
expect(res.body.value).toBeNull();
});
it('PUT + GET round-trips a value', async () => {
await request(app)
.put('/api/settings/my_key')
.send({ value: 'my_value' });
const res = await request(app).get('/api/settings/my_key');
expect(res.body.value).toBe('my_value');
});
it('PUT rejects missing value', async () => {
const res = await request(app)
.put('/api/settings/no_val')
.send({});
expect(res.status).toBe(400);
});
});
// ─── Sync Status ─────────────────────────────────────────────
describe('GET /api/sync/status', () => {
it('returns sync status', async () => {
const res = await request(app).get('/api/sync/status');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('elementCount');
expect(res.body).toHaveProperty('memoryUsage');
expect(res.body).toHaveProperty('websocketClients');
});
});
// ─── Tenant-scoped via X-Tenant-Id header ────────────────────
describe('Tenant-scoped requests via X-Tenant-Id', () => {
it('elements are isolated per tenant', async () => {
const { ensureTenant } = await import('../../src/db.js');
ensureTenant('tenant-a', 'A', '/a');
ensureTenant('tenant-b', 'B', '/b');
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'tenant-a')
.send({ type: 'rectangle', x: 0, y: 0, width: 10, height: 10 });
await request(app)
.post('/api/elements')
.set('X-Tenant-Id', 'tenant-b')
.send({ type: 'ellipse', x: 0, y: 0, width: 10, height: 10 });
const resA = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'tenant-a');
expect(resA.body.count).toBe(1);
expect(resA.body.elements[0].type).toBe('rectangle');
const resB = await request(app)
.get('/api/elements')
.set('X-Tenant-Id', 'tenant-b');
expect(resB.body.count).toBe(1);
expect(resB.body.elements[0].type).toBe('ellipse');
});
});
+426
View File
@@ -0,0 +1,426 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
initDb,
closeDb,
setElement,
getElement,
hasElement,
deleteElement,
getAllElements,
getElementCount,
clearElements,
queryElements,
searchElements,
getElementHistory,
getProjectHistory,
saveSnapshot,
getSnapshot,
listSnapshots,
ensureTenant,
setActiveTenant,
getActiveTenant,
getActiveTenantId,
listTenants,
createProject,
listProjects,
setActiveProject,
getActiveProject,
getActiveProjectId,
getDefaultProjectForTenant,
bulkReplaceElements,
getSetting,
setSetting,
} from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
function makeElement(overrides: Partial<ServerElement> = {}): ServerElement {
return {
id: `el-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: 'rectangle',
x: 100,
y: 200,
width: 150,
height: 80,
version: 1,
...overrides,
};
}
beforeEach(() => {
dbPath = path.join(os.tmpdir(), `excalidraw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
initDb(dbPath);
// Reset module-level active tenant/project to 'default' which initDb() always creates
setActiveTenant('default');
});
afterEach(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
// ─── Element CRUD ────────────────────────────────────────────
describe('Element CRUD', () => {
it('setElement + getElement round-trips correctly', () => {
const el = makeElement({ id: 'e1' });
setElement('e1', el);
const fetched = getElement('e1');
expect(fetched).toBeDefined();
expect(fetched!.id).toBe('e1');
expect(fetched!.type).toBe('rectangle');
expect(fetched!.x).toBe(100);
expect(fetched!.y).toBe(200);
});
it('hasElement returns true for existing, false for missing', () => {
expect(hasElement('missing')).toBe(false);
setElement('exists', makeElement({ id: 'exists' }));
expect(hasElement('exists')).toBe(true);
});
it('getElement returns undefined for non-existent id', () => {
expect(getElement('nope')).toBeUndefined();
});
it('setElement updates an existing element and increments version', () => {
const el = makeElement({ id: 'e1' });
setElement('e1', el);
const updated = makeElement({ id: 'e1', x: 999 });
setElement('e1', updated);
const fetched = getElement('e1');
expect(fetched!.x).toBe(999);
});
it('deleteElement soft-deletes and returns true', () => {
setElement('del1', makeElement({ id: 'del1' }));
expect(deleteElement('del1')).toBe(true);
expect(getElement('del1')).toBeUndefined();
expect(hasElement('del1')).toBe(false);
});
it('deleteElement returns false for non-existent id', () => {
expect(deleteElement('nope')).toBe(false);
});
it('deleted element can be re-created', () => {
setElement('recr', makeElement({ id: 'recr' }));
deleteElement('recr');
expect(getElement('recr')).toBeUndefined();
setElement('recr', makeElement({ id: 'recr', x: 42 }));
expect(getElement('recr')!.x).toBe(42);
});
it('getAllElements returns all non-deleted elements', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
setElement('c', makeElement({ id: 'c' }));
deleteElement('b');
const all = getAllElements();
expect(all.length).toBe(2);
expect(all.map(e => e.id).sort()).toEqual(['a', 'c']);
});
it('getElementCount returns correct count', () => {
expect(getElementCount()).toBe(0);
setElement('x', makeElement({ id: 'x' }));
setElement('y', makeElement({ id: 'y' }));
expect(getElementCount()).toBe(2);
deleteElement('x');
expect(getElementCount()).toBe(1);
});
it('clearElements soft-deletes all and returns count', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
setElement('c', makeElement({ id: 'c' }));
const count = clearElements();
expect(count).toBe(3);
expect(getAllElements()).toEqual([]);
expect(getElementCount()).toBe(0);
});
it('clearElements on empty canvas returns 0', () => {
expect(clearElements()).toBe(0);
});
});
// ─── Query & Search ──────────────────────────────────────────
describe('queryElements', () => {
it('filters by type', () => {
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
setElement('r2', makeElement({ id: 'r2', type: 'rectangle' }));
const rects = queryElements('rectangle');
expect(rects.length).toBe(2);
expect(rects.every(e => e.type === 'rectangle')).toBe(true);
});
it('filters by arbitrary property', () => {
setElement('a', makeElement({ id: 'a', x: 10, y: 20 }));
setElement('b', makeElement({ id: 'b', x: 10, y: 99 }));
const results = queryElements(undefined, { x: 10 });
expect(results.length).toBe(2);
});
it('returns all when no filters', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
expect(queryElements().length).toBe(2);
});
});
describe('searchElements (FTS)', () => {
it('finds elements by label text', () => {
setElement('t1', makeElement({ id: 't1', type: 'text', label: { text: 'Hello World' } }));
setElement('t2', makeElement({ id: 't2', type: 'text', label: { text: 'Goodbye' } }));
const results = searchElements('Hello');
expect(results.length).toBe(1);
expect(results[0]!.id).toBe('t1');
});
it('finds elements by type in FTS', () => {
setElement('r1', makeElement({ id: 'r1', type: 'rectangle' }));
setElement('e1', makeElement({ id: 'e1', type: 'ellipse' }));
const results = searchElements('rectangle');
expect(results.length).toBe(1);
expect(results[0]!.id).toBe('r1');
});
});
// ─── Version History ─────────────────────────────────────────
describe('Version History', () => {
it('records create and update operations', () => {
setElement('v1', makeElement({ id: 'v1' }));
setElement('v1', makeElement({ id: 'v1', x: 999 }));
const history = getElementHistory('v1');
expect(history.length).toBe(2);
expect(history[0]!.operation).toBe('update');
expect(history[1]!.operation).toBe('create');
});
it('records delete operation', () => {
setElement('v2', makeElement({ id: 'v2' }));
deleteElement('v2');
const history = getElementHistory('v2');
expect(history.length).toBe(2);
expect(history[0]!.operation).toBe('delete');
expect(history[1]!.operation).toBe('create');
});
it('getProjectHistory returns all operations across elements', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
deleteElement('a');
const history = getProjectHistory();
expect(history.length).toBe(3);
});
it('respects limit parameter', () => {
setElement('a', makeElement({ id: 'a' }));
setElement('b', makeElement({ id: 'b' }));
setElement('c', makeElement({ id: 'c' }));
const history = getProjectHistory(2);
expect(history.length).toBe(2);
});
});
// ─── Snapshots ───────────────────────────────────────────────
describe('Snapshots', () => {
it('save and retrieve a snapshot', () => {
const elements = [makeElement({ id: 's1' }), makeElement({ id: 's2' })];
saveSnapshot('snap1', elements);
const snapshot = getSnapshot('snap1');
expect(snapshot).toBeDefined();
expect(snapshot!.name).toBe('snap1');
expect(snapshot!.elements.length).toBe(2);
});
it('getSnapshot returns undefined for missing name', () => {
expect(getSnapshot('nonexistent')).toBeUndefined();
});
it('listSnapshots returns all snapshots with counts', () => {
saveSnapshot('snap-a', [makeElement()]);
saveSnapshot('snap-b', [makeElement(), makeElement()]);
const list = listSnapshots();
expect(list.length).toBe(2);
const snapB = list.find(s => s.name === 'snap-b');
expect(snapB!.elementCount).toBe(2);
});
it('saveSnapshot with same name overwrites', () => {
saveSnapshot('dup', [makeElement()]);
saveSnapshot('dup', [makeElement(), makeElement(), makeElement()]);
const snapshot = getSnapshot('dup');
expect(snapshot!.elements.length).toBe(3);
const list = listSnapshots();
expect(list.filter(s => s.name === 'dup').length).toBe(1);
});
});
// ─── Tenants ─────────────────────────────────────────────────
describe('Tenants', () => {
it('default tenant exists after initDb', () => {
const tenant = getActiveTenant();
expect(tenant).toBeDefined();
expect(tenant.id).toBe('default');
});
it('ensureTenant creates a new tenant', () => {
const t = ensureTenant('t1', 'Test Tenant', '/workspace/test');
expect(t.id).toBe('t1');
expect(t.name).toBe('Test Tenant');
expect(t.workspace_path).toBe('/workspace/test');
});
it('ensureTenant is idempotent', () => {
ensureTenant('t1', 'Test', '/path');
const t2 = ensureTenant('t1', 'Test', '/path');
expect(t2.id).toBe('t1');
const tenants = listTenants();
expect(tenants.filter(t => t.id === 't1').length).toBe(1);
});
it('setActiveTenant switches the active tenant', () => {
ensureTenant('t2', 'Tenant 2', '/t2');
setActiveTenant('t2');
expect(getActiveTenantId()).toBe('t2');
});
it('setActiveTenant throws for non-existent tenant', () => {
expect(() => setActiveTenant('no-such')).toThrow();
});
it('listTenants returns all tenants', () => {
ensureTenant('a', 'A', '/a');
ensureTenant('b', 'B', '/b');
const tenants = listTenants();
expect(tenants.length).toBeGreaterThanOrEqual(3); // default + a + b
});
});
// ─── Projects ────────────────────────────────────────────────
describe('Projects', () => {
it('default project exists', () => {
const project = getActiveProject();
expect(project).toBeDefined();
expect(project.id).toBe('default');
});
it('createProject creates and can be listed', () => {
const proj = createProject('My Project', 'A test project');
expect(proj.name).toBe('My Project');
const projects = listProjects();
expect(projects.some(p => p.name === 'My Project')).toBe(true);
});
it('setActiveProject changes the active project', () => {
const proj = createProject('Switch Me');
setActiveProject(proj.id);
expect(getActiveProjectId()).toBe(proj.id);
});
it('setActiveProject throws for non-existent project', () => {
expect(() => setActiveProject('fake')).toThrow();
});
it('elements are scoped to the active project', () => {
const proj1 = createProject('P1');
const proj2 = createProject('P2');
setActiveProject(proj1.id);
setElement('e1', makeElement({ id: 'e1' }));
setActiveProject(proj2.id);
setElement('e2', makeElement({ id: 'e2' }));
setActiveProject(proj1.id);
expect(getAllElements().length).toBe(1);
expect(getAllElements()[0]!.id).toBe('e1');
setActiveProject(proj2.id);
expect(getAllElements().length).toBe(1);
expect(getAllElements()[0]!.id).toBe('e2');
});
it('getDefaultProjectForTenant creates a default project if none exists', () => {
ensureTenant('orphan', 'Orphan', '/orphan');
const projId = getDefaultProjectForTenant('orphan');
expect(projId).toBe('orphan-default');
});
});
// ─── Settings ────────────────────────────────────────────────
describe('Settings', () => {
it('getSetting returns undefined for missing key', () => {
expect(getSetting('nonexistent')).toBeUndefined();
});
it('setSetting + getSetting round-trips', () => {
setSetting('theme', 'dark');
expect(getSetting('theme')).toBe('dark');
});
it('setSetting overwrites existing value', () => {
setSetting('key', 'val1');
setSetting('key', 'val2');
expect(getSetting('key')).toBe('val2');
});
});
// ─── Bulk Operations ─────────────────────────────────────────
describe('bulkReplaceElements', () => {
it('replaces all elements atomically', () => {
setElement('old1', makeElement({ id: 'old1' }));
setElement('old2', makeElement({ id: 'old2' }));
const newElements = [makeElement({ id: 'new1' }), makeElement({ id: 'new2' }), makeElement({ id: 'new3' })];
const count = bulkReplaceElements(newElements);
expect(count).toBe(3);
const all = getAllElements();
expect(all.length).toBe(3);
expect(all.map(e => e.id).sort()).toEqual(['new1', 'new2', 'new3']);
});
it('replaces with empty array clears all', () => {
setElement('x', makeElement({ id: 'x' }));
bulkReplaceElements([]);
expect(getAllElements()).toEqual([]);
});
});
+255
View File
@@ -0,0 +1,255 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { initDb, closeDb, setElement, clearElements } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import WebSocket from 'ws';
import path from 'path';
import os from 'os';
import fs from 'fs';
let dbPath: string;
let port: number;
let startCanvasServer: () => Promise<void>;
let stopCanvasServer: () => Promise<void>;
/**
* Connect a WS client and immediately start buffering all messages.
* Returns the ws handle + a collected messages array.
*/
function connectAndCollect(): Promise<{ ws: WebSocket; messages: any[] }> {
return new Promise((resolve, reject) => {
const messages: any[] = [];
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('message', (raw) => messages.push(JSON.parse(raw.toString())));
ws.on('open', () => {
// Give the server a moment to push initial messages
setTimeout(() => resolve({ ws, messages }), 300);
});
ws.on('error', reject);
});
}
function waitForMessageOfType(ws: WebSocket, type: string, timeoutMs = 5000): Promise<any> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout waiting for message type: ${type}`)), timeoutMs);
const handler = (data: WebSocket.RawData) => {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timer);
ws.off('message', handler);
resolve(msg);
}
};
ws.on('message', handler);
});
}
function connectClient(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:${port}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
}
function drainInitialMessages(ws: WebSocket): Promise<void> {
return new Promise((resolve) => {
let count = 0;
const handler = () => {
count++;
if (count >= 3) {
ws.off('message', handler);
resolve();
}
};
ws.on('message', handler);
setTimeout(() => {
ws.off('message', handler);
resolve();
}, 1000);
});
}
beforeAll(async () => {
port = 3200 + Math.floor(Math.random() * 100);
process.env.CANVAS_PORT = String(port);
process.env.HOST = 'localhost';
dbPath = path.join(os.tmpdir(), `excalidraw-ws-test-${Date.now()}.db`);
initDb(dbPath);
const mod = await import('../../src/server.js');
startCanvasServer = mod.startCanvasServer;
stopCanvasServer = mod.stopCanvasServer;
await startCanvasServer();
});
afterAll(async () => {
await stopCanvasServer();
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbPath + suffix); } catch {}
}
});
beforeEach(() => {
clearElements();
});
describe('WebSocket connection', () => {
it('connects and receives tenant_switched, initial_elements, sync_status', async () => {
const { ws, messages } = await connectAndCollect();
const types = messages.map(m => m.type);
expect(types).toContain('tenant_switched');
expect(types).toContain('initial_elements');
expect(types).toContain('sync_status');
const initMsg = messages.find(m => m.type === 'initial_elements');
expect(Array.isArray(initMsg.elements)).toBe(true);
const syncMsg = messages.find(m => m.type === 'sync_status');
expect(syncMsg).toHaveProperty('elementCount');
ws.close();
});
it('receives initial_elements with existing data', async () => {
setElement('init-el', {
id: 'init-el', type: 'rectangle', x: 10, y: 20, width: 100, height: 50, version: 1,
} as ServerElement);
const { ws, messages } = await connectAndCollect();
const initMsg = messages.find(m => m.type === 'initial_elements');
expect(initMsg).toBeDefined();
expect(initMsg.elements.length).toBe(1);
expect(initMsg.elements[0].id).toBe('init-el');
ws.close();
});
});
describe('WebSocket broadcasts', () => {
it('broadcasts element_created on POST /api/elements', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const createdPromise = waitForMessageOfType(ws, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 }),
});
const msg = await createdPromise;
expect(msg.element.type).toBe('rectangle');
ws.close();
});
it('broadcasts element_deleted on DELETE /api/elements/:id', async () => {
setElement('del-ws', {
id: 'del-ws', type: 'ellipse', x: 0, y: 0, width: 30, height: 30, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const deletedPromise = waitForMessageOfType(ws, 'element_deleted');
await fetch(`http://localhost:${port}/api/elements/del-ws`, { method: 'DELETE' });
const msg = await deletedPromise;
expect(msg.elementId).toBe('del-ws');
ws.close();
});
it('broadcasts element_updated on PUT /api/elements/:id', async () => {
setElement('upd-ws', {
id: 'upd-ws', type: 'rectangle', x: 0, y: 0, width: 50, height: 50, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const updatedPromise = waitForMessageOfType(ws, 'element_updated');
await fetch(`http://localhost:${port}/api/elements/upd-ws`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: 999 }),
});
const msg = await updatedPromise;
expect(msg.element.x).toBe(999);
ws.close();
});
it('broadcasts canvas_cleared on DELETE /api/elements/clear', async () => {
setElement('clr1', {
id: 'clr1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10, version: 1,
} as ServerElement);
const ws = await connectClient();
await drainInitialMessages(ws);
const clearedPromise = waitForMessageOfType(ws, 'canvas_cleared');
await fetch(`http://localhost:${port}/api/elements/clear`, { method: 'DELETE' });
const msg = await clearedPromise;
expect(msg.type).toBe('canvas_cleared');
expect(msg).toHaveProperty('timestamp');
ws.close();
});
it('broadcasts elements_batch_created on POST /api/elements/batch', async () => {
const ws = await connectClient();
await drainInitialMessages(ws);
const batchPromise = waitForMessageOfType(ws, 'elements_batch_created');
await fetch(`http://localhost:${port}/api/elements/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
],
}),
});
const msg = await batchPromise;
expect(msg.elements.length).toBe(2);
ws.close();
});
it('broadcasts to multiple connected clients', async () => {
const ws1 = await connectClient();
const ws2 = await connectClient();
await drainInitialMessages(ws1);
await drainInitialMessages(ws2);
const promise1 = waitForMessageOfType(ws1, 'element_created');
const promise2 = waitForMessageOfType(ws2, 'element_created');
await fetch(`http://localhost:${port}/api/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'diamond', x: 0, y: 0, width: 60, height: 60 }),
});
const [msg1, msg2] = await Promise.all([promise1, promise2]);
expect(msg1.element.type).toBe('diamond');
expect(msg2.element.type).toBe('diamond');
ws1.close();
ws2.close();
});
});
+273
View File
@@ -0,0 +1,273 @@
import { test, expect } from '@playwright/test';
const API = 'http://localhost:3100';
test.beforeEach(async ({ request }) => {
await request.delete(`${API}/api/elements/clear`);
});
// ─── Page Load ───────────────────────────────────────────────
test.describe('Page Load', () => {
test('canvas page loads successfully', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.header h1')).toContainText('Excalidraw Canvas');
});
test('shows connected status after WebSocket connects', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
});
test('has Clear Canvas button', async ({ page }) => {
await page.goto('/');
await expect(page.locator('button:has-text("Clear Canvas")')).toBeVisible();
});
test('has Sync button', async ({ page }) => {
await page.goto('/');
await expect(page.locator('button:has-text("Sync")')).toBeVisible();
});
});
// ─── Health Endpoint ─────────────────────────────────────────
test.describe('Health Endpoint', () => {
test('returns healthy status', async ({ request }) => {
const res = await request.get(`${API}/health`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.status).toBe('healthy');
});
});
// ─── API Element CRUD via Playwright request ─────────────────
test.describe('Element CRUD via API', () => {
test('create and list elements', async ({ request }) => {
const createRes = await request.post(`${API}/api/elements`, {
data: {
id: 'e2e-rect-1',
type: 'rectangle',
x: 100,
y: 100,
width: 200,
height: 100,
backgroundColor: '#ff6b6b',
},
});
expect(createRes.ok()).toBe(true);
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
expect(listBody.count).toBe(1);
expect(listBody.elements[0].id).toBe('e2e-rect-1');
});
test('batch create elements', async ({ request }) => {
const batchRes = await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ id: 'batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
{ id: 'batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
{ id: 'batch-3', type: 'text', x: 50, y: 100, text: 'E2E Test' },
],
},
});
expect(batchRes.ok()).toBe(true);
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
expect(listBody.count).toBe(3);
});
test('delete element', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { id: 'del-e2e', type: 'rectangle', x: 50, y: 50, width: 100, height: 100 },
});
const delRes = await request.delete(`${API}/api/elements/del-e2e`);
expect(delRes.ok()).toBe(true);
const checkRes = await request.get(`${API}/api/elements/del-e2e`);
expect(checkRes.status()).toBe(404);
});
test('clear all elements', async ({ request }) => {
await request.post(`${API}/api/elements/batch`, {
data: {
elements: [
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
],
},
});
const clearRes = await request.delete(`${API}/api/elements/clear`);
expect(clearRes.ok()).toBe(true);
const clearBody = await clearRes.json();
expect(clearBody.count).toBe(2);
const listRes = await request.get(`${API}/api/elements`);
const listBody = await listRes.json();
expect(listBody.count).toBe(0);
});
});
// ─── Real-time Sync ──────────────────────────────────────────
test.describe('Real-time Canvas Sync', () => {
test('element created via API appears in canvas', async ({ page, request }) => {
await page.goto('/');
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
await page.waitForTimeout(500);
await request.post(`${API}/api/elements`, {
data: {
id: 'sync-rect',
type: 'rectangle',
x: 100,
y: 100,
width: 200,
height: 100,
},
});
await page.waitForTimeout(1000);
// Verify element exists in backend
const verifyRes = await request.get(`${API}/api/elements/sync-rect`);
expect(verifyRes.ok()).toBe(true);
});
test('canvas_cleared broadcast clears the canvas', async ({ page, request }) => {
await page.goto('/');
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
await page.waitForTimeout(500);
await request.post(`${API}/api/elements`, {
data: { id: 'clear-test', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
});
await page.waitForTimeout(300);
await request.delete(`${API}/api/elements/clear`);
await page.waitForTimeout(500);
const listRes = await request.get(`${API}/api/elements`);
const body = await listRes.json();
expect(body.count).toBe(0);
});
});
// ─── Clear Canvas UI Confirmation ────────────────────────────
test.describe('Clear Canvas UI Confirmation', () => {
test('Clear Canvas button shows confirmation dialog', async ({ page, request }) => {
// Reset the skip-confirm preference
await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, {
data: { value: 'false' },
});
await page.goto('/');
await page.waitForTimeout(500);
await page.locator('button:has-text("Clear Canvas")').click();
const dialog = page.locator('.confirm-dialog');
await expect(dialog).toBeVisible({ timeout: 2000 });
await expect(dialog.locator('.confirm-title')).toContainText('Clear Canvas');
});
test('Cancel closes the confirmation dialog', async ({ page, request }) => {
await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, {
data: { value: 'false' },
});
await page.goto('/');
await page.waitForTimeout(500);
await page.locator('button:has-text("Clear Canvas")').click();
await expect(page.locator('.confirm-dialog')).toBeVisible();
await page.locator('.confirm-dialog button:has-text("Cancel")').click();
await expect(page.locator('.confirm-dialog')).not.toBeVisible();
});
test('Confirm button clears the canvas', async ({ page, request }) => {
await request.put(`${API}/api/settings/clear_canvas_skip_confirm`, {
data: { value: 'false' },
});
await request.post(`${API}/api/elements`, {
data: { type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
});
await page.goto('/');
await page.waitForTimeout(500);
await page.locator('button:has-text("Clear Canvas")').click();
await expect(page.locator('.confirm-dialog')).toBeVisible();
await page.locator('.confirm-dialog button:has-text("Clear")').click();
await expect(page.locator('.confirm-dialog')).not.toBeVisible();
await page.waitForTimeout(1000);
const listRes = await request.get(`${API}/api/elements`);
const body = await listRes.json();
expect(body.count).toBe(0);
});
});
// ─── Snapshots ───────────────────────────────────────────────
test.describe('Snapshots via API', () => {
test('create and list snapshots', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
});
const snapRes = await request.post(`${API}/api/snapshots`, {
data: { name: 'e2e-snapshot' },
});
expect(snapRes.ok()).toBe(true);
const listRes = await request.get(`${API}/api/snapshots`);
const listBody = await listRes.json();
expect(listBody.snapshots.some((s: any) => s.name === 'e2e-snapshot')).toBe(true);
});
test('get snapshot by name', async ({ request }) => {
await request.post(`${API}/api/elements`, {
data: { type: 'ellipse', x: 10, y: 10, width: 30, height: 30 },
});
await request.post(`${API}/api/snapshots`, {
data: { name: 'get-snap' },
});
const res = await request.get(`${API}/api/snapshots/get-snap`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.snapshot.name).toBe('get-snap');
});
});
// ─── Settings via API ────────────────────────────────────────
test.describe('Settings via API', () => {
test('set and get a setting', async ({ request }) => {
await request.put(`${API}/api/settings/e2e_key`, {
data: { value: 'e2e_value' },
});
const res = await request.get(`${API}/api/settings/e2e_key`);
const body = await res.json();
expect(body.value).toBe('e2e_value');
});
test('returns null for missing key', async ({ request }) => {
const res = await request.get(`${API}/api/settings/nonexistent_key`);
const body = await res.json();
expect(body.value).toBeNull();
});
});
+6
View File
@@ -0,0 +1,6 @@
import { initDb } from '../../dist/db.js';
import { startCanvasServer } from '../../dist/server.js';
const dbPath = process.env.EXCALIDRAW_DB_PATH || '/tmp/excalidraw-e2e-test.db';
initDb(dbPath);
await startCanvasServer();
+218
View File
@@ -0,0 +1,218 @@
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);
});
});
+12
View File
@@ -7,6 +7,18 @@ export default defineConfig({
build: {
outDir: '../dist/frontend',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks(id) {
if (
id.includes('@excalidraw/excalidraw') &&
id.includes('subset')
) {
return id.split('/').pop()?.replace(/\.[^.]+$/, '');
}
},
},
},
},
server: {
port: 5173,
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['tests/backend/**/*.test.ts', 'tests/frontend/**/*.test.ts'],
exclude: ['tests/e2e/**'],
testTimeout: 15000,
hookTimeout: 15000,
pool: 'forks',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/setup.ts', 'src/index.ts'],
},
},
resolve: {
alias: {
// Vitest resolves .js imports to .ts source files
},
},
});