Funnet av N18-roundtrip-røyken: render-suiten var grønn 1 av 4 kjøringer, ikke "grønn siden N13" som STATE påsto. Rotårsaken er produksjonskode, ikke en test-flake. runChromeScreenshot.finish() sender SIGKILL til forelder-Chrome og resolver umiddelbart uten å vente på at prosessen dør. SIGKILL på forelderen dreper ikke dens renderer/GPU-barn, som fortsetter å skrive inn i --user-data-dir under workDir mens finally-blokken traverserer samme katalog. rmSync tømmer den, nye filer dukker opp, og fjerning av selve katalogen feiler med ENOTEMPTY. Ren timing-race — derav de ~50 %. Brukerkonsekvensen var den verste sorten: PNG-en skrives til outPath UTENFOR workDir og er allerede ferdig når dette skjer, så oppryddingen veltet et render som faktisk lyktes. Fix: cleanupWorkDir() — eksplisitt retry-løkke (testbar med injisert rm) pluss fs sine egne maxRetries/retryDelay, og den kaster aldri. En temp-katalog som blir liggende er en bagatell mot å miste et ferdig bilde; OS rydder tmpdir. TDD: rødt bevist først (manglende eksport), så 3 tester på kontrakten — svelger ENOTEMPTY og forsøker på nytt, fjerner katalogen i normaltilfellet, gir seg stille når den allerede er borte. render 60/0 -> 63/0, grønn 5 av 5 kjøringer (var 1 av 4). test-runner 270/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
import { describe, test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { execFileSync } from 'node:child_process';
|
||
import {
|
||
TARGETS,
|
||
DEFAULT_TOKENS,
|
||
deriveDimensions,
|
||
loadBrandTokens,
|
||
tokensToCss,
|
||
wrapSvg,
|
||
injectTokens,
|
||
parseSvgMeta,
|
||
validateFigure,
|
||
resolveChrome,
|
||
parseArgs,
|
||
renderFigure,
|
||
cleanupWorkDir,
|
||
} from '../build-figur.mjs';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const FIXTURE_SVG = path.join(__dirname, 'fixtures', 'demo-figur.svg');
|
||
|
||
function tmpdir() {
|
||
return fs.mkdtempSync(path.join(os.tmpdir(), 'build-figur-test-'));
|
||
}
|
||
|
||
// Leser bredde/høyde fra PNG-IHDR (byte 16–23, big-endian).
|
||
function pngDimensions(file) {
|
||
const buf = fs.readFileSync(file);
|
||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||
}
|
||
|
||
describe('TARGETS + deriveDimensions', () => {
|
||
test('carousel er 1080×1350 (fast)', () => {
|
||
assert.equal(TARGETS.carousel.width, 1080);
|
||
assert.equal(TARGETS.carousel.height, 1350);
|
||
});
|
||
|
||
test('article er 1200 bred med innholdsstyrt høyde', () => {
|
||
assert.equal(TARGETS.article.width, 1200);
|
||
assert.equal(TARGETS.article.height, null);
|
||
});
|
||
|
||
test('single er kvadratisk 1200×1200', () => {
|
||
assert.equal(TARGETS.single.width, 1200);
|
||
assert.equal(TARGETS.single.height, 1200);
|
||
});
|
||
|
||
test('article + aspekt 16:9 gir avledet høyde 675', () => {
|
||
const d = deriveDimensions('article', 1200 / 675, {});
|
||
assert.equal(d.width, 1200);
|
||
assert.equal(d.height, 675);
|
||
assert.equal(d.warnings.length, 0);
|
||
});
|
||
|
||
test('article uten aspekt gir fallback-høyde med warning', () => {
|
||
const d = deriveDimensions('article', null, {});
|
||
assert.equal(d.width, 1200);
|
||
assert.ok(d.height > 0);
|
||
assert.ok(d.warnings.some((w) => /høyde/i.test(w)));
|
||
});
|
||
|
||
test('--width/--height-overstyringer vinner over målet', () => {
|
||
const d = deriveDimensions('carousel', null, { width: 800, height: 600 });
|
||
assert.equal(d.width, 800);
|
||
assert.equal(d.height, 600);
|
||
});
|
||
|
||
test('ukjent mål kaster med gyldige mål i meldingen', () => {
|
||
assert.throws(() => deriveDimensions('poster', null, {}), /article/);
|
||
});
|
||
});
|
||
|
||
describe('loadBrandTokens — token-seam (brukerdata, aldri hardkodet)', () => {
|
||
test('uten brand-tokens.json: nøytrale defaults, source=default', () => {
|
||
const dir = tmpdir();
|
||
const r = loadBrandTokens(dir);
|
||
assert.equal(r.source, 'default');
|
||
assert.deepEqual(r.tokens, DEFAULT_TOKENS);
|
||
assert.equal(r.warnings.length, 0);
|
||
});
|
||
|
||
test('gyldig fil merges over defaults, source=user', () => {
|
||
const dir = tmpdir();
|
||
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(dir, 'profile', 'brand-tokens.json'),
|
||
JSON.stringify({ accent: '#123456' }),
|
||
'utf8'
|
||
);
|
||
const r = loadBrandTokens(dir);
|
||
assert.equal(r.source, 'user');
|
||
assert.equal(r.tokens.accent, '#123456');
|
||
// umerkede felter beholder defaults
|
||
assert.equal(r.tokens.background, DEFAULT_TOKENS.background);
|
||
});
|
||
|
||
test('ugyldig JSON: defaults + warning, kaster aldri', () => {
|
||
const dir = tmpdir();
|
||
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
|
||
fs.writeFileSync(path.join(dir, 'profile', 'brand-tokens.json'), '{not json', 'utf8');
|
||
let r;
|
||
assert.doesNotThrow(() => { r = loadBrandTokens(dir); });
|
||
assert.equal(r.source, 'default');
|
||
assert.ok(r.warnings.some((w) => /brand-tokens\.json/.test(w)));
|
||
});
|
||
});
|
||
|
||
describe('tokensToCss + wrapSvg + injectTokens — CSS-injeksjon', () => {
|
||
test('tokensToCss produserer --figur-variabler for alle tokens', () => {
|
||
const css = tokensToCss(DEFAULT_TOKENS);
|
||
assert.match(css, /:root\{/);
|
||
for (const key of Object.keys(DEFAULT_TOKENS)) {
|
||
assert.ok(css.includes(`--figur-${key}`), `mangler --figur-${key}`);
|
||
}
|
||
});
|
||
|
||
test('egendefinerte tokenverdier reflekteres i CSS-en', () => {
|
||
const css = tokensToCss({ ...DEFAULT_TOKENS, accent: '#ABCDEF' });
|
||
assert.match(css, /--figur-accent:\s*#ABCDEF/);
|
||
});
|
||
|
||
test('wrapSvg bygger komplett HTML med svg + tokens + mål-dimensjoner', () => {
|
||
const html = wrapSvg('<svg viewBox="0 0 10 10"></svg>', { width: 1080, height: 1350 }, DEFAULT_TOKENS);
|
||
assert.match(html, /<!DOCTYPE html>/i);
|
||
assert.match(html, /<svg viewBox="0 0 10 10">/);
|
||
assert.match(html, /--figur-accent/);
|
||
assert.match(html, /width:\s*1080px/);
|
||
assert.match(html, /height:\s*1350px/);
|
||
});
|
||
|
||
test('injectTokens legger style rett etter <head> når den finnes', () => {
|
||
const out = injectTokens('<html><head><title>x</title></head><body></body></html>', DEFAULT_TOKENS);
|
||
assert.match(out, /<head>\s*<style>[^<]*--figur-ink/);
|
||
});
|
||
|
||
test('injectTokens prepender style når <head> mangler', () => {
|
||
const out = injectTokens('<p>bare et fragment</p>', DEFAULT_TOKENS);
|
||
assert.ok(out.trimStart().startsWith('<style>'));
|
||
assert.match(out, /<p>bare et fragment<\/p>/);
|
||
});
|
||
});
|
||
|
||
describe('parseSvgMeta — aspekt fra SVG-kilden', () => {
|
||
test('viewBox gir aspekt', () => {
|
||
const aspect = parseSvgMeta('<svg viewBox="0 0 1200 675"></svg>');
|
||
assert.ok(Math.abs(aspect - 1200 / 675) < 1e-9);
|
||
});
|
||
|
||
test('width/height-attributter gir aspekt', () => {
|
||
const aspect = parseSvgMeta('<svg width="800px" height="400"></svg>');
|
||
assert.equal(aspect, 2);
|
||
});
|
||
|
||
test('uten dimensjonsinfo: null', () => {
|
||
assert.equal(parseSvgMeta('<svg><rect/></svg>'), null);
|
||
});
|
||
});
|
||
|
||
describe('validateFigure — designregler som warn, aldri hard fail', () => {
|
||
test('font-size under 24 gir warning', () => {
|
||
const w = validateFigure('<svg><text font-size="14">liten</text></svg>');
|
||
assert.ok(w.some((x) => /24/.test(x)));
|
||
});
|
||
|
||
test('font-size ≥ 24 gir ingen tekst-warning', () => {
|
||
const w = validateFigure('<svg><text font-size="28">ok</text><text style="font-size: 32px">ok</text></svg>');
|
||
assert.ok(!w.some((x) => /24/.test(x)));
|
||
});
|
||
|
||
test('mer enn 2 ikke-nøytrale farger gir fargebudsjett-warning', () => {
|
||
const w = validateFigure('<svg><rect fill="#9A3324"/><rect fill="#2F6F9F"/><rect fill="#22AA55"/></svg>');
|
||
assert.ok(w.some((x) => /farge/i.test(x)));
|
||
});
|
||
|
||
test('nøytraler (gråtoner/hvit/sort) telles ikke mot fargebudsjettet', () => {
|
||
const w = validateFigure(
|
||
'<svg><rect fill="#FFFFFF"/><rect fill="#1A1A1A"/><rect fill="#888888"/><rect fill="#9A3324" font-size="30"/></svg>'
|
||
);
|
||
assert.ok(!w.some((x) => /farge/i.test(x)));
|
||
});
|
||
});
|
||
|
||
describe('resolveChrome — robust binær-oppdagelse (mockbar)', () => {
|
||
test('finner app-bundle-sti via exists-probe', () => {
|
||
const r = resolveChrome({
|
||
exists: (p) => p.includes('Google Chrome.app'),
|
||
which: () => null,
|
||
});
|
||
assert.equal(r.available, true);
|
||
assert.match(r.path, /Google Chrome/);
|
||
});
|
||
|
||
test('faller tilbake til PATH via which-probe', () => {
|
||
const r = resolveChrome({
|
||
exists: () => false,
|
||
which: (cmd) => (cmd === 'chromium' ? '/usr/local/bin/chromium' : null),
|
||
});
|
||
assert.equal(r.available, true);
|
||
assert.equal(r.path, '/usr/local/bin/chromium');
|
||
});
|
||
|
||
test('ingen kandidater: available=false med install-hint', () => {
|
||
const r = resolveChrome({ exists: () => false, which: () => null });
|
||
assert.equal(r.available, false);
|
||
assert.match(r.hint, /Chrome/);
|
||
});
|
||
});
|
||
|
||
describe('parseArgs — CLI-kontrakt', () => {
|
||
test('defaults: target=article, out avledes senere (null)', () => {
|
||
const r = parseArgs(['fig.svg']);
|
||
assert.equal(r.error, undefined);
|
||
assert.equal(r.input, 'fig.svg');
|
||
assert.equal(r.target, 'article');
|
||
assert.equal(r.out, null);
|
||
});
|
||
|
||
test('--target/--out/--width/--height parses (tall som tall)', () => {
|
||
const r = parseArgs(['fig.svg', '--target', 'carousel', '--out', '/tmp/x.png', '--width', '900', '--height', '1200']);
|
||
assert.equal(r.target, 'carousel');
|
||
assert.equal(r.out, '/tmp/x.png');
|
||
assert.equal(r.width, 900);
|
||
assert.equal(r.height, 1200);
|
||
});
|
||
|
||
test('ugyldig target gir error med gyldige mål', () => {
|
||
const r = parseArgs(['fig.svg', '--target', 'poster']);
|
||
assert.match(r.error, /article/);
|
||
});
|
||
|
||
test('manglende input gir usage-error', () => {
|
||
const r = parseArgs([]);
|
||
assert.match(r.error, /[Bb]ruk/);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Ende-til-ende (krever ekte Chrome — skippes CI-vennlig når binær mangler)
|
||
// ---------------------------------------------------------------------------
|
||
const chrome = resolveChrome();
|
||
|
||
describe('renderFigure — ende-til-ende via headless Chrome', { skip: !chrome.available && 'Chrome ikke funnet' }, () => {
|
||
test('article: fixture-SVG → PNG med 1200px bredde og aspekt-avledet høyde', async () => {
|
||
const out = path.join(tmpdir(), 'fig-article.png');
|
||
const r = await renderFigure({ input: FIXTURE_SVG, target: 'article', out, dataDir: tmpdir() });
|
||
assert.equal(r.out, out);
|
||
const dim = pngDimensions(out);
|
||
assert.equal(dim.width, 1200);
|
||
assert.equal(dim.height, 675); // fixture er 16:9 (viewBox 1200×675)
|
||
});
|
||
|
||
test('carousel: fixture-SVG → PNG 1080×1350', async () => {
|
||
const out = path.join(tmpdir(), 'fig-carousel.png');
|
||
await renderFigure({ input: FIXTURE_SVG, target: 'carousel', out, dataDir: tmpdir() });
|
||
const dim = pngDimensions(out);
|
||
assert.equal(dim.width, 1080);
|
||
assert.equal(dim.height, 1350);
|
||
});
|
||
|
||
test('render uten brand-tokens.json lykkes med nøytrale defaults', async () => {
|
||
const out = path.join(tmpdir(), 'fig-default-tokens.png');
|
||
const r = await renderFigure({ input: FIXTURE_SVG, target: 'single', out, dataDir: tmpdir() });
|
||
assert.equal(r.tokensSource, 'default');
|
||
assert.ok(fs.existsSync(out));
|
||
// PNG-magic
|
||
const buf = fs.readFileSync(out);
|
||
assert.equal(buf.readUInt32BE(0), 0x89504e47);
|
||
});
|
||
|
||
test('CLI: node build-figur.mjs <fixture> --target carousel --out <png>', () => {
|
||
const out = path.join(tmpdir(), 'fig-cli.png');
|
||
const script = path.join(__dirname, '..', 'build-figur.mjs');
|
||
execFileSync(process.execPath, [script, FIXTURE_SVG, '--target', 'carousel', '--out', out], {
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
const dim = pngDimensions(out);
|
||
assert.equal(dim.width, 1080);
|
||
assert.equal(dim.height, 1350);
|
||
});
|
||
});
|
||
|
||
describe('cleanupWorkDir — opprydding velter aldri et vellykket render', () => {
|
||
test('svelger ENOTEMPTY fra en katalog Chrome fortsatt skriver til', () => {
|
||
const err = Object.assign(new Error('ENOTEMPTY, Directory not empty'), { code: 'ENOTEMPTY' });
|
||
let calls = 0;
|
||
const rm = () => { calls += 1; throw err; };
|
||
assert.doesNotThrow(() => cleanupWorkDir('/tmp/build-figur-fake', rm));
|
||
assert.ok(calls > 1, `skal forsoeke paa nytt foer den gir opp (kall: ${calls})`);
|
||
});
|
||
|
||
test('fjerner katalogen i normaltilfellet', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-ok-'));
|
||
fs.writeFileSync(path.join(dir, 'figur.html'), '<html></html>', 'utf8');
|
||
cleanupWorkDir(dir);
|
||
assert.equal(fs.existsSync(dir), false);
|
||
});
|
||
|
||
test('gir seg uten aa kaste naar katalogen allerede er borte', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-gone-'));
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
assert.doesNotThrow(() => cleanupWorkDir(dir));
|
||
});
|
||
});
|