feat: add test suite, CI/CD pipeline, setup wizard, and upstream feature ports (#6)

Establish comprehensive quality infrastructure for a project that previously
had zero tests, enabling confident refactoring and community contributions
with automated guardrails. Port upstream enhancements for font normalization,
image element support, and arrow binding preservation.

🏗️ Testing infrastructure:
- Unit tests for SQLite persistence layer and element validation helpers
- Integration tests for REST API, WebSocket broadcast, and arrow binding
- E2E tests with Playwright for canvas rendering and real-time sync
- Vitest + Playwright configuration with proper isolation

👷 CI/CD pipeline:
- Auto-versioning from conventional commits on push to main
- Auto-publish to NPM and Docker Hub on GitHub release
- Matrix testing across Node 18/20/22 with pinned dependencies
- Docker health check with diagnostic logging on failure
- Preserve rollup status checks for branch protection gates

📦 Developer experience:
- Interactive setup wizard for first-time configuration
- Canvas clear confirmation and scene description tools
- Frontend helpers extracted for testability

🔧 Upstream feature ports:
- Font family normalization (string names to numeric IDs)
- Image element support with file management API
- Arrow binding preservation through server round-trips
- Vite config fix for font subsetting worker chunk names
- Idempotent database initialization for standalone Docker mode

🐛 Docker fixes:
- Set EXCALIDRAW_DB_PATH in both Dockerfiles to writable /app/data/
- Make initDb() idempotent and closeDb() reset-safe for test isolation

🎯 Provides the safety net needed for rapid iteration — every PR is
validated across 120 test cases before merge, and releases are fully
automated from commit to published package.

Co-authored-by: sanjibdevnathlabs <devnath.sanjib@gmail.com>
This commit is contained in:
Sanjib Devnath
2026-03-13 12:08:07 +05:30
committed by GitHub
co-authored by sanjibdevnathlabs
parent 4c50472ee4
commit 63209f9d5a
28 changed files with 4850 additions and 713 deletions
+54 -19
View File
@@ -2,22 +2,24 @@ name: CI
on:
push:
branches: [ main, develop ]
branches: [main, develop]
pull_request:
branches: [ main, develop ]
branches: [main, develop]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
name: Build and Type Check
name: Build & Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
@@ -28,36 +30,34 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
- name: Type check
run: npm run type-check
- name: Build project
- name: Build
run: npm run build
- name: Unit & integration tests
run: npm test
- name: Check build artifacts
run: |
echo "Checking if build artifacts exist..."
test -f dist/index.js || (echo "dist/index.js not found" && exit 1)
test -f dist/server.js || (echo "dist/server.js not found" && exit 1)
test -d dist/frontend || (echo "dist/frontend not found" && exit 1)
echo "All build artifacts present!"
test -f dist/index.js
test -f dist/server.js
test -d dist/frontend
- name: Upload build artifacts
if: matrix.node-version == '20.x'
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: |
dist/
path: dist/
retention-days: 7
lint-check:
name: Lint Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -71,11 +71,46 @@ jobs:
- name: Check for TypeScript errors
run: npm run type-check
e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Install Playwright browsers
run: ./node_modules/.bin/playwright install --with-deps chromium
- name: Run E2E tests
run: npm run test:e2e
env:
EXCALIDRAW_DB_PATH: /tmp/excalidraw-e2e-ci.db
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
ci-status:
runs-on: ubuntu-latest
continue-on-error: false
name: CI Status Check
needs: [build-and-test, lint-check]
needs: [build-and-test, lint-check, e2e]
if: always()
permissions:
statuses: write
+55 -65
View File
@@ -1,144 +1,134 @@
name: Docker Build & Push
name: Docker Build
# PR validation and manual builds. Production pushes are handled by release.yml.
on:
push:
branches: [ main ]
tags:
- 'v*.*.*'
pull_request:
branches: [ main ]
branches: [main]
paths:
- 'Dockerfile*'
- 'src/**'
- 'frontend/**'
- 'package.json'
workflow_dispatch:
inputs:
push:
description: 'Push images to Docker Hub'
required: true
type: boolean
default: false
env:
REGISTRY: docker.io
IMAGE_NAME_MCP: sanjibdevnath/mcp-excalidraw-local
IMAGE_NAME_CANVAS: sanjibdevnath/mcp-excalidraw-local-canvas
jobs:
build-and-push-mcp:
name: Build and Push MCP Server Image
build-mcp:
name: Build MCP Server image
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for MCP Server
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_MCP }}
images: ${{ env.IMAGE_NAME_MCP }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push MCP Server image
- name: Build MCP Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
build-and-push-canvas:
name: Build and Push Canvas Server Image
build-canvas:
name: Build Canvas Server image
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
if: github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Canvas Server
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_CANVAS }}
images: ${{ env.IMAGE_NAME_CANVAS }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Canvas Server image
- name: Build Canvas Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.canvas
push: ${{ github.event_name != 'pull_request' }}
push: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
test-docker-images:
name: Test Docker Images
needs: [build-and-push-mcp, build-and-push-canvas]
if: github.event_name != 'pull_request'
test-images:
name: Test Docker images
needs: [build-mcp, build-canvas]
runs-on: ubuntu-latest
steps:
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v4
- name: Test Canvas Server image
- name: Build and test Canvas image locally
run: |
docker pull ${{ env.IMAGE_NAME_CANVAS }}:latest
docker run -d -p 3000:3000 --name test-canvas ${{ env.IMAGE_NAME_CANVAS }}:latest
sleep 10
curl -f http://localhost:3000/health || exit 1
docker logs test-canvas
docker build -f Dockerfile.canvas -t canvas-test .
docker run -d -p 3000:3000 --name test-canvas canvas-test
HEALTHY=false
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/health > /dev/null 2>&1; then
echo "Health check passed on attempt $i"
HEALTHY=true
break
fi
echo "Waiting for server... ($i/30)"
sleep 1
done
if [ "$HEALTHY" != "true" ]; then
echo "::error::Health check failed after 30 attempts"
docker logs test-canvas
docker stop test-canvas || true
exit 1
fi
curl -sf http://localhost:3000/health | jq .
docker stop test-canvas
- name: Test MCP Server image
run: |
docker pull ${{ env.IMAGE_NAME_MCP }}:latest
echo "MCP Server image pulled successfully"
docker-status:
runs-on: ubuntu-latest
continue-on-error: false
name: Docker Build Status Check
needs: [build-and-push-mcp, build-and-push-canvas]
needs: [build-mcp, build-canvas, test-images]
if: always()
permissions:
statuses: write
+34 -62
View File
@@ -1,26 +1,30 @@
name: Publish to NPM
name: Publish to NPM (manual)
# Fallback for manual publishing. The primary publish path is release.yml.
# Use this for publishing beta/next tags or re-publishing a failed release.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g., latest, beta, next)'
description: 'NPM dist-tag (latest, beta, next)'
required: true
default: 'latest'
type: choice
options:
- latest
- beta
- next
jobs:
publish:
name: Publish to NPM Registry
name: Publish to NPM (${{ github.event.inputs.tag }})
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -32,75 +36,43 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run type check
- name: Type check
run: npm run type-check
- name: Build project
- name: Build
run: npm run build
- name: Unit & integration tests
run: npm test
- name: Verify build artifacts
run: |
echo "Verifying build artifacts..."
test -f dist/index.js || (echo "ERROR: dist/index.js not found" && exit 1)
test -f dist/server.js || (echo "ERROR: dist/server.js not found" && exit 1)
test -d dist/frontend || (echo "ERROR: dist/frontend not found" && exit 1)
echo "All required artifacts present!"
test -f dist/index.js
test -f dist/server.js
test -d dist/frontend
- name: Get package version
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Get version
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- name: Check if version exists on NPM
id: check-version
id: check
run: |
if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.package-version.outputs.version }} version 2>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Version ${{ steps.package-version.outputs.version }} already exists on NPM"
if npm view @sanjibdevnath/mcp-excalidraw-local@${{ steps.version.outputs.version }} version 2>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Version ${{ steps.package-version.outputs.version }} does not exist on NPM"
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to NPM (Release)
if: github.event_name == 'release' && steps.check-version.outputs.exists == 'false'
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to NPM (Manual)
if: github.event_name == 'workflow_dispatch' && steps.check-version.outputs.exists == 'false'
- name: Publish to NPM
if: steps.check.outputs.exists == 'false'
run: npm publish --tag ${{ github.event.inputs.tag }} --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Skip publishing (version exists)
if: steps.check-version.outputs.exists == 'true'
- name: Skip (version exists)
if: steps.check.outputs.exists == 'true'
run: |
echo "⚠️ Skipping publish - version ${{ steps.package-version.outputs.version }} already exists on NPM"
echo "Please bump the version in package.json before publishing"
- name: Create GitHub Release Assets
if: github.event_name == 'release'
run: |
tar -czf mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz dist/
- name: Upload Release Assets
if: github.event_name == 'release'
uses: softprops/action-gh-release@v1
with:
files: |
mcp-excalidraw-local-${{ steps.package-version.outputs.version }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify:
name: Publish Notification
needs: publish
runs-on: ubuntu-latest
if: success()
steps:
- name: Success notification
run: |
echo "✅ Package successfully published to NPM!"
echo "View at: https://www.npmjs.com/package/@sanjibdevnath/mcp-excalidraw-local"
echo "⚠️ Version ${{ steps.version.outputs.version }} already on NPM"
echo "Bump the version in package.json first"
exit 1
+282
View File
@@ -0,0 +1,282 @@
name: Release & Publish
on:
push:
branches: [main]
paths-ignore:
- '*.md'
- 'docs/**'
- '.github/workflows/ci.yml'
permissions:
contents: write
id-token: write
jobs:
# Gate: only release if CI passed and there are releasable commits
check:
name: Check for releasable commits
runs-on: ubuntu-latest
outputs:
bump: ${{ steps.bump.outputs.bump }}
new_version: ${{ steps.bump.outputs.new_version }}
changelog: ${{ steps.bump.outputs.changelog }}
should_release: ${{ steps.bump.outputs.should_release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version bump from conventional commits
id: bump
run: |
# Find the latest semver tag
LATEST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -n1)
if [ -z "$LATEST_TAG" ]; then
LATEST_TAG=$(git rev-list --max-parents=0 HEAD)
echo "No tags found, using first commit"
fi
echo "Latest tag: $LATEST_TAG"
# Get commit messages since last tag
COMMITS=$(git log "$LATEST_TAG"..HEAD --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s")
if [ -z "$COMMITS" ]; then
echo "No new commits since $LATEST_TAG"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Skip if the only commit is a version bump
NON_RELEASE_COMMITS=$(echo "$COMMITS" | grep -v "^chore(release):" || true)
if [ -z "$NON_RELEASE_COMMITS" ]; then
echo "Only release commits found, skipping"
echo "should_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Commits since $LATEST_TAG:"
echo "$COMMITS"
# Determine bump type from conventional commit prefixes
BUMP="patch"
if echo "$COMMITS" | grep -qiE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:"; then
BUMP="major"
elif echo "$COMMITS" | grep -qiE "^(feat|feature)(\(.+\))?:"; then
BUMP="minor"
elif echo "$COMMITS" | grep -qiE "^✨"; then
BUMP="minor"
fi
# Read current version and compute new one
CURRENT=$(node -p "require('./package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
case "$BUMP" in
major) NEW_VERSION="$((MAJOR+1)).0.0" ;;
minor) NEW_VERSION="$MAJOR.$((MINOR+1)).0" ;;
patch) NEW_VERSION="$MAJOR.$MINOR.$((PATCH+1))" ;;
esac
echo "Current: $CURRENT → New: $NEW_VERSION ($BUMP)"
# Build changelog from conventional commits
CHANGELOG=""
BREAKING=$(echo "$COMMITS" | grep -iE "^.*(BREAKING[ -]CHANGE|!)(\(.+\))?:" || true)
FEATURES=$(echo "$COMMITS" | grep -iE "^(feat|feature|✨)" || true)
FIXES=$(echo "$COMMITS" | grep -iE "^(fix|🐛)" || true)
OTHERS=$(echo "$COMMITS" | grep -viE "^(feat|feature|✨|fix|🐛|chore\(release\))" | grep -viE "BREAKING" || true)
if [ -n "$BREAKING" ]; then
CHANGELOG="$CHANGELOG
### ⚠️ Breaking Changes
$(echo "$BREAKING" | sed 's/^/- /')"
fi
if [ -n "$FEATURES" ]; then
CHANGELOG="$CHANGELOG
### ✨ Features
$(echo "$FEATURES" | sed 's/^/- /')"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="$CHANGELOG
### 🐛 Bug Fixes
$(echo "$FIXES" | sed 's/^/- /')"
fi
if [ -n "$OTHERS" ]; then
CHANGELOG="$CHANGELOG
### 📦 Other Changes
$(echo "$OTHERS" | sed 's/^/- /')"
fi
echo "bump=$BUMP" >> "$GITHUB_OUTPUT"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "should_release=true" >> "$GITHUB_OUTPUT"
# Multi-line output for changelog
{
echo "changelog<<CHANGELOG_EOF"
echo "$CHANGELOG"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
test:
name: Pre-release tests
needs: check
if: needs.check.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
- name: Unit & integration tests
run: npm test
release:
name: Version bump & release
needs: [check, test]
if: needs.check.outputs.should_release == 'true'
runs-on: ubuntu-latest
outputs:
version: ${{ needs.check.outputs.new_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump version in package.json
run: |
npm version ${{ needs.check.outputs.new_version }} --no-git-tag-version
git add package.json package-lock.json
git commit -m "chore(release): v${{ needs.check.outputs.new_version }}"
git tag "v${{ needs.check.outputs.new_version }}"
- name: Push version commit and tag
run: |
git push origin main
git push origin "v${{ needs.check.outputs.new_version }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.check.outputs.new_version }}
name: v${{ needs.check.outputs.new_version }}
body: |
## What's Changed in v${{ needs.check.outputs.new_version }}
${{ needs.check.outputs.changelog }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/v${{ needs.check.outputs.new_version }}...v${{ needs.check.outputs.new_version }}
---
```
npm install @sanjibdevnath/mcp-excalidraw-local@${{ needs.check.outputs.new_version }}
```
draft: false
prerelease: false
publish-npm:
name: Publish to NPM
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Check if version already on NPM
id: check-npm
run: |
VERSION=$(node -p "require('./package.json').version")
if npm view @sanjibdevnath/mcp-excalidraw-local@$VERSION version 2>/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to NPM
if: steps.check-npm.outputs.exists == 'false'
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Published
if: steps.check-npm.outputs.exists == 'false'
run: echo "✅ Published v$(node -p "require('./package.json').version") to NPM"
- name: Skipped (already exists)
if: steps.check-npm.outputs.exists == 'true'
run: echo "⚠️ Version already on NPM, skipping"
publish-docker:
name: Build & push Docker images
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.release.outputs.version }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push MCP Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
sanjibdevnath/mcp-excalidraw-local:latest
sanjibdevnath/mcp-excalidraw-local:v${{ needs.release.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Build and push Canvas Server image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.canvas
push: true
tags: |
sanjibdevnath/mcp-excalidraw-local-canvas:latest
sanjibdevnath/mcp-excalidraw-local-canvas:v${{ needs.release.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64