diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index e3840ea..35e31d2 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -36,3 +36,27 @@ jobs: - name: Build frontend working-directory: frontend run: npm run build + + e2e-test: + runs-on: docker + container: node:20 + steps: + - uses: actions/checkout@v4 + - name: Install backend dependencies + working-directory: backend + run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps + - name: Install frontend dependencies + working-directory: frontend + run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps + - name: Install Playwright browsers + working-directory: frontend + run: npx playwright install --with-deps chromium + - name: Run Playwright E2E tests + working-directory: frontend + run: npm run test:e2e:ci + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/playwright-tests/playwright-report.json diff --git a/backend/package.json b/backend/package.json index 0a0b795..3e94c31 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "tsx watch src/index.ts", + "dev:ci": "tsx src/index.ts", "build": "tsc", "postbuild": "cp src/database/schema.sql dist/database/schema.sql", "start": "node dist/index.js", diff --git a/backend/src/index.ts b/backend/src/index.ts index 8c182b5..3eeb038 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,16 +25,15 @@ async function main() { } // Graceful shutdown - process.on('SIGTERM', async () => { + const shutdown = async (signal: string) => { + console.log(`Received ${signal}, shutting down gracefully...`); await fastify.close(); + await closeYjsPersistence(); db.close(); process.exit(0); - }); - process.on('SIGINT', async () => { - await fastify.close(); - db.close(); - process.exit(0); - }); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); } main(); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 9cf388d..0bfc0ae 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -7,6 +7,13 @@ import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; import { validateSettingsKey, validateSettingsValue } from '../utils/validation.js'; +/** Built-in default settings so the frontend never sees a 404 for known keys. */ +const DEFAULT_SETTINGS: Record = { + 'cad.unit': 'mm', + 'cad.gridSize': '20', + 'cad.scaleFactor': '1', +}; + export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // Get a single setting by key fastify.get('/api/settings/:key', async (request, reply) => { @@ -15,8 +22,12 @@ export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInt const keyErr = validateSettingsKey(key); if (keyErr) return reply.code(400).send({ error: keyErr }); const setting = db.getSetting(key); - if (!setting) return reply.code(404).send({ error: 'Setting not found' }); - return setting; + if (setting) return setting; + // Return a default value for known CAD settings instead of 404 + if (DEFAULT_SETTINGS[key] !== undefined) { + return { key, value: DEFAULT_SETTINGS[key], updated_at: new Date().toISOString() }; + } + return reply.code(404).send({ error: 'Setting not found' }); }); // Create or update a setting (upsert) diff --git a/backend/src/websocket/yjsServer.ts b/backend/src/websocket/yjsServer.ts index fc9935e..630422a 100644 --- a/backend/src/websocket/yjsServer.ts +++ b/backend/src/websocket/yjsServer.ts @@ -28,25 +28,54 @@ let persistence: LeveldbPersistence | null = null; let persistenceReady: Promise | null = null; export async function initYjsPersistence(): Promise { - try { - persistence = new LeveldbPersistence(PERSISTENCE_DIR); - // Set persistenceReady so getOrCreateDoc can await it - persistenceReady = (async () => { - // Wait for LevelDB to be ready - await (persistence as any).getAllDocNames(); - })(); - await persistenceReady; - console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`); - } catch (err) { - console.warn('Yjs persistence failed to init (non-fatal):', err); - } + const tryInit = async (allowReset: boolean): Promise => { + try { + persistence = new LeveldbPersistence(PERSISTENCE_DIR); + persistenceReady = (async () => { + // Wait for LevelDB to be ready + await (persistence as any).getAllDocNames(); + })(); + await persistenceReady; + console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`); + } catch (err) { + // In dev mode the persistence dir lives under /tmp. If the previous process + // did not shut down cleanly, LevelDB can be left in a locked/corrupted state. + // Resetting the directory is acceptable because collaboration history is ephemeral. + if (allowReset && PERSISTENCE_DIR.startsWith('/tmp/')) { + console.warn(`Yjs persistence init failed, resetting ${PERSISTENCE_DIR} and retrying...`); + try { + if (persistence) { + await persistence.destroy().catch(() => {}); + persistence = null; + persistenceReady = null; + } + const fs = await import('fs'); + fs.rmSync(PERSISTENCE_DIR, { recursive: true, force: true }); + fs.mkdirSync(PERSISTENCE_DIR, { recursive: true }); + } catch (cleanupErr) { + console.warn('Yjs persistence cleanup failed:', cleanupErr); + } + return tryInit(false); + } + console.warn('Yjs persistence failed to init (running without disk persistence):', err); + persistence = null; + persistenceReady = null; + } + }; + + await tryInit(true); } export async function closeYjsPersistence(): Promise { if (persistence) { - await persistence.destroy(); + try { + await persistence.destroy(); + } catch (err) { + console.warn('Yjs persistence close failed (non-fatal):', err); + } persistence = null; } + persistenceReady = null; } async function getOrCreateDoc(docName: string): Promise { diff --git a/backend/tests/settings.test.ts b/backend/tests/settings.test.ts index 98c616d..23f8d05 100644 --- a/backend/tests/settings.test.ts +++ b/backend/tests/settings.test.ts @@ -76,6 +76,26 @@ describe('Settings API', () => { expect(body.value).toBe('test-value'); expect(body.updated_at).toBeDefined(); }); + + it('should return default CAD settings instead of 404', async () => { + const defaults = [ + { key: 'cad.unit', expected: 'mm' }, + { key: 'cad.gridSize', expected: '20' }, + { key: 'cad.scaleFactor', expected: '1' }, + ]; + for (const { key, expected } of defaults) { + const response = await app.inject({ + method: 'GET', + url: `/api/settings/${key}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.key).toBe(key); + expect(body.value).toBe(expected); + expect(body.updated_at).toBeDefined(); + } + }); }); // ─── Put (upsert) ─────────────────────────────────── diff --git a/frontend/package.json b/frontend/package.json index 2aee8cc..b67f6be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite --host 0.0.0.0 --port 5173", "build": "tsc && vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ci": "playwright test playwright-tests/diagnose-editor.test.ts playwright-tests/tool-exhaustive.test.ts" }, "dependencies": { "dxf-parser": "^1.1.2", diff --git a/frontend/playwright-tests/diagnose-editor.test.ts b/frontend/playwright-tests/diagnose-editor.test.ts index 50c1a9a..d61b6c5 100644 --- a/frontend/playwright-tests/diagnose-editor.test.ts +++ b/frontend/playwright-tests/diagnose-editor.test.ts @@ -40,10 +40,9 @@ test('diagnose editor DOM', async () => { await page.waitForSelector('.dashboard-project-card', { timeout: 10000 }); await page.locator('.dashboard-project-card').first().click(); - await page.waitForTimeout(500); - // Dashboard shows a detail panel on the right with 'Projekt öffnen' - await page.locator('button:has-text("Projekt öffnen")').first().click(); - await page.waitForTimeout(3000); + // Card click now opens the project directly + await page.waitForSelector('canvas', { timeout: 10000 }); + await page.waitForTimeout(1000); // Take screenshot const shot = '/tmp/web-cad-clone/frontend/playwright-tests/screenshots/diagnose-editor.png'; diff --git a/frontend/playwright-tests/browser-exhaustive.test.ts b/frontend/playwright-tests/manual/browser-exhaustive.test.ts similarity index 96% rename from frontend/playwright-tests/browser-exhaustive.test.ts rename to frontend/playwright-tests/manual/browser-exhaustive.test.ts index 2e82993..45fc5ba 100644 --- a/frontend/playwright-tests/browser-exhaustive.test.ts +++ b/frontend/playwright-tests/manual/browser-exhaustive.test.ts @@ -138,15 +138,7 @@ test.describe('Web CAD Exhaustive Browser Tests', () => { test('01: open project and load CAD editor', async () => { const stopCapture = captureConsoleErrors(page, 'open project'); await page.locator('.dashboard-project-card').first().click(); - await waitForStable(page, 500); - // Dashboard opens detail panel on the right with 'Projekt öffnen' - const openBtn = await page.locator('button:has-text("Projekt öffnen")').first(); - if (await openBtn.count() > 0 && await openBtn.isVisible().catch(() => false)) { - await openBtn.click(); - } else { - logIssue('Dashboard', 'major', 'Projekt öffnen button not visible', ''); - } - // Wait for editor to render: any tool button from the left sidebar is visible + // Card click now opens the project directly try { await page.waitForSelector('[data-tool="line"]', { timeout: 10000 }); await waitForStable(page, 1000); diff --git a/frontend/playwright-tests/tool-exhaustive.test.ts b/frontend/playwright-tests/tool-exhaustive.test.ts index b982587..5b65af6 100644 --- a/frontend/playwright-tests/tool-exhaustive.test.ts +++ b/frontend/playwright-tests/tool-exhaustive.test.ts @@ -111,7 +111,7 @@ test('exhaustive tool test v2', async () => { await page.reload(); await page.waitForSelector('.dashboard-project-card', { timeout: 10000 }); await page.locator('.dashboard-project-card').first().click(); - await page.locator('button:has-text("Projekt öffnen")').first().click(); + // Card click now opens the project directly await page.waitForSelector('canvas', { timeout: 10000 }); await page.waitForTimeout(2500); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 42382c3..9796109 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -14,13 +14,13 @@ export default defineConfig({ }, webServer: [ { - command: 'cd /tmp/web-cad-clone/backend && npm run dev', + command: 'cd ../backend && npm run dev:ci', url: 'http://localhost:3001/api/health', reuseExistingServer: true, timeout: 120000, }, { - command: 'cd /tmp/web-cad-clone/frontend && npm run dev -- --port 5173', + command: 'npm run dev -- --port 5173', url: 'http://localhost:5173', reuseExistingServer: true, timeout: 120000, diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1983879..cca7f21 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -332,6 +332,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) { const handleProjectCardClick = (projectId: string) => { setSelectedProjectId(projectId); setMobileInfoOpen(true); + onOpenProject(projectId); }; const handleSaveProject = async () => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6b38c8c..327cb7e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -102,10 +102,11 @@ export async function createProject(token: string, name: string, description?: s } export async function deleteProject(token: string, id: string): Promise { - await fetch(`${API_BASE}/api/projects/${id}`, { + const res = await fetch(`${API_BASE}/api/projects/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); + if (!res.ok) throw new Error('Failed to delete project'); } export async function updateProject(token: string, id: string, updates: { name?: string; description?: string | null }): Promise { @@ -225,10 +226,11 @@ export async function updateElement(token: string, elementId: string, patch: Par } export async function deleteElement(token: string, elementId: string): Promise { - await fetch(`${API_BASE}/api/elements/${elementId}`, { + const res = await fetch(`${API_BASE}/api/elements/${elementId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); + if (!res.ok) throw new Error('Failed to delete element'); } // ─── Layers ───────────────────────────────────────────── @@ -259,10 +261,11 @@ export async function updateLayer(token: string, layerId: string, patch: Partial } export async function deleteLayer(token: string, layerId: string): Promise { - await fetch(`${API_BASE}/api/layers/${layerId}`, { + const res = await fetch(`${API_BASE}/api/layers/${layerId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); + if (!res.ok) throw new Error('Failed to delete layer'); } // ─── Blocks ───────────────────────────────────────────── @@ -293,10 +296,11 @@ export async function updateBlock(token: string, blockId: string, patch: Partial } export async function deleteBlock(token: string, blockId: string): Promise { - await fetch(`${API_BASE}/api/blocks/${blockId}`, { + const res = await fetch(`${API_BASE}/api/blocks/${blockId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); + if (!res.ok) throw new Error('Failed to delete block'); } // ─── Composite: Load full project data ─────────────────── @@ -642,10 +646,11 @@ export async function renameGlobalFolder(token: string, id: string, name: string } export async function deleteGlobalFolder(token: string, id: string): Promise { - await fetch(`${API_BASE}/api/global-folders/${id}`, { + const res = await fetch(`${API_BASE}/api/global-folders/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); + if (!res.ok) throw new Error('Failed to delete global folder'); } // ─── Global Blocks ──────────────────────────────────────── @@ -694,10 +699,11 @@ export async function renameGlobalBlock(token: string, id: string, name: string) } export async function deleteGlobalBlock(token: string, id: string): Promise { - await fetch(`${API_BASE}/api/global-blocks/${id}`, { + const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); + if (!res.ok) throw new Error('Failed to delete global block'); } export { API_BASE };