fix: browser testing issues and missing UI tools
- Fix WebSocket URL to use ws:// for http development environments - Make createLayer idempotent to prevent race-condition 500 under React StrictMode - Add URL-based routing for /register so register page actually shows register form - Add missing tool buttons to LeftSidebar: polygon, chair, extend, fillet - Add Playwright browser test suite for exhaustive UI testing - Update .gitignore for Playwright artifacts Unit tests: backend 254/254, frontend 380/380
This commit is contained in:
@@ -11,3 +11,8 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
.vitest/
|
.vitest/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-tests/screenshots/
|
||||||
|
frontend/playwright-tests/*.json
|
||||||
|
|||||||
@@ -207,6 +207,10 @@ export class SqliteAdapter implements DatabaseInterface {
|
|||||||
|
|
||||||
createLayer(data: Partial<DBLayer>): DBLayer {
|
createLayer(data: Partial<DBLayer>): DBLayer {
|
||||||
const id = data.id ?? `layer-${Date.now()}`;
|
const id = data.id ?? `layer-${Date.now()}`;
|
||||||
|
// Idempotent: if a layer with the same id already exists, return it.
|
||||||
|
// This prevents race conditions under React StrictMode double-render.
|
||||||
|
const existing = this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer | undefined;
|
||||||
|
if (existing) return existing;
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible !== undefined ? (data.visible ? 1 : 0) : 1, data.locked !== undefined ? (data.locked ? 1 : 0) : 0,
|
).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible !== undefined ? (data.visible ? 1 : 0) : 1, data.locked !== undefined ? (data.locked ? 1 : 0) : 0,
|
||||||
|
|||||||
Generated
+60
@@ -18,6 +18,7 @@
|
|||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -660,6 +661,21 @@
|
|||||||
"pako": "^1.0.10"
|
"pako": "^1.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||||
|
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -2408,6 +2424,50 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||||
|
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||||
|
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* Exhaustive Browser Test Suite for Web CAD
|
||||||
|
* Tests every tool, menu, dialog and workflow reachable in the UI.
|
||||||
|
* Run with: npx playwright test playwright-tests/browser-exhaustive.test.ts --project=chromium
|
||||||
|
*/
|
||||||
|
import { test, expect, chromium, type Browser, type Page } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
const TEST_EMAIL = `browser-test-${Date.now()}@example.com`;
|
||||||
|
const TEST_PASSWORD = 'Test1234!';
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
category: string;
|
||||||
|
severity: 'critical' | 'major' | 'minor' | 'info';
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
screenshot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
|
||||||
|
async function api(method: string, endpoint: string, body?: unknown, token?: string) {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function logIssue(category: string, severity: Issue['severity'], title: string, detail: string, screenshot?: string) {
|
||||||
|
issues.push({ category, severity, title, detail, screenshot });
|
||||||
|
console.log(`[${severity.toUpperCase()}] ${category}: ${title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function screenshot(page: Page, name: string) {
|
||||||
|
const file = path.resolve(`/tmp/web-cad-clone/frontend/playwright-tests/screenshots/${name}.png`);
|
||||||
|
await page.screenshot({ path: file, fullPage: false });
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureConsoleErrors(page: Page, label: string) {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('console', msg => {
|
||||||
|
if (msg.type() === 'error') {
|
||||||
|
const text = msg.text();
|
||||||
|
// Ignore benign 404s for settings that don't exist yet
|
||||||
|
if (text.includes('/api/settings/')) return;
|
||||||
|
errors.push(text);
|
||||||
|
logIssue('Console', 'major', `Console error during ${label}`, text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on('pageerror', err => {
|
||||||
|
const text = err.message;
|
||||||
|
errors.push(text);
|
||||||
|
logIssue('PageError', 'critical', `Page error during ${label}`, text);
|
||||||
|
});
|
||||||
|
return () => errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStable(page: Page, ms = 300) {
|
||||||
|
await page.waitForTimeout(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickIfExists(page: Page, selector: string, label: string) {
|
||||||
|
const el = await page.locator(selector).first();
|
||||||
|
if (await el.count() > 0 && await el.isVisible().catch(() => false)) {
|
||||||
|
await el.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logIssue('UI', 'minor', `${label} not found`, `Selector ${selector} not visible`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Web CAD Exhaustive Browser Tests', () => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
|
||||||
|
let browser: Browser;
|
||||||
|
let page: Page;
|
||||||
|
let token: string;
|
||||||
|
let userId: string;
|
||||||
|
let projectId: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// Ensure screenshot dir exists
|
||||||
|
fs.mkdirSync('/tmp/web-cad-clone/frontend/playwright-tests/screenshots', { recursive: true });
|
||||||
|
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||||
|
|
||||||
|
// Register test user
|
||||||
|
const reg = await api('POST', '/api/auth/register', { email: TEST_EMAIL, password: TEST_PASSWORD, name: 'Browser Bot' });
|
||||||
|
if (reg.status !== 200 && reg.status !== 201) {
|
||||||
|
// Maybe user already exists? Try login
|
||||||
|
const login = await api('POST', '/api/auth/login', { email: TEST_EMAIL, password: TEST_PASSWORD });
|
||||||
|
if (login.status !== 200) throw new Error(`Auth failed: ${login.status} ${JSON.stringify(login.body)}`);
|
||||||
|
token = login.body.session.token;
|
||||||
|
userId = login.body.user.id;
|
||||||
|
} else {
|
||||||
|
token = reg.body.session.token;
|
||||||
|
userId = reg.body.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
const proj = await api('POST', '/api/projects', { name: 'Exhaustive Browser Test', description: 'Automated test' }, token);
|
||||||
|
if (proj.status !== 200 && proj.status !== 201) throw new Error(`Project create failed: ${proj.status} ${JSON.stringify(proj.body)}`);
|
||||||
|
projectId = proj.body.id;
|
||||||
|
|
||||||
|
// Inject session and open app
|
||||||
|
await page.goto(`${BASE_URL}/`);
|
||||||
|
await page.evaluate((t) => { localStorage.setItem('auth_token', t); }, token);
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('.dashboard-project-card', { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await browser.close();
|
||||||
|
// Write JSON report
|
||||||
|
const report = {
|
||||||
|
totalIssues: issues.length,
|
||||||
|
critical: issues.filter(i => i.severity === 'critical').length,
|
||||||
|
major: issues.filter(i => i.severity === 'major').length,
|
||||||
|
minor: issues.filter(i => i.severity === 'minor').length,
|
||||||
|
info: issues.filter(i => i.severity === 'info').length,
|
||||||
|
issues,
|
||||||
|
};
|
||||||
|
fs.writeFileSync('/tmp/web-cad-clone/frontend/playwright-tests/browser-test-report.json', JSON.stringify(report, null, 2));
|
||||||
|
console.log('\n=== REPORT ===');
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('[data-tool="line"]', { timeout: 10000 });
|
||||||
|
await waitForStable(page, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
logIssue('Editor', 'critical', 'CAD editor did not render tool buttons', String(e), await screenshot(page, '01_editor_not_loaded'));
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('02: draw tools - line, rect, circle', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'draw tools');
|
||||||
|
for (const tool of ['line', 'rect', 'circle']) {
|
||||||
|
const clicked = await clickIfExists(page, `[data-tool="${tool}"]`, `${tool} tool button`);
|
||||||
|
if (!clicked) {
|
||||||
|
// Try alternative selectors
|
||||||
|
const alt = await page.locator('.tool-button, .ribbon-button, button').filter({ hasText: new RegExp(tool, 'i') }).first();
|
||||||
|
if (await alt.count() > 0 && await alt.isVisible().catch(() => false)) {
|
||||||
|
await alt.click();
|
||||||
|
} else {
|
||||||
|
logIssue('DrawTool', 'major', `Cannot select ${tool} tool`, 'Tool button not found');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await waitForStable(page, 300);
|
||||||
|
// Draw on canvas center (two clicks for 2-point tools)
|
||||||
|
const canvas = await page.locator('canvas').first();
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
logIssue('DrawTool', 'critical', `Canvas not found for ${tool}`, '');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cx = box.x + box.width / 2;
|
||||||
|
const cy = box.y + box.height / 2;
|
||||||
|
await page.mouse.click(cx - 50, cy - 50);
|
||||||
|
await waitForStable(page, 200);
|
||||||
|
await page.mouse.click(cx + 50, cy + 50);
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitForStable(page, 200);
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('03: modification tools - select, move, copy, delete', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'modification tools');
|
||||||
|
await clickIfExists(page, '[data-tool="select"], button:has-text("select")', 'select tool');
|
||||||
|
const canvas = await page.locator('canvas').first();
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
logIssue('ModifyTool', 'critical', 'Canvas not found', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cx = box.x + box.width / 2;
|
||||||
|
const cy = box.y + box.height / 2;
|
||||||
|
// Select element at center
|
||||||
|
await page.mouse.click(cx, cy);
|
||||||
|
await waitForStable(page, 300);
|
||||||
|
// Try move tool and drag
|
||||||
|
await clickIfExists(page, '[data-tool="move"], button:has-text("move")', 'move tool');
|
||||||
|
await page.mouse.move(cx, cy);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(cx + 100, cy + 100);
|
||||||
|
await page.mouse.up();
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
// Copy tool
|
||||||
|
await clickIfExists(page, '[data-tool="copy"], button:has-text("copy")', 'copy tool');
|
||||||
|
await page.mouse.move(cx + 100, cy + 100);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(cx + 200, cy + 200);
|
||||||
|
await page.mouse.up();
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
// Delete tool
|
||||||
|
await clickIfExists(page, '[data-tool="delete"], button:has-text("delete")', 'delete tool');
|
||||||
|
await page.mouse.click(cx, cy);
|
||||||
|
await waitForStable(page, 300);
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('04: seating tools - row, block, table, stage', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'seating tools');
|
||||||
|
for (const tool of ['seating-row', 'seating-block', 'table', 'stage']) {
|
||||||
|
const clicked = await clickIfExists(page, `[data-tool="${tool}"]`, `${tool} tool`);
|
||||||
|
if (!clicked) {
|
||||||
|
logIssue('SeatingTool', 'major', `Cannot select ${tool} tool`, 'Tool button not found');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await waitForStable(page, 300);
|
||||||
|
const canvas = await page.locator('canvas').first();
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
if (!box) continue;
|
||||||
|
const cx = box.x + box.width / 2 + (Math.random() * 200 - 100);
|
||||||
|
const cy = box.y + box.height / 2 + (Math.random() * 200 - 100);
|
||||||
|
await page.mouse.click(cx, cy);
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitForStable(page, 200);
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('05: layer panel operations', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'layer panel');
|
||||||
|
const opened = await clickIfExists(page, '[data-testid="layer-panel-toggle"], button:has-text("Layers")', 'layer panel toggle');
|
||||||
|
if (opened) {
|
||||||
|
await clickIfExists(page, 'button:has-text("+ Layer")', 'add layer button');
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
await clickIfExists(page, 'button:has-text("+ Layer")', 'add layer button second');
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('06: block library', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'block library');
|
||||||
|
await clickIfExists(page, '[data-testid="block-library-toggle"], button:has-text("Blocks")', 'block library toggle');
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('07: topbar menus and ribbon', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'topbar ribbon');
|
||||||
|
// Try clicking each ribbon tab
|
||||||
|
const tabs = ['Start', 'Zeichnen', 'Bearbeiten', 'Ansicht', 'Bestuhlung', 'Einfügen'];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
await clickIfExists(page, `.ribbon-tab:has-text("${tab}"), button:has-text("${tab}")`, `${tab} ribbon tab`);
|
||||||
|
await waitForStable(page, 300);
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('08: settings modal', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'settings');
|
||||||
|
await clickIfExists(page, 'button[aria-label="Einstellungen"], .settings-button, button:has-text("⚙")', 'settings button');
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
await clickIfExists(page, 'button:has-text("Schließen"), button:has-text("Close"), .modal-close', 'close settings');
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('09: notifications', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'notifications');
|
||||||
|
await clickIfExists(page, 'button[aria-label="Benachrichtigungen"], .notifications-button', 'notifications button');
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('10: AI copilot panel', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'ai copilot');
|
||||||
|
await clickIfExists(page, '[data-testid="ki-copilot-toggle"], button:has-text("KI"), button:has-text("Copilot")', 'KI copilot toggle');
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
const input = await page.locator('.copilot-input, input[placeholder*="KI"]').first();
|
||||||
|
if (await input.count() > 0 && await input.isVisible().catch(() => false)) {
|
||||||
|
await input.fill('Erstelle einen Tisch mit 8 Stühlen');
|
||||||
|
await input.press('Enter');
|
||||||
|
await waitForStable(page, 1000);
|
||||||
|
} else {
|
||||||
|
logIssue('AICopilot', 'minor', 'KI Copilot input not found', '');
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('11: zoom and pan controls', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'zoom pan');
|
||||||
|
await clickIfExists(page, 'button:has-text("+")', 'zoom in');
|
||||||
|
await clickIfExists(page, 'button:has-text("-")', 'zoom out');
|
||||||
|
await clickIfExists(page, 'button:has-text("Fit")', 'fit view');
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('12: command line', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'command line');
|
||||||
|
const cmd = await page.locator('.command-line-input, input[placeholder*="Befehl"]').first();
|
||||||
|
if (await cmd.count() > 0 && await cmd.isVisible().catch(() => false)) {
|
||||||
|
await cmd.fill('line');
|
||||||
|
await cmd.press('Enter');
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('13: undo/redo shortcuts', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'undo redo');
|
||||||
|
await page.keyboard.press('Control+z');
|
||||||
|
await waitForStable(page, 300);
|
||||||
|
await page.keyboard.press('Control+y');
|
||||||
|
await waitForStable(page, 300);
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('14: export buttons', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'export');
|
||||||
|
await clickIfExists(page, 'button:has-text("Export"), button:has-text("DXF"), button:has-text("PDF")', 'export buttons');
|
||||||
|
await waitForStable(page, 500);
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('15: logout returns to login', async () => {
|
||||||
|
const stopCapture = captureConsoleErrors(page, 'logout');
|
||||||
|
await clickIfExists(page, 'button:has-text("Abmelden"), button:has-text("Logout")', 'logout button');
|
||||||
|
await waitForStable(page, 1000);
|
||||||
|
const loginVisible = await page.locator('text=Anmelden').first().isVisible().catch(() => false);
|
||||||
|
if (!loginVisible) {
|
||||||
|
logIssue('Auth', 'major', 'Logout did not return to login screen', '', await screenshot(page, '15_logout_failed'));
|
||||||
|
}
|
||||||
|
stopCapture();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { test, expect, chromium } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
|
async function api(method: string, endpoint: string, body?: unknown, token?: string) {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${API_URL}${endpoint}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('diagnose editor DOM', async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||||
|
|
||||||
|
// Register user
|
||||||
|
const email = `diag-${Date.now()}@example.com`;
|
||||||
|
const reg = await api('POST', '/api/auth/register', { email, password: 'Test1234!', name: 'Diag' });
|
||||||
|
const token = reg.body.session.token;
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
const proj = await api('POST', '/api/projects', { name: 'Diag', description: 'x' }, token);
|
||||||
|
|
||||||
|
// Open app with token
|
||||||
|
await page.goto(`${BASE_URL}/`);
|
||||||
|
await page.evaluate((t) => localStorage.setItem('auth_token', t), token);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const failedRequests: string[] = [];
|
||||||
|
const badResponses: { url: string; status: number }[] = [];
|
||||||
|
page.on('requestfailed', req => failedRequests.push(`${req.method()} ${req.url()}: ${req.failure()?.errorText}`));
|
||||||
|
page.on('response', res => {
|
||||||
|
const status = res.status();
|
||||||
|
if (status >= 400) badResponses.push({ url: res.url(), status });
|
||||||
|
});
|
||||||
|
page.on('console', msg => { if (msg.type() === 'error') console.log('CONSOLE ERROR:', msg.text()); });
|
||||||
|
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Take screenshot
|
||||||
|
const shot = '/tmp/web-cad-clone/frontend/playwright-tests/screenshots/diagnose-editor.png';
|
||||||
|
fs.mkdirSync('/tmp/web-cad-clone/frontend/playwright-tests/screenshots', { recursive: true });
|
||||||
|
await page.screenshot({ path: shot, fullPage: false });
|
||||||
|
|
||||||
|
// Dump DOM structure of key elements
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const dataTools = Array.from(document.querySelectorAll('[data-tool]')).map(el => ({
|
||||||
|
tool: el.getAttribute('data-tool'),
|
||||||
|
tag: el.tagName,
|
||||||
|
className: el.className,
|
||||||
|
text: el.textContent?.slice(0, 40) || '',
|
||||||
|
visible: !!(el as HTMLElement).offsetParent,
|
||||||
|
}));
|
||||||
|
const toolBtns = Array.from(document.querySelectorAll('.tool-btn')).map(el => ({
|
||||||
|
tag: el.tagName,
|
||||||
|
className: el.className,
|
||||||
|
dataTool: el.getAttribute('data-tool'),
|
||||||
|
text: el.textContent?.slice(0, 40) || '',
|
||||||
|
visible: !!(el as HTMLElement).offsetParent,
|
||||||
|
}));
|
||||||
|
const leftbar = !!document.querySelector('.leftbar');
|
||||||
|
const ribbon = !!document.querySelector('.ribbon-bar, .ribbon');
|
||||||
|
const canvas = !!document.querySelector('canvas');
|
||||||
|
const canvasCount = document.querySelectorAll('canvas').length;
|
||||||
|
return { leftbar, ribbon, canvas, canvasCount, dataTools, toolBtns };
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = { ...info, failedRequests, badResponses };
|
||||||
|
fs.writeFileSync('/tmp/web-cad-clone/frontend/playwright-tests/editor-dom.json', JSON.stringify(output, null, 2));
|
||||||
|
console.log('DOM INFO:', JSON.stringify(output, null, 2));
|
||||||
|
console.log('Screenshot:', shot);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './playwright-tests',
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list'], ['json', { outputFile: './playwright-tests/playwright-report.json' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'cd /tmp/web-cad-clone/backend && npm run dev',
|
||||||
|
url: 'http://localhost:3001/api/health',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'cd /tmp/web-cad-clone/frontend && npm run dev -- --port 5173',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'], viewport: { width: 1600, height: 1000 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -1778,7 +1778,10 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
|
|||||||
// ─── App Wrapper (Auth Gate) ───────────────────────────
|
// ─── App Wrapper (Auth Gate) ───────────────────────────
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const [authView, setAuthView] = useState<'login' | 'register'>('login');
|
const [authView, setAuthView] = useState<'login' | 'register'>(() => {
|
||||||
|
// URL-based routing: /register shows register form, otherwise login
|
||||||
|
return window.location.pathname === '/register' ? 'register' : 'login';
|
||||||
|
});
|
||||||
const [openedProjectId, setOpenedProjectId] = useState<string | null>(null);
|
const [openedProjectId, setOpenedProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Not authenticated → show Login or Register
|
// Not authenticated → show Login or Register
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const sectionZeichnen: ToolDef[] = [
|
|||||||
{ tool: 'rect', title: 'Rechteck (REC)', label: 'Rechteck', kbd: 'REC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg> },
|
{ tool: 'rect', title: 'Rechteck (REC)', label: 'Rechteck', kbd: 'REC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="1"/></svg> },
|
||||||
{ tool: 'circle', title: 'Kreis (C)', label: 'Kreis', kbd: 'C', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/></svg> },
|
{ tool: 'circle', title: 'Kreis (C)', label: 'Kreis', kbd: 'C', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/></svg> },
|
||||||
{ tool: 'arc', title: 'Bogen (A)', label: 'Bogen', kbd: 'A', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/><path d="M3 12a9 9 0 0 1 9-9"/></svg> },
|
{ tool: 'arc', title: 'Bogen (A)', label: 'Bogen', kbd: 'A', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/><path d="M3 12a9 9 0 0 1 9-9"/></svg> },
|
||||||
|
{ tool: 'polygon', title: 'Polygon (POL)', label: 'Polygon', kbd: 'POL', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 22 8.5 18 22 6 22 2 8.5"/></svg> },
|
||||||
|
{ tool: 'chair', title: 'Stuhl (CH)', label: 'Stuhl', kbd: 'CH', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="10" width="12" height="8" rx="1"/><path d="M6 10V6a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v4"/></svg> },
|
||||||
{ tool: 'text', title: 'Text (T)', label: 'Text', kbd: 'T', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg> },
|
{ tool: 'text', title: 'Text (T)', label: 'Text', kbd: 'T', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg> },
|
||||||
{ tool: 'dimension', title: 'Bemaßung (DIM)', label: 'Bemaßung', kbd: 'DIM', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 3H3l3 18 3-9 6 9 3-9 3 9 3-18z"/><line x1="3" y1="3" x2="21" y2="21"/></svg> },
|
{ tool: 'dimension', title: 'Bemaßung (DIM)', label: 'Bemaßung', kbd: 'DIM', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 3H3l3 18 3-9 6 9 3-9 3 9 3-18z"/><line x1="3" y1="3" x2="21" y2="21"/></svg> },
|
||||||
{ tool: 'hatch', title: 'Schraffur (H)', label: 'Schraffur', kbd: 'H', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="9" y2="3"/><line x1="9" y1="21" x2="21" y2="9"/><line x1="15" y1="21" x2="21" y2="15"/></svg> },
|
{ tool: 'hatch', title: 'Schraffur (H)', label: 'Schraffur', kbd: 'H', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="0"/><line x1="3" y1="9" x2="9" y2="3"/><line x1="9" y1="21" x2="21" y2="9"/><line x1="15" y1="21" x2="21" y2="15"/></svg> },
|
||||||
@@ -36,6 +38,8 @@ const sectionBearbeiten: ToolDef[] = [
|
|||||||
{ tool: 'scale', title: 'Skalieren (SC)', label: 'Scale', kbd: 'SC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg> },
|
{ tool: 'scale', title: 'Skalieren (SC)', label: 'Scale', kbd: 'SC', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg> },
|
||||||
{ tool: 'mirror', title: 'Spiegeln (MI)', label: 'Mirror', kbd: 'MI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v18"/><path d="M5 8l5-5 5 5"/><path d="M5 16l5 5 5-5"/></svg> },
|
{ tool: 'mirror', title: 'Spiegeln (MI)', label: 'Mirror', kbd: 'MI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v18"/><path d="M5 8l5-5 5 5"/><path d="M5 16l5 5 5-5"/></svg> },
|
||||||
{ tool: 'trim', title: 'Trimmen (TR)', label: 'Trim', kbd: 'TR', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> },
|
{ tool: 'trim', title: 'Trimmen (TR)', label: 'Trim', kbd: 'TR', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg> },
|
||||||
|
{ tool: 'extend', title: 'Verlängern (EX)', label: 'Extend', kbd: 'EX', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 12 20 12"/><polyline points="14 6 20 12 14 18"/></svg> },
|
||||||
|
{ tool: 'fillet', title: 'Verrunden (F)', label: 'Fillet', kbd: 'F', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 22V12a10 10 0 0 1 10-10h10"/></svg> },
|
||||||
{ tool: 'offset', title: 'Versatz (O)', label: 'Offset', kbd: 'O', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h12"/><path d="M3 12h18"/><path d="M3 18h12"/><path d="M21 6v12"/></svg> },
|
{ tool: 'offset', title: 'Versatz (O)', label: 'Offset', kbd: 'O', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h12"/><path d="M3 12h18"/><path d="M3 18h12"/><path d="M21 6v12"/></svg> },
|
||||||
{ tool: 'delete', title: 'Löschen (E)', label: 'Löschen', kbd: 'E', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> },
|
{ tool: 'delete', title: 'Löschen (E)', label: 'Löschen', kbd: 'E', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export interface UseYjsBindingResult {
|
|||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_WS_URL = `wss://${window.location.host}`;
|
const DEFAULT_WS_URL = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}`;
|
||||||
|
|
||||||
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
|
||||||
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = opts;
|
const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = opts;
|
||||||
|
|||||||
Reference in New Issue
Block a user