From 1166ea5b3fbaad275ecc057bf7ab17435f88859b Mon Sep 17 00:00:00 2001 From: "Maxime Roy (new.blacc)" Date: Mon, 6 Apr 2026 13:18:03 +0200 Subject: [PATCH] chore: release v1.0.5 (#10) feat(projects): project management UI, sync countdown, fix project switching --- CHANGELOG.md | 18 ++ CLAUDE.md | 4 +- README.md | 4 + frontend/index.html | 99 +++++++++ frontend/src/App.tsx | 249 ++++++++++++++++++++++- package.json | 2 +- src/db.ts | 16 ++ src/index.ts | 22 +- src/server.ts | 71 ++++++- tests/backend/api.test.ts | 216 +++++++++++++++++++- tests/backend/project-switch-e2e.test.ts | 182 +++++++++++++++++ tests/frontend/sync-countdown.test.ts | 247 ++++++++++++++++++++++ 12 files changed, 1119 insertions(+), 11 deletions(-) create mode 100644 tests/backend/project-switch-e2e.test.ts create mode 100644 tests/frontend/sync-countdown.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6342aad..7f56d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 545831e..71e72ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/README.md b/README.md index bb2e320..8f3dfff 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/frontend/index.html b/frontend/index.html index 1696791..2fdc4fe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c19ce9f..9c3873f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -74,7 +74,12 @@ function App(): JSX.Element { }) const isSyncingRef = useRef(false) const debounceTimerRef = useRef | null>(null) + const countdownTimerRef = useRef | null>(null) + const idleTimerRef = useRef | null>(null) + const lastChangeTimeRef = useRef(0) + const [syncCountdown, setSyncCountdown] = useState(null) const lastSyncedHashRef = useRef('') + const lastSeenHashRef = useRef('') const lastSyncVersionRef = useRef( parseInt(localStorage.getItem('excalidraw-last-sync-version') ?? '0', 10) ) @@ -120,6 +125,15 @@ function App(): JSX.Element { const [tenantSearch, setTenantSearch] = useState('') const searchInputRef = useRef(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(false) + const [newProjectName, setNewProjectName] = useState('') + const [isCreatingProject, setIsCreatingProject] = useState(false) + const newProjectInputRef = useRef(null) + const [confirmDeleteProjectId, setConfirmDeleteProjectId] = useState(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 => { 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 { Workspace: {activeTenant.name} ▾ )} + {activeProject && ( + + )} {toast &&
{toast}
} @@ -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'} + + + ) : ( + <> + + {activeProject?.id !== p.id && projectList.length > 1 && ( + + )} + + )} + + ))} + {projectList.length === 0 &&
No projects yet
} + +
+ setNewProjectName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') createProjectUI() }} + /> + +
+ + + )} + {/* Clear canvas confirmation modal (UI button only) */} {showClearConfirm && (
setShowClearConfirm(false)}> diff --git a/package.json b/package.json index abd77a8..05b984c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/db.ts b/src/db.ts index 4a727ce..25faf41 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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 { diff --git a/src/index.ts b/src/index.ts index be3e649..112b3fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { diff --git a/src/server.ts b/src/server.ts index 5f97d8c..b72e6bf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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) => { diff --git a/tests/backend/api.test.ts b/tests/backend/api.test.ts index 44fc173..65bba40 100644 --- a/tests/backend/api.test.ts +++ b/tests/backend/api.test.ts @@ -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); + }); +}); diff --git a/tests/backend/project-switch-e2e.test.ts b/tests/backend/project-switch-e2e.test.ts new file mode 100644 index 0000000..b1ff8b8 --- /dev/null +++ b/tests/backend/project-switch-e2e.test.ts @@ -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'); + }); +}); diff --git a/tests/frontend/sync-countdown.test.ts b/tests/frontend/sync-countdown.test.ts new file mode 100644 index 0000000..d9db4c4 --- /dev/null +++ b/tests/frontend/sync-countdown.test.ts @@ -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 | null = null; + let tickInterval: ReturnType | 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(); + }); +});