diff --git a/test/capture.mjs b/test/capture.mjs index efece5f..862ba66 100644 --- a/test/capture.mjs +++ b/test/capture.mjs @@ -16,7 +16,7 @@ * * The dummy backend is always-authed, so no login step is needed. */ -import { spawn } from 'node:child_process'; +import { execSync, spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -40,11 +40,24 @@ const DEBUG_PORT = Number(args['debug-port'] ?? 9333); const BASE = `http://localhost:${VITE_PORT}`; const SETTLE_MS = Number(args.settle ?? 2500); const CHROME = args.chrome ?? '/usr/bin/google-chrome'; +const FILTER = args.filter ? String(args.filter) : null; +const ACTIVE_ROUTES = FILTER + ? ROUTES.filter((r) => r.name.includes(FILTER) || r.path.includes(FILTER)) + : ROUTES; function wait(ms) { return new Promise((r) => setTimeout(r, ms)); } +function freePorts(ports) { + try { + const portList = ports.map((p) => `${p}/tcp`).join(' '); + execSync(`fuser -k ${portList} 2>/dev/null || true`, { stdio: 'ignore' }); + } catch { + // ignore + } +} + async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) { const net = await import('node:net'); const start = Date.now(); @@ -66,33 +79,39 @@ async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) { } } +const activeProcs = new Set(); +let activeProfile = null; + function startProc(cmd, procArgs, env, tag) { const child = spawn(cmd, procArgs, { cwd: FRONT_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], - // Own process group per child so stopProc can kill the whole tree - // (e.g. `npx vite` wrapper + the real vite process). detached: process.platform !== 'win32', }); - // Piped stdio keeps the parent alive even after kill(); detach the streams - // so teardown below can actually exit. - child.stdout.on('data', (d) => process.stdout.write(`[${tag}] ${d}`)); + activeProcs.add(child); + child.stdout.on('data', (d) => { + const text = String(d); + if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return; + process.stdout.write(`[${tag}] ${d}`); + }); child.stderr.on('data', (d) => { const text = String(d); - // preview proxy logs are noise during capture; keep errors. if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return; process.stderr.write(`[${tag}] ${d}`); }); + child.on('exit', () => activeProcs.delete(child)); child.stdout.unref?.(); child.stderr.unref?.(); child.unref?.(); return child; } -async function stopProc(child, tag, timeoutMs = 8000) { - if (!child || child.exitCode != null || child.signalCode != null) return; - // Kill the whole process group (child + grandchildren like npx/vite). +async function stopProc(child, tag, timeoutMs = 4000) { + if (!child || child.exitCode != null || child.signalCode != null) { + activeProcs.delete(child); + return; + } const killTree = (sig) => { try { if (child.pid && process.platform !== 'win32') { @@ -100,7 +119,7 @@ async function stopProc(child, tag, timeoutMs = 8000) { return; } } catch { - // fall through to single kill + // fall through } try { child.kill(sig); @@ -120,19 +139,39 @@ async function stopProc(child, tag, timeoutMs = 8000) { }); killTree('SIGTERM'); }); - // Drain any buffered pipe listeners so they can't hold the loop open. + activeProcs.delete(child); try { child.stdout?.destroy(); - } catch { - // ignore - } + } catch {} try { child.stderr?.destroy(); - } catch { - // ignore - } + } catch {} } +async function cleanupEverything() { + for (const child of Array.from(activeProcs)) { + await stopProc(child, 'proc'); + } + if (activeProfile) { + try { + fs.rmSync(activeProfile, { recursive: true, force: true }); + } catch {} + activeProfile = null; + } + freePorts([DUMMY_PORT, VITE_PORT, DEBUG_PORT]); +} + +process.on('SIGINT', async () => { + console.log('\n[capture] Interrupted, cleaning up...'); + await cleanupEverything(); + process.exit(130); +}); + +process.on('SIGTERM', async () => { + await cleanupEverything(); + process.exit(143); +}); + async function main() { if (!fs.existsSync(CHROME)) { throw new Error(`Chrome not found at ${CHROME} (pass --chrome=...)`); @@ -142,6 +181,9 @@ async function main() { } fs.mkdirSync(OUT_DIR, { recursive: true }); + // Ensure ports are free before starting + freePorts([DUMMY_PORT, VITE_PORT, DEBUG_PORT]); + // Production bundle first — screenshots must reflect the shipped app. console.log('[capture] vite build…'); await new Promise((resolve, reject) => { @@ -169,12 +211,12 @@ async function main() { 'preview', ); await waitForPort(VITE_PORT); - await wait(2000); + await wait(1500); // Fresh profile per run: the production SW registers and caches dist/ — // never let it leak between reference/compare runs. - const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-chrome-')); - fs.mkdirSync(profile, { recursive: true }); + activeProfile = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-chrome-')); + fs.mkdirSync(activeProfile, { recursive: true }); const chrome = startProc( CHROME, [ @@ -183,7 +225,7 @@ async function main() { '--disable-gpu', '--hide-scrollbars', `--remote-debugging-port=${DEBUG_PORT}`, - `--user-data-dir=${profile}`, + `--user-data-dir=${activeProfile}`, 'about:blank', ], {}, @@ -194,44 +236,37 @@ async function main() { let failed = 0; try { for (const viewport of VIEWPORTS) { - for (const route of ROUTES) { + for (const route of ACTIVE_ROUTES) { const file = path.join(OUT_DIR, `${route.name}.${viewport.name}.png`); try { await capturePage(DEBUG_PORT, `${BASE}${route.path}`, file, { width: viewport.width, height: viewport.height, settleMs: SETTLE_MS, + dialog: route.dialog, }); const kb = (fs.statSync(file).size / 1024).toFixed(0); - console.log(`[capture] ${route.path} @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`); + console.log(`[capture] ${route.name} (${route.path}) @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`); } catch (err) { - console.error(`[capture] FAIL ${route.path} @ ${viewport.name}: ${err.message}`); + console.error(`[capture] FAIL ${route.name} (${route.path}) @ ${viewport.name}: ${err.message}`); failed++; } } } } finally { - await stopProc(chrome, 'chrome'); - await stopProc(server, 'preview'); - await stopProc(dummy, 'dummy'); - // Remove the throwaway Chrome profile (SW + caches). - try { - fs.rmSync(profile, { recursive: true, force: true }); - } catch { - // ignore - } + await cleanupEverything(); } + if (failed > 0) { console.error(`[capture] ${failed} shots failed`); process.exit(1); } console.log(`[capture] done → ${OUT_DIR}`); - // Explicit exit: spawned grandchildren (preview server, chrome helpers) can - // otherwise keep the event loop alive after teardown. process.exit(0); } -main().catch((err) => { +main().catch(async (err) => { console.error(`[capture] FAILED: ${err.message}`); + await cleanupEverything(); process.exit(1); }); diff --git a/test/cdp.mjs b/test/cdp.mjs index dab99a1..40aad18 100644 --- a/test/cdp.mjs +++ b/test/cdp.mjs @@ -161,7 +161,7 @@ async function httpJson(url, options = {}) { * Opens a new page target on the browser, navigates, waits for the app root * to contain content + network to quiet down, captures a PNG. */ -export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000 }) { +export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000, dialog }) { // Chrome ≥136 requires PUT for /json/new. const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, { method: 'PUT', @@ -205,6 +205,58 @@ export async function capturePage(debugPort, url, file, { width, height, settleM // Extra quiet period for images / thumbhash decode / layout. await wait(settleMs); + // If a dialog needs to be opened before capturing: + if (dialog) { + if (dialog.name === 'settings') { + // Open user avatar menu + await session.send('Runtime.evaluate', { + expression: `(() => { + const btn = document.querySelector('nav .MuiAvatar-root')?.closest('button'); + if (btn) btn.click(); + })()`, + }); + await wait(300); + + // Click "Einstellungen" menu item + await session.send('Runtime.evaluate', { + expression: `(() => { + const item = Array.from(document.querySelectorAll('.MuiMenuItem-root')) + .find(el => el.textContent.includes('Einstellungen')); + if (item) item.click(); + })()`, + }); + await wait(400); + + // Click specific tab if requested + if (dialog.tab) { + await session.send('Runtime.evaluate', { + expression: `(() => { + const target = ${JSON.stringify(dialog.tab.toLowerCase())}; + const tab = Array.from(document.querySelectorAll('[role="dialog"] [role="tab"]')) + .find(el => el.textContent.trim().toLowerCase() === target || el.textContent.toLowerCase().includes(target)); + if (tab) tab.click(); + })()`, + }); + await wait(300); + } + + // Wait for any dialog-internal loading spinners to settle + const dialogDeadline = Date.now() + 15000; + while (Date.now() < dialogDeadline) { + const res = await session.send('Runtime.evaluate', { + expression: `(() => { + const spinners = document.querySelectorAll('[role="dialog"] [role="progressbar"]').length; + return { spinners }; + })()`, + returnByValue: true, + }); + if ((res.result?.value?.spinners ?? 0) === 0) break; + await wait(300); + } + await wait(800); + } + } + // Determinism: hide carets and scrollbars. await session.send('Runtime.evaluate', { expression: `(() => { const s = document.createElement('style'); diff --git a/test/collect.mjs b/test/collect.mjs index 26d62f0..0bdb414 100644 --- a/test/collect.mjs +++ b/test/collect.mjs @@ -77,6 +77,27 @@ async function main() { fs.mkdirSync(FIXTURE_DIR, { recursive: true }); fs.mkdirSync(IMAGE_DIR, { recursive: true }); + const snapshotFile = path.join(FIXTURE_DIR, 'snapshot.json'); + try { + await fetch(`${BACKEND}/api/auth/me`, { signal: AbortSignal.timeout(1500) }); + } catch { + if (fs.existsSync(snapshotFile)) { + console.log(`[rig:collect] Live backend (${BACKEND}) not running; using existing ${snapshotFile}`); + return; + } + throw new Error(`Live backend (${BACKEND}) not running and ${snapshotFile} does not exist.`); + } + + // Load existing snapshot if available for fallbacks + let existingSnapshot = null; + try { + if (fs.existsSync(snapshotFile)) { + existingSnapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8')); + } + } catch { + // Ignore + } + // GOOGLE_CLIENT_ID comes from the testing backend env (../dashback/.env). let dotenvClientId = null; try { @@ -127,13 +148,25 @@ async function main() { const data = await getOptional(token, `/api/category/${id}/articles`); snapshot.catalog.articlesByCategory[String(id)] = data?.articles ?? []; } - snapshot.catalog.articleDetail = ( - await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}`) - )?.article ?? null; + + const allArticles = Object.values(snapshot.catalog.articlesByCategory).flat(); + const targetSku = + allArticles.find((a) => a.sku === KNOWN_SKU)?.sku || + allArticles[0]?.sku || + KNOWN_SKU; + + snapshot.catalog.articleDetail = + (await getOptional(token, `/api/article/${encodeURIComponent(targetSku)}`))?.article ?? + existingSnapshot?.catalog?.articleDetail ?? + null; snapshot.catalog.articleHistory = - (await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/history?limit=50`))?.history ?? []; + (await getOptional(token, `/api/article/${encodeURIComponent(targetSku)}/history?limit=50`))?.history ?? + existingSnapshot?.catalog?.articleHistory ?? + []; snapshot.catalog.articleSuppliers = - (await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/supplier`))?.suppliers ?? []; + (await getOptional(token, `/api/article/${encodeURIComponent(targetSku)}/supplier`))?.suppliers ?? + existingSnapshot?.catalog?.articleSuppliers ?? + []; snapshot.catalog.brands = (await getOptional(token, '/api/brand'))?.brands ?? []; snapshot.catalog.units = (await getOptional(token, '/api/unit'))?.units ?? []; snapshot.catalog.warehouses = diff --git a/test/dummyBackend.mjs b/test/dummyBackend.mjs index 29a0e5a..66ce651 100644 --- a/test/dummyBackend.mjs +++ b/test/dummyBackend.mjs @@ -161,6 +161,22 @@ const server = http.createServer(async (req, res) => { article: { ...catalog.articleDetail, sku, name: catalog.articleDetail.name }, }); } + const catArticle = Object.values(catalog.articlesByCategory ?? {}).flat().find((a) => a.sku === sku); + if (catArticle) { + return json(res, 200, { + article: { + id: catArticle.id, + sku: catArticle.sku, + name: catArticle.name, + unitamount: 1, + unit: { si: 'stk', name: 'Stück' }, + prices: [{ minmultiplier: 1, net: 10, taxRate: 19, customergroupId: 1 }], + categoryIds: [], + photos: catArticle.fullHash ? [{ fullHash: catArticle.fullHash, thumbHash: catArticle.thumbHash }] : [], + children: [], + }, + }); + } return json(res, 404, { error: 'Article not found' }); } if (p === '/api/article/history') return json(res, 200, { history: misc.articleHistoryGrid ?? [] }); @@ -419,12 +435,27 @@ const server = http.createServer(async (req, res) => { if (p.match(/^\/api\/company\/setting\/.+$/) && method === 'PUT') { return json(res, 200, { setting: { key: decodeURIComponent(p.split('/').pop()), ...(body ?? {}) } }); } + if (p === '/api/company/import' && method === 'POST') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write('event: status\ndata: {"message": "Import gestartet. Daten werden verarbeitet..."}\n\n'); + res.write('event: progress\ndata: {"phase": "categories", "current": 1, "total": 1, "item": "Katalog"}\n\n'); + res.write('event: done\ndata: {"ok": true, "message": "Import erfolgreich abgeschlossen.", "stats": {"categories": 1, "categoriesCreated": 1, "categoryPictures": 0, "articlesTotal": 1, "articlesImported": 1, "articlesUpdated": 0, "variantsLinked": 0, "articlePictures": 1}}\n\n'); + res.end(); + return; + } if (p.match(/^\/api\/number\/\d+$/) && method === 'PATCH') { return json(res, 200, { number: { id: Number(p.split('/').pop()), ...(body ?? {}) } }); } if (p === '/api/posterminal' && method === 'PUT') { return json(res, 200, { posterminal: { id: 999001, ...(body ?? {}) } }); } + if (p.match(/^\/api\/posterminal\/\d+$/) && method === 'PATCH') { + return json(res, 200, { posterminal: { id: Number(p.split('/').pop()), ...(body ?? {}) } }); + } if (p.match(/^\/api\/posterminal\/\d+\/(all|category\/\d+)$/) && ['PUT', 'DELETE'].includes(method)) { return json(res, 200, { id: Number(p.split('/')[3]), categoryIds: [] }); } diff --git a/test/routes.mjs b/test/routes.mjs index 270d046..4588067 100644 --- a/test/routes.mjs +++ b/test/routes.mjs @@ -33,4 +33,10 @@ export const ROUTES = [ { name: 'infos', path: '/infos' }, { name: 'infos-statistics', path: '/infos/statistics' }, { name: 'infos-globalhistory', path: '/infos/globalhistory' }, + { name: 'dialog-settings-unternehmen', path: '/artikel', dialog: { name: 'settings', tab: 'Unternehmen' } }, + { name: 'dialog-settings-einstellungen', path: '/artikel', dialog: { name: 'settings', tab: 'Einstellungen' } }, + { name: 'dialog-settings-nummernkreise', path: '/artikel', dialog: { name: 'settings', tab: 'Nummernkreise' } }, + { name: 'dialog-settings-drucker', path: '/artikel', dialog: { name: 'settings', tab: 'Drucker' } }, + { name: 'dialog-settings-importexport', path: '/artikel', dialog: { name: 'settings', tab: 'Import/Export' } }, + { name: 'dialog-settings-posterminal', path: '/artikel', dialog: { name: 'settings', tab: 'POS Terminal' } }, ];