ui-flow-api.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. /**
  2. * `GET /api/flow` — the call path behind the Flow strip (CG-50).
  3. *
  4. * Against a real indexed fixture over a real loopback server, like the rest of
  5. * the viewer's API suite. The fixture is shaped to produce the four things this
  6. * endpoint has to get right and that a synthetic payload cannot prove:
  7. *
  8. * - a real five-hop chain of calls, so the hops, their edges, and the line each
  9. * card is opened at all come out of the graph rather than out of a fixture
  10. * object,
  11. * - two definitions of the same name, one of them in a test file, so the
  12. * directed search's overload handling and the `ambiguous` report can be
  13. * checked (this is the shape that broke `main` on the engine's own index —
  14. * the right definition sorted seventh),
  15. * - a symbol nothing reaches, so "no path" is exercised as the ordinary answer
  16. * it is rather than as an error,
  17. * - a Go interface with one implementation, so a SYNTHESIZED hop — the thing
  18. * the strip draws dashed and labels with its wiring site — is a real edge
  19. * from the resolver rather than a hand-written metadata blob.
  20. *
  21. * The pure geometry is tested without a server in `ui-flow-model.test.ts`.
  22. */
  23. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  24. import * as http from 'http';
  25. import * as fs from 'fs';
  26. import * as os from 'os';
  27. import * as path from 'path';
  28. import CodeGraph from '../src/index';
  29. import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
  30. import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
  31. import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
  32. import type { Edge } from '../src/types';
  33. let server: UiServerHandle;
  34. let api: GraphApi;
  35. let tempDir: string;
  36. let projectRoot: string;
  37. function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
  38. return new Promise((resolve, reject) => {
  39. const req = http.request(
  40. {
  41. host: '127.0.0.1',
  42. port: server.port,
  43. path: requestPath,
  44. method: 'GET',
  45. headers: { Host: `127.0.0.1:${server.port}` },
  46. setHost: false,
  47. },
  48. (res) => {
  49. const chunks: Buffer[] = [];
  50. res.on('data', (c: Buffer) => chunks.push(c));
  51. res.on('end', () =>
  52. resolve({
  53. status: res.statusCode ?? 0,
  54. body: Buffer.concat(chunks).toString('utf-8'),
  55. type: res.headers['content-type'],
  56. })
  57. );
  58. }
  59. );
  60. req.on('error', reject);
  61. req.end();
  62. });
  63. }
  64. async function getFlow(query: string, expected = 200): Promise<any> {
  65. const res = await request(`/api/flow${query}`);
  66. expect(res.type).toBe('application/json; charset=utf-8');
  67. expect(res.status).toBe(expected);
  68. return JSON.parse(res.body);
  69. }
  70. function write(root: string, rel: string, body: string): void {
  71. const full = path.join(root, rel);
  72. fs.mkdirSync(path.dirname(full), { recursive: true });
  73. fs.writeFileSync(full, body);
  74. }
  75. /** `name` at each hop, so an assertion reads like the strip does. */
  76. function names(flow: any): string[] {
  77. return flow.hops.map((h: any) => h.node.name);
  78. }
  79. beforeAll(async () => {
  80. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-flow-'));
  81. projectRoot = path.join(tempDir, 'project');
  82. // A five-hop chain: bootstrap -> handleRequest -> loadRow -> readRow -> toRow.
  83. write(
  84. projectRoot,
  85. 'src/main.ts',
  86. `import { handleRequest } from './server/handler';
  87. export function bootstrap(): string {
  88. const banner = 'ready';
  89. return handleRequest(banner);
  90. }
  91. `
  92. );
  93. write(
  94. projectRoot,
  95. 'src/server/handler.ts',
  96. `import { loadRow } from '../db/rows';
  97. export function handleRequest(id: string): string {
  98. const trimmed = id.trim();
  99. return loadRow(trimmed);
  100. }
  101. /** Nothing on the chain calls this — it is the "no path" endpoint. */
  102. export function orphanHandler(): string {
  103. return 'nobody calls me';
  104. }
  105. `
  106. );
  107. write(
  108. projectRoot,
  109. 'src/db/rows.ts',
  110. `export function loadRow(id: string): string {
  111. return readRow(id);
  112. }
  113. function readRow(id: string): string {
  114. return toRow(id);
  115. }
  116. function toRow(id: string): string {
  117. return id.toUpperCase();
  118. }
  119. `
  120. );
  121. // Two `describe` definitions, one of them in a test file: the ambiguity the
  122. // directed search has to walk past rather than truncate away.
  123. write(
  124. projectRoot,
  125. 'src/db/describe.ts',
  126. `import { loadRow } from './rows';
  127. export function describeRow(id: string): string {
  128. return loadRow(id);
  129. }
  130. `
  131. );
  132. write(
  133. projectRoot,
  134. '__tests__/rows.test.ts',
  135. `export function describeRow(id: string): string {
  136. return id;
  137. }
  138. `
  139. );
  140. // A Go interface with one implementation: the resolver synthesizes an
  141. // interface-impl `calls` edge across it, which is what the strip draws dashed.
  142. write(
  143. projectRoot,
  144. 'go/clock.go',
  145. `package clock
  146. type Clock interface {
  147. Now() string
  148. }
  149. type SystemClock struct{}
  150. func (SystemClock) Now() string {
  151. return stamp()
  152. }
  153. func stamp() string {
  154. return "now"
  155. }
  156. func Tick(c Clock) string {
  157. return c.Now()
  158. }
  159. `
  160. );
  161. const cg = CodeGraph.initSync(projectRoot, {
  162. config: { include: ['src/**/*.ts', '__tests__/**/*.ts', 'go/**/*.go'], exclude: [] },
  163. });
  164. await cg.indexAll();
  165. cg.resolveReferences();
  166. cg.close();
  167. const viewerDir = path.join(tempDir, 'viewer');
  168. fs.mkdirSync(viewerDir, { recursive: true });
  169. fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
  170. api = createGraphApi({ projectRoot });
  171. server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
  172. }, 120_000);
  173. afterAll(async () => {
  174. api?.close();
  175. await server?.close();
  176. if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  177. });
  178. describe('parseFlowQuery', () => {
  179. it('reads the three shapes and refuses the empty one', () => {
  180. expect(parseFlowQuery(new URLSearchParams('from=a&to=b'))).toEqual({
  181. kind: 'directed',
  182. from: 'a',
  183. to: 'b',
  184. });
  185. expect(parseFlowQuery(new URLSearchParams('symbols=a,b,c'))).toEqual({
  186. kind: 'symbols',
  187. text: 'a,b,c',
  188. });
  189. expect(parseFlowQuery(new URLSearchParams('hop=sx&hop=dy&hop=uz'))).toEqual({
  190. kind: 'trail',
  191. hops: [
  192. { id: 'x', dir: 'start' },
  193. { id: 'y', dir: 'down' },
  194. { id: 'z', dir: 'up' },
  195. ],
  196. });
  197. expect(() => parseFlowQuery(new URLSearchParams(''))).toThrow(/No flow was asked for/);
  198. });
  199. it('refuses a pair that names the same symbol twice', () => {
  200. expect(() => parseFlowQuery(new URLSearchParams('from=run&to=run'))).toThrow(/same symbol/);
  201. });
  202. it('takes a trail over a from/to pair, and refuses a one-hop trail', () => {
  203. // A hop parameter is only ever sent by "Read as flow", which is a complete
  204. // question on its own; a stray `from` alongside it must not be searched.
  205. const parsed = parseFlowQuery(new URLSearchParams('from=a&to=b&hop=sx&hop=dy'));
  206. expect(parsed.kind).toBe('trail');
  207. expect(() => parseFlowQuery(new URLSearchParams('hop=sx'))).toThrow(/at least two hops/);
  208. });
  209. });
  210. describe('flowEdgeLabel', () => {
  211. const edge = (metadata: Record<string, unknown>, provenance = 'heuristic'): Edge =>
  212. ({ kind: 'calls', source: 'a', target: 'b', provenance, metadata }) as unknown as Edge;
  213. it('names the mechanism and the wiring site for a synthesized hop', () => {
  214. expect(
  215. flowEdgeLabel(edge({ synthesizedBy: 'callback', registeredAt: 'src/a.ts:12' }), false)
  216. ).toBe('via callback · registered at src/a.ts:12');
  217. });
  218. it('never lets a synthesized hop read as a plain call', () => {
  219. expect(flowEdgeLabel(edge({ synthesizedBy: 'react-render' }), false)).toBe('via react render');
  220. });
  221. it('says "called by" when the reader walked the edge backwards', () => {
  222. expect(flowEdgeLabel(edge({}, 'resolved'), true)).toBe('called by');
  223. expect(flowEdgeLabel(edge({}, 'resolved'), false)).toBe('calls');
  224. });
  225. });
  226. describe('GET /api/flow — a directed question', () => {
  227. it('returns the whole chain, one hop per card', async () => {
  228. const payload = await getFlow('?from=bootstrap&to=toRow');
  229. expect(payload.query).toMatchObject({ kind: 'directed', from: 'bootstrap', to: 'toRow' });
  230. expect(payload.reason).toBeNull();
  231. expect(payload.flows).toHaveLength(1);
  232. expect(names(payload.flows[0])).toEqual([
  233. 'bootstrap',
  234. 'handleRequest',
  235. 'loadRow',
  236. 'readRow',
  237. 'toRow',
  238. ]);
  239. expect(payload.flows[0].label).toBe('bootstrap → toRow');
  240. });
  241. it('opens each card at the line that calls the next one', async () => {
  242. const { flows } = await getFlow('?from=bootstrap&to=toRow');
  243. const hops = flows[0].hops;
  244. for (let i = 0; i < hops.length - 1; i++) {
  245. const ref = hops[i].callRef;
  246. expect(ref, `hop ${i} has a call site`).not.toBeNull();
  247. expect(ref.name).toBe(hops[i + 1].node.name);
  248. expect(ref.targetId).toBe(hops[i + 1].node.id);
  249. expect(ref.backwards).toBe(false);
  250. // The window is centred on it, and the source really contains it.
  251. expect(ref.line).toBeGreaterThanOrEqual(hops[i].source.from);
  252. expect(ref.line).toBeLessThanOrEqual(hops[i].source.to);
  253. const offset = ref.line - hops[i].source.from;
  254. expect(hops[i].source.lines[offset]).toContain(hops[i + 1].node.name);
  255. }
  256. // The last card has nothing to call, so it opens at its own definition.
  257. const last = hops[hops.length - 1];
  258. expect(last.callRef).toBeNull();
  259. expect(last.source.from).toBeLessThanOrEqual(last.node.line);
  260. expect(last.source.to).toBeGreaterThanOrEqual(last.node.line);
  261. });
  262. it('carries the edge on every hop but the first, with its line', async () => {
  263. const { flows } = await getFlow('?from=bootstrap&to=toRow');
  264. const hops = flows[0].hops;
  265. expect(hops[0].edge).toBeNull();
  266. for (let i = 1; i < hops.length; i++) {
  267. expect(hops[i].edge.kind).toBe('calls');
  268. expect(hops[i].edge.label).toBe('calls');
  269. expect(hops[i].edge.upward).toBe(false);
  270. expect(hops[i].edge.synthesized).toBe(false);
  271. // The edge's line is the previous card's call site — the two agree, and
  272. // the strip prints both, so a disagreement would be visible.
  273. expect(hops[i].edge.line).toBe(hops[i - 1].callRef.line);
  274. }
  275. });
  276. it('highlights each card with real source, never a drifted slice', async () => {
  277. const { flows } = await getFlow('?from=bootstrap&to=toRow');
  278. for (const hop of flows[0].hops) {
  279. expect(hop.source.drift).toBe(false);
  280. expect(hop.source.lines.length).toBeGreaterThan(0);
  281. expect(hop.source.lines.length).toBe(hop.source.to - hop.source.from + 1);
  282. // Highlight rides with the slice and is line-for-line with it (CG-43).
  283. expect(hop.source.highlight.lines).toHaveLength(hop.source.lines.length);
  284. }
  285. });
  286. it('answers "not connected" as an ordinary answer, with a reason', async () => {
  287. const payload = await getFlow('?from=bootstrap&to=orphanHandler');
  288. expect(payload.flows).toEqual([]);
  289. expect(payload.reason).toMatch(/No chain of calls reaches orphanHandler/);
  290. expect(payload.reason).toMatch(/dynamic dispatch/);
  291. expect(payload.unresolved).toEqual([]);
  292. });
  293. it('says which names matched nothing rather than blaming the path', async () => {
  294. const payload = await getFlow('?from=bootstrap&to=thisNameIsNotHere');
  295. expect(payload.unresolved).toEqual(['thisNameIsNotHere']);
  296. expect(payload.reason).toMatch(/thisNameIsNotHere names nothing/);
  297. });
  298. it('walks past an overload in a test file and reports the ambiguity', async () => {
  299. const payload = await getFlow('?from=describeRow&to=toRow');
  300. expect(names(payload.flows[0])).toEqual(['describeRow', 'loadRow', 'readRow', 'toRow']);
  301. const ambiguity = payload.ambiguous.find((a: any) => a.token === 'describeRow');
  302. expect(ambiguity).toBeDefined();
  303. expect(ambiguity.chosen.file).toBe('src/db/describe.ts');
  304. expect(ambiguity.others.map((o: any) => o.file)).toContain('__tests__/rows.test.ts');
  305. });
  306. });
  307. describe('GET /api/flow — a synthesized hop', () => {
  308. it('draws the interface bridge as a dashed hop that names its mechanism', async () => {
  309. const payload = await getFlow('?from=Tick&to=stamp');
  310. expect(payload.flows.length).toBeGreaterThan(0);
  311. const hops = payload.flows[0].hops;
  312. expect(names(payload.flows[0])[0]).toBe('Tick');
  313. expect(names(payload.flows[0]).at(-1)).toBe('stamp');
  314. const synthesized = hops.filter((h: any) => h.edge?.synthesized);
  315. expect(synthesized.length).toBeGreaterThan(0);
  316. for (const hop of synthesized) {
  317. expect(hop.edge.provenance).toBe('heuristic');
  318. expect(hop.edge.label).toMatch(/^via /);
  319. expect(hop.edge.label).not.toBe('calls');
  320. }
  321. });
  322. });
  323. describe('GET /api/flow — explore parity', () => {
  324. it('answers a ?symbols= question with the chain the explore search finds', async () => {
  325. const payload = await getFlow('?symbols=bootstrap,loadRow,toRow');
  326. expect(payload.query.kind).toBe('symbols');
  327. expect(payload.flows.length).toBeGreaterThan(0);
  328. // The endpoint must not have its own path finder. Run the engine's directly
  329. // and require the same hops, in the same order.
  330. const cg = CodeGraph.openSync(projectRoot);
  331. try {
  332. const flow = resolveNamedSymbolFlow(cg, 'bootstrap,loadRow,toRow');
  333. expect(flow.chains[0]?.steps.map((s) => s.node.id)).toEqual(
  334. payload.flows[0].hops.map((h: any) => h.node.id)
  335. );
  336. } finally {
  337. cg.close();
  338. }
  339. });
  340. });
  341. describe('GET /api/flow — a trail read as a flow', () => {
  342. it('draws the hops it was given, finding the edge that already joins them', async () => {
  343. const forward = await getFlow('?from=bootstrap&to=toRow');
  344. const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id);
  345. const query = ids
  346. .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'd'}${id}`)}`)
  347. .join('&');
  348. const payload = await getFlow(`?${query}`);
  349. expect(payload.query.kind).toBe('trail');
  350. expect(payload.flows[0].hops.map((h: any) => h.node.id)).toEqual(ids);
  351. expect(payload.flows[0].hops[1].edge.kind).toBe('calls');
  352. expect(payload.flows[0].hops[1].edge.upward).toBe(false);
  353. });
  354. it('reads a trail walked BACKWARDS as caller hops, opened at the calling line', async () => {
  355. const forward = await getFlow('?from=bootstrap&to=toRow');
  356. const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id).reverse();
  357. const query = ids
  358. .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'u'}${id}`)}`)
  359. .join('&');
  360. const payload = await getFlow(`?${query}`);
  361. const hops = payload.flows[0].hops;
  362. expect(hops.map((h: any) => h.node.id)).toEqual(ids);
  363. // Every hop after the first is the caller of the one before it, so its own
  364. // body holds the call — and the card opens there, pointing BACK.
  365. for (let i = 1; i < hops.length; i++) {
  366. expect(hops[i].edge.upward).toBe(true);
  367. expect(hops[i].edge.label).toBe('called by');
  368. expect(hops[i].callRef.backwards).toBe(true);
  369. expect(hops[i].callRef.name).toBe(hops[i - 1].node.name);
  370. expect(hops[i].callRef.line).toBe(hops[i].edge.line);
  371. }
  372. // The first card is the callee: nothing in it calls anything on this trail.
  373. expect(hops[0].callRef).toBeNull();
  374. });
  375. it('says so when the ids on a trail are no longer in the index', async () => {
  376. const payload = await getFlow('?hop=smethod%3Agone&hop=dmethod%3Aalso-gone');
  377. expect(payload.flows).toEqual([]);
  378. expect(payload.unresolved).toEqual(['method:gone', 'method:also-gone']);
  379. expect(payload.reason).toMatch(/still in the index/);
  380. });
  381. });
  382. describe('GET /api/flow — refusals', () => {
  383. it('answers JSON, not text, when the question is malformed', async () => {
  384. const payload = await getFlow('', 400);
  385. expect(payload.code).toBe('bad-request');
  386. expect(payload.error).toMatch(/No flow was asked for/);
  387. expect(payload.hint).toMatch(/\?from=/);
  388. });
  389. it('caps the number of trail hops it will read', async () => {
  390. const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
  391. const payload = await getFlow(`?${query}`, 400);
  392. expect(payload.code).toBe('bad-request');
  393. expect(payload.error).toMatch(/longer than this endpoint reads/);
  394. });
  395. it('is listed on the API index', async () => {
  396. const res = await request('/api');
  397. const body = JSON.parse(res.body);
  398. const entry = body.endpoints.find((e: any) => e.path === '/api/flow');
  399. expect(entry).toBeDefined();
  400. expect(entry.params).toContain('from');
  401. expect(entry.params).toContain('hop');
  402. });
  403. });