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