ui-server-api.test.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  1. /**
  2. * The `codegraph ui` read-only JSON API (CG-42).
  3. *
  4. * Everything runs against a real indexed fixture project over a real loopback
  5. * server — no mocks — because the properties worth pinning are the ones that
  6. * only exist end to end: the drift verdict comes from hashing bytes on disk
  7. * against what the index stored, the refusals come from the same chokepoint the
  8. * static server uses, and the caps only matter once a symbol really does have
  9. * hundreds of callers.
  10. *
  11. * The fixture is built to produce each of those: a call chain three deep, a
  12. * test file that reaches it, a type used only as a type, an import that cannot
  13. * resolve, and one deliberately hot function with 500 callers.
  14. */
  15. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  16. import * as http from 'http';
  17. import * as fs from 'fs';
  18. import * as os from 'os';
  19. import * as path from 'path';
  20. import CodeGraph from '../src/index';
  21. import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
  22. interface Response {
  23. status: number;
  24. headers: http.IncomingHttpHeaders;
  25. body: string;
  26. }
  27. let server: UiServerHandle;
  28. let api: GraphApi;
  29. let tempDir: string;
  30. let projectRoot: string;
  31. let viewerDir: string;
  32. /**
  33. * One request against a live server, with the loopback `Host` the boundary
  34. * wants. Written with `http.request` rather than `fetch` so the `Host` header
  35. * is ours to set — undici treats it as forbidden.
  36. */
  37. function requestOn(port: number, requestPath: string, method = 'GET'): Promise<Response> {
  38. return new Promise((resolve, reject) => {
  39. const req = http.request(
  40. {
  41. host: '127.0.0.1',
  42. port,
  43. path: requestPath,
  44. method,
  45. headers: { Host: `127.0.0.1:${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. headers: res.headers,
  55. body: Buffer.concat(chunks).toString('utf-8'),
  56. })
  57. );
  58. }
  59. );
  60. req.on('error', reject);
  61. req.end();
  62. });
  63. }
  64. /** The same, against the main fixture's server. */
  65. function request(requestPath: string, method = 'GET'): Promise<Response> {
  66. return requestOn(server.port, requestPath, method);
  67. }
  68. /**
  69. * Payloads are read as `any` on purpose: these tests assert the JSON contract
  70. * the viewer sees over the wire, so typing them against the server's own
  71. * interfaces would only prove the server agrees with itself.
  72. */
  73. async function getJson(requestPath: string): Promise<any> {
  74. const res = await request(requestPath);
  75. expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
  76. return JSON.parse(res.body);
  77. }
  78. async function getStatusAndJson(requestPath: string): Promise<{ status: number; body: any }> {
  79. const res = await request(requestPath);
  80. expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
  81. return { status: res.status, body: JSON.parse(res.body) };
  82. }
  83. /** Find a symbol in the fixture by name, through the API itself. */
  84. async function idOf(name: string, kind?: string): Promise<string> {
  85. const search = await getJson(`/api/search?q=${encodeURIComponent(name)}`);
  86. const hit = search.results.items.find(
  87. (r: any) => r.name === name && (kind === undefined || r.kind === kind)
  88. );
  89. expect(hit, `no ${kind ?? 'symbol'} named ${name} in the fixture`).toBeTruthy();
  90. return hit.id as string;
  91. }
  92. beforeAll(async () => {
  93. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-api-'));
  94. projectRoot = path.join(tempDir, 'project');
  95. const srcDir = path.join(projectRoot, 'src');
  96. const testsDir = path.join(projectRoot, '__tests__');
  97. fs.mkdirSync(srcDir, { recursive: true });
  98. fs.mkdirSync(testsDir, { recursive: true });
  99. fs.writeFileSync(
  100. path.join(srcDir, 'types.ts'),
  101. `export interface Config {
  102. ttlMs: number;
  103. label: string;
  104. }
  105. export type CacheKey = string;
  106. `
  107. );
  108. fs.writeFileSync(
  109. path.join(srcDir, 'cache.ts'),
  110. `import { Config, CacheKey } from './types';
  111. export class Cache {
  112. private store = new Map<string, string>();
  113. private config: Config;
  114. constructor(config: Config) {
  115. this.config = config;
  116. }
  117. read(key: CacheKey): string | undefined {
  118. return this.store.get(key);
  119. }
  120. write(key: CacheKey, value: string): void {
  121. this.store.set(key, value);
  122. }
  123. }
  124. `
  125. );
  126. fs.writeFileSync(
  127. path.join(srcDir, 'service.ts'),
  128. `import { Cache } from './cache';
  129. import { Config } from './types';
  130. // Not in the index: a package that was never installed here.
  131. import { serialize } from 'some-external-package';
  132. export class Service {
  133. private cache: Cache;
  134. constructor(config: Config) {
  135. this.cache = new Cache(config);
  136. }
  137. load(key: string): string {
  138. const hit = this.cache.read(key);
  139. if (hit !== undefined) return hit;
  140. const fresh = serialize(key);
  141. this.cache.write(key, fresh);
  142. return fresh;
  143. }
  144. }
  145. `
  146. );
  147. fs.writeFileSync(
  148. path.join(srcDir, 'handler.ts'),
  149. `import { Service } from './service';
  150. export function handleRequest(service: Service, key: string): string {
  151. return service.load(key);
  152. }
  153. `
  154. );
  155. // 500 callers into one function: the N+1 and capping behaviour only shows up
  156. // at this scale, and the fixture keeps CI honest without needing the engine's
  157. // own index to be present.
  158. const callers = Array.from(
  159. { length: 500 },
  160. (_, i) => `export function caller${i}(): number {\n return hot(${i});\n}`
  161. ).join('\n\n');
  162. fs.writeFileSync(
  163. path.join(srcDir, 'hot.ts'),
  164. `export function hot(n: number): number {
  165. return n * 2;
  166. }
  167. ${callers}
  168. `
  169. );
  170. // CRLF on purpose: tree-sitter numbers rows by `\n`, so a CRLF file must come
  171. // back with the same line numbers the graph recorded — and without the stray
  172. // `\r` rendering at the end of every line. This is what a Windows checkout
  173. // with core.autocrlf looks like, and it is decided by bytes, not by the OS.
  174. fs.writeFileSync(
  175. path.join(srcDir, 'crlf.ts'),
  176. ['export function windowsStyle(n: number): number {', ' return n + 1;', '}', ''].join('\r\n')
  177. );
  178. fs.writeFileSync(
  179. path.join(testsDir, 'service.test.ts'),
  180. `import { Service } from '../src/service';
  181. export function testLoadsThroughCache(): void {
  182. const service = new Service({ ttlMs: 1, label: 'x' });
  183. service.load('k');
  184. }
  185. `
  186. );
  187. const cg = CodeGraph.initSync(projectRoot, {
  188. config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
  189. });
  190. await cg.indexAll();
  191. cg.resolveReferences();
  192. // Hand the index over: the API opens its own read-only connection, which is
  193. // also what happens in production (the CLI never shares an instance).
  194. cg.close();
  195. viewerDir = path.join(tempDir, 'viewer');
  196. fs.mkdirSync(viewerDir, { recursive: true });
  197. fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
  198. api = createGraphApi({ projectRoot });
  199. server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
  200. }, 120_000);
  201. afterAll(async () => {
  202. api?.close();
  203. await server?.close();
  204. if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  205. });
  206. describe('GET /api', () => {
  207. it('lists the endpoints it answers', async () => {
  208. const body = await getJson('/api');
  209. expect(body.readOnly).toBe(true);
  210. const paths = body.endpoints.map((e: any) => e.path);
  211. expect(paths).toEqual(
  212. expect.arrayContaining([
  213. '/api/stats',
  214. '/api/search',
  215. '/api/node/<id>',
  216. '/api/source',
  217. '/api/file/<path>',
  218. '/api/routes',
  219. ])
  220. );
  221. });
  222. it('404s an unknown endpoint as JSON, never as the app shell', async () => {
  223. const { status, body } = await getStatusAndJson('/api/nope');
  224. expect(status).toBe(404);
  225. expect(body.code).toBe('not-found');
  226. });
  227. it('answers HEAD with the headers and no body', async () => {
  228. const res = await request('/api/stats', 'HEAD');
  229. expect(res.status).toBe(200);
  230. expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
  231. expect(Number(res.headers['content-length'])).toBeGreaterThan(0);
  232. expect(res.body).toBe('');
  233. });
  234. });
  235. describe('GET /api/stats', () => {
  236. it('reports the project, the index state and the graph counts', async () => {
  237. const body = await getJson('/api/stats');
  238. expect(body.project.root).toBe(projectRoot);
  239. expect(body.project.name).toBe('project');
  240. expect(body.index.state).toBe('complete');
  241. expect(body.index.stale).toBe(false);
  242. expect(typeof body.index.lastIndexedAt).toBe('number');
  243. expect(body.index.backend).toBe('node-sqlite');
  244. expect(typeof body.index.extractionVersion).toBe('number');
  245. expect(body.graph.nodes).toBeGreaterThan(0);
  246. expect(body.graph.edges).toBeGreaterThan(0);
  247. expect(body.graph.files).toBeGreaterThanOrEqual(6);
  248. expect(body.graph.nodesByKind.class).toBeGreaterThanOrEqual(2);
  249. expect(body.graph.filesByLanguage.typescript).toBeGreaterThanOrEqual(6);
  250. // The thresholds travel with the data so the viewer's copy cannot drift.
  251. expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 });
  252. });
  253. it('reports a blast-radius scale the widest symbol in the index reaches', async () => {
  254. const body = await getJson('/api/stats');
  255. const scale = body.blastScale;
  256. // `hot` is called by 500 distinct functions and nothing else in the fixture
  257. // comes close, so the exact maximum is knowable here.
  258. expect(scale.maxDirect).toBe(500);
  259. // Its radius is at least its own callers; the sample is capped, so the
  260. // count is a floor and the flag says so rather than claiming exhaustive.
  261. expect(scale.maxWithinHops).toBeGreaterThanOrEqual(500);
  262. expect(scale.hops).toBe(3);
  263. expect(scale.sampled).toBeGreaterThan(0);
  264. expect(scale.sampled).toBeLessThanOrEqual(24);
  265. expect(scale.estimated).toBe(true);
  266. });
  267. it('serves the scale from cache — the second call does not re-traverse', async () => {
  268. const first = await getJson('/api/stats');
  269. const started = Date.now();
  270. const second = await getJson('/api/stats');
  271. expect(second.blastScale).toEqual(first.blastScale);
  272. // 24 depth-3 traversals over a 500-caller graph are not free; a cached
  273. // answer is. The margin is wide because this is a smoke test for the
  274. // memo existing at all, not a benchmark.
  275. expect(Date.now() - started).toBeLessThan(250);
  276. });
  277. });
  278. describe('GET /api/search', () => {
  279. it('ranks exact over prefix over substring, and groups by kind', async () => {
  280. const body = await getJson('/api/search?q=Cache');
  281. const first = body.results.items[0];
  282. expect(first.name).toBe('Cache');
  283. expect(first.kind).toBe('class');
  284. expect(first.matchKind).toBe('exact');
  285. const ranks = body.results.items.map((r: any) => r.matchKind);
  286. const order = ['exact', 'prefix', 'substring', 'qualified', 'file', 'related'];
  287. const asNumbers = ranks.map((r: string) => order.indexOf(r));
  288. expect(asNumbers).toEqual([...asNumbers].sort((a, b) => a - b));
  289. // Flattening the groups reproduces the flat ranking, so the palette can use
  290. // either without them disagreeing.
  291. const flattened = body.groups.flatMap((g: any) => g.items.map((i: any) => i.id));
  292. expect(new Set(flattened)).toEqual(new Set(body.results.items.map((r: any) => r.id)));
  293. for (const group of body.groups) expect(group.count).toBe(group.items.length);
  294. });
  295. it('returns a signature and a file:line for every result', async () => {
  296. const body = await getJson('/api/search?q=handleRequest');
  297. const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
  298. expect(hit.file).toBe('src/handler.ts');
  299. expect(hit.line).toBeGreaterThan(0);
  300. expect(hit.endLine).toBeGreaterThanOrEqual(hit.line);
  301. expect(hit.signature).toContain('service');
  302. expect(hit.qualifiedName).toBeTruthy();
  303. expect(hit.language).toBe('typescript');
  304. });
  305. it('finds a mid-name match FTS tokens cannot', async () => {
  306. const body = await getJson('/api/search?q=quest');
  307. const names = body.results.items.map((r: any) => r.name);
  308. expect(names).toContain('handleRequest');
  309. const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
  310. expect(hit.matchKind).toBe('substring');
  311. });
  312. it('honours the kind: filter grammar', async () => {
  313. const body = await getJson('/api/search?q=' + encodeURIComponent('kind:class Cache'));
  314. expect(body.filters.kinds).toEqual(['class']);
  315. expect(body.results.items.every((r: any) => r.kind === 'class')).toBe(true);
  316. });
  317. it('marks test files so the palette can rank them down', async () => {
  318. const body = await getJson('/api/search?q=testLoadsThroughCache');
  319. const hit = body.results.items.find((r: any) => r.name === 'testLoadsThroughCache');
  320. expect(hit.test).toBe(true);
  321. });
  322. it('answers an empty search box with nothing, and a missing q with 400', async () => {
  323. const empty = await getStatusAndJson('/api/search?q=');
  324. expect(empty.status).toBe(200);
  325. expect(empty.body.results.total).toBe(0);
  326. expect(empty.body.groups).toEqual([]);
  327. const missing = await getStatusAndJson('/api/search');
  328. expect(missing.status).toBe(400);
  329. expect(missing.body.code).toBe('bad-request');
  330. });
  331. it('returns an empty result set for a name nothing has', async () => {
  332. const body = await getJson('/api/search?q=zzznotasymbolanywhere');
  333. expect(body.results.total).toBe(0);
  334. });
  335. });
  336. describe('GET /api/node/<id>', () => {
  337. it('returns the symbol, its ancestors and its members in source order', async () => {
  338. const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
  339. expect(body.node.name).toBe('Cache');
  340. expect(body.node.kind).toBe('class');
  341. expect(body.node.file).toBe('src/cache.ts');
  342. expect(body.node.lines).toBe(body.node.endLine - body.node.line + 1);
  343. expect(body.node.exported).toBe(true);
  344. // Outermost first: the file, then anything between it and the symbol.
  345. expect(body.ancestors[0].kind).toBe('file');
  346. expect(body.ancestors[0].file).toBe('src/cache.ts');
  347. const members = body.members.items.map((m: any) => m.name);
  348. expect(members).toEqual(expect.arrayContaining(['read', 'write', 'store', 'config']));
  349. const lines = body.members.items.map((m: any) => m.line);
  350. expect(lines).toEqual([...lines].sort((a, b) => a - b));
  351. for (const member of body.members.items) {
  352. expect(member.parentId).toBe(body.node.id);
  353. expect(member.depth).toBe(1);
  354. }
  355. expect(body.members.total).toBe(body.members.shown);
  356. });
  357. it('gives every member its own fan-in and fan-out — the outline is the body', async () => {
  358. const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
  359. const byName = new Map(body.members.items.map((m: any) => [m.name, m]));
  360. for (const member of body.members.items) {
  361. expect(typeof member.fanIn).toBe('number');
  362. expect(typeof member.fanOut).toBe('number');
  363. expect(member.fanIn).toBeGreaterThanOrEqual(0);
  364. expect(member.fanOut).toBeGreaterThanOrEqual(0);
  365. }
  366. // `Service.load` calls both, and `Cache` contains them: at least the
  367. // containment edge plus one call each. Without these numbers a 700-line
  368. // class's outline cannot say which member carries weight.
  369. expect((byName.get('read') as any).fanIn).toBeGreaterThanOrEqual(2);
  370. expect((byName.get('write') as any).fanIn).toBeGreaterThanOrEqual(2);
  371. // The class itself calls nothing — its methods do, which is exactly why
  372. // the per-member counts have to come from the members.
  373. expect(body.counts.callees).toBe(0);
  374. expect(body.members.items.some((m: any) => m.fanOut > 0)).toBe(true);
  375. });
  376. it('nests a file outline one level deeper, so a class shows its methods', async () => {
  377. const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`);
  378. const byDepth = new Map<number, string[]>();
  379. for (const member of body.members.items) {
  380. byDepth.set(member.depth, [...(byDepth.get(member.depth) ?? []), member.name]);
  381. }
  382. expect(byDepth.get(1)).toContain('Cache');
  383. expect(byDepth.get(2)).toEqual(expect.arrayContaining(['read', 'write']));
  384. });
  385. it('groups incoming edges by the calling symbol, with their call sites', async () => {
  386. const readId = await idOf('read', 'method');
  387. const body = await getJson(`/api/node/${readId}`);
  388. const fromLoad = body.incoming.items.find((r: any) => r.node.name === 'load');
  389. expect(fromLoad, 'Service.load should call Cache.read').toBeTruthy();
  390. expect(fromLoad.node.file).toBe('src/service.ts');
  391. expect(fromLoad.edgeKinds).toContain('calls');
  392. expect(fromLoad.edgeCount).toBeGreaterThanOrEqual(1);
  393. expect(fromLoad.lines.length).toBeGreaterThanOrEqual(1);
  394. expect(fromLoad.lines).toEqual([...fromLoad.lines].sort((a: number, b: number) => a - b));
  395. expect(typeof fromLoad.fanIn).toBe('number');
  396. expect(fromLoad.hub).toBe(false);
  397. });
  398. it('carries every edge attribute the viewer draws with', async () => {
  399. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  400. const relation = body.incoming.items.find((r: any) => r.node.name === 'load');
  401. const edge = relation.edges[0];
  402. expect(edge.kind).toBe('calls');
  403. expect(typeof edge.line).toBe('number');
  404. expect(typeof edge.col).toBe('number');
  405. expect(typeof edge.confidence).toBe('number');
  406. expect(typeof edge.resolvedBy).toBe('string');
  407. // Confidence decides the uncertain fold; the group agrees with its edges.
  408. expect(relation.confidence).toBe(
  409. Math.max(...relation.edges.map((e: any) => e.confidence ?? -1))
  410. );
  411. expect(relation.uncertain).toBe(relation.confidence < 0.6);
  412. expect(relation.synthesized).toBe(false);
  413. });
  414. it('groups outgoing edges by the called symbol, ordered by call site', async () => {
  415. const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
  416. const names = body.outgoing.items.map((r: any) => r.node.name);
  417. expect(names).toEqual(expect.arrayContaining(['read', 'write']));
  418. const firstLines = body.outgoing.items
  419. .map((r: any) => r.lines[0])
  420. .filter((l: number | undefined) => l !== undefined);
  421. expect(firstLines).toEqual([...firstLines].sort((a, b) => a - b));
  422. });
  423. it('splits type references out of the callee rail', async () => {
  424. // Type edges attach to the MEMBER that names the type, not to its class:
  425. // `Service`'s constructor is where `Config` and `new Cache(...)` both live,
  426. // which makes it the one place both halves of the split are visible.
  427. const service = await getJson(`/api/node/${await idOf('Service', 'class')}`);
  428. const ctor = service.members.items.find((m: any) => m.name === 'constructor');
  429. const body = await getJson(`/api/node/${ctor.id}`);
  430. const typeNames = body.typesUsed.map((t: any) => t.node.name);
  431. expect(typeNames).toContain('Config');
  432. expect(body.typesUsed.every((t: any) => t.edgeKinds.includes('references'))).toBe(true);
  433. // A type reference is not also a callee row...
  434. expect(body.outgoing.items.map((r: any) => r.node.name)).not.toContain('Config');
  435. // ...but a class reached by any other edge kind still is: `new Cache(...)`
  436. // is an `instantiates` edge, and moving it would hide a real dependency.
  437. const instantiated = body.outgoing.items.find((r: any) => r.node.name === 'Cache');
  438. expect(instantiated).toBeTruthy();
  439. expect(instantiated.edgeKinds).toContain('instantiates');
  440. });
  441. it('summarizes which tests reach the symbol', async () => {
  442. const reached = await getJson(`/api/node/${await idOf('load', 'method')}`);
  443. expect(reached.tests.reached).toBe(true);
  444. expect(reached.tests.hops).toBe(1);
  445. expect(reached.tests.files).toContain('__tests__/service.test.ts');
  446. expect(reached.tests.fileCount).toBeGreaterThanOrEqual(1);
  447. expect(reached.tests.files.length).toBeLessThanOrEqual(6);
  448. expect(reached.tests.exhaustive).toBe(true);
  449. const unreached = await getJson(`/api/node/${await idOf('hot', 'function')}`);
  450. expect(unreached.tests.reached).toBe(false);
  451. expect(unreached.tests.hops).toBeNull();
  452. expect(unreached.tests.files).toEqual([]);
  453. });
  454. it('counts the calls that leave the index instead of hiding them', async () => {
  455. const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
  456. expect(body.outsideIndex.total).toBeGreaterThan(0);
  457. const names = body.outsideIndex.samples.map((s: any) => s.name);
  458. expect(names).toContain('serialize');
  459. for (const sample of body.outsideIndex.samples) {
  460. expect(typeof sample.line).toBe('number');
  461. expect(typeof sample.kind).toBe('string');
  462. }
  463. });
  464. it('summarizes the blast radius at three hops', async () => {
  465. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  466. expect(body.blast.hops).toBe(3);
  467. expect(body.blast.direct).toBe(body.counts.callers);
  468. // load → handleRequest / the test both sit inside three hops of Cache.read.
  469. expect(body.blast.withinHops).toBeGreaterThan(body.blast.direct);
  470. expect(body.blast.files).toBeGreaterThanOrEqual(2);
  471. expect(body.blast.testFiles).toBeGreaterThanOrEqual(1);
  472. expect(body.blast.routes).toBe(0);
  473. expect(body.blast.topFiles[0].symbols).toBeGreaterThanOrEqual(1);
  474. });
  475. it('keeps every count equal to the list it labels', async () => {
  476. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  477. expect(body.counts.callers).toBe(body.incoming.total);
  478. expect(body.counts.callees).toBe(body.outgoing.total);
  479. expect(body.counts.typesUsed).toBe(body.typesUsed.length);
  480. expect(body.counts.members).toBe(body.members.total);
  481. expect(body.blast.direct).toBe(body.counts.callers);
  482. });
  483. it('reports fan-in, fan-out and the hub flag', async () => {
  484. const quiet = await getJson(`/api/node/${await idOf('write', 'method')}`);
  485. expect(quiet.counts.hub).toBe(false);
  486. expect(quiet.counts.callers).toBeLessThan(40);
  487. expect(quiet.counts.fanIn).toBeGreaterThanOrEqual(quiet.counts.callers);
  488. const hot = await getJson(`/api/node/${await idOf('hot', 'function')}`);
  489. expect(hot.counts.hub).toBe(true);
  490. expect(hot.counts.callers).toBeGreaterThanOrEqual(500);
  491. });
  492. it('flags nothing as drifted while the fixture is untouched', async () => {
  493. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  494. expect(body.drift).toBe(false);
  495. });
  496. it('404s an id that names nothing, and 400s an empty one', async () => {
  497. const missing = await getStatusAndJson('/api/node/method:notarealid');
  498. expect(missing.status).toBe(404);
  499. expect(missing.body.code).toBe('not-found');
  500. expect(missing.body.hint).toBeTruthy();
  501. const empty = await getStatusAndJson('/api/node/');
  502. expect(empty.status).toBe(400);
  503. });
  504. });
  505. describe('GET /api/node/<id> — the busiest symbol', () => {
  506. it('caps the caller list, keeps the true total, and stays fast', async () => {
  507. const hotId = await idOf('hot', 'function');
  508. await request(`/api/node/${hotId}`); // warm the connection and the caches
  509. const started = performance.now();
  510. const res = await request(`/api/node/${hotId}`);
  511. const elapsed = performance.now() - started;
  512. expect(res.status).toBe(200);
  513. const body = JSON.parse(res.body);
  514. expect(body.incoming.total).toBeGreaterThanOrEqual(500);
  515. expect(body.incoming.shown).toBe(300);
  516. expect(body.incoming.truncated).toBe(true);
  517. expect(body.incoming.items).toHaveLength(300);
  518. // Grouped: one row per calling symbol, each carrying its own call sites.
  519. expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(300);
  520. expect(body.counts.callers).toBeGreaterThanOrEqual(500);
  521. expect(body.blast.direct).toBe(body.counts.callers);
  522. // 500 callers resolved one query at a time would be nowhere near this.
  523. expect(elapsed).toBeLessThan(100);
  524. });
  525. });
  526. describe('GET /api/source', () => {
  527. it('returns the requested slice with the index line numbering', async () => {
  528. const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
  529. expect(body.drift).toBe(false);
  530. expect(body.file).toBe('src/cache.ts');
  531. expect(body.language).toBe('typescript');
  532. expect(body.from).toBe(1);
  533. expect(body.to).toBe(3);
  534. expect(body.lines).toHaveLength(3);
  535. expect(body.lines[0]).toContain("import { Config, CacheKey } from './types'");
  536. expect(body.totalLines).toBeGreaterThan(3);
  537. expect(body.truncated).toBe(false);
  538. });
  539. it('serves the whole file when no range is given', async () => {
  540. const body = await getJson('/api/source?file=src/handler.ts');
  541. expect(body.from).toBe(1);
  542. expect(body.to).toBe(body.totalLines);
  543. expect(body.lines).toHaveLength(body.totalLines);
  544. });
  545. it('slices exactly the lines a symbol claims', async () => {
  546. const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
  547. const body = await getJson(
  548. `/api/source?file=${node.node.file}&from=${node.node.line}&to=${node.node.endLine}`
  549. );
  550. expect(body.lines[0]).toContain('handleRequest');
  551. expect(body.lines).toHaveLength(node.node.lines);
  552. });
  553. it('refuses to slice a file that changed on disk after the last sync', async () => {
  554. const target = path.join(projectRoot, 'src', 'handler.ts');
  555. const original = fs.readFileSync(target);
  556. try {
  557. fs.writeFileSync(target, Buffer.concat([Buffer.from('// a new first line\n'), original]));
  558. const body = await getJson('/api/source?file=src/handler.ts&from=1&to=3');
  559. expect(body.drift).toBe(true);
  560. // The whole point: no slice, rather than a slice of the wrong lines.
  561. expect(body.lines).toBeUndefined();
  562. expect(body.reason).toContain('changed on disk after the last index sync');
  563. // And every screen that renders indexed line ranges is told.
  564. const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
  565. expect(node.drift).toBe(true);
  566. const file = await getJson('/api/file/src/handler.ts');
  567. expect(file.drift).toBe(true);
  568. } finally {
  569. fs.writeFileSync(target, original);
  570. }
  571. });
  572. it('does not call an identical rewrite drift', async () => {
  573. const target = path.join(projectRoot, 'src', 'handler.ts');
  574. const original = fs.readFileSync(target);
  575. // Same bytes, new mtime — what a checkout or a formatter no-op looks like.
  576. fs.writeFileSync(target, original);
  577. const body = await getJson('/api/source?file=src/handler.ts&from=1&to=2');
  578. expect(body.drift).toBe(false);
  579. expect(body.lines).toHaveLength(2);
  580. });
  581. it('keeps a CRLF file on the index line numbering, without the stray carriage returns', async () => {
  582. const node = await getJson(`/api/node/${await idOf('windowsStyle', 'function')}`);
  583. expect(node.node.file).toBe('src/crlf.ts');
  584. const body = await getJson('/api/source?file=src/crlf.ts');
  585. expect(body.drift).toBe(false);
  586. expect(body.totalLines).toBe(3);
  587. expect(body.lines).toEqual([
  588. 'export function windowsStyle(n: number): number {',
  589. ' return n + 1;',
  590. '}',
  591. ]);
  592. expect(body.lines.some((l: string) => l.includes('\r'))).toBe(false);
  593. // The symbol's indexed range still names its own body.
  594. const slice = await getJson(
  595. `/api/source?file=src/crlf.ts&from=${node.node.line}&to=${node.node.endLine}`
  596. );
  597. expect(slice.lines[0]).toContain('windowsStyle');
  598. });
  599. it('refuses a path that escapes the project', async () => {
  600. const traversal = await getStatusAndJson(
  601. '/api/source?file=' + encodeURIComponent('../../../etc/passwd')
  602. );
  603. expect(traversal.status).toBe(403);
  604. expect(traversal.body.code).toBe('refused');
  605. const absolute = await getStatusAndJson(
  606. '/api/source?file=' + encodeURIComponent('/etc/passwd')
  607. );
  608. expect(absolute.status).toBe(403);
  609. expect(absolute.body.code).toBe('refused');
  610. expect(absolute.body.error).toContain('absolute');
  611. });
  612. it('refuses a NUL byte in the path', async () => {
  613. const { status, body } = await getStatusAndJson(
  614. '/api/source?file=' + encodeURIComponent('src/cache.ts\u0000.png')
  615. );
  616. expect(status).toBe(403);
  617. expect(body.code).toBe('refused');
  618. });
  619. it('404s a file that exists but is not indexed', async () => {
  620. fs.writeFileSync(path.join(projectRoot, 'notes.md'), '# not indexed\n');
  621. const { status, body } = await getStatusAndJson('/api/source?file=notes.md');
  622. expect(status).toBe(404);
  623. expect(body.code).toBe('not-found');
  624. expect(body.hint).toContain('index');
  625. });
  626. it('rejects a range that names nothing', async () => {
  627. const past = await getStatusAndJson('/api/source?file=src/handler.ts&from=99999');
  628. expect(past.status).toBe(400);
  629. expect(past.body.error).toContain('past the end');
  630. const backwards = await getStatusAndJson('/api/source?file=src/handler.ts&from=10&to=4');
  631. expect(backwards.status).toBe(400);
  632. const nonNumeric = await getStatusAndJson('/api/source?file=src/handler.ts&from=abc');
  633. expect(nonNumeric.status).toBe(400);
  634. });
  635. });
  636. describe('GET /api/file/<path>', () => {
  637. it('returns the file record and its outline in source order', async () => {
  638. const body = await getJson('/api/file/src/cache.ts');
  639. expect(body.file.path).toBe('src/cache.ts');
  640. expect(body.file.language).toBe('typescript');
  641. expect(body.file.size).toBeGreaterThan(0);
  642. expect(body.file.contentHash).toMatch(/^[0-9a-f]{64}$/);
  643. expect(body.file.generated).toBe(false);
  644. expect(body.file.test).toBe(false);
  645. expect(body.file.id).toMatch(/^file:/);
  646. expect(body.drift).toBe(false);
  647. const lines = body.outline.items.map((o: any) => o.line);
  648. expect(lines).toEqual([...lines].sort((a, b) => a - b));
  649. const cacheRow = body.outline.items.find((o: any) => o.name === 'Cache');
  650. expect(cacheRow.depth).toBe(0);
  651. expect(cacheRow.parentId).toBeNull();
  652. const readRow = body.outline.items.find((o: any) => o.name === 'read');
  653. expect(readRow.depth).toBe(1);
  654. expect(readRow.parentId).toBe(cacheRow.id);
  655. expect(readRow.fanIn).toBeGreaterThanOrEqual(1);
  656. expect(typeof readRow.fanOut).toBe('number');
  657. // The file node is the subject, not a row; imports have their own rail.
  658. expect(body.outline.items.some((o: any) => o.kind === 'file')).toBe(false);
  659. expect(body.outline.items.some((o: any) => o.kind === 'import')).toBe(false);
  660. });
  661. it('maps imports and imported-by to files', async () => {
  662. const body = await getJson('/api/file/src/cache.ts');
  663. const importedByFiles = body.importedBy.items.map((r: any) => r.file);
  664. expect(importedByFiles).toContain('src/service.ts');
  665. const importFiles = body.imports.items.map((r: any) => r.file);
  666. expect(importFiles).toContain('src/types.ts');
  667. // Never itself: same-file `imports` edges (the import declarations) are dropped.
  668. expect(importFiles).not.toContain('src/cache.ts');
  669. expect(importedByFiles).not.toContain('src/cache.ts');
  670. const typesRow = body.imports.items.find((r: any) => r.file === 'src/types.ts');
  671. expect(typesRow.symbolCount).toBeGreaterThanOrEqual(1);
  672. expect(typesRow.symbols[0].name).toBeTruthy();
  673. expect(typesRow.symbols[0].id).toBeTruthy();
  674. expect(typesRow.test).toBe(false);
  675. });
  676. it('names the imports that never resolved rather than dropping them', async () => {
  677. const body = await getJson('/api/file/src/service.ts');
  678. const names = body.unresolvedImports.map((u: any) => u.name);
  679. expect(names).toContain('some-external-package');
  680. });
  681. it('reports the wider cross-file relationship too', async () => {
  682. const body = await getJson('/api/file/src/cache.ts');
  683. expect(body.dependents).toContain('src/service.ts');
  684. expect(body.dependencies).toContain('src/types.ts');
  685. });
  686. it('404s a file that is not in the index and refuses one outside the project', async () => {
  687. const missing = await getStatusAndJson('/api/file/src/nope.ts');
  688. expect(missing.status).toBe(404);
  689. expect(missing.body.code).toBe('not-found');
  690. const outside = await getStatusAndJson(
  691. '/api/file/' + encodeURIComponent('/etc/passwd')
  692. );
  693. expect(outside.status).toBe(403);
  694. expect(outside.body.code).toBe('refused');
  695. });
  696. });
  697. describe('GET /api/routes', () => {
  698. it('says plainly that this project is not a routed app', async () => {
  699. const body = await getJson('/api/routes');
  700. expect(body.routed).toBe(false);
  701. expect(body.entries).toEqual([]);
  702. expect(body.routeCount).toBe(0);
  703. expect(body.shown).toBe(0);
  704. expect(body.truncated).toBe(false);
  705. });
  706. it('refuses a limit the manifest cannot answer truthfully', async () => {
  707. // Below three, the engine's manifest reports every routed project as
  708. // unrouted — a wrong answer, so the parameter is refused instead.
  709. for (const limit of ['0', '2', '-1', 'abc']) {
  710. const { status, body } = await getStatusAndJson(`/api/routes?limit=${limit}`);
  711. expect(status, `limit=${limit}`).toBe(400);
  712. expect(body.code).toBe('bad-request');
  713. }
  714. });
  715. describe('a project that IS routed', () => {
  716. let routedApi: GraphApi;
  717. let routedServer: UiServerHandle;
  718. beforeAll(async () => {
  719. const routedRoot = path.join(tempDir, 'routed');
  720. fs.mkdirSync(path.join(routedRoot, 'src'), { recursive: true });
  721. fs.writeFileSync(
  722. path.join(routedRoot, 'src', 'routes.ts'),
  723. `import express from 'express';
  724. const app = express();
  725. export function listUsers(req: any, res: any): void { res.json([]); }
  726. export function getUser(req: any, res: any): void { res.json({}); }
  727. export function createUser(req: any, res: any): void { res.json({}); }
  728. export function deleteUser(req: any, res: any): void { res.json({}); }
  729. app.get('/users', listUsers);
  730. app.get('/users/:id', getUser);
  731. app.post('/users', createUser);
  732. app.delete('/users/:id', deleteUser);
  733. export default app;
  734. `
  735. );
  736. const routedCg = CodeGraph.initSync(routedRoot, {
  737. config: { include: ['src/**/*.ts'], exclude: [] },
  738. });
  739. await routedCg.indexAll();
  740. routedCg.resolveReferences();
  741. routedCg.close();
  742. routedApi = createGraphApi({ projectRoot: routedRoot });
  743. routedServer = await startUiServer({
  744. projectRoot: routedRoot,
  745. viewerDir,
  746. port: 0,
  747. api: routedApi.handler,
  748. });
  749. }, 120_000);
  750. afterAll(async () => {
  751. routedApi?.close();
  752. await routedServer?.close();
  753. });
  754. it('maps each URL to its handler, with a node id to navigate to', async () => {
  755. const res = await requestOn(routedServer.port, '/api/routes');
  756. const body = JSON.parse(res.body);
  757. expect(body.routed).toBe(true);
  758. expect(body.routeCount).toBe(4);
  759. expect(body.shown).toBe(4);
  760. expect(body.truncated).toBe(false);
  761. expect(body.topHandlerFile).toBe('src/routes.ts');
  762. expect(body.topHandlerFileCount).toBe(4);
  763. const urls = body.entries.map((e: any) => e.url);
  764. expect(urls).toEqual(
  765. expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
  766. );
  767. const listUsers = body.entries.find((e: any) => e.url === 'GET /users');
  768. expect(listUsers.handler).toBe('listUsers');
  769. expect(listUsers.handlerKind).toBe('function');
  770. expect(listUsers.file).toBe('src/routes.ts');
  771. expect(listUsers.line).toBeGreaterThan(0);
  772. // The manifest carries no ids of its own; resolving them is what makes a
  773. // route row clickable, so it has to actually resolve.
  774. expect(listUsers.handlerId).toBeTruthy();
  775. const handler = JSON.parse(
  776. (await requestOn(routedServer.port, `/api/node/${listUsers.handlerId}`)).body
  777. );
  778. expect(handler.node.name).toBe('listUsers');
  779. });
  780. it('honours the limit and says when it cut the list', async () => {
  781. const res = await requestOn(routedServer.port, '/api/routes?limit=3');
  782. const body = JSON.parse(res.body);
  783. expect(body.routed).toBe(true);
  784. expect(body.entries).toHaveLength(3);
  785. expect(body.shown).toBe(3);
  786. expect(body.truncated).toBe(true);
  787. // The headline count is the whole graph's, not the page's.
  788. expect(body.routeCount).toBe(4);
  789. });
  790. });
  791. });
  792. /**
  793. * The acceptance bar from the issue, against the engine's OWN index rather than
  794. * a fixture: `LRUCache.get` in `src/resolution/lru-cache.ts`, 500+ callers.
  795. *
  796. * `.codegraph/` is gitignored, so this only runs on a machine that has indexed
  797. * this repository. The fixture test above covers the same properties in CI; this
  798. * one is the check against the real, messy graph the number came from.
  799. */
  800. describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))(
  801. "the engine's own busiest symbol",
  802. () => {
  803. const repoRoot = path.resolve(__dirname, '..');
  804. let repoApi: GraphApi;
  805. let repoServer: UiServerHandle;
  806. beforeAll(async () => {
  807. repoApi = createGraphApi({ projectRoot: repoRoot });
  808. repoServer = await startUiServer({
  809. projectRoot: repoRoot,
  810. viewerDir,
  811. port: 0,
  812. api: repoApi.handler,
  813. });
  814. });
  815. afterAll(async () => {
  816. repoApi?.close();
  817. await repoServer?.close();
  818. });
  819. const repoGet = (requestPath: string): Promise<Response> =>
  820. requestOn(repoServer.port, requestPath);
  821. it('answers in under 100 ms with grouped, capped lists and correct counts', async () => {
  822. const search = JSON.parse(
  823. (await repoGet('/api/search?q=' + encodeURIComponent('LRUCache.get'))).body
  824. );
  825. const hit = search.results.items.find(
  826. (r: any) => r.name === 'get' && r.file.endsWith('src/resolution/lru-cache.ts')
  827. );
  828. expect(hit, 'LRUCache.get should be in the engine\'s own index').toBeTruthy();
  829. await repoGet(`/api/node/${hit.id}`); // warm
  830. const started = performance.now();
  831. const res = await repoGet(`/api/node/${hit.id}`);
  832. const elapsed = performance.now() - started;
  833. expect(res.status).toBe(200);
  834. const body = JSON.parse(res.body);
  835. expect(body.counts.fanIn).toBeGreaterThanOrEqual(500);
  836. expect(body.counts.hub).toBe(true);
  837. // Grouped by calling symbol, so the row count is the distinct-caller
  838. // count, never the edge count.
  839. expect(body.incoming.items).toHaveLength(body.incoming.shown);
  840. expect(body.incoming.shown).toBeLessThanOrEqual(300);
  841. expect(body.incoming.shown).toBe(Math.min(300, body.incoming.total));
  842. expect(body.incoming.truncated).toBe(body.incoming.total > 300);
  843. expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(
  844. body.incoming.shown
  845. );
  846. const edgesInRows = body.incoming.items.reduce(
  847. (sum: number, r: any) => sum + r.edgeCount,
  848. 0
  849. );
  850. expect(edgesInRows).toBeLessThanOrEqual(body.counts.fanIn);
  851. expect(body.blast.direct).toBe(body.counts.callers);
  852. expect(body.tests.reached).toBe(true);
  853. expect(elapsed).toBeLessThan(100);
  854. });
  855. }
  856. );
  857. describe('an index that is not there', () => {
  858. it('answers with the same guidance the CLI prints, not a stack trace', async () => {
  859. const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-noindex-'));
  860. const detached = createGraphApi({ projectRoot: emptyRoot });
  861. const detachedServer = await startUiServer({
  862. projectRoot: emptyRoot,
  863. viewerDir,
  864. port: 0,
  865. api: detached.handler,
  866. });
  867. try {
  868. const res = await requestOn(detachedServer.port, '/api/stats');
  869. expect(res.status).toBe(503);
  870. const body = JSON.parse(res.body);
  871. expect(body.code).toBe('no-index');
  872. expect(body.error).toContain('No CodeGraph index found');
  873. expect(body.hint).toContain('codegraph init');
  874. expect(body.error).not.toContain(' at ');
  875. } finally {
  876. detached.close();
  877. await detachedServer.close();
  878. fs.rmSync(emptyRoot, { recursive: true, force: true });
  879. }
  880. });
  881. });