fix(settings, yjs, ux, ci): default settings, y-leveldb restart resilience, dashboard one-click open, delete error handling, Playwright CI
This commit is contained in:
@@ -36,3 +36,27 @@ jobs:
|
|||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run build
|
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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"dev:ci": "tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
|
"postbuild": "cp src/database/schema.sql dist/database/schema.sql",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
|||||||
@@ -25,16 +25,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', async () => {
|
const shutdown = async (signal: string) => {
|
||||||
|
console.log(`Received ${signal}, shutting down gracefully...`);
|
||||||
await fastify.close();
|
await fastify.close();
|
||||||
|
await closeYjsPersistence();
|
||||||
db.close();
|
db.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
};
|
||||||
process.on('SIGINT', async () => {
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
await fastify.close();
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
db.close();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { requireAuth } from '../auth/authMiddleware.js';
|
|||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
import { validateSettingsKey, validateSettingsValue } from '../utils/validation.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<string, string> = {
|
||||||
|
'cad.unit': 'mm',
|
||||||
|
'cad.gridSize': '20',
|
||||||
|
'cad.scaleFactor': '1',
|
||||||
|
};
|
||||||
|
|
||||||
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// Get a single setting by key
|
// Get a single setting by key
|
||||||
fastify.get('/api/settings/:key', async (request, reply) => {
|
fastify.get('/api/settings/:key', async (request, reply) => {
|
||||||
@@ -15,8 +22,12 @@ export function registerSettingsRoutes(fastify: FastifyInstance, db: DatabaseInt
|
|||||||
const keyErr = validateSettingsKey(key);
|
const keyErr = validateSettingsKey(key);
|
||||||
if (keyErr) return reply.code(400).send({ error: keyErr });
|
if (keyErr) return reply.code(400).send({ error: keyErr });
|
||||||
const setting = db.getSetting(key);
|
const setting = db.getSetting(key);
|
||||||
if (!setting) return reply.code(404).send({ error: 'Setting not found' });
|
if (setting) return 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)
|
// Create or update a setting (upsert)
|
||||||
|
|||||||
@@ -28,25 +28,54 @@ let persistence: LeveldbPersistence | null = null;
|
|||||||
let persistenceReady: Promise<void> | null = null;
|
let persistenceReady: Promise<void> | null = null;
|
||||||
|
|
||||||
export async function initYjsPersistence(): Promise<void> {
|
export async function initYjsPersistence(): Promise<void> {
|
||||||
try {
|
const tryInit = async (allowReset: boolean): Promise<void> => {
|
||||||
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
|
try {
|
||||||
// Set persistenceReady so getOrCreateDoc can await it
|
persistence = new LeveldbPersistence(PERSISTENCE_DIR);
|
||||||
persistenceReady = (async () => {
|
persistenceReady = (async () => {
|
||||||
// Wait for LevelDB to be ready
|
// Wait for LevelDB to be ready
|
||||||
await (persistence as any).getAllDocNames();
|
await (persistence as any).getAllDocNames();
|
||||||
})();
|
})();
|
||||||
await persistenceReady;
|
await persistenceReady;
|
||||||
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
|
console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Yjs persistence failed to init (non-fatal):', 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<void> {
|
export async function closeYjsPersistence(): Promise<void> {
|
||||||
if (persistence) {
|
if (persistence) {
|
||||||
await persistence.destroy();
|
try {
|
||||||
|
await persistence.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Yjs persistence close failed (non-fatal):', err);
|
||||||
|
}
|
||||||
persistence = null;
|
persistence = null;
|
||||||
}
|
}
|
||||||
|
persistenceReady = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
|
async function getOrCreateDoc(docName: string): Promise<Y.Doc> {
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ describe('Settings API', () => {
|
|||||||
expect(body.value).toBe('test-value');
|
expect(body.value).toBe('test-value');
|
||||||
expect(body.updated_at).toBeDefined();
|
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) ───────────────────────────────────
|
// ─── Put (upsert) ───────────────────────────────────
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ test('diagnose editor DOM', async () => {
|
|||||||
|
|
||||||
await page.waitForSelector('.dashboard-project-card', { timeout: 10000 });
|
await page.waitForSelector('.dashboard-project-card', { timeout: 10000 });
|
||||||
await page.locator('.dashboard-project-card').first().click();
|
await page.locator('.dashboard-project-card').first().click();
|
||||||
await page.waitForTimeout(500);
|
// Card click now opens the project directly
|
||||||
// Dashboard shows a detail panel on the right with 'Projekt öffnen'
|
await page.waitForSelector('canvas', { timeout: 10000 });
|
||||||
await page.locator('button:has-text("Projekt öffnen")').first().click();
|
await page.waitForTimeout(1000);
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// Take screenshot
|
// Take screenshot
|
||||||
const shot = '/tmp/web-cad-clone/frontend/playwright-tests/screenshots/diagnose-editor.png';
|
const shot = '/tmp/web-cad-clone/frontend/playwright-tests/screenshots/diagnose-editor.png';
|
||||||
|
|||||||
+1
-9
@@ -138,15 +138,7 @@ test.describe('Web CAD Exhaustive Browser Tests', () => {
|
|||||||
test('01: open project and load CAD editor', async () => {
|
test('01: open project and load CAD editor', async () => {
|
||||||
const stopCapture = captureConsoleErrors(page, 'open project');
|
const stopCapture = captureConsoleErrors(page, 'open project');
|
||||||
await page.locator('.dashboard-project-card').first().click();
|
await page.locator('.dashboard-project-card').first().click();
|
||||||
await waitForStable(page, 500);
|
// Card click now opens the project directly
|
||||||
// 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
|
|
||||||
try {
|
try {
|
||||||
await page.waitForSelector('[data-tool="line"]', { timeout: 10000 });
|
await page.waitForSelector('[data-tool="line"]', { timeout: 10000 });
|
||||||
await waitForStable(page, 1000);
|
await waitForStable(page, 1000);
|
||||||
@@ -111,7 +111,7 @@ test('exhaustive tool test v2', async () => {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForSelector('.dashboard-project-card', { timeout: 10000 });
|
await page.waitForSelector('.dashboard-project-card', { timeout: 10000 });
|
||||||
await page.locator('.dashboard-project-card').first().click();
|
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.waitForSelector('canvas', { timeout: 10000 });
|
||||||
await page.waitForTimeout(2500);
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command: 'cd /tmp/web-cad-clone/backend && npm run dev',
|
command: 'cd ../backend && npm run dev:ci',
|
||||||
url: 'http://localhost:3001/api/health',
|
url: 'http://localhost:3001/api/health',
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: 'cd /tmp/web-cad-clone/frontend && npm run dev -- --port 5173',
|
command: 'npm run dev -- --port 5173',
|
||||||
url: 'http://localhost:5173',
|
url: 'http://localhost:5173',
|
||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
|
|||||||
const handleProjectCardClick = (projectId: string) => {
|
const handleProjectCardClick = (projectId: string) => {
|
||||||
setSelectedProjectId(projectId);
|
setSelectedProjectId(projectId);
|
||||||
setMobileInfoOpen(true);
|
setMobileInfoOpen(true);
|
||||||
|
onOpenProject(projectId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProject = async () => {
|
const handleSaveProject = async () => {
|
||||||
|
|||||||
@@ -102,10 +102,11 @@ export async function createProject(token: string, name: string, description?: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProject(token: string, id: string): Promise<void> {
|
export async function deleteProject(token: string, id: string): Promise<void> {
|
||||||
await fetch(`${API_BASE}/api/projects/${id}`, {
|
const res = await fetch(`${API_BASE}/api/projects/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
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<Project> {
|
export async function updateProject(token: string, id: string, updates: { name?: string; description?: string | null }): Promise<Project> {
|
||||||
@@ -225,10 +226,11 @@ export async function updateElement(token: string, elementId: string, patch: Par
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteElement(token: string, elementId: string): Promise<void> {
|
export async function deleteElement(token: string, elementId: string): Promise<void> {
|
||||||
await fetch(`${API_BASE}/api/elements/${elementId}`, {
|
const res = await fetch(`${API_BASE}/api/elements/${elementId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete element');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Layers ─────────────────────────────────────────────
|
// ─── Layers ─────────────────────────────────────────────
|
||||||
@@ -259,10 +261,11 @@ export async function updateLayer(token: string, layerId: string, patch: Partial
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteLayer(token: string, layerId: string): Promise<void> {
|
export async function deleteLayer(token: string, layerId: string): Promise<void> {
|
||||||
await fetch(`${API_BASE}/api/layers/${layerId}`, {
|
const res = await fetch(`${API_BASE}/api/layers/${layerId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete layer');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Blocks ─────────────────────────────────────────────
|
// ─── Blocks ─────────────────────────────────────────────
|
||||||
@@ -293,10 +296,11 @@ export async function updateBlock(token: string, blockId: string, patch: Partial
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBlock(token: string, blockId: string): Promise<void> {
|
export async function deleteBlock(token: string, blockId: string): Promise<void> {
|
||||||
await fetch(`${API_BASE}/api/blocks/${blockId}`, {
|
const res = await fetch(`${API_BASE}/api/blocks/${blockId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete block');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Composite: Load full project data ───────────────────
|
// ─── 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<void> {
|
export async function deleteGlobalFolder(token: string, id: string): Promise<void> {
|
||||||
await fetch(`${API_BASE}/api/global-folders/${id}`, {
|
const res = await fetch(`${API_BASE}/api/global-folders/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete global folder');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Global Blocks ────────────────────────────────────────
|
// ─── 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<void> {
|
export async function deleteGlobalBlock(token: string, id: string): Promise<void> {
|
||||||
await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
const res = await fetch(`${API_BASE}/api/global-blocks/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete global block');
|
||||||
}
|
}
|
||||||
|
|
||||||
export { API_BASE };
|
export { API_BASE };
|
||||||
|
|||||||
Reference in New Issue
Block a user