1
0

ui-package.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. /**
  2. * `@colbymchenry/codegraph-ui` — the package's own test (task CG-61).
  3. *
  4. * A minimal Svelte host mounts the three headline components from the package
  5. * entry against a MOCK adapter and asserts what lands in the document. That is
  6. * the whole promise of the package in one file: CodeGraph Pro renders these
  7. * same components over its own in-process engine reads, so if a screen can be
  8. * drawn from an object literal here, it can be drawn from a graph there.
  9. *
  10. * The import is `ui/src/index.ts` — the package entry itself, not the
  11. * components one by one — so a name dropped from the public surface fails here
  12. * rather than in the Pro app.
  13. *
  14. * Everything below is deliberately about the SEAM, not about the screens:
  15. * layout, geometry and the rails have their own suites (`ui-symbol-model`,
  16. * `ui-flow-model`, `ui-map-model`). What is being proved here is that no
  17. * component reaches past the adapter for anything.
  18. */
  19. import { readFileSync } from 'node:fs';
  20. import { join } from 'node:path';
  21. import { flushSync, mount, unmount } from 'svelte';
  22. import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
  23. import {
  24. ArchitectureMap,
  25. CodegraphUi,
  26. FlowStrip,
  27. SearchPalette,
  28. SymbolView,
  29. SavedTrails,
  30. TrailBar,
  31. TypeHierarchy,
  32. createHttpAdapter,
  33. fileHref,
  34. flowHref,
  35. getGraphAdapter,
  36. hashNavigation,
  37. live,
  38. mapHref,
  39. setGraphAdapter,
  40. setNavigationDriver,
  41. symbolHref,
  42. trail,
  43. type GraphAdapter,
  44. type NavigationDriver,
  45. type WireFlowPayload,
  46. type WireMapPayload,
  47. type WireNodeRef,
  48. type WireSource,
  49. type WireStats,
  50. type WireHierarchy,
  51. type WireSymbolPayload,
  52. } from '../ui/src/index';
  53. /* ---------------------------------------------------------------- fixtures */
  54. const ROOT = join(import.meta.dirname, '..');
  55. function nodeRef(overrides: Partial<WireNodeRef> = {}): WireNodeRef {
  56. return {
  57. id: 'function:parseToken@src/auth/token.ts:12',
  58. kind: 'function',
  59. name: 'parseToken',
  60. qualifiedName: 'parseToken',
  61. file: 'src/auth/token.ts',
  62. line: 12,
  63. endLine: 18,
  64. language: 'typescript',
  65. test: false,
  66. ...overrides,
  67. };
  68. }
  69. const CALLER = nodeRef({
  70. id: 'function:handleCallback@src/auth/callback.ts:40',
  71. name: 'handleCallback',
  72. qualifiedName: 'handleCallback',
  73. file: 'src/auth/callback.ts',
  74. line: 40,
  75. endLine: 60,
  76. });
  77. const CALLEE = nodeRef({
  78. id: 'function:decodeJwt@src/auth/jwt.ts:3',
  79. name: 'decodeJwt',
  80. qualifiedName: 'decodeJwt',
  81. file: 'src/auth/jwt.ts',
  82. line: 3,
  83. endLine: 9,
  84. });
  85. const SYMBOL: WireSymbolPayload = {
  86. node: {
  87. ...nodeRef(),
  88. startColumn: 0,
  89. endColumn: 1,
  90. lines: 7,
  91. exported: true,
  92. },
  93. ancestors: [nodeRef({ id: 'file:src/auth/token.ts', kind: 'file', name: 'token.ts' })],
  94. members: { total: 0, shown: 0, truncated: false, items: [] },
  95. incoming: {
  96. total: 1,
  97. shown: 1,
  98. truncated: false,
  99. items: [
  100. {
  101. node: CALLER,
  102. edgeKinds: ['calls'],
  103. edges: [{ kind: 'calls', line: 44, col: 6, confidence: 1 }],
  104. edgeCount: 1,
  105. lines: [44],
  106. confidence: 1,
  107. uncertain: false,
  108. synthesized: false,
  109. },
  110. ],
  111. },
  112. outgoing: {
  113. total: 1,
  114. shown: 1,
  115. truncated: false,
  116. items: [
  117. {
  118. node: CALLEE,
  119. edgeKinds: ['calls'],
  120. edges: [{ kind: 'calls', line: 14, col: 10, confidence: 1 }],
  121. edgeCount: 1,
  122. lines: [14],
  123. confidence: 1,
  124. uncertain: false,
  125. synthesized: false,
  126. },
  127. ],
  128. },
  129. typesUsed: [],
  130. hierarchy: null,
  131. counts: { callers: 1, callees: 1, typesUsed: 0, fanIn: 1, fanOut: 1, members: 0, hub: false },
  132. tests: { reached: false, hops: null, fileCount: 0, files: [], exhaustive: true, hopsSearched: 3 },
  133. outsideIndex: { total: 0, byKind: {}, samples: [] },
  134. blast: {
  135. direct: 1,
  136. withinHops: 2,
  137. hops: 3,
  138. files: 2,
  139. testFiles: 0,
  140. routes: 0,
  141. topFiles: [{ file: 'src/auth/callback.ts', symbols: 1, test: false }],
  142. },
  143. drift: false,
  144. };
  145. const SOURCE_LINES = [
  146. 'export function parseToken(raw: string): Token {',
  147. ' // Normalize expiry before anything else reads it.',
  148. ' const claims = decodeJwt(raw);',
  149. ' return { ...claims, expiresAt: claims.exp * 1000 };',
  150. '}',
  151. ];
  152. const SOURCE: WireSource = {
  153. file: 'src/auth/token.ts',
  154. language: 'typescript',
  155. drift: false,
  156. showing: 'indexed',
  157. contentHash: 'abc123',
  158. indexedAt: 1_700_000_000_000,
  159. generated: false,
  160. totalLines: 40,
  161. from: 12,
  162. to: 18,
  163. lines: SOURCE_LINES,
  164. };
  165. const FLOW: WireFlowPayload = {
  166. query: { kind: 'directed', from: 'handleCallback', to: 'decodeJwt', symbols: [] },
  167. flows: [
  168. {
  169. id: 'flow-1',
  170. label: 'handleCallback → decodeJwt',
  171. partial: false,
  172. boundary: null,
  173. hops: [
  174. {
  175. node: CALLER,
  176. edge: null,
  177. callRef: { line: 44, col: 6, name: 'parseToken', targetId: SYMBOL.node.id, backwards: false },
  178. source: {
  179. file: 'src/auth/callback.ts',
  180. language: 'typescript',
  181. from: 44,
  182. to: 46,
  183. lines: [' const token = parseToken(raw);'],
  184. drift: false,
  185. },
  186. },
  187. {
  188. node: nodeRef(),
  189. edge: {
  190. kind: 'calls',
  191. line: 44,
  192. label: 'calls',
  193. upward: false,
  194. uncertain: false,
  195. synthesized: false,
  196. },
  197. callRef: null,
  198. source: {
  199. file: 'src/auth/token.ts',
  200. language: 'typescript',
  201. from: 12,
  202. to: 14,
  203. lines: SOURCE_LINES.slice(0, 3),
  204. drift: false,
  205. },
  206. },
  207. ],
  208. },
  209. ],
  210. ambiguous: [],
  211. unresolved: [],
  212. reason: null,
  213. index: { lastIndexedAt: 1_700_000_000_000, edges: 4, files: 3 },
  214. timing: { elapsedMs: 2 },
  215. };
  216. const MAP: WireMapPayload = {
  217. root: 'src',
  218. depth: 1,
  219. roots: [{ root: 'src', label: 'src', files: 3 }],
  220. modules: [
  221. {
  222. id: 'src/auth',
  223. label: 'auth',
  224. files: 2,
  225. symbols: 6,
  226. languages: [{ language: 'typescript', files: 2 }],
  227. test: false,
  228. facade: false,
  229. fileList: { total: 2, shown: 2, truncated: false, items: ['src/auth/token.ts', 'src/auth/callback.ts'] },
  230. },
  231. {
  232. id: 'src/http',
  233. label: 'http',
  234. files: 1,
  235. symbols: 3,
  236. languages: [{ language: 'typescript', files: 1 }],
  237. test: false,
  238. facade: false,
  239. fileList: { total: 1, shown: 1, truncated: false, items: ['src/http/server.ts'] },
  240. },
  241. ],
  242. links: [
  243. {
  244. source: 'src/http',
  245. target: 'src/auth',
  246. count: 9,
  247. declared: 7,
  248. byKind: [{ kind: 'calls', count: 9 }],
  249. topPairs: [{ from: 'src/http/server.ts', to: 'src/auth/token.ts', count: 9, declared: 7 }],
  250. },
  251. ],
  252. cycles: { total: 0, shown: 0, truncated: false, items: [] },
  253. excluded: { uncertainEdges: 0, confidenceBelow: 0.6 },
  254. index: { lastIndexedAt: 1_700_000_000_000, edges: 9, files: 3 },
  255. timing: { elapsedMs: 1, cached: false },
  256. };
  257. const STATS: WireStats = {
  258. project: { root: '/tmp/demo', name: 'demo' },
  259. index: {
  260. state: 'ready',
  261. lastIndexedAt: 1_700_000_000_000,
  262. stale: false,
  263. version: '1.0.0',
  264. extractionVersion: 1,
  265. backend: 'node-sqlite',
  266. journalMode: 'wal',
  267. pendingReferences: 0,
  268. generatedFiles: 0,
  269. watching: false,
  270. watcherDegraded: false,
  271. },
  272. graph: {
  273. nodes: 9,
  274. edges: 9,
  275. files: 3,
  276. nodesByKind: { function: 9 },
  277. edgesByKind: { calls: 9 },
  278. filesByLanguage: { typescript: 3 },
  279. dbSizeBytes: 1024,
  280. walSizeBytes: 0,
  281. },
  282. frameworks: [],
  283. thresholds: { hub: 40, uncertainBelow: 0.6 },
  284. blastScale: { maxDirect: 20, maxWithinHops: 60, hops: 3, sampled: 24, estimated: true },
  285. };
  286. /* ------------------------------------------------------------ mock adapter */
  287. /** Every method the components can reach, and a record of which ones they did. */
  288. function mockAdapter(): { adapter: GraphAdapter; calls: string[] } {
  289. const calls: string[] = [];
  290. const seen = <T>(name: string, value: T): Promise<T> => {
  291. calls.push(name);
  292. return Promise.resolve(value);
  293. };
  294. const adapter: GraphAdapter = {
  295. stats: () => seen('stats', STATS),
  296. search: () =>
  297. seen('search', {
  298. query: '',
  299. text: '',
  300. filters: { kinds: [], languages: [], paths: [], names: [] },
  301. results: { total: 0, shown: 0, truncated: false, items: [] },
  302. groups: [],
  303. }),
  304. node: (id) => {
  305. calls.push(`node:${id}`);
  306. return Promise.resolve(SYMBOL);
  307. },
  308. nodes: () => seen('nodes', { items: [], missing: [] }),
  309. source: (request) => {
  310. calls.push(`source:${request.file}`);
  311. return Promise.resolve(SOURCE);
  312. },
  313. file: () =>
  314. seen('file', {
  315. file: {
  316. path: 'src/auth/token.ts',
  317. language: 'typescript',
  318. size: 900,
  319. modifiedAt: 0,
  320. indexedAt: 0,
  321. contentHash: 'abc123',
  322. nodeCount: 3,
  323. generated: false,
  324. test: false,
  325. errors: [],
  326. id: 'file:src/auth/token.ts',
  327. },
  328. topLevel: { calls: 0 },
  329. drift: false,
  330. outline: { total: 0, shown: 0, truncated: false, items: [] },
  331. imports: { total: 0, shown: 0, truncated: false, items: [] },
  332. importedBy: { total: 0, shown: 0, truncated: false, items: [] },
  333. unresolvedImports: [],
  334. dependencies: [],
  335. dependents: [],
  336. }),
  337. fileCode: () =>
  338. seen('fileCode', {
  339. file: {
  340. path: 'src/auth/token.ts',
  341. language: 'typescript',
  342. size: 900,
  343. indexedAt: 0,
  344. contentHash: 'abc123',
  345. generated: false,
  346. test: false,
  347. errors: [],
  348. id: 'file:src/auth/token.ts',
  349. totalLines: 40,
  350. },
  351. drift: false,
  352. outline: { total: 0, shown: 0, truncated: false, items: [] },
  353. calls: { total: 0, shown: 0, truncated: false, items: [] },
  354. outside: { total: 0, shown: 0, truncated: false, items: [] },
  355. intraFileCalls: 0,
  356. timing: { elapsedMs: 1 },
  357. }),
  358. flow: () => seen('flow', FLOW),
  359. map: () => seen('map', MAP),
  360. routes: () =>
  361. seen('routes', {
  362. routed: false,
  363. routeCount: 0,
  364. shown: 0,
  365. truncated: false,
  366. topHandlerFile: null,
  367. topHandlerFileCount: 0,
  368. entries: [],
  369. }),
  370. entryPoints: () =>
  371. seen('entryPoints', {
  372. frameworks: [],
  373. routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
  374. files: { total: 0, shown: 0, truncated: false, items: [] },
  375. tests: { total: 0, shown: 0, truncated: false, items: [] },
  376. hubs: { total: 0, shown: 0, truncated: false, items: [] },
  377. index: { lastIndexedAt: null, files: 3 },
  378. timing: { elapsedMs: 1, cached: false },
  379. }),
  380. deadCode: () =>
  381. seen('deadCode', {
  382. rows: { total: 0, shown: 0, truncated: false, items: [] },
  383. groups: [],
  384. candidates: 0,
  385. excluded: [],
  386. excludedTotal: 0,
  387. kinds: ['function'],
  388. includeExported: false,
  389. includeTests: false,
  390. includeGenerated: false,
  391. bounded: false,
  392. corroborated: true,
  393. timing: { elapsedMs: 1 },
  394. }),
  395. trails: () =>
  396. seen('trails', {
  397. trails: [],
  398. // A host with nowhere to keep trails still ANSWERS the question — it
  399. // says it is read-only rather than omitting the method, so the screens
  400. // show the section explained instead of showing a Save that does
  401. // nothing.
  402. readOnly: true,
  403. readOnlyReason: 'This host does not store trails.',
  404. directory: '.codegraph/ui/trails',
  405. skipped: 0,
  406. bounded: false,
  407. }),
  408. // Deliberately no `events`, `saveTrail` or `deleteTrail`: a host without a
  409. // live channel and without anywhere to write is the normal case, and
  410. // nothing may poll or offer to save in their absence.
  411. };
  412. return { adapter, calls };
  413. }
  414. /* ----------------------------------------------------------------- harness */
  415. let host: HTMLDivElement;
  416. let mounted: Record<string, unknown> | null = null;
  417. /** jsdom has none of the observers a canvas library expects. */
  418. beforeAll(() => {
  419. class NoopObserver {
  420. observe(): void {}
  421. unobserve(): void {}
  422. disconnect(): void {}
  423. }
  424. const globals = globalThis as Record<string, unknown>;
  425. globals.ResizeObserver ??= NoopObserver;
  426. globals.IntersectionObserver ??= NoopObserver;
  427. globals.MutationObserver ??= NoopObserver;
  428. globals.requestAnimationFrame ??= (fn: FrameRequestCallback) =>
  429. setTimeout(() => fn(0), 0) as unknown as number;
  430. globals.cancelAnimationFrame ??= (handle: number) => clearTimeout(handle);
  431. // jsdom's own `matchMedia` is a stub that is not callable here, and Svelte's
  432. // `MediaQuery` (which `@xyflow/svelte`'s store constructs eagerly) calls it
  433. // the moment a canvas mounts. Replace it outright rather than guarding.
  434. const media = (query: string) => ({
  435. media: query,
  436. matches: false,
  437. onchange: null,
  438. addEventListener() {},
  439. removeEventListener() {},
  440. addListener() {},
  441. removeListener() {},
  442. dispatchEvent: () => false,
  443. });
  444. Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: media });
  445. globals.matchMedia = media;
  446. if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {};
  447. });
  448. beforeEach(() => {
  449. host = document.createElement('div');
  450. document.body.appendChild(host);
  451. trail.clear();
  452. });
  453. afterEach(() => {
  454. if (mounted) {
  455. void unmount(mounted);
  456. mounted = null;
  457. }
  458. host.remove();
  459. setGraphAdapter(null);
  460. setNavigationDriver(null);
  461. });
  462. /**
  463. * Mount a component and let its data effects settle.
  464. *
  465. * Every screen fetches inside an `$effect`, so a render is not finished until
  466. * the promise the adapter returned has resolved and the follow-up render has
  467. * flushed. Two macrotask turns cover the deepest chain any of them has (the
  468. * Symbol view: node, then its source).
  469. */
  470. async function render(
  471. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  472. component: any,
  473. props: Record<string, unknown>
  474. ): Promise<void> {
  475. mounted = mount(component, { target: host, props }) as Record<string, unknown>;
  476. for (let turn = 0; turn < 4; turn += 1) {
  477. await new Promise((resolve) => setTimeout(resolve, 0));
  478. flushSync();
  479. }
  480. }
  481. describe('@colbymchenry/codegraph-ui — a host renders the package', () => {
  482. it('SymbolView draws callers, source and the callee rail from a mock adapter', async () => {
  483. const { adapter, calls } = mockAdapter();
  484. setGraphAdapter(adapter);
  485. await render(SymbolView, { id: SYMBOL.node.id, line: null });
  486. // It asked the adapter, by id, and it asked for the symbol's own slice.
  487. expect(calls).toContain(`node:${SYMBOL.node.id}`);
  488. expect(calls).toContain('source:src/auth/token.ts');
  489. const text = host.textContent ?? '';
  490. expect(text).toContain('parseToken');
  491. // The caller rail (left) and the callee rail (right) are both drawn.
  492. expect(text).toContain('handleCallback');
  493. expect(text).toContain('decodeJwt');
  494. // The verbatim source, not a summary of it.
  495. expect(text).toContain('expiresAt');
  496. // The honesty badge: nothing in the fixture's graph tests this symbol.
  497. expect(text.toLowerCase()).toContain('test');
  498. });
  499. it('TypeHierarchy draws the fan, its wiring and its fold from a payload alone', async () => {
  500. const implementers = Array.from({ length: 14 }, (_, i) => ({
  501. id: `impl-${i}`,
  502. kind: 'class' as const,
  503. name: `Target${i}`,
  504. qualifiedName: `Target${i}`,
  505. file: `src/targets/target-${i}.ts`,
  506. line: 1,
  507. endLine: 9,
  508. language: 'typescript' as const,
  509. test: false,
  510. depth: 1,
  511. parentId: SYMBOL.node.id,
  512. relation: 'implements' as const,
  513. // The first one arrived through a resolver rather than a parse, which is
  514. // the case the block has to draw differently.
  515. synthesized: i === 0,
  516. ...(i === 0 ? { via: 'go-implements', registeredAt: 'src/clock.go:11' } : {}),
  517. hiddenSubtypes: 0,
  518. }));
  519. const hierarchy: WireHierarchy = {
  520. ancestors: { total: 0, shown: 0, truncated: false, items: [] },
  521. descendants: {
  522. total: implementers.length,
  523. shown: implementers.length,
  524. truncated: false,
  525. items: implementers,
  526. },
  527. direct: implementers.length,
  528. implementers: implementers.length,
  529. bounded: false,
  530. polymorphic: true,
  531. };
  532. await render(TypeHierarchy, { hierarchy, focus: SYMBOL.node, onopen: () => {} });
  533. const text = host.textContent ?? '';
  534. // The claim a reader cannot get by counting rows.
  535. expect(text).toContain('14 implementations');
  536. // The wiring site of the synthesized edge.
  537. expect(text).toContain('go-implements');
  538. // Twelve rows, then the fold — never a silent truncation.
  539. expect(text).toContain('+2 more implementations');
  540. expect(text).toContain('Target0');
  541. expect(text).not.toContain('Target13');
  542. // It draws no network of its own: this component was handed a payload.
  543. expect(host.querySelectorAll('path').length).toBe(12);
  544. });
  545. it('FlowStrip draws one card per hop from a mock adapter', async () => {
  546. const { adapter, calls } = mockAdapter();
  547. setGraphAdapter(adapter);
  548. await render(FlowStrip, {
  549. from: 'handleCallback',
  550. to: 'decodeJwt',
  551. symbols: null,
  552. trailParam: null,
  553. });
  554. expect(calls).toContain('flow');
  555. const text = host.textContent ?? '';
  556. expect(text).toContain('handleCallback');
  557. expect(text).toContain('parseToken');
  558. });
  559. it('ArchitectureMap draws modules and their dependency from a mock adapter', async () => {
  560. const { adapter, calls } = mockAdapter();
  561. setGraphAdapter(adapter);
  562. await render(ArchitectureMap, { root: 'src', depth: 1, tests: false });
  563. expect(calls).toContain('map');
  564. const text = host.textContent ?? '';
  565. expect(text).toContain('auth');
  566. expect(text).toContain('http');
  567. });
  568. it('TrailBar and SearchPalette mount and read through the same adapter', async () => {
  569. const { adapter } = mockAdapter();
  570. setGraphAdapter(adapter);
  571. trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
  572. await render(TrailBar, {});
  573. expect(host.textContent ?? '').toContain('parseToken');
  574. void unmount(mounted as Record<string, unknown>);
  575. mounted = null;
  576. host.innerHTML = '';
  577. await render(SearchPalette, {});
  578. expect(host.querySelector('input[role="combobox"]')).not.toBeNull();
  579. });
  580. it('offers no Save when the adapter cannot write, and says why in the list', async () => {
  581. const { adapter } = mockAdapter();
  582. setGraphAdapter(adapter);
  583. trail.push({ id: SYMBOL.node.id, name: 'parseToken', kind: 'function', dir: 'start' });
  584. await render(TrailBar, {});
  585. // The one screen affordance that must never appear against a read-only
  586. // host: an adapter with no `saveTrail` has no button, not a button that
  587. // fails.
  588. expect(host.textContent ?? '').not.toContain('Save trail');
  589. void unmount(mounted as Record<string, unknown>);
  590. mounted = null;
  591. host.innerHTML = '';
  592. await render(SavedTrails, { hideWhenEmpty: false });
  593. const text = host.textContent ?? '';
  594. expect(text).toContain('Saved trails');
  595. expect(text).toContain('This host does not store trails.');
  596. });
  597. it('CodegraphUi installs the adapter before its children ask for data', async () => {
  598. const { adapter, calls } = mockAdapter();
  599. // NOT installed by hand — the provider is the only thing that installs it.
  600. expect(getGraphAdapter()).not.toBe(adapter);
  601. mounted = mount(CodegraphUi, { target: host, props: { adapter } }) as Record<string, unknown>;
  602. flushSync();
  603. expect(getGraphAdapter()).toBe(adapter);
  604. expect(calls).toEqual([]);
  605. });
  606. });
  607. describe('@colbymchenry/codegraph-ui — the seams', () => {
  608. it('a host navigation driver replaces every href the components build', () => {
  609. const seen: string[] = [];
  610. const driver: NavigationDriver = {
  611. symbolHref: (id) => `/review/42/symbol/${encodeURIComponent(id)}`,
  612. fileHref: (path) => `/review/42/file/${path}`,
  613. mapHref: () => '/review/42/map',
  614. flowHref: () => '/review/42/flow',
  615. entryHref: () => '/review/42',
  616. navigate: (href) => seen.push(href),
  617. back: () => seen.push('back'),
  618. };
  619. setNavigationDriver(driver);
  620. expect(symbolHref('function:x')).toBe('/review/42/symbol/function%3Ax');
  621. expect(fileHref('src/a.ts')).toBe('/review/42/file/src/a.ts');
  622. expect(mapHref()).toBe('/review/42/map');
  623. expect(flowHref()).toBe('/review/42/flow');
  624. setNavigationDriver(null);
  625. // Back to the viewer's own address space, unchanged.
  626. expect(symbolHref('function:x')).toBe(hashNavigation.symbolHref('function:x'));
  627. expect(symbolHref('function:x')).toBe('#/s/function%3Ax');
  628. });
  629. it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
  630. const asked: string[] = [];
  631. const adapter = createHttpAdapter({
  632. fetch: async (input) => {
  633. asked.push(String(input));
  634. return new Response(JSON.stringify(STATS), {
  635. status: 200,
  636. headers: { 'content-type': 'application/json' },
  637. });
  638. },
  639. });
  640. await adapter.stats();
  641. await adapter.node('function:parse@a.ts:1');
  642. await adapter.source({ file: 'src/a.ts', from: 1, to: 4 });
  643. await adapter.nodes(['a', 'b']);
  644. expect(asked[0]).toBe('api/stats');
  645. // Ids are encoded per slash-separated segment, so ':' survives and '/' is
  646. // still a path separator.
  647. expect(asked[1]).toBe('api/node/function%3Aparse%40a.ts%3A1');
  648. expect(asked[2]).toBe('api/source?file=src%2Fa.ts&from=1&to=4');
  649. // Repeated `id` params, never a comma-joined list.
  650. expect(asked[3]).toBe('api/nodes?id=a&id=b');
  651. });
  652. it('an adapter with no live channel never connects and never polls', () => {
  653. const { adapter } = mockAdapter();
  654. setGraphAdapter(adapter);
  655. expect(adapter.events).toBeUndefined();
  656. // `live.start()` is a no-op in a jsdom test that never called it; what is
  657. // asserted here is the counters a host can still drive by hand.
  658. const before = live.indexTick;
  659. live.signal('index', { index: { lastIndexedAt: 1, files: 3 } });
  660. expect(live.indexTick).toBe(before + 1);
  661. });
  662. });
  663. describe('@colbymchenry/codegraph-ui — the published shape', () => {
  664. const manifest = JSON.parse(
  665. readFileSync(join(ROOT, 'ui', 'package.json'), 'utf8')
  666. ) as Record<string, any>;
  667. it('is versioned with the engine', () => {
  668. const engine = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as {
  669. version: string;
  670. };
  671. expect(manifest.version).toBe(engine.version);
  672. });
  673. it('is named, scoped and not publishable by accident', () => {
  674. expect(manifest.name).toBe('@colbymchenry/codegraph-ui');
  675. // The package is PREPARED, not published (CG-61). `private` is the guard:
  676. // npm refuses to publish it until the maintainer deliberately removes this.
  677. expect(manifest.private).toBe(true);
  678. });
  679. it('exports the entry, the theme and nothing else', () => {
  680. expect(Object.keys(manifest.exports).sort()).toEqual(['.', './package.json', './theme.css']);
  681. expect(manifest.exports['.'].svelte).toBe('./dist/index.js');
  682. expect(manifest.exports['.'].types).toBe('./dist/index.d.ts');
  683. });
  684. it('takes svelte as a peer, so a host never gets a second copy', () => {
  685. expect(manifest.peerDependencies.svelte).toBeDefined();
  686. expect(manifest.dependencies?.svelte).toBeUndefined();
  687. // The canvas library is a real dependency: the Map and the Flow strip are
  688. // unusable without it and a host must not have to know its version.
  689. expect(manifest.dependencies['@xyflow/svelte']).toBeDefined();
  690. });
  691. });