This commit is contained in:
seb
2026-10-07 21:28:26 +02:00
parent ee5a30bb21
commit d49ec77043
509 changed files with 2969 additions and 924 deletions

45
test/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Visual testing rig
Dummy backend + frozen fixtures + screenshot reference/compare. No npm deps
beyond the repo (uses system Chrome at `/usr/bin/google-chrome`).
## Workflow
```bash
# Build reference screenshots from live data (:4208 must run in dashback).
# Collects fixtures, then captures 21 routes × 2 viewports into
# test/screenshots/reference/ (committed to git).
npm run rig:reference
# Capture fresh screenshots and compare against reference (all in one).
# Fails when > 0.1% of pixels differ; diffs → test/screenshots/diff/
npm run rig:compare
```
## Viewports
- `1920x1200`, `1280x960` (see `test/routes.mjs`)
## Files
| File | Role |
|---|---|
| `test/routes.mjs` | Route + viewport definitions (concrete ids: `ART-000001`, delivery `6`, entity `1`) |
| `test/collect.mjs` | Fixture collector (mints JWT via dashback env + signing lib) |
| `test/dummyBackend.mjs` | Always-authed stub serving `test/fixtures/snapshot.json` + `images/` |
| `test/cdp.mjs` | Zero-dep CDP client (fresh page target per shot, settle wait, PNG capture) |
| `test/capture.mjs` | Orchestrates dummy + vite + Chrome |
| `test/compare.mjs` | Zero-dep PNG decoder + comparator (0.1% tolerance, red diff PNGs) |
| `test/fixtures/` | Committed snapshot + binary thumbs (2.5 MiB) |
| `test/screenshots/reference/` | Committed reference PNGs (7.5 MiB) |
## Notes
- `vite.config.js` honors `RIG_API_TARGET` for the `/api` proxy (default `http://localhost:4208`),
so the rig runs alongside live dev servers on :4208/:4209.
- The dummy holds `/api/events` open with heartbeats and never emits — fixtures stay frozen.
- Mutations (POST/PATCH/PUT/DELETE) echo fixtures so UI actions don't crash mid-run.
- `GET /api/history` and `GET /api/article/history` don't exist in the backend yet
(frontend already renders empty grids); the collector stores empty fixtures for them.
- Capture waits for `#root` content > 20 KB with zero `[role=progressbar]` spinners,
plus a settle delay, then hides carets/scrollbars for determinism.

152
test/capture.mjs Normal file
View File

@@ -0,0 +1,152 @@
/**
* Capture screenshots for every route × viewport against the dummy backend.
*
* Usage:
* node test/capture.mjs [--out test/screenshots/current]
*
* Flow: spawns the dummy backend (default :4218) + `vite --port 4229` with
* RIG_API_TARGET pointing at the dummy, then drives a persistent headless
* Chrome (remote-debugging port) via CDP: one fresh page target per shot,
* waits for React hydration + data, captures full-page PNGs. No npm deps.
*
* The dummy backend is always-authed, so no login step is needed.
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ROUTES, VIEWPORTS } from './routes.mjs';
import { capturePage } from './cdp.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONT_ROOT = path.resolve(__dirname, '..');
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.split('=');
return [k.replace(/^--/, ''), v ?? true];
}),
);
const OUT_DIR = path.resolve(FRONT_ROOT, args.out ?? 'test/screenshots/current');
const DUMMY_PORT = Number(args['dummy-port'] ?? 4218);
const VITE_PORT = Number(args['vite-port'] ?? 4229);
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';
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) {
const net = await import('node:net');
const start = Date.now();
for (;;) {
try {
await new Promise((resolve, reject) => {
const s = net.default.connect(port, host);
s.on('connect', () => {
s.end();
resolve();
});
s.on('error', reject);
});
return;
} catch {
if (Date.now() - start > timeoutMs) throw new Error(`port ${port} not ready`);
await wait(300);
}
}
}
function startProc(cmd, procArgs, env, tag) {
const child = spawn(cmd, procArgs, {
cwd: FRONT_ROOT,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (d) => process.stdout.write(`[${tag}] ${d}`));
child.stderr.on('data', (d) => {
const text = String(d);
// vite proxy logs are noise during capture; keep errors.
if (tag === 'vite' && text.includes('[vite-proxy]')) return;
process.stderr.write(`[${tag}] ${d}`);
});
return child;
}
async function main() {
if (!fs.existsSync(CHROME)) {
throw new Error(`Chrome not found at ${CHROME} (pass --chrome=...)`);
}
if (!fs.existsSync(path.join(FRONT_ROOT, 'test', 'fixtures', 'snapshot.json'))) {
throw new Error('Missing test/fixtures/snapshot.json — run: npm run rig:collect');
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const dummy = startProc('node', ['test/dummyBackend.mjs', `--port=${DUMMY_PORT}`], {}, 'dummy');
await waitForPort(DUMMY_PORT);
const vite = startProc(
'npx',
['vite', '--port', String(VITE_PORT), '--strictPort'],
{ RIG_API_TARGET: `http://localhost:${DUMMY_PORT}` },
'vite',
);
await waitForPort(VITE_PORT);
await wait(3000);
const profile = '/tmp/rig-chrome-profile';
fs.mkdirSync(profile, { recursive: true });
const chrome = startProc(
CHROME,
[
'--headless=new',
'--no-sandbox',
'--disable-gpu',
'--hide-scrollbars',
`--remote-debugging-port=${DEBUG_PORT}`,
`--user-data-dir=${profile}`,
'about:blank',
],
{},
'chrome',
);
await waitForPort(DEBUG_PORT);
let failed = 0;
try {
for (const viewport of VIEWPORTS) {
for (const route of 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,
});
const kb = (fs.statSync(file).size / 1024).toFixed(0);
console.log(`[capture] ${route.path} @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`);
} catch (err) {
console.error(`[capture] FAIL ${route.path} @ ${viewport.name}: ${err.message}`);
failed++;
}
}
}
} finally {
chrome.kill();
vite.kill();
dummy.kill();
}
if (failed > 0) {
console.error(`[capture] ${failed} shots failed`);
process.exit(1);
}
console.log(`[capture] done → ${OUT_DIR}`);
}
main().catch((err) => {
console.error(`[capture] FAILED: ${err.message}`);
process.exit(1);
});

215
test/cdp.mjs Normal file
View File

@@ -0,0 +1,215 @@
/**
* CDP helpers for the visual testing rig (no npm deps).
* Connects to a Chrome remote-debugging port, opens a fresh page target per
* shot, waits for the app to settle, captures a full-page PNG.
*/
import net from 'node:net';
import fs from 'node:fs';
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
class CdpConnection {
constructor(socket) {
this.socket = socket;
this.id = 0;
this.pending = new Map();
this.buf = Buffer.alloc(0);
this.handlers = new Map();
socket.on('data', (chunk) => this.onData(chunk));
}
onData(chunk) {
this.buf = Buffer.concat([this.buf, chunk]);
for (;;) {
if (this.buf.length < 2) return;
const opcode = this.buf[0] & 0x0f;
let len = this.buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (this.buf.length < 4) return;
len = this.buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (this.buf.length < 10) return;
len = Number(this.buf.readBigUInt64BE(2));
off = 10;
}
if ((this.buf[1] & 0x80) !== 0) off += 4;
if (this.buf.length < off + len) return;
const payload = this.buf.subarray(off, off + len);
this.buf = this.buf.subarray(off + len);
if (opcode === 0x8) {
this.socket.end();
return;
}
if (opcode !== 0x1) continue;
let msg;
try {
msg = JSON.parse(payload.toString('utf8'));
} catch {
continue;
}
if (msg.id != null && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
if (msg.error) reject(new Error(`${msg.error.code}: ${msg.error.message}`));
else resolve(msg.result);
} else if (msg.method && this.handlers.has(msg.method)) {
for (const fn of this.handlers.get(msg.method)) {
try {
fn(msg.params);
} catch {
// ignore
}
}
}
}
}
sendFrame(opcode, payload) {
const len = payload.length;
let header;
if (len < 126) header = Buffer.from([0x80 | opcode, 0x80 | len, 0, 0, 0, 0]);
else if (len < 65536) {
header = Buffer.alloc(8);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(14);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
this.socket.write(Buffer.concat([header, payload]));
}
send(method, params = {}, timeoutMs = 30000) {
const callId = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(callId, { resolve, reject });
this.sendFrame(0x1, Buffer.from(JSON.stringify({ id: callId, method, params })));
setTimeout(() => {
if (this.pending.has(callId)) {
this.pending.delete(callId);
reject(new Error(`CDP timeout: ${method}`));
}
}, timeoutMs);
});
}
on(method, fn) {
if (!this.handlers.has(method)) this.handlers.set(method, new Set());
this.handlers.get(method).add(fn);
return () => this.handlers.get(method)?.delete(fn);
}
close() {
try {
this.sendFrame(0x8, Buffer.alloc(0));
} catch {
// ignore
}
this.socket.end();
}
}
export async function cdpConnect(wsUrl) {
const url = new URL(wsUrl);
const key = Buffer.from(`rig-${Date.now()}`).toString('base64');
const socket = net.connect(Number(url.port), url.hostname);
await new Promise((resolve, reject) => {
socket.on('connect', () => {
socket.write(
`GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.hostname}:${url.port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
);
});
socket.on('error', reject);
let head = '';
const onData = (chunk) => {
head += chunk.toString('latin1');
if (head.includes('\r\n\r\n')) {
socket.off('data', onData);
resolve();
}
};
socket.on('data', onData);
});
return new CdpConnection(socket);
}
async function httpJson(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.json();
}
/**
* 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 }) {
// Chrome ≥136 requires PUT for /json/new.
const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, {
method: 'PUT',
});
const session = await cdpConnect(target.webSocketDebuggerUrl);
try {
await session.send('Page.enable');
await session.send('Runtime.enable');
await session.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: false,
});
await session.send('Page.navigate', { url });
// Wait until #root has substantial content (React hydrated + data loaded).
const deadline = Date.now() + 45000;
for (;;) {
const res = await session.send(
'Runtime.evaluate',
{
expression: `(() => {
const root = document.getElementById('root');
const len = root ? root.innerHTML.length : 0;
const spinners = document.querySelectorAll('[role="progressbar"]').length;
return { len, spinners };
})()`,
returnByValue: true,
},
15000,
);
const { len, spinners } = res.result?.value ?? { len: 0, spinners: 99 };
if (len > 20000 && spinners === 0) break;
if (Date.now() > deadline) {
console.warn(`[capture] settle timeout for ${url} (root=${len}, spinners=${spinners})`);
break;
}
await wait(500);
}
// Extra quiet period for images / thumbhash decode / layout.
await wait(settleMs);
// Determinism: hide carets and scrollbars.
await session.send('Runtime.evaluate', {
expression: `(() => { const s = document.createElement('style');
s.textContent = '*{caret-color:transparent !important} ::-webkit-scrollbar{display:none !important}';
document.head.appendChild(s); })()`,
});
await wait(400);
const { data } = await session.send(
'Page.captureScreenshot',
{ format: 'png', captureBeyondViewport: true, fromSurface: true },
30000,
);
fs.writeFileSync(file, Buffer.from(data, 'base64'));
} finally {
session.close();
await fetch(`http://127.0.0.1:${debugPort}/json/close/${target.id}`).catch(() => {});
}
}

240
test/collect.mjs Normal file
View File

@@ -0,0 +1,240 @@
/**
* Collects a deterministic fixture snapshot from the live testing backend.
*
* Usage:
* npm run rig:reference (runs collect first, then captures)
*
* Reads ../dashback/.env for GOOGLE_CLIENT_ID (stored as googleClientId in the
* snapshot) and mints a JWT for user id 1 with the backend's own env + signing
* library, then GETs every endpoint the dummy backend needs to serve.
* Writes test/fixtures/snapshot.json (plus images/ for binary thumbs).
*
* The live backend must run at http://localhost:4208 (npm run dev in dashback).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONT_ROOT = path.resolve(__dirname, '..');
const BACK_ROOT = path.resolve(FRONT_ROOT, '../dashback');
const FIXTURE_DIR = path.join(FRONT_ROOT, 'test', 'fixtures');
const IMAGE_DIR = path.join(FIXTURE_DIR, 'images');
const BACKEND = process.env.RIG_BACKEND ?? 'http://localhost:4208';
function parseDotEnv(text) {
const out = {};
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return out;
}
async function mintToken() {
const env = await import(path.join(BACK_ROOT, 'src/config/env.js')).then(
(m) => m.default,
);
const { default: jwt } = await import(
path.join(BACK_ROOT, 'node_modules/jsonwebtoken/index.js')
);
return {
token: jwt.sign({ id: 1 }, env.jwtSecret, { expiresIn: '1d' }),
googleClientId: env.googleClientId ?? null,
};
}
async function get(token, p, { binary = false } = {}) {
const res = await fetch(`${BACKEND}${p}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`GET ${p} → ${res.status} ${text.slice(0, 200)}`);
}
if (binary) return Buffer.from(await res.arrayBuffer());
return res.json();
}
async function getOptional(token, p) {
try {
return await get(token, p);
} catch (err) {
console.warn(`[rig:collect] skip ${p}: ${err.message}`);
return null;
}
}
const KNOWN_SKU = 'ART-000001';
const KNOWN_DELIVERY = 6;
const KNOWN_ENTITY = 1;
async function main() {
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
fs.mkdirSync(IMAGE_DIR, { recursive: true });
// GOOGLE_CLIENT_ID comes from the testing backend env (../dashback/.env).
let dotenvClientId = null;
try {
const raw = fs.readFileSync(path.join(BACK_ROOT, '.env'), 'utf8');
dotenvClientId = parseDotEnv(raw).GOOGLE_CLIENT_ID ?? null;
} catch {
console.warn('[rig:collect] ../dashback/.env not readable, using backend env module');
}
const { token, googleClientId } = await mintToken();
const me = await get(token, '/api/auth/me');
const user = me.user;
const companyId = user.selectedCompanyId;
const snapshot = {
meta: {
collectedAt: new Date().toISOString(),
backend: BACKEND,
companyId,
googleClientId: dotenvClientId ?? googleClientId,
},
auth: {
me: { user, googleClientId: dotenvClientId ?? googleClientId },
companies: (await get(token, '/api/company')).companies ?? [],
},
company: {},
catalog: {},
parties: {},
documents: {},
misc: {},
images: {},
};
// Company detail is fetched per-id via GET /api/company (list) — the
// frontend's companyApi.getById() filters the list client-side.
snapshot.company.detail =
snapshot.auth.companies.find((c) => c.id === companyId) ?? null;
snapshot.company.settings =
(await getOptional(token, '/api/company/setting'))?.settings ?? [];
// Catalog
const tree = await get(token, '/api/category/tree');
snapshot.catalog.tree = tree;
const categoryIds = (tree.tree ?? []).map((t) => t.id);
snapshot.catalog.articlesByCategory = {};
for (const id of categoryIds) {
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;
snapshot.catalog.articleHistory =
(await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/history?limit=50`))?.history ?? [];
snapshot.catalog.articleSuppliers =
(await getOptional(token, `/api/article/${encodeURIComponent(KNOWN_SKU)}/supplier`))?.suppliers ?? [];
snapshot.catalog.brands = (await getOptional(token, '/api/brand'))?.brands ?? [];
snapshot.catalog.units = (await getOptional(token, '/api/unit'))?.units ?? [];
snapshot.catalog.warehouses =
(await getOptional(token, '/api/warehouse'))?.warehouses ?? [];
snapshot.catalog.attributes =
(await getOptional(token, '/api/attributename'))?.attributenames ?? [];
snapshot.catalog.properties =
(await getOptional(token, '/api/propertyname'))?.propertynames ?? [];
// Parties
snapshot.parties.suppliers =
(await getOptional(token, '/api/supplier'))?.suppliers ?? [];
snapshot.parties.entities =
(await getOptional(token, '/api/entity'))?.entities ?? [];
snapshot.parties.entityDetail =
(await getOptional(token, `/api/entity/${KNOWN_ENTITY}`))?.entity ?? null;
snapshot.parties.entityAddresses =
(await getOptional(token, `/api/entity/${KNOWN_ENTITY}/address`))?.addresses ?? [];
snapshot.parties.entityContacts =
(await getOptional(token, `/api/entity/${KNOWN_ENTITY}/contact`))?.contacts ?? [];
snapshot.parties.customergroups =
(await getOptional(token, '/api/customergroup'))?.customergroups ?? [];
// Documents
snapshot.documents.deliveries =
(await getOptional(token, '/api/delivery'))?.deliveries ?? [];
snapshot.documents.deliveryDetail =
(await getOptional(token, `/api/delivery/${KNOWN_DELIVERY}`))?.delivery ?? null;
snapshot.documents.quotes = (await getOptional(token, '/api/quote'))?.quotes ?? [];
snapshot.documents.orders = (await getOptional(token, '/api/order'))?.orders ?? [];
snapshot.documents.numbers =
(await getOptional(token, '/api/number'))?.numbers ?? [];
// Misc
snapshot.misc.posterminals =
(await getOptional(token, '/api/posterminal'))?.posterminals ?? [];
snapshot.misc.printers = (await getOptional(token, '/api/printer'))?.printers ?? [];
snapshot.misc.labeltemplates =
(await getOptional(token, '/api/labeltemplate'))?.labeltemplates ?? [];
snapshot.misc.vfs = (await getOptional(token, '/api/vfs'))?.files ?? [];
snapshot.misc.globalHistory =
(await getOptional(token, '/api/history?limit=50'))?.history ??
(await getOptional(token, '/api/history?limit=50'))?.rows ??
[];
snapshot.misc.articleHistoryGrid =
(await getOptional(token, '/api/article/history?limit=50'))?.history ??
(await getOptional(token, '/api/article/history?limit=50'))?.rows ??
[];
// Binary thumbs: collect every fullHash referenced by articles/categories/brands
// so /api/thumb/:hash and /api/image/:hash serve byte-identical images.
// Backend fullHash is an 8-char alphanumeric string (see dashback pic.js).
const hashes = new Set();
const collectHash = (h) => {
if (typeof h === 'string' && /^[A-Za-z0-9]{8}$/.test(h)) hashes.add(h);
};
for (const list of Object.values(snapshot.catalog.articlesByCategory)) {
for (const a of list) collectHash(a.fullHash);
}
for (const t of tree.tree ?? []) collectHash(t.fullHash);
for (const b of snapshot.catalog.brands) {
collectHash(b.fullHash);
for (const p of b.pics ?? []) collectHash(p.fullHash);
}
for (const p of snapshot.catalog.articleDetail?.photos ?? []) collectHash(p.fullHash);
let imageBytes = 0;
for (const hash of hashes) {
try {
const buf = await get(token, `/api/thumb/${hash}`, { binary: true });
fs.writeFileSync(path.join(IMAGE_DIR, `${hash}.avif`), buf);
snapshot.images[hash] = `${hash}.avif`;
imageBytes += buf.length;
} catch (err) {
console.warn(`[rig:collect] thumb ${hash.slice(0, 12)}…: ${err.message}`);
}
}
fs.writeFileSync(
path.join(FIXTURE_DIR, 'snapshot.json'),
`${JSON.stringify(snapshot, null, 2)}\n`,
);
const counts = {
categories: categoryIds.length,
categoryArticles: Object.values(snapshot.catalog.articlesByCategory).reduce(
(n, l) => n + l.length,
0,
),
brands: snapshot.catalog.brands.length,
images: Object.keys(snapshot.images).length,
deliveries: snapshot.documents.deliveries.length,
entities: snapshot.parties.entities.length,
};
console.log(
`[rig:collect] snapshot.json + ${counts.images} images ` +
`(${(imageBytes / 1024).toFixed(0)} KiB): ${JSON.stringify(counts)}`,
);
}
main().catch((err) => {
console.error(`[rig:collect] FAILED: ${err.message}`);
process.exit(1);
});

263
test/compare.mjs Normal file
View File

@@ -0,0 +1,263 @@
/**
* Compare current screenshots against the reference set with very low tolerance.
*
* Usage:
* node test/compare.mjs [--ref test/screenshots/reference] [--cur test/screenshots/current]
* [--tolerance 0.001] [--diff test/screenshots/diff]
*
* Pure Node.js PNG decoder + comparator (no npm deps): parses PNG (8-bit
* RGB/RGBA, non-interlaced — what Chrome headless writes), compares pixels
* with per-channel tolerance, fails when the fraction of differing pixels
* exceeds --tolerance (default 0.1%).
*
* Exit 0 when all match, 1 with a per-file report otherwise. Writes red-tinted
* diff PNGs for mismatches when sizes are equal.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRONT_ROOT = path.resolve(__dirname, '..');
const args = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.split('=');
return [k.replace(/^--/, ''), v ?? true];
}),
);
const REF_DIR = path.resolve(FRONT_ROOT, args.ref ?? 'test/screenshots/reference');
const CUR_DIR = path.resolve(FRONT_ROOT, args.cur ?? 'test/screenshots/current');
const DIFF_DIR = path.resolve(FRONT_ROOT, args.diff ?? 'test/screenshots/diff');
const TOLERANCE = Number(args.tolerance ?? 0.001); // fraction of pixels allowed to differ
const CHANNEL_TOL = Number(args['channel-tol'] ?? 8); // per-channel 0-255 tolerance
function parsePng(buf) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!buf.subarray(0, 8).equals(sig)) throw new Error('not a PNG');
let off = 8;
let width;
let height;
let bitDepth;
let colorType;
let interlace;
const idat = [];
while (off < buf.length) {
const len = buf.readUInt32BE(off);
const type = buf.toString('ascii', off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
interlace = data[12];
} else if (type === 'IDAT') {
idat.push(data);
} else if (type === 'IEND') {
break;
}
off += 12 + len;
}
if (bitDepth !== 8 || interlace !== 0 || ![2, 6].includes(colorType)) {
throw new Error(`unsupported PNG (depth=${bitDepth} type=${colorType} interlace=${interlace})`);
}
const channels = colorType === 6 ? 4 : 3;
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = width * channels;
const pixels = Buffer.alloc(width * height * channels);
let p = 0;
let prev = Buffer.alloc(stride);
for (let y = 0; y < height; y++) {
const filter = raw[p++];
const cur = raw.subarray(p, p + stride);
p += stride;
const out = Buffer.alloc(stride);
for (let i = 0; i < stride; i++) {
const a = i >= channels ? out[i - channels] : 0;
const b = prev[i];
const c = i >= channels ? prev[i - channels] : 0;
let v;
switch (filter) {
case 0: v = cur[i]; break;
case 1: v = (cur[i] + a) & 255; break;
case 2: v = (cur[i] + b) & 255; break;
case 3: v = (cur[i] + ((a + b) >> 1)) & 255; break;
case 4: {
const pa = Math.abs(b - c);
const pb = Math.abs(a - c);
const pc = Math.abs(a + b - 2 * c);
const pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
v = (cur[i] + pr) & 255;
break;
}
default: throw new Error(`bad filter ${filter}`);
}
out[i] = v;
}
out.copy(pixels, y * stride);
prev = out;
}
return { width, height, channels, pixels };
}
function crc32(buf) {
let table = crc32.table;
if (!table) {
table = crc32.table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
}
let crc = -1;
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 255];
return (crc ^ -1) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, crc]);
}
function encodePng(width, height, rgba) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0;
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([
sig,
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw)),
chunk('IEND', Buffer.alloc(0)),
]);
}
function toRgba(parsed) {
if (parsed.channels === 4) return parsed.pixels;
const out = Buffer.alloc(parsed.width * parsed.height * 4);
for (let i = 0, j = 0; i < parsed.pixels.length; i += 3, j += 4) {
out[j] = parsed.pixels[i];
out[j + 1] = parsed.pixels[i + 1];
out[j + 2] = parsed.pixels[i + 2];
out[j + 3] = 255;
}
return out;
}
function compareFiles(refFile, curFile) {
const ref = parsePng(fs.readFileSync(refFile));
const cur = parsePng(fs.readFileSync(curFile));
if (ref.width !== cur.width || ref.height !== cur.height) {
return {
ok: false,
reason: `size mismatch ref=${ref.width}x${ref.height} cur=${cur.width}x${cur.height}`,
};
}
const a = toRgba(ref);
const b = toRgba(cur);
const total = ref.width * ref.height;
let diff = 0;
let maxDelta = 0;
const diffImg = Buffer.alloc(a.length);
for (let i = 0; i < total; i++) {
const o = i * 4;
const d = Math.max(
Math.abs(a[o] - b[o]),
Math.abs(a[o + 1] - b[o + 1]),
Math.abs(a[o + 2] - b[o + 2]),
);
if (d > maxDelta) maxDelta = d;
if (d > CHANNEL_TOL) {
diff++;
diffImg[o] = 255;
diffImg[o + 1] = 0;
diffImg[o + 2] = 0;
diffImg[o + 3] = 255;
} else {
// dim grayscale of reference for context
const g = Math.round((a[o] + a[o + 1] + a[o + 2]) / 6);
diffImg[o] = g;
diffImg[o + 1] = g;
diffImg[o + 2] = g;
diffImg[o + 3] = 255;
}
}
const frac = diff / total;
return {
ok: frac <= TOLERANCE,
diffPixels: diff,
totalPixels: total,
frac,
maxDelta,
diffImg: diff > 0 ? encodePng(ref.width, ref.height, diffImg) : null,
};
}
function main() {
if (!fs.existsSync(REF_DIR)) {
console.error(`[compare] no reference dir ${REF_DIR} — run: npm run rig:reference`);
process.exit(1);
}
if (!fs.existsSync(CUR_DIR)) {
console.error(`[compare] no current dir ${CUR_DIR} — run: npm run rig:screenshots`);
process.exit(1);
}
fs.mkdirSync(DIFF_DIR, { recursive: true });
const files = fs.readdirSync(REF_DIR).filter((f) => f.endsWith('.png')).sort();
if (!files.length) {
console.error('[compare] reference dir is empty');
process.exit(1);
}
let failed = 0;
for (const f of files) {
const cur = path.join(CUR_DIR, f);
if (!fs.existsSync(cur)) {
console.log(`[compare] MISSING ${f} (no current screenshot)`);
failed++;
continue;
}
let result;
try {
result = compareFiles(path.join(REF_DIR, f), cur);
} catch (err) {
console.log(`[compare] ERROR ${f}: ${err.message}`);
failed++;
continue;
}
if (result.ok) {
console.log(
`[compare] OK ${f} (${result.diffPixels}/${result.totalPixels} px, maxΔ=${result.maxDelta})`,
);
} else {
failed++;
const detail = result.reason
?? `${result.diffPixels}/${result.totalPixels} px differ (${(result.frac * 100).toFixed(3)}% > ${(TOLERANCE * 100).toFixed(3)}%), maxΔ=${result.maxDelta}`;
console.log(`[compare] FAIL ${f}: ${detail}`);
if (result.diffImg) {
fs.writeFileSync(path.join(DIFF_DIR, f), result.diffImg);
}
}
}
if (failed > 0) {
console.log(`[compare] ${failed}/${files.length} mismatched (tolerance ${(TOLERANCE * 100).toFixed(2)}%, channel±${CHANNEL_TOL}). Diffs → ${DIFF_DIR}`);
process.exit(1);
}
console.log(`[compare] all ${files.length} screenshots match (tolerance ${(TOLERANCE * 100).toFixed(2)}%, channel±${CHANNEL_TOL})`);
}
main();

490
test/dummyBackend.mjs Normal file
View File

@@ -0,0 +1,490 @@
/**
* Dummy backend for the visual testing rig.
*
* Serves the frozen fixture snapshot (test/fixtures/snapshot.json) on every
* GET endpoint the frontend reads during screenshot runs. Always-authed stub:
* every request is treated as the fixture user, so headless --screenshot runs
* need no cookie juggling. (There is no logged-out/login screenshot.)
*
* Mutating methods (POST/PATCH/PUT/DELETE) return the stored fixture payload
* (or a minimal echo) so UI actions don't crash mid-run — persistence is
* in-memory only and resets on restart.
*
* Usage: node test/dummyBackend.mjs [--port 4218]
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_DIR = path.join(__dirname, 'fixtures');
const IMAGE_DIR = path.join(FIXTURE_DIR, 'images');
const portArg = process.argv.find((a) => a.startsWith('--port='))?.split('=')[1];
const PORT = Number(portArg ?? process.env.RIG_BACKEND_PORT ?? 4208);
function loadSnapshot() {
const file = path.join(FIXTURE_DIR, 'snapshot.json');
if (!fs.existsSync(file)) {
console.error(
`[dummy-backend] missing ${file}. Run: npm run rig:collect (live backend at :4208 required)`,
);
process.exit(1);
}
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
const snap = loadSnapshot();
const { auth, company, catalog, parties, documents, misc } = snap;
const googleClientId = snap.meta?.googleClientId ?? null;
const user = auth.me.user;
function json(res, status, body) {
const text = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(text),
'Cache-Control': 'no-store',
});
res.end(text);
}
function readBody(req) {
return new Promise((resolve) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return resolve(null);
try {
resolve(JSON.parse(raw));
} catch {
resolve(raw);
}
});
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const p = url.pathname;
const method = req.method;
// Public / auth bootstrap. The dummy is always-authed: every request is
// treated as the fixture user, so headless --screenshot runs need no
// cookie juggling. (There is no logged-out/login screenshot in the rig.)
if (p === '/api/health') return json(res, 200, { ok: true });
if (p === '/api/auth/me' && method === 'GET') {
return json(res, 200, { user, googleClientId });
}
if (p === '/api/auth/login' && method === 'POST') {
await readBody(req);
res.setHeader('Set-Cookie', 'token=dummy-rig-token; Path=/; HttpOnly');
return json(res, 200, { message: 'Login successful', user });
}
if (p === '/api/auth/logout' && method === 'POST') {
res.setHeader('Set-Cookie', 'token=; Path=/; HttpOnly; Max-Age=0');
return json(res, 200, { ok: true });
}
if (p === '/api/auth/selected-company' && method === 'PUT') {
return json(res, 200, { user });
}
// Everything else is served as the fixture user (always-authed stub).
// SSE: hold the stream open with heartbeats, never emit (frozen fixtures).
if (p === '/api/events' && method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
const beat = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch {
clearInterval(beat);
}
}, 20000);
req.on('close', () => clearInterval(beat));
return;
}
// Binary images (byte-identical to the live backend at collect time).
// Backend fullHash is an 8-char alphanumeric string (see dashback pic.js).
const thumbMatch = p.match(/^\/api\/thumb\/([A-Za-z0-9]{8})$/);
if (thumbMatch && method === 'GET') {
const file = path.join(IMAGE_DIR, `${thumbMatch[1]}.avif`);
if (!fs.existsSync(file)) return json(res, 404, { error: 'Image not found' });
const buf = fs.readFileSync(file);
res.writeHead(200, {
'Content-Type': 'image/avif',
'Content-Length': buf.length,
'Cache-Control': 'private, max-age=31536000, immutable',
ETag: `"${thumbMatch[1]}"`,
});
return res.end(buf);
}
const imageMatch = p.match(/^\/api\/image\/([A-Za-z0-9]{8})$/);
if (imageMatch && method === 'GET') {
const file = path.join(IMAGE_DIR, `${imageMatch[1]}.avif`);
if (!fs.existsSync(file)) return json(res, 404, { error: 'Image not found' });
const buf = fs.readFileSync(file);
res.writeHead(200, {
'Content-Type': 'image/avif',
'Content-Length': buf.length,
'Cache-Control': 'private, max-age=31536000, immutable',
ETag: `"${imageMatch[1]}"`,
});
return res.end(buf);
}
// ── Reads ──────────────────────────────────────────────
if (method === 'GET') {
if (p === '/api/company') return json(res, 200, { companies: auth.companies });
if (p === '/api/category/tree') return json(res, 200, catalog.tree);
const catArticles = p.match(/^\/api\/category\/(\d+)\/articles$/);
if (catArticles) {
return json(res, 200, {
articles: catalog.articlesByCategory[catArticles[1]] ?? [],
});
}
const articleMatch = p.match(/^\/api\/article\/([^/]+)$/);
if (articleMatch && !['search', 'history', 'export.zip'].includes(articleMatch[1])) {
const sku = decodeURIComponent(articleMatch[1]);
if (sku === catalog.articleDetail?.sku) return json(res, 200, { article: catalog.articleDetail });
// Any other SKU: clone the known detail so deep links still render.
if (catalog.articleDetail) {
return json(res, 200, {
article: { ...catalog.articleDetail, sku, name: catalog.articleDetail.name },
});
}
return json(res, 404, { error: 'Article not found' });
}
if (p === '/api/article/history') return json(res, 200, { history: misc.articleHistoryGrid ?? [] });
const skuHistory = p.match(/^\/api\/article\/([^/]+)\/history$/);
if (skuHistory) return json(res, 200, { history: catalog.articleHistory ?? [] });
const skuSuppliers = p.match(/^\/api\/article\/([^/]+)\/supplier$/);
if (skuSuppliers) return json(res, 200, { suppliers: catalog.articleSuppliers ?? [] });
const skuChildren = p.match(/^\/api\/article\/([^/]+)\/children$/);
if (skuChildren) return json(res, 200, { children: [] });
const skuAttr = p.match(/^\/api\/article\/([^/]+)\/attributevalue$/);
if (skuAttr) return json(res, 200, { attributevalues: [] });
const skuProp = p.match(/^\/api\/article\/([^/]+)\/property$/);
if (skuProp) return json(res, 200, { properties: [] });
const skuArticlelist = p.match(/^\/api\/article\/([^/]+)\/articlelist$/);
if (skuArticlelist) return json(res, 200, { items: [] });
const skuOptions = p.match(/^\/api\/article\/([^/]+)\/optionslist$/);
if (skuOptions) return json(res, 200, { options: [] });
if (p === '/api/brand') return json(res, 200, { brands: catalog.brands ?? [] });
if (p === '/api/unit') return json(res, 200, { units: catalog.units ?? [] });
if (p === '/api/warehouse') return json(res, 200, { warehouses: catalog.warehouses ?? [] });
const warehouseById = p.match(/^\/api\/warehouse\/(\d+)$/);
if (warehouseById) {
const found = (catalog.warehouses ?? []).find((w) => String(w.id) === warehouseById[1]);
if (!found) return json(res, 404, { error: 'Warehouse not found' });
return json(res, 200, { warehouse: found });
}
if (p === '/api/attributename') return json(res, 200, { attributenames: catalog.attributes ?? [] });
const attrValues = p.match(/^\/api\/attributename\/(\d+)\/value$/);
if (attrValues) {
const attr = (catalog.attributes ?? []).find((a) => String(a.id) === attrValues[1]);
return json(res, 200, { values: attr?.values ?? [] });
}
if (p === '/api/propertyname') return json(res, 200, { propertynames: catalog.properties ?? [] });
if (p === '/api/supplier') return json(res, 200, { suppliers: parties.suppliers ?? [] });
const supplierArticles = p.match(/^\/api\/supplier\/(\d+)\/articles$/);
if (supplierArticles) return json(res, 200, { articles: [] });
if (p === '/api/entity') return json(res, 200, { entities: parties.entities ?? [] });
if (p === '/api/entity/next-number') return json(res, 200, { number: 'KD-001001' });
const entityMatch = p.match(/^\/api\/entity\/(\d+)$/);
if (entityMatch) {
const found = (parties.entities ?? []).find((e) => String(e.id) === entityMatch[1]);
if (!found && parties.entityDetail && String(parties.entityDetail.id) === entityMatch[1]) {
return json(res, 200, { entity: parties.entityDetail });
}
if (!found) return json(res, 404, { error: 'Entity not found' });
return json(res, 200, { entity: found });
}
const entityAddr = p.match(/^\/api\/entity\/(\d+)\/address$/);
if (entityAddr) return json(res, 200, { addresses: parties.entityAddresses ?? [] });
const entityContact = p.match(/^\/api\/entity\/(\d+)\/contact$/);
if (entityContact) return json(res, 200, { contacts: parties.entityContacts ?? [] });
if (p === '/api/customergroup') return json(res, 200, { customergroups: parties.customergroups ?? [] });
if (p === '/api/delivery') return json(res, 200, { deliveries: documents.deliveries ?? [] });
const deliveryMatch = p.match(/^\/api\/delivery\/(\d+)$/);
if (deliveryMatch) {
const found = (documents.deliveries ?? []).find((d) => String(d.id) === deliveryMatch[1]);
const detail = documents.deliveryDetail && String(documents.deliveryDetail.id) === deliveryMatch[1]
? documents.deliveryDetail
: null;
if (detail) return json(res, 200, { delivery: detail });
if (found && !detail) {
return json(res, 200, {
delivery: { ...found, lines: [], files: found.files ?? [] },
});
}
return json(res, 404, { error: 'Delivery not found' });
}
if (p === '/api/quote') return json(res, 200, { quotes: documents.quotes ?? [] });
const quoteMatch = p.match(/^\/api\/quote\/(\d+)$/);
if (quoteMatch) {
const found = (documents.quotes ?? []).find((q) => String(q.id) === quoteMatch[1]);
if (!found) return json(res, 404, { error: 'Quote not found' });
return json(res, 200, { quote: { ...found, lines: found.lines ?? [] } });
}
if (p === '/api/order') return json(res, 200, { orders: documents.orders ?? [] });
const orderMatch = p.match(/^\/api\/order\/(\d+)$/);
if (orderMatch) {
const found = (documents.orders ?? []).find((o) => String(o.id) === orderMatch[1]);
if (!found) return json(res, 404, { error: 'Order not found' });
return json(res, 200, { order: { ...found, lines: found.lines ?? [] } });
}
if (p === '/api/number') return json(res, 200, { numbers: documents.numbers ?? [] });
if (p === '/api/posterminal') return json(res, 200, { posterminals: misc.posterminals ?? [] });
if (p === '/api/printer') return json(res, 200, { printers: misc.printers ?? [] });
const printerById = p.match(/^\/api\/printer\/(\d+)$/);
if (printerById) {
const found = (misc.printers ?? []).find((x) => String(x.id) === printerById[1]);
if (!found) return json(res, 404, { error: 'Printer not found' });
return json(res, 200, { printer: found });
}
if (p === '/api/labeltemplate') return json(res, 200, { labeltemplates: misc.labeltemplates ?? [] });
const labelById = p.match(/^\/api\/labeltemplate\/(\d+)$/);
if (labelById) {
const found = (misc.labeltemplates ?? []).find((x) => String(x.id) === labelById[1]);
if (!found) return json(res, 404, { error: 'Label template not found' });
return json(res, 200, { labeltemplate: found });
}
if (p === '/api/company/setting') return json(res, 200, { settings: company.settings ?? [] });
const settingByKey = p.match(/^\/api\/company\/setting\/(.+)$/);
if (settingByKey) {
const found = (company.settings ?? []).find((s) => s.key === decodeURIComponent(settingByKey[1]));
if (!found) return json(res, 404, { error: 'Setting not found' });
return json(res, 200, { setting: found });
}
if (p === '/api/vfs') return json(res, 200, { files: misc.vfs ?? [] });
if (p === '/api/history') return json(res, 200, { history: misc.globalHistory ?? [], rows: misc.globalHistory ?? [] });
if (p === '/api/modelconversation') return json(res, 200, { conversations: [] });
const conversationMsgs = p.match(/^\/api\/modelconversation\/(\d+)\/message$/);
if (conversationMsgs) return json(res, 200, { messages: [] });
const ledgerSku = p.match(/^\/api\/ledger\/sku\/(.+)$/);
if (ledgerSku) return json(res, 200, { ledger: [] });
const ledgerMa = p.match(/^\/api\/ledger\/manifestarticle\/(\d+)$/);
if (ledgerMa) return json(res, 200, { ledger: [] });
if (p === '/api/ledger') return json(res, 200, { ledger: [] });
if (p === '/api/article/search') {
const q = (url.searchParams.get('target') ?? '').toLowerCase();
const all = Object.values(catalog.articlesByCategory ?? {}).flat();
const seen = new Map();
for (const a of all) seen.set(String(a.sku), a);
const rows = [...seen.values()].filter(
(a) => !q || String(a.sku).toLowerCase().includes(q) || String(a.name ?? '').toLowerCase().includes(q),
);
return json(res, 200, { articles: rows.slice(0, 50), total: rows.length });
}
return json(res, 404, { error: 'Not found' });
}
// ── Mutations: echo fixtures so the UI never crashes mid-run ──
const body = await readBody(req);
if (method === 'POST' && p === '/api/article') {
return json(res, 201, { article: { ...catalog.articleDetail, ...(body ?? {}) } });
}
const skuPatch = p.match(/^\/api\/article\/([^/]+)$/);
if (skuPatch && (method === 'PATCH' || method === 'PUT')) {
return json(res, 200, { article: { ...catalog.articleDetail, ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/article\/[^/]+(\/image\/.+)?$/)) {
res.writeHead(204);
return res.end();
}
if (p.match(/^\/api\/article\/[^/]+\/(prices|weight|brand|eans|categories|images\/order|supplier\/order|attributevalue|property|articlelist|optionslist|parent|children|print)$/) && ['PUT', 'PATCH', 'POST'].includes(method)) {
if (p.endsWith('/supplier/order')) return json(res, 200, { ids: body?.ids ?? [] });
if (p.endsWith('/images/order')) return json(res, 200, { fullHashes: body?.fullHashes ?? [] });
if (p.endsWith('/articlelist')) return json(res, 200, { items: body?.items ?? [] });
if (p.endsWith('/optionslist')) return json(res, 200, { options: body?.options ?? [] });
if (p.endsWith('/attributevalue')) return json(res, 200, { attributevalues: [] });
if (p.endsWith('/property')) return json(res, 200, { properties: [] });
return json(res, 200, { article: { ...catalog.articleDetail, ...(body ?? {}) } });
}
if (p.match(/^\/api\/article\/[^/]+\/supplier(\/\d+)?$/) && ['POST', 'PATCH'].includes(method)) {
return json(res, method === 'POST' ? 201 : 200, {
supplier: { id: 1, entityId: 1, ...(body ?? {}) },
});
}
if (method === 'DELETE' && p.match(/^\/api\/article\/[^/]+\/(supplier|property|children)\/.+$/)) {
return json(res, 200, { ok: true });
}
if (p === '/api/category' && method === 'POST') {
return json(res, 201, { category: { id: 999001, parentId: body?.parentId ?? 1, sort: 0, ...(body ?? {}) } });
}
if (p.match(/^\/api\/category\/\d+$/) && ['PATCH', 'PUT'].includes(method)) {
const id = Number(p.split('/').pop());
return json(res, 200, { category: { id, ...(body ?? {}) } });
}
if (p === '/api/category/order' && method === 'PUT') {
return json(res, 200, { categoryIds: body?.categoryIds ?? [] });
}
if (method === 'DELETE' && p.match(/^\/api\/category\/\d+(\/image)?$/)) {
return json(res, 200, { category: { id: Number(p.split('/')[3]) } });
}
if (p === '/api/brand' && method === 'PUT') {
return json(res, 200, { brand: { id: 999001, ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/brand\/[^/]+(\/image)?$/)) {
return json(res, 200, { brand: { id: 999001 } });
}
if (p === '/api/unit' && method === 'PUT') {
return json(res, 200, { unit: { ...(body ?? {}) } });
}
if (method === 'DELETE' && p.startsWith('/api/unit')) {
res.writeHead(204);
return res.end();
}
if (p === '/api/supplier' && method === 'POST') {
return json(res, 201, { supplier: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/supplier\/\d+$/) && method === 'PATCH') {
return json(res, 200, { supplier: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/(supplier|warehouse|printer|labeltemplate|customergroup|entity|delivery|quote|order|posterminal)\/[^/]+(\/(address|contact|category|all))?$/)) {
if (p.includes('/posterminal/') && (p.endsWith('/all') || p.includes('/category/'))) {
return json(res, 200, { id: Number(p.split('/')[3]), categoryIds: [] });
}
res.writeHead(204);
return res.end();
}
if (p === '/api/warehouse' && method === 'POST') {
return json(res, 201, { warehouse: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/warehouse\/\d+$/) && method === 'PATCH') {
return json(res, 200, { warehouse: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/warehouse/order' && method === 'PUT') {
return json(res, 200, { warehouseIds: body?.warehouseIds ?? [] });
}
if (p === '/api/stock/adjust' && method === 'POST') {
return json(res, 200, { ok: true });
}
if (p === '/api/delivery' && method === 'POST') {
return json(res, 201, { delivery: { ...(documents.deliveryDetail ?? {}), ...(body ?? {}) } });
}
if (p.match(/^\/api\/delivery\/\d+$/) && method === 'PATCH') {
return json(res, 200, { delivery: { ...(documents.deliveryDetail ?? {}), ...(body ?? {}) } });
}
if (p === '/api/quote' && method === 'POST') {
return json(res, 201, { quote: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/quote\/\d+$/) && method === 'PATCH') {
return json(res, 200, { quote: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/order' && method === 'POST') {
return json(res, 201, { order: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/order\/\d+$/) && method === 'PATCH') {
return json(res, 200, { order: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/entity' && method === 'POST') {
return json(res, 201, { entity: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/entity\/\d+$/) && method === 'PATCH') {
return json(res, 200, { entity: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p.match(/^\/api\/entity\/\d+\/(address|contact)$/) && method === 'POST') {
const kind = p.endsWith('address') ? 'address' : 'contact';
return json(res, 201, { [kind]: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/entity\/\d+\/(address|contact)\/\d+$/) && method === 'PATCH') {
const kind = p.includes('/address/') ? 'address' : 'contact';
return json(res, 200, { [kind]: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/customergroup' && method === 'POST') {
return json(res, 201, { customergroup: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/customergroup\/\d+$/) && method === 'PATCH') {
return json(res, 200, { customergroup: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/customergroup/order' && method === 'PUT') {
return json(res, 200, { customergroupIds: body?.customergroupIds ?? [] });
}
if (p === '/api/company' && method === 'POST') {
return json(res, 201, { company: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/company\/\d+$/) && method === 'PUT') {
return json(res, 200, { company: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p.match(/^\/api\/company\/setting\/.+$/) && method === 'PUT') {
return json(res, 200, { setting: { key: decodeURIComponent(p.split('/').pop()), ...(body ?? {}) } });
}
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+\/(all|category\/\d+)$/) && ['PUT', 'DELETE'].includes(method)) {
return json(res, 200, { id: Number(p.split('/')[3]), categoryIds: [] });
}
if (p === '/api/printer' && method === 'POST') {
return json(res, 201, { printer: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/printer\/\d+$/) && method === 'PATCH') {
return json(res, 200, { printer: { id: Number(p.split('/').pop()), ...(body ?? {}) } });
}
if (p === '/api/printer/order' && method === 'PUT') {
return json(res, 200, { printerIds: body?.printerIds ?? [] });
}
if (p === '/api/labeltemplate' && method === 'POST') {
return json(res, 201, { labeltemplate: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/labeltemplate\/.+$/) && method === 'PATCH') {
return json(res, 200, { labeltemplate: { id: p.split('/').pop(), ...(body ?? {}) } });
}
if (p === '/api/ledger' && method === 'POST') {
return json(res, 201, { ledger: { id: 999001, ...(body ?? {}) } });
}
if (p === '/api/attributename' && method === 'POST') {
return json(res, 201, { attributename: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/attributename\/.+$/) && ['PATCH', 'PUT'].includes(method)) {
return json(res, 200, { attributename: { id: p.split('/').pop(), ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/(attributename|attributevalue|propertyname)\/.+$/)) {
return json(res, 200, { ok: true });
}
if (p === '/api/propertyname' && method === 'POST') {
return json(res, 201, { propertyname: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/propertyname\/.+$/) && ['PATCH', 'PUT'].includes(method)) {
return json(res, 200, { propertyname: { id: p.split('/').pop(), ...(body ?? {}) } });
}
if (p === '/api/modelconversation' && method === 'POST') {
return json(res, 201, { conversation: { id: 999001, ...(body ?? {}) } });
}
if (p.match(/^\/api\/modelconversation\/\d+\/message$/) && method === 'POST') {
return json(res, 201, { message: { id: 999001, ...(body ?? {}) } });
}
if (method === 'DELETE' && p.match(/^\/api\/modelconversation\/\d+$/)) {
res.writeHead(204);
return res.end();
}
if (p === '/api/vfs/upload' && method === 'POST') {
return json(res, 200, { files: [] });
}
if (p.startsWith('/api/vfs/') && method === 'PUT') {
return json(res, 200, { ok: true });
}
if (method === 'DELETE' && p.startsWith('/api/vfs/')) {
res.writeHead(204);
return res.end();
}
return json(res, 404, { error: 'Not found' });
});
server.listen(PORT, () => {
console.log(`[dummy-backend] fixtures on http://localhost:${PORT} (${Object.keys(catalog.articlesByCategory ?? {}).length} categories)`);
});

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 940 B

After

Width:  |  Height:  |  Size: 940 B

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 891 B

After

Width:  |  Height:  |  Size: 891 B

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1011 B

After

Width:  |  Height:  |  Size: 1011 B

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
test/fixtures/images/FMrsioFY.avif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Some files were not shown because too many files have changed in this diff Show More