#!/usr/bin/env node // Local-only. Never invoked from CI. Re-run intentionally; commit the // resulting JSON in one atomic commit. // // Captures the current /api/network response into the canonical fixture used // by buildLayout.test.ts. Asserts a minimum shape so a silently-empty backend // can't write a vacuous fixture. import { writeFileSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080'; const EMAIL = process.env.CAPTURE_EMAIL; const PASSWORD = process.env.CAPTURE_PASSWORD; // Preflight guards: this script writes the canonical Stammbaum fixture from // a *running* backend with admin-shaped credentials. Two slips would be // silent disasters — running with default creds against staging/prod, or // running with a typo'd BACKEND_URL that happens to resolve. Refuse both // before sending a single byte. preflight(); function preflight() { const failures = []; if (!EMAIL) { failures.push('CAPTURE_EMAIL must be set explicitly (no default).'); } if (!PASSWORD) { failures.push('CAPTURE_PASSWORD must be set explicitly (no default).'); } if (!isLocalhost(BACKEND_URL)) { failures.push( `BACKEND_URL must point at localhost / 127.0.0.1 (got: ${BACKEND_URL}). ` + 'This script is local-only.' ); } if (failures.length > 0) { console.error('Preflight failed:'); for (const f of failures) console.error(` - ${f}`); process.exit(2); } } function isLocalhost(url) { try { const host = new URL(url).hostname; return host === 'localhost' || host === '127.0.0.1' || host === '::1'; } catch { return false; } } const HERE = dirname(fileURLToPath(import.meta.url)); const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`; // Sanity floors — calibrated against the canonical dataset (May 2026: 62 // nodes, 5 generations, 28 SPOUSE_OF edges, 1 multi-spouse person). The point // is catching a silently empty backend or a structurally-regressed snapshot, // not strict size validation; raise these only if the canonical graph grows // substantially. The same gates are unit-tested at // src/lib/person/genealogy/__fixtures__/validateFixture.ts — keep both // definitions in sync. const MIN_NODES = 50; const MIN_GENERATIONS = 5; const MIN_SPOUSE_OF_EDGES = 1; const MIN_MULTI_SPOUSE_PERSONS = 1; function parseSetCookies(headers) { const out = new Map(); const raw = headers.getSetCookie?.() ?? []; for (const line of raw) { const [pair] = line.split(';'); const eq = pair.indexOf('='); if (eq < 0) continue; out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); } return out; } function serialiseCookies(jar) { return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); } async function login(jar) { const xsrf = randomUUID(); jar.set('XSRF-TOKEN', xsrf); const res = await fetch(`${BACKEND_URL}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, Cookie: serialiseCookies(jar) }, body: JSON.stringify({ email: EMAIL, password: PASSWORD }) }); if (!res.ok) { throw new Error(`Login failed: ${res.status} ${await res.text()}`); } for (const [k, v] of parseSetCookies(res.headers)) jar.set(k, v); } async function fetchNetwork(jar) { const res = await fetch(`${BACKEND_URL}/api/network`, { headers: { Cookie: serialiseCookies(jar) } }); if (!res.ok) { throw new Error(`GET /api/network failed: ${res.status} ${await res.text()}`); } return res.json(); } function validate(network) { const nodes = Array.isArray(network.nodes) ? network.nodes : []; const edges = Array.isArray(network.edges) ? network.edges : []; const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF'); const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null)); const multiSpousePersons = countMultiSpousePersons(spouseEdges); const failures = []; if (nodes.length < MIN_NODES) { failures.push(`expected >= ${MIN_NODES} nodes, got ${nodes.length}`); } if (generations.size < MIN_GENERATIONS) { failures.push(`expected >= ${MIN_GENERATIONS} distinct generations, got ${generations.size}`); } if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) { failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`); } if (multiSpousePersons < MIN_MULTI_SPOUSE_PERSONS) { failures.push( `expected >= ${MIN_MULTI_SPOUSE_PERSONS} person with multiple SPOUSE_OF edges, ` + `got ${multiSpousePersons}` ); } if (failures.length > 0) { throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`); } return { nodes: nodes.length, edges: edges.length, spouseEdges: spouseEdges.length, generations: [...generations].sort((a, b) => a - b), multiSpousePersons }; } function countMultiSpousePersons(spouseEdges) { const partners = new Map(); for (const e of spouseEdges) { if (!e.personId || !e.relatedPersonId) continue; mapAddToSet(partners, e.personId, e.relatedPersonId); mapAddToSet(partners, e.relatedPersonId, e.personId); } let count = 0; for (const set of partners.values()) { if (set.size >= 2) count += 1; } return count; } function mapAddToSet(map, key, value) { const s = map.get(key); if (s) s.add(value); else map.set(key, new Set([value])); } function writeFixture(network) { mkdirSync(dirname(FIXTURE_PATH), { recursive: true }); writeFileSync(FIXTURE_PATH, JSON.stringify(network, null, '\t') + '\n', 'utf8'); } async function main() { const jar = new Map(); console.error(`Capturing /api/network from ${BACKEND_URL} as ${EMAIL} ...`); await login(jar); const network = await fetchNetwork(jar); const stats = validate(network); writeFixture(network); console.error( `Wrote ${FIXTURE_PATH}\n nodes: ${stats.nodes}\n edges: ${stats.edges}\n SPOUSE_OF edges: ${stats.spouseEdges}\n multi-spouse persons: ${stats.multiSpousePersons}\n generations: G${stats.generations.join(', G')}` ); } main().catch((err) => { console.error(err.message); process.exit(1); });