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
+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();
});
});