Files
codegraph/__tests__/ui-export-svg.test.ts
T
Colby McHenry 8ac0138940 feat(ui): copy the flow or the map as an image, for a PR comment or a README (CG-55)
"Copy image" and "Download SVG" on the Flow strip's header and in the Map's
side panel. The image is the distribution loop: a flow pasted into a review, a
map pasted into a README, read by somebody with no viewer open.

The exporter serialises the LAYOUT OBJECT rather than scraping the DOM — no
html-to-image, no foreignObject, no new dependency. buildFlowLayout and
buildMapLayout already compute every rectangle, port and curve before a
component renders, so the image and the screen come from one piece of
arithmetic and cannot drift apart, and the whole exporter is a pure function a
test runs with no browser. Output is presentation-only SVG (rect, line, path,
polygon, text, tspan, clipPath) — no script, no external reference, no data:
URL — which is what GitHub's sanitiser accepts in a README.

Light theme is forced whatever the viewer is set to: a dark strip on GitHub's
white comment background reads as a mistake, not a preference. 24px of paper
around the drawing, a caption naming the path or the root at the bottom left,
a CodeGraph mark at the bottom right.

Fonts travel as family stacks, not bytes (spec). An SVG loaded as an image may
not fetch a webfont, so a raster falls back to the platform's own monospace —
every fallback in the stack advances at ~0.6em like IBM Plex Mono, so the code
grid survives and only the letterforms change. Text is truncated
arithmetically with an ellipsis and clipped as well, so a wider fallback
cannot spill a source line out of a card.

`scale` multiplies only the root width/height while the viewBox stays in CSS
pixels, so the raster draws an image whose intrinsic size is already 2x
instead of upscaling a 1x bitmap. The clipboard write uses the ClipboardItem
promise form (Safari discards the gesture across an await) and falls back to
downloading the PNG, saying which happened rather than claiming a copy it did
not make.

Measured on this repo: execute -> rowToFileRecord (8 hops) exports 3690x253
CSS px, 491 kB PNG at 2x / 38 kB SVG; the 16-module map reproduces the canvas
exactly — 16 boxes, 52 links, 9 layer rules, both band labels, and with
src/index.ts selected 15 links and 4 dimmed boxes.
2026-08-27 05:41:34 -05:00

532 lines
19 KiB
TypeScript
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.
/**
* The SVG exporter (CG-55) — `ui/src/lib/export-svg.ts`.
*
* The export exists to leave the app, so the properties worth pinning are the
* ones a reader on the other side depends on:
*
* - it is **well-formed XML**, or GitHub's sanitiser drops it and the reader
* sees a broken-image icon with no explanation;
* - it carries the **light** tokens whatever the viewer was set to, because a
* dark image on a white comment background reads as a mistake;
* - it says the **same thing the screen does** — same cards, same hops, same
* dashed hops, same hidden thin links — because the whole point of exporting
* from the layout object rather than the DOM is that the two cannot diverge;
* - it fits the drawing, with nothing running off the edge of the canvas.
*
* Everything here is pure. The raster step needs a browser and is verified
* over CDP against a live `codegraph ui`.
*/
import { describe, it, expect } from 'vitest';
import {
EXPORT_COLORS,
EXPORT_PADDING,
MARK_TEXT,
capRows,
esc,
exportFilename,
flowSvg,
mapSvg,
truncate,
wrapText,
} from '../ui/src/lib/export-svg';
import { buildFlowLayout } from '../ui/src/lib/flow-model';
import { buildMapLayout } from '../ui/src/lib/map-model';
import type {
WireFlow,
WireFlowBoundary,
WireFlowEdge,
WireFlowHop,
WireMapLink,
WireMapModule,
WireNodeRef,
} from '../ui/src/lib/api';
/* ------------------------------------------------------------- builders -- */
function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
return {
kind: 'calls',
label: 'calls',
upward: false,
uncertain: false,
synthesized: false,
line: 42,
...over,
};
}
function ref(name: string): WireNodeRef {
return {
id: `method:${name}`,
kind: 'method',
name,
qualifiedName: name,
file: `src/deep/${name}.ts`,
line: 10,
endLine: 40,
language: 'typescript',
test: false,
};
}
function hop(
name: string,
opts: { lines?: string[]; edge?: WireFlowEdge | null; callLine?: number } = {}
): WireFlowHop {
const lines = opts.lines ?? [' const a = 1;', ' return other(a);'];
return {
node: ref(name),
edge: opts.edge === undefined ? edge() : opts.edge,
callRef:
opts.callLine === undefined
? null
: { line: opts.callLine, col: 9, name: 'other', targetId: 'method:other', backwards: false },
source: {
file: `src/deep/${name}.ts`,
language: 'typescript',
from: 7,
to: 6 + lines.length,
lines,
drift: false,
},
};
}
function flow(id: string, names: string[], over: Partial<WireFlow> = {}): WireFlow {
return {
id,
label: `${names[0]}${names[names.length - 1]}`,
hops: names.map((name, i) =>
hop(name, { edge: i === 0 ? null : edge(), callLine: i === 0 ? 8 : undefined })
),
boundary: null,
partial: false,
...over,
};
}
function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
return {
node: ref('routeAny'),
sites: [
{
form: 'computed-call',
label: 'computed member call',
snippet: 'return table[name](payload);',
line: 61,
key: 'save',
keyIsType: false,
moreSites: 0,
candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
candidateNote: null,
},
],
uncertain: { total: 0, shown: 0, truncated: false, items: [] },
further: { total: 0, shown: 0, truncated: false, items: [] },
missed: [],
...over,
};
}
function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
return {
id,
label: id.slice(id.lastIndexOf('/') + 1) || id,
files: over.files ?? 3,
symbols: over.symbols ?? 30,
languages: over.languages ?? [{ language: 'typescript', files: 3 }],
test: over.test ?? false,
facade: over.facade ?? false,
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
};
}
function link(source: string, target: string, count: number, declared = count): WireMapLink {
return { source, target, count, declared, byKind: [{ kind: 'calls', count }], topPairs: [] };
}
/* ------------------------------------------------------------ utilities -- */
/**
* Parse the SVG the way a consumer does.
*
* `DOMParser` is not in Node, so this is a hand-rolled well-formedness check:
* every tag balanced, every attribute quoted, no stray `<` or `&` in text. That
* is exactly the class of bug an un-escaped symbol name (`Map<K,V>`, `a && b`)
* would introduce, and it is the one that makes GitHub refuse the file.
*/
function assertWellFormed(svg: string): void {
const stack: string[] = [];
const tag = /<(\/?)([a-zA-Z:]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
let at = 0;
let match: RegExpExecArray | null;
while ((match = tag.exec(svg)) !== null) {
const between = svg.slice(at, match.index);
expect(between, `unescaped < or & in text: ${JSON.stringify(between)}`).not.toMatch(
/[<]|&(?!(amp|lt|gt|quot|apos|#\d+);)/
);
at = match.index + match[0].length;
const [, closing, name, attrs, selfClosing] = match;
// Every attribute is name="value" with a balanced pair of quotes.
const quotes = (attrs as string).split('"').length - 1;
expect(quotes % 2, `unbalanced quotes in <${name} ${attrs}>`).toBe(0);
if (closing === '/') {
expect(stack.pop(), 'closing tag with no opener').toBe(name);
} else if (selfClosing !== '/') {
stack.push(name as string);
}
}
expect(stack, 'unclosed tags').toEqual([]);
}
function viewBox(svg: string): { width: number; height: number } {
const box = /viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/.exec(svg);
expect(box, 'no viewBox').toBeTruthy();
return { width: Number(box![1]), height: Number(box![2]) };
}
function rootSize(svg: string): { width: number; height: number } {
const w = /<svg[^>]*\bwidth="(\d+)"/.exec(svg);
const h = /<svg[^>]*\bheight="(\d+)"/.exec(svg);
return { width: Number(w![1]), height: Number(h![1]) };
}
/** Every x/y coordinate that appears on a drawn element, for a bounds check. */
function coords(svg: string): Array<{ x: number; y: number }> {
const out: Array<{ x: number; y: number }> = [];
const re = /x="(-?\d+(?:\.\d+)?)"\s+y="(-?\d+(?:\.\d+)?)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(svg)) !== null) out.push({ x: Number(m[1]), y: Number(m[2]) });
return out;
}
/* ------------------------------------------------------------ primitives -- */
describe('esc', () => {
it('escapes everything XML would choke on', () => {
expect(esc('Map<K, V> & "co"')).toBe('Map&lt;K, V&gt; &amp; &quot;co&quot;');
});
});
describe('truncate', () => {
it('leaves a string that fits alone, and ellipses one that does not', () => {
expect(truncate('short', 400, 12)).toBe('short');
// 12px mono advances at 7.2px, so 36px holds five characters.
expect(truncate('abcdefgh', 36, 12)).toBe('abcd…');
});
it('does not emit a lone ellipsis when there is no room at all', () => {
expect(truncate('abcdefgh', 7, 12)).toBe('');
});
});
describe('wrapText', () => {
it('breaks on words, never mid-word', () => {
expect(wrapText('the quick brown fox jumps over', 12)).toEqual([
'the quick',
'brown fox',
'jumps over',
]);
});
it('keeps an over-long word on its own line rather than losing it', () => {
expect(wrapText('aa supercalifragilistic bb', 8)).toEqual(['aa', 'supercalifragilistic', 'bb']);
});
});
describe('exportFilename', () => {
it('slugs a flow label into something a filesystem accepts', () => {
expect(exportFilename('flow', 'execute → getFile')).toBe('codegraph-flow-execute-getfile');
expect(exportFilename('map', 'src/')).toBe('codegraph-map-src');
expect(exportFilename('map', '')).toBe('codegraph-map');
});
});
/* ------------------------------------------------------------ flow strip -- */
describe('flowSvg', () => {
const layout = buildFlowLayout([flow('f1', ['execute', 'openFile', 'rowToFileRecord'])], 'f1');
it('is well-formed XML with a viewBox and the mark', () => {
const svg = flowSvg(layout);
assertWellFormed(svg);
expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(true);
expect(svg.trimEnd().endsWith('</svg>')).toBe(true);
expect(svg).toContain(`>${MARK_TEXT}</text>`);
});
it('paints the light paper whatever the viewer was set to', () => {
const svg = flowSvg(layout);
expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
expect(svg).toContain(EXPORT_COLORS.ink);
// No token from the dark set appears anywhere in the file: dark paper,
// dark ink, dark accent. An export follows the reader's page, not ours.
for (const dark of ['#1c1a14', '#f3f1ea', '#d48b96', '#34322a']) {
expect(svg, dark).not.toContain(dark);
}
});
it('names every hop on the strip, once each', () => {
const svg = flowSvg(layout);
for (const name of ['execute', 'openFile', 'rowToFileRecord']) {
expect(svg.split(`>${name}<`).length - 1, name).toBe(1);
}
});
it('keeps fonts as stacks and embeds nothing', () => {
const svg = flowSvg(layout);
expect(svg).toContain("'IBM Plex Mono'");
expect(svg).not.toContain('@font-face');
expect(svg).not.toContain('base64');
});
it('scales only the root size — the geometry is identical', () => {
const one = flowSvg(layout, { scale: 1 });
const two = flowSvg(layout, { scale: 2 });
expect(viewBox(two)).toEqual(viewBox(one));
expect(rootSize(two).width).toBe(rootSize(one).width * 2);
expect(rootSize(two).height).toBe(rootSize(one).height * 2);
// Same drawing, two envelopes: everything between the root tags matches.
expect(two.slice(two.indexOf('\n'))).toBe(one.slice(one.indexOf('\n')));
});
it('fits the drawing inside the canvas with the padding on every side', () => {
const svg = flowSvg(layout);
const box = viewBox(svg);
const cards = layout.cards;
const spanX = Math.max(...cards.map((c) => c.x + c.width)) - Math.min(...cards.map((c) => c.x));
expect(box.width).toBeGreaterThanOrEqual(spanX + EXPORT_PADDING * 2);
for (const { x, y } of coords(svg)) {
expect(x).toBeGreaterThanOrEqual(-1);
expect(y).toBeGreaterThanOrEqual(-1);
}
});
it('carries the edge label and the line the call was recorded at', () => {
const svg = flowSvg(layout);
expect(svg).toContain('>calls</text>');
expect(svg).toContain('>line 42</text>');
});
it('dashes a synthesized hop exactly as the strip does', () => {
const synthesized = flow('f2', ['a', 'b']);
synthesized.hops[1]!.edge = edge({
synthesized: true,
label: 'via callback · registered at src/wire.ts:88',
});
const svg = flowSvg(buildFlowLayout([synthesized], 'f2'));
expect(svg).toContain('stroke-dasharray="5 3"');
// The wiring site is the evidence for a hop nobody can see in the source.
expect(svg).toContain('wire.ts:88');
});
it('tints the call line and underlines the identifier the graph resolved', () => {
const one = flow('f3', ['execute', 'other']);
one.hops[0]!.callRef = {
line: 8,
col: 9,
name: 'other',
targetId: 'method:other',
backwards: false,
};
const svg = flowSvg(buildFlowLayout([one], 'f3'));
expect(svg).toContain(`fill="${EXPORT_COLORS.accentSoft}"`);
expect(svg).toContain(`<tspan fill="${EXPORT_COLORS.accent}">other</tspan>`);
expect(svg).toContain(`stroke="${EXPORT_COLORS.accentLine}"`);
});
it('preserves the indentation of every source line', () => {
const svg = flowSvg(layout);
expect(svg).toContain('xml:space="preserve"');
expect(svg).toContain('<tspan> </tspan>');
});
it('escapes source that would otherwise break the document', () => {
const nasty = flow('f4', ['render']);
nasty.hops[0]!.source!.lines = ['const x = a < b && c > d;', 'type T = Map<K, "v">;'];
const svg = flowSvg(buildFlowLayout([nasty], 'f4'));
assertWellFormed(svg);
expect(svg).toContain('&lt;');
expect(svg).toContain('&amp;&amp;');
});
it('draws the end cap dashed, with the site, the key and the candidate', () => {
const capped = flow('f5', ['dispatch'], { boundary: null });
capped.boundary = boundary({ node: capped.hops[0]!.node });
const svg = flowSvg(buildFlowLayout([capped], 'f5'));
expect(svg).toContain('Where the graph stops.');
expect(svg).toContain('computed member call at line 61');
expect(svg).toContain('>key save</text>');
expect(svg).toContain('1 candidate target');
// The dotted link into a cap, and the cap's own dashed border.
expect(svg).toContain('stroke-dasharray="2 4"');
expect(svg).toContain('>end of</text>');
// …and no arrowhead on it: the absence of a continuation is the finding.
expect(svg.match(/<polygon/g)).toBeNull();
});
it('gives the cap room for the lines it really wraps to', () => {
const long = boundary({
sites: [
{
form: 'computed-call',
label: 'reflective invoke through a registry of handlers',
snippet: 'x',
line: 61,
key: null,
keyIsType: false,
moreSites: 3,
candidates: [],
candidateNote: 'the key is too generic to shortlist against',
},
],
});
const rows = capRows({
id: 'cap:x',
anchorId: 'x',
boundary: long,
x: 0,
y: 0,
width: 240,
height: 10,
flows: ['f'],
});
// Every row is inside the cap's own text column…
for (const row of rows.rows) expect(row.text.length).toBeLessThanOrEqual(32);
// …and the height accounts for all of them.
expect(rows.height).toBeGreaterThan(rows.rows.length * 15);
});
it('dims the paths that are not the picked one when several are drawn', () => {
const both = [flow('a', ['start', 'left', 'end']), flow('b', ['start', 'right', 'end'])];
const svg = flowSvg(buildFlowLayout(both, 'a'), { activeFlowId: 'a', showAll: true });
expect(svg).toContain('opacity="0.4"');
// The picked path keeps the accent border; the other does not.
expect(svg).toContain(`stroke="${EXPORT_COLORS.accent}"`);
expect(svg).toContain('>right</text>');
});
it('writes the caption next to the mark', () => {
const svg = flowSvg(layout, { caption: 'execute → rowToFileRecord · 3 hops' });
expect(svg).toContain('execute → rowToFileRecord · 3 hops');
assertWellFormed(svg);
});
});
/* -------------------------------------------------------------------- map -- */
describe('mapSvg', () => {
const payload = {
modules: [
mod('src/bin'),
mod('src/mcp'),
mod('src/db', { symbols: 1218, files: 54 }),
mod('__tests__', { test: true }),
],
links: [
link('src/bin', 'src/mcp', 30),
link('src/mcp', 'src/db', 22),
link('src/bin', 'src/db', 2),
link('__tests__', 'src/db', 40),
],
};
const layout = buildMapLayout(payload, { includeTests: false });
it('is well-formed, light, and marked', () => {
const svg = mapSvg(layout);
assertWellFormed(svg);
expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
expect(svg).toContain(`>${MARK_TEXT}</text>`);
});
it('draws every module box with its name and its counts', () => {
const svg = mapSvg(layout);
expect(svg).toContain('>src/bin</text>');
expect(svg).toContain('>src/db</text>');
expect(svg).toContain('>1218 symbols · 54 files</text>');
// Tests were filtered out of the layout, so they are not in the image.
expect(svg).not.toContain('>__tests__</text>');
});
it('names the top and bottom bands', () => {
const svg = mapSvg(layout);
expect(svg).toContain('>entry points</text>');
expect(svg).toContain('>foundations — depend on nothing below</text>');
});
it('hides the same thin links the canvas hides', () => {
const svg = mapSvg(layout);
// src/bin → src/db carries 2, under MIN_WEIGHT: one path per visible link
// plus one per layer rule is not a count worth asserting, so check the
// stroke widths instead — a hidden link contributes none.
const drawn = svg.match(/<path /g)?.length ?? 0;
expect(drawn).toBe(layout.edges.filter((e) => !e.thin && !e.back).length);
});
it('brings a selected modules thin links out, as the canvas does', () => {
const svg = mapSvg(layout, { selected: 'src/bin' });
const drawn = svg.match(/<path /g)?.length ?? 0;
expect(drawn).toBe(
layout.edges.filter((e) => e.source === 'src/bin' || e.target === 'src/bin').length
);
});
it('dims a module the selection does not touch, and only that one', () => {
// src/bin reaches both other modules, so a fixture needs a fourth module
// standing apart before dimming has anything to say.
const apart = buildMapLayout(
{ modules: [...payload.modules, mod('site')], links: payload.links },
{ includeTests: false }
);
const svg = mapSvg(apart, { selected: 'src/bin' });
// Exactly one box goes grey: its rule and its two lines of text.
expect(svg.split(`stroke="${EXPORT_COLORS.ink4}"`).length - 1).toBe(1);
expect(svg.split(`fill="${EXPORT_COLORS.ink4}"`).length - 1).toBe(2);
});
it('scales the root only', () => {
const one = mapSvg(layout, { scale: 1 });
const two = mapSvg(layout, { scale: 2 });
expect(viewBox(two)).toEqual(viewBox(one));
expect(rootSize(two).width).toBe(rootSize(one).width * 2);
});
it('keeps every drawn coordinate inside the canvas', () => {
const svg = mapSvg(layout);
const box = viewBox(svg);
for (const { x, y } of coords(svg)) {
expect(x).toBeGreaterThanOrEqual(-1);
expect(y).toBeGreaterThanOrEqual(-1);
expect(x).toBeLessThanOrEqual(box.width + 1);
expect(y).toBeLessThanOrEqual(box.height + 1);
}
// Layer rules are the one thing that spans the whole picture, and the one
// that used to run off the right-hand edge: they follow the boxes, not the
// canvas' own padded width.
const rules = [...svg.matchAll(/x1="(-?[\d.]+)"[^>]*x2="(-?[\d.]+)"/g)];
expect(rules.length).toBeGreaterThan(0);
for (const [, x1, x2] of rules) {
expect(Number(x1)).toBeGreaterThanOrEqual(0);
expect(Number(x2)).toBeLessThanOrEqual(box.width);
}
});
it('marks a test module dashed when it is included', () => {
const withTests = buildMapLayout(payload, { includeTests: true });
const svg = mapSvg(withTests);
expect(svg).toContain('>__tests__</text>');
expect(svg).toContain('stroke-dasharray="4 3"');
});
it('survives a module id that needs escaping', () => {
const odd = buildMapLayout(
{ modules: [mod('src/<odd> & co'), mod('src/db')], links: [link('src/<odd> & co', 'src/db', 9)] },
{ includeTests: false }
);
const svg = mapSvg(odd);
assertWellFormed(svg);
expect(svg).toContain('&lt;odd&gt; &amp; co');
});
});