feat(linkedin-studio): N2 — build-figur renderer (SVG/HTML->PNG, 3 mål, token-seam) [skip-docs]

TDD (33 nye tester, render-suite 20->53/0 = ny floor, 4 ekte e2e-renders):
- render/build-figur.mjs: SVG/HTML -> PNG via headless Chrome; tre mål
  (article 1200xauto, carousel 1080x1350, single 1200x1200), overstyrbare
  via --width/--height; CLI + importerbar modul.
- Token-seam: design-tokens leses fra
  LINKEDIN_STUDIO_DATA/profile/brand-tokens.json (brukerdata), nøytrale
  defaults i modulen — merkevare aldri hardkodet i repoet. Injiseres som
  CSS-variabler (--figur-*).
- Warn-validering (aldri hard fail): min 24pt tekst, fargebudsjett
  (maks 2 ikke-nøytrale farger).
- Robust Chrome-oppdagelse: app-bundle-stier -> PATH -> install-hint
  (prober injiserbare for test).
- macOS-quirk (verifisert lokalt, Chrome 150): headless skriver PNG-en men
  avslutter aldri -> stderr-watchdog («written to file») + kill + verifisering
  av output. Søk-først utført (kjent headless-ustabilitet på macOS).
- Fixture: nøytral demo-SVG (placeholder-søylediagram) under
  render/__tests__/fixtures/.

Alle seks suiter grønne: test-runner 138/0, trends 245/0, brain 134/0,
hooks 140/0, tests 35/0, render 53/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: 26f26c3d-be91-47c4-bbdf-4694ba063b67
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:26:19 +02:00
commit c4434ed789
3 changed files with 669 additions and 0 deletions

View file

@ -0,0 +1,284 @@
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,
} 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);
});
});

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" role="img" aria-label="Demo: soylediagram med placeholder-data">
<!-- Noytral demo-figur (placeholder-data) for build-figur-testene.
Bruker token-CSS-variabler med noytrale fallbacks. -->
<rect width="1200" height="675" fill="var(--figur-background, #FFFFFF)"/>
<text x="80" y="90" font-family="var(--figur-fontFamily, sans-serif)" font-size="40" font-weight="700" fill="var(--figur-ink, #1A1A1A)">Kvartalsvis utvikling (demo)</text>
<text x="80" y="135" font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">Placeholder-data — ikke reelle tall</text>
<g>
<rect x="120" y="380" width="140" height="180" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="360" y="320" width="140" height="240" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="600" y="250" width="140" height="310" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="840" y="180" width="140" height="380" fill="var(--figur-accent, #2F6F9F)"/>
</g>
<line x1="80" y1="560" x2="1120" y2="560" stroke="var(--figur-rule, #D9D9D9)" stroke-width="2"/>
<g font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">
<text x="160" y="605">Q1</text>
<text x="400" y="605">Q2</text>
<text x="640" y="605">Q3</text>
<text x="880" y="605">Q4</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB