This commit is contained in:
seb
2026-10-08 04:45:35 +02:00
parent b01ebccf90
commit ec238c7b50
5 changed files with 200 additions and 43 deletions

View File

@@ -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);
});

View File

@@ -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');

View File

@@ -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 =

View File

@@ -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: [] });
}

View File

@@ -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' } },
];