ui-flow-api.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  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 { ToolHandler } from '../src/mcp/tools';
  33. import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
  34. import type { Edge } from '../src/types';
  35. let server: UiServerHandle;
  36. let api: GraphApi;
  37. let tempDir: string;
  38. let projectRoot: string;
  39. function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
  40. return new Promise((resolve, reject) => {
  41. const req = http.request(
  42. {
  43. host: '127.0.0.1',
  44. port: server.port,
  45. path: requestPath,
  46. method: 'GET',
  47. headers: { Host: `127.0.0.1:${server.port}` },
  48. setHost: false,
  49. },
  50. (res) => {
  51. const chunks: Buffer[] = [];
  52. res.on('data', (c: Buffer) => chunks.push(c));
  53. res.on('end', () =>
  54. resolve({
  55. status: res.statusCode ?? 0,
  56. body: Buffer.concat(chunks).toString('utf-8'),
  57. type: res.headers['content-type'],
  58. })
  59. );
  60. }
  61. );
  62. req.on('error', reject);
  63. req.end();
  64. });
  65. }
  66. async function getFlow(query: string, expected = 200): Promise<any> {
  67. const res = await request(`/api/flow${query}`);
  68. expect(res.type).toBe('application/json; charset=utf-8');
  69. expect(res.status).toBe(expected);
  70. return JSON.parse(res.body);
  71. }
  72. function write(root: string, rel: string, body: string): void {
  73. const full = path.join(root, rel);
  74. fs.mkdirSync(path.dirname(full), { recursive: true });
  75. fs.writeFileSync(full, body);
  76. }
  77. /** `name` at each hop, so an assertion reads like the strip does. */
  78. function names(flow: any): string[] {
  79. return flow.hops.map((h: any) => h.node.name);
  80. }
  81. beforeAll(async () => {
  82. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-flow-'));
  83. projectRoot = path.join(tempDir, 'project');
  84. // A five-hop chain: bootstrap -> handleRequest -> loadRow -> readRow -> toRow.
  85. write(
  86. projectRoot,
  87. 'src/main.ts',
  88. `import { handleRequest } from './server/handler';
  89. export function bootstrap(): string {
  90. const banner = 'ready';
  91. return handleRequest(banner);
  92. }
  93. `
  94. );
  95. write(
  96. projectRoot,
  97. 'src/server/handler.ts',
  98. `import { loadRow } from '../db/rows';
  99. export function handleRequest(id: string): string {
  100. const trimmed = id.trim();
  101. return loadRow(trimmed);
  102. }
  103. /** Nothing on the chain calls this — it is the "no path" endpoint. */
  104. export function orphanHandler(): string {
  105. return 'nobody calls me';
  106. }
  107. `
  108. );
  109. write(
  110. projectRoot,
  111. 'src/db/rows.ts',
  112. `export function loadRow(id: string): string {
  113. return readRow(id);
  114. }
  115. function readRow(id: string): string {
  116. return toRow(id);
  117. }
  118. function toRow(id: string): string {
  119. return id.toUpperCase();
  120. }
  121. `
  122. );
  123. // Two `describe` definitions, one of them in a test file: the ambiguity the
  124. // directed search has to walk past rather than truncate away.
  125. write(
  126. projectRoot,
  127. 'src/db/describe.ts',
  128. `import { loadRow } from './rows';
  129. export function describeRow(id: string): string {
  130. return loadRow(id);
  131. }
  132. `
  133. );
  134. write(
  135. projectRoot,
  136. '__tests__/rows.test.ts',
  137. `export function describeRow(id: string): string {
  138. return id;
  139. }
  140. `
  141. );
  142. // A registry whose call target is a string key (CG-51): one site whose key is
  143. // a literal — so a candidate shortlist is possible — and one whose key is a
  144. // runtime value, where claiming a candidate would be a guess.
  145. write(
  146. projectRoot,
  147. 'src/router/table.ts',
  148. `type Handler = (payload: string) => string;
  149. const routerTable: Record<string, Handler> = {};
  150. export function register(key: string, fn: Handler): void {
  151. routerTable[key] = fn;
  152. }
  153. export function routeSave(payload: string): string {
  154. return routerTable['save'](payload);
  155. }
  156. export function routeAny(name: string, payload: string): string {
  157. return routerTable[name](payload);
  158. }
  159. export function beginWork(name: string, payload: string): string {
  160. return routeAny(name, payload);
  161. }
  162. `
  163. );
  164. write(
  165. projectRoot,
  166. 'src/router/handlers.ts',
  167. `import { register } from './table';
  168. export function onSave(payload: string): string {
  169. return payload;
  170. }
  171. register('save', onSave);
  172. `
  173. );
  174. // A Go interface with one implementation: the resolver synthesizes an
  175. // interface-impl `calls` edge across it, which is what the strip draws dashed.
  176. write(
  177. projectRoot,
  178. 'go/clock.go',
  179. `package clock
  180. type Clock interface {
  181. Now() string
  182. }
  183. type SystemClock struct{}
  184. func (SystemClock) Now() string {
  185. return stamp()
  186. }
  187. func stamp() string {
  188. return "now"
  189. }
  190. func Tick(c Clock) string {
  191. return c.Now()
  192. }
  193. `
  194. );
  195. const cg = CodeGraph.initSync(projectRoot, {
  196. config: { include: ['src/**/*.ts', '__tests__/**/*.ts', 'go/**/*.go'], exclude: [] },
  197. });
  198. await cg.indexAll();
  199. cg.resolveReferences();
  200. cg.close();
  201. const viewerDir = path.join(tempDir, 'viewer');
  202. fs.mkdirSync(viewerDir, { recursive: true });
  203. fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
  204. api = createGraphApi({ projectRoot });
  205. server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
  206. }, 120_000);
  207. afterAll(async () => {
  208. api?.close();
  209. await server?.close();
  210. if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  211. });
  212. describe('parseFlowQuery', () => {
  213. it('reads the three shapes and refuses the empty one', () => {
  214. expect(parseFlowQuery(new URLSearchParams('from=a&to=b'))).toEqual({
  215. kind: 'directed',
  216. from: 'a',
  217. to: 'b',
  218. });
  219. expect(parseFlowQuery(new URLSearchParams('symbols=a,b,c'))).toEqual({
  220. kind: 'symbols',
  221. text: 'a,b,c',
  222. });
  223. expect(parseFlowQuery(new URLSearchParams('hop=sx&hop=dy&hop=uz'))).toEqual({
  224. kind: 'trail',
  225. hops: [
  226. { id: 'x', dir: 'start' },
  227. { id: 'y', dir: 'down' },
  228. { id: 'z', dir: 'up' },
  229. ],
  230. });
  231. expect(() => parseFlowQuery(new URLSearchParams(''))).toThrow(/No flow was asked for/);
  232. });
  233. it('refuses a pair that names the same symbol twice', () => {
  234. expect(() => parseFlowQuery(new URLSearchParams('from=run&to=run'))).toThrow(/same symbol/);
  235. });
  236. it('takes a trail over a from/to pair, and refuses a one-hop trail', () => {
  237. // A hop parameter is only ever sent by "Read as flow", which is a complete
  238. // question on its own; a stray `from` alongside it must not be searched.
  239. const parsed = parseFlowQuery(new URLSearchParams('from=a&to=b&hop=sx&hop=dy'));
  240. expect(parsed.kind).toBe('trail');
  241. expect(() => parseFlowQuery(new URLSearchParams('hop=sx'))).toThrow(/at least two hops/);
  242. });
  243. });
  244. describe('flowEdgeLabel', () => {
  245. const edge = (metadata: Record<string, unknown>, provenance = 'heuristic'): Edge =>
  246. ({ kind: 'calls', source: 'a', target: 'b', provenance, metadata }) as unknown as Edge;
  247. it('names the mechanism and the wiring site for a synthesized hop', () => {
  248. expect(
  249. flowEdgeLabel(edge({ synthesizedBy: 'callback', registeredAt: 'src/a.ts:12' }), false)
  250. ).toBe('via callback · registered at src/a.ts:12');
  251. });
  252. it('never lets a synthesized hop read as a plain call', () => {
  253. expect(flowEdgeLabel(edge({ synthesizedBy: 'react-render' }), false)).toBe('via react render');
  254. });
  255. it('says "called by" when the reader walked the edge backwards', () => {
  256. expect(flowEdgeLabel(edge({}, 'resolved'), true)).toBe('called by');
  257. expect(flowEdgeLabel(edge({}, 'resolved'), false)).toBe('calls');
  258. });
  259. });
  260. describe('GET /api/flow — a directed question', () => {
  261. it('returns the whole chain, one hop per card', async () => {
  262. const payload = await getFlow('?from=bootstrap&to=toRow');
  263. expect(payload.query).toMatchObject({ kind: 'directed', from: 'bootstrap', to: 'toRow' });
  264. expect(payload.reason).toBeNull();
  265. expect(payload.flows).toHaveLength(1);
  266. expect(names(payload.flows[0])).toEqual([
  267. 'bootstrap',
  268. 'handleRequest',
  269. 'loadRow',
  270. 'readRow',
  271. 'toRow',
  272. ]);
  273. expect(payload.flows[0].label).toBe('bootstrap → toRow');
  274. });
  275. it('opens each card at the line that calls the next one', async () => {
  276. const { flows } = await getFlow('?from=bootstrap&to=toRow');
  277. const hops = flows[0].hops;
  278. for (let i = 0; i < hops.length - 1; i++) {
  279. const ref = hops[i].callRef;
  280. expect(ref, `hop ${i} has a call site`).not.toBeNull();
  281. expect(ref.name).toBe(hops[i + 1].node.name);
  282. expect(ref.targetId).toBe(hops[i + 1].node.id);
  283. expect(ref.backwards).toBe(false);
  284. // The window is centred on it, and the source really contains it.
  285. expect(ref.line).toBeGreaterThanOrEqual(hops[i].source.from);
  286. expect(ref.line).toBeLessThanOrEqual(hops[i].source.to);
  287. const offset = ref.line - hops[i].source.from;
  288. expect(hops[i].source.lines[offset]).toContain(hops[i + 1].node.name);
  289. }
  290. // The last card has nothing to call, so it opens at its own definition.
  291. const last = hops[hops.length - 1];
  292. expect(last.callRef).toBeNull();
  293. expect(last.source.from).toBeLessThanOrEqual(last.node.line);
  294. expect(last.source.to).toBeGreaterThanOrEqual(last.node.line);
  295. });
  296. it('carries the edge on every hop but the first, with its line', async () => {
  297. const { flows } = await getFlow('?from=bootstrap&to=toRow');
  298. const hops = flows[0].hops;
  299. expect(hops[0].edge).toBeNull();
  300. for (let i = 1; i < hops.length; i++) {
  301. expect(hops[i].edge.kind).toBe('calls');
  302. expect(hops[i].edge.label).toBe('calls');
  303. expect(hops[i].edge.upward).toBe(false);
  304. expect(hops[i].edge.synthesized).toBe(false);
  305. // The edge's line is the previous card's call site — the two agree, and
  306. // the strip prints both, so a disagreement would be visible.
  307. expect(hops[i].edge.line).toBe(hops[i - 1].callRef.line);
  308. }
  309. });
  310. it('highlights each card with real source, never a drifted slice', async () => {
  311. const { flows } = await getFlow('?from=bootstrap&to=toRow');
  312. for (const hop of flows[0].hops) {
  313. expect(hop.source.drift).toBe(false);
  314. expect(hop.source.lines.length).toBeGreaterThan(0);
  315. expect(hop.source.lines.length).toBe(hop.source.to - hop.source.from + 1);
  316. // Highlight rides with the slice and is line-for-line with it (CG-43).
  317. expect(hop.source.highlight.lines).toHaveLength(hop.source.lines.length);
  318. }
  319. });
  320. it('answers "not connected" as an ordinary answer, with a reason', async () => {
  321. const payload = await getFlow('?from=bootstrap&to=orphanHandler');
  322. expect(payload.flows).toEqual([]);
  323. expect(payload.reason).toMatch(/No chain of calls reaches orphanHandler/);
  324. expect(payload.reason).toMatch(/dynamic dispatch/);
  325. expect(payload.unresolved).toEqual([]);
  326. });
  327. it('says which names matched nothing rather than blaming the path', async () => {
  328. const payload = await getFlow('?from=bootstrap&to=thisNameIsNotHere');
  329. expect(payload.unresolved).toEqual(['thisNameIsNotHere']);
  330. expect(payload.reason).toMatch(/thisNameIsNotHere names nothing/);
  331. });
  332. it('walks past an overload in a test file and reports the ambiguity', async () => {
  333. const payload = await getFlow('?from=describeRow&to=toRow');
  334. expect(names(payload.flows[0])).toEqual(['describeRow', 'loadRow', 'readRow', 'toRow']);
  335. const ambiguity = payload.ambiguous.find((a: any) => a.token === 'describeRow');
  336. expect(ambiguity).toBeDefined();
  337. expect(ambiguity.chosen.file).toBe('src/db/describe.ts');
  338. expect(ambiguity.others.map((o: any) => o.file)).toContain('__tests__/rows.test.ts');
  339. });
  340. });
  341. describe('GET /api/flow — where the graph stops', () => {
  342. it('caps a keyed dispatch with its form, its key and a candidate target', async () => {
  343. const payload = await getFlow('?from=routeSave&to=onSave');
  344. // No static edge crosses `routerTable['save']`, so this is not a path — it
  345. // is the one card where the looking stopped, plus the cap.
  346. expect(payload.reason).toMatch(/No chain of calls reaches onSave/);
  347. const flow = payload.flows[0];
  348. expect(flow.partial).toBe(true);
  349. expect(names(flow)).toEqual(['routeSave']);
  350. const boundary = flow.boundary;
  351. expect(boundary.node.name).toBe('routeSave');
  352. const site = boundary.sites[0];
  353. expect(site.form).toBe('computed-call');
  354. expect(site.label).toBe('computed member call');
  355. expect(site.key).toBe('save');
  356. expect(site.line).toBeGreaterThan(boundary.node.line);
  357. expect(site.candidates.map((c: any) => c.display)).toContain('onSave');
  358. // The reader named it, so the cap says so rather than presenting it as new.
  359. expect(site.candidates.find((c: any) => c.display === 'onSave').named).toBe(true);
  360. expect(boundary.missed.map((m: any) => m.name)).toContain('onSave');
  361. });
  362. it('opens the card at the dispatch line, with real source around it', async () => {
  363. const payload = await getFlow('?from=routeSave&to=onSave');
  364. const flow = payload.flows[0];
  365. const site = flow.boundary.sites[0];
  366. const source = flow.hops[0].source;
  367. expect(source.drift).toBe(false);
  368. expect(source.from).toBeLessThanOrEqual(site.line);
  369. expect(source.to).toBeGreaterThanOrEqual(site.line);
  370. expect(source.lines.join('\n')).toContain("routerTable['save']");
  371. });
  372. it('claims no candidates when the key is a runtime value', async () => {
  373. const payload = await getFlow('?from=routeAny&to=onSave');
  374. const site = payload.flows[0].boundary.sites[0];
  375. expect(site.form).toBe('computed-call');
  376. expect(site.key).toBeNull();
  377. expect(site.candidates).toEqual([]);
  378. expect(site.candidateNote).toBeNull();
  379. });
  380. it('caps a chain that connects but never reaches everything it was asked about', async () => {
  381. const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
  382. const flow = payload.flows[0];
  383. expect(flow.partial).toBe(false);
  384. expect(names(flow)).toEqual(['beginWork', 'routeAny']);
  385. // The cap hangs off the dead end, not off the symbol that was named last.
  386. expect(flow.boundary.node.name).toBe('routeAny');
  387. expect(flow.boundary.sites[0].form).toBe('computed-call');
  388. expect(flow.boundary.missed.map((m: any) => m.name)).toEqual(['onSave']);
  389. // The last card opens at the dispatch line the cap beside it describes.
  390. const last = flow.hops[flow.hops.length - 1].source;
  391. const stop = flow.boundary.sites[0].line;
  392. expect(last.from).toBeLessThanOrEqual(stop);
  393. expect(last.to).toBeGreaterThanOrEqual(stop);
  394. });
  395. it('never caps a flow that reaches what it was asked for', async () => {
  396. const payload = await getFlow('?from=bootstrap&to=toRow');
  397. expect(payload.flows[0].boundary).toBeNull();
  398. expect(payload.flows[0].partial).toBe(false);
  399. });
  400. it('stays silent when nothing connects and no dispatch site explains it', async () => {
  401. // `bootstrap` and `orphanHandler` are both ordinary code. Inventing a
  402. // stopping point here would be a claim, not a finding.
  403. const payload = await getFlow('?from=bootstrap&to=orphanHandler');
  404. expect(payload.flows).toEqual([]);
  405. });
  406. it('counts the calls the path did not need and lists them', async () => {
  407. const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
  408. const { further, uncertain } = payload.flows[0].boundary;
  409. // The count and the list are the same fact — the rule every payload keeps.
  410. expect(further.shown).toBe(further.items.length);
  411. expect(further.total).toBeGreaterThanOrEqual(further.shown);
  412. expect(uncertain.shown).toBe(uncertain.items.length);
  413. });
  414. });
  415. describe('the end cap and codegraph_explore agree', () => {
  416. it('names the same site, the same key and the same candidate', async () => {
  417. const payload = await getFlow('?from=routeSave&to=onSave');
  418. const site = payload.flows[0].boundary.sites[0];
  419. const cg = CodeGraph.openSync(projectRoot);
  420. try {
  421. const res = await new ToolHandler(cg).execute('codegraph_explore', {
  422. query: 'routeSave onSave',
  423. });
  424. const text = res.content[0].text as string;
  425. // Both renderings come from `findDynamicBoundaries`; if they ever drift
  426. // apart, a reader with the strip and the MCP answer side by side has no
  427. // way to tell which one is lying.
  428. expect(text).toContain('**Dynamic boundaries');
  429. expect(text).toContain(site.label);
  430. expect(text).toContain(`src/router/table.ts:${site.line}`);
  431. expect(text).toContain(`candidates for key \`${site.key}\``);
  432. for (const candidate of site.candidates) expect(text).toContain(candidate.display);
  433. } finally {
  434. cg.close();
  435. }
  436. });
  437. it('splits a symbol\'s outgoing calls into the sure and the unfollowed', () => {
  438. const cg = CodeGraph.openSync(projectRoot);
  439. try {
  440. const node = cg.getNodesByName('handleRequest')[0]!;
  441. const all = continuationsFrom(cg, node);
  442. expect(all.resolved.map((c) => c.node.name)).toContain('loadRow');
  443. expect(all.uncertain.every((c) => (c.confidence ?? 1) < 0.6)).toBe(true);
  444. // Excluding what is already on the path is what keeps the cap from
  445. // listing the hop the reader just walked as an unexplored exit.
  446. const target = all.resolved[0]!.node.id;
  447. const rest = continuationsFrom(cg, node, new Set([target]));
  448. expect(rest.resolved.map((c) => c.node.id)).not.toContain(target);
  449. } finally {
  450. cg.close();
  451. }
  452. });
  453. });
  454. describe('GET /api/flow — a synthesized hop', () => {
  455. it('draws the interface bridge as a dashed hop that names its mechanism', async () => {
  456. const payload = await getFlow('?from=Tick&to=stamp');
  457. expect(payload.flows.length).toBeGreaterThan(0);
  458. const hops = payload.flows[0].hops;
  459. expect(names(payload.flows[0])[0]).toBe('Tick');
  460. expect(names(payload.flows[0]).at(-1)).toBe('stamp');
  461. const synthesized = hops.filter((h: any) => h.edge?.synthesized);
  462. expect(synthesized.length).toBeGreaterThan(0);
  463. for (const hop of synthesized) {
  464. expect(hop.edge.provenance).toBe('heuristic');
  465. expect(hop.edge.label).toMatch(/^via /);
  466. expect(hop.edge.label).not.toBe('calls');
  467. }
  468. });
  469. });
  470. describe('GET /api/flow — explore parity', () => {
  471. it('answers a ?symbols= question with the chain the explore search finds', async () => {
  472. const payload = await getFlow('?symbols=bootstrap,loadRow,toRow');
  473. expect(payload.query.kind).toBe('symbols');
  474. expect(payload.flows.length).toBeGreaterThan(0);
  475. // The endpoint must not have its own path finder. Run the engine's directly
  476. // and require the same hops, in the same order.
  477. const cg = CodeGraph.openSync(projectRoot);
  478. try {
  479. const flow = resolveNamedSymbolFlow(cg, 'bootstrap,loadRow,toRow');
  480. expect(flow.chains[0]?.steps.map((s) => s.node.id)).toEqual(
  481. payload.flows[0].hops.map((h: any) => h.node.id)
  482. );
  483. } finally {
  484. cg.close();
  485. }
  486. });
  487. });
  488. describe('GET /api/flow — a trail read as a flow', () => {
  489. it('draws the hops it was given, finding the edge that already joins them', async () => {
  490. const forward = await getFlow('?from=bootstrap&to=toRow');
  491. const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id);
  492. const query = ids
  493. .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'd'}${id}`)}`)
  494. .join('&');
  495. const payload = await getFlow(`?${query}`);
  496. expect(payload.query.kind).toBe('trail');
  497. expect(payload.flows[0].hops.map((h: any) => h.node.id)).toEqual(ids);
  498. expect(payload.flows[0].hops[1].edge.kind).toBe('calls');
  499. expect(payload.flows[0].hops[1].edge.upward).toBe(false);
  500. });
  501. it('reads a trail walked BACKWARDS as caller hops, opened at the calling line', async () => {
  502. const forward = await getFlow('?from=bootstrap&to=toRow');
  503. const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id).reverse();
  504. const query = ids
  505. .map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'u'}${id}`)}`)
  506. .join('&');
  507. const payload = await getFlow(`?${query}`);
  508. const hops = payload.flows[0].hops;
  509. expect(hops.map((h: any) => h.node.id)).toEqual(ids);
  510. // Every hop after the first is the caller of the one before it, so its own
  511. // body holds the call — and the card opens there, pointing BACK.
  512. for (let i = 1; i < hops.length; i++) {
  513. expect(hops[i].edge.upward).toBe(true);
  514. expect(hops[i].edge.label).toBe('called by');
  515. expect(hops[i].callRef.backwards).toBe(true);
  516. expect(hops[i].callRef.name).toBe(hops[i - 1].node.name);
  517. expect(hops[i].callRef.line).toBe(hops[i].edge.line);
  518. }
  519. // The first card is the callee: nothing in it calls anything on this trail.
  520. expect(hops[0].callRef).toBeNull();
  521. });
  522. it('says so when the ids on a trail are no longer in the index', async () => {
  523. const payload = await getFlow('?hop=smethod%3Agone&hop=dmethod%3Aalso-gone');
  524. expect(payload.flows).toEqual([]);
  525. expect(payload.unresolved).toEqual(['method:gone', 'method:also-gone']);
  526. expect(payload.reason).toMatch(/still in the index/);
  527. });
  528. });
  529. describe('GET /api/flow — refusals', () => {
  530. it('answers JSON, not text, when the question is malformed', async () => {
  531. const payload = await getFlow('', 400);
  532. expect(payload.code).toBe('bad-request');
  533. expect(payload.error).toMatch(/No flow was asked for/);
  534. expect(payload.hint).toMatch(/\?from=/);
  535. });
  536. it('caps the number of trail hops it will read', async () => {
  537. const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
  538. const payload = await getFlow(`?${query}`, 400);
  539. expect(payload.code).toBe('bad-request');
  540. expect(payload.error).toMatch(/longer than this endpoint reads/);
  541. });
  542. it('is listed on the API index', async () => {
  543. const res = await request('/api');
  544. const body = JSON.parse(res.body);
  545. const entry = body.endpoints.find((e: any) => e.path === '/api/flow');
  546. expect(entry).toBeDefined();
  547. expect(entry.params).toContain('from');
  548. expect(entry.params).toContain('hop');
  549. });
  550. });