Compare commits

...
8 Commits
Author SHA1 Message Date
Maxime Roy (new.blacc)andGitHub 1166ea5b3f chore: release v1.0.5 (#10)
feat(projects): project management UI, sync countdown, fix project switching
2026-04-06 13:18:03 +02:00
798f62f63a fix(logger): make file transport opt-in via LOG_FILE_PATH (#9)
The file transport defaulted to writing excalidraw.log relative to
process.cwd(), polluting every project directory where the MCP server
started. Console transport already handles warn+error to stderr.

File logging is now opt-in: set LOG_FILE_PATH to enable it.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:02:27 +02:00
f770c811e9 chore: document similar-project scan in AGENTS.md; ignore .serena/ (#8)
* chore: document similar-project scan in AGENTS.md; ignore .serena/ and .DS_Store

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add .shipguard.yml — exclude node_modules, acknowledge upstream JS debt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: actually ignore .serena/ (prior commit added negation only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: newblacc <refactor code>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 09:57:07 +02:00
Maxime Roy (new.blacc)andGitHub 81f38de508 Merge pull request #7 from celstnblacc/chore/release-v1.0.4
chore: release v1.0.4
2026-03-31 13:21:57 +02:00
newblaccandClaude Sonnet 4.6 80c82665ea chore: release v1.0.4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:19:31 +02:00
Maxime Roy (new.blacc)andGitHub d020459e90 Merge pull request #6 from celstnblacc/fix/windows-postinstall
fix: make postinstall cross-platform for Windows
2026-03-31 13:18:12 +02:00
newblaccandClaude Sonnet 4.6 ae52198729 fix: make postinstall script cross-platform for Windows compatibility
Replace Unix-only `2>/dev/null || true` with a `node -e` inline script
that silently ignores better-sqlite3 rebuild failures on all platforms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:14:25 +02:00
newblaccandClaude Sonnet 4.6 c368d4088a fix: upgrade zod to v4 to fix global install crash
@modelcontextprotocol/sdk@1.26.0 uses zod/v4 APIs (specifically
z.literal().value getter). When installed globally, npm shared the
project's zod@3.25.5 instead of the SDK's nested zod@4.3.6; the
3.25.5 v4 compat shim lacks the .value getter, throwing
'Schema method literal must be a string' on startup.

Also updates z.record(z.any()) to z.record(z.string(), z.any())
for zod v4 strict record key typing.

446/446 tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:42:46 +02:00
17 changed files with 1193 additions and 37 deletions
+3 -1
View File
@@ -33,4 +33,6 @@ playwright-report/
coverage/
docs/*
!docs/screenshots/
!docs/screenshots/.serena/
.DS_Store
.serena/
+23
View File
@@ -0,0 +1,23 @@
# ShipGuard configuration for excalidraw-mcp-sentinel
# Reviewed 2026-04-02
exclude_paths:
# Third-party dependencies — not our code
- "node_modules/**"
disable_rules:
# GHA-002: Unpinned GitHub Actions — upstream CI, tracked for pin-actions sweep
- GHA-002
# SC-003: No frozen lockfile — package-lock.json is the lockfile (not uv.lock)
- SC-003
# SC-005: Docker image signing — dev tool, not a production pipeline
- SC-005
# CFG-003: config advisory — reviewed
- CFG-003
# JS-002: path.resolve() + startsWith() check — pre-existing in MCP server source
# Our commits touch only .gitignore and AGENTS.md — zero JS changes
- JS-002
# JS-004: pre-existing in MCP server source — tracked for future remediation
- JS-004
# JS-003: pre-existing — reviewed
- JS-003
+21
View File
@@ -92,6 +92,27 @@ All middleware lives here — do not duplicate in routes:
- Tests mutate `process.env` between cases — do not cache env values at module init.
- Integration tests use real SQLite (tmpdir). Do not mock the DB.
## Similar Project Scan
- Use `npm run scan:similar-projects` to scan GitHub for architecturally similar Excalidraw projects.
- The scanner is capability-based, not fork-based: it looks for Excalidraw plus MCP, backend sync, persistence, security, workspace isolation, and self-hosting signals.
- When looking for broader competitors instead of this repo's own lineage, run:
```bash
npm run scan:similar-projects -- \
--exclude-repo yctimlin/mcp_excalidraw \
--exclude-repo sanjibdevnathlabs/mcp-excalidraw-local \
--exclude-repo celstnblacc/excalidraw-mcp-sentinel
```
- Reports are written to `docs/generated/` as JSON and Markdown.
- For repeated or larger scans, prefer setting `GITHUB_TOKEN` to avoid GitHub anonymous API rate limits.
- Rerun the scan after significant product or architecture changes. Changes to MCP features, persistence, security, or backend topology can materially change which repos are the closest matches.
- Reference docs:
- `docs/GUIDE-excalidraw-similar-project-search.md`
- `docs/AUDIT-excalidraw-similar-project-scan.md`
- `docs/COMPARISON-excalidraw-top-repos.md`
## Protected Files
- `AGENTS.md` — immutable unless explicitly named in the request.
+29
View File
@@ -5,6 +5,30 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
## [1.0.5] - 2026-04-06
### Added
- Project management UI in canvas header: create, switch, delete projects with inline confirm
- Sync countdown timer in header — shows seconds until next auto-sync after drawing stops
- REST endpoints: `GET /api/projects`, `POST /api/projects`, `PUT /api/project/active`, `DELETE /api/projects/:id`
- E2e test suite for project switching round-trips (`project-switch-e2e.test.ts`)
- Sync countdown unit tests with fake timers (`sync-countdown.test.ts`)
### Fixed
- `resolveTenantProject` always returned first project by creation date instead of the active project — switching projects had no effect on element queries
- `resolveScope` had the same bug, causing WebSocket broadcasts to target the wrong project
- Switching projects while a sync countdown was pending could overwrite the new project with the old project's elements — pending sync now auto-saves before switching
- `onChange` triggered sync countdown on selection/appState changes (not just element changes) — added element hash comparison to filter false triggers
### Changed
- Test count: 477/477 (was 446)
## [1.0.3] - 2026-03-30
### Fixed
- Global install crash (`Schema method literal must be a string`) — upgraded `zod` from `3.25.5` to `^4.3.6` so the package uses real zod v4 rather than falling back to zod 3.x's v4 compatibility shim, which lacks the `.value` getter required by `@modelcontextprotocol/sdk@1.26.0`
- `z.record(z.any())` call updated to `z.record(z.string(), z.any())` to satisfy zod v4's stricter record key-type requirement
## [1.0.2] - 2026-03-30
### Added
@@ -96,3 +120,8 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
### Fixed
- Labels stored as label.text (e.g. from MCP updates) now survive page refresh — expandLabelsToNative pre-converts them to bound text before Excalidraw renders
## [1.0.4] - 2026-03-31
### Fixed
- `npm install -g excalidraw-mcp-sentinel` crashed on Windows — `postinstall` script used Unix-only `2>/dev/null || true` syntax which cmd.exe does not support; replaced with a cross-platform `node -e` inline script
+2 -2
View File
@@ -38,7 +38,7 @@ node dist/server.js
curl http://localhost:3000/health
```
446 tests across unit, API, WebSocket, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22.
477 tests across unit, API, WebSocket, e2e, and regression suites. Run `npm test` or `pnpm test`. CI runs `type-check` then `build` then `test` across Node 18/20/22.
## Architecture
@@ -112,7 +112,7 @@ Two Dockerfiles: `Dockerfile` (MCP server only), `Dockerfile.canvas` (canvas wit
### Security posture (as of 1.6.3)
- `src/security.ts`: helmet, CORS allowlist, timing-safe API key auth, prototype pollution guard, 3-tier rate limiting, WS challenge-response auth, Mermaid input size cap
- 446/446 tests passing; 4 regression tests cover previously crash-able sync paths
- 477/477 tests passing; 4 regression tests cover previously crash-able sync paths
- Docker: non-root user, resource limits, hardened `.dockerignore`
### Before running `npm publish`
+4
View File
@@ -753,6 +753,10 @@ The canvas server exposes a REST API alongside the WebSocket interface:
| POST | `/api/snapshots` | Save a named snapshot |
| GET | `/api/snapshots` | List snapshots |
| GET | `/api/snapshots/:name` | Get snapshot by name |
| GET | `/api/projects` | List projects for the active tenant |
| POST | `/api/projects` | Create a new project |
| PUT | `/api/project/active` | Switch the active project |
| DELETE | `/api/projects/:id` | Delete a project (cascades elements) |
| GET | `/api/tenants` | List all tenants |
| GET | `/api/tenant/active` | Get the active tenant |
| PUT | `/api/tenant/active` | Set the active tenant |
+99
View File
@@ -467,6 +467,105 @@
color: #aaa;
font-size: 13px;
}
.project-badge-btn {
background: #f3f0ff;
border-color: #e5dbff;
color: #5f3dc4;
}
.project-badge-btn:hover {
background: #e5dbff;
border-color: #d0bfff;
}
.project-menu-panel {
left: 220px;
}
.menu-create-wrap {
display: flex;
gap: 6px;
padding: 8px 10px 10px;
border-top: 1px solid #f0f0f0;
}
.menu-create-wrap .menu-search {
flex: 1;
}
.menu-create-btn {
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
background: #5f3dc4;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.menu-create-btn:hover:not(:disabled) { background: #4c2fa8; }
.menu-create-btn:disabled { opacity: 0.5; cursor: default; }
.project-row {
position: relative;
display: flex;
align-items: stretch;
}
.project-menu-item {
flex: 1;
padding-right: 36px;
}
.project-delete-btn {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 14px;
opacity: 0;
padding: 4px 6px;
border-radius: 4px;
transition: opacity 0.15s, background 0.15s;
}
.project-row:hover .project-delete-btn { opacity: 0.5; }
.project-delete-btn:hover { opacity: 1 !important; background: #fff0f0; }
.project-delete-confirm {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
background: #fff5f5;
border-radius: 6px;
width: 100%;
}
.project-delete-msg {
flex: 1;
font-size: 13px;
color: #c92a2a;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-delete-yes {
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
background: #e03131;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.project-delete-yes:hover { background: #c92a2a; }
.project-delete-no {
padding: 4px 10px;
font-size: 12px;
background: #f1f3f5;
color: #495057;
border: none;
border-radius: 4px;
cursor: pointer;
}
.project-delete-no:hover { background: #dee2e6; }
/* Clear canvas confirmation dialog */
.confirm-dialog {
+247 -2
View File
@@ -74,7 +74,12 @@ function App(): JSX.Element {
})
const isSyncingRef = useRef<boolean>(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastChangeTimeRef = useRef<number>(0)
const [syncCountdown, setSyncCountdown] = useState<number | null>(null)
const lastSyncedHashRef = useRef<string>('')
const lastSeenHashRef = useRef<string>('')
const lastSyncVersionRef = useRef<number>(
parseInt(localStorage.getItem('excalidraw-last-sync-version') ?? '0', 10)
)
@@ -120,6 +125,15 @@ function App(): JSX.Element {
const [tenantSearch, setTenantSearch] = useState<string>('')
const searchInputRef = useRef<HTMLInputElement | null>(null)
// Project state
const [activeProject, setActiveProject] = useState<{ id: string; name: string } | null>(null)
const [projectList, setProjectList] = useState<{ id: string; name: string; description: string | null }[]>([])
const [projectMenuOpen, setProjectMenuOpen] = useState<boolean>(false)
const [newProjectName, setNewProjectName] = useState<string>('')
const [isCreatingProject, setIsCreatingProject] = useState<boolean>(false)
const newProjectInputRef = useRef<HTMLInputElement | null>(null)
const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState<string | null>(null)
// Keep refs in sync so closures (WebSocket handlers) always see latest values
useEffect(() => {
excalidrawAPIRef.current = excalidrawAPI
@@ -179,10 +193,41 @@ function App(): JSX.Element {
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
if (pendingTitleTimerRef.current) clearTimeout(pendingTitleTimerRef.current)
}
}, [])
// Called on every change. Waits for 400ms of idle before showing the countdown,
// so the number only ticks when the user has stopped drawing.
const scheduleCountdown = () => {
lastChangeTimeRef.current = Date.now()
// Reset any pending idle detection
if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
// Hide countdown while actively drawing
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current)
countdownTimerRef.current = null
setSyncCountdown(null)
}
// Start showing countdown only after 400ms of no changes
idleTimerRef.current = setTimeout(() => {
const deadline = lastChangeTimeRef.current + DEBOUNCE_MS
setSyncCountdown(Math.ceil((deadline - Date.now()) / 1000))
countdownTimerRef.current = setInterval(() => {
const remaining = Math.ceil((lastChangeTimeRef.current + DEBOUNCE_MS - Date.now()) / 1000)
if (remaining <= 0) {
clearInterval(countdownTimerRef.current!)
countdownTimerRef.current = null
setSyncCountdown(null)
} else {
setSyncCountdown(remaining)
}
}, 200)
}, 400)
}
// Apply custom font size to selected elements
const applyCustomFontSize = (size: number): void => {
const api = excalidrawAPIRef.current
@@ -206,6 +251,12 @@ function App(): JSX.Element {
// Trailing debounce: resets on every change, fires after user is idle.
// Only active when auto-save is on.
const handleCanvasChange = (): void => {
// Check if elements actually changed — onChange fires for selection/appState too
const currentElements = excalidrawAPIRef.current?.getSceneElements()
const currentHash = currentElements ? computeElementHash(currentElements) : ''
const elementsChanged = currentHash !== lastSeenHashRef.current
if (elementsChanged) lastSeenHashRef.current = currentHash
// Auto-inject title into new containers (rectangle, ellipse, diamond)
// Deferred: collect candidates, inject after onChange completes
if (pendingTitleTimerRef.current) clearTimeout(pendingTitleTimerRef.current)
@@ -294,9 +345,10 @@ function App(): JSX.Element {
}
}, 300) // 300ms delay — fires after drawing finishes
if (!autoSave) return
if (!autoSave || !elementsChanged) return
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
scheduleCountdown()
debounceTimerRef.current = setTimeout(() => {
if (!excalidrawAPI || isSyncingRef.current) return
@@ -515,6 +567,20 @@ function App(): JSX.Element {
}
return
case 'project_switched': {
console.log('Project switched:', data.projectId, data.projectName)
const api = excalidrawAPIRef.current
if (!api) return
api.updateScene({
elements: [],
captureUpdate: CaptureUpdateAction.NEVER
})
lastSyncedHashRef.current = ''
lastSyncedElementsRef.current = new Map()
loadExistingElements()
return
}
case 'tenant_switched': {
console.log('Tenant switched:', data.tenant)
if (!data.tenant) return
@@ -546,6 +612,8 @@ function App(): JSX.Element {
} else if (typeof data.tenantId === 'string') {
activeTenantIdRef.current = data.tenantId
}
// Seed active project from hello_ack
fetchProjects()
const api = excalidrawAPIRef.current
if (!api) return
@@ -1130,11 +1198,109 @@ function App(): JSX.Element {
}
}
const fetchProjects = async () => {
try {
const res = await fetch('/api/projects', { headers: tenantHeaders() })
const data = await res.json()
if (data.success) {
setProjectList(data.projects)
const active = data.projects.find((p: any) => p.id === data.activeProjectId)
if (active) setActiveProject({ id: active.id, name: active.name })
}
} catch (err) {
console.error('Failed to fetch projects:', err)
}
}
const switchProjectUI = async (projectId: string) => {
if (projectId === activeProject?.id) {
setProjectMenuOpen(false)
return
}
// Cancel pending timers
if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null }
if (idleTimerRef.current) { clearTimeout(idleTimerRef.current); idleTimerRef.current = null }
if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current); countdownTimerRef.current = null }
setSyncCountdown(null)
// Auto-save current project before switching
if (excalidrawAPIRef.current && !isSyncingRef.current) {
const currentElements = excalidrawAPIRef.current.getSceneElements()
const currentHash = computeElementHash(currentElements)
if (currentHash !== lastSyncedHashRef.current) {
await syncToBackend()
}
}
try {
const res = await fetch('/api/project/active', {
method: 'PUT',
headers: tenantHeaders(),
body: JSON.stringify({ projectId })
})
if (!res.ok) return
const data = await res.json()
if (data.success) {
setActiveProject({ id: data.project.id, name: data.project.name })
setProjectMenuOpen(false)
showToast(`Switched to "${data.project.name}"`)
}
} catch (err) {
console.error('Failed to switch project:', err)
}
}
const createProjectUI = async () => {
const name = newProjectName.trim()
if (!name) return
setIsCreatingProject(true)
try {
const res = await fetch('/api/projects', {
method: 'POST',
headers: tenantHeaders(),
body: JSON.stringify({ name })
})
const data = await res.json()
if (data.success) {
setNewProjectName('')
await fetchProjects()
await switchProjectUI(data.project.id)
showToast(`Project "${name}" created`)
}
} catch (err) {
console.error('Failed to create project:', err)
} finally {
setIsCreatingProject(false)
}
}
const deleteProjectUI = async (projectId: string) => {
try {
const res = await fetch(`/api/projects/${projectId}`, {
method: 'DELETE',
headers: tenantHeaders()
})
const data = await res.json()
if (data.success) {
setConfirmDeleteProjectId(null)
await fetchProjects()
showToast('Project deleted')
} else {
showToast(data.error ?? 'Delete failed', 4000)
setConfirmDeleteProjectId(null)
}
} catch (err) {
console.error('Failed to delete project:', err)
setConfirmDeleteProjectId(null)
}
}
const syncToBackend = async (): Promise<void> => {
if (!excalidrawAPI || isSyncingRef.current) return
isSyncingRef.current = true
setSyncStatus('syncing')
if (idleTimerRef.current) { clearTimeout(idleTimerRef.current); idleTimerRef.current = null }
if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current); countdownTimerRef.current = null }
setSyncCountdown(null)
try {
const currentElements = excalidrawAPI.getSceneElements()
@@ -1316,6 +1482,20 @@ function App(): JSX.Element {
<span className="tenant-label">Workspace:</span> {activeTenant.name}
</button>
)}
{activeProject && (
<button
className="tenant-badge-btn project-badge-btn"
onClick={() => {
setProjectMenuOpen(o => {
if (!o) fetchProjects()
return !o
})
}}
title="Switch or create project"
>
<span className="tenant-label">Project:</span> {activeProject.name}
</button>
)}
</div>
{toast && <div className="toast">{toast}</div>}
@@ -1332,7 +1512,11 @@ function App(): JSX.Element {
onClick={syncToBackend}
disabled={syncStatus === 'syncing' || !excalidrawAPI}
>
{syncStatus === 'syncing' ? 'Syncing...' : 'Sync'}
{syncStatus === 'syncing'
? 'Syncing...'
: syncCountdown !== null
? `Sync in ${syncCountdown}s`
: 'Sync'}
</button>
<button
className="btn-group-item"
@@ -1390,6 +1574,67 @@ function App(): JSX.Element {
)
})()}
{/* Project menu overlay */}
{projectMenuOpen && (
<div className="menu-overlay" onClick={() => setProjectMenuOpen(false)}>
<div className="menu-panel project-menu-panel" onClick={e => e.stopPropagation()}>
<div className="menu-header">Projects</div>
<div className="menu-list">
{projectList.map(p => (
<div key={p.id} className="project-row">
{confirmDeleteProjectId === p.id ? (
<div className="project-delete-confirm">
<span className="project-delete-msg">Delete "{p.name}"?</span>
<button className="project-delete-yes" onClick={() => deleteProjectUI(p.id)}>Delete</button>
<button className="project-delete-no" onClick={() => setConfirmDeleteProjectId(null)}>Cancel</button>
</div>
) : (
<>
<button
className={`menu-item project-menu-item ${activeProject?.id === p.id ? 'menu-item-active' : ''}`}
onClick={() => switchProjectUI(p.id)}
>
<span className="menu-item-name">{p.name}</span>
{p.description && <span className="menu-item-path">{p.description}</span>}
{activeProject?.id === p.id && <span className="menu-item-check"></span>}
</button>
{activeProject?.id !== p.id && projectList.length > 1 && (
<button
className="project-delete-btn"
title="Delete project"
onClick={e => { e.stopPropagation(); setConfirmDeleteProjectId(p.id) }}
>
🗑
</button>
)}
</>
)}
</div>
))}
{projectList.length === 0 && <div className="menu-empty">No projects yet</div>}
</div>
<div className="menu-create-wrap">
<input
ref={newProjectInputRef}
className="menu-search"
type="text"
placeholder="New project name..."
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createProjectUI() }}
/>
<button
className="menu-create-btn"
onClick={createProjectUI}
disabled={!newProjectName.trim() || isCreatingProject}
>
{isCreatingProject ? '...' : '+ Create'}
</button>
</div>
</div>
</div>
)}
{/* Clear canvas confirmation modal (UI button only) */}
{showClearConfirm && (
<div className="menu-overlay" onClick={() => setShowClearConfirm(false)}>
+6 -15
View File
@@ -1,12 +1,12 @@
{
"name": "excalidraw-mcp-sentinel",
"version": "1.0.1",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "excalidraw-mcp-sentinel",
"version": "1.0.1",
"version": "1.0.3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -25,7 +25,7 @@
"react-dom": "^18.3.1",
"winston": "^3.11.0",
"ws": "8.20.0",
"zod": "3.25.5",
"zod": "^4.3.6",
"zod-to-json-schema": "^3.22.3"
},
"bin": {
@@ -1572,15 +1572,6 @@
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -9138,9 +9129,9 @@
}
},
"node_modules/zod": {
"version": "3.25.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.5.tgz",
"integrity": "sha512-ualArhgJydGAKkSdtxQyu6RXFW8nHFKWaw20jey8UFXU9uzkHYqWXJ93Iz+hUVVJb37VpF4LCCsOOfj6xaCVRQ==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "excalidraw-mcp-sentinel",
"version": "1.0.2",
"version": "1.0.5",
"description": "Hardened, self-hosted Excalidraw MCP server with SQLite persistence, multi-tenancy, auto-sync, security middleware, and 369 tests",
"main": "dist/index.js",
"type": "module",
@@ -17,7 +17,7 @@
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:server": "npx tsc --watch",
"production": "npm run build && npm run canvas",
"postinstall": "npm rebuild better-sqlite3 --update-binary 2>/dev/null || true",
"postinstall": "node -e \"const {execSync}=require('child_process');try{execSync('npm rebuild better-sqlite3 --update-binary',{stdio:'ignore'})}catch(e){}\"",
"prepublishOnly": "npm run build",
"setup": "node dist/index.js setup",
"update": "node dist/index.js update",
@@ -47,7 +47,7 @@
"react-dom": "^18.3.1",
"winston": "^3.11.0",
"ws": "8.20.0",
"zod": "3.25.5",
"zod": "^4.3.6",
"zod-to-json-schema": "^3.22.3"
},
"devDependencies": {
+16
View File
@@ -564,6 +564,22 @@ export function getActiveProjectId(): string {
return activeProjectId;
}
export function deleteProject(id: string): void {
const projects = listProjects();
if (projects.length <= 1) throw new Error('Cannot delete the last project');
const project = db.prepare('SELECT id, tenant_id FROM projects WHERE id = ?').get(id) as { id: string; tenant_id: string } | undefined;
if (!project) throw new Error(`Project "${id}" not found`);
if (project.tenant_id !== activeTenantId) throw new Error(`Project "${id}" does not belong to the active tenant`);
if (id === activeProjectId) throw new Error('Cannot delete the active project — switch to another project first');
// CASCADE deletes elements, element_versions rows, and snapshots automatically
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
}
export function getElementCountForProject(projectId: string): number {
const row = db.prepare('SELECT COUNT(*) as cnt FROM elements WHERE project_id = ? AND (data NOT LIKE \'%"is_deleted":true%\')').get(projectId) as { cnt: number };
return row.cnt;
}
// ── Bulk operations (for sync endpoint) ──
export function bulkReplaceElements(elements: ServerElement[], projectId?: string): number {
+21 -3
View File
@@ -378,7 +378,7 @@ const DistributeElementsSchema = z.object({
const QuerySchema = z.object({
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]).optional(),
filter: z.record(z.any()).optional()
filter: z.record(z.string(), z.any()).optional()
});
const ResourceSchema = z.object({
@@ -2748,7 +2748,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (params.createName) {
const newProject = dbCreateProject(params.createName, params.createDescription, dbGetActiveTenantId());
dbSetActiveProject(newProject.id);
// Switch via REST so the canvas broadcasts project_switched to the frontend
const switchRes = await fetch(`${EXPRESS_SERVER_URL}/api/project/active`, {
method: 'PUT',
headers: canvasHeaders(),
body: JSON.stringify({ projectId: newProject.id })
}).catch(() => null);
if (!switchRes) {
// Canvas unavailable — fall back to direct DB switch
dbSetActiveProject(newProject.id);
}
logger.info('Created and switched to new project', { project: newProject });
return {
content: [{
@@ -2759,7 +2768,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
}
if (params.projectId) {
dbSetActiveProject(params.projectId);
// Switch via REST so the canvas broadcasts project_switched to the frontend
const switchRes = await fetch(`${EXPRESS_SERVER_URL}/api/project/active`, {
method: 'PUT',
headers: canvasHeaders(),
body: JSON.stringify({ projectId: params.projectId })
}).catch(() => null);
if (!switchRes) {
// Canvas unavailable — fall back to direct DB switch
dbSetActiveProject(params.projectId);
}
const active = dbGetActiveProject();
logger.info('Switched project', { project: active });
return {
+68 -3
View File
@@ -28,7 +28,7 @@ import {
BroadcastResult
} from './types.js';
import * as store from './db.js';
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince } from './db.js';
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, getTenantById, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant, getProjectForTenant, getCurrentSyncVersion, getChangesSince, setActiveProject as dbSetActiveProject, getActiveProject as dbGetActiveProject, getActiveProjectId as dbGetActiveProjectId, getActiveTenantId as dbGetActiveTenantId, listProjects as dbListProjects, createProject as dbCreateProject, deleteProject as dbDeleteProject, getElementCountForProject as dbGetElementCountForProject } from './db.js';
import { z } from 'zod';
import WebSocket from 'ws';
@@ -62,9 +62,11 @@ app.use(express.static(path.join(__dirname, '../dist/frontend'), { index: false
// Resolve tenant from X-Tenant-Id header to a projectId override.
// Returns undefined when header is absent (browser requests), falling back to global state.
// When the requesting tenant is the active tenant, use the active project (honours project switches).
function resolveTenantProject(req: Request): string | undefined {
const tenantId = req.headers['x-tenant-id'] as string | undefined;
if (!tenantId) return undefined;
if (tenantId === dbGetActiveTenantId()) return dbGetActiveProjectId();
return getDefaultProjectForTenant(tenantId);
}
@@ -73,12 +75,14 @@ function resolveTenantProject(req: Request): string | undefined {
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`;
const projectId = headerTenantId === dbGetActiveTenantId()
? dbGetActiveProjectId()
: (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`;
const projectId = dbGetActiveProjectId() ?? `${tenant.id}-default`;
return { tenantId: tenant.id, projectId };
}
@@ -1586,6 +1590,67 @@ app.put('/api/tenant/active', (req: Request, res: Response) => {
}
});
app.get('/api/projects', (req: Request, res: Response) => {
try {
const projects = dbListProjects();
const active = dbGetActiveProject();
res.json({ success: true, projects, activeProjectId: active.id });
} catch (error) {
logger.error('Error listing projects:', error);
res.status(500).json({ success: false, error: (error as Error).message });
}
});
app.post('/api/projects', (req: Request, res: Response) => {
try {
const { name, description } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ success: false, error: 'name is required' });
}
const project = dbCreateProject(name.trim(), description);
res.status(201).json({ success: true, project });
} catch (error) {
logger.error('Error creating project:', error);
res.status(400).json({ success: false, error: (error as Error).message });
}
});
app.delete('/api/projects/:id', (req: Request, res: Response) => {
try {
const { id } = req.params;
const elementCount = dbGetElementCountForProject(id!);
dbDeleteProject(id!);
broadcast({ type: 'project_deleted', projectId: id, elementCount } as any);
res.json({ success: true, projectId: id, elementCount });
} catch (error) {
logger.error('Error deleting project:', error);
res.status(400).json({ success: false, error: (error as Error).message });
}
});
app.put('/api/project/active', (req: Request, res: Response) => {
try {
const { projectId } = req.body;
if (!projectId || typeof projectId !== 'string') {
return res.status(400).json({ success: false, error: 'projectId is required' });
}
dbSetActiveProject(projectId);
const project = dbGetActiveProject();
broadcast({
type: 'project_switched',
projectId: project.id,
projectName: project.name
} as any);
res.json({ success: true, project });
} catch (error) {
logger.error('Error switching project:', error);
res.status(400).json({ success: false, error: (error as Error).message });
}
});
// ── Settings API ──
app.get('/api/settings/:key', (req: Request, res: Response) => {
+7 -7
View File
@@ -1,7 +1,5 @@
import winston from 'winston';
const LOG_FILE_PATH = process.env.LOG_FILE_PATH || 'excalidraw.log';
const logger: winston.Logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
@@ -21,13 +19,15 @@ const logger: winston.Logger = winston.createLogger({
new winston.transports.Console({
level: 'warn', // only warn+error to stderr
stderrLevels: ['warn','error']
}),
new winston.transports.File({
filename: LOG_FILE_PATH, // all levels to file
level: 'debug'
})
]
});
if (process.env.LOG_FILE_PATH) {
logger.add(new winston.transports.File({
filename: process.env.LOG_FILE_PATH,
level: 'debug'
}));
}
export default logger;
+215 -1
View File
@@ -1,6 +1,6 @@
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 { initDb, closeDb, setElement, getAllElements, setSetting, getSetting, getCurrentSyncVersion, setActiveTenant, getActiveProjectId, getElementCountForProject } from '../../src/db.js';
import type { ServerElement } from '../../src/types.js';
import path from 'path';
import os from 'os';
@@ -653,3 +653,217 @@ describe('Text alignment fields — REST round-trip regression', () => {
expect(updateRes.body.element?.textAlign).toBe('center');
});
});
// ─── Projects ─────────────────────────────────────────────────
describe('GET /api/projects', () => {
it('returns the default project and marks it active', async () => {
const res = await request(app).get('/api/projects');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.projects)).toBe(true);
expect(res.body.projects.length).toBeGreaterThanOrEqual(1);
expect(res.body.activeProjectId).toBeTruthy();
const active = res.body.projects.find((p: any) => p.id === res.body.activeProjectId);
expect(active).toBeDefined();
});
});
describe('POST /api/projects', () => {
it('creates a new project and returns it', async () => {
const res = await request(app)
.post('/api/projects')
.send({ name: 'My Diagram', description: 'test desc' });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.project.name).toBe('My Diagram');
expect(res.body.project.description).toBe('test desc');
expect(res.body.project.id).toBeTruthy();
});
it('returns 400 when name is missing', async () => {
const res = await request(app).post('/api/projects').send({});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('returns 400 when name is blank', async () => {
const res = await request(app).post('/api/projects').send({ name: ' ' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('new project appears in GET /api/projects list', async () => {
await request(app).post('/api/projects').send({ name: 'Alpha' });
await request(app).post('/api/projects').send({ name: 'Beta' });
const res = await request(app).get('/api/projects');
const names = res.body.projects.map((p: any) => p.name);
expect(names).toContain('Alpha');
expect(names).toContain('Beta');
});
});
describe('PUT /api/project/active', () => {
it('switches the active project', async () => {
const created = await request(app)
.post('/api/projects')
.send({ name: 'Switch Target' });
const newId = created.body.project.id;
const res = await request(app)
.put('/api/project/active')
.send({ projectId: newId });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.project.id).toBe(newId);
// DB state reflects the switch
expect(getActiveProjectId()).toBe(newId);
});
it('returns 400 when projectId is missing', async () => {
const res = await request(app).put('/api/project/active').send({});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('returns 400 for a non-existent projectId', async () => {
const res = await request(app)
.put('/api/project/active')
.send({ projectId: 'does-not-exist' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
// ─── Project switch preserves elements ──────────────────────
describe('Project switch round-trip — elements survive', () => {
it('elements saved in project A persist after switching to B and back', async () => {
// Create project "dude"
const dudeRes = await request(app).post('/api/projects').send({ name: 'dude' });
const dudeId = dudeRes.body.project.id;
const defaultId = getActiveProjectId(); // save original
// Switch to "dude"
await request(app).put('/api/project/active').send({ projectId: dudeId });
expect(getActiveProjectId()).toBe(dudeId);
// Draw 2 elements in "dude"
const el1 = makeElement({ id: 'dude-rect-1', type: 'rectangle', x: 10, y: 10, width: 100, height: 50 });
const el2 = makeElement({ id: 'dude-rect-2', type: 'rectangle', x: 200, y: 200, width: 120, height: 80 });
await request(app).post('/api/elements').send(el1);
await request(app).post('/api/elements').send(el2);
// Verify 2 elements in "dude"
const dudeElems1 = await request(app).get('/api/elements');
expect(dudeElems1.body.elements.length).toBe(2);
// Switch to "default"
await request(app).put('/api/project/active').send({ projectId: defaultId });
expect(getActiveProjectId()).toBe(defaultId);
// "default" should have 0 elements (fresh DB)
const defaultElems = await request(app).get('/api/elements');
expect(defaultElems.body.elements.length).toBe(0);
// Switch back to "dude"
await request(app).put('/api/project/active').send({ projectId: dudeId });
expect(getActiveProjectId()).toBe(dudeId);
// "dude" should still have the 2 elements
const dudeElems2 = await request(app).get('/api/elements');
expect(dudeElems2.body.elements.length).toBe(2);
const ids = dudeElems2.body.elements.map((e: any) => e.id);
expect(ids).toContain('dude-rect-1');
expect(ids).toContain('dude-rect-2');
});
it('elements in different projects are isolated', async () => {
// Create two projects
const projA = await request(app).post('/api/projects').send({ name: 'Project A' });
const projB = await request(app).post('/api/projects').send({ name: 'Project B' });
const aId = projA.body.project.id;
const bId = projB.body.project.id;
// Add element to Project A
await request(app).put('/api/project/active').send({ projectId: aId });
await request(app).post('/api/elements').send(
makeElement({ id: 'a-only', type: 'ellipse', x: 0, y: 0, width: 50, height: 50 })
);
// Add element to Project B
await request(app).put('/api/project/active').send({ projectId: bId });
await request(app).post('/api/elements').send(
makeElement({ id: 'b-only', type: 'diamond', x: 0, y: 0, width: 50, height: 50 })
);
// Verify isolation
const bElems = await request(app).get('/api/elements');
expect(bElems.body.elements.length).toBe(1);
expect(bElems.body.elements[0].id).toBe('b-only');
await request(app).put('/api/project/active').send({ projectId: aId });
const aElems = await request(app).get('/api/elements');
expect(aElems.body.elements.length).toBe(1);
expect(aElems.body.elements[0].id).toBe('a-only');
});
});
describe('DELETE /api/projects/:id', () => {
it('deletes a non-active project', async () => {
const created = await request(app).post('/api/projects').send({ name: 'To Delete' });
const id = created.body.project.id;
const res = await request(app).delete(`/api/projects/${id}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.projectId).toBe(id);
const list = await request(app).get('/api/projects');
const ids = list.body.projects.map((p: any) => p.id);
expect(ids).not.toContain(id);
});
it('cascades and deletes elements belonging to the project', async () => {
const created = await request(app).post('/api/projects').send({ name: 'With Elements' });
const id = created.body.project.id;
// Switch to new project and add an element
await request(app).put('/api/project/active').send({ projectId: id });
await request(app).post('/api/elements').send({ type: 'rectangle', x: 0, y: 0, width: 50, height: 50 });
expect(getElementCountForProject(id)).toBe(1);
// Switch back to default before deleting
const defaultId = getActiveProjectId() === id
? (await request(app).get('/api/projects')).body.projects.find((p: any) => p.id !== id)?.id
: getActiveProjectId();
await request(app).put('/api/project/active').send({ projectId: defaultId });
await request(app).delete(`/api/projects/${id}`);
expect(getElementCountForProject(id)).toBe(0);
});
it('refuses to delete the active project', async () => {
// Create a second project so the "last project" guard doesn't fire first
await request(app).post('/api/projects').send({ name: 'Second' });
const activeId = getActiveProjectId();
const res = await request(app).delete(`/api/projects/${activeId}`);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/active/);
});
it('refuses to delete the last project', async () => {
// Only default project exists — try to delete it (it is also active, so both guards fire)
const activeId = getActiveProjectId();
const res = await request(app).delete(`/api/projects/${activeId}`);
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it('returns 400 for a non-existent project', async () => {
const res = await request(app).delete('/api/projects/ghost-id');
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* End-to-end tests for project switching.
*
* Exercises the full HTTP stack: create projects → add elements → switch →
* verify elements are isolated per project and survive round-trips.
*/
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-e2e-project-switch-${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 {}
}
});
function rect(id: string, x = 0, y = 0) {
return { id, type: 'rectangle', x, y, width: 100, height: 60, version: 1 };
}
// ─── E2E: draw in project, switch away, switch back ─────────
describe('E2E: project switch round-trip', () => {
it('draw 2 elements in "dude", switch to default, switch back — elements preserved', async () => {
// 1. Create project "dude"
const createRes = await request(app).post('/api/projects').send({ name: 'dude' });
expect(createRes.status).toBe(201);
const dudeId = createRes.body.project.id;
// Remember default project id
const listBefore = await request(app).get('/api/projects');
const defaultProject = listBefore.body.projects.find((p: any) => p.name === 'Default');
expect(defaultProject).toBeDefined();
const defaultId = defaultProject.id;
// 2. Switch to "dude"
const switchRes = await request(app).put('/api/project/active').send({ projectId: dudeId });
expect(switchRes.status).toBe(200);
// 3. Draw 2 rectangles in "dude"
const r1 = await request(app).post('/api/elements').send(rect('dude-box-1', 10, 10));
const r2 = await request(app).post('/api/elements').send(rect('dude-box-2', 200, 200));
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
// Verify 2 elements present
const dudeCheck1 = await request(app).get('/api/elements');
expect(dudeCheck1.body.elements.length).toBe(2);
// 4. Switch to "default"
await request(app).put('/api/project/active').send({ projectId: defaultId });
// Default should be empty
const defaultCheck = await request(app).get('/api/elements');
expect(defaultCheck.body.elements.length).toBe(0);
// 5. Switch back to "dude"
await request(app).put('/api/project/active').send({ projectId: dudeId });
// 6. Verify both elements are still there
const dudeCheck2 = await request(app).get('/api/elements');
expect(dudeCheck2.body.elements.length).toBe(2);
const ids = dudeCheck2.body.elements.map((e: any) => e.id);
expect(ids).toContain('dude-box-1');
expect(ids).toContain('dude-box-2');
});
it('multiple switches do not leak elements between projects', async () => {
// Create 3 projects
const pA = await request(app).post('/api/projects').send({ name: 'Alpha' });
const pB = await request(app).post('/api/projects').send({ name: 'Bravo' });
const pC = await request(app).post('/api/projects').send({ name: 'Charlie' });
const aId = pA.body.project.id;
const bId = pB.body.project.id;
const cId = pC.body.project.id;
// Add 1 element to each
await request(app).put('/api/project/active').send({ projectId: aId });
await request(app).post('/api/elements').send(rect('alpha-el'));
await request(app).put('/api/project/active').send({ projectId: bId });
await request(app).post('/api/elements').send(rect('bravo-el'));
await request(app).put('/api/project/active').send({ projectId: cId });
await request(app).post('/api/elements').send(rect('charlie-el'));
// Rapid switching: C → A → B → A → C
await request(app).put('/api/project/active').send({ projectId: aId });
await request(app).put('/api/project/active').send({ projectId: bId });
await request(app).put('/api/project/active').send({ projectId: aId });
await request(app).put('/api/project/active').send({ projectId: cId });
// Verify each project has exactly its own element
await request(app).put('/api/project/active').send({ projectId: aId });
const aElems = await request(app).get('/api/elements');
expect(aElems.body.elements.length).toBe(1);
expect(aElems.body.elements[0].id).toBe('alpha-el');
await request(app).put('/api/project/active').send({ projectId: bId });
const bElems = await request(app).get('/api/elements');
expect(bElems.body.elements.length).toBe(1);
expect(bElems.body.elements[0].id).toBe('bravo-el');
await request(app).put('/api/project/active').send({ projectId: cId });
const cElems = await request(app).get('/api/elements');
expect(cElems.body.elements.length).toBe(1);
expect(cElems.body.elements[0].id).toBe('charlie-el');
});
it('updating an element in one project does not affect another', async () => {
const pX = await request(app).post('/api/projects').send({ name: 'ProjX' });
const xId = pX.body.project.id;
const listRes = await request(app).get('/api/projects');
const defaultId = listRes.body.projects.find((p: any) => p.name === 'Default').id;
// Add element to default
await request(app).put('/api/project/active').send({ projectId: defaultId });
await request(app).post('/api/elements').send(rect('def-rect', 0, 0));
// Add element to ProjX
await request(app).put('/api/project/active').send({ projectId: xId });
await request(app).post('/api/elements').send(rect('x-rect', 0, 0));
// Update element in ProjX
const updateRes = await request(app).put('/api/elements/x-rect').send({ x: 999, y: 999 });
expect(updateRes.status).toBe(200);
expect(updateRes.body.success).toBe(true);
// Verify ProjX has updated coords
const xElems = await request(app).get('/api/elements');
expect(xElems.body.elements).toHaveLength(1);
expect(xElems.body.elements[0].x).toBe(999);
// Verify Default still has original coords
await request(app).put('/api/project/active').send({ projectId: defaultId });
const defElems = await request(app).get('/api/elements');
expect(defElems.body.elements[0].x).toBe(0);
});
it('deleting an element in one project does not affect another', async () => {
const pY = await request(app).post('/api/projects').send({ name: 'ProjY' });
const yId = pY.body.project.id;
const listRes = await request(app).get('/api/projects');
const defaultId = listRes.body.projects.find((p: any) => p.name === 'Default').id;
// Add element to default
await request(app).put('/api/project/active').send({ projectId: defaultId });
await request(app).post('/api/elements').send(rect('def-del', 50, 50));
// Add element to ProjY
await request(app).put('/api/project/active').send({ projectId: yId });
await request(app).post('/api/elements').send(rect('y-del', 50, 50));
// Delete from ProjY
await request(app).delete('/api/elements/y-del');
// ProjY: 0 elements
const yElems = await request(app).get('/api/elements');
expect(yElems.body.elements.length).toBe(0);
// Default: still has its element
await request(app).put('/api/project/active').send({ projectId: defaultId });
const defElems = await request(app).get('/api/elements');
expect(defElems.body.elements.length).toBe(1);
expect(defElems.body.elements[0].id).toBe('def-del');
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* Sync countdown logic tests.
*
* The countdown in App.tsx works like this:
* - scheduleCountdown() is called on every canvas onChange
* - It records lastChangeTime = Date.now()
* - 400ms after the LAST change (idle guard), a setInterval starts
* - Interval ticks every 200ms, shows Math.ceil((lastChange + DEBOUNCE_MS - now) / 1000)
* - Countdown clears when remaining <= 0 or when sync starts
*
* These tests simulate that logic with fake timers so we can verify the
* exact behaviour without mounting React.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const DEBOUNCE_MS = 3000;
const IDLE_GUARD_MS = 400;
const TICK_MS = 200;
// ── Pure simulation of the countdown mechanism ────────────────
interface CountdownSim {
scheduleCountdown: () => void;
cancelCountdown: () => void; // called when sync starts
getCountdown: () => number | null;
cleanup: () => void;
}
function makeCountdownSim(): CountdownSim {
let lastChangeTime = 0;
let countdown: number | null = null;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let tickInterval: ReturnType<typeof setInterval> | null = null;
function startTicking() {
if (tickInterval) clearInterval(tickInterval);
const initial = Math.ceil((lastChangeTime + DEBOUNCE_MS - Date.now()) / 1000);
countdown = initial > 0 ? initial : null;
tickInterval = setInterval(() => {
const remaining = Math.ceil((lastChangeTime + DEBOUNCE_MS - Date.now()) / 1000);
if (remaining <= 0) {
clearInterval(tickInterval!);
tickInterval = null;
countdown = null;
} else {
countdown = remaining;
}
}, TICK_MS);
}
function scheduleCountdown() {
lastChangeTime = Date.now();
// Reset idle guard — any new change pushes the idle window
if (idleTimer) clearTimeout(idleTimer);
// Hide countdown while actively drawing
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
countdown = null;
// Show countdown only after IDLE_GUARD_MS of quiet
idleTimer = setTimeout(startTicking, IDLE_GUARD_MS);
}
function cancelCountdown() {
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
countdown = null;
}
function cleanup() {
cancelCountdown();
}
return {
scheduleCountdown,
cancelCountdown,
getCountdown: () => countdown,
cleanup,
};
}
// ── Tests ─────────────────────────────────────────────────────
describe('sync countdown — idle guard', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('shows null while actively drawing (within idle guard window)', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
// Still within the 400ms idle guard
vi.advanceTimersByTime(IDLE_GUARD_MS - 10);
expect(sim.getCountdown()).toBeNull();
sim.cleanup();
});
it('starts showing countdown after idle guard passes', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
expect(sim.getCountdown()).toBeGreaterThan(0);
sim.cleanup();
});
it('resets idle guard on each new change — no countdown while drawing', () => {
const sim = makeCountdownSim();
// Rapid changes every 100ms for 600ms total
for (let i = 0; i < 6; i++) {
sim.scheduleCountdown();
vi.advanceTimersByTime(100);
}
// 600ms elapsed but idle guard resets each time — countdown still null
expect(sim.getCountdown()).toBeNull();
// Now stop drawing; after idle guard the countdown appears
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
expect(sim.getCountdown()).toBeGreaterThan(0);
sim.cleanup();
});
});
describe('sync countdown — tick behaviour', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('starts at DEBOUNCE_MS/1000 seconds after idle', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
sim.cleanup();
});
it('counts down and reaches null when debounce fires', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
// Let idle guard pass + full debounce elapse
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + TICK_MS * 2);
expect(sim.getCountdown()).toBeNull();
sim.cleanup();
});
it('passes through 3 → 2 → 1 without skipping', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
const observed: (number | null)[] = [];
// Sample countdown every second for 4 seconds after idle guard
for (let s = 0; s <= 4; s++) {
vi.advanceTimersByTime(s === 0 ? IDLE_GUARD_MS + TICK_MS : 1000);
observed.push(sim.getCountdown());
}
expect(observed).toContain(3);
expect(observed).toContain(2);
expect(observed).toContain(1);
expect(observed[observed.length - 1]).toBeNull(); // cleared after 3s
sim.cleanup();
});
it('never goes negative', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
// Advance well past debounce
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + 5000);
const val = sim.getCountdown();
expect(val === null || val > 0).toBe(true);
sim.cleanup();
});
});
describe('sync countdown — cancelCountdown (sync started)', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('cancels before idle guard fires', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
vi.advanceTimersByTime(200); // still inside idle guard
sim.cancelCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS * 5);
expect(sim.getCountdown()).toBeNull();
sim.cleanup();
});
it('cancels after countdown has started', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS + 1000); // countdown showing 2
expect(sim.getCountdown()).toBe(2);
sim.cancelCountdown();
expect(sim.getCountdown()).toBeNull();
sim.cleanup();
});
it('allows a new countdown cycle after cancel', () => {
const sim = makeCountdownSim();
sim.scheduleCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS + 1000);
sim.cancelCountdown(); // sync started
// User draws again after sync
sim.scheduleCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
sim.cleanup();
});
});
describe('sync countdown — multiple change bursts', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('second burst after first sync resets correctly', () => {
const sim = makeCountdownSim();
// First burst → sync → cancel
sim.scheduleCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + DEBOUNCE_MS + TICK_MS * 2);
sim.cancelCountdown();
// Second burst
sim.scheduleCountdown();
vi.advanceTimersByTime(IDLE_GUARD_MS + TICK_MS);
expect(sim.getCountdown()).toBe(DEBOUNCE_MS / 1000);
sim.cleanup();
});
it('countdown stays null between burst end and idle guard', () => {
const sim = makeCountdownSim();
// Two rapid changes 50ms apart
sim.scheduleCountdown();
vi.advanceTimersByTime(50);
sim.scheduleCountdown();
// 300ms after last change — still inside idle guard
vi.advanceTimersByTime(300);
expect(sim.getCountdown()).toBeNull();
// 400ms after last change — idle guard has passed
vi.advanceTimersByTime(IDLE_GUARD_MS - 300 + TICK_MS);
expect(sim.getCountdown()).toBeGreaterThan(0);
sim.cleanup();
});
});