linkedin-studio/render/__tests__/build-figur.test.mjs
Kjell Tore Guttormsen e0f964ad60 fix(linkedin-studio): N18 Del 1 — opprydding velter aldri et vellykket figur-render [skip-docs]
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
2026-07-26 17:19:01 +02:00

308 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 1623, 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));
});
});