ui-export-svg.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. /**
  2. * The SVG exporter (CG-55) — `ui/src/lib/export-svg.ts`.
  3. *
  4. * The export exists to leave the app, so the properties worth pinning are the
  5. * ones a reader on the other side depends on:
  6. *
  7. * - it is **well-formed XML**, or GitHub's sanitiser drops it and the reader
  8. * sees a broken-image icon with no explanation;
  9. * - it carries the **light** tokens whatever the viewer was set to, because a
  10. * dark image on a white comment background reads as a mistake;
  11. * - it says the **same thing the screen does** — same cards, same hops, same
  12. * dashed hops, same hidden thin links — because the whole point of exporting
  13. * from the layout object rather than the DOM is that the two cannot diverge;
  14. * - it fits the drawing, with nothing running off the edge of the canvas.
  15. *
  16. * Everything here is pure. The raster step needs a browser and is verified
  17. * over CDP against a live `codegraph ui`.
  18. */
  19. import { describe, it, expect } from 'vitest';
  20. import {
  21. EXPORT_COLORS,
  22. EXPORT_PADDING,
  23. MARK_TEXT,
  24. capRows,
  25. esc,
  26. exportFilename,
  27. flowSvg,
  28. mapSvg,
  29. truncate,
  30. wrapText,
  31. } from '../ui/src/lib/export-svg';
  32. import { buildFlowLayout } from '../ui/src/lib/flow-model';
  33. import { buildMapLayout } from '../ui/src/lib/map-model';
  34. import type {
  35. WireFlow,
  36. WireFlowBoundary,
  37. WireFlowEdge,
  38. WireFlowHop,
  39. WireMapLink,
  40. WireMapModule,
  41. WireNodeRef,
  42. } from '../ui/src/lib/api';
  43. /* ------------------------------------------------------------- builders -- */
  44. function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
  45. return {
  46. kind: 'calls',
  47. label: 'calls',
  48. upward: false,
  49. uncertain: false,
  50. synthesized: false,
  51. line: 42,
  52. ...over,
  53. };
  54. }
  55. function ref(name: string): WireNodeRef {
  56. return {
  57. id: `method:${name}`,
  58. kind: 'method',
  59. name,
  60. qualifiedName: name,
  61. file: `src/deep/${name}.ts`,
  62. line: 10,
  63. endLine: 40,
  64. language: 'typescript',
  65. test: false,
  66. };
  67. }
  68. function hop(
  69. name: string,
  70. opts: { lines?: string[]; edge?: WireFlowEdge | null; callLine?: number } = {}
  71. ): WireFlowHop {
  72. const lines = opts.lines ?? [' const a = 1;', ' return other(a);'];
  73. return {
  74. node: ref(name),
  75. edge: opts.edge === undefined ? edge() : opts.edge,
  76. callRef:
  77. opts.callLine === undefined
  78. ? null
  79. : { line: opts.callLine, col: 9, name: 'other', targetId: 'method:other', backwards: false },
  80. source: {
  81. file: `src/deep/${name}.ts`,
  82. language: 'typescript',
  83. from: 7,
  84. to: 6 + lines.length,
  85. lines,
  86. drift: false,
  87. },
  88. };
  89. }
  90. function flow(id: string, names: string[], over: Partial<WireFlow> = {}): WireFlow {
  91. return {
  92. id,
  93. label: `${names[0]} → ${names[names.length - 1]}`,
  94. hops: names.map((name, i) =>
  95. hop(name, { edge: i === 0 ? null : edge(), callLine: i === 0 ? 8 : undefined })
  96. ),
  97. boundary: null,
  98. partial: false,
  99. ...over,
  100. };
  101. }
  102. function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
  103. return {
  104. node: ref('routeAny'),
  105. sites: [
  106. {
  107. form: 'computed-call',
  108. label: 'computed member call',
  109. snippet: 'return table[name](payload);',
  110. line: 61,
  111. key: 'save',
  112. keyIsType: false,
  113. moreSites: 0,
  114. candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
  115. candidateNote: null,
  116. },
  117. ],
  118. uncertain: { total: 0, shown: 0, truncated: false, items: [] },
  119. further: { total: 0, shown: 0, truncated: false, items: [] },
  120. missed: [],
  121. ...over,
  122. };
  123. }
  124. function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
  125. return {
  126. id,
  127. label: id.slice(id.lastIndexOf('/') + 1) || id,
  128. files: over.files ?? 3,
  129. symbols: over.symbols ?? 30,
  130. languages: over.languages ?? [{ language: 'typescript', files: 3 }],
  131. test: over.test ?? false,
  132. facade: over.facade ?? false,
  133. fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
  134. };
  135. }
  136. function link(source: string, target: string, count: number, declared = count): WireMapLink {
  137. return { source, target, count, declared, byKind: [{ kind: 'calls', count }], topPairs: [] };
  138. }
  139. /* ------------------------------------------------------------ utilities -- */
  140. /**
  141. * Parse the SVG the way a consumer does.
  142. *
  143. * `DOMParser` is not in Node, so this is a hand-rolled well-formedness check:
  144. * every tag balanced, every attribute quoted, no stray `<` or `&` in text. That
  145. * is exactly the class of bug an un-escaped symbol name (`Map<K,V>`, `a && b`)
  146. * would introduce, and it is the one that makes GitHub refuse the file.
  147. */
  148. function assertWellFormed(svg: string): void {
  149. const stack: string[] = [];
  150. const tag = /<(\/?)([a-zA-Z:]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
  151. let at = 0;
  152. let match: RegExpExecArray | null;
  153. while ((match = tag.exec(svg)) !== null) {
  154. const between = svg.slice(at, match.index);
  155. expect(between, `unescaped < or & in text: ${JSON.stringify(between)}`).not.toMatch(
  156. /[<]|&(?!(amp|lt|gt|quot|apos|#\d+);)/
  157. );
  158. at = match.index + match[0].length;
  159. const [, closing, name, attrs, selfClosing] = match;
  160. // Every attribute is name="value" with a balanced pair of quotes.
  161. const quotes = (attrs as string).split('"').length - 1;
  162. expect(quotes % 2, `unbalanced quotes in <${name} ${attrs}>`).toBe(0);
  163. if (closing === '/') {
  164. expect(stack.pop(), 'closing tag with no opener').toBe(name);
  165. } else if (selfClosing !== '/') {
  166. stack.push(name as string);
  167. }
  168. }
  169. expect(stack, 'unclosed tags').toEqual([]);
  170. }
  171. function viewBox(svg: string): { width: number; height: number } {
  172. const box = /viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/.exec(svg);
  173. expect(box, 'no viewBox').toBeTruthy();
  174. return { width: Number(box![1]), height: Number(box![2]) };
  175. }
  176. function rootSize(svg: string): { width: number; height: number } {
  177. const w = /<svg[^>]*\bwidth="(\d+)"/.exec(svg);
  178. const h = /<svg[^>]*\bheight="(\d+)"/.exec(svg);
  179. return { width: Number(w![1]), height: Number(h![1]) };
  180. }
  181. /** Every x/y coordinate that appears on a drawn element, for a bounds check. */
  182. function coords(svg: string): Array<{ x: number; y: number }> {
  183. const out: Array<{ x: number; y: number }> = [];
  184. const re = /x="(-?\d+(?:\.\d+)?)"\s+y="(-?\d+(?:\.\d+)?)"/g;
  185. let m: RegExpExecArray | null;
  186. while ((m = re.exec(svg)) !== null) out.push({ x: Number(m[1]), y: Number(m[2]) });
  187. return out;
  188. }
  189. /* ------------------------------------------------------------ primitives -- */
  190. describe('esc', () => {
  191. it('escapes everything XML would choke on', () => {
  192. expect(esc('Map<K, V> & "co"')).toBe('Map&lt;K, V&gt; &amp; &quot;co&quot;');
  193. });
  194. });
  195. describe('truncate', () => {
  196. it('leaves a string that fits alone, and ellipses one that does not', () => {
  197. expect(truncate('short', 400, 12)).toBe('short');
  198. // 12px mono advances at 7.2px, so 36px holds five characters.
  199. expect(truncate('abcdefgh', 36, 12)).toBe('abcd…');
  200. });
  201. it('does not emit a lone ellipsis when there is no room at all', () => {
  202. expect(truncate('abcdefgh', 7, 12)).toBe('');
  203. });
  204. });
  205. describe('wrapText', () => {
  206. it('breaks on words, never mid-word', () => {
  207. expect(wrapText('the quick brown fox jumps over', 12)).toEqual([
  208. 'the quick',
  209. 'brown fox',
  210. 'jumps over',
  211. ]);
  212. });
  213. it('keeps an over-long word on its own line rather than losing it', () => {
  214. expect(wrapText('aa supercalifragilistic bb', 8)).toEqual(['aa', 'supercalifragilistic', 'bb']);
  215. });
  216. });
  217. describe('exportFilename', () => {
  218. it('slugs a flow label into something a filesystem accepts', () => {
  219. expect(exportFilename('flow', 'execute → getFile')).toBe('codegraph-flow-execute-getfile');
  220. expect(exportFilename('map', 'src/')).toBe('codegraph-map-src');
  221. expect(exportFilename('map', '')).toBe('codegraph-map');
  222. });
  223. });
  224. /* ------------------------------------------------------------ flow strip -- */
  225. describe('flowSvg', () => {
  226. const layout = buildFlowLayout([flow('f1', ['execute', 'openFile', 'rowToFileRecord'])], 'f1');
  227. it('is well-formed XML with a viewBox and the mark', () => {
  228. const svg = flowSvg(layout);
  229. assertWellFormed(svg);
  230. expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(true);
  231. expect(svg.trimEnd().endsWith('</svg>')).toBe(true);
  232. expect(svg).toContain(`>${MARK_TEXT}</text>`);
  233. });
  234. it('paints the light paper whatever the viewer was set to', () => {
  235. const svg = flowSvg(layout);
  236. expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
  237. expect(svg).toContain(EXPORT_COLORS.ink);
  238. // No token from the dark set appears anywhere in the file: dark paper,
  239. // dark ink, dark accent. An export follows the reader's page, not ours.
  240. for (const dark of ['#1c1a14', '#f3f1ea', '#d48b96', '#34322a']) {
  241. expect(svg, dark).not.toContain(dark);
  242. }
  243. });
  244. it('names every hop on the strip, once each', () => {
  245. const svg = flowSvg(layout);
  246. for (const name of ['execute', 'openFile', 'rowToFileRecord']) {
  247. expect(svg.split(`>${name}<`).length - 1, name).toBe(1);
  248. }
  249. });
  250. it('keeps fonts as stacks and embeds nothing', () => {
  251. const svg = flowSvg(layout);
  252. expect(svg).toContain("'IBM Plex Mono'");
  253. expect(svg).not.toContain('@font-face');
  254. expect(svg).not.toContain('base64');
  255. });
  256. it('scales only the root size — the geometry is identical', () => {
  257. const one = flowSvg(layout, { scale: 1 });
  258. const two = flowSvg(layout, { scale: 2 });
  259. expect(viewBox(two)).toEqual(viewBox(one));
  260. expect(rootSize(two).width).toBe(rootSize(one).width * 2);
  261. expect(rootSize(two).height).toBe(rootSize(one).height * 2);
  262. // Same drawing, two envelopes: everything between the root tags matches.
  263. expect(two.slice(two.indexOf('\n'))).toBe(one.slice(one.indexOf('\n')));
  264. });
  265. it('fits the drawing inside the canvas with the padding on every side', () => {
  266. const svg = flowSvg(layout);
  267. const box = viewBox(svg);
  268. const cards = layout.cards;
  269. const spanX = Math.max(...cards.map((c) => c.x + c.width)) - Math.min(...cards.map((c) => c.x));
  270. expect(box.width).toBeGreaterThanOrEqual(spanX + EXPORT_PADDING * 2);
  271. for (const { x, y } of coords(svg)) {
  272. expect(x).toBeGreaterThanOrEqual(-1);
  273. expect(y).toBeGreaterThanOrEqual(-1);
  274. }
  275. });
  276. it('carries the edge label and the line the call was recorded at', () => {
  277. const svg = flowSvg(layout);
  278. expect(svg).toContain('>calls</text>');
  279. expect(svg).toContain('>line 42</text>');
  280. });
  281. it('dashes a synthesized hop exactly as the strip does', () => {
  282. const synthesized = flow('f2', ['a', 'b']);
  283. synthesized.hops[1]!.edge = edge({
  284. synthesized: true,
  285. label: 'via callback · registered at src/wire.ts:88',
  286. });
  287. const svg = flowSvg(buildFlowLayout([synthesized], 'f2'));
  288. expect(svg).toContain('stroke-dasharray="5 3"');
  289. // The wiring site is the evidence for a hop nobody can see in the source.
  290. expect(svg).toContain('wire.ts:88');
  291. });
  292. it('tints the call line and underlines the identifier the graph resolved', () => {
  293. const one = flow('f3', ['execute', 'other']);
  294. one.hops[0]!.callRef = {
  295. line: 8,
  296. col: 9,
  297. name: 'other',
  298. targetId: 'method:other',
  299. backwards: false,
  300. };
  301. const svg = flowSvg(buildFlowLayout([one], 'f3'));
  302. expect(svg).toContain(`fill="${EXPORT_COLORS.accentSoft}"`);
  303. expect(svg).toContain(`<tspan fill="${EXPORT_COLORS.accent}">other</tspan>`);
  304. expect(svg).toContain(`stroke="${EXPORT_COLORS.accentLine}"`);
  305. });
  306. it('preserves the indentation of every source line', () => {
  307. const svg = flowSvg(layout);
  308. expect(svg).toContain('xml:space="preserve"');
  309. expect(svg).toContain('<tspan> </tspan>');
  310. });
  311. it('escapes source that would otherwise break the document', () => {
  312. const nasty = flow('f4', ['render']);
  313. nasty.hops[0]!.source!.lines = ['const x = a < b && c > d;', 'type T = Map<K, "v">;'];
  314. const svg = flowSvg(buildFlowLayout([nasty], 'f4'));
  315. assertWellFormed(svg);
  316. expect(svg).toContain('&lt;');
  317. expect(svg).toContain('&amp;&amp;');
  318. });
  319. it('draws the end cap dashed, with the site, the key and the candidate', () => {
  320. const capped = flow('f5', ['dispatch'], { boundary: null });
  321. capped.boundary = boundary({ node: capped.hops[0]!.node });
  322. const svg = flowSvg(buildFlowLayout([capped], 'f5'));
  323. expect(svg).toContain('Where the graph stops.');
  324. expect(svg).toContain('computed member call at line 61');
  325. expect(svg).toContain('>key save</text>');
  326. expect(svg).toContain('1 candidate target');
  327. // The dotted link into a cap, and the cap's own dashed border.
  328. expect(svg).toContain('stroke-dasharray="2 4"');
  329. expect(svg).toContain('>end of</text>');
  330. // …and no arrowhead on it: the absence of a continuation is the finding.
  331. expect(svg.match(/<polygon/g)).toBeNull();
  332. });
  333. it('gives the cap room for the lines it really wraps to', () => {
  334. const long = boundary({
  335. sites: [
  336. {
  337. form: 'computed-call',
  338. label: 'reflective invoke through a registry of handlers',
  339. snippet: 'x',
  340. line: 61,
  341. key: null,
  342. keyIsType: false,
  343. moreSites: 3,
  344. candidates: [],
  345. candidateNote: 'the key is too generic to shortlist against',
  346. },
  347. ],
  348. });
  349. const rows = capRows({
  350. id: 'cap:x',
  351. anchorId: 'x',
  352. boundary: long,
  353. x: 0,
  354. y: 0,
  355. width: 240,
  356. height: 10,
  357. flows: ['f'],
  358. });
  359. // Every row is inside the cap's own text column…
  360. for (const row of rows.rows) expect(row.text.length).toBeLessThanOrEqual(32);
  361. // …and the height accounts for all of them.
  362. expect(rows.height).toBeGreaterThan(rows.rows.length * 15);
  363. });
  364. it('dims the paths that are not the picked one when several are drawn', () => {
  365. const both = [flow('a', ['start', 'left', 'end']), flow('b', ['start', 'right', 'end'])];
  366. const svg = flowSvg(buildFlowLayout(both, 'a'), { activeFlowId: 'a', showAll: true });
  367. expect(svg).toContain('opacity="0.4"');
  368. // The picked path keeps the accent border; the other does not.
  369. expect(svg).toContain(`stroke="${EXPORT_COLORS.accent}"`);
  370. expect(svg).toContain('>right</text>');
  371. });
  372. it('writes the caption next to the mark', () => {
  373. const svg = flowSvg(layout, { caption: 'execute → rowToFileRecord · 3 hops' });
  374. expect(svg).toContain('execute → rowToFileRecord · 3 hops');
  375. assertWellFormed(svg);
  376. });
  377. });
  378. /* -------------------------------------------------------------------- map -- */
  379. describe('mapSvg', () => {
  380. const payload = {
  381. modules: [
  382. mod('src/bin'),
  383. mod('src/mcp'),
  384. mod('src/db', { symbols: 1218, files: 54 }),
  385. mod('__tests__', { test: true }),
  386. ],
  387. links: [
  388. link('src/bin', 'src/mcp', 30),
  389. link('src/mcp', 'src/db', 22),
  390. link('src/bin', 'src/db', 2),
  391. link('__tests__', 'src/db', 40),
  392. ],
  393. };
  394. const layout = buildMapLayout(payload, { includeTests: false });
  395. it('is well-formed, light, and marked', () => {
  396. const svg = mapSvg(layout);
  397. assertWellFormed(svg);
  398. expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
  399. expect(svg).toContain(`>${MARK_TEXT}</text>`);
  400. });
  401. it('draws every module box with its name and its counts', () => {
  402. const svg = mapSvg(layout);
  403. expect(svg).toContain('>src/bin</text>');
  404. expect(svg).toContain('>src/db</text>');
  405. expect(svg).toContain('>1218 symbols · 54 files</text>');
  406. // Tests were filtered out of the layout, so they are not in the image.
  407. expect(svg).not.toContain('>__tests__</text>');
  408. });
  409. it('names the top and bottom bands', () => {
  410. const svg = mapSvg(layout);
  411. expect(svg).toContain('>entry points</text>');
  412. expect(svg).toContain('>foundations — depend on nothing below</text>');
  413. });
  414. it('hides the same thin links the canvas hides', () => {
  415. const svg = mapSvg(layout);
  416. // src/bin → src/db carries 2, under MIN_WEIGHT: one path per visible link
  417. // plus one per layer rule is not a count worth asserting, so check the
  418. // stroke widths instead — a hidden link contributes none.
  419. const drawn = svg.match(/<path /g)?.length ?? 0;
  420. expect(drawn).toBe(layout.edges.filter((e) => !e.thin && !e.back).length);
  421. });
  422. it('brings a selected module’s thin links out, as the canvas does', () => {
  423. const svg = mapSvg(layout, { selected: 'src/bin' });
  424. const drawn = svg.match(/<path /g)?.length ?? 0;
  425. expect(drawn).toBe(
  426. layout.edges.filter((e) => e.source === 'src/bin' || e.target === 'src/bin').length
  427. );
  428. });
  429. it('dims a module the selection does not touch, and only that one', () => {
  430. // src/bin reaches both other modules, so a fixture needs a fourth module
  431. // standing apart before dimming has anything to say.
  432. const apart = buildMapLayout(
  433. { modules: [...payload.modules, mod('site')], links: payload.links },
  434. { includeTests: false }
  435. );
  436. const svg = mapSvg(apart, { selected: 'src/bin' });
  437. // Exactly one box goes grey: its rule and its two lines of text.
  438. expect(svg.split(`stroke="${EXPORT_COLORS.ink4}"`).length - 1).toBe(1);
  439. expect(svg.split(`fill="${EXPORT_COLORS.ink4}"`).length - 1).toBe(2);
  440. });
  441. it('scales the root only', () => {
  442. const one = mapSvg(layout, { scale: 1 });
  443. const two = mapSvg(layout, { scale: 2 });
  444. expect(viewBox(two)).toEqual(viewBox(one));
  445. expect(rootSize(two).width).toBe(rootSize(one).width * 2);
  446. });
  447. it('keeps every drawn coordinate inside the canvas', () => {
  448. const svg = mapSvg(layout);
  449. const box = viewBox(svg);
  450. for (const { x, y } of coords(svg)) {
  451. expect(x).toBeGreaterThanOrEqual(-1);
  452. expect(y).toBeGreaterThanOrEqual(-1);
  453. expect(x).toBeLessThanOrEqual(box.width + 1);
  454. expect(y).toBeLessThanOrEqual(box.height + 1);
  455. }
  456. // Layer rules are the one thing that spans the whole picture, and the one
  457. // that used to run off the right-hand edge: they follow the boxes, not the
  458. // canvas' own padded width.
  459. const rules = [...svg.matchAll(/x1="(-?[\d.]+)"[^>]*x2="(-?[\d.]+)"/g)];
  460. expect(rules.length).toBeGreaterThan(0);
  461. for (const [, x1, x2] of rules) {
  462. expect(Number(x1)).toBeGreaterThanOrEqual(0);
  463. expect(Number(x2)).toBeLessThanOrEqual(box.width);
  464. }
  465. });
  466. it('marks a test module dashed when it is included', () => {
  467. const withTests = buildMapLayout(payload, { includeTests: true });
  468. const svg = mapSvg(withTests);
  469. expect(svg).toContain('>__tests__</text>');
  470. expect(svg).toContain('stroke-dasharray="4 3"');
  471. });
  472. it('survives a module id that needs escaping', () => {
  473. const odd = buildMapLayout(
  474. { modules: [mod('src/<odd> & co'), mod('src/db')], links: [link('src/<odd> & co', 'src/db', 9)] },
  475. { includeTests: false }
  476. );
  477. const svg = mapSvg(odd);
  478. assertWellFormed(svg);
  479. expect(svg).toContain('&lt;odd&gt; &amp; co');
  480. });
  481. });