chore: release v1.0.5 (#10)

feat(projects): project management UI, sync countdown, fix project switching
This commit is contained in:
Maxime Roy (new.blacc)
2026-04-06 13:18:03 +02:00
committed by GitHub
parent 798f62f63a
commit 1166ea5b3f
12 changed files with 1119 additions and 11 deletions
+18
View File
@@ -5,6 +5,24 @@ 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
+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)}>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "excalidraw-mcp-sentinel",
"version": "1.0.4",
"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",
+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 {
+18
View File
@@ -2748,7 +2748,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
if (params.createName) {
const newProject = dbCreateProject(params.createName, params.createDescription, dbGetActiveTenantId());
// 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) {
// 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) => {
+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();
});
});