Compare commits
+151
-48
@@ -2,80 +2,183 @@ 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
|
||||
check-changes:
|
||||
name: Check for app changes
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
outputs:
|
||||
should_test: ${{ steps.filter.outputs.should_test }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Check build artifacts
|
||||
- name: Check changed files
|
||||
id: filter
|
||||
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!"
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "should_test=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD || true)
|
||||
if echo "$CHANGED" | grep -qE '^(src/|frontend/|tests/|package\.json|package-lock\.json|tsconfig\.json|vite\.config|vitest\.config|playwright\.config)'; then
|
||||
echo "should_test=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No application code changed, skipping tests"
|
||||
echo "should_test=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: matrix.node-version == '20.x'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
dist/
|
||||
retention-days: 7
|
||||
|
||||
lint-check:
|
||||
name: Lint Check
|
||||
setup:
|
||||
name: Install & Build
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should_test == 'true'
|
||||
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
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache node_modules
|
||||
id: cache-nm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-nm.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Check for TypeScript errors
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
test:
|
||||
name: Unit & Integration Tests
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Restore node_modules from cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
lint:
|
||||
name: Lint & Type Check
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Restore node_modules from cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Restore node_modules from cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Download build output
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- name: Check build artifacts
|
||||
run: |
|
||||
test -f dist/index.js
|
||||
test -f dist/server.js
|
||||
test -d dist/frontend
|
||||
|
||||
- name: Get Playwright version
|
||||
id: pw-version
|
||||
run: echo "version=$(node -e "console.log(require('./node_modules/@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: cache-pw
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.cache-pw.outputs.cache-hit != 'true'
|
||||
run: ./node_modules/.bin/playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.cache-pw.outputs.cache-hit == 'true'
|
||||
run: ./node_modules/.bin/playwright install-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: [check-changes, setup, test, lint, e2e]
|
||||
if: always()
|
||||
permissions:
|
||||
statuses: write
|
||||
|
||||
@@ -1,144 +1,163 @@
|
||||
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]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push:
|
||||
description: 'Push images to Docker Hub'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
check-changes:
|
||||
name: Check for Docker-related changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
outputs:
|
||||
should_build: ${{ steps.filter.outputs.should_build }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check changed files
|
||||
id: filter
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "should_build=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
CHANGED=$(git diff --name-only origin/main...HEAD || true)
|
||||
if echo "$CHANGED" | grep -qE '^(Dockerfile|src/|frontend/|package\.json)'; then
|
||||
echo "should_build=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No Docker-related files changed, skipping builds"
|
||||
echo "should_build=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build-mcp:
|
||||
name: Build MCP Server image
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
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]
|
||||
if: needs.build-mcp.result == 'success' && needs.build-canvas.result == 'success'
|
||||
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: [check-changes, 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: 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,273 @@
|
||||
name: Release & Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release-main
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for releasable commits
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
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 }}
|
||||
prev_tag: ${{ steps.bump.outputs.prev_tag }}
|
||||
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)
|
||||
PREV_TAG="${LATEST_TAG:-}"
|
||||
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 (subject + body for BREAKING CHANGE footers)
|
||||
SUBJECTS=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s")
|
||||
FULL_LOG=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%B---END---" 2>/dev/null || git log --pretty=format:"%B---END---")
|
||||
|
||||
if [ -z "$SUBJECTS" ]; 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 "$SUBJECTS" | 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 "$SUBJECTS"
|
||||
|
||||
# Determine bump type from conventional commit prefixes and footers
|
||||
BUMP="patch"
|
||||
if echo "$SUBJECTS" | grep -qiE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:"; then
|
||||
BUMP="major"
|
||||
elif echo "$FULL_LOG" | grep -qiE "^BREAKING[ -]CHANGE:"; then
|
||||
BUMP="major"
|
||||
elif echo "$SUBJECTS" | grep -qiE "^(feat|feature)(\(.+\))?:"; then
|
||||
BUMP="minor"
|
||||
elif echo "$SUBJECTS" | 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 "$SUBJECTS" | grep -iE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:" || true)
|
||||
FEATURES=$(echo "$SUBJECTS" | grep -iE "^(feat|feature|✨)" || true)
|
||||
FIXES=$(echo "$SUBJECTS" | grep -iE "^(fix|🐛)" || true)
|
||||
OTHERS=$(echo "$SUBJECTS" | grep -viE "^(feat|feature|✨|fix|🐛|chore\(release\))" | grep -viE "BREAKING" || true)
|
||||
|
||||
add_section() {
|
||||
local header="$1"
|
||||
local items="$2"
|
||||
if [ -n "$items" ]; then
|
||||
CHANGELOG="$(printf '%s\n\n%s\n%s' "$CHANGELOG" "$header" "$(echo "$items" | sed 's/^/- /')")"
|
||||
fi
|
||||
}
|
||||
|
||||
add_section "### ⚠️ Breaking Changes" "$BREAKING"
|
||||
add_section "### ✨ Features" "$FEATURES"
|
||||
add_section "### 🐛 Bug Fixes" "$FIXES"
|
||||
add_section "### 📦 Other Changes" "$OTHERS"
|
||||
|
||||
echo "bump=$BUMP" >> "$GITHUB_OUTPUT"
|
||||
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Multi-line output for changelog
|
||||
{
|
||||
echo "changelog<<CHANGELOG_EOF"
|
||||
echo "$CHANGELOG"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
release:
|
||||
name: Version bump & release
|
||||
needs: check
|
||||
if: needs.check.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ needs.check.outputs.new_version }}
|
||||
steps:
|
||||
- name: Generate release bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "sanjibdevnathlabs-release-bot[bot]"
|
||||
git config user.email "${{ secrets.APP_ID }}+sanjibdevnathlabs-release-bot[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:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
tag_name: v${{ needs.check.outputs.new_version }}
|
||||
name: v${{ needs.check.outputs.new_version }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## What's Changed in v${{ needs.check.outputs.new_version }}
|
||||
${{ needs.check.outputs.changelog }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ needs.check.outputs.prev_tag && needs.check.outputs.prev_tag || 'initial' }}...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'
|
||||
|
||||
- name: Cache node_modules
|
||||
id: cache-nm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-node20.x-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-nm.outputs.cache-hit != 'true'
|
||||
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
|
||||
@@ -15,8 +15,16 @@ public/dist/
|
||||
.cursor/
|
||||
.claude/
|
||||
|
||||
# User preferences (only the example ships)
|
||||
skills/excalidraw-skill/preferences.json
|
||||
|
||||
# Development artifacts
|
||||
*.excalidraw
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
coverage/
|
||||
|
||||
docs/*
|
||||
!docs/screenshots/
|
||||
@@ -0,0 +1,115 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What This Is
|
||||
|
||||
A fully local, self-hosted Excalidraw MCP server. Single Node.js process that runs an MCP server (stdio, 32 tools), an embedded Express+WebSocket canvas server, and SQLite persistence with multi-tenancy. Forked from [yctimlin/mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw).
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies (pnpm preferred, npm works too)
|
||||
pnpm install
|
||||
pnpm rebuild better-sqlite3 esbuild
|
||||
|
||||
# Full build (frontend + server)
|
||||
pnpm run build
|
||||
|
||||
# Build only server (TypeScript)
|
||||
pnpm run build:server # npx tsc
|
||||
|
||||
# Build only frontend (Vite/React)
|
||||
pnpm run build:frontend # vite build
|
||||
|
||||
# Type check without emit
|
||||
pnpm run type-check # npx tsc --noEmit
|
||||
|
||||
# Dev mode (watch server + Vite dev server on :5173)
|
||||
pnpm run dev
|
||||
|
||||
# Run the MCP server (starts MCP stdio + canvas on :3000)
|
||||
node dist/index.js
|
||||
|
||||
# Run canvas server standalone
|
||||
node dist/server.js
|
||||
|
||||
# Health check
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
There are no unit tests. Validation is done via type checking (`pnpm run type-check`) and build verification. The CI runs `type-check` then `build` across Node 18/20/22.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Single process, three subsystems:**
|
||||
|
||||
```
|
||||
src/index.ts ── MCP Server (stdio) ── 32 tools, connects to canvas via HTTP
|
||||
├── imports server.ts ── Canvas Server (Express + WebSocket on CANVAS_PORT)
|
||||
├── imports db.ts ── SQLite layer (better-sqlite3, WAL mode)
|
||||
└── imports types.ts ── Shared types, element validation, ID generation
|
||||
|
||||
frontend/ ── React + Excalidraw UI (Vite build → dist/frontend/)
|
||||
├── src/App.tsx ── Main component, WS connection, auto-sync, workspace switcher
|
||||
└── src/main.tsx ── Entry point
|
||||
```
|
||||
|
||||
**Data flow:** MCP tool call → `index.ts` handler → HTTP to canvas REST API (`server.ts`) → SQLite (`db.ts`) + WebSocket broadcast → frontend updates.
|
||||
|
||||
**Key design decisions:**
|
||||
- Canvas server is embedded in the MCP process — `startCanvasServer()` is called from `runServer()`. If port is taken by an existing healthy instance, it reuses it instead of crashing.
|
||||
- Multi-tenancy: workspace path → SHA-256 hash (12 chars) → tenant ID. Each tenant has isolated projects/elements. Tenant auto-detected via `server.listRoots()` after MCP connection.
|
||||
- All element data stored as JSON blobs in SQLite `elements.data` column. FTS5 virtual table for full-text search on labels.
|
||||
- Logging goes to file (`excalidraw.log`) at debug level, only warn+error to stderr (to avoid breaking stdio JSON protocol).
|
||||
|
||||
## Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` (~2540 lines) | MCP server entry point. Tool definitions, tool handlers, tenant bootstrap, server lifecycle. |
|
||||
| `src/server.ts` (~1155 lines) | Express canvas server. REST API, WebSocket, Zod schemas, arrow binding resolution, image export relay. |
|
||||
| `src/db.ts` (~510 lines) | SQLite persistence. Migrations, CRUD, FTS, versioning, snapshots, tenants, projects. |
|
||||
| `src/types.ts` (~315 lines) | TypeScript interfaces for elements, WebSocket messages, API responses. `generateId()` and `validateElement()`. |
|
||||
| `src/utils/logger.ts` | Winston logger config (file + stderr). |
|
||||
| `frontend/src/App.tsx` | React Excalidraw wrapper with WS sync, auto-sync, workspace switcher. |
|
||||
| `vite.config.js` | Frontend build config. Root=`frontend/`, output=`dist/frontend/`. Dev proxy to `:3000`. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|----------|---------|-------|
|
||||
| `CANVAS_PORT` | `3000` | Canvas server port |
|
||||
| `EXCALIDRAW_DB_PATH` | `~/.excalidraw-mcp/excalidraw.db` | SQLite database location |
|
||||
| `EXCALIDRAW_EXPORT_DIR` | `process.cwd()` | Allowed directory for file exports (path traversal protection) |
|
||||
| `EXPRESS_SERVER_URL` | `http://localhost:{CANVAS_PORT}` | Only needed if running canvas separately |
|
||||
| `LOG_FILE_PATH` | `excalidraw.log` | Winston log file |
|
||||
| `LOG_LEVEL` | `info` | Winston log level |
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
- ESM modules (`"type": "module"` in package.json, `"module": "ESNext"` in tsconfig)
|
||||
- Strict mode enabled with `noUncheckedIndexedAccess`
|
||||
- Target ES2022, output to `dist/`
|
||||
- All `.js` imports in source use `.js` extension (ESM requirement)
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Canvas sync is fire-and-forget**: MCP tool handlers call canvas REST API but don't fail if canvas is unavailable. The `syncToCanvas()` helper catches errors and returns null.
|
||||
- **Tenant-scoped operations**: Every REST endpoint resolves tenant via `X-Tenant-Id` header → `resolveTenantProject()` → project ID. Browser requests (no header) fall back to global active state.
|
||||
- **Arrow binding**: `startElementId`/`endElementId` on arrows are resolved to edge-point coordinates in `resolveArrowBindings()` (server.ts). The server computes intersection points for rectangle/ellipse/diamond shapes.
|
||||
- **Image export relay**: MCP → REST `/api/export/image` → WebSocket broadcast → frontend renders → POST back to `/api/export/image/result` → resolves pending promise.
|
||||
- **Element versioning**: Every create/update/delete records a version in `element_versions` table. Soft-delete pattern (`is_deleted` flag).
|
||||
|
||||
## Docker
|
||||
|
||||
Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas with frontend). `docker-compose.yml` orchestrates both with a `full` profile.
|
||||
|
||||
|
||||
## Code Search Optimization
|
||||
|
||||
When exploring or understanding code in supported languages (JS, TS, Python, Go, Rust, Java, C, C++, Ruby):
|
||||
- Use `smart_search(query, path)` instead of Grep+Glob chains for discovering functions/classes/symbols
|
||||
- Use `smart_outline(file_path)` instead of Read to understand file structure (~1-2K tokens vs ~12K+)
|
||||
- Use `smart_unfold(file_path, symbol_name)` instead of Read for viewing specific functions (~400-2K tokens)
|
||||
- Fall back to Grep for exact string/regex searches, Read for non-code files and files under 100 lines
|
||||
+4
-3
@@ -2,7 +2,7 @@
|
||||
# Builds the MCP server with SQLite persistence
|
||||
|
||||
# Stage 1: Build backend (TypeScript compilation + native modules)
|
||||
FROM node:18-slim AS builder
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY tsconfig.json ./
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 2: Production MCP Server
|
||||
FROM node:18-slim AS production
|
||||
FROM node:20-slim AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
+5
-4
@@ -2,7 +2,7 @@
|
||||
# Provides the web interface, REST API, and SQLite persistence
|
||||
|
||||
# Stage 1: Build frontend
|
||||
FROM node:18-slim AS frontend-builder
|
||||
FROM node:20-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,7 +14,7 @@ COPY vite.config.js ./
|
||||
RUN npm run build:frontend
|
||||
|
||||
# Stage 2: Build backend (TypeScript compilation + native modules)
|
||||
FROM node:18-slim AS backend-builder
|
||||
FROM node:20-slim AS backend-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -28,7 +28,7 @@ COPY tsconfig.json ./
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 3: Production Canvas Server
|
||||
FROM node:18-slim AS production
|
||||
FROM node:20-slim AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# MCP Excalidraw Local
|
||||
|
||||
[](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/ci.yml)
|
||||
[](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/docker.yml)
|
||||
[](https://github.com/sanjibdevnathlabs/mcp-excalidraw-local/actions/workflows/release.yml)
|
||||
[](LICENSE)
|
||||
|
||||
A fully local, self-hosted Excalidraw MCP server with **SQLite persistence**, **multi-tenancy**, and **auto-sync** — designed to run entirely on your machine without depending on `excalidraw.com`.
|
||||
@@ -37,14 +37,14 @@ 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)
|
||||
- [Updating](#updating)
|
||||
- [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 +55,344 @@ 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 >= 20** (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:**
|
||||
Install "Desktop development with C++" from [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-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).
|
||||
|
||||
## Updating
|
||||
|
||||
Already installed a previous version? The interactive update wizard is the easiest way to update everything — MCP server **and** agent skills — in one go.
|
||||
|
||||
### Interactive Update (recommended)
|
||||
|
||||
```bash
|
||||
npx @sanjibdevnath/mcp-excalidraw-local@latest update
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Example session</summary>
|
||||
|
||||
```
|
||||
$ npx @sanjibdevnath/mcp-excalidraw-local@latest update
|
||||
|
||||
Excalidraw MCP — Update v1.2.0
|
||||
|
||||
[1/2] Skill Update
|
||||
|
||||
Found 2 existing skill installation(s):
|
||||
[1] Cursor (global) — ~/.cursor/skills/excalidraw-skill
|
||||
[2] Claude Code (global) — ~/.claude/skills/excalidraw-skill
|
||||
|
||||
Update all 2 installation(s) to v1.2.0? [Y/n]: Y
|
||||
✔ Updated Cursor (global) — ~/.cursor/skills/excalidraw-skill
|
||||
✔ Updated Claude Code (global) — ~/.claude/skills/excalidraw-skill
|
||||
|
||||
2/2 skill(s) updated.
|
||||
|
||||
[2/2] MCP Configuration
|
||||
Re-apply MCP server config? (overwrites existing entry) [y/N]: N
|
||||
MCP config unchanged.
|
||||
|
||||
Update complete! Restart your MCP client to pick up changes.
|
||||
```
|
||||
</details>
|
||||
|
||||
The update wizard:
|
||||
1. **Finds all existing skill installations** across Cursor, Claude Code, and Codex CLI (both global and local scopes)
|
||||
2. **Updates them in-place** with the latest skill files (SKILL.md, cheatsheet, geometric-thinking reference, helper scripts)
|
||||
3. **Offers to install** the skill for any detected agent that doesn't have it yet
|
||||
4. **Optionally re-applies MCP config** if needed
|
||||
|
||||
> **Why this matters:** The agent skill contains workflow guidance, sizing rules, color palettes, and anti-patterns that evolve alongside the MCP tools. Updating the MCP server without updating the skill means your AI agent is working with stale instructions.
|
||||
|
||||
### Manual Update by Installation Method
|
||||
|
||||
If you prefer to update manually, follow the steps for your installation method, then restart your MCP client.
|
||||
|
||||
#### npx users
|
||||
|
||||
If your MCP config uses `npx -y @sanjibdevnath/mcp-excalidraw-local`, npx caches the package locally and won't automatically fetch new versions.
|
||||
|
||||
**Option A — Clear the cache (one-time):**
|
||||
```bash
|
||||
npm cache clean --force
|
||||
```
|
||||
Then restart your MCP client. npx will download the latest version on next launch.
|
||||
|
||||
**Option B — Pin to `@latest` in your MCP config (permanent fix):**
|
||||
|
||||
Update the `args` in your MCP config to include `@latest`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"excalidraw-canvas": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local@latest"],
|
||||
"env": { "CANVAS_PORT": "3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This ensures npx always checks for the newest published version.
|
||||
|
||||
#### From-source users
|
||||
|
||||
```bash
|
||||
cd mcp-excalidraw-local
|
||||
git pull origin main
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### Docker users
|
||||
|
||||
```bash
|
||||
docker pull sanjibdevnath/mcp-excalidraw-local:latest
|
||||
docker pull sanjibdevnath/mcp-excalidraw-local-canvas:latest
|
||||
```
|
||||
|
||||
Then recreate your containers (`docker compose up -d` or `docker run` again).
|
||||
|
||||
#### Updating the agent skill manually
|
||||
|
||||
If you skipped the interactive update, copy the skill files yourself:
|
||||
```bash
|
||||
cp -R skills/excalidraw-skill ~/.cursor/skills/excalidraw-skill
|
||||
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
|
||||
```
|
||||
|
||||
### Verify the update
|
||||
|
||||
```bash
|
||||
# Check the running version
|
||||
curl -s http://localhost:3000/health
|
||||
|
||||
# Or check the installed package version
|
||||
npx @sanjibdevnath/mcp-excalidraw-local --version
|
||||
```
|
||||
|
||||
## How We Differ from the Official Excalidraw MCP
|
||||
|
||||
@@ -89,8 +418,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 +432,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 +441,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 +449,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 +473,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 +542,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 +640,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 +666,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>
|
||||
|
||||
+387
-158
@@ -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)
|
||||
|
||||
@@ -154,6 +68,12 @@ function App(): JSX.Element {
|
||||
const isSyncingRef = useRef<boolean>(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastSyncedHashRef = useRef<string>('')
|
||||
const lastSyncVersionRef = useRef<number>(
|
||||
parseInt(localStorage.getItem('excalidraw-last-sync-version') ?? '0', 10)
|
||||
)
|
||||
const lastSyncedElementsRef = useRef<Map<string, ServerElement>>(new Map())
|
||||
const lastReceivedSyncVersionRef = useRef<number>(0)
|
||||
const isResyncingRef = useRef<boolean>(false)
|
||||
|
||||
const DEBOUNCE_MS = 3000
|
||||
|
||||
@@ -165,7 +85,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 +126,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 +164,80 @@ 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: [] })
|
||||
lastSyncedElementsRef.current = new Map()
|
||||
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 })
|
||||
}
|
||||
|
||||
// Populate sync baseline so deletions are detected on next sync
|
||||
const baselineMap = new Map<string, ServerElement>()
|
||||
for (const el of result.elements) {
|
||||
baselineMap.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = baselineMap
|
||||
}
|
||||
|
||||
// Fetch current sync version so delta sync works correctly
|
||||
try {
|
||||
const versionRes = await fetch('/api/sync/version', { headers: tenantHeaders() })
|
||||
const versionData = await versionRes.json()
|
||||
if (versionData.success && typeof versionData.syncVersion === 'number') {
|
||||
lastSyncVersionRef.current = versionData.syncVersion
|
||||
lastReceivedSyncVersionRef.current = versionData.syncVersion
|
||||
localStorage.setItem('excalidraw-last-sync-version', String(versionData.syncVersion))
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Set hash baseline so auto-sync doesn't immediately re-sync unchanged content
|
||||
if (excalidrawAPI) {
|
||||
const sceneElements = excalidrawAPI.getSceneElements()
|
||||
lastSyncedHashRef.current = computeElementHash(sceneElements)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -316,26 +285,115 @@ function App(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const sendHello = (tenantId: string): void => {
|
||||
const ws = websocketRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId }))
|
||||
}
|
||||
|
||||
const sendAck = (msgId: string | undefined, status: 'applied' | 'partial' | 'failed', elementCount?: number, expectedCount?: number): void => {
|
||||
if (!msgId) return
|
||||
const ws = websocketRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'ack', msgId, status, elementCount, expectedCount }))
|
||||
}
|
||||
|
||||
const triggerDeltaResync = async (): Promise<void> => {
|
||||
if (isResyncingRef.current) return
|
||||
isResyncingRef.current = true
|
||||
try {
|
||||
const response = await fetch('/api/elements/sync/v2', {
|
||||
method: 'POST',
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
lastSyncVersion: lastReceivedSyncVersionRef.current,
|
||||
changes: []
|
||||
})
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json() as {
|
||||
currentSyncVersion: number
|
||||
serverChanges: { id: string; action: string; element: any; sync_version: number }[]
|
||||
}
|
||||
const api = excalidrawAPIRef.current
|
||||
if (api && data.serverChanges.length > 0) {
|
||||
const scene = api.getSceneElements()
|
||||
let merged = [...scene]
|
||||
for (const sc of data.serverChanges) {
|
||||
if (sc.action === 'delete') {
|
||||
merged = merged.filter(el => el.id !== sc.id)
|
||||
} else if (sc.element) {
|
||||
const cleaned = cleanElementForExcalidraw(sc.element)
|
||||
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
|
||||
const idx = merged.findIndex(el => el.id === sc.id)
|
||||
if (idx >= 0) {
|
||||
merged[idx] = converted[0]!
|
||||
} else {
|
||||
merged.push(...converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
api.updateScene({ elements: merged, captureUpdate: CaptureUpdateAction.NEVER })
|
||||
}
|
||||
lastReceivedSyncVersionRef.current = data.currentSyncVersion
|
||||
lastSyncVersionRef.current = data.currentSyncVersion
|
||||
localStorage.setItem('excalidraw-last-sync-version', String(data.currentSyncVersion))
|
||||
// Update sync baseline so deletion detection works after resync
|
||||
if (api) {
|
||||
const activeElements = api.getSceneElements().filter(el => !el.isDeleted)
|
||||
const baselineMap = new Map<string, any>()
|
||||
for (const el of normalizeForBackend(activeElements)) {
|
||||
baselineMap.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = baselineMap
|
||||
lastSyncedHashRef.current = computeElementHash(api.getSceneElements())
|
||||
}
|
||||
console.log(`Delta resync complete: received ${data.serverChanges.length} changes, now at v${data.currentSyncVersion}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delta resync failed:', err)
|
||||
} finally {
|
||||
isResyncingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
|
||||
if (!excalidrawAPI) {
|
||||
// Gap detection (Task 12): if a message carries sync_version, check for gaps
|
||||
if (data.sync_version !== undefined && typeof data.sync_version === 'number') {
|
||||
const expected = lastReceivedSyncVersionRef.current + 1
|
||||
if (data.sync_version > expected && lastReceivedSyncVersionRef.current > 0) {
|
||||
console.warn(`Sync gap: expected v${expected}, got v${data.sync_version}. Triggering resync.`)
|
||||
triggerDeltaResync()
|
||||
return // resync will fetch everything including this message's changes
|
||||
}
|
||||
lastReceivedSyncVersionRef.current = data.sync_version
|
||||
}
|
||||
|
||||
const api = excalidrawAPIRef.current
|
||||
if (!api) {
|
||||
sendAck(data.msgId, 'failed')
|
||||
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
|
||||
})
|
||||
// Update sync baseline for deletion detection
|
||||
const initBaseline = new Map<string, any>()
|
||||
for (const el of data.elements) {
|
||||
initBaseline.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = initBaseline
|
||||
}
|
||||
break
|
||||
|
||||
@@ -344,47 +402,49 @@ 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
|
||||
})
|
||||
}
|
||||
const scene = api.getSceneElements()
|
||||
const landed = scene.some(s => s.id === data.element!.id)
|
||||
sendAck(data.msgId, landed ? 'applied' : 'failed', landed ? 1 : 0, 1)
|
||||
}
|
||||
break
|
||||
|
||||
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
|
||||
})
|
||||
sendAck(data.msgId, 'applied', 1, 1)
|
||||
}
|
||||
break
|
||||
|
||||
case 'element_deleted':
|
||||
if (data.elementId) {
|
||||
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
|
||||
excalidrawAPI.updateScene({
|
||||
api.updateScene({
|
||||
elements: filteredElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
sendAck(data.msgId, 'applied', 1, 1)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -393,28 +453,31 @@ 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
|
||||
})
|
||||
}
|
||||
// Verify elements landed in the scene
|
||||
const scene = api.getSceneElements()
|
||||
const expectedIds = data.elements.map((e: ServerElement) => e.id)
|
||||
const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length
|
||||
const status = landedCount === expectedIds.length ? 'applied' : landedCount > 0 ? 'partial' : 'failed'
|
||||
sendAck(data.msgId, status, landedCount, expectedIds.length)
|
||||
}
|
||||
break
|
||||
|
||||
case 'elements_synced':
|
||||
console.log(`Sync confirmed by server: ${data.count} elements`)
|
||||
// Sync confirmation already handled by HTTP response
|
||||
break
|
||||
|
||||
case 'sync_status':
|
||||
@@ -423,19 +486,46 @@ function App(): JSX.Element {
|
||||
|
||||
case 'canvas_cleared':
|
||||
console.log('Canvas cleared by server')
|
||||
excalidrawAPI.updateScene({
|
||||
api.updateScene({
|
||||
elements: [],
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
sendAck(data.msgId, 'applied')
|
||||
break
|
||||
|
||||
case 'export_image_request':
|
||||
console.log('Received image export request', data)
|
||||
if (data.requestId) {
|
||||
try {
|
||||
const elements = excalidrawAPI.getSceneElements()
|
||||
const appState = excalidrawAPI.getAppState()
|
||||
const files = excalidrawAPI.getFiles()
|
||||
// Viewport capture: grab the rendered canvas DOM element directly
|
||||
// This captures exactly what the user sees, respecting zoom/scroll.
|
||||
if (data.captureViewport && data.format !== 'svg') {
|
||||
const canvasEl = document.querySelector('.excalidraw__canvas') as HTMLCanvasElement
|
||||
?? document.querySelector('canvas') as HTMLCanvasElement
|
||||
if (canvasEl) {
|
||||
const dataUrl = canvasEl.toDataURL('image/png')
|
||||
const base64 = dataUrl.split(',')[1]
|
||||
if (base64) {
|
||||
await fetch('/api/export/image/result', {
|
||||
method: 'POST',
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
requestId: data.requestId,
|
||||
format: 'png',
|
||||
data: base64
|
||||
})
|
||||
})
|
||||
console.log('Viewport screenshot captured for request', data.requestId)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Fall through to exportToBlob if canvas capture failed
|
||||
console.warn('Viewport canvas capture failed, falling back to exportToBlob')
|
||||
}
|
||||
|
||||
const elements = api.getSceneElements()
|
||||
const appState = api.getAppState()
|
||||
const files = api.getFiles()
|
||||
|
||||
if (data.format === 'svg') {
|
||||
const svg = await exportToSvg({
|
||||
@@ -528,20 +618,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: false })
|
||||
}
|
||||
} 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: false })
|
||||
} 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 +642,7 @@ function App(): JSX.Element {
|
||||
appState.scrollY = data.offsetY
|
||||
}
|
||||
if (Object.keys(appState).length > 0) {
|
||||
excalidrawAPI.updateScene({ appState })
|
||||
api.updateScene({ appState })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,13 +682,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,16 +702,28 @@ 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) {
|
||||
const incoming = data.tenant as TenantInfo
|
||||
// Only reload if the switch came from an external source (MCP tool)
|
||||
// and we aren't already on that tenant (UI-driven switch handles its own reload)
|
||||
// Send hello to register WS connection under the correct tenant scope
|
||||
sendHello(incoming.id)
|
||||
if (incoming.id !== activeTenantIdRef.current) {
|
||||
activeTenantIdRef.current = incoming.id
|
||||
setActiveTenant(incoming)
|
||||
excalidrawAPI.updateScene({
|
||||
api.updateScene({
|
||||
elements: [],
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
@@ -634,6 +735,25 @@ function App(): JSX.Element {
|
||||
}
|
||||
break
|
||||
|
||||
case 'hello_ack':
|
||||
console.log('Hello acknowledged by server:', data.tenantId, data.projectId)
|
||||
if (data.elements && Array.isArray(data.elements) && data.elements.length > 0) {
|
||||
const converted = convertToExcalidrawElements(data.elements)
|
||||
api.updateScene({
|
||||
elements: converted,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
// Update sync baseline for deletion detection
|
||||
const helloBaseline = new Map<string, any>()
|
||||
for (const el of data.elements) {
|
||||
helloBaseline.set(el.id, el)
|
||||
}
|
||||
lastSyncedElementsRef.current = helloBaseline
|
||||
} else if (data.elements && data.elements.length === 0) {
|
||||
lastSyncedElementsRef.current = new Map()
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', data.type)
|
||||
}
|
||||
@@ -790,21 +910,71 @@ function App(): JSX.Element {
|
||||
const activeElements = currentElements.filter(el => !el.isDeleted)
|
||||
const backendElements = normalizeForBackend(activeElements)
|
||||
|
||||
const response = await fetch('/api/elements/sync', {
|
||||
// Compute delta: what changed since last sync
|
||||
const changes: { id: string; action: string; element?: any }[] = []
|
||||
const currentMap = new Map<string, any>()
|
||||
for (const el of backendElements) {
|
||||
currentMap.set(el.id, el)
|
||||
const prev = lastSyncedElementsRef.current.get(el.id)
|
||||
if (!prev || JSON.stringify(prev) !== JSON.stringify(el)) {
|
||||
changes.push({ id: el.id, action: 'upsert', element: el })
|
||||
}
|
||||
}
|
||||
// Detect deletions: elements in last sync but not current
|
||||
for (const [id] of lastSyncedElementsRef.current) {
|
||||
if (!currentMap.has(id)) {
|
||||
changes.push({ id, action: 'delete' })
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('/api/elements/sync/v2', {
|
||||
method: 'POST',
|
||||
headers: tenantHeaders(),
|
||||
body: JSON.stringify({
|
||||
elements: backendElements,
|
||||
timestamp: new Date().toISOString()
|
||||
lastSyncVersion: lastSyncVersionRef.current,
|
||||
changes
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result: ApiResponse = await response.json()
|
||||
const result = await response.json() as {
|
||||
currentSyncVersion: number
|
||||
serverChanges: { id: string; action: string; element: any; sync_version: number }[]
|
||||
appliedCount: number
|
||||
}
|
||||
|
||||
// Apply server-side changes (MCP-created elements, other tabs' changes)
|
||||
if (result.serverChanges.length > 0) {
|
||||
const api = excalidrawAPIRef.current
|
||||
if (api) {
|
||||
const scene = api.getSceneElements()
|
||||
let merged = [...scene]
|
||||
for (const sc of result.serverChanges) {
|
||||
if (sc.action === 'delete') {
|
||||
merged = merged.filter(el => el.id !== sc.id)
|
||||
} else if (sc.element) {
|
||||
const cleaned = cleanElementForExcalidraw(sc.element)
|
||||
const converted = convertToExcalidrawElements([cleaned], { regenerateIds: false })
|
||||
const idx = merged.findIndex(el => el.id === sc.id)
|
||||
if (idx >= 0) {
|
||||
merged[idx] = converted[0]!
|
||||
} else {
|
||||
merged.push(...converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
api.updateScene({ elements: merged, captureUpdate: CaptureUpdateAction.NEVER })
|
||||
}
|
||||
}
|
||||
|
||||
// Update tracking state
|
||||
lastSyncVersionRef.current = result.currentSyncVersion
|
||||
localStorage.setItem('excalidraw-last-sync-version', String(result.currentSyncVersion))
|
||||
lastSyncedElementsRef.current = currentMap
|
||||
lastSyncedHashRef.current = computeElementHash(currentElements)
|
||||
setSyncStatus('idle')
|
||||
showToast('Saved')
|
||||
console.log(`Sync: ${result.count} elements synced`)
|
||||
console.log(`Delta sync: ${result.appliedCount} applied, ${result.serverChanges.length} received from server`)
|
||||
} else {
|
||||
setSyncStatus('idle')
|
||||
showToast('Sync failed', 3000)
|
||||
@@ -819,7 +989,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 +1041,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 +1106,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 +1153,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
+19
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.0.0",
|
||||
"version": "1.6.2",
|
||||
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -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"
|
||||
"setup": "node dist/index.js setup",
|
||||
"update": "node dist/index.js update",
|
||||
"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",
|
||||
@@ -93,7 +107,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: excalidraw-skill
|
||||
description: Programmatic canvas toolkit for creating, editing, and refining Excalidraw diagrams via MCP tools (32 tools) or REST API with real-time canvas sync, multi-tenant workspace isolation, SQLite persistence, project management, full-text search, and element version history. Use when an agent needs to draw or lay out diagrams on a live canvas, iteratively refine diagrams using screenshots, manage workspaces/tenants and projects, export/import .excalidraw files or PNG/SVG images, search elements, view change history, save/restore canvas snapshots, or perform element-level CRUD. Canvas server port is configurable via CANVAS_PORT env var (default 3000).
|
||||
description: MANDATORY prerequisite for ALL Excalidraw MCP tool usage. Read this skill BEFORE calling any Excalidraw tool (batch_create_elements, create_element, create_from_mermaid, update_element, etc.) — without this skill's sizing formulas, two-batch ordering (shapes first, arrows second), and write-check-review verification cycle, diagrams will have invisible arrows, truncated text, and overlapping elements. Use whenever the user asks to draw, create, visualize, sketch, or diagram anything — flowcharts, architecture diagrams, system designs, org charts, sequence flows, decision trees, network topologies, ER diagrams, mind maps, or any visual on Excalidraw canvas. Also covers diagram refinement, PNG/SVG export, project/workspace management, and all canvas interactions.
|
||||
---
|
||||
|
||||
# Excalidraw Skill
|
||||
@@ -15,6 +15,79 @@ Run these checks **in order**:
|
||||
|
||||
See `references/cheatsheet.md` for the full MCP-vs-REST mapping and REST API gotchas.
|
||||
|
||||
## Step 1: Load User Preferences
|
||||
|
||||
Before creating any elements, load the user's diagram preferences. These control default font, roughness, stroke width, etc.
|
||||
|
||||
### Preference Resolution Order (most specific wins)
|
||||
|
||||
| Priority | Scope | Location | Persists |
|
||||
|----------|-------|----------|----------|
|
||||
| 1 (highest) | Session | In-memory (set via prompt during this conversation) | No — current session only |
|
||||
| 2 | Folder | `.claude/excalidraw-preferences.json` in the current project root | Yes — per-project |
|
||||
| 3 | Global | `~/.claude/skills/excalidraw-skill/preferences.json` | Yes — all projects |
|
||||
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 5, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
|
||||
|
||||
### How to Load
|
||||
|
||||
1. **Check folder-level first**: Read `.claude/excalidraw-preferences.json` from the current working directory (or project root). If it exists and has `defaults`, use those values.
|
||||
2. **Fall back to global**: Read `~/.claude/skills/excalidraw-skill/preferences.json`. If it exists and has `defaults`, use those values.
|
||||
3. **If neither exists** → run the **First-Time Setup** prompt below.
|
||||
4. **Merge**: Folder preferences override global; global overrides hardcoded. Only override fields that are explicitly set.
|
||||
|
||||
### First-Time Setup (Interactive)
|
||||
|
||||
If no preferences file exists at either location, **prompt the user before drawing anything**:
|
||||
|
||||
> **Excalidraw Preferences Setup**
|
||||
>
|
||||
> I don't have any saved diagram preferences yet. Let me set up your defaults so every diagram looks the way you want.
|
||||
|
||||
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
|
||||
|
||||
1. **Font family** — Which font for all text? _(IDs from `src/font-families.json`)_
|
||||
- Excalifont (hand-drawn) = 5
|
||||
- Helvetica (sans-serif) = 2
|
||||
- Cascadia (monospace) = 3
|
||||
- Comic Shanns = 8
|
||||
- Nunito = 6
|
||||
- Lilita One = 7
|
||||
|
||||
2. **Roughness** — Diagram style?
|
||||
- Clean/professional (roughness: 0) — recommended
|
||||
- Hand-drawn sketch (roughness: 1)
|
||||
- Very rough (roughness: 2)
|
||||
|
||||
3. **Scope** — Where to save?
|
||||
- **This session only** — don't save to disk, just use for this conversation
|
||||
- **This project** — save to `.claude/excalidraw-preferences.json` in project root
|
||||
- **Global (all projects)** — save to `~/.claude/skills/excalidraw-skill/preferences.json`
|
||||
|
||||
Then save the preferences JSON to the chosen location:
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"fontFamily": <user_choice>,
|
||||
"fontSize": 20,
|
||||
"roughness": <user_choice>,
|
||||
"strokeWidth": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For session-only scope, just hold the values in memory and apply them to every element in this conversation.
|
||||
|
||||
### Applying Preferences
|
||||
|
||||
Once loaded, apply `defaults` to **every element** that supports the property:
|
||||
- `fontFamily` → all text-containing elements (text, rectangles with labels, diamonds, ellipses, arrows with labels)
|
||||
- `fontSize` → text elements and labels (unless the element explicitly overrides it)
|
||||
- `roughness` → all elements
|
||||
- `strokeWidth` → arrows and lines
|
||||
|
||||
User-specified values in individual element calls always override preferences.
|
||||
|
||||
## Core Principles (Read Before Any Diagram)
|
||||
|
||||
These principles were learned through extensive iterative use. Violating them produces bad diagrams.
|
||||
@@ -125,6 +198,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
|
||||
@@ -244,6 +361,118 @@ Layout:
|
||||
Title: fontSize=24 above each zone
|
||||
```
|
||||
|
||||
## Geometric Thinking (Critical — For All Diagram Types)
|
||||
|
||||
Excalidraw only offers basic primitives: rectangles, diamonds, ellipses, lines, arrows, text. Building anything beyond simple box-and-arrow diagrams requires **geometric composition** — combining many small primitives using coordinate math to form complex shapes, textures, and layouts.
|
||||
|
||||
### Principle 1: Compose Complex Shapes from Many Small Primitives
|
||||
|
||||
**Never use one large primitive where many small ones create a better result.**
|
||||
|
||||
A single large diamond looks like a flat rhombus. But 45 small diamonds arranged in a triangular grid with tessellating offset rows looks like a tiled roof. The technique:
|
||||
|
||||
1. **Identify the target shape** (triangle, circle, arc, wave, etc.)
|
||||
2. **Choose a small primitive** that can tile/fill that shape (diamond for tiles, rectangle for bricks, ellipse for clouds)
|
||||
3. **Compute a grid of positions** that fills the target shape's boundary
|
||||
4. **Apply row-by-row reduction** for tapered shapes (triangles, cones)
|
||||
5. **Add interleaving offset rows** to eliminate gaps (tessellation)
|
||||
|
||||
```
|
||||
Example — Triangular tiled roof (building width=380, center_x=1090):
|
||||
|
||||
Tile size: 52w × 36h
|
||||
Row spacing: 28px vertical (= tile height)
|
||||
Interleave offset: 26px horizontal (= tile width / 2)
|
||||
Interleave rows at: midpoint y between main rows
|
||||
|
||||
Main rows (reduce by 2 tiles per row):
|
||||
Row 1: 9 tiles at y=130 | ◇◇◇◇◇◇◇◇◇
|
||||
Row 2: 7 tiles at y=102 | ◇◇◇◇◇◇◇
|
||||
Row 3: 5 tiles at y=74 | ◇◇◇◇◇
|
||||
Row 4: 3 tiles at y=46 | ◇◇◇
|
||||
Row 5: 1 tile at y=18 | ◇
|
||||
|
||||
Interleaving rows (offset by half tile width, fill gaps):
|
||||
Inter 1: 8 tiles at y=116 | ◇◇◇◇◇◇◇◇
|
||||
Inter 2: 6 tiles at y=88 | ◇◇◇◇◇◇
|
||||
Inter 3: 4 tiles at y=60 | ◇◇◇◇
|
||||
Inter 4: 2 tiles at y=32 | ◇◇
|
||||
|
||||
Total: 45 tiles → seamless triangular roof
|
||||
```
|
||||
|
||||
### Principle 2: Tessellation — Eliminate Gaps with Offset Rows
|
||||
|
||||
When same-row primitives leave triangular/pointed gaps, add **interleaving rows** offset by half the primitive width:
|
||||
|
||||
```
|
||||
Gap pattern (diamonds side-by-side): Filled with interleaving row:
|
||||
/\ /\ /\ /\ /\ /\ /\ /\
|
||||
/ \/ \/ \/ \ / \/ \/ \/ \
|
||||
\ /\ /\ /\ / \ /\/\/\/\/\/\ /
|
||||
\/ \/ \/ \/ ◇◇◇◇◇◇◇◇◇ ← offset row
|
||||
/\ /\ /\ /\
|
||||
```
|
||||
|
||||
**Formula for interleaving:**
|
||||
```
|
||||
main_row_y[n] = base_y - n * row_spacing
|
||||
inter_row_y[n] = (main_row_y[n] + main_row_y[n+1]) / 2
|
||||
inter_row_x_offset = tile_width / 2
|
||||
inter_row_count = main_row_count[n] - 1
|
||||
```
|
||||
|
||||
### Principle 3: Parametric Positioning — Use Formulas, Not Guessing
|
||||
|
||||
Compute element positions mathematically. Common formulas:
|
||||
|
||||
**Centering N items in a container:**
|
||||
```
|
||||
item_x[i] = container_x + (container_width - N * item_width) / (N + 1) * (i + 1) + i * item_width
|
||||
```
|
||||
|
||||
**Circular arrangement (N items around center):**
|
||||
```
|
||||
angle[i] = (2π / N) * i + rotation_offset
|
||||
x[i] = center_x + radius * cos(angle[i]) - item_width / 2
|
||||
y[i] = center_y + radius * sin(angle[i]) - item_height / 2
|
||||
```
|
||||
|
||||
**Triangular reduction (pyramid/roof):**
|
||||
```
|
||||
count[row] = base_count - 2 * row
|
||||
x_start[row] = center_x - (count[row] * tile_width) / 2
|
||||
y[row] = base_y - row * row_spacing
|
||||
```
|
||||
|
||||
**Isometric projection (2.5D diagrams):**
|
||||
```
|
||||
screen_x = (grid_x - grid_y) * tile_width / 2
|
||||
screen_y = (grid_x + grid_y) * tile_height / 2
|
||||
```
|
||||
|
||||
### Principle 4: Scale Primitives to Context
|
||||
|
||||
When applying a technique to different-sized containers, **scale the primitive size proportionally**:
|
||||
|
||||
```
|
||||
Hut (body width 290px) → tiles 40w × 28h, 9 per base row
|
||||
Building (body width 380px) → tiles 52w × 36h, 9 per base row
|
||||
```
|
||||
|
||||
Maintain the same count-per-row for visual consistency; adjust individual tile dimensions.
|
||||
|
||||
### Technique Catalogs
|
||||
|
||||
For detailed recipes and formulas for specific diagram types, see [geometric-thinking.md](references/geometric-thinking.md):
|
||||
|
||||
- **Illustrative diagrams**: 11 visual element recipes (roofs, walls, clouds, trees, fences, water, smoke, windows, stairs)
|
||||
- **Flow diagrams**: Sugiyama-inspired 4-step hierarchical layout (layer assignment → ordering → coordinates → edge routing)
|
||||
- **Architecture diagrams**: Zone-grid layout with service grid-packing and dependency layering
|
||||
- **Isometric/2.5D diagrams**: Screen projection formulas for depth illusion
|
||||
- **Repeating patterns**: Generic repeat and brick-pattern offset formulas
|
||||
- **Anti-patterns**: 6 common geometric composition mistakes and fixes
|
||||
|
||||
## Workflow: Iterative Refinement
|
||||
|
||||
```
|
||||
@@ -259,55 +488,18 @@ create shapes (batch 1)
|
||||
|
||||
For multi-diagram canvases, offset each new diagram by 300px+ from the previous one's bounding box.
|
||||
|
||||
## Workflow: Multi-Tenancy (Workspaces)
|
||||
## Workflow: Tenants, Projects & Search
|
||||
|
||||
The MCP is multi-tenant. Each Cursor workspace automatically gets its own tenant (identified by a SHA-256 hash of the workspace path). All elements, projects, and snapshots are scoped to the active tenant.
|
||||
|
||||
### Automatic Tenant Detection
|
||||
|
||||
On MCP startup, the server:
|
||||
1. Creates a tenant from `process.cwd()` (initial guess)
|
||||
2. After connecting, calls `server.listRoots()` to get the real workspace path from Cursor
|
||||
3. If different, re-creates/switches to the correct tenant and notifies the canvas
|
||||
|
||||
This means globally-configured MCPs (`~/.cursor/mcp.json`) correctly detect the per-window workspace — no manual setup needed.
|
||||
|
||||
### Tenant Operations
|
||||
Multi-tenant: each Cursor workspace auto-gets its own tenant (SHA-256 hash of workspace path). Globally-configured MCPs detect per-window workspace automatically.
|
||||
|
||||
| Task | Tool | Notes |
|
||||
|------|------|-------|
|
||||
| See all workspaces | `list_tenants` | Returns id, name, workspace_path, created_at |
|
||||
| Switch workspace | `switch_tenant` with `tenantId` | Canvas reloads that tenant's elements via WebSocket |
|
||||
| Check current tenant | (from describe_scene or frontend header) | Shows "Workspace: [name]" in canvas |
|
||||
|
||||
### Multiple Cursor Instances
|
||||
|
||||
Each instance sends its own `X-Tenant-Id` header on every HTTP/MCP request. SQLite uses `busy_timeout` for concurrent write safety. No state conflicts between windows.
|
||||
|
||||
## Workflow: Projects (Within a Tenant)
|
||||
|
||||
Projects group diagrams within a tenant. Each tenant has a "Default Project" created automatically. Use projects to organize different diagram sets (e.g., "Architecture", "User Flows", "Sprint Planning").
|
||||
|
||||
| Task | Tool | Notes |
|
||||
|------|------|-------|
|
||||
| List projects | `list_projects` | Shows all projects in active tenant |
|
||||
| Switch project | `switch_project` with `projectId` | Elements change to that project's set |
|
||||
| Create new project | `switch_project` with `createName` | Creates and switches in one call |
|
||||
|
||||
## Workflow: Search & History
|
||||
|
||||
### Full-Text Search
|
||||
|
||||
`search_elements` with `query` — searches across element labels and text content in the active project. Useful for finding specific elements in large diagrams.
|
||||
|
||||
### Element Version History
|
||||
|
||||
`element_history` — view create/update/delete operations for:
|
||||
- A specific element: pass `elementId`
|
||||
- Entire active project: omit `elementId`
|
||||
- Control result count with `limit` (default 50)
|
||||
|
||||
Use history to debug unexpected changes or audit what was modified.
|
||||
| List workspaces | `list_tenants` | Returns id, name, workspace_path |
|
||||
| Switch workspace | `switch_tenant` with `tenantId` | Canvas reloads tenant's elements |
|
||||
| List projects | `list_projects` | Projects group diagrams within a tenant |
|
||||
| Switch/create project | `switch_project` | Pass `projectId` or `createName` |
|
||||
| Full-text search | `search_elements` with `query` | Searches labels and text content |
|
||||
| Version history | `element_history` | Pass `elementId` or omit for project-wide |
|
||||
|
||||
## Workflow: Refine An Existing Diagram
|
||||
|
||||
@@ -345,7 +537,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 |
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
|
||||
"_fontReference": "See src/font-families.json for canonical font ID → name mapping.",
|
||||
"defaults": {
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"roughness": 0,
|
||||
"strokeWidth": 2
|
||||
}
|
||||
}
|
||||
@@ -94,16 +94,15 @@
|
||||
|---------|----------------|-----------------|
|
||||
| Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` |
|
||||
| Arrow binding | `"startElementId": "id"` / `"endElementId": "id"` | `"start": {"id": "id"}` / `"end": {"id": "id"}` |
|
||||
| `fontFamily` | String `"1"` or omit | String `"1"` or omit (never a number) |
|
||||
| `fontFamily` | Number or string — use value from user preferences (see Step 1 in SKILL.md) | String — use value from user preferences |
|
||||
| Tenant scoping | Auto (uses active tenant) | Include `X-Tenant-Id` header on every request |
|
||||
|
||||
### Element Creation Best Practices
|
||||
|
||||
- **Always set `roughness: 0`** for clean, professional diagrams (default is hand-drawn).
|
||||
- **Always set `strokeWidth: 2`** on arrows for visibility.
|
||||
- **Always apply user preferences** — load from Step 1 in SKILL.md and apply `fontFamily`, `roughness`, `fontSize`, `strokeWidth` to every element.
|
||||
- **Create shapes first, arrows second** (two separate `batch_create_elements` calls).
|
||||
- **Assign custom `id`** to every shape so arrows can reference it.
|
||||
- **Size shapes for their text** — Virgil font is ~30% wider than standard. Use sizing formulas from SKILL.md.
|
||||
- **Size shapes for their text** — use sizing formulas from SKILL.md.
|
||||
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects — normalized automatically.
|
||||
- **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points. **Elbowed arrows**: Use `"elbowed": true`.
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Geometric Thinking — Detailed Technique Catalogs
|
||||
|
||||
## Technique Catalog: Illustrative Diagrams
|
||||
|
||||
| Visual Element | Primitive Composition | Key Parameters |
|
||||
|---------------|----------------------|----------------|
|
||||
| **Tiled roof** | Grid of diamonds, rows reduce by 2, interleave offset rows | tile_size, row_count, center_x |
|
||||
| **Brick wall** | Grid of rectangles, alternating rows offset by half-width | brick_w, brick_h, mortar_gap |
|
||||
| **Cloud** | 5-7 overlapping ellipses of varying sizes | center, radii, overlap% |
|
||||
| **Tree** | Rectangle trunk + 3-4 overlapping ellipses (canopy) | trunk_w, canopy_r |
|
||||
| **Fence** | Repeated thin rectangles with pointed-top triangles | post_spacing, post_h |
|
||||
| **Road/path** | Two parallel lines + dashed center line | width, dash_pattern |
|
||||
| **Water/waves** | Repeating sine-curve approximated by overlapping ellipses | amplitude, wavelength |
|
||||
| **Sun rays** | Central ellipse + rotated lines at equal angles | ray_count, ray_length |
|
||||
| **Smoke/steam** | 3+ ellipses of increasing size, ascending and drifting | size_step, drift_x |
|
||||
| **Window (classic)** | Rectangle + 2 thin rectangle cross-bars (H and V) | win_w, win_h, bar_thickness=3 |
|
||||
| **Stairs** | Stacked rectangles, each offset right and down | step_w, step_h, count |
|
||||
|
||||
## Technique Catalog: Flow Diagrams (Sugiyama-Inspired Layout)
|
||||
|
||||
For flowcharts and directed graphs, use the Sugiyama hierarchical layout algorithm:
|
||||
|
||||
**Step 1 — Layer Assignment:**
|
||||
Assign each node to a horizontal layer. Entry nodes at top (layer 0), each subsequent step increases layer number. Nodes in the same layer share the same Y coordinate.
|
||||
|
||||
```
|
||||
layer_y[n] = start_y + n * (node_height + vertical_gap)
|
||||
vertical_gap: 120px minimum (for visible arrows)
|
||||
```
|
||||
|
||||
**Step 2 — Ordering Within Layers:**
|
||||
Position nodes within each layer to minimize edge crossings. Place each node at the average X of its connected neighbors in the layer above.
|
||||
|
||||
```
|
||||
node_x = average(connected_parent_x_positions)
|
||||
If two nodes overlap: spread by node_width + horizontal_gap
|
||||
```
|
||||
|
||||
**Step 3 — Coordinate Assignment:**
|
||||
Center nodes around the diagram midpoint. Distribute evenly within each layer.
|
||||
|
||||
```
|
||||
layer_width = count * node_width + (count - 1) * horizontal_gap
|
||||
first_x = center_x - layer_width / 2
|
||||
node_x[i] = first_x + i * (node_width + horizontal_gap)
|
||||
```
|
||||
|
||||
**Step 4 — Edge Routing:**
|
||||
Create arrows after all shapes. Use `startElementId`/`endElementId` for binding.
|
||||
- Same-layer connections: horizontal arrows
|
||||
- Cross-layer connections: vertical arrows
|
||||
- Multi-layer spans: consider adding routing waypoints
|
||||
|
||||
**Decision branches:**
|
||||
```
|
||||
YES path → horizontal right to answer box (same layer)
|
||||
NO path → vertical down to next decision (next layer)
|
||||
```
|
||||
|
||||
## Technique Catalog: Architecture Diagrams (Zone-Grid Layout)
|
||||
|
||||
**Zone-based layout** for microservices, infrastructure, and system architecture:
|
||||
|
||||
**Step 1 — Define zones as large translucent rectangles:**
|
||||
```
|
||||
zone_width = max(services_count * (service_w + gap) + padding * 2, 400)
|
||||
zone_height = rows * (service_h + gap) + padding * 2 + title_height
|
||||
zone_bg = "#e9ecef", opacity = 30
|
||||
```
|
||||
|
||||
**Step 2 — Grid-pack services within zones:**
|
||||
```
|
||||
col = service_index % cols_per_row
|
||||
row = service_index / cols_per_row (integer division)
|
||||
service_x = zone_x + padding + col * (service_w + gap)
|
||||
service_y = zone_y + title_height + padding + row * (service_h + gap)
|
||||
```
|
||||
|
||||
**Step 3 — Connect zones with arrows:**
|
||||
- Solid arrows for synchronous calls
|
||||
- Dashed arrows (`strokeStyle: "dashed"`) for async/event-driven
|
||||
- Label arrows with protocol/method (REST, gRPC, Kafka, etc.)
|
||||
|
||||
**Step 4 — Layer zones top-to-bottom by dependency depth:**
|
||||
```
|
||||
Client layer: y = 0
|
||||
API Gateway: y = zone_height + 80
|
||||
Services: y = 2 * (zone_height + 80)
|
||||
Data stores: y = 3 * (zone_height + 80)
|
||||
```
|
||||
|
||||
## Technique Catalog: Isometric / 2.5D Diagrams
|
||||
|
||||
For infrastructure and deployment diagrams with depth:
|
||||
|
||||
```
|
||||
Isometric grid formulas:
|
||||
screen_x = origin_x + (col - row) * cell_width / 2
|
||||
screen_y = origin_y + (col + row) * cell_height / 2
|
||||
|
||||
For a server rack at grid position (2, 3):
|
||||
screen_x = 500 + (2 - 3) * 60 / 2 = 470
|
||||
screen_y = 100 + (2 + 3) * 30 / 2 = 175
|
||||
```
|
||||
|
||||
Use diamonds for floor tiles, parallelogram-approximated rectangles for side faces. Stack elements vertically (subtract from y) to show height.
|
||||
|
||||
## Technique Catalog: Repeating Patterns
|
||||
|
||||
For any repeating visual pattern (fences, grids, timelines, Gantt bars):
|
||||
|
||||
```
|
||||
Generic repeat formula:
|
||||
element_x[i] = start_x + i * (element_width + gap)
|
||||
element_y[i] = start_y (same for horizontal repeat)
|
||||
|
||||
With alternating offset (brick pattern):
|
||||
offset = (row % 2) * (element_width / 2 + gap / 2)
|
||||
element_x[i] = start_x + offset + i * (element_width + gap)
|
||||
```
|
||||
|
||||
## Anti-Patterns for Geometric Composition
|
||||
|
||||
| Mistake | Why It Fails | Do This Instead |
|
||||
|---------|-------------|-----------------|
|
||||
| Single large primitive for complex shape | Looks flat, unrealistic | Compose from many small primitives |
|
||||
| Same-row diamonds without interleaving | Visible triangular gaps | Add offset rows at midpoint Y |
|
||||
| Guessing coordinates | Misaligned elements, uneven spacing | Use parametric formulas |
|
||||
| Same tile size for all containers | Looks wrong at different scales | Scale tile size to container width |
|
||||
| Too few primitives | Sparse, gappy appearance | Use enough tiles to achieve ≥80% coverage |
|
||||
| Forgetting z-order | Background elements cover foreground | Create back-to-front: background first, details last |
|
||||
@@ -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);
|
||||
@@ -161,6 +168,21 @@ function runMigrations(): void {
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_tenant ON projects(tenant_id)`);
|
||||
|
||||
// Migration: add sync_version to elements table
|
||||
const elementCols = db.prepare("PRAGMA table_info(elements)").all() as { name: string }[];
|
||||
if (!elementCols.some(c => c.name === 'sync_version')) {
|
||||
db.exec(`ALTER TABLE elements ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_elements_sync_version ON elements(project_id, sync_version)`);
|
||||
logger.info('Migrated: added sync_version column to elements');
|
||||
}
|
||||
|
||||
// Migration: add sync_version counter to projects table
|
||||
const projectCols = db.prepare("PRAGMA table_info(projects)").all() as { name: string }[];
|
||||
if (!projectCols.some(c => c.name === 'sync_version')) {
|
||||
db.exec(`ALTER TABLE projects ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0`);
|
||||
logger.info('Migrated: added sync_version counter to projects');
|
||||
}
|
||||
|
||||
// Migration: assign orphan projects (no tenant_id) to default tenant
|
||||
const orphans = db.prepare('SELECT id FROM projects WHERE tenant_id IS NULL').all() as { id: string }[];
|
||||
if (orphans.length > 0) {
|
||||
@@ -188,6 +210,44 @@ function pid(override?: string): string {
|
||||
return override ?? activeProjectId;
|
||||
}
|
||||
|
||||
// ── Sync Version ──
|
||||
|
||||
export function incrementSyncVersion(projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
db.prepare('UPDATE projects SET sync_version = sync_version + 1 WHERE id = ?').run(p);
|
||||
const row = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(p) as { sync_version: number } | undefined;
|
||||
return row?.sync_version ?? 0;
|
||||
}
|
||||
|
||||
export function getCurrentSyncVersion(projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const row = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(p) as { sync_version: number } | undefined;
|
||||
return row?.sync_version ?? 0;
|
||||
}
|
||||
|
||||
export interface ElementChange {
|
||||
id: string;
|
||||
action: 'upsert' | 'delete';
|
||||
element: ServerElement;
|
||||
sync_version: number;
|
||||
}
|
||||
|
||||
export function getChangesSince(sinceVersion: number, projectId?: string): ElementChange[] {
|
||||
const p = pid(projectId);
|
||||
const rows = db.prepare(`
|
||||
SELECT id, data, sync_version, is_deleted FROM elements
|
||||
WHERE project_id = ? AND sync_version > ?
|
||||
ORDER BY sync_version ASC
|
||||
`).all(p, sinceVersion) as { id: string; data: string; sync_version: number; is_deleted: number }[];
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
action: r.is_deleted ? 'delete' as const : 'upsert' as const,
|
||||
element: JSON.parse(r.data),
|
||||
sync_version: r.sync_version
|
||||
}));
|
||||
}
|
||||
|
||||
// Given a tenant ID, return its default project (creating one if needed)
|
||||
export function getDefaultProjectForTenant(tenantId: string): string {
|
||||
const row = db.prepare(
|
||||
@@ -220,11 +280,12 @@ export function hasElement(id: string, projectId?: string): boolean {
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function setElement(id: string, element: ServerElement, projectId?: string): void {
|
||||
export function setElement(id: string, element: ServerElement, projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const now = new Date().toISOString();
|
||||
const data = JSON.stringify(element);
|
||||
const labelText = extractLabelText(element);
|
||||
const sv = incrementSyncVersion(p);
|
||||
const existing = db.prepare(
|
||||
'SELECT version, is_deleted FROM elements WHERE id = ? AND project_id = ?'
|
||||
).get(id, p) as { version: number; is_deleted: number } | undefined;
|
||||
@@ -232,21 +293,22 @@ export function setElement(id: string, element: ServerElement, projectId?: strin
|
||||
if (existing) {
|
||||
const newVersion = existing.is_deleted ? 1 : (existing.version + 1);
|
||||
db.prepare(`
|
||||
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0
|
||||
UPDATE elements SET type = ?, data = ?, label_text = ?, updated_at = ?, version = ?, is_deleted = 0, sync_version = ?
|
||||
WHERE id = ? AND project_id = ?
|
||||
`).run(element.type, data, labelText, now, newVersion, id, p);
|
||||
`).run(element.type, data, labelText, now, newVersion, sv, id, p);
|
||||
|
||||
recordVersion(id, newVersion, data, existing.is_deleted ? 'create' : 'update', p);
|
||||
updateFts(id, labelText, element.type);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`).run(id, p, element.type, data, labelText, now, now);
|
||||
INSERT INTO elements (id, project_id, type, data, label_text, created_at, updated_at, version, sync_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
`).run(id, p, element.type, data, labelText, now, now, sv);
|
||||
|
||||
recordVersion(id, 1, data, 'create', p);
|
||||
insertFts(id, labelText, element.type);
|
||||
}
|
||||
return sv;
|
||||
}
|
||||
|
||||
export function deleteElement(id: string, projectId?: string): boolean {
|
||||
@@ -258,10 +320,11 @@ export function deleteElement(id: string, projectId?: string): boolean {
|
||||
if (!existing) return false;
|
||||
|
||||
const newVersion = existing.version + 1;
|
||||
const sv = incrementSyncVersion(p);
|
||||
db.prepare(`
|
||||
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?
|
||||
UPDATE elements SET is_deleted = 1, version = ?, updated_at = ?, sync_version = ?
|
||||
WHERE id = ? AND project_id = ?
|
||||
`).run(newVersion, new Date().toISOString(), id, p);
|
||||
`).run(newVersion, new Date().toISOString(), sv, id, p);
|
||||
|
||||
recordVersion(id, newVersion, existing.data, 'delete', p);
|
||||
deleteFts(id);
|
||||
@@ -286,14 +349,15 @@ export function clearElements(projectId?: string): number {
|
||||
const p = pid(projectId);
|
||||
const now = new Date().toISOString();
|
||||
const elements = getAllElements(p);
|
||||
const sv = incrementSyncVersion(p);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?
|
||||
UPDATE elements SET is_deleted = 1, version = version + 1, updated_at = ?, sync_version = ?
|
||||
WHERE project_id = ? AND is_deleted = 0
|
||||
`);
|
||||
|
||||
const clearTx = db.transaction(() => {
|
||||
const info = stmt.run(now, p);
|
||||
const info = stmt.run(now, sv, p);
|
||||
for (const el of elements) {
|
||||
recordVersion(el.id, (el.version || 1) + 1, JSON.stringify(el), 'delete', p);
|
||||
deleteFts(el.id);
|
||||
@@ -501,9 +565,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"fonts": [
|
||||
{ "id": 5, "name": "Excalifont", "label": "Excalifont (hand-drawn)", "aliases": ["excalifont", "hand-drawn"] },
|
||||
{ "id": 2, "name": "Helvetica", "label": "Helvetica (sans-serif)", "aliases": ["helvetica", "arial", "sans-serif"] },
|
||||
{ "id": 3, "name": "Cascadia", "label": "Cascadia (monospace)", "aliases": ["cascadia", "monospace", "courier"] },
|
||||
{ "id": 8, "name": "Comic Shanns", "label": "Comic Shanns", "aliases": ["comic shanns", "comic sans"] },
|
||||
{ "id": 6, "name": "Nunito", "label": "Nunito", "aliases": ["nunito"] },
|
||||
{ "id": 7, "name": "Lilita One", "label": "Lilita One", "aliases": ["lilita one"] },
|
||||
{ "id": 9, "name": "Liberation Sans", "label": "Liberation Sans", "aliases": ["liberation sans"], "legacy": true },
|
||||
{ "id": 1, "name": "Virgil", "label": "Virgil (legacy)", "aliases": ["virgil"], "legacy": true }
|
||||
],
|
||||
"defaultFontFamily": 5
|
||||
}
|
||||
+500
-98
@@ -25,7 +25,11 @@ import {
|
||||
EXCALIDRAW_ELEMENT_TYPES,
|
||||
ServerElement,
|
||||
ExcalidrawElementType,
|
||||
validateElement
|
||||
validateElement,
|
||||
normalizeFontFamily,
|
||||
files as globalFiles,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
FONT_FAMILY_DESCRIPTION,
|
||||
} from './types.js';
|
||||
import fetch from 'node-fetch';
|
||||
import { startCanvasServer, stopCanvasServer } from './server.js';
|
||||
@@ -63,6 +67,51 @@ 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;
|
||||
|
||||
// User preferences for element defaults (font, roughness, etc.)
|
||||
// Resolution: folder-level .claude/excalidraw-preferences.json > global ~/.claude/skills/excalidraw-skill/preferences.json > hardcoded
|
||||
interface ExcalidrawPreferences {
|
||||
fontFamily: number;
|
||||
fontSize: number;
|
||||
roughness: number;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: 20,
|
||||
roughness: 0,
|
||||
strokeWidth: 2,
|
||||
};
|
||||
|
||||
function loadPreferences(): ExcalidrawPreferences {
|
||||
const locations = [
|
||||
path.join(process.cwd(), '.claude', 'excalidraw-preferences.json'),
|
||||
path.join(process.env.HOME || '~', '.claude', 'skills', 'excalidraw-skill', 'preferences.json'),
|
||||
];
|
||||
|
||||
for (const loc of locations) {
|
||||
try {
|
||||
if (fs.existsSync(loc)) {
|
||||
const raw = JSON.parse(fs.readFileSync(loc, 'utf-8'));
|
||||
if (raw?.defaults) {
|
||||
logger.info(`Loaded user preferences from ${loc}`);
|
||||
return { ...HARDCODED_DEFAULTS, ...raw.defaults };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to read preferences from ${loc}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return HARDCODED_DEFAULTS;
|
||||
}
|
||||
|
||||
const USER_PREFS = loadPreferences();
|
||||
|
||||
// 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;
|
||||
@@ -73,9 +122,18 @@ interface ApiResponse {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface CanvasStatus {
|
||||
connectedBrowsers: number;
|
||||
ackedBy: number;
|
||||
reason?: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface SyncResponse {
|
||||
element?: ServerElement;
|
||||
elements?: ServerElement[];
|
||||
syncedToCanvas?: boolean;
|
||||
canvasStatus?: CanvasStatus;
|
||||
}
|
||||
|
||||
function canvasHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
@@ -150,34 +208,50 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
|
||||
return result as SyncResponse;
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Canvas sync failed for ${operation}:`, (error as Error).message);
|
||||
// Don't throw - we want MCP operations to work even if canvas is unavailable
|
||||
return null;
|
||||
const err = error as Error & { cause?: { code?: string } };
|
||||
// Distinguish network errors (canvas truly unavailable) from API errors (canvas responded with error).
|
||||
// Network errors: return null so MCP can degrade gracefully.
|
||||
// API errors: re-throw so the caller gets the actual error message.
|
||||
const isNetworkError = err.message?.includes('fetch failed') ||
|
||||
err.message?.includes('ECONNREFUSED') ||
|
||||
err.cause?.code === 'ECONNREFUSED' ||
|
||||
err.cause?.code === 'ENOTFOUND' ||
|
||||
err.message?.includes('network') ||
|
||||
err.name === 'TypeError'; // fetch throws TypeError for network failures
|
||||
|
||||
if (isNetworkError) {
|
||||
logger.warn(`Canvas unavailable for ${operation}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
// API error — propagate the actual error message
|
||||
logger.warn(`Canvas API error for ${operation}:`, err.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to sync element creation to canvas
|
||||
async function createElementOnCanvas(elementData: ServerElement): Promise<ServerElement | null> {
|
||||
async function createElementOnCanvas(elementData: ServerElement): Promise<SyncResponse | null> {
|
||||
const result = await syncToCanvas('create', elementData);
|
||||
return result?.element || elementData;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to sync element update to canvas
|
||||
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<ServerElement | null> {
|
||||
// Helper to sync element update to canvas
|
||||
async function updateElementOnCanvas(elementData: Partial<ServerElement> & { id: string }): Promise<SyncResponse | null> {
|
||||
const result = await syncToCanvas('update', elementData);
|
||||
return result?.element || null;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to sync element deletion to canvas
|
||||
async function deleteElementOnCanvas(elementId: string): Promise<any> {
|
||||
const result = await syncToCanvas('delete', { id: elementId });
|
||||
return result;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to sync batch creation to canvas
|
||||
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<ServerElement[] | null> {
|
||||
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<SyncResponse | null> {
|
||||
const result = await syncToCanvas('batch_create', elementsData);
|
||||
return result?.elements || elementsData;
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
// Helper to fetch element from canvas
|
||||
@@ -247,7 +321,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 +332,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 +486,7 @@ const tools: Tool[] = [
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' },
|
||||
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
|
||||
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 +717,7 @@ const tools: Tool[] = [
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' },
|
||||
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
|
||||
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 +743,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 +1055,15 @@ 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,
|
||||
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
fontSize: elementProps.fontSize ?? USER_PREFS.fontSize,
|
||||
strokeWidth: elementProps.strokeWidth ?? USER_PREFS.strokeWidth,
|
||||
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(),
|
||||
@@ -994,22 +1080,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const excalidrawElement = convertTextToLabel(element);
|
||||
|
||||
// Create element directly on HTTP server (no local storage)
|
||||
const canvasElement = await createElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasElement) {
|
||||
const canvasResponse = await createElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to create element: HTTP server unavailable');
|
||||
}
|
||||
|
||||
logger.info('Element created via MCP and synced to canvas', {
|
||||
id: excalidrawElement.id,
|
||||
|
||||
const synced = canvasResponse.syncedToCanvas ?? false;
|
||||
logger.info('Element created via MCP', {
|
||||
id: excalidrawElement.id,
|
||||
type: excalidrawElement.type,
|
||||
synced: !!canvasElement
|
||||
synced,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
});
|
||||
|
||||
|
||||
const statusEmoji = synced ? '✅' : '⚠️';
|
||||
const statusText = synced
|
||||
? 'Synced to canvas and confirmed by browser'
|
||||
: `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element created successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${statusEmoji} ${statusText}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1020,10 +1113,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()
|
||||
};
|
||||
@@ -1032,21 +1126,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const excalidrawElement = convertTextToLabel(updatePayload as ServerElement);
|
||||
|
||||
// Update element directly on HTTP server (no local storage)
|
||||
const canvasElement = await updateElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasElement) {
|
||||
const canvasResponse = await updateElementOnCanvas(excalidrawElement);
|
||||
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to update element: HTTP server unavailable or element not found');
|
||||
}
|
||||
|
||||
logger.info('Element updated via MCP and synced to canvas', {
|
||||
id: excalidrawElement.id,
|
||||
synced: !!canvasElement
|
||||
|
||||
const synced = canvasResponse.syncedToCanvas ?? false;
|
||||
logger.info('Element updated via MCP', {
|
||||
id: excalidrawElement.id,
|
||||
synced,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
content: [{
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Element updated successfully!\n\n${JSON.stringify(canvasElement, null, 2)}\n\n✅ Synced to canvas`
|
||||
text: `Element updated successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${synced ? '✅ Synced to canvas and confirmed' : `⚠️ Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1468,11 +1564,15 @@ 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,
|
||||
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
|
||||
roughness: elementProps.roughness ?? USER_PREFS.roughness,
|
||||
fontSize: elementProps.fontSize ?? USER_PREFS.fontSize,
|
||||
strokeWidth: elementProps.strokeWidth ?? USER_PREFS.strokeWidth,
|
||||
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(),
|
||||
@@ -1489,28 +1589,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
createdElements.push(excalidrawElement);
|
||||
}
|
||||
|
||||
const canvasElements = await batchCreateElementsOnCanvas(createdElements);
|
||||
const canvasResponse = await batchCreateElementsOnCanvas(createdElements);
|
||||
|
||||
if (!canvasElements) {
|
||||
if (!canvasResponse) {
|
||||
throw new Error('Failed to batch create elements: HTTP server unavailable');
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
elements: canvasElements,
|
||||
count: canvasElements.length,
|
||||
syncedToCanvas: true
|
||||
elements: canvasResponse.elements ?? createdElements,
|
||||
count: (canvasResponse.elements ?? createdElements).length,
|
||||
syncedToCanvas: canvasResponse.syncedToCanvas ?? false,
|
||||
canvasStatus: canvasResponse.canvasStatus
|
||||
};
|
||||
|
||||
logger.info('Batch elements created via MCP and synced to canvas', {
|
||||
logger.info('Batch elements created via MCP', {
|
||||
count: result.count,
|
||||
synced: result.syncedToCanvas
|
||||
synced: result.syncedToCanvas,
|
||||
canvasStatus: result.canvasStatus
|
||||
});
|
||||
|
||||
const statusEmoji = result.syncedToCanvas ? '✅' : '⚠️';
|
||||
const statusText = result.syncedToCanvas
|
||||
? 'All elements synced to canvas and confirmed by browser'
|
||||
: `Canvas sync not confirmed (${result.canvasStatus?.reason ?? 'unknown'})`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${result.syncedToCanvas ? '✅ All elements synced to canvas' : '⚠️ Canvas sync failed (elements still created locally)'}`
|
||||
text: `${result.count} elements created successfully!\n\n${JSON.stringify(result, null, 2)}\n\n${statusEmoji} ${statusText}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1530,23 +1637,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 +1751,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 +1765,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
appState: {
|
||||
viewBackgroundColor: '#ffffff',
|
||||
gridSize: null
|
||||
}
|
||||
},
|
||||
files: exportFiles
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(excalidrawScene, null, 2);
|
||||
@@ -1587,7 +1777,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,8 +1819,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
throw new Error('No elements found in the import data');
|
||||
}
|
||||
|
||||
if (params.mode === 'replace') {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
// 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 {}
|
||||
}
|
||||
|
||||
// Batch create the imported elements
|
||||
@@ -1642,7 +1850,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
version: 1
|
||||
}));
|
||||
|
||||
const canvasElements = await batchCreateElementsOnCanvas(elementsToCreate);
|
||||
if (params.mode === 'replace') {
|
||||
// Backup current elements before clearing to prevent data loss
|
||||
const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() });
|
||||
const backupData = await backupResp.json() as ApiResponse;
|
||||
const backupElements = backupData.elements || [];
|
||||
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
|
||||
try {
|
||||
await batchCreateElementsOnCanvas(elementsToCreate);
|
||||
} catch (createError) {
|
||||
// Restore backup atomically to prevent data loss
|
||||
logger.error('Import failed after clear, restoring backup:', (createError as Error).message);
|
||||
if (backupElements.length > 0) {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, {
|
||||
method: 'POST',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ elements: backupElements })
|
||||
});
|
||||
}
|
||||
throw new Error(`Import failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`);
|
||||
}
|
||||
} else {
|
||||
await batchCreateElementsOnCanvas(elementsToCreate);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
@@ -1714,24 +1946,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
logger.info('Duplicating elements via MCP', { count: params.elementIds.length });
|
||||
|
||||
const duplicates: ServerElement[] = [];
|
||||
// Build ID map first so binding references can be remapped
|
||||
const idMap = new Map<string, string>();
|
||||
const originals: ServerElement[] = [];
|
||||
for (const id of params.elementIds) {
|
||||
const original = await getElementFromCanvas(id);
|
||||
if (!original) {
|
||||
logger.warn(`Element ${id} not found, skipping duplicate`);
|
||||
continue;
|
||||
}
|
||||
const newId = generateId();
|
||||
idMap.set(id, newId);
|
||||
originals.push(original);
|
||||
}
|
||||
|
||||
const duplicates: ServerElement[] = [];
|
||||
for (const original of originals) {
|
||||
const { createdAt, updatedAt, version, syncedAt, source, syncTimestamp, ...rest } = original;
|
||||
const duplicate: ServerElement = {
|
||||
...rest,
|
||||
id: generateId(),
|
||||
id: idMap.get(original.id) || generateId(),
|
||||
x: original.x + offsetX,
|
||||
y: original.y + offsetY,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1
|
||||
};
|
||||
|
||||
// Remap binding references to point to duplicated elements
|
||||
const dup = duplicate as any;
|
||||
if (dup.startElementId && idMap.has(dup.startElementId)) {
|
||||
dup.startElementId = idMap.get(dup.startElementId);
|
||||
}
|
||||
if (dup.endElementId && idMap.has(dup.endElementId)) {
|
||||
dup.endElementId = idMap.get(dup.endElementId);
|
||||
}
|
||||
if (dup.start?.id && idMap.has(dup.start.id)) {
|
||||
dup.start = { ...dup.start, id: idMap.get(dup.start.id) };
|
||||
}
|
||||
if (dup.end?.id && idMap.has(dup.end.id)) {
|
||||
dup.end = { ...dup.end, id: idMap.get(dup.end.id) };
|
||||
}
|
||||
if (Array.isArray(dup.boundElements)) {
|
||||
dup.boundElements = dup.boundElements.map((be: any) => ({
|
||||
...be,
|
||||
id: idMap.has(be.id) ? idMap.get(be.id) : be.id
|
||||
}));
|
||||
}
|
||||
if (dup.containerId && idMap.has(dup.containerId)) {
|
||||
dup.containerId = idMap.get(dup.containerId);
|
||||
}
|
||||
|
||||
duplicates.push(duplicate);
|
||||
}
|
||||
|
||||
@@ -1786,10 +2051,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const data = await response.json() as { success: boolean; snapshot: { name: string; elements: ServerElement[]; createdAt: string } };
|
||||
|
||||
// Backup current elements before clearing to prevent data loss
|
||||
const backupResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, { headers: canvasHeaders() });
|
||||
const backupData = await backupResp.json() as ApiResponse;
|
||||
const backupElements = backupData.elements || [];
|
||||
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
|
||||
// Restore elements
|
||||
const canvasElements = await batchCreateElementsOnCanvas(data.snapshot.elements);
|
||||
// Restore elements from snapshot
|
||||
try {
|
||||
await batchCreateElementsOnCanvas(data.snapshot.elements);
|
||||
} catch (createError) {
|
||||
// Restore backup atomically to prevent data loss
|
||||
logger.error('Snapshot restore failed after clear, restoring backup:', (createError as Error).message);
|
||||
if (backupElements.length > 0) {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/sync`, {
|
||||
method: 'POST',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ elements: backupElements })
|
||||
});
|
||||
}
|
||||
throw new Error(`Snapshot restore failed: ${(createError as Error).message}. Previous ${backupElements.length} elements have been restored.`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
@@ -1814,7 +2097,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 +2107,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 +2116,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 +2214,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 +2245,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') }]
|
||||
};
|
||||
@@ -1924,7 +2262,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({
|
||||
format: 'png',
|
||||
background: params.background ?? true
|
||||
background: params.background ?? true,
|
||||
captureViewport: true
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2022,8 +2361,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
if (el.type === 'text') {
|
||||
base.text = text ?? '';
|
||||
base.originalText = text ?? '';
|
||||
base.fontSize = rest.fontSize ?? 20;
|
||||
base.fontFamily = rest.fontFamily ?? 1;
|
||||
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
||||
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
||||
base.textAlign = rest.textAlign ?? 'center';
|
||||
base.verticalAlign = rest.verticalAlign ?? 'middle';
|
||||
base.autoResize = rest.autoResize ?? true;
|
||||
@@ -2117,8 +2456,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
locked: false,
|
||||
text: labelText,
|
||||
originalText: labelText,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? 16),
|
||||
fontFamily: rest.fontFamily ?? 1,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
|
||||
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
autoResize: true,
|
||||
@@ -2475,12 +2814,31 @@ async function runServer(): Promise<void> {
|
||||
const { tenantId: newTid } = applyTenant(workspacePath);
|
||||
|
||||
try {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
const putRes = await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
method: 'PUT',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ tenantId: newTid })
|
||||
});
|
||||
} catch {}
|
||||
if (!putRes.ok) {
|
||||
logger.error(`Failed to set tenant on canvas server: HTTP ${putRes.status}`);
|
||||
}
|
||||
|
||||
// Verify the canvas server accepted the tenant switch
|
||||
const verifyRes = await fetch(`${EXPRESS_SERVER_URL}/api/tenant/active`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (verifyRes.ok) {
|
||||
const verifyData = await verifyRes.json() as { tenant?: { id?: string } };
|
||||
if (verifyData.tenant?.id !== newTid) {
|
||||
logger.error(
|
||||
`Canvas server has stale tenant: expected "${newTid}", got "${verifyData.tenant?.id}". ` +
|
||||
`Restart the canvas server or kill the process on port ${process.env['CANVAS_PORT'] || 3000}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (tenantErr) {
|
||||
logger.error('Failed to update tenant on canvas server:', (tenantErr as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (rootsErr) {
|
||||
@@ -2508,7 +2866,13 @@ async function runServer(): Promise<void> {
|
||||
}
|
||||
|
||||
// Add global error handlers
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
process.on('uncaughtException', (error: Error & { code?: string }) => {
|
||||
// EADDRINUSE from the canvas server is handled gracefully in startCanvasServer —
|
||||
// don't let a stray emit from httpServer kill the entire MCP process.
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.warn('Ignoring EADDRINUSE in global handler (canvas server will reuse existing instance)');
|
||||
return;
|
||||
}
|
||||
logger.error('Uncaught exception:', error);
|
||||
process.stderr.write(`UNCAUGHT EXCEPTION: ${error.message}\n${error.stack}\n`);
|
||||
setTimeout(() => process.exit(1), 1000);
|
||||
@@ -2525,12 +2889,50 @@ if (process.env.DEBUG === 'true') {
|
||||
logger.debug('Debug mode enabled');
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
function isMainModule(): boolean {
|
||||
try {
|
||||
const ourPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
||||
const argPath = process.argv[1];
|
||||
if (!argPath) return false;
|
||||
return ourPath === fs.realpathSync(path.resolve(argPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (arg === 'setup') {
|
||||
import('./setup.js').then(m => m.runSetup()).catch(error => {
|
||||
process.stderr.write(`Setup failed: ${(error as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (arg === 'update') {
|
||||
import('./setup.js').then(m => m.runUpdate()).catch(error => {
|
||||
process.stderr.write(`Update failed: ${(error as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (arg === '--help' || arg === '-h' || arg === '--version' || arg === '-v') {
|
||||
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(`${pkg.name} v${pkg.version}\n\nUsage:\n mcp-excalidraw-local Start MCP server (stdio transport)\n mcp-excalidraw-local setup Interactive setup wizard\n mcp-excalidraw-local update Update agent skills and MCP config\n mcp-excalidraw-local --help Show this help\n mcp-excalidraw-local --version Show version\n`);
|
||||
} else {
|
||||
process.stdout.write(`${pkg.version}\n`);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write('Could not read package.json\n');
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
} else {
|
||||
runServer().catch(error => {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default runServer;
|
||||
+569
-80
@@ -2,6 +2,7 @@ import express, { type Application, Request, Response, NextFunction } from 'expr
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
@@ -18,10 +19,15 @@ import {
|
||||
BatchCreatedMessage,
|
||||
SyncStatusMessage,
|
||||
InitialElementsMessage,
|
||||
Snapshot
|
||||
Snapshot,
|
||||
normalizeFontFamily,
|
||||
ExcalidrawFile,
|
||||
files,
|
||||
ClientConnection,
|
||||
BroadcastResult
|
||||
} 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, getCurrentSyncVersion, getChangesSince } from './db.js';
|
||||
import { z } from 'zod';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -53,62 +59,287 @@ function resolveTenantProject(req: Request): string | undefined {
|
||||
return getDefaultProjectForTenant(tenantId);
|
||||
}
|
||||
|
||||
// WebSocket connections
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
// Broadcast to all connected clients
|
||||
function broadcast(message: WebSocketMessage): void {
|
||||
const data = JSON.stringify(message);
|
||||
clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
});
|
||||
// Resolve both tenantId and projectId for scoped broadcast.
|
||||
// Falls back to active tenant/project when header is absent.
|
||||
function resolveScope(req: Request): { tenantId: string; projectId: string } {
|
||||
const headerTenantId = req.headers['x-tenant-id'] as string | undefined;
|
||||
if (headerTenantId) {
|
||||
const projectId = getDefaultProjectForTenant(headerTenantId) ?? `${headerTenantId}-default`;
|
||||
return { tenantId: headerTenantId, projectId };
|
||||
}
|
||||
// Fallback for browser requests without header
|
||||
const tenant = dbGetActiveTenant();
|
||||
const projectId = getDefaultProjectForTenant(tenant.id) ?? `${tenant.id}-default`;
|
||||
return { tenantId: tenant.id, projectId };
|
||||
}
|
||||
|
||||
// WebSocket connection handling
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
clients.add(ws);
|
||||
logger.info('New WebSocket connection established');
|
||||
// ── Connection Registry (Task 3) ──────────────────────────────────────────
|
||||
// Scoped by tenant → project → Set<ClientConnection>
|
||||
const connections = new Map<string, Map<string, Set<ClientConnection>>>();
|
||||
// Reverse lookup: ws → ClientConnection (for fast cleanup)
|
||||
const wsToConnection = new Map<WebSocket, ClientConnection>();
|
||||
|
||||
function registerConnection(conn: ClientConnection): void {
|
||||
let tenantMap = connections.get(conn.tenantId);
|
||||
if (!tenantMap) {
|
||||
tenantMap = new Map();
|
||||
connections.set(conn.tenantId, tenantMap);
|
||||
}
|
||||
let projectSet = tenantMap.get(conn.projectId);
|
||||
if (!projectSet) {
|
||||
projectSet = new Set();
|
||||
tenantMap.set(conn.projectId, projectSet);
|
||||
}
|
||||
projectSet.add(conn);
|
||||
wsToConnection.set(conn.ws, conn);
|
||||
}
|
||||
|
||||
function unregisterConnection(ws: WebSocket): void {
|
||||
const conn = wsToConnection.get(ws);
|
||||
if (!conn) return;
|
||||
const tenantMap = connections.get(conn.tenantId);
|
||||
if (tenantMap) {
|
||||
const projectSet = tenantMap.get(conn.projectId);
|
||||
if (projectSet) {
|
||||
projectSet.delete(conn);
|
||||
if (projectSet.size === 0) tenantMap.delete(conn.projectId);
|
||||
}
|
||||
if (tenantMap.size === 0) connections.delete(conn.tenantId);
|
||||
}
|
||||
wsToConnection.delete(ws);
|
||||
}
|
||||
|
||||
function moveConnection(ws: WebSocket, newTenantId: string, newProjectId: string): void {
|
||||
unregisterConnection(ws);
|
||||
const conn = { ws, tenantId: newTenantId, projectId: newProjectId, connectedAt: Date.now(), identified: true };
|
||||
registerConnection(conn);
|
||||
}
|
||||
|
||||
function getConnectionsForScope(tenantId: string, projectId: string): Set<ClientConnection> {
|
||||
return connections.get(tenantId)?.get(projectId) ?? new Set();
|
||||
}
|
||||
|
||||
// ── Scoped Broadcast (Task 5) ─────────────────────────────────────────────
|
||||
function broadcastToScope(
|
||||
tenantId: string,
|
||||
projectId: string,
|
||||
message: WebSocketMessage,
|
||||
exclude?: WebSocket
|
||||
): BroadcastResult {
|
||||
const msgId = generateId();
|
||||
(message as any).msgId = msgId;
|
||||
|
||||
const scopeConns = getConnectionsForScope(tenantId, projectId);
|
||||
const targets = [...scopeConns].filter(c =>
|
||||
c.ws !== exclude && c.ws.readyState === WebSocket.OPEN
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return { delivered: 0, msgId, reason: 'no_clients_in_scope' };
|
||||
}
|
||||
|
||||
const data = JSON.stringify(message);
|
||||
for (const conn of targets) {
|
||||
conn.ws.send(data);
|
||||
}
|
||||
|
||||
return { delivered: targets.length, msgId };
|
||||
}
|
||||
|
||||
// ── ACK Tracking (Task 6) ─────────────────────────────────────────────────
|
||||
interface AckResult {
|
||||
acked: boolean;
|
||||
delivered: number;
|
||||
reason?: string;
|
||||
ackPayload?: { status: string; elementCount?: number; expectedCount?: number };
|
||||
}
|
||||
|
||||
interface PendingAck {
|
||||
resolve: (payload: { status: string; elementCount?: number; expectedCount?: number } | null) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pendingAcks = new Map<string, PendingAck>();
|
||||
|
||||
function resolveAck(msgId: string, payload: { status: string; elementCount?: number; expectedCount?: number }): void {
|
||||
const pending = pendingAcks.get(msgId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pendingAcks.delete(msgId);
|
||||
pending.resolve(payload);
|
||||
}
|
||||
|
||||
async function broadcastWithAck(
|
||||
tenantId: string,
|
||||
projectId: string,
|
||||
message: WebSocketMessage,
|
||||
timeoutMs: number = 3000
|
||||
): Promise<AckResult> {
|
||||
const br = broadcastToScope(tenantId, projectId, message);
|
||||
|
||||
if (br.delivered === 0) {
|
||||
return { acked: false, delivered: 0, reason: br.reason ?? 'no_clients' };
|
||||
}
|
||||
|
||||
// Wait for first ACK from any client
|
||||
const ackPayload = await new Promise<{ status: string; elementCount?: number; expectedCount?: number } | null>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingAcks.delete(br.msgId);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
pendingAcks.set(br.msgId, { resolve, timer });
|
||||
});
|
||||
|
||||
return {
|
||||
acked: ackPayload !== null,
|
||||
delivered: br.delivered,
|
||||
ackPayload: ackPayload ?? undefined,
|
||||
reason: ackPayload ? undefined : 'ack_timeout'
|
||||
};
|
||||
}
|
||||
|
||||
// ── Per-Scope Broadcast Serialization ────────────────────────────────────
|
||||
// When multiple MCP tool calls fire in parallel (e.g., parallel create_element),
|
||||
// each produces a broadcastWithAck. Without serialization, the frontend receives
|
||||
// overlapping WS messages and getSceneElements() returns stale snapshots,
|
||||
// causing earlier elements to be clobbered.
|
||||
// This queue ensures broadcasts within the same scope are sent one at a time,
|
||||
// waiting for the previous ACK before sending the next.
|
||||
const scopeBroadcastQueues = new Map<string, Promise<AckResult>>();
|
||||
|
||||
async function serializedBroadcastWithAck(
|
||||
tenantId: string,
|
||||
projectId: string,
|
||||
message: WebSocketMessage,
|
||||
timeoutMs: number = 3000
|
||||
): Promise<AckResult> {
|
||||
const scopeKey = `${tenantId}/${projectId}`;
|
||||
|
||||
// Chain onto the previous broadcast for this scope (or start fresh)
|
||||
const previous = scopeBroadcastQueues.get(scopeKey) ?? Promise.resolve({} as AckResult);
|
||||
|
||||
const current = previous
|
||||
// Wait for previous to settle (success or failure) before sending ours
|
||||
.catch(() => {})
|
||||
.then(() => broadcastWithAck(tenantId, projectId, message, timeoutMs));
|
||||
|
||||
scopeBroadcastQueues.set(scopeKey, current);
|
||||
|
||||
// Send current tenant info
|
||||
try {
|
||||
const tenant = dbGetActiveTenant();
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tenant_switched',
|
||||
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
|
||||
}));
|
||||
} catch {}
|
||||
|
||||
// Send current elements to new client
|
||||
return await current;
|
||||
} finally {
|
||||
// Clean up if we're still the tail of the queue
|
||||
if (scopeBroadcastQueues.get(scopeKey) === current) {
|
||||
scopeBroadcastQueues.delete(scopeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy broadcast: sends to ALL connected clients (used for global messages
|
||||
// like tenant_switched that aren't scoped to a single project).
|
||||
function broadcast(message: WebSocketMessage): void {
|
||||
const data = JSON.stringify(message);
|
||||
for (const conn of wsToConnection.values()) {
|
||||
if (conn.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket Connection Handling (Task 4: Hello Handshake) ───────────────
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
// Register with fallback scope until hello handshake identifies the client.
|
||||
const tenant = (() => { try { return dbGetActiveTenant(); } catch { return { id: 'default', name: 'default', workspace_path: '' }; } })();
|
||||
const fallbackProjectId = getDefaultProjectForTenant(tenant.id) ?? 'default';
|
||||
const conn: ClientConnection = {
|
||||
ws,
|
||||
tenantId: tenant.id,
|
||||
projectId: fallbackProjectId,
|
||||
connectedAt: Date.now(),
|
||||
identified: false
|
||||
};
|
||||
registerConnection(conn);
|
||||
logger.info('New WebSocket connection established (awaiting hello)');
|
||||
|
||||
// Send tenant info so the FE knows where to send hello
|
||||
ws.send(JSON.stringify({
|
||||
type: 'tenant_switched',
|
||||
tenant: { id: tenant.id, name: tenant.name, workspace_path: tenant.workspace_path }
|
||||
}));
|
||||
|
||||
// For backward compatibility: also send initial_elements immediately.
|
||||
// New FE versions will ignore this and use hello_ack instead.
|
||||
const initialMessage: InitialElementsMessage = {
|
||||
type: 'initial_elements',
|
||||
elements: store.getAllElements()
|
||||
elements: store.getAllElements(fallbackProjectId)
|
||||
};
|
||||
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',
|
||||
elementCount: store.getElementCount(),
|
||||
elementCount: store.getElementCount(fallbackProjectId),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
ws.send(JSON.stringify(syncMessage));
|
||||
|
||||
|
||||
// Handle incoming messages from this client
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'hello') {
|
||||
const helloTenantId = msg.tenantId as string;
|
||||
const helloProjectId = (msg.projectId as string) || getDefaultProjectForTenant(msg.tenantId) || `${msg.tenantId}-default`;
|
||||
if (helloTenantId) {
|
||||
// Move connection to the correct scope
|
||||
moveConnection(ws, helloTenantId, helloProjectId);
|
||||
logger.info(`Client identified: tenant=${helloTenantId} project=${helloProjectId}`);
|
||||
|
||||
// Respond with scoped elements
|
||||
const elements = store.getAllElements(helloProjectId);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello_ack',
|
||||
tenantId: helloTenantId,
|
||||
projectId: helloProjectId,
|
||||
elements
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (msg.type === 'ack' && msg.msgId) {
|
||||
resolveAck(msg.msgId, {
|
||||
status: msg.status ?? 'applied',
|
||||
elementCount: msg.elementCount,
|
||||
expectedCount: msg.expectedCount
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug('Failed to parse WS message from client:', (err as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clients.delete(ws);
|
||||
unregisterConnection(ws);
|
||||
logger.info('WebSocket connection closed');
|
||||
});
|
||||
|
||||
|
||||
ws.on('error', (error) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
clients.delete(ws);
|
||||
unregisterConnection(ws);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 +352,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 +368,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 +392,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 +410,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
|
||||
@@ -195,32 +441,43 @@ app.get('/api/elements', (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Create new element
|
||||
app.post('/api/elements', (req: Request, res: Response) => {
|
||||
app.post('/api/elements', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const params = CreateElementSchema.parse(req.body);
|
||||
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
|
||||
};
|
||||
|
||||
store.setElement(id, element, projId);
|
||||
|
||||
const sv = store.setElement(id, element, projId);
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementCreatedMessage = {
|
||||
type: 'element_created',
|
||||
element: element
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
(message as any).sync_version = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
element: element
|
||||
element: element,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
ackedBy: ackResult.acked ? 1 : 0,
|
||||
reason: ackResult.reason,
|
||||
scope: `${scope.tenantId}/${scope.projectId}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating element:', error);
|
||||
@@ -232,7 +489,7 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Update element
|
||||
app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
app.put('/api/elements/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { id } = req.params;
|
||||
@@ -253,24 +510,35 @@ 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
|
||||
};
|
||||
|
||||
store.setElement(id, updatedElement, projId);
|
||||
|
||||
const sv = store.setElement(id, updatedElement, projId);
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementUpdatedMessage = {
|
||||
type: 'element_updated',
|
||||
element: updatedElement
|
||||
};
|
||||
broadcast(message);
|
||||
|
||||
(message as any).sync_version = sv;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
element: updatedElement
|
||||
element: updatedElement,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
ackedBy: ackResult.acked ? 1 : 0,
|
||||
reason: ackResult.reason,
|
||||
scope: `${scope.tenantId}/${scope.projectId}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating element:', error);
|
||||
@@ -287,7 +555,8 @@ app.delete('/api/elements/clear', (req: Request, res: Response) => {
|
||||
const projId = resolveTenantProject(req);
|
||||
const count = store.clearElements(projId);
|
||||
|
||||
broadcast({
|
||||
const scope = resolveScope(req);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'canvas_cleared',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
@@ -329,13 +598,13 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
store.deleteElement(id, projId);
|
||||
|
||||
// Broadcast to all connected clients
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const message: ElementDeletedMessage = {
|
||||
type: 'element_deleted',
|
||||
elementId: id!
|
||||
};
|
||||
broadcast(message);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -526,11 +795,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,
|
||||
@@ -549,7 +816,7 @@ function resolveArrowBindings(batchElements: ServerElement[], projectId?: string
|
||||
}
|
||||
|
||||
// Batch create elements
|
||||
app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
app.post('/api/elements/batch', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { elements: elementsToCreate } = req.body;
|
||||
@@ -566,9 +833,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
|
||||
@@ -579,19 +848,28 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
|
||||
resolveArrowBindings(createdElements, projId);
|
||||
|
||||
createdElements.forEach(el => store.setElement(el.id, el, projId));
|
||||
let latestSyncVersion = 0;
|
||||
createdElements.forEach(el => { latestSyncVersion = store.setElement(el.id, el, projId); });
|
||||
|
||||
// Broadcast to all connected clients
|
||||
const scope = resolveScope(req);
|
||||
const message: BatchCreatedMessage = {
|
||||
type: 'elements_batch_created',
|
||||
elements: createdElements
|
||||
};
|
||||
broadcast(message);
|
||||
(message as any).sync_version = latestSyncVersion;
|
||||
const ackResult = await serializedBroadcastWithAck(scope.tenantId, scope.projectId, message);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
elements: createdElements,
|
||||
count: createdElements.length
|
||||
count: createdElements.length,
|
||||
syncedToCanvas: ackResult.acked,
|
||||
canvasStatus: {
|
||||
connectedBrowsers: ackResult.delivered,
|
||||
ackedBy: ackResult.acked ? 1 : 0,
|
||||
reason: ackResult.reason,
|
||||
scope: `${scope.tenantId}/${scope.projectId}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error batch creating elements:', error);
|
||||
@@ -619,8 +897,9 @@ app.post('/api/elements/from-mermaid', (req: Request, res: Response) => {
|
||||
hasConfig: !!config
|
||||
});
|
||||
|
||||
// Broadcast to all WebSocket clients to process the Mermaid diagram
|
||||
broadcast({
|
||||
// Broadcast to scoped WebSocket clients to process the Mermaid diagram
|
||||
const scope = resolveScope(req);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'mermaid_convert',
|
||||
mermaidDiagram,
|
||||
config: config || {},
|
||||
@@ -688,7 +967,8 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
store.bulkReplaceElements(processedElements, projId);
|
||||
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
|
||||
|
||||
broadcast({
|
||||
const scope = resolveScope(req);
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'elements_synced',
|
||||
count: successCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -714,6 +994,140 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delta Sync v2 (Task 10) ──
|
||||
|
||||
app.post('/api/elements/sync/v2', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const { lastSyncVersion = 0, changes = [] } = req.body;
|
||||
|
||||
if (typeof lastSyncVersion !== 'number') {
|
||||
return res.status(400).json({ success: false, error: 'lastSyncVersion must be a number' });
|
||||
}
|
||||
|
||||
const scope = resolveScope(req);
|
||||
const feChangeIds = new Set<string>();
|
||||
|
||||
// Apply FE changes to DB
|
||||
let appliedCount = 0;
|
||||
for (const change of changes) {
|
||||
const { id, action, element } = change;
|
||||
if (!id || !action) continue;
|
||||
feChangeIds.add(id);
|
||||
|
||||
if (action === 'delete') {
|
||||
store.deleteElement(id, projId);
|
||||
appliedCount++;
|
||||
} else if (action === 'upsert' && element) {
|
||||
store.setElement(id, element, projId);
|
||||
appliedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get BE-side changes the FE hasn't seen (excluding what FE just sent)
|
||||
const allBEChanges = getChangesSince(lastSyncVersion, projId);
|
||||
const serverChanges = allBEChanges.filter(c => !feChangeIds.has(c.id));
|
||||
|
||||
const currentVersion = getCurrentSyncVersion(projId);
|
||||
|
||||
// Broadcast FE changes to other tabs in scope
|
||||
if (appliedCount > 0) {
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'elements_synced',
|
||||
count: appliedCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'delta_sync_v2',
|
||||
sync_version: currentVersion
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
currentSyncVersion: currentVersion,
|
||||
serverChanges,
|
||||
appliedCount
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Delta sync v2 error:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current sync version for a project
|
||||
app.get('/api/sync/version', (req: Request, res: Response) => {
|
||||
try {
|
||||
const projId = resolveTenantProject(req);
|
||||
const version = getCurrentSyncVersion(projId);
|
||||
res.json({ success: true, syncVersion: version });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
@@ -724,7 +1138,7 @@ const pendingExports = new Map<string, PendingExport>();
|
||||
|
||||
app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { format, background } = req.body;
|
||||
const { format, background, captureViewport } = req.body;
|
||||
|
||||
if (!format || !['png', 'svg'].includes(format)) {
|
||||
return res.status(400).json({
|
||||
@@ -733,7 +1147,7 @@ app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (clients.size === 0) {
|
||||
if (wsToConnection.size === 0) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'No frontend client connected. Open the canvas in a browser first.'
|
||||
@@ -741,6 +1155,7 @@ app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const requestId = generateId();
|
||||
const scope = resolveScope(req);
|
||||
|
||||
const exportPromise = new Promise<{ format: string; data: string }>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -751,11 +1166,12 @@ app.post('/api/export/image', (req: Request, res: Response) => {
|
||||
pendingExports.set(requestId, { resolve, reject, timeout });
|
||||
});
|
||||
|
||||
broadcast({
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'export_image_request',
|
||||
requestId,
|
||||
format,
|
||||
background: background ?? true
|
||||
background: background ?? true,
|
||||
captureViewport: captureViewport ?? false
|
||||
});
|
||||
|
||||
exportPromise
|
||||
@@ -832,7 +1248,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { scrollToContent, scrollToElementId, zoom, offsetX, offsetY } = req.body;
|
||||
|
||||
if (clients.size === 0) {
|
||||
if (wsToConnection.size === 0) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: 'No frontend client connected. Open the canvas in a browser first.'
|
||||
@@ -840,6 +1256,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const requestId = generateId();
|
||||
const scope = resolveScope(req);
|
||||
|
||||
const viewportPromise = new Promise<{ success: boolean; message: string }>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -850,7 +1267,7 @@ app.post('/api/viewport', (req: Request, res: Response) => {
|
||||
pendingViewports.set(requestId, { resolve, reject, timeout });
|
||||
});
|
||||
|
||||
broadcast({
|
||||
broadcastToScope(scope.tenantId, scope.projectId, {
|
||||
type: 'set_viewport',
|
||||
requestId,
|
||||
scrollToContent,
|
||||
@@ -1052,6 +1469,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);
|
||||
@@ -1059,7 +1504,7 @@ app.get('/health', (req: Request, res: Response) => {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
elements_count: store.getElementCount(projId),
|
||||
websocket_clients: clients.size
|
||||
websocket_clients: wsToConnection.size
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1074,7 +1519,7 @@ app.get('/api/sync/status', (req: Request, res: Response) => {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
|
||||
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
|
||||
},
|
||||
websocketClients: clients.size
|
||||
websocketClients: wsToConnection.size
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1091,9 +1536,37 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
const PORT = parseInt(process.env.CANVAS_PORT || process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || 'localhost';
|
||||
|
||||
export function startCanvasServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
/** Track whether we own the canvas server or are reusing an existing one. */
|
||||
let canvasServerOwned = false;
|
||||
|
||||
export function isCanvasServerOwned(): boolean {
|
||||
return canvasServerOwned;
|
||||
}
|
||||
|
||||
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.
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const res = await fetch(`http://${HOST}:${PORT}/health`, { signal: controller.signal as any });
|
||||
clearTimeout(timeout);
|
||||
const body = await res.json() as any;
|
||||
if (body?.status === 'healthy') {
|
||||
logger.info(`Reusing existing canvas server on port ${PORT} (elements: ${body.elements_count}, ws clients: ${body.websocket_clients})`);
|
||||
return; // reuse — skip listen entirely
|
||||
}
|
||||
} catch {
|
||||
// No server on this port (connection refused) or not a canvas server — proceed to start our own
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
httpServer.removeListener('error', onError);
|
||||
reject(err);
|
||||
};
|
||||
@@ -1101,22 +1574,38 @@ export function startCanvasServer(): Promise<void> {
|
||||
|
||||
httpServer.listen(PORT, HOST, () => {
|
||||
httpServer.removeListener('error', onError);
|
||||
logger.info(`Canvas server running on http://${HOST}:${PORT}`);
|
||||
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
canvasServerOwned = true;
|
||||
logger.info(`Canvas server running on http://${HOST}:${PORT}`);
|
||||
logger.info(`WebSocket server running on ws://${HOST}:${PORT}`);
|
||||
}
|
||||
|
||||
export function stopCanvasServer(): Promise<void> {
|
||||
if (!canvasServerOwned) {
|
||||
logger.info('Canvas server not owned by this process, skipping shutdown');
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
clients.forEach(c => c.close());
|
||||
for (const conn of wsToConnection.values()) conn.ws.close();
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Direct execution: `node dist/server.js` still works standalone
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
function isServerMainModule(): boolean {
|
||||
try {
|
||||
const ourPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
||||
const argPath = process.argv[1];
|
||||
if (!argPath) return false;
|
||||
return ourPath === fs.realpathSync(path.resolve(argPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isServerMainModule()) {
|
||||
startCanvasServer().catch((err) => {
|
||||
logger.error('Failed to start canvas server:', err);
|
||||
process.exit(1);
|
||||
|
||||
+788
@@ -0,0 +1,788 @@
|
||||
#!/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 { FONT_FAMILIES, DEFAULT_FONT_FAMILY } from './types.js';
|
||||
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;
|
||||
mcpCliRemove?: string;
|
||||
mcpCliCommand?: string;
|
||||
instructionConfig?: {
|
||||
global: string;
|
||||
local: string;
|
||||
format: 'claude-md' | 'cursor-mdc';
|
||||
};
|
||||
}
|
||||
|
||||
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'),
|
||||
instructionConfig: {
|
||||
global: path.join(home, '.cursor', 'rules', 'excalidraw.mdc'),
|
||||
local: path.join(process.cwd(), '.cursor', 'rules', 'excalidraw.mdc'),
|
||||
format: 'cursor-mdc',
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
mcpCliRemove: 'claude mcp remove excalidraw-canvas --scope user',
|
||||
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local@latest',
|
||||
instructionConfig: {
|
||||
global: path.join(home, '.claude', 'CLAUDE.md'),
|
||||
local: path.join(process.cwd(), 'CLAUDE.md'),
|
||||
format: 'claude-md',
|
||||
},
|
||||
},
|
||||
{
|
||||
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/4', 'Environment');
|
||||
let allOk = true;
|
||||
|
||||
// Node.js version
|
||||
const nodeVersion = process.version;
|
||||
const major = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
|
||||
if (major >= 20) {
|
||||
ok(`Node.js ${nodeVersion} ${'.' .repeat(Math.max(0, 24 - nodeVersion.length))} OK`);
|
||||
} else {
|
||||
fail(`Node.js ${nodeVersion} — requires >= 20.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(' 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(' Install "Desktop development with C++" from Visual Studio Build Tools');
|
||||
info(' https://visualstudio.microsoft.com/visual-cpp-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;
|
||||
}
|
||||
|
||||
// ── Preference Setup ─────────────────────────────────────────
|
||||
|
||||
// Derived from FONT_FAMILIES in types.ts — single source of truth
|
||||
const FONT_OPTIONS = FONT_FAMILIES
|
||||
.filter(f => !f.legacy)
|
||||
.map(f => ({ value: f.id, label: f.label }));
|
||||
|
||||
const ROUGHNESS_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 0, label: 'Clean / professional' },
|
||||
{ value: 1, label: 'Hand-drawn sketch' },
|
||||
{ value: 2, label: 'Very rough' },
|
||||
];
|
||||
|
||||
function getGlobalPreferencesPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'skills', 'excalidraw-skill', 'preferences.json');
|
||||
}
|
||||
|
||||
function globalPreferencesExist(): boolean {
|
||||
return fs.existsSync(getGlobalPreferencesPath());
|
||||
}
|
||||
|
||||
function writePreferencesFile(filePath: string, prefs: { fontFamily: number; fontSize: number; roughness: number; strokeWidth: number }): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const content = {
|
||||
defaults: prefs,
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function phasePreferences(rl: readline.Interface, phaseLabel: string): Promise<void> {
|
||||
heading(phaseLabel, 'Diagram Preferences');
|
||||
|
||||
const prefsPath = getGlobalPreferencesPath();
|
||||
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
|
||||
const d = raw?.defaults;
|
||||
if (d) {
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === d.fontFamily)?.label ?? `font ${d.fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === d.roughness)?.label ?? `roughness ${d.roughness}`;
|
||||
ok(`Current: ${fontLabel}, ${roughLabel}, fontSize ${d.fontSize}, strokeWidth ${d.strokeWidth}`);
|
||||
const change = await confirm(rl, 'Change preferences?', false);
|
||||
if (!change) return;
|
||||
}
|
||||
} catch {
|
||||
warn(`Could not read ${prefsPath}, will reconfigure.`);
|
||||
}
|
||||
}
|
||||
|
||||
info('These defaults apply to every diagram (font, style, etc.).');
|
||||
info('');
|
||||
|
||||
// Font
|
||||
process.stdout.write('\n Font family:\n');
|
||||
FONT_OPTIONS.forEach((f, i) => {
|
||||
const marker = f.value === DEFAULT_FONT_FAMILY ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${f.label}${marker}\n`);
|
||||
});
|
||||
const fontAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const fontIdx = fontAnswer === '' ? 0 : parseInt(fontAnswer, 10) - 1;
|
||||
const fontFamily = (fontIdx >= 0 && fontIdx < FONT_OPTIONS.length) ? FONT_OPTIONS[fontIdx]!.value : DEFAULT_FONT_FAMILY;
|
||||
|
||||
// Roughness
|
||||
process.stdout.write('\n Diagram style:\n');
|
||||
ROUGHNESS_OPTIONS.forEach((r, i) => {
|
||||
const marker = r.value === 0 ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${r.label}${marker}\n`);
|
||||
});
|
||||
const roughAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const roughIdx = roughAnswer === '' ? 0 : parseInt(roughAnswer, 10) - 1;
|
||||
const roughness = (roughIdx >= 0 && roughIdx < ROUGHNESS_OPTIONS.length) ? ROUGHNESS_OPTIONS[roughIdx]!.value : 0;
|
||||
|
||||
const prefs = { fontFamily, fontSize: 20, roughness, strokeWidth: 2 };
|
||||
|
||||
try {
|
||||
writePreferencesFile(prefsPath, prefs);
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === fontFamily)?.label ?? `${fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === roughness)?.label ?? `${roughness}`;
|
||||
ok(`Saved: ${fontLabel}, ${roughLabel}`);
|
||||
ok(`File: ${prefsPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed to save preferences: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Skill Installation ─────────────────────────────
|
||||
|
||||
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
|
||||
heading('2/4', '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}`);
|
||||
|
||||
// Write instruction directive to ensure skill auto-triggers
|
||||
if (agent.instructionConfig) {
|
||||
const instrPath = agent.instructionConfig[scope];
|
||||
try {
|
||||
writeInstructionDirective(instrPath, agent.instructionConfig.format);
|
||||
ok(`Skill directive added to ${instrPath}`);
|
||||
} catch (instrErr) {
|
||||
warn(`Could not write directive to ${instrPath}: ${(instrErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Instruction Directives ──────────────────────────────────
|
||||
|
||||
const DIRECTIVE_MARKER_START = '<!-- excalidraw-mcp:start -->';
|
||||
const DIRECTIVE_MARKER_END = '<!-- excalidraw-mcp:end -->';
|
||||
|
||||
const EXCALIDRAW_DIRECTIVE_BODY =
|
||||
'When Excalidraw MCP tools are available (batch_create_elements, create_element, ' +
|
||||
'create_from_mermaid, etc.), ALWAYS invoke the `/excalidraw-skill` skill FIRST ' +
|
||||
'before calling any Excalidraw tool. The skill contains essential sizing formulas, ' +
|
||||
'batch ordering rules (shapes first, arrows second), and a mandatory ' +
|
||||
'write-check-review verification cycle. Using Excalidraw tools without consulting ' +
|
||||
'this skill produces broken diagrams — invisible arrows, truncated text, ' +
|
||||
'overlapping elements.';
|
||||
|
||||
function buildClaudeMdSection(): string {
|
||||
return [
|
||||
DIRECTIVE_MARKER_START,
|
||||
'## Excalidraw Canvas — Skill Directive',
|
||||
'',
|
||||
EXCALIDRAW_DIRECTIVE_BODY,
|
||||
DIRECTIVE_MARKER_END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildCursorMdc(): string {
|
||||
return [
|
||||
'---',
|
||||
'description: Always consult excalidraw-skill before using Excalidraw MCP tools',
|
||||
'globs:',
|
||||
'alwaysApply: true',
|
||||
'---',
|
||||
'',
|
||||
'## Excalidraw Canvas — Skill Directive',
|
||||
'',
|
||||
EXCALIDRAW_DIRECTIVE_BODY,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function writeInstructionDirective(filePath: string, format: 'claude-md' | 'cursor-mdc'): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (format === 'cursor-mdc') {
|
||||
// Cursor .mdc files are standalone — write/overwrite the whole file
|
||||
fs.writeFileSync(filePath, buildCursorMdc(), 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
// For claude-md: append or replace the marked section
|
||||
let content = '';
|
||||
if (fs.existsSync(filePath)) {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
const section = buildClaudeMdSection();
|
||||
const startIdx = content.indexOf(DIRECTIVE_MARKER_START);
|
||||
const endIdx = content.indexOf(DIRECTIVE_MARKER_END);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
// Replace existing section
|
||||
content = content.slice(0, startIdx) + section + content.slice(endIdx + DIRECTIVE_MARKER_END.length);
|
||||
} else {
|
||||
// Append with spacing
|
||||
const trimmed = content.trimEnd();
|
||||
content = trimmed + (trimmed ? '\n\n' : '') + section + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
// ── Phase 3: MCP Configuration ──────────────────────────────
|
||||
|
||||
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
|
||||
heading('4/4', '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 {
|
||||
if (agent.mcpCliRemove) {
|
||||
try { execSync(agent.mcpCliRemove, { stdio: 'pipe' }); } catch { /* ignore if not found */ }
|
||||
}
|
||||
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@latest'],
|
||||
env: { CANVAS_PORT: '3000' },
|
||||
};
|
||||
|
||||
let existing: any = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
try {
|
||||
existing = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Failed to parse ${configPath} — fix the JSON syntax and try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
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 checkJsonConfigStatus(configPath: string): 'up-to-date' | 'needs-update' | 'not-found' {
|
||||
if (!fs.existsSync(configPath)) return 'not-found';
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
const entry = config?.mcpServers?.['excalidraw-canvas'];
|
||||
if (!entry) return 'not-found';
|
||||
|
||||
const args: string[] = entry.args ?? [];
|
||||
const hasLatest = args.some((a: string) => a.includes('@latest'));
|
||||
return hasLatest ? 'up-to-date' : 'needs-update';
|
||||
} catch {
|
||||
return 'not-found';
|
||||
}
|
||||
}
|
||||
|
||||
function printManualConfig(): void {
|
||||
process.stdout.write(`
|
||||
Manual config (JSON):
|
||||
${DIM}{
|
||||
"mcpServers": {
|
||||
"excalidraw-canvas": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local@latest"],
|
||||
"env": { "CANVAS_PORT": "3000" }
|
||||
}
|
||||
}
|
||||
}${RESET}
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────
|
||||
|
||||
interface SkillInstallation {
|
||||
agent: AgentDef;
|
||||
scope: 'global' | 'local';
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
function getPackageVersion(): string {
|
||||
try {
|
||||
const pkgPath = path.resolve(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
return pkg.version ?? 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingSkillInstalls(): SkillInstallation[] {
|
||||
const agents = getAgents();
|
||||
const installs: SkillInstallation[] = [];
|
||||
for (const agent of agents) {
|
||||
for (const scope of ['global', 'local'] as const) {
|
||||
const skillDir = path.join(agent.skillBasePaths[scope], 'excalidraw-skill');
|
||||
installs.push({
|
||||
agent,
|
||||
scope,
|
||||
path: skillDir,
|
||||
exists: fs.existsSync(path.join(skillDir, 'SKILL.md')),
|
||||
});
|
||||
}
|
||||
}
|
||||
return installs;
|
||||
}
|
||||
|
||||
export async function runUpdate(): Promise<void> {
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write('Error: Update requires an interactive terminal.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const version = getPackageVersion();
|
||||
process.stdout.write(`\n ${BOLD}Excalidraw MCP — Update${RESET} ${DIM}v${version}${RESET}\n`);
|
||||
|
||||
try {
|
||||
// ── Phase 1: Detect existing skill installations ──────────
|
||||
heading('1/3', 'Skill Update');
|
||||
|
||||
const allInstalls = findExistingSkillInstalls();
|
||||
const existing = allInstalls.filter(i => i.exists);
|
||||
const missing = allInstalls.filter(i => !i.exists);
|
||||
const detectedAgents = detectInstalledAgents();
|
||||
|
||||
const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill');
|
||||
if (!fs.existsSync(skillSource)) {
|
||||
fail(`Skill source not found at ${skillSource}`);
|
||||
fail('This can happen with corrupted installs. Try: npx @sanjibdevnath/mcp-excalidraw-local@latest setup');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
process.stdout.write(`\n Found ${CYAN}${existing.length}${RESET} existing skill installation(s):\n`);
|
||||
existing.forEach((inst, i) => {
|
||||
const label = `${inst.agent.name} (${inst.scope})`;
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${label} — ${DIM}${inst.path}${RESET}\n`);
|
||||
});
|
||||
|
||||
const doUpdate = await confirm(rl, `\n Update all ${existing.length} installation(s) to v${version}?`);
|
||||
if (doUpdate) {
|
||||
let updated = 0;
|
||||
for (const inst of existing) {
|
||||
try {
|
||||
copyDirSync(skillSource, inst.path);
|
||||
ok(`Updated ${inst.agent.name} (${inst.scope}) — ${inst.path}`);
|
||||
updated++;
|
||||
|
||||
// Update instruction directive
|
||||
if (inst.agent.instructionConfig) {
|
||||
const instrPath = inst.agent.instructionConfig[inst.scope];
|
||||
try {
|
||||
writeInstructionDirective(instrPath, inst.agent.instructionConfig.format);
|
||||
ok(`Skill directive updated in ${instrPath}`);
|
||||
} catch (instrErr) {
|
||||
warn(`Could not update directive in ${instrPath}: ${(instrErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`Failed to update ${inst.path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`\n ${GREEN}${updated}/${existing.length}${RESET} skill(s) updated.\n`);
|
||||
} else {
|
||||
info(`${DIM}Skipped skill update.${RESET}`);
|
||||
}
|
||||
} else {
|
||||
info('No existing skill installations found.');
|
||||
}
|
||||
|
||||
// Offer to install for detected agents that don't have the skill
|
||||
const agentsWithoutSkill = detectedAgents.filter(agent =>
|
||||
!existing.some(inst => inst.agent.name === agent.name),
|
||||
);
|
||||
|
||||
if (agentsWithoutSkill.length > 0) {
|
||||
process.stdout.write(`\n Agents without the skill:\n`);
|
||||
agentsWithoutSkill.forEach((a, i) => {
|
||||
process.stdout.write(` ${YELLOW}[${i + 1}]${RESET} ${a.name}\n`);
|
||||
});
|
||||
|
||||
const doInstall = await confirm(rl, 'Install the skill for these agents?');
|
||||
if (doInstall) {
|
||||
for (const agent of agentsWithoutSkill) {
|
||||
const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `);
|
||||
const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global';
|
||||
const destDir = path.join(agent.skillBasePaths[scope], 'excalidraw-skill');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
copyDirSync(skillSource, destDir);
|
||||
ok(`Installed to ${destDir}`);
|
||||
|
||||
// Write instruction directive
|
||||
if (agent.instructionConfig) {
|
||||
const instrPath = agent.instructionConfig[scope];
|
||||
try {
|
||||
writeInstructionDirective(instrPath, agent.instructionConfig.format);
|
||||
ok(`Skill directive added to ${instrPath}`);
|
||||
} catch (instrErr) {
|
||||
warn(`Could not write directive to ${instrPath}: ${(instrErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Preferences ─────────────────────────────────
|
||||
await phasePreferences(rl, '2/3');
|
||||
|
||||
// ── Phase 3: MCP config check ────────────────────────────
|
||||
heading('3/3', 'MCP Configuration');
|
||||
|
||||
for (const agent of detectedAgents) {
|
||||
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
|
||||
const status = checkJsonConfigStatus(agent.mcpConfigPath);
|
||||
|
||||
if (status === 'up-to-date') {
|
||||
ok(`${agent.name} — already uses @latest, auto-updates on restart.`);
|
||||
} else if (status === 'needs-update') {
|
||||
const doIt = await confirm(rl, `${agent.name} — config uses a pinned version. Migrate to @latest?`);
|
||||
if (doIt) {
|
||||
try {
|
||||
mergeJsonConfig(agent.mcpConfigPath);
|
||||
ok(`Migrated to @latest in ${agent.mcpConfigPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const doIt = await confirm(rl, `${agent.name} — no MCP config found. Add it?`);
|
||||
if (doIt) {
|
||||
try {
|
||||
mergeJsonConfig(agent.mcpConfigPath);
|
||||
ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) {
|
||||
info(`${agent.name} — config managed via CLI (cannot auto-detect version).`);
|
||||
const doIt = await confirm(rl, `${agent.name} — re-register with @latest?`, false);
|
||||
if (doIt) {
|
||||
try {
|
||||
if (agent.mcpCliRemove) {
|
||||
try { execSync(agent.mcpCliRemove, { stdio: 'pipe' }); } catch { /* ignore if not found */ }
|
||||
}
|
||||
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
|
||||
ok(`Re-registered 'excalidraw-canvas' via ${agent.name} CLI`);
|
||||
} catch (err) {
|
||||
fail(`CLI registration failed: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
info(`${DIM}Skipped.${RESET}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n ${GREEN}${BOLD}Update complete!${RESET} Restart your MCP client to pick up changes.\n\n`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
export async function runSetup(): Promise<void> {
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write('Error: Setup requires an interactive terminal. Run this command directly in your terminal (not piped or in CI).\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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 phasePreferences(rl, '3/4');
|
||||
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();
|
||||
}
|
||||
}
|
||||
+104
-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,48 @@ export type WebSocketMessageType =
|
||||
| 'canvas_cleared'
|
||||
| 'export_image_request'
|
||||
| 'set_viewport'
|
||||
| 'tenant_switched';
|
||||
| 'tenant_switched'
|
||||
| 'files_added'
|
||||
| 'file_deleted'
|
||||
| 'hello'
|
||||
| 'hello_ack'
|
||||
| 'ack';
|
||||
|
||||
// Connection registry types
|
||||
export interface ClientConnection {
|
||||
ws: import('ws').WebSocket;
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
connectedAt: number;
|
||||
identified: boolean; // true after hello handshake
|
||||
}
|
||||
|
||||
export interface BroadcastResult {
|
||||
delivered: number;
|
||||
msgId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface HelloMessage extends WebSocketMessage {
|
||||
type: 'hello';
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface HelloAckMessage extends WebSocketMessage {
|
||||
type: 'hello_ack';
|
||||
tenantId: string;
|
||||
projectId: string;
|
||||
elements: ServerElement[];
|
||||
}
|
||||
|
||||
export interface AckMessage extends WebSocketMessage {
|
||||
type: 'ack';
|
||||
msgId: string;
|
||||
status: 'applied' | 'partial' | 'failed';
|
||||
elementCount?: number;
|
||||
expectedCount?: number;
|
||||
}
|
||||
|
||||
export interface InitialElementsMessage extends WebSocketMessage {
|
||||
type: 'initial_elements';
|
||||
@@ -289,6 +338,58 @@ 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 families — single source of truth ──────────────────────────────
|
||||
// IDs match the @excalidraw/excalidraw FONT_FAMILY constant.
|
||||
// The canonical data lives in font-families.json; every other file derives from it.
|
||||
import fontData from './font-families.json' with { type: 'json' };
|
||||
|
||||
export interface FontFamilyDef {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string;
|
||||
aliases: string[];
|
||||
legacy?: boolean; // hidden from setup menus / tool docs
|
||||
}
|
||||
|
||||
export const FONT_FAMILIES: FontFamilyDef[] = fontData.fonts as FontFamilyDef[];
|
||||
|
||||
export const DEFAULT_FONT_FAMILY: number = fontData.defaultFontFamily;
|
||||
|
||||
// Derived: description string for MCP tool schemas
|
||||
export const FONT_FAMILY_DESCRIPTION =
|
||||
'Font family: ' +
|
||||
FONT_FAMILIES.filter(f => !f.legacy).map(f => `${f.id}=${f.name}`).join(', ') +
|
||||
'. Accepts name strings too.';
|
||||
|
||||
// Derived: string → number mapping for normalization
|
||||
const FONT_FAMILY_MAP: Record<string, number> = {};
|
||||
for (const font of FONT_FAMILIES) {
|
||||
for (const alias of font.aliases) {
|
||||
FONT_FAMILY_MAP[alias] = font.id;
|
||||
}
|
||||
}
|
||||
|
||||
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,589 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant } 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);
|
||||
// Reset module-level active tenant/project to 'default' (may be stale from previous test)
|
||||
setActiveTenant('default');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version ───────────────────────────────────────────
|
||||
|
||||
describe('GET /api/sync/version', () => {
|
||||
it('returns syncVersion 0 initially', async () => {
|
||||
const res = await request(app).get('/api/sync/version');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.syncVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('syncVersion increases after element creation', async () => {
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
|
||||
|
||||
const res = await request(app).get('/api/sync/version');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.syncVersion).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2 ──────────────────────────────────────────
|
||||
|
||||
describe('POST /api/elements/sync/v2', () => {
|
||||
it('returns currentSyncVersion and empty serverChanges', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body).toHaveProperty('currentSyncVersion');
|
||||
expect(typeof res.body.currentSyncVersion).toBe('number');
|
||||
expect(Array.isArray(res.body.serverChanges)).toBe(true);
|
||||
expect(res.body.serverChanges.length).toBe(0);
|
||||
});
|
||||
|
||||
it('applies upsert changes', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{
|
||||
id: 'sv2-1',
|
||||
action: 'upsert',
|
||||
element: { id: 'sv2-1', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.appliedCount).toBe(1);
|
||||
|
||||
const getRes = await request(app).get('/api/elements/sv2-1');
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body.element.id).toBe('sv2-1');
|
||||
});
|
||||
|
||||
it('applies delete changes', async () => {
|
||||
setElement('sv2-del', makeElement({ id: 'sv2-del' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'sv2-del', action: 'delete' }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.appliedCount).toBe(1);
|
||||
|
||||
const getRes = await request(app).get('/api/elements/sv2-del');
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns server changes since lastSyncVersion', async () => {
|
||||
setElement('sv1', makeElement({ id: 'sv1' }));
|
||||
setElement('sv2', makeElement({ id: 'sv2' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.serverChanges.length).toBeGreaterThanOrEqual(2);
|
||||
const ids = res.body.serverChanges.map((c: any) => c.id);
|
||||
expect(ids).toContain('sv1');
|
||||
expect(ids).toContain('sv2');
|
||||
});
|
||||
|
||||
it('rejects non-number lastSyncVersion', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 'bad', changes: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canvasStatus in mutation responses ─────────────────────
|
||||
|
||||
describe('canvasStatus in mutation responses', () => {
|
||||
it('POST /api/elements includes syncedToCanvas and canvasStatus', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.syncedToCanvas).toBe('boolean');
|
||||
expect(res.body.syncedToCanvas).toBe(false);
|
||||
expect(res.body.canvasStatus).toBeDefined();
|
||||
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
|
||||
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
|
||||
expect(res.body.canvasStatus).toHaveProperty('reason');
|
||||
expect(res.body.canvasStatus).toHaveProperty('scope');
|
||||
});
|
||||
|
||||
it('PUT /api/elements/:id includes canvasStatus', async () => {
|
||||
setElement('cs-put', makeElement({ id: 'cs-put', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/elements/cs-put')
|
||||
.send({ x: 100 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.syncedToCanvas).toBe('boolean');
|
||||
expect(res.body.canvasStatus).toBeDefined();
|
||||
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
|
||||
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
|
||||
expect(res.body.canvasStatus).toHaveProperty('reason');
|
||||
expect(res.body.canvasStatus).toHaveProperty('scope');
|
||||
});
|
||||
|
||||
it('POST /api/elements/batch includes canvasStatus', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
||||
{ type: 'ellipse', x: 100, y: 100, width: 40, height: 40 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.syncedToCanvas).toBe('boolean');
|
||||
expect(res.body.canvasStatus).toBeDefined();
|
||||
expect(res.body.canvasStatus).toHaveProperty('connectedBrowsers');
|
||||
expect(res.body.canvasStatus).toHaveProperty('ackedBy');
|
||||
expect(res.body.canvasStatus).toHaveProperty('reason');
|
||||
expect(res.body.canvasStatus).toHaveProperty('scope');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, setActiveTenant } 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;
|
||||
let app: any;
|
||||
|
||||
function makeRect(overrides: Partial<ServerElement> = {}): ServerElement {
|
||||
return {
|
||||
id: `rect-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 150,
|
||||
height: 80,
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEllipse(overrides: Partial<ServerElement> = {}): ServerElement {
|
||||
return {
|
||||
id: `ell-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'ellipse',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 120,
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDiamond(overrides: Partial<ServerElement> = {}): ServerElement {
|
||||
return {
|
||||
id: `dia-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
type: 'diamond',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
version: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeArrow(id: string, startId?: string, endId?: string): any {
|
||||
return {
|
||||
id,
|
||||
type: 'arrow',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 0,
|
||||
...(startId ? { start: { id: startId } } : {}),
|
||||
...(endId ? { end: { id: endId } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-arrow-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Arrow Binding Resolution via Batch Create ──────────────
|
||||
|
||||
describe('Arrow binding resolution - rectangles', () => {
|
||||
it('resolves arrow between two rectangles', async () => {
|
||||
const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 });
|
||||
const r2 = makeRect({ id: 'r2', x: 300, y: 0, width: 100, height: 50 });
|
||||
const arrow = makeArrow('a1', 'r1', 'r2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r1, r2, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
const createdArrow = res.body.elements.find((e: any) => e.id === 'a1');
|
||||
expect(createdArrow).toBeDefined();
|
||||
// Arrow should have computed start/end points
|
||||
expect(typeof createdArrow.x).toBe('number');
|
||||
expect(typeof createdArrow.y).toBe('number');
|
||||
expect(typeof createdArrow.width).toBe('number');
|
||||
expect(typeof createdArrow.height).toBe('number');
|
||||
});
|
||||
|
||||
it('arrow points are positioned between the two rectangles', async () => {
|
||||
const r1 = makeRect({ id: 'r1', x: 0, y: 0, width: 100, height: 50 });
|
||||
const r2 = makeRect({ id: 'r2', x: 400, y: 0, width: 100, height: 50 });
|
||||
const arrow = makeArrow('a1', 'r1', 'r2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r1, r2, arrow] });
|
||||
|
||||
const a = res.body.elements.find((e: any) => e.id === 'a1');
|
||||
// Arrow should have reasonable coordinates between the two shapes
|
||||
// The exact positions depend on edge-point computation; just verify it's between the two shape centers
|
||||
expect(a.x).toBeGreaterThanOrEqual(0);
|
||||
expect(a.x + a.width).toBeLessThanOrEqual(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arrow binding resolution - ellipses', () => {
|
||||
it('resolves arrow between two ellipses', async () => {
|
||||
const e1 = makeEllipse({ id: 'e1', x: 0, y: 0, width: 80, height: 80 });
|
||||
const e2 = makeEllipse({ id: 'e2', x: 300, y: 0, width: 80, height: 80 });
|
||||
const arrow = makeArrow('ae1', 'e1', 'e2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [e1, e2, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
const a = res.body.elements.find((e: any) => e.id === 'ae1');
|
||||
expect(a).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arrow binding resolution - diamonds', () => {
|
||||
it('resolves arrow between two diamonds', async () => {
|
||||
const d1 = makeDiamond({ id: 'd1', x: 0, y: 0, width: 100, height: 100 });
|
||||
const d2 = makeDiamond({ id: 'd2', x: 300, y: 0, width: 100, height: 100 });
|
||||
const arrow = makeArrow('ad1', 'd1', 'd2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [d1, d2, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
const a = res.body.elements.find((e: any) => e.id === 'ad1');
|
||||
expect(a).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arrow binding resolution - mixed shapes', () => {
|
||||
it('resolves arrow from rectangle to ellipse', async () => {
|
||||
const r = makeRect({ id: 'mr', x: 0, y: 0, width: 100, height: 50 });
|
||||
const e = makeEllipse({ id: 'me', x: 300, y: 0, width: 80, height: 80 });
|
||||
const arrow = makeArrow('ma1', 'mr', 'me');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r, e, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves arrow from diamond to rectangle', async () => {
|
||||
const d = makeDiamond({ id: 'md', x: 0, y: 0, width: 100, height: 100 });
|
||||
const r = makeRect({ id: 'mr2', x: 300, y: 0, width: 150, height: 80 });
|
||||
const arrow = makeArrow('ma2', 'md', 'mr2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [d, r, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arrow binding resolution - edge cases', () => {
|
||||
it('arrow with only start binding', async () => {
|
||||
const r = makeRect({ id: 'so', x: 0, y: 0, width: 100, height: 50 });
|
||||
const arrow = makeArrow('sa1', 'so', undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('arrow with only end binding', async () => {
|
||||
const r = makeRect({ id: 'eo', x: 300, y: 0, width: 100, height: 50 });
|
||||
const arrow = makeArrow('ea1', undefined, 'eo');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('arrow referencing non-existent element does not crash', async () => {
|
||||
const arrow = makeArrow('ghost-arrow', 'nonexistent-1', 'nonexistent-2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('arrow between overlapping shapes (same center)', async () => {
|
||||
const r1 = makeRect({ id: 'ov1', x: 100, y: 100, width: 100, height: 50 });
|
||||
const r2 = makeRect({ id: 'ov2', x: 100, y: 100, width: 100, height: 50 });
|
||||
const arrow = makeArrow('ova', 'ov1', 'ov2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r1, r2, arrow] });
|
||||
|
||||
// Should not crash even with identical centers (dx=0, dy=0)
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('arrow between vertically aligned shapes', async () => {
|
||||
const r1 = makeRect({ id: 'vr1', x: 100, y: 0, width: 100, height: 50 });
|
||||
const r2 = makeRect({ id: 'vr2', x: 100, y: 300, width: 100, height: 50 });
|
||||
const arrow = makeArrow('va', 'vr1', 'vr2');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r1, r2, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
const a = res.body.elements.find((e: any) => e.id === 'va');
|
||||
// Arrow should connect shapes that are vertically aligned — just verify it exists and has valid dimensions
|
||||
expect(typeof a.width).toBe('number');
|
||||
expect(typeof a.height).toBe('number');
|
||||
});
|
||||
|
||||
it('cross-batch arrow referencing pre-existing element', async () => {
|
||||
// Create a shape first
|
||||
setElement('pre-existing', makeRect({ id: 'pre-existing', x: 0, y: 0, width: 100, height: 50 }));
|
||||
|
||||
// Batch create an arrow referencing the pre-existing shape
|
||||
const r2 = makeRect({ id: 'batch-r', x: 300, y: 0, width: 100, height: 50 });
|
||||
const arrow = makeArrow('cross-arrow', 'pre-existing', 'batch-r');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r2, arrow] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('multiple arrows between same two shapes', async () => {
|
||||
const r1 = makeRect({ id: 'multi-r1', x: 0, y: 0, width: 100, height: 50 });
|
||||
const r2 = makeRect({ id: 'multi-r2', x: 300, y: 0, width: 100, height: 50 });
|
||||
const a1 = makeArrow('multi-a1', 'multi-r1', 'multi-r2');
|
||||
const a2 = makeArrow('multi-a2', 'multi-r2', 'multi-r1');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [r1, r2, a1, a2] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.elements).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
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>;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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 collectMessages(ws: WebSocket, count: number, timeoutMs = 5000): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messages: any[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
ws.off('message', handler);
|
||||
resolve(messages); // return whatever we collected
|
||||
}, timeoutMs);
|
||||
const handler = (data: WebSocket.RawData) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
messages.push(msg);
|
||||
if (messages.length >= count) {
|
||||
clearTimeout(timer);
|
||||
ws.off('message', handler);
|
||||
resolve(messages);
|
||||
}
|
||||
};
|
||||
ws.on('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
port = 3300 + Math.floor(Math.random() * 100);
|
||||
process.env.CANVAS_PORT = String(port);
|
||||
process.env.HOST = 'localhost';
|
||||
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-bugfix-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();
|
||||
});
|
||||
|
||||
// ─── Fix 3: Hello handshake without explicit projectId ──────
|
||||
|
||||
describe('Hello handshake without projectId', () => {
|
||||
it('server resolves projectId when hello only has tenantId', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
|
||||
// Send hello with only tenantId (no projectId)
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
tenantId: 'default',
|
||||
// projectId intentionally omitted
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.type).toBe('hello_ack');
|
||||
expect(msg.tenantId).toBe('default');
|
||||
// Server should have resolved a project ID
|
||||
expect(msg.projectId).toBeDefined();
|
||||
expect(typeof msg.projectId).toBe('string');
|
||||
expect(msg.projectId.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(msg.elements)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('hello_ack includes existing elements for the resolved project', async () => {
|
||||
setElement('hello-noproj-el', {
|
||||
id: 'hello-noproj-el', type: 'rectangle', x: 5, y: 10, width: 80, height: 40, version: 1,
|
||||
} as ServerElement);
|
||||
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
tenantId: 'default',
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.elements.length).toBeGreaterThanOrEqual(1);
|
||||
const found = msg.elements.find((el: any) => el.id === 'hello-noproj-el');
|
||||
expect(found).toBeDefined();
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 3: WS registration after hello ──────────────────────
|
||||
|
||||
describe('WS scoped broadcast after hello', () => {
|
||||
it('client receives broadcasts after hello handshake', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
// Send hello to properly register
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
// Now create an element — the hello-registered client should receive the broadcast
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 6: Serialized broadcasts prevent race conditions ────
|
||||
|
||||
describe('Serialized broadcast ordering', () => {
|
||||
it('parallel element creations arrive in order to WS client', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
// Send hello to register properly
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
// Auto-ACK all messages so the serialized queue advances
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.msgId && msg.type !== 'hello_ack') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Fire 5 parallel element creations
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `serial-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
for (const res of responses) {
|
||||
expect(res.ok).toBe(true);
|
||||
}
|
||||
|
||||
// Verify all 5 elements exist in the DB
|
||||
const listRes = await fetch(`http://localhost:${port}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(5);
|
||||
|
||||
const ids = listBody.elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual([
|
||||
'serial-0',
|
||||
'serial-1',
|
||||
'serial-2',
|
||||
'serial-3',
|
||||
'serial-4',
|
||||
]);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('parallel creates all get ACKed when client is responsive', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
// Send hello
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
// Auto-ACK
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.msgId && msg.type !== 'hello_ack') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Fire 3 parallel creates and check all get syncedToCanvas: true
|
||||
const promises = Array.from({ length: 3 }, (_, i) =>
|
||||
fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `ack-serial-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
}),
|
||||
}).then(r => r.json())
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const result of results) {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.syncedToCanvas).toBe(true);
|
||||
}
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sync_version monotonically increases across parallel creates ─
|
||||
|
||||
describe('sync_version ordering with parallel creates', () => {
|
||||
it('each element_created broadcast has a unique monotonic sync_version', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId: 'default' }));
|
||||
await helloAckPromise;
|
||||
|
||||
const receivedVersions: number[] = [];
|
||||
|
||||
// Auto-ACK and collect sync_versions
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'element_created' && msg.sync_version !== undefined) {
|
||||
receivedVersions.push(msg.sync_version);
|
||||
}
|
||||
if (msg.msgId && msg.type !== 'hello_ack') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Create 3 elements in parallel
|
||||
const promises = Array.from({ length: 3 }, (_, i) =>
|
||||
fetch(`http://localhost:${port}/api/elements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `sv-order-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Wait for all broadcasts to be received
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// All 3 sync_versions should be unique
|
||||
expect(receivedVersions.length).toBe(3);
|
||||
const unique = new Set(receivedVersions);
|
||||
expect(unique.size).toBe(3);
|
||||
|
||||
// Due to serialized broadcast, they should arrive in monotonic order
|
||||
for (let i = 1; i < receivedVersions.length; i++) {
|
||||
expect(receivedVersions[i]).toBeGreaterThan(receivedVersions[i - 1]!);
|
||||
}
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, setActiveTenant, clearElements } 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;
|
||||
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-bugfix-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Fix 1: Batch create returns proper error messages ──────
|
||||
|
||||
describe('Batch create error handling', () => {
|
||||
it('rejects invalid element in batch with descriptive error', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ type: 'invalid-type', x: 0, y: 0 }, // invalid type
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
// Should include actual validation error, not "HTTP server unavailable"
|
||||
expect(res.body.error).toBeDefined();
|
||||
expect(res.body.error).not.toContain('HTTP server unavailable');
|
||||
});
|
||||
|
||||
it('batch create with all valid elements succeeds', 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: 0, width: 80, height: 80 },
|
||||
{ type: 'text', x: 50, y: 50, text: 'Hello' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBe(3);
|
||||
});
|
||||
|
||||
it('batch create preserves all elements in DB', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ id: 'b1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'b2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(2);
|
||||
const ids = listRes.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('b1');
|
||||
expect(ids).toContain('b2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 2: Image export endpoint passes captureViewport ────
|
||||
|
||||
describe('Image export captureViewport parameter', () => {
|
||||
it('accepts captureViewport parameter in export request', async () => {
|
||||
// Without a connected WS client, this will 503.
|
||||
// We just verify the endpoint accepts the parameter without crashing.
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'png', background: true, captureViewport: true });
|
||||
|
||||
// 503 = no frontend connected (expected in tests), but not 400 (bad request)
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toContain('No frontend client connected');
|
||||
});
|
||||
|
||||
it('rejects invalid format even with captureViewport', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'bmp', captureViewport: true });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 4: set_viewport uses animate: false ────────────────
|
||||
// (This is tested in E2E where the browser processes viewport commands.)
|
||||
// For the backend, we verify the viewport endpoint accepts requests.
|
||||
|
||||
describe('Viewport endpoint', () => {
|
||||
it('accepts viewport control request', async () => {
|
||||
// Without a connected WS client this will 503
|
||||
const res = await request(app)
|
||||
.post('/api/viewport')
|
||||
.send({ scrollToContent: true });
|
||||
|
||||
// The viewport endpoint may not exist as a REST endpoint — it's WS-driven.
|
||||
// If it returns 404, that's fine; the point is we don't crash.
|
||||
expect([200, 404, 503].includes(res.status)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Concurrent element creation doesn't lose elements ──────
|
||||
|
||||
describe('Concurrent element creation', () => {
|
||||
it('parallel POST /api/elements all persist correctly', async () => {
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements')
|
||||
.send({
|
||||
id: `concurrent-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 100,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 50,
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const res of results) {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
}
|
||||
|
||||
// All 5 elements should exist in the DB
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(5);
|
||||
|
||||
const ids = listRes.body.elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual([
|
||||
'concurrent-0',
|
||||
'concurrent-1',
|
||||
'concurrent-2',
|
||||
'concurrent-3',
|
||||
'concurrent-4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parallel batch + single creates all persist', async () => {
|
||||
const batchPromise = request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
{ id: 'batch-a', type: 'rectangle', x: 0, y: 0, width: 50, height: 50 },
|
||||
{ id: 'batch-b', type: 'ellipse', x: 100, y: 0, width: 50, height: 50 },
|
||||
],
|
||||
});
|
||||
|
||||
const singlePromise = request(app)
|
||||
.post('/api/elements')
|
||||
.send({ id: 'single-c', type: 'diamond', x: 200, y: 0, width: 60, height: 60 });
|
||||
|
||||
const [batchRes, singleRes] = await Promise.all([batchPromise, singlePromise]);
|
||||
expect(batchRes.status).toBe(200);
|
||||
expect(singleRes.status).toBe(200);
|
||||
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,520 @@
|
||||
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,
|
||||
incrementSyncVersion,
|
||||
getCurrentSyncVersion,
|
||||
getChangesSince,
|
||||
} 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version ───────────────────────────────────────────
|
||||
|
||||
describe('Sync Version', () => {
|
||||
it('getCurrentSyncVersion returns 0 initially', () => {
|
||||
expect(getCurrentSyncVersion()).toBe(0);
|
||||
});
|
||||
|
||||
it('incrementSyncVersion increments and returns new version', () => {
|
||||
expect(incrementSyncVersion()).toBe(1);
|
||||
expect(incrementSyncVersion()).toBe(2);
|
||||
expect(incrementSyncVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it('setElement increments sync_version', () => {
|
||||
setElement('sv1', makeElement({ id: 'sv1' }));
|
||||
expect(getCurrentSyncVersion()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('setElement returns sync_version', () => {
|
||||
const sv = setElement('sv2', makeElement({ id: 'sv2' }));
|
||||
expect(sv).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('deleteElement increments sync_version', () => {
|
||||
setElement('del-sv', makeElement({ id: 'del-sv' }));
|
||||
const versionAfterCreate = getCurrentSyncVersion();
|
||||
deleteElement('del-sv');
|
||||
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreate);
|
||||
});
|
||||
|
||||
it('clearElements increments sync_version', () => {
|
||||
setElement('clr1', makeElement({ id: 'clr1' }));
|
||||
setElement('clr2', makeElement({ id: 'clr2' }));
|
||||
const versionAfterCreates = getCurrentSyncVersion();
|
||||
clearElements();
|
||||
expect(getCurrentSyncVersion()).toBeGreaterThan(versionAfterCreates);
|
||||
});
|
||||
|
||||
it('getChangesSince returns empty for version 0 when no elements', () => {
|
||||
const changes = getChangesSince(0);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it('getChangesSince returns upserts after setElement', () => {
|
||||
setElement('cs1', makeElement({ id: 'cs1' }));
|
||||
setElement('cs2', makeElement({ id: 'cs2' }));
|
||||
|
||||
const changes = getChangesSince(0);
|
||||
expect(changes.length).toBe(2);
|
||||
expect(changes.every(c => c.action === 'upsert')).toBe(true);
|
||||
});
|
||||
|
||||
it('getChangesSince returns delete entries', () => {
|
||||
setElement('csd1', makeElement({ id: 'csd1' }));
|
||||
deleteElement('csd1');
|
||||
|
||||
const changes = getChangesSince(0);
|
||||
const deleteChange = changes.find(c => c.action === 'delete');
|
||||
expect(deleteChange).toBeDefined();
|
||||
});
|
||||
|
||||
it('getChangesSince filters by version', () => {
|
||||
const sv1 = setElement('fv1', makeElement({ id: 'fv1' }));
|
||||
setElement('fv2', makeElement({ id: 'fv2' }));
|
||||
|
||||
const changes = getChangesSince(sv1);
|
||||
expect(changes.length).toBe(1);
|
||||
expect(changes[0]!.id).toBe('fv2');
|
||||
});
|
||||
|
||||
it('sync_version is scoped per project', () => {
|
||||
const proj1 = createProject('SV-P1');
|
||||
const proj2 = createProject('SV-P2');
|
||||
|
||||
setActiveProject(proj1.id);
|
||||
setElement('sp1', makeElement({ id: 'sp1' }));
|
||||
const sv1 = getCurrentSyncVersion(proj1.id);
|
||||
|
||||
setActiveProject(proj2.id);
|
||||
setElement('sp2', makeElement({ id: 'sp2' }));
|
||||
setElement('sp3', makeElement({ id: 'sp3' }));
|
||||
const sv2 = getCurrentSyncVersion(proj2.id);
|
||||
|
||||
// Each project tracks its own sync_version independently
|
||||
expect(sv1).toBeGreaterThan(0);
|
||||
expect(sv2).toBeGreaterThan(0);
|
||||
// P2 had more mutations so its version should be higher than P1's
|
||||
expect(sv2).toBeGreaterThan(sv1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setActiveTenant, getCurrentSyncVersion } 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;
|
||||
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-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Clear Canvas Token Flow (via REST) ─────────────────────
|
||||
// Simulates the clear_canvas MCP tool's token-based confirmation
|
||||
|
||||
describe('Clear canvas confirmation flow', () => {
|
||||
it('DELETE /api/elements/clear removes all elements', async () => {
|
||||
setElement('cl-1', makeElement({ id: 'cl-1' }));
|
||||
setElement('cl-2', makeElement({ id: 'cl-2' }));
|
||||
expect(getAllElements()).toHaveLength(2);
|
||||
|
||||
const res = await request(app).delete('/api/elements/clear');
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.count).toBeDefined();
|
||||
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clear on empty canvas returns zero count', async () => {
|
||||
const res = await request(app).delete('/api/elements/clear');
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('cleared elements stay gone on subsequent GET requests', async () => {
|
||||
setElement('stay-gone', makeElement({ id: 'stay-gone' }));
|
||||
await request(app).delete('/api/elements/clear');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Import Scene (Replace Mode) ────────────────────────────
|
||||
// Tests the REST layer that import_scene MCP tool uses
|
||||
|
||||
describe('Import scene - replace mode via sync', () => {
|
||||
it('POST /api/elements/sync replaces all elements atomically', async () => {
|
||||
setElement('old-1', makeElement({ id: 'old-1' }));
|
||||
setElement('old-2', makeElement({ id: 'old-2' }));
|
||||
|
||||
const newElements = [
|
||||
makeElement({ id: 'new-1', x: 0 }),
|
||||
makeElement({ id: 'new-2', x: 100 }),
|
||||
makeElement({ id: 'new-3', x: 200 }),
|
||||
];
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: newElements });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(3);
|
||||
const ids = elements.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['new-1', 'new-2', 'new-3']);
|
||||
});
|
||||
|
||||
it('POST /api/elements/sync with empty array clears all', async () => {
|
||||
setElement('will-be-replaced', makeElement({ id: 'will-be-replaced' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: [] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('old elements do not reappear after replace', async () => {
|
||||
setElement('ghost', makeElement({ id: 'ghost' }));
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: [makeElement({ id: 'replacement' })] });
|
||||
|
||||
// Multiple GET requests should consistently show only the replacement
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.elements[0].id).toBe('replacement');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Import Scene (Merge Mode) ──────────────────────────────
|
||||
|
||||
describe('Import scene - merge mode via batch', () => {
|
||||
it('POST /api/elements/batch adds without removing existing', async () => {
|
||||
setElement('existing', makeElement({ id: 'existing', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
makeElement({ id: 'imported-1', x: 100 }),
|
||||
makeElement({ id: 'imported-2', x: 200 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(3);
|
||||
const ids = elements.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['existing', 'imported-1', 'imported-2']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Restore Snapshot ───────────────────────────────────────
|
||||
|
||||
describe('Snapshot create and restore flow', () => {
|
||||
it('save snapshot, clear, verify snapshot still exists', async () => {
|
||||
setElement('snap-1', makeElement({ id: 'snap-1' }));
|
||||
setElement('snap-2', makeElement({ id: 'snap-2' }));
|
||||
|
||||
// Save snapshot
|
||||
const snapRes = await request(app)
|
||||
.post('/api/snapshots')
|
||||
.send({ name: 'before-clear' });
|
||||
expect(snapRes.body.success).toBe(true);
|
||||
|
||||
// Clear
|
||||
await request(app).delete('/api/elements/clear');
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
// Snapshot should still contain the elements
|
||||
const getRes = await request(app).get('/api/snapshots/before-clear');
|
||||
expect(getRes.body.success).toBe(true);
|
||||
expect(getRes.body.snapshot.elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('restore via sync endpoint preserves all snapshot elements', async () => {
|
||||
const elements = [
|
||||
makeElement({ id: 'rs-1', x: 0 }),
|
||||
makeElement({ id: 'rs-2', x: 100 }),
|
||||
];
|
||||
for (const el of elements) setElement(el.id, el);
|
||||
|
||||
// Save snapshot
|
||||
await request(app).post('/api/snapshots').send({ name: 'restore-test' });
|
||||
|
||||
// Clear and add different elements
|
||||
await request(app).delete('/api/elements/clear');
|
||||
setElement('different', makeElement({ id: 'different' }));
|
||||
|
||||
// Get snapshot
|
||||
const snapRes = await request(app).get('/api/snapshots/restore-test');
|
||||
const snapshotElements = snapRes.body.snapshot.elements;
|
||||
|
||||
// Restore via sync (atomic replace)
|
||||
const syncRes = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: snapshotElements });
|
||||
expect(syncRes.body.success).toBe(true);
|
||||
|
||||
// Verify restored state
|
||||
const final = getAllElements();
|
||||
expect(final).toHaveLength(2);
|
||||
const ids = final.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['rs-1', 'rs-2']);
|
||||
});
|
||||
|
||||
it('restore non-existent snapshot returns 404', async () => {
|
||||
const res = await request(app).get('/api/snapshots/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('snapshot overwrites with same name', async () => {
|
||||
setElement('v1-el', makeElement({ id: 'v1-el' }));
|
||||
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
|
||||
|
||||
setElement('v2-el', makeElement({ id: 'v2-el' }));
|
||||
await request(app).post('/api/snapshots').send({ name: 'overwrite-test' });
|
||||
|
||||
const res = await request(app).get('/api/snapshots/overwrite-test');
|
||||
expect(res.body.snapshot.elements).toHaveLength(2); // Both elements
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Duplicate Elements ─────────────────────────────────────
|
||||
|
||||
describe('Duplicate elements via API', () => {
|
||||
it('duplicating elements creates new IDs', async () => {
|
||||
setElement('dup-src', makeElement({ id: 'dup-src', x: 0, y: 0 }));
|
||||
|
||||
// Get the original
|
||||
const getRes = await request(app).get('/api/elements/dup-src');
|
||||
expect(getRes.body.success).toBe(true);
|
||||
|
||||
// Create a duplicate via batch (simulating what duplicate_elements does)
|
||||
const original = getRes.body.element;
|
||||
const duplicate = {
|
||||
...original,
|
||||
id: 'dup-copy',
|
||||
x: original.x + 20,
|
||||
y: original.y + 20,
|
||||
};
|
||||
|
||||
const batchRes = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: [duplicate] });
|
||||
|
||||
expect(batchRes.body.success).toBe(true);
|
||||
expect(getAllElements()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('duplicated arrow with remapped bindings points to duplicated shapes', async () => {
|
||||
// Create shape + arrow
|
||||
const rect = makeElement({ id: 'dup-rect', x: 0, y: 0, width: 100, height: 50 });
|
||||
const rect2 = makeElement({ id: 'dup-rect2', x: 300, y: 0, width: 100, height: 50 });
|
||||
setElement('dup-rect', rect);
|
||||
setElement('dup-rect2', rect2);
|
||||
|
||||
// Create arrow binding references
|
||||
const arrow = {
|
||||
id: 'dup-arrow',
|
||||
type: 'arrow',
|
||||
x: 100, y: 25,
|
||||
width: 200, height: 0,
|
||||
start: { id: 'dup-rect' },
|
||||
end: { id: 'dup-rect2' },
|
||||
};
|
||||
|
||||
// Simulate duplication with ID remapping
|
||||
const idMap = new Map([
|
||||
['dup-rect', 'copy-rect'],
|
||||
['dup-rect2', 'copy-rect2'],
|
||||
['dup-arrow', 'copy-arrow'],
|
||||
]);
|
||||
|
||||
const dupArrow: any = {
|
||||
...arrow,
|
||||
id: 'copy-arrow',
|
||||
x: arrow.x + 20,
|
||||
y: arrow.y + 20,
|
||||
start: { id: idMap.get(arrow.start.id) || arrow.start.id },
|
||||
end: { id: idMap.get(arrow.end.id) || arrow.end.id },
|
||||
};
|
||||
|
||||
expect(dupArrow.start.id).toBe('copy-rect');
|
||||
expect(dupArrow.end.id).toBe('copy-rect2');
|
||||
|
||||
// Create the duplicated shapes and arrow
|
||||
const batchRes = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({
|
||||
elements: [
|
||||
makeElement({ id: 'copy-rect', x: 20, y: 20, width: 100, height: 50 }),
|
||||
makeElement({ id: 'copy-rect2', x: 320, y: 20, width: 100, height: 50 }),
|
||||
dupArrow,
|
||||
],
|
||||
});
|
||||
|
||||
expect(batchRes.body.success).toBe(true);
|
||||
const createdArrow = batchRes.body.elements.find((e: any) => e.id === 'copy-arrow');
|
||||
expect(createdArrow).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Mermaid Conversion Relay ───────────────────────────────
|
||||
|
||||
describe('Mermaid conversion relay', () => {
|
||||
it('POST /api/elements/from-mermaid accepts valid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({
|
||||
mermaidDiagram: 'graph TD\n A-->B',
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.mermaidDiagram).toBe('graph TD\n A-->B');
|
||||
expect(res.body.message).toContain('frontend');
|
||||
});
|
||||
|
||||
it('rejects empty mermaid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: '' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing mermaid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('accepts diagram with config options', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({
|
||||
mermaidDiagram: 'sequenceDiagram\n A->>B: Hello',
|
||||
config: { theme: 'dark' },
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.config).toEqual({ theme: 'dark' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Image Export Relay ─────────────────────────────────────
|
||||
|
||||
describe('Image export relay', () => {
|
||||
it('POST /api/export/image without connected browser returns 503', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'png', background: true });
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/export/image accepts captureViewport parameter', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/export/image')
|
||||
.send({ format: 'png', background: true, captureViewport: true });
|
||||
|
||||
// Will be 503 since no browser, but should not 400 on the parameter
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Viewport Relay ─────────────────────────────────────────
|
||||
|
||||
describe('Viewport relay', () => {
|
||||
it('POST /api/viewport without connected browser returns 503', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/viewport')
|
||||
.send({ action: 'scrollToContent' });
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('accepts various viewport actions', async () => {
|
||||
for (const action of ['scrollToContent', 'zoomToFit']) {
|
||||
const res = await request(app)
|
||||
.post('/api/viewport')
|
||||
.send({ action });
|
||||
|
||||
// 503 expected (no browser), but validates the action is accepted
|
||||
expect(res.status).toBe(503);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Files API ──────────────────────────────────────────────
|
||||
|
||||
describe('Files API comprehensive', () => {
|
||||
it('GET /api/files returns empty initially', async () => {
|
||||
const res = await request(app).get('/api/files');
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(Object.keys(res.body.files)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('POST /api/files adds files and GET returns them', async () => {
|
||||
await request(app)
|
||||
.post('/api/files')
|
||||
.send({
|
||||
files: {
|
||||
'f1': { id: 'f1', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
|
||||
'f2': { id: 'f2', mimeType: 'image/jpeg', dataURL: 'data:image/jpeg;base64,xyz', created: Date.now() },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/files');
|
||||
expect(Object.keys(res.body.files)).toHaveLength(2);
|
||||
expect(res.body.files['f1'].mimeType).toBe('image/png');
|
||||
expect(res.body.files['f2'].mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('DELETE /api/files/:id removes the file', async () => {
|
||||
await request(app)
|
||||
.post('/api/files')
|
||||
.send({
|
||||
files: {
|
||||
'del-f': { id: 'del-f', mimeType: 'image/png', dataURL: 'data:image/png;base64,abc', created: Date.now() },
|
||||
},
|
||||
});
|
||||
|
||||
const delRes = await request(app).delete('/api/files/del-f');
|
||||
expect(delRes.body.success).toBe(true);
|
||||
|
||||
const listRes = await request(app).get('/api/files');
|
||||
expect(listRes.body.files['del-f']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('DELETE /api/files/:id for non-existent file returns 404', async () => {
|
||||
const res = await request(app).delete('/api/files/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/files rejects non-object body', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/files')
|
||||
.send({ files: 'not-an-object' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Status ────────────────────────────────────────────
|
||||
|
||||
describe('Sync status endpoint', () => {
|
||||
it('GET /api/sync/status returns element count', async () => {
|
||||
setElement('ss-1', makeElement({ id: 'ss-1' }));
|
||||
setElement('ss-2', makeElement({ id: 'ss-2' }));
|
||||
|
||||
const res = await request(app).get('/api/sync/status');
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.elementCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Element Version History ────────────────────────────────
|
||||
|
||||
describe('Element version history via API', () => {
|
||||
it('element has version after creation and update', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
expect(createRes.body.success).toBe(true);
|
||||
|
||||
const updateRes = await request(app)
|
||||
.put('/api/elements/hist-el')
|
||||
.send({ x: 500 });
|
||||
expect(updateRes.body.success).toBe(true);
|
||||
|
||||
const getRes = await request(app).get('/api/elements/hist-el');
|
||||
expect(getRes.body.element.x).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Handling ─────────────────────────────────────────
|
||||
|
||||
describe('API error handling', () => {
|
||||
it('POST /api/elements with invalid JSON returns 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('not-json');
|
||||
|
||||
// Express body-parser returns 400 or 500 on parse failure depending on version
|
||||
expect([400, 500]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('PUT /api/elements/:id on non-existent element returns 404', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/elements/nonexistent')
|
||||
.send({ x: 100 });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE /api/elements/:id on non-existent returns 404', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/api/elements/nonexistent');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/elements/batch rejects non-array elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: 'not-an-array' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/snapshots rejects missing name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/snapshots')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setActiveTenant } from '../../src/db.js';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
let dbPath: string;
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-security-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Input Validation ───────────────────────────────────────
|
||||
|
||||
describe('Input validation - element creation', () => {
|
||||
it('rejects element with missing type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects element with invalid type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'malicious<script>', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects element with negative dimensions gracefully', async () => {
|
||||
// Server should handle negative dimensions without crashing
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: -100, height: -50 });
|
||||
|
||||
// May succeed (Excalidraw allows negative) or fail validation — either is acceptable
|
||||
expect([200, 400]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('handles very large coordinates without crashing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 1e15, y: 1e15, width: 100, height: 50 });
|
||||
|
||||
// Should not crash the server
|
||||
expect([200, 400]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - batch operations', () => {
|
||||
it('rejects batch with non-array elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: { not: 'an array' } });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects batch with null elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements: null });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('handles extremely large batch without crash', async () => {
|
||||
const elements = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `bulk-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 10,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/batch')
|
||||
.send({ elements });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - sync endpoints', () => {
|
||||
it('POST /api/elements/sync rejects non-array elements', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: 'not-array' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/elements/sync/v2 rejects non-number lastSyncVersion', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 'not-a-number', changes: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/elements/sync/v2 handles missing changes gracefully', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: 0 });
|
||||
|
||||
// Should use default empty array
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - settings', () => {
|
||||
it('PUT /api/settings/:key rejects missing value', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/settings/test')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/settings/:key returns null for missing key', async () => {
|
||||
const res = await request(app).get('/api/settings/nonexistent');
|
||||
expect(res.body.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - tenant operations', () => {
|
||||
it('PUT /api/tenant/active rejects missing tenantId', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PUT /api/tenant/active rejects non-existent tenant', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'nonexistent-tenant-xyz' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - search', () => {
|
||||
it('GET /api/elements/search with no params returns all elements', async () => {
|
||||
const res = await request(app).get('/api/elements/search');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/elements/search handles special characters in query', async () => {
|
||||
const res = await request(app).get('/api/elements/search?q=%22OR%201%3D1');
|
||||
// FTS5 may reject special chars with 500 — acceptable as long as server doesn't crash
|
||||
expect([200, 400, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input validation - mermaid', () => {
|
||||
it('rejects non-string mermaid diagram', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/from-mermaid')
|
||||
.send({ mermaidDiagram: 12345 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Header Handling ────────────────────────────────────────
|
||||
|
||||
describe('X-Tenant-Id header handling', () => {
|
||||
it('invalid X-Tenant-Id gracefully falls back', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'nonexistent-tenant');
|
||||
|
||||
// Should either return empty elements or error — 500 is acceptable for unknown tenant
|
||||
expect([200, 400, 404, 500]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Content-Type Handling ──────────────────────────────────
|
||||
|
||||
describe('Content-Type edge cases', () => {
|
||||
it('POST with no content-type header handles gracefully', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements')
|
||||
.send('');
|
||||
|
||||
// Should not crash
|
||||
expect([200, 400]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { initDb, closeDb, setElement, getAllElements, deleteElement, clearElements, getCurrentSyncVersion, getChangesSince, setActiveTenant } 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;
|
||||
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-sync-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
initDb(dbPath);
|
||||
setActiveTenant('default');
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
try { fs.unlinkSync(dbPath + suffix); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Deletion Flows ──────────────────────────
|
||||
|
||||
describe('Delta sync v2 - deletion flows', () => {
|
||||
it('deletes elements when client sends action:delete', async () => {
|
||||
setElement('a', makeElement({ id: 'a' }));
|
||||
setElement('b', makeElement({ id: 'b' }));
|
||||
setElement('c', makeElement({ id: 'c' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'a', action: 'delete' },
|
||||
{ id: 'b', action: 'delete' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.appliedCount).toBe(2);
|
||||
|
||||
const remaining = getAllElements();
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).toBe('c');
|
||||
});
|
||||
|
||||
it('deletes all elements when client sends delete for every element', async () => {
|
||||
setElement('x', makeElement({ id: 'x' }));
|
||||
setElement('y', makeElement({ id: 'y' }));
|
||||
setElement('z', makeElement({ id: 'z' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'x', action: 'delete' },
|
||||
{ id: 'y', action: 'delete' },
|
||||
{ id: 'z', action: 'delete' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.appliedCount).toBe(3);
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('delete for non-existent element does not crash', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'ghost', action: 'delete' }],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('deleted elements do not reappear on subsequent GET /api/elements', async () => {
|
||||
setElement('persist-1', makeElement({ id: 'persist-1' }));
|
||||
setElement('persist-2', makeElement({ id: 'persist-2' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: 'persist-1', action: 'delete' }],
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.elements[0].id).toBe('persist-2');
|
||||
});
|
||||
|
||||
it('deleted elements do not reappear after multiple reload cycles', async () => {
|
||||
setElement('reload-1', makeElement({ id: 'reload-1' }));
|
||||
setElement('reload-2', makeElement({ id: 'reload-2' }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Simulate: frontend syncs deletions
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'reload-1', action: 'delete' },
|
||||
{ id: 'reload-2', action: 'delete' },
|
||||
],
|
||||
});
|
||||
|
||||
// Simulate: multiple page reloads fetching elements
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(0);
|
||||
expect(res.body.elements).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Mixed Operations ────────────────────────
|
||||
|
||||
describe('Delta sync v2 - mixed operations', () => {
|
||||
it('handles mixed upserts and deletes in single sync', async () => {
|
||||
setElement('a', makeElement({ id: 'a', x: 0 }));
|
||||
setElement('b', makeElement({ id: 'b', x: 100 }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'a', action: 'delete' },
|
||||
{ id: 'c', action: 'upsert', element: makeElement({ id: 'c', x: 200 }) },
|
||||
{ id: 'b', action: 'upsert', element: makeElement({ id: 'b', x: 150 }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.appliedCount).toBe(3);
|
||||
|
||||
const remaining = getAllElements();
|
||||
expect(remaining).toHaveLength(2);
|
||||
const ids = remaining.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['b', 'c']);
|
||||
|
||||
const b = remaining.find(e => e.id === 'b')!;
|
||||
expect(b.x).toBe(150);
|
||||
});
|
||||
|
||||
it('upsert after delete re-creates the element', async () => {
|
||||
setElement('revive', makeElement({ id: 'revive', x: 0 }));
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Delete it
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: 'revive', action: 'delete' }],
|
||||
});
|
||||
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
// Re-create it
|
||||
const v1 = getCurrentSyncVersion();
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v1,
|
||||
changes: [{ id: 'revive', action: 'upsert', element: makeElement({ id: 'revive', x: 999 }) }],
|
||||
});
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].id).toBe('revive');
|
||||
expect(elements[0].x).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Bidirectional ───────────────────────────
|
||||
|
||||
describe('Delta sync v2 - bidirectional sync', () => {
|
||||
it('returns server-side changes not sent by client', async () => {
|
||||
// Server has elements from MCP
|
||||
setElement('mcp-1', makeElement({ id: 'mcp-1' }));
|
||||
setElement('mcp-2', makeElement({ id: 'mcp-2' }));
|
||||
|
||||
// Client syncs from version 0 with its own new element
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'fe-1', action: 'upsert', element: makeElement({ id: 'fe-1' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
// Server should return mcp-1 and mcp-2 as changes the client hasn't seen
|
||||
const serverChangeIds = res.body.serverChanges.map((c: any) => c.id).sort();
|
||||
expect(serverChangeIds).toEqual(['mcp-1', 'mcp-2']);
|
||||
// fe-1 should NOT be in serverChanges (client already knows about it)
|
||||
expect(serverChangeIds).not.toContain('fe-1');
|
||||
});
|
||||
|
||||
it('server-side deletes appear as delete actions in serverChanges', async () => {
|
||||
setElement('srv-del', makeElement({ id: 'srv-del' }));
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
// Server-side delete (simulating MCP delete_element)
|
||||
deleteElement('srv-del');
|
||||
const v1 = getCurrentSyncVersion();
|
||||
|
||||
// Client syncs from before the delete
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: v0, changes: [] });
|
||||
|
||||
const deleteChange = res.body.serverChanges.find((c: any) => c.id === 'srv-del');
|
||||
expect(deleteChange).toBeDefined();
|
||||
expect(deleteChange.action).toBe('delete');
|
||||
});
|
||||
|
||||
it('excludes client-sent IDs from serverChanges', async () => {
|
||||
setElement('shared', makeElement({ id: 'shared', x: 0 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'shared', action: 'upsert', element: makeElement({ id: 'shared', x: 50 }) },
|
||||
],
|
||||
});
|
||||
|
||||
// 'shared' should NOT appear in serverChanges since the client sent it
|
||||
const serverIds = res.body.serverChanges.map((c: any) => c.id);
|
||||
expect(serverIds).not.toContain('shared');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2: Multiple Rounds ─────────────────────────
|
||||
|
||||
describe('Delta sync v2 - multiple rounds', () => {
|
||||
it('tracks sync version across multiple sync rounds', async () => {
|
||||
// Round 1: create elements
|
||||
const r1 = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a' }) },
|
||||
{ id: 'r1-b', action: 'upsert', element: makeElement({ id: 'r1-b' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(r1.body.currentSyncVersion).toBeGreaterThan(0);
|
||||
const v1 = r1.body.currentSyncVersion;
|
||||
|
||||
// Round 2: update one, delete one, create one
|
||||
const r2 = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v1,
|
||||
changes: [
|
||||
{ id: 'r1-a', action: 'upsert', element: makeElement({ id: 'r1-a', x: 999 }) },
|
||||
{ id: 'r1-b', action: 'delete' },
|
||||
{ id: 'r2-c', action: 'upsert', element: makeElement({ id: 'r2-c' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(r2.body.currentSyncVersion).toBeGreaterThan(v1);
|
||||
expect(r2.body.appliedCount).toBe(3);
|
||||
// No new server-side changes should be returned
|
||||
expect(r2.body.serverChanges).toHaveLength(0);
|
||||
|
||||
// Verify final state
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(2);
|
||||
const ids = elements.map(e => e.id).sort();
|
||||
expect(ids).toEqual(['r1-a', 'r2-c']);
|
||||
expect(elements.find(e => e.id === 'r1-a')!.x).toBe(999);
|
||||
});
|
||||
|
||||
it('empty sync returns current version without changes', async () => {
|
||||
setElement('existing', makeElement({ id: 'existing' }));
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({ lastSyncVersion: v0, changes: [] });
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.appliedCount).toBe(0);
|
||||
expect(res.body.serverChanges).toHaveLength(0);
|
||||
expect(res.body.currentSyncVersion).toBe(v0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version Monotonicity ──────────────────────────────
|
||||
|
||||
describe('Sync version monotonicity', () => {
|
||||
it('sync version always increases after mutations', async () => {
|
||||
const versions: number[] = [];
|
||||
|
||||
// Create
|
||||
setElement('mono-a', makeElement({ id: 'mono-a' }));
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Update via sync
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'mono-a', action: 'upsert', element: makeElement({ id: 'mono-a', x: 50 }) }],
|
||||
});
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Delete via sync
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: versions[versions.length - 1],
|
||||
changes: [{ id: 'mono-a', action: 'delete' }],
|
||||
});
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Create via API
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.send({ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
versions.push(getCurrentSyncVersion());
|
||||
|
||||
// Every version should be strictly greater than the previous
|
||||
for (let i = 1; i < versions.length; i++) {
|
||||
expect(versions[i]).toBeGreaterThan(versions[i - 1]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('getChangesSince correctly filters by version', async () => {
|
||||
setElement('cs-a', makeElement({ id: 'cs-a' }));
|
||||
const v1 = getCurrentSyncVersion();
|
||||
|
||||
setElement('cs-b', makeElement({ id: 'cs-b' }));
|
||||
const v2 = getCurrentSyncVersion();
|
||||
|
||||
setElement('cs-c', makeElement({ id: 'cs-c' }));
|
||||
const v3 = getCurrentSyncVersion();
|
||||
|
||||
// Changes since v1 should include cs-b and cs-c but not cs-a
|
||||
const changes = getChangesSince(v1);
|
||||
const ids = changes.map(c => c.id).sort();
|
||||
expect(ids).toEqual(['cs-b', 'cs-c']);
|
||||
|
||||
// Changes since v2 should only include cs-c
|
||||
const changes2 = getChangesSince(v2);
|
||||
expect(changes2).toHaveLength(1);
|
||||
expect(changes2[0].id).toBe('cs-c');
|
||||
|
||||
// Changes since v3 should be empty
|
||||
expect(getChangesSince(v3)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Concurrent Sync Requests ───────────────────────────────
|
||||
|
||||
describe('Concurrent sync requests', () => {
|
||||
it('parallel sync requests all complete without data loss', async () => {
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: `par-${i}`, action: 'upsert', element: makeElement({ id: `par-${i}`, x: i * 100 }) },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const r of results) {
|
||||
expect(r.body.success).toBe(true);
|
||||
expect(r.body.appliedCount).toBe(1);
|
||||
}
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('parallel deletes all take effect', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setElement(`pd-${i}`, makeElement({ id: `pd-${i}` }));
|
||||
}
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [{ id: `pd-${i}`, action: 'delete' }],
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync After Clear ───────────────────────────────────────
|
||||
|
||||
describe('Sync after clear', () => {
|
||||
it('elements created after clear persist correctly', async () => {
|
||||
setElement('pre-clear', makeElement({ id: 'pre-clear' }));
|
||||
|
||||
await request(app).delete('/api/elements/clear');
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
const v0 = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: v0,
|
||||
changes: [
|
||||
{ id: 'post-clear', action: 'upsert', element: makeElement({ id: 'post-clear' }) },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.body.appliedCount).toBe(1);
|
||||
expect(getAllElements()).toHaveLength(1);
|
||||
expect(getAllElements()[0].id).toBe('post-clear');
|
||||
});
|
||||
|
||||
it('sync from version 0 after clear returns clear as delete changes', async () => {
|
||||
setElement('was-here', makeElement({ id: 'was-here' }));
|
||||
clearElements();
|
||||
|
||||
// Sync from 0 should see the element as a delete
|
||||
const changes = getChangesSince(0);
|
||||
const deleteChange = changes.find(c => c.id === 'was-here');
|
||||
expect(deleteChange).toBeDefined();
|
||||
expect(deleteChange!.action).toBe('delete');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Overwrite Sync (Legacy) ────────────────────────────────
|
||||
|
||||
describe('POST /api/elements/sync (legacy overwrite)', () => {
|
||||
it('replaces all elements and deleted ones stay gone on GET', async () => {
|
||||
setElement('old-1', makeElement({ id: 'old-1' }));
|
||||
setElement('old-2', makeElement({ id: 'old-2' }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({
|
||||
elements: [makeElement({ id: 'new-1' })],
|
||||
});
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const elements = getAllElements();
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].id).toBe('new-1');
|
||||
|
||||
// Old elements should not be returned
|
||||
const listRes = await request(app).get('/api/elements');
|
||||
expect(listRes.body.count).toBe(1);
|
||||
expect(listRes.body.elements[0].id).toBe('new-1');
|
||||
});
|
||||
|
||||
it('overwrite with empty array clears all elements', async () => {
|
||||
setElement('gone', makeElement({ id: 'gone' }));
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync')
|
||||
.send({ elements: [] });
|
||||
|
||||
expect(getAllElements()).toHaveLength(0);
|
||||
|
||||
const res = await request(app).get('/api/elements');
|
||||
expect(res.body.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GET /api/sync/version consistency ──────────────────────
|
||||
|
||||
describe('GET /api/sync/version', () => {
|
||||
it('matches internal getCurrentSyncVersion', async () => {
|
||||
setElement('sv-check', makeElement({ id: 'sv-check' }));
|
||||
const internal = getCurrentSyncVersion();
|
||||
|
||||
const res = await request(app).get('/api/sync/version');
|
||||
expect(res.body.syncVersion).toBe(internal);
|
||||
});
|
||||
|
||||
it('increases after sync/v2 applies changes', async () => {
|
||||
const r1 = await request(app).get('/api/sync/version');
|
||||
const v1 = r1.body.syncVersion;
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.send({
|
||||
lastSyncVersion: 0,
|
||||
changes: [{ id: 'bump', action: 'upsert', element: makeElement({ id: 'bump' }) }],
|
||||
});
|
||||
|
||||
const r2 = await request(app).get('/api/sync/version');
|
||||
expect(r2.body.syncVersion).toBeGreaterThan(v1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { initDb, closeDb, setElement, getAllElements, setActiveTenant, ensureTenant, setActiveProject, getActiveProjectId, getCurrentSyncVersion, getChangesSince, clearElements } from '../../src/db.js';
|
||||
import type { ServerElement } from '../../src/types.js';
|
||||
import WebSocket from 'ws';
|
||||
import request from 'supertest';
|
||||
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>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Connect and wait until initial messages are drained. */
|
||||
async function connectAndDrain(): Promise<WebSocket> {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
return ws;
|
||||
}
|
||||
|
||||
/** Send hello and wait for hello_ack. */
|
||||
async function sendHelloAndWait(ws: WebSocket, tenantId: string): Promise<any> {
|
||||
const ackPromise = waitForMessageOfType(ws, 'hello_ack', 8000);
|
||||
ws.send(JSON.stringify({ type: 'hello', tenantId }));
|
||||
return ackPromise;
|
||||
}
|
||||
|
||||
function collectMessagesFor(ws: WebSocket, durationMs: number): Promise<any[]> {
|
||||
return new Promise((resolve) => {
|
||||
const msgs: any[] = [];
|
||||
const handler = (data: WebSocket.RawData) => msgs.push(JSON.parse(data.toString()));
|
||||
ws.on('message', handler);
|
||||
setTimeout(() => {
|
||||
ws.off('message', handler);
|
||||
resolve(msgs);
|
||||
}, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
port = 3300 + Math.floor(Math.random() * 100);
|
||||
process.env.CANVAS_PORT = String(port);
|
||||
process.env.HOST = 'localhost';
|
||||
|
||||
dbPath = path.join(os.tmpdir(), `excalidraw-isolation-test-${Date.now()}.db`);
|
||||
initDb(dbPath);
|
||||
|
||||
const mod = await import('../../src/server.js');
|
||||
app = mod.default;
|
||||
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(() => {
|
||||
setActiveTenant('default');
|
||||
});
|
||||
|
||||
// ─── Element Isolation per Tenant ───────────────────────────
|
||||
|
||||
describe('Element isolation per tenant', () => {
|
||||
it('elements in tenant A are not visible to tenant B', async () => {
|
||||
ensureTenant('tenant-a', 'Tenant A', '/path/a');
|
||||
ensureTenant('tenant-b', 'Tenant B', '/path/b');
|
||||
|
||||
// Create element in tenant A
|
||||
setActiveTenant('tenant-a');
|
||||
const projA = getActiveProjectId();
|
||||
setElement('el-a', makeElement({ id: 'el-a' }), projA);
|
||||
|
||||
// Create element in tenant B
|
||||
setActiveTenant('tenant-b');
|
||||
const projB = getActiveProjectId();
|
||||
setElement('el-b', makeElement({ id: 'el-b' }), projB);
|
||||
|
||||
// Verify isolation via API
|
||||
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].id).toBe('el-a');
|
||||
|
||||
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].id).toBe('el-b');
|
||||
});
|
||||
|
||||
it('deleting elements in tenant A does not affect tenant B', async () => {
|
||||
ensureTenant('del-a', 'Del A', '/path/del-a');
|
||||
ensureTenant('del-b', 'Del B', '/path/del-b');
|
||||
|
||||
setActiveTenant('del-a');
|
||||
const projA = getActiveProjectId();
|
||||
setElement('del-el-a', makeElement({ id: 'del-el-a' }), projA);
|
||||
|
||||
setActiveTenant('del-b');
|
||||
const projB = getActiveProjectId();
|
||||
setElement('del-el-b', makeElement({ id: 'del-el-b' }), projB);
|
||||
|
||||
// Delete from tenant A via API
|
||||
await request(app)
|
||||
.delete('/api/elements/del-el-a')
|
||||
.set('X-Tenant-Id', 'del-a');
|
||||
|
||||
// Tenant A should be empty
|
||||
const resA = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'del-a');
|
||||
expect(resA.body.count).toBe(0);
|
||||
|
||||
// Tenant B should still have its element
|
||||
const resB = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'del-b');
|
||||
expect(resB.body.count).toBe(1);
|
||||
expect(resB.body.elements[0].id).toBe('del-el-b');
|
||||
});
|
||||
|
||||
it('clear in tenant A does not affect tenant B', async () => {
|
||||
ensureTenant('clr-a', 'Clr A', '/path/clr-a');
|
||||
ensureTenant('clr-b', 'Clr B', '/path/clr-b');
|
||||
|
||||
setActiveTenant('clr-a');
|
||||
setElement('clr-el-a', makeElement({ id: 'clr-el-a' }), getActiveProjectId());
|
||||
|
||||
setActiveTenant('clr-b');
|
||||
setElement('clr-el-b', makeElement({ id: 'clr-el-b' }), getActiveProjectId());
|
||||
|
||||
// Clear tenant A
|
||||
await request(app)
|
||||
.delete('/api/elements/clear')
|
||||
.set('X-Tenant-Id', 'clr-a');
|
||||
|
||||
const resA = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'clr-a');
|
||||
expect(resA.body.count).toBe(0);
|
||||
|
||||
const resB = await request(app)
|
||||
.get('/api/elements')
|
||||
.set('X-Tenant-Id', 'clr-b');
|
||||
expect(resB.body.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version Isolation per Tenant ──────────────────────
|
||||
|
||||
describe('Sync version isolation', () => {
|
||||
it('sync versions are independent per tenant/project', async () => {
|
||||
ensureTenant('sv-a', 'SV A', '/path/sv-a');
|
||||
ensureTenant('sv-b', 'SV B', '/path/sv-b');
|
||||
|
||||
// Create in tenant A
|
||||
setActiveTenant('sv-a');
|
||||
const projA = getActiveProjectId();
|
||||
setElement('sv-el-a', makeElement({ id: 'sv-el-a' }), projA);
|
||||
const vA = getCurrentSyncVersion(projA);
|
||||
|
||||
// Create in tenant B
|
||||
setActiveTenant('sv-b');
|
||||
const projB = getActiveProjectId();
|
||||
setElement('sv-el-b', makeElement({ id: 'sv-el-b' }), projB);
|
||||
const vB = getCurrentSyncVersion(projB);
|
||||
|
||||
// Both should have version 1 (independent counters)
|
||||
expect(vA).toBe(1);
|
||||
expect(vB).toBe(1);
|
||||
});
|
||||
|
||||
it('delta sync v2 is scoped to the requesting tenant', async () => {
|
||||
ensureTenant('ds-a', 'DS A', '/path/ds-a');
|
||||
ensureTenant('ds-b', 'DS B', '/path/ds-b');
|
||||
|
||||
// Create in tenant A via API
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ds-a')
|
||||
.send({ id: 'ds-el-a', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
// Create in tenant B via API
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ds-b')
|
||||
.send({ id: 'ds-el-b', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
// Sync for tenant A from version 0
|
||||
const resA = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.set('X-Tenant-Id', 'ds-a')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
const idsA = resA.body.serverChanges.map((c: any) => c.id);
|
||||
expect(idsA).toContain('ds-el-a');
|
||||
expect(idsA).not.toContain('ds-el-b');
|
||||
|
||||
// Sync for tenant B from version 0
|
||||
const resB = await request(app)
|
||||
.post('/api/elements/sync/v2')
|
||||
.set('X-Tenant-Id', 'ds-b')
|
||||
.send({ lastSyncVersion: 0, changes: [] });
|
||||
|
||||
const idsB = resB.body.serverChanges.map((c: any) => c.id);
|
||||
expect(idsB).toContain('ds-el-b');
|
||||
expect(idsB).not.toContain('ds-el-a');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WebSocket Tenant Isolation ─────────────────────────────
|
||||
|
||||
describe('WebSocket tenant-scoped broadcasts', () => {
|
||||
it('broadcast for tenant A does NOT reach client registered to tenant B', async () => {
|
||||
ensureTenant('ws-a', 'WS A', '/path/ws-a');
|
||||
ensureTenant('ws-b', 'WS B', '/path/ws-b');
|
||||
|
||||
const wsA = await connectAndDrain();
|
||||
const wsB = await connectAndDrain();
|
||||
|
||||
await sendHelloAndWait(wsA, 'ws-a');
|
||||
await sendHelloAndWait(wsB, 'ws-b');
|
||||
|
||||
// Start collecting messages on client B
|
||||
const bMessages = collectMessagesFor(wsB, 2000);
|
||||
|
||||
// Create element in tenant A scope
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ws-a')
|
||||
.send({ id: 'ws-only-a', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
const received = await bMessages;
|
||||
|
||||
// Client B should NOT receive the element_created for tenant A
|
||||
const created = received.filter(m => m.type === 'element_created' && m.element?.id === 'ws-only-a');
|
||||
expect(created).toHaveLength(0);
|
||||
|
||||
wsA.close();
|
||||
wsB.close();
|
||||
});
|
||||
|
||||
it('broadcast for tenant A reaches all clients registered to tenant A', async () => {
|
||||
ensureTenant('ws-multi', 'WS Multi', '/path/ws-multi');
|
||||
|
||||
const ws1 = await connectAndDrain();
|
||||
const ws2 = await connectAndDrain();
|
||||
|
||||
await sendHelloAndWait(ws1, 'ws-multi');
|
||||
await sendHelloAndWait(ws2, 'ws-multi');
|
||||
|
||||
const p1 = waitForMessageOfType(ws1, 'element_created');
|
||||
const p2 = waitForMessageOfType(ws2, 'element_created');
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ws-multi')
|
||||
.send({ id: 'ws-shared', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
const [m1, m2] = await Promise.all([p1, p2]);
|
||||
expect(m1.element.id).toBe('ws-shared');
|
||||
expect(m2.element.id).toBe('ws-shared');
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hello Handshake Isolation ──────────────────────────────
|
||||
|
||||
describe('Hello handshake returns scoped elements', () => {
|
||||
it('hello with tenantId returns only that tenant elements', async () => {
|
||||
ensureTenant('hello-a', 'Hello A', '/path/hello-a');
|
||||
ensureTenant('hello-b', 'Hello B', '/path/hello-b');
|
||||
|
||||
// Populate both tenants
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'hello-a')
|
||||
.send({ id: 'ha-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'hello-b')
|
||||
.send({ id: 'hb-el', type: 'ellipse', x: 0, y: 0, width: 80, height: 80 });
|
||||
|
||||
const ws = await connectAndDrain();
|
||||
const ack = await sendHelloAndWait(ws, 'hello-a');
|
||||
|
||||
expect(ack.tenantId).toBe('hello-a');
|
||||
expect(ack.elements).toBeDefined();
|
||||
|
||||
const elementIds = ack.elements.map((e: any) => e.id);
|
||||
expect(elementIds).toContain('ha-el');
|
||||
expect(elementIds).not.toContain('hb-el');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenant Switch via API ──────────────────────────────────
|
||||
|
||||
describe('Tenant switch via API', () => {
|
||||
it('PUT /api/tenant/active switches context and broadcasts', async () => {
|
||||
ensureTenant('switch-to', 'Switch To', '/path/switch-to');
|
||||
|
||||
const ws = await connectAndDrain();
|
||||
const switchPromise = waitForMessageOfType(ws, 'tenant_switched', 8000);
|
||||
|
||||
await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'switch-to' });
|
||||
|
||||
const msg = await switchPromise;
|
||||
expect(msg.tenant).toBeDefined();
|
||||
expect(msg.tenant.id).toBe('switch-to');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('GET /api/elements after tenant switch returns new tenant elements', async () => {
|
||||
ensureTenant('ctx-old', 'Old', '/path/old');
|
||||
ensureTenant('ctx-new', 'New', '/path/new');
|
||||
|
||||
await request(app)
|
||||
.post('/api/elements')
|
||||
.set('X-Tenant-Id', 'ctx-new')
|
||||
.send({ id: 'new-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 });
|
||||
|
||||
// Switch to new tenant
|
||||
await request(app)
|
||||
.put('/api/tenant/active')
|
||||
.send({ tenantId: 'ctx-new' });
|
||||
|
||||
// Elements should be from the new tenant
|
||||
const res = await request(app).get('/api/elements');
|
||||
const ids = res.body.elements.map((e: any) => e.id);
|
||||
expect(ids).toContain('new-el');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hello handshake', () => {
|
||||
it('client receives hello_ack after sending hello', async () => {
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
tenantId: 'default',
|
||||
projectId: 'default',
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.type).toBe('hello_ack');
|
||||
expect(msg.tenantId).toBe('default');
|
||||
expect(msg.projectId).toBe('default');
|
||||
expect(Array.isArray(msg.elements)).toBe(true);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('hello_ack contains elements for the requested project', async () => {
|
||||
setElement('hello-el', {
|
||||
id: 'hello-el', type: 'rectangle', x: 5, y: 10, width: 80, height: 40, version: 1,
|
||||
} as ServerElement);
|
||||
|
||||
const ws = await connectClient();
|
||||
await drainInitialMessages(ws);
|
||||
|
||||
const helloAckPromise = waitForMessageOfType(ws, 'hello_ack');
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
tenantId: 'default',
|
||||
projectId: 'default',
|
||||
}));
|
||||
|
||||
const msg = await helloAckPromise;
|
||||
expect(msg.elements.length).toBeGreaterThanOrEqual(1);
|
||||
const found = msg.elements.find((el: any) => el.id === 'hello-el');
|
||||
expect(found).toBeDefined();
|
||||
expect(found.type).toBe('rectangle');
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scoped broadcast', () => {
|
||||
it('broadcast reaches all clients in the same default scope', 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: 'rectangle', x: 0, y: 0, width: 30, height: 30 }),
|
||||
});
|
||||
|
||||
const [msg1, msg2] = await Promise.all([promise1, promise2]);
|
||||
expect(msg1.element.type).toBe('rectangle');
|
||||
expect(msg2.element.type).toBe('rectangle');
|
||||
// Both messages should have the same msgId since they came from the same broadcast
|
||||
expect(msg1.msgId).toBe(msg2.msgId);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACK model', () => {
|
||||
it('mutation broadcasts include msgId', 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).toHaveProperty('msgId');
|
||||
expect(typeof msg.msgId).toBe('string');
|
||||
expect(msg.msgId.length).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('server accepts ack messages without error', 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: 'ellipse', x: 10, y: 10, width: 40, height: 40 }),
|
||||
});
|
||||
|
||||
const msg = await createdPromise;
|
||||
|
||||
// Send ACK back — should not cause any errors or disconnection
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ack',
|
||||
msgId: msg.msgId,
|
||||
status: 'applied',
|
||||
}));
|
||||
|
||||
// Wait briefly to ensure server processes the ack without crashing
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify the connection is still open (readyState 1 = OPEN)
|
||||
expect(ws.readyState).toBe(WebSocket.OPEN);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync_version in broadcasts', () => {
|
||||
it('element_created broadcast includes sync_version', 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).toHaveProperty('sync_version');
|
||||
expect(typeof msg.sync_version).toBe('number');
|
||||
expect(msg.sync_version).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('element_updated broadcast includes sync_version', async () => {
|
||||
setElement('sv-upd', {
|
||||
id: 'sv-upd', 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/sv-upd`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ x: 500 }),
|
||||
});
|
||||
|
||||
const msg = await updatedPromise;
|
||||
expect(msg).toHaveProperty('sync_version');
|
||||
expect(typeof msg.sync_version).toBe('number');
|
||||
expect(msg.sync_version).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
it('elements_batch_created broadcast includes sync_version', 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).toHaveProperty('sync_version');
|
||||
expect(typeof msg.sync_version).toBe('number');
|
||||
expect(msg.sync_version).toBeGreaterThan(0);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const API = 'http://localhost:3100';
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
});
|
||||
|
||||
// ─── Fix 3: Hello handshake → real-time sync works immediately ──
|
||||
|
||||
test.describe('Hello handshake and real-time sync', () => {
|
||||
test('element created via API appears in canvas without page reload', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
// Wait for hello handshake to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create an element via API — it should appear in the canvas immediately
|
||||
const createRes = await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'hello-sync-test',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#a5d8ff',
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const body = await createRes.json();
|
||||
|
||||
// syncedToCanvas should be true because the browser's WS is registered
|
||||
// via hello handshake
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
});
|
||||
|
||||
test('batch create via API syncs to canvas', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const batchRes = await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'batch-sync-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'batch-sync-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(batchRes.ok()).toBe(true);
|
||||
const body = await batchRes.json();
|
||||
|
||||
// Should be ACKed because browser is connected and registered
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
expect(body.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 6: Parallel creates don't lose elements ────────────
|
||||
|
||||
test.describe('Parallel element creation (race condition fix)', () => {
|
||||
test('5 parallel API creates all persist and sync to canvas', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Fire 5 parallel element creations
|
||||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||||
request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: `parallel-${i}`,
|
||||
type: 'rectangle',
|
||||
x: i * 150,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 60,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
for (const res of results) {
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
// All 5 should exist in the DB
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(5);
|
||||
|
||||
// Wait for all broadcasts to complete
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify via Excalidraw API that all 5 are in the canvas
|
||||
const canvasElementCount = await page.evaluate(() => {
|
||||
// Access the Excalidraw API through the window if exposed
|
||||
const excalidrawWrapper = document.querySelector('.excalidraw');
|
||||
if (!excalidrawWrapper) return -1;
|
||||
// Count rendered canvas elements via the backend
|
||||
return fetch('/api/elements')
|
||||
.then(r => r.json())
|
||||
.then(data => data.count);
|
||||
});
|
||||
expect(canvasElementCount).toBe(5);
|
||||
});
|
||||
|
||||
test('parallel batch + single create all persist', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const [batchRes, singleRes] = await Promise.all([
|
||||
request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'mix-batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'mix-batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mix-single', type: 'diamond', x: 400, y: 0, width: 60, height: 60 },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(batchRes.ok()).toBe(true);
|
||||
expect(singleRes.ok()).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 1: Batch create error messages ─────────────────────
|
||||
|
||||
test.describe('Batch create error handling (E2E)', () => {
|
||||
test('batch with invalid element returns descriptive error, not "unavailable"', async ({ request }) => {
|
||||
const res = await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ type: 'invalid-thing', x: 0, y: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).not.toContain('HTTP server unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 5: Viewport control ────────────────────────────────
|
||||
|
||||
test.describe('Viewport control', () => {
|
||||
test('set_viewport scrollToContent works without animation delay', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create some elements spread across the canvas
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'vp-el-1', type: 'rectangle', x: 0, y: 0, width: 200, height: 100 },
|
||||
{ id: 'vp-el-2', type: 'rectangle', x: 1000, y: 1000, width: 200, height: 100 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Elements should exist
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 4: Screenshot capture ──────────────────────────────
|
||||
|
||||
test.describe('Screenshot and image export', () => {
|
||||
test('export image endpoint works with browser connected', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create an element so there's something to capture
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'screenshot-el',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#ff6b6b',
|
||||
},
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Request a screenshot (full scene export)
|
||||
const exportRes = await request.post(`${API}/api/export/image`, {
|
||||
data: { format: 'png', background: true },
|
||||
});
|
||||
expect(exportRes.ok()).toBe(true);
|
||||
const exportBody = await exportRes.json();
|
||||
expect(exportBody.success).toBe(true);
|
||||
expect(exportBody.format).toBe('png');
|
||||
expect(typeof exportBody.data).toBe('string');
|
||||
expect(exportBody.data.length).toBeGreaterThan(100); // non-trivial base64
|
||||
});
|
||||
|
||||
test('viewport screenshot (captureViewport) works with browser connected', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Create an element
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'vp-screenshot-el',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#4ecdc4',
|
||||
},
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Request a viewport screenshot
|
||||
const exportRes = await request.post(`${API}/api/export/image`, {
|
||||
data: { format: 'png', background: true, captureViewport: true },
|
||||
});
|
||||
expect(exportRes.ok()).toBe(true);
|
||||
const exportBody = await exportRes.json();
|
||||
expect(exportBody.success).toBe(true);
|
||||
expect(exportBody.format).toBe('png');
|
||||
expect(typeof exportBody.data).toBe('string');
|
||||
expect(exportBody.data.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version API ───────────────────────────────────────
|
||||
|
||||
test.describe('Sync Version API', () => {
|
||||
test('GET /api/sync/version returns initial version', async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/sync/version`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.syncVersion).toBe('number');
|
||||
});
|
||||
|
||||
test('sync version increases after element creation', async ({ request }) => {
|
||||
const beforeRes = await request.get(`${API}/api/sync/version`);
|
||||
const beforeBody = await beforeRes.json();
|
||||
const versionBefore = beforeBody.syncVersion;
|
||||
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'sync-ver-el',
|
||||
type: 'rectangle',
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 100,
|
||||
height: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const afterRes = await request.get(`${API}/api/sync/version`);
|
||||
const afterBody = await afterRes.json();
|
||||
expect(afterBody.syncVersion).toBeGreaterThan(versionBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2 API ──────────────────────────────────────
|
||||
|
||||
test.describe('Delta Sync v2 API', () => {
|
||||
test('accepts empty changes and returns current state', async ({ request }) => {
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: { lastSyncVersion: 0, changes: [] },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(typeof body.currentSyncVersion).toBe('number');
|
||||
expect(Array.isArray(body.serverChanges)).toBe(true);
|
||||
});
|
||||
|
||||
test('applies upsert changes via delta sync', async ({ request }) => {
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{
|
||||
id: 'delta-upsert-1',
|
||||
action: 'upsert',
|
||||
element: {
|
||||
id: 'delta-upsert-1',
|
||||
type: 'rectangle',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 120,
|
||||
height: 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.appliedCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify the element exists via GET
|
||||
const getRes = await request.get(`${API}/api/elements/delta-upsert-1`);
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const getBody = await getRes.json();
|
||||
expect(getBody.element.id).toBe('delta-upsert-1');
|
||||
});
|
||||
|
||||
test('returns server changes for elements created via normal API', async ({ request }) => {
|
||||
// Create an element via the normal REST API
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'normal-api-el',
|
||||
type: 'ellipse',
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 80,
|
||||
height: 80,
|
||||
},
|
||||
});
|
||||
|
||||
// Now call delta sync with lastSyncVersion: 0 to get all server changes
|
||||
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: { lastSyncVersion: 0, changes: [] },
|
||||
});
|
||||
expect(syncRes.ok()).toBe(true);
|
||||
const syncBody = await syncRes.json();
|
||||
expect(syncBody.serverChanges.some((el: any) => el.id === 'normal-api-el')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canvasStatus in API responses ──────────────────────────
|
||||
|
||||
test.describe('canvasStatus in API responses', () => {
|
||||
test('element creation response includes canvasStatus', async ({ request }) => {
|
||||
const createRes = await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'status-check-el',
|
||||
type: 'rectangle',
|
||||
x: 300,
|
||||
y: 300,
|
||||
width: 150,
|
||||
height: 75,
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const body = await createRes.json();
|
||||
|
||||
// syncedToCanvas should be a boolean
|
||||
expect(typeof body.syncedToCanvas).toBe('boolean');
|
||||
|
||||
// canvasStatus object should be present with expected fields
|
||||
expect(body.canvasStatus).toBeDefined();
|
||||
expect(typeof body.canvasStatus.connectedBrowsers).toBe('number');
|
||||
expect(typeof body.canvasStatus.ackedBy).toBe('number');
|
||||
expect(typeof body.canvasStatus.reason).toBe('string');
|
||||
expect(typeof body.canvasStatus.scope).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Real-time Sync with ACK ────────────────────────────────
|
||||
|
||||
test.describe('Real-time Sync with ACK', () => {
|
||||
test('syncedToCanvas is true when browser is connected', async ({ page, request }) => {
|
||||
// Open the page and wait for WebSocket connection
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Create an element via API while browser is connected
|
||||
const createRes = await request.post(`${API}/api/elements`, {
|
||||
data: {
|
||||
id: 'ack-test-rect',
|
||||
type: 'rectangle',
|
||||
x: 400,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: '#4ecdc4',
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const body = await createRes.json();
|
||||
|
||||
// Browser should have ACKed, so syncedToCanvas should be true
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
|
||||
// Also verify the element exists in the backend
|
||||
const verifyRes = await request.get(`${API}/api/elements/ack-test-rect`);
|
||||
expect(verifyRes.ok()).toBe(true);
|
||||
const verifyBody = await verifyRes.json();
|
||||
expect(verifyBody.element.id).toBe('ack-test-rect');
|
||||
});
|
||||
|
||||
test('batch create with browser connected gets ACK', async ({ page, request }) => {
|
||||
// Open the page and wait for WebSocket connection
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Batch create elements via API while browser is connected
|
||||
const batchRes = await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'ack-batch-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'ack-batch-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(batchRes.ok()).toBe(true);
|
||||
const body = await batchRes.json();
|
||||
|
||||
// Browser should have ACKed the batch broadcast
|
||||
expect(body.syncedToCanvas).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,541 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const API = 'http://localhost:3100';
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────
|
||||
|
||||
async function waitForConnected(page: Page): Promise<void> {
|
||||
await expect(page.locator('.status span')).toContainText('Connected', { timeout: 5000 });
|
||||
}
|
||||
|
||||
async function waitForElements(request: any, expectedCount: number, timeoutMs = 5000): Promise<any[]> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const res = await request.get(`${API}/api/elements`);
|
||||
const body = await res.json();
|
||||
if (body.count === expectedCount) return body.elements;
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${expectedCount} elements`);
|
||||
}
|
||||
|
||||
async function getServerElementCount(request: any): Promise<number> {
|
||||
const res = await request.get(`${API}/api/elements`);
|
||||
const body = await res.json();
|
||||
return body.count;
|
||||
}
|
||||
|
||||
async function getSyncVersion(request: any): Promise<number> {
|
||||
const res = await request.get(`${API}/api/sync/version`);
|
||||
const body = await res.json();
|
||||
return body.syncVersion;
|
||||
}
|
||||
|
||||
// ─── THE Critical Regression Test ───────────────────────────
|
||||
// This is the exact scenario that was broken: delete in UI → sync → reload → elements gone
|
||||
|
||||
test.describe('Delete + Sync + Reload persistence', () => {
|
||||
test('elements deleted via API stay gone after page reload', async ({ page, request }) => {
|
||||
// 1. Create elements on server
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'del-r1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
|
||||
});
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'del-r2', type: 'ellipse', x: 400, y: 100, width: 150, height: 150 },
|
||||
});
|
||||
|
||||
// 2. Load the page, verify elements loaded
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500); // Let elements render
|
||||
|
||||
// 3. Delete them via sync/v2 (simulating what the Sync button does after UI deletion)
|
||||
const syncVersion = await getSyncVersion(request);
|
||||
const syncRes = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: syncVersion,
|
||||
changes: [
|
||||
{ id: 'del-r1', action: 'delete' },
|
||||
{ id: 'del-r2', action: 'delete' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const syncBody = await syncRes.json();
|
||||
expect(syncBody.success).toBe(true);
|
||||
expect(syncBody.appliedCount).toBe(2);
|
||||
|
||||
// 4. Verify server has 0 elements
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
|
||||
// 5. Reload the page
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 6. Verify elements are still gone on server (the regression was here)
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
|
||||
test('sync button persists deletions that survive reload', async ({ page, request }) => {
|
||||
// 1. Create elements on server
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'sb-1', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
|
||||
});
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'sb-2', type: 'text', x: 100, y: 300, text: 'To be deleted' },
|
||||
});
|
||||
|
||||
// 2. Load the page
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(1000); // Let elements load + sync baseline populate
|
||||
|
||||
// 3. Delete elements via delta sync (simulating UI delete + Sync button)
|
||||
const v = await getSyncVersion(request);
|
||||
await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: v,
|
||||
changes: [
|
||||
{ id: 'sb-1', action: 'delete' },
|
||||
{ id: 'sb-2', action: 'delete' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Reload
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 5. Verify no elements on server
|
||||
const count = await getServerElementCount(request);
|
||||
expect(count).toBe(0);
|
||||
|
||||
// 6. Reload again to double-check
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Sync v2 E2E ──────────────────────────────────────
|
||||
|
||||
test.describe('Delta sync v2 E2E', () => {
|
||||
test('frontend delta sync creates elements that persist', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
// Simulate what the frontend does: send a sync with upserts
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: 0,
|
||||
changes: [
|
||||
{ id: 'ds-e2e-1', action: 'upsert', element: { id: 'ds-e2e-1', type: 'rectangle', x: 50, y: 50, width: 100, height: 60 } },
|
||||
{ id: 'ds-e2e-2', action: 'upsert', element: { id: 'ds-e2e-2', type: 'ellipse', x: 200, y: 50, width: 80, height: 80 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.appliedCount).toBe(2);
|
||||
|
||||
// Reload and verify they persist
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
const elements = await waitForElements(request, 2);
|
||||
const ids = elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual(['ds-e2e-1', 'ds-e2e-2']);
|
||||
});
|
||||
|
||||
test('delta sync handles mixed create+delete+update', async ({ request }) => {
|
||||
// Create initial elements
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mix-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mix-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
});
|
||||
|
||||
const v = await getSyncVersion(request);
|
||||
|
||||
// Mixed operation: delete mix-1, update mix-2, create mix-3
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: {
|
||||
lastSyncVersion: v,
|
||||
changes: [
|
||||
{ id: 'mix-1', action: 'delete' },
|
||||
{ id: 'mix-2', action: 'upsert', element: { id: 'mix-2', type: 'ellipse', x: 300, y: 100, width: 80, height: 80 } },
|
||||
{ id: 'mix-3', action: 'upsert', element: { id: 'mix-3', type: 'text', x: 50, y: 200, text: 'New' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.appliedCount).toBe(3);
|
||||
|
||||
// Verify final state
|
||||
const listRes = await request.get(`${API}/api/elements`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.count).toBe(2);
|
||||
const ids = listBody.elements.map((e: any) => e.id).sort();
|
||||
expect(ids).toEqual(['mix-2', 'mix-3']);
|
||||
});
|
||||
|
||||
test('server returns MCP-created elements as serverChanges', async ({ request }) => {
|
||||
// MCP creates an element (via normal API)
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'mcp-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
|
||||
// Frontend syncs from version 0
|
||||
const res = await request.post(`${API}/api/elements/sync/v2`, {
|
||||
data: { lastSyncVersion: 0, changes: [] },
|
||||
});
|
||||
const body = await res.json();
|
||||
const serverIds = body.serverChanges.map((c: any) => c.id);
|
||||
expect(serverIds).toContain('mcp-el');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auto-sync behavior ─────────────────────────────────────
|
||||
|
||||
test.describe('Auto-sync toggle', () => {
|
||||
test('auto-sync button toggles state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
const autoSaveBtn = page.locator('button[title*="Auto-sync"]');
|
||||
await expect(autoSaveBtn).toBeVisible();
|
||||
|
||||
// Check initial state (should show the sun/moon icon and be clickable)
|
||||
await autoSaveBtn.click();
|
||||
// Second click toggles back
|
||||
await autoSaveBtn.click();
|
||||
// No crash = success
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sync Version Tracking E2E ──────────────────────────────
|
||||
|
||||
test.describe('Sync version tracking', () => {
|
||||
test('sync version increases after each mutation', async ({ request }) => {
|
||||
const v0 = await getSyncVersion(request);
|
||||
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'sv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
const v1 = await getSyncVersion(request);
|
||||
expect(v1).toBeGreaterThan(v0);
|
||||
|
||||
await request.put(`${API}/api/elements/sv-1`, {
|
||||
data: { x: 50 },
|
||||
});
|
||||
const v2 = await getSyncVersion(request);
|
||||
expect(v2).toBeGreaterThan(v1);
|
||||
|
||||
await request.delete(`${API}/api/elements/sv-1`);
|
||||
const v3 = await getSyncVersion(request);
|
||||
expect(v3).toBeGreaterThan(v2);
|
||||
});
|
||||
|
||||
test('batch create increments sync version for each element', async ({ request }) => {
|
||||
const v0 = await getSyncVersion(request);
|
||||
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'bsv-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'bsv-2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
{ id: 'bsv-3', type: 'text', x: 50, y: 100, text: 'Test' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const v1 = await getSyncVersion(request);
|
||||
expect(v1).toBeGreaterThanOrEqual(v0 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Real-time Sync (MCP→Canvas) ────────────────────────────
|
||||
|
||||
test.describe('MCP to Canvas real-time sync', () => {
|
||||
test('element created via API appears on canvas via WebSocket', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
// Create element via API
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'rt-el', type: 'rectangle', x: 100, y: 100, width: 200, height: 100, backgroundColor: '#ff0000' },
|
||||
});
|
||||
|
||||
// Wait for canvas to receive it via WS
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify element is on the canvas (check via API since we can't easily inspect Excalidraw internals)
|
||||
const elements = await waitForElements(request, 1);
|
||||
expect(elements[0].id).toBe('rt-el');
|
||||
});
|
||||
|
||||
test('batch create appears on canvas without reload', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'rt-b1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'rt-b2', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const elements = await waitForElements(request, 2);
|
||||
expect(elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('element update appears on canvas without reload', async ({ page, request }) => {
|
||||
// Pre-create
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'rt-upd', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Update
|
||||
await request.put(`${API}/api/elements/rt-upd`, {
|
||||
data: { x: 500, y: 500 },
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify update persisted
|
||||
const res = await request.get(`${API}/api/elements/rt-upd`);
|
||||
const body = await res.json();
|
||||
expect(body.element.x).toBe(500);
|
||||
expect(body.element.y).toBe(500);
|
||||
});
|
||||
|
||||
test('element delete via API clears from canvas', async ({ page, request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'rt-del', type: 'rectangle', x: 100, y: 100, width: 200, height: 100 },
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await request.delete(`${API}/api/elements/rt-del`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Clear Canvas E2E ───────────────────────────────────────
|
||||
|
||||
test.describe('Clear canvas persistence', () => {
|
||||
test('clearing via API removes all elements permanently', async ({ page, request }) => {
|
||||
// Create elements
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'clr-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'clr-2', type: 'text', x: 50, y: 100, text: 'Will be cleared' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Clear
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify gone
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
|
||||
// Reload
|
||||
await page.reload();
|
||||
await waitForConnected(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Still gone
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Snapshot Create + Restore ──────────────────────────────
|
||||
|
||||
test.describe('Snapshots E2E', () => {
|
||||
test('create snapshot, clear, restore, verify elements return', async ({ request }) => {
|
||||
// Create elements
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'snap-1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'snap-2', type: 'text', x: 50, y: 100, text: 'Snapshot test' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Save snapshot
|
||||
const snapRes = await request.post(`${API}/api/snapshots`, {
|
||||
data: { name: 'test-snap' },
|
||||
});
|
||||
expect((await snapRes.json()).success).toBe(true);
|
||||
|
||||
// Clear
|
||||
await request.delete(`${API}/api/elements/clear`);
|
||||
expect(await getServerElementCount(request)).toBe(0);
|
||||
|
||||
// List snapshots
|
||||
const listRes = await request.get(`${API}/api/snapshots`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.snapshots.some((s: any) => s.name === 'test-snap')).toBe(true);
|
||||
|
||||
// Get snapshot
|
||||
const getRes = await request.get(`${API}/api/snapshots/test-snap`);
|
||||
const getBody = await getRes.json();
|
||||
expect(getBody.snapshot).toBeDefined();
|
||||
expect(getBody.snapshot.elements).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Settings Persistence ───────────────────────────────────
|
||||
|
||||
test.describe('Settings E2E', () => {
|
||||
test('settings persist across requests', async ({ request }) => {
|
||||
await request.put(`${API}/api/settings/test_key`, {
|
||||
data: { value: 'test_value' },
|
||||
});
|
||||
|
||||
const res = await request.get(`${API}/api/settings/test_key`);
|
||||
const body = await res.json();
|
||||
expect(body.value).toBe('test_value');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Files API E2E ──────────────────────────────────────────
|
||||
|
||||
test.describe('Files API E2E', () => {
|
||||
test('add and list files', async ({ request }) => {
|
||||
const addRes = await request.post(`${API}/api/files`, {
|
||||
data: {
|
||||
files: {
|
||||
'file-1': {
|
||||
id: 'file-1',
|
||||
mimeType: 'image/png',
|
||||
dataURL: 'data:image/png;base64,iVBOR...',
|
||||
created: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((await addRes.json()).success).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${API}/api/files`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.files['file-1']).toBeDefined();
|
||||
expect(listBody.files['file-1'].mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
test('delete file', async ({ request }) => {
|
||||
await request.post(`${API}/api/files`, {
|
||||
data: {
|
||||
files: {
|
||||
'file-del': {
|
||||
id: 'file-del',
|
||||
mimeType: 'image/png',
|
||||
dataURL: 'data:image/png;base64,abc',
|
||||
created: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const delRes = await request.delete(`${API}/api/files/file-del`);
|
||||
expect((await delRes.json()).success).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${API}/api/files`);
|
||||
const listBody = await listRes.json();
|
||||
expect(listBody.files['file-del']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search API E2E ─────────────────────────────────────────
|
||||
|
||||
test.describe('Search E2E', () => {
|
||||
test('search by type returns matching elements', async ({ request }) => {
|
||||
await request.post(`${API}/api/elements/batch`, {
|
||||
data: {
|
||||
elements: [
|
||||
{ id: 'srch-r', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
{ id: 'srch-e', type: 'ellipse', x: 200, y: 0, width: 80, height: 80 },
|
||||
{ id: 'srch-t', type: 'text', x: 50, y: 100, text: 'Search me' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Filter by type
|
||||
const res = await request.get(`${API}/api/elements/search?type=rectangle`);
|
||||
const body = await res.json();
|
||||
expect(body.elements.length).toBe(1);
|
||||
expect(body.elements[0].type).toBe('rectangle');
|
||||
});
|
||||
|
||||
test('full-text search finds elements by label', async ({ request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'fts-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, label: { text: 'Authentication Service' } },
|
||||
});
|
||||
|
||||
const res = await request.get(`${API}/api/elements/search?q=Authentication`);
|
||||
const body = await res.json();
|
||||
expect(body.elements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.elements.some((e: any) => e.id === 'fts-el')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tenant API E2E ─────────────────────────────────────────
|
||||
|
||||
test.describe('Tenant management E2E', () => {
|
||||
test('list tenants returns at least default', async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/tenants`);
|
||||
const body = await res.json();
|
||||
expect(body.tenants.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('active tenant is available', async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/tenant/active`);
|
||||
const body = await res.json();
|
||||
expect(body.tenant).toBeDefined();
|
||||
expect(body.tenant.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Element Version History E2E ────────────────────────────
|
||||
|
||||
test.describe('Element version history E2E', () => {
|
||||
test('element history tracks create and update', async ({ request }) => {
|
||||
await request.post(`${API}/api/elements`, {
|
||||
data: { id: 'hist-el', type: 'rectangle', x: 0, y: 0, width: 100, height: 50 },
|
||||
});
|
||||
|
||||
await request.put(`${API}/api/elements/hist-el`, {
|
||||
data: { x: 500 },
|
||||
});
|
||||
|
||||
// Get element to verify it exists and is updated
|
||||
const getRes = await request.get(`${API}/api/elements/hist-el`);
|
||||
const body = await getRes.json();
|
||||
expect(body.element.x).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
cleanElementForExcalidraw,
|
||||
validateAndFixBindings,
|
||||
computeElementHash,
|
||||
isImageElement,
|
||||
isShapeContainerType,
|
||||
normalizeImageElement,
|
||||
restoreBindings,
|
||||
} 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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isImageElement ─────────────────────────────────────────
|
||||
|
||||
describe('isImageElement', () => {
|
||||
it('returns true for image type', () => {
|
||||
expect(isImageElement({ type: 'image' } as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-image types', () => {
|
||||
expect(isImageElement({ type: 'rectangle' } as any)).toBe(false);
|
||||
expect(isImageElement({ type: 'text' } as any)).toBe(false);
|
||||
expect(isImageElement({ type: 'arrow' } as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isShapeContainerType ───────────────────────────────────
|
||||
|
||||
describe('isShapeContainerType', () => {
|
||||
it('returns true for container types', () => {
|
||||
expect(isShapeContainerType('rectangle')).toBe(true);
|
||||
expect(isShapeContainerType('ellipse')).toBe(true);
|
||||
expect(isShapeContainerType('diamond')).toBe(true);
|
||||
expect(isShapeContainerType('arrow')).toBe(true);
|
||||
expect(isShapeContainerType('line')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-container types', () => {
|
||||
expect(isShapeContainerType('text')).toBe(false);
|
||||
expect(isShapeContainerType('image')).toBe(false);
|
||||
expect(isShapeContainerType('freedraw')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeImageElement ──────────────────────────────────
|
||||
|
||||
describe('normalizeImageElement', () => {
|
||||
it('fills in default values for missing properties', () => {
|
||||
const el = { id: 'img1', type: 'image', x: 0, y: 0, width: 100, height: 100 };
|
||||
const result = normalizeImageElement(el);
|
||||
|
||||
expect(result.status).toBe('saved');
|
||||
expect(result.fileId).toBeNull();
|
||||
expect(result.scale).toEqual([1, 1]);
|
||||
expect(result.angle).toBe(0);
|
||||
expect(result.roughness).toBe(1);
|
||||
expect(result.opacity).toBe(100);
|
||||
expect(result.isDeleted).toBe(false);
|
||||
expect(result.locked).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves existing values', () => {
|
||||
const el = {
|
||||
id: 'img2',
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
status: 'pending',
|
||||
fileId: 'abc',
|
||||
scale: [2, 2] as [number, number],
|
||||
opacity: 50,
|
||||
};
|
||||
const result = normalizeImageElement(el);
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.fileId).toBe('abc');
|
||||
expect(result.scale).toEqual([2, 2]);
|
||||
expect(result.opacity).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── restoreBindings ────────────────────────────────────────
|
||||
|
||||
describe('restoreBindings', () => {
|
||||
it('restores startBinding and endBinding from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow1',
|
||||
type: 'arrow',
|
||||
x: 0,
|
||||
y: 0,
|
||||
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
|
||||
endBinding: { elementId: 'rect2', focus: 0, gap: 5 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding).toEqual({ elementId: 'rect1', focus: 0, gap: 5 });
|
||||
expect(result[0].endBinding).toEqual({ elementId: 'rect2', focus: 0, gap: 5 });
|
||||
});
|
||||
|
||||
it('restores boundElements from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'rect1', type: 'rectangle', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'rect1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
boundElements: [{ id: 'arrow1', type: 'arrow' }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].boundElements).toEqual([{ id: 'arrow1', type: 'arrow' }]);
|
||||
});
|
||||
|
||||
it('restores elbowed property from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, elbowed: true },
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].elbowed).toBe(true);
|
||||
});
|
||||
|
||||
it('does not overwrite existing bindings', () => {
|
||||
const existingBinding = { elementId: 'rect99', focus: 1, gap: 10 };
|
||||
const converted = [
|
||||
{ id: 'arrow1', type: 'arrow', x: 0, y: 0, startBinding: existingBinding },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow1',
|
||||
type: 'arrow',
|
||||
x: 0,
|
||||
y: 0,
|
||||
startBinding: { elementId: 'rect1', focus: 0, gap: 5 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding).toEqual(existingBinding);
|
||||
});
|
||||
|
||||
it('handles elements not found in originals', () => {
|
||||
const converted = [
|
||||
{ id: 'new1', type: 'rectangle', x: 0, y: 0 },
|
||||
];
|
||||
const originals = [
|
||||
{ id: 'other', type: 'rectangle', x: 0, y: 0, boundElements: [{ id: 'a', type: 'arrow' }] },
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0]).toEqual({ id: 'new1', type: 'rectangle', x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
cleanElementForExcalidraw,
|
||||
computeElementHash,
|
||||
isImageElement,
|
||||
isShapeContainerType,
|
||||
normalizeImageElement,
|
||||
validateAndFixBindings,
|
||||
restoreBindings,
|
||||
} from '../../frontend/src/utils/elementHelpers.js';
|
||||
|
||||
// ─── cleanElementForExcalidraw comprehensive ────────────────
|
||||
|
||||
describe('cleanElementForExcalidraw - comprehensive', () => {
|
||||
it('strips all server-only metadata fields', () => {
|
||||
const serverEl = {
|
||||
id: 'el-1',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 150,
|
||||
height: 80,
|
||||
version: 1,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
syncedAt: '2024-01-01',
|
||||
source: 'mcp',
|
||||
syncTimestamp: 12345,
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(serverEl);
|
||||
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');
|
||||
// Core props preserved
|
||||
expect(cleaned.id).toBe('el-1');
|
||||
expect(cleaned.type).toBe('rectangle');
|
||||
expect(cleaned.x).toBe(100);
|
||||
});
|
||||
|
||||
it('preserves label text on container elements', () => {
|
||||
const el = {
|
||||
id: 'cont-1',
|
||||
type: 'rectangle',
|
||||
x: 0, y: 0, width: 200, height: 100,
|
||||
label: { text: 'My Label' },
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(el);
|
||||
expect(cleaned.label?.text || (cleaned as any).text).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves arrow binding properties', () => {
|
||||
const arrow = {
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
x: 0, y: 0,
|
||||
width: 200, height: 0,
|
||||
start: { id: 'rect-1' },
|
||||
end: { id: 'rect-2' },
|
||||
startElementId: 'rect-1',
|
||||
endElementId: 'rect-2',
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(arrow);
|
||||
// Should preserve binding references
|
||||
expect(cleaned.type).toBe('arrow');
|
||||
});
|
||||
|
||||
it('handles elements with no optional properties', () => {
|
||||
const minimal = {
|
||||
id: 'min-1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 50,
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(minimal);
|
||||
expect(cleaned.id).toBe('min-1');
|
||||
expect(cleaned.type).toBe('rectangle');
|
||||
});
|
||||
|
||||
it('handles text element with originalText', () => {
|
||||
const textEl = {
|
||||
id: 'text-1',
|
||||
type: 'text',
|
||||
x: 0, y: 0,
|
||||
text: 'Hello',
|
||||
originalText: 'Hello',
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
};
|
||||
|
||||
const cleaned = cleanElementForExcalidraw(textEl);
|
||||
expect(cleaned.type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeElementHash ─────────────────────────────────────
|
||||
|
||||
describe('computeElementHash - edge cases', () => {
|
||||
it('hash changes when element position changes', () => {
|
||||
const elements = [{ id: 'h1', type: 'rectangle', x: 0, y: 0, width: 100, height: 50, version: 1 }] as any;
|
||||
const hash1 = computeElementHash(elements);
|
||||
|
||||
const moved = [{ id: 'h1', type: 'rectangle', x: 50, y: 50, width: 100, height: 50, version: 2 }] as any;
|
||||
const hash2 = computeElementHash(moved);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('hash changes when element is deleted (removed from array)', () => {
|
||||
const full = [
|
||||
{ id: 'h1', type: 'rectangle', version: 1 },
|
||||
{ id: 'h2', type: 'ellipse', version: 1 },
|
||||
] as any;
|
||||
const partial = [{ id: 'h1', type: 'rectangle', version: 1 }] as any;
|
||||
|
||||
expect(computeElementHash(full)).not.toBe(computeElementHash(partial));
|
||||
});
|
||||
|
||||
it('hash is stable for same input', () => {
|
||||
const elements = [
|
||||
{ id: 'stable-1', type: 'rectangle', version: 1 },
|
||||
{ id: 'stable-2', type: 'ellipse', version: 1 },
|
||||
] as any;
|
||||
|
||||
expect(computeElementHash(elements)).toBe(computeElementHash(elements));
|
||||
});
|
||||
|
||||
it('hash uses id+version (type changes without version bump are not detected)', () => {
|
||||
// Hash formula is: count + join(id+version) — type is NOT included
|
||||
const rect = [{ id: 'morph', type: 'rectangle', version: 1 }] as any;
|
||||
const ellipse = [{ id: 'morph', type: 'ellipse', version: 1 }] as any;
|
||||
|
||||
// Same id+version → same hash (this is expected behavior)
|
||||
expect(computeElementHash(rect)).toBe(computeElementHash(ellipse));
|
||||
|
||||
// Version bump makes them different
|
||||
const updated = [{ id: 'morph', type: 'ellipse', version: 2 }] as any;
|
||||
expect(computeElementHash(rect)).not.toBe(computeElementHash(updated));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validateAndFixBindings comprehensive ───────────────────
|
||||
|
||||
describe('validateAndFixBindings - comprehensive', () => {
|
||||
it('preserves valid container + bound text relationship', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: 'container',
|
||||
type: 'rectangle',
|
||||
boundElements: [{ id: 'bound-text', type: 'text' }],
|
||||
},
|
||||
{
|
||||
id: 'bound-text',
|
||||
type: 'text',
|
||||
containerId: 'container',
|
||||
},
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
const container = result.find((e: any) => e.id === 'container');
|
||||
const text = result.find((e: any) => e.id === 'bound-text');
|
||||
|
||||
expect(container.boundElements).toHaveLength(1);
|
||||
expect(text.containerId).toBe('container');
|
||||
});
|
||||
|
||||
it('removes orphaned boundElements references', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: 'container',
|
||||
type: 'rectangle',
|
||||
boundElements: [
|
||||
{ id: 'exists', type: 'text' },
|
||||
{ id: 'ghost', type: 'text' },
|
||||
],
|
||||
},
|
||||
{ id: 'exists', type: 'text', containerId: 'container' },
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
const container = result.find((e: any) => e.id === 'container');
|
||||
expect(container.boundElements).toHaveLength(1);
|
||||
expect(container.boundElements[0].id).toBe('exists');
|
||||
});
|
||||
|
||||
it('nullifies containerId when container does not exist', () => {
|
||||
const elements = [
|
||||
{ id: 'orphan', type: 'text', containerId: 'nonexistent' },
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
expect(result[0].containerId).toBeNull();
|
||||
});
|
||||
|
||||
it('handles arrow boundElements correctly', () => {
|
||||
const elements = [
|
||||
{
|
||||
id: 'shape',
|
||||
type: 'rectangle',
|
||||
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
|
||||
},
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'shape' },
|
||||
},
|
||||
];
|
||||
|
||||
const result = validateAndFixBindings(elements);
|
||||
const shape = result.find((e: any) => e.id === 'shape');
|
||||
expect(shape.boundElements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles empty boundElements array (converts to null)', () => {
|
||||
const elements = [{ id: 'empty', type: 'rectangle', boundElements: [] }];
|
||||
const result = validateAndFixBindings(elements);
|
||||
// Implementation converts empty filtered arrays to null
|
||||
expect(result[0].boundElements).toBeNull();
|
||||
});
|
||||
|
||||
it('handles null boundElements', () => {
|
||||
const elements = [{ id: 'null-bound', type: 'rectangle', boundElements: null }];
|
||||
const result = validateAndFixBindings(elements);
|
||||
expect(result[0].boundElements).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isImageElement ─────────────────────────────────────────
|
||||
|
||||
describe('isImageElement - comprehensive', () => {
|
||||
it('returns true for image type', () => {
|
||||
expect(isImageElement({ type: 'image', fileId: 'f1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for all other types', () => {
|
||||
const nonImageTypes = ['rectangle', 'ellipse', 'diamond', 'arrow', 'line', 'text', 'freedraw'];
|
||||
for (const type of nonImageTypes) {
|
||||
expect(isImageElement({ type })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true when fileId is present regardless of type', () => {
|
||||
// Some implementations check fileId as fallback
|
||||
const result = isImageElement({ type: 'image', fileId: 'some-file' });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isShapeContainerType ───────────────────────────────────
|
||||
|
||||
describe('isShapeContainerType - comprehensive', () => {
|
||||
it('returns true for all container types', () => {
|
||||
const containerTypes = ['rectangle', 'ellipse', 'diamond'];
|
||||
for (const type of containerTypes) {
|
||||
expect(isShapeContainerType(type)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true for arrow and line (they are container types)', () => {
|
||||
// arrow and line are included in SHAPE_CONTAINER_TYPES
|
||||
expect(isShapeContainerType('arrow')).toBe(true);
|
||||
expect(isShapeContainerType('line')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-container types', () => {
|
||||
const nonContainer = ['text', 'freedraw', 'image', 'frame'];
|
||||
for (const type of nonContainer) {
|
||||
expect(isShapeContainerType(type)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeImageElement ──────────────────────────────────
|
||||
|
||||
describe('normalizeImageElement - comprehensive', () => {
|
||||
it('fills in all required defaults for minimal image', () => {
|
||||
const minimal = {
|
||||
id: 'img-1',
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fileId: 'file-1',
|
||||
};
|
||||
|
||||
const normalized = normalizeImageElement(minimal);
|
||||
expect(normalized.type).toBe('image');
|
||||
expect(normalized.fileId).toBe('file-1');
|
||||
// Should have all required Excalidraw properties
|
||||
expect(normalized).toHaveProperty('strokeColor');
|
||||
expect(normalized).toHaveProperty('backgroundColor');
|
||||
expect(normalized).toHaveProperty('fillStyle');
|
||||
expect(normalized).toHaveProperty('opacity');
|
||||
});
|
||||
|
||||
it('preserves explicit values over defaults', () => {
|
||||
const custom = {
|
||||
id: 'img-2',
|
||||
type: 'image',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
fileId: 'file-2',
|
||||
opacity: 50,
|
||||
angle: 1.5,
|
||||
};
|
||||
|
||||
const normalized = normalizeImageElement(custom);
|
||||
expect(normalized.opacity).toBe(50);
|
||||
expect(normalized.angle).toBe(1.5);
|
||||
expect(normalized.x).toBe(50);
|
||||
expect(normalized.y).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── restoreBindings ────────────────────────────────────────
|
||||
|
||||
describe('restoreBindings - comprehensive', () => {
|
||||
it('restores startBinding and endBinding from originals', () => {
|
||||
const converted = [
|
||||
{ id: 'arrow-1', type: 'arrow' },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'rect-1', focus: 0, gap: 5, fixedPoint: null },
|
||||
endBinding: { elementId: 'rect-2', focus: 0, gap: 5, fixedPoint: null },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding.elementId).toBe('rect-1');
|
||||
expect(result[0].endBinding.elementId).toBe('rect-2');
|
||||
});
|
||||
|
||||
it('restores boundElements on shapes', () => {
|
||||
const converted = [
|
||||
{ id: 'shape-1', type: 'rectangle' },
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'shape-1',
|
||||
type: 'rectangle',
|
||||
boundElements: [{ id: 'arrow-1', type: 'arrow' }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].boundElements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not overwrite existing bindings', () => {
|
||||
const converted = [
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'already-set', focus: 0, gap: 3, fixedPoint: null },
|
||||
},
|
||||
];
|
||||
const originals = [
|
||||
{
|
||||
id: 'arrow-1',
|
||||
type: 'arrow',
|
||||
startBinding: { elementId: 'original', focus: 0, gap: 5, fixedPoint: null },
|
||||
},
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].startBinding.elementId).toBe('already-set');
|
||||
});
|
||||
|
||||
it('handles element not found in originals', () => {
|
||||
const converted = [{ id: 'new-1', type: 'rectangle' }];
|
||||
const originals = [{ id: 'other', type: 'ellipse' }];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].id).toBe('new-1');
|
||||
// Should not crash
|
||||
});
|
||||
|
||||
it('restores elbowed property on arrows', () => {
|
||||
const converted = [{ id: 'elb-arrow', type: 'arrow' }];
|
||||
const originals = [
|
||||
{ id: 'elb-arrow', type: 'arrow', elbowed: true },
|
||||
];
|
||||
|
||||
const result = restoreBindings(converted, originals);
|
||||
expect(result[0].elbowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delta Computation Logic (simulated) ────────────────────
|
||||
// Tests the algorithm used in syncToBackend for detecting changes
|
||||
|
||||
describe('Delta computation (simulated syncToBackend logic)', () => {
|
||||
type Element = { id: string; type: string; x: number; version: number };
|
||||
|
||||
function computeDelta(
|
||||
currentElements: Element[],
|
||||
lastSynced: Map<string, Element>
|
||||
): { id: string; action: 'upsert' | 'delete'; element?: Element }[] {
|
||||
const changes: { id: string; action: 'upsert' | 'delete'; element?: Element }[] = [];
|
||||
const currentMap = new Map<string, Element>();
|
||||
|
||||
for (const el of currentElements) {
|
||||
currentMap.set(el.id, el);
|
||||
const prev = lastSynced.get(el.id);
|
||||
if (!prev || JSON.stringify(prev) !== JSON.stringify(el)) {
|
||||
changes.push({ id: el.id, action: 'upsert', element: el });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id] of lastSynced) {
|
||||
if (!currentMap.has(id)) {
|
||||
changes.push({ id, action: 'delete' });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
it('detects new elements as upserts', () => {
|
||||
const current = [{ id: 'a', type: 'rect', x: 0, version: 1 }];
|
||||
const lastSynced = new Map<string, Element>();
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(delta[0].action).toBe('upsert');
|
||||
expect(delta[0].id).toBe('a');
|
||||
});
|
||||
|
||||
it('detects removed elements as deletes', () => {
|
||||
const current: Element[] = [];
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
['b', { id: 'b', type: 'rect', x: 100, version: 1 }],
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(2);
|
||||
expect(delta.every(d => d.action === 'delete')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects updated elements as upserts', () => {
|
||||
const current = [{ id: 'a', type: 'rect', x: 50, version: 2 }];
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(delta[0].action).toBe('upsert');
|
||||
});
|
||||
|
||||
it('returns empty when nothing changed', () => {
|
||||
const el = { id: 'a', type: 'rect', x: 0, version: 1 };
|
||||
const current = [el];
|
||||
const lastSynced = new Map<string, Element>([['a', { ...el }]]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles mixed operations correctly', () => {
|
||||
const current = [
|
||||
{ id: 'a', type: 'rect', x: 50, version: 2 }, // updated
|
||||
{ id: 'c', type: 'rect', x: 200, version: 1 }, // new
|
||||
];
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
['b', { id: 'b', type: 'rect', x: 100, version: 1 }], // deleted
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(3);
|
||||
|
||||
const upserts = delta.filter(d => d.action === 'upsert');
|
||||
const deletes = delta.filter(d => d.action === 'delete');
|
||||
|
||||
expect(upserts).toHaveLength(2); // a (updated) + c (new)
|
||||
expect(deletes).toHaveLength(1); // b
|
||||
expect(deletes[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('THE BUG: empty lastSynced means no deletions detected', () => {
|
||||
// This is the exact bug scenario: elements loaded from server but lastSynced not populated
|
||||
const current: Element[] = []; // User deleted everything
|
||||
const lastSynced = new Map<string, Element>(); // Bug: was never populated
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
// With empty lastSynced, no deletions are detected - this was the regression
|
||||
expect(delta).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FIXED: populated lastSynced detects all deletions', () => {
|
||||
// After fix: lastSynced is populated on load
|
||||
const current: Element[] = []; // User deleted everything
|
||||
const lastSynced = new Map<string, Element>([
|
||||
['a', { id: 'a', type: 'rect', x: 0, version: 1 }],
|
||||
['b', { id: 'b', type: 'rect', x: 100, version: 1 }],
|
||||
]);
|
||||
|
||||
const delta = computeDelta(current, lastSynced);
|
||||
expect(delta).toHaveLength(2);
|
||||
expect(delta.every(d => d.action === 'delete')).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