ui-server-api.test.ts 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199
  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. // Module-level statements: the engine records them as edges out of the FILE
  156. // node, which is the only reason `/api/entrypoints` can see an executable
  157. // root at all. Nothing else in the fixture runs anything on the way down.
  158. fs.writeFileSync(
  159. path.join(srcDir, 'main.ts'),
  160. `import { Service } from './service';
  161. import { handleRequest } from './handler';
  162. const service = new Service({ ttlMs: 5, label: 'main' });
  163. const first = handleRequest(service, 'boot');
  164. const second = service.load('warm');
  165. export const started = [first, second];
  166. `
  167. );
  168. // 500 callers into one function: the N+1 and capping behaviour only shows up
  169. // at this scale, and the fixture keeps CI honest without needing the engine's
  170. // own index to be present.
  171. const callers = Array.from(
  172. { length: 500 },
  173. (_, i) => `export function caller${i}(): number {\n return hot(${i});\n}`
  174. ).join('\n\n');
  175. fs.writeFileSync(
  176. path.join(srcDir, 'hot.ts'),
  177. `export function hot(n: number): number {
  178. return n * 2;
  179. }
  180. ${callers}
  181. `
  182. );
  183. // CRLF on purpose: tree-sitter numbers rows by `\n`, so a CRLF file must come
  184. // back with the same line numbers the graph recorded — and without the stray
  185. // `\r` rendering at the end of every line. This is what a Windows checkout
  186. // with core.autocrlf looks like, and it is decided by bytes, not by the OS.
  187. fs.writeFileSync(
  188. path.join(srcDir, 'crlf.ts'),
  189. ['export function windowsStyle(n: number): number {', ' return n + 1;', '}', ''].join('\r\n')
  190. );
  191. fs.writeFileSync(
  192. path.join(testsDir, 'service.test.ts'),
  193. `import { Service } from '../src/service';
  194. export function testLoadsThroughCache(): void {
  195. const service = new Service({ ttlMs: 1, label: 'x' });
  196. service.load('k');
  197. }
  198. // Module level, on purpose: a test file that RUNS something must still be
  199. // excluded from the entry points.
  200. testLoadsThroughCache();
  201. `
  202. );
  203. const cg = CodeGraph.initSync(projectRoot, {
  204. config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
  205. });
  206. await cg.indexAll();
  207. cg.resolveReferences();
  208. // Hand the index over: the API opens its own read-only connection, which is
  209. // also what happens in production (the CLI never shares an instance).
  210. cg.close();
  211. viewerDir = path.join(tempDir, 'viewer');
  212. fs.mkdirSync(viewerDir, { recursive: true });
  213. fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
  214. api = createGraphApi({ projectRoot });
  215. server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
  216. }, 120_000);
  217. afterAll(async () => {
  218. api?.close();
  219. await server?.close();
  220. if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  221. });
  222. describe('GET /api', () => {
  223. it('lists the endpoints it answers', async () => {
  224. const body = await getJson('/api');
  225. expect(body.readOnly).toBe(true);
  226. const paths = body.endpoints.map((e: any) => e.path);
  227. expect(paths).toEqual(
  228. expect.arrayContaining([
  229. '/api/stats',
  230. '/api/search',
  231. '/api/node/<id>',
  232. '/api/source',
  233. '/api/file/<path>',
  234. '/api/routes',
  235. ])
  236. );
  237. });
  238. it('404s an unknown endpoint as JSON, never as the app shell', async () => {
  239. const { status, body } = await getStatusAndJson('/api/nope');
  240. expect(status).toBe(404);
  241. expect(body.code).toBe('not-found');
  242. });
  243. it('answers HEAD with the headers and no body', async () => {
  244. const res = await request('/api/stats', 'HEAD');
  245. expect(res.status).toBe(200);
  246. expect(res.headers['content-type']).toBe('application/json; charset=utf-8');
  247. expect(Number(res.headers['content-length'])).toBeGreaterThan(0);
  248. expect(res.body).toBe('');
  249. });
  250. });
  251. describe('GET /api/stats', () => {
  252. it('reports the project, the index state and the graph counts', async () => {
  253. const body = await getJson('/api/stats');
  254. expect(body.project.root).toBe(projectRoot);
  255. expect(body.project.name).toBe('project');
  256. expect(body.index.state).toBe('complete');
  257. expect(body.index.stale).toBe(false);
  258. expect(typeof body.index.lastIndexedAt).toBe('number');
  259. expect(body.index.backend).toBe('node-sqlite');
  260. expect(typeof body.index.extractionVersion).toBe('number');
  261. expect(body.graph.nodes).toBeGreaterThan(0);
  262. expect(body.graph.edges).toBeGreaterThan(0);
  263. expect(body.graph.files).toBeGreaterThanOrEqual(6);
  264. expect(body.graph.nodesByKind.class).toBeGreaterThanOrEqual(2);
  265. expect(body.graph.filesByLanguage.typescript).toBeGreaterThanOrEqual(6);
  266. // The thresholds travel with the data so the viewer's copy cannot drift.
  267. expect(body.thresholds).toEqual({ hub: 40, uncertainBelow: 0.6 });
  268. });
  269. it('reports a blast-radius scale the widest symbol in the index reaches', async () => {
  270. const body = await getJson('/api/stats');
  271. const scale = body.blastScale;
  272. // `hot` is called by 500 distinct functions and nothing else in the fixture
  273. // comes close, so the exact maximum is knowable here.
  274. expect(scale.maxDirect).toBe(500);
  275. // Its radius is at least its own callers; the sample is capped, so the
  276. // count is a floor and the flag says so rather than claiming exhaustive.
  277. expect(scale.maxWithinHops).toBeGreaterThanOrEqual(500);
  278. expect(scale.hops).toBe(3);
  279. expect(scale.sampled).toBeGreaterThan(0);
  280. expect(scale.sampled).toBeLessThanOrEqual(24);
  281. expect(scale.estimated).toBe(true);
  282. });
  283. it('serves the scale from cache — the second call does not re-traverse', async () => {
  284. const first = await getJson('/api/stats');
  285. const started = Date.now();
  286. const second = await getJson('/api/stats');
  287. expect(second.blastScale).toEqual(first.blastScale);
  288. // 24 depth-3 traversals over a 500-caller graph are not free; a cached
  289. // answer is. The margin is wide because this is a smoke test for the
  290. // memo existing at all, not a benchmark.
  291. expect(Date.now() - started).toBeLessThan(250);
  292. });
  293. });
  294. describe('GET /api/search', () => {
  295. it('ranks exact over prefix over substring, and groups by kind', async () => {
  296. const body = await getJson('/api/search?q=Cache');
  297. const first = body.results.items[0];
  298. expect(first.name).toBe('Cache');
  299. expect(first.kind).toBe('class');
  300. expect(first.matchKind).toBe('exact');
  301. const ranks = body.results.items.map((r: any) => r.matchKind);
  302. const order = ['exact', 'prefix', 'substring', 'qualified', 'file', 'related'];
  303. const asNumbers = ranks.map((r: string) => order.indexOf(r));
  304. expect(asNumbers).toEqual([...asNumbers].sort((a, b) => a - b));
  305. // Flattening the groups reproduces the flat ranking, so the palette can use
  306. // either without them disagreeing.
  307. const flattened = body.groups.flatMap((g: any) => g.items.map((i: any) => i.id));
  308. expect(new Set(flattened)).toEqual(new Set(body.results.items.map((r: any) => r.id)));
  309. for (const group of body.groups) expect(group.count).toBe(group.items.length);
  310. });
  311. it('returns a signature and a file:line for every result', async () => {
  312. const body = await getJson('/api/search?q=handleRequest');
  313. const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
  314. expect(hit.file).toBe('src/handler.ts');
  315. expect(hit.line).toBeGreaterThan(0);
  316. expect(hit.endLine).toBeGreaterThanOrEqual(hit.line);
  317. expect(hit.signature).toContain('service');
  318. expect(hit.qualifiedName).toBeTruthy();
  319. expect(hit.language).toBe('typescript');
  320. });
  321. it('finds a mid-name match FTS tokens cannot', async () => {
  322. const body = await getJson('/api/search?q=quest');
  323. const names = body.results.items.map((r: any) => r.name);
  324. expect(names).toContain('handleRequest');
  325. const hit = body.results.items.find((r: any) => r.name === 'handleRequest');
  326. expect(hit.matchKind).toBe('substring');
  327. });
  328. it('honours the kind: filter grammar', async () => {
  329. const body = await getJson('/api/search?q=' + encodeURIComponent('kind:class Cache'));
  330. expect(body.filters.kinds).toEqual(['class']);
  331. expect(body.results.items.every((r: any) => r.kind === 'class')).toBe(true);
  332. });
  333. it('marks test files so the palette can rank them down', async () => {
  334. const body = await getJson('/api/search?q=testLoadsThroughCache');
  335. const hit = body.results.items.find((r: any) => r.name === 'testLoadsThroughCache');
  336. expect(hit.test).toBe(true);
  337. });
  338. it('answers an empty search box with nothing, and a missing q with 400', async () => {
  339. const empty = await getStatusAndJson('/api/search?q=');
  340. expect(empty.status).toBe(200);
  341. expect(empty.body.results.total).toBe(0);
  342. expect(empty.body.groups).toEqual([]);
  343. const missing = await getStatusAndJson('/api/search');
  344. expect(missing.status).toBe(400);
  345. expect(missing.body.code).toBe('bad-request');
  346. });
  347. it('returns an empty result set for a name nothing has', async () => {
  348. const body = await getJson('/api/search?q=zzznotasymbolanywhere');
  349. expect(body.results.total).toBe(0);
  350. });
  351. });
  352. describe('GET /api/node/<id>', () => {
  353. it('returns the symbol, its ancestors and its members in source order', async () => {
  354. const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
  355. expect(body.node.name).toBe('Cache');
  356. expect(body.node.kind).toBe('class');
  357. expect(body.node.file).toBe('src/cache.ts');
  358. expect(body.node.lines).toBe(body.node.endLine - body.node.line + 1);
  359. expect(body.node.exported).toBe(true);
  360. // Outermost first: the file, then anything between it and the symbol.
  361. expect(body.ancestors[0].kind).toBe('file');
  362. expect(body.ancestors[0].file).toBe('src/cache.ts');
  363. const members = body.members.items.map((m: any) => m.name);
  364. expect(members).toEqual(expect.arrayContaining(['read', 'write', 'store', 'config']));
  365. const lines = body.members.items.map((m: any) => m.line);
  366. expect(lines).toEqual([...lines].sort((a, b) => a - b));
  367. for (const member of body.members.items) {
  368. expect(member.parentId).toBe(body.node.id);
  369. expect(member.depth).toBe(1);
  370. }
  371. expect(body.members.total).toBe(body.members.shown);
  372. });
  373. it('gives every member its own fan-in and fan-out — the outline is the body', async () => {
  374. const body = await getJson(`/api/node/${await idOf('Cache', 'class')}`);
  375. const byName = new Map(body.members.items.map((m: any) => [m.name, m]));
  376. for (const member of body.members.items) {
  377. expect(typeof member.fanIn).toBe('number');
  378. expect(typeof member.fanOut).toBe('number');
  379. expect(member.fanIn).toBeGreaterThanOrEqual(0);
  380. expect(member.fanOut).toBeGreaterThanOrEqual(0);
  381. }
  382. // `Service.load` calls both, and `Cache` contains them: at least the
  383. // containment edge plus one call each. Without these numbers a 700-line
  384. // class's outline cannot say which member carries weight.
  385. expect((byName.get('read') as any).fanIn).toBeGreaterThanOrEqual(2);
  386. expect((byName.get('write') as any).fanIn).toBeGreaterThanOrEqual(2);
  387. // The class itself calls nothing — its methods do, which is exactly why
  388. // the per-member counts have to come from the members.
  389. expect(body.counts.callees).toBe(0);
  390. expect(body.members.items.some((m: any) => m.fanOut > 0)).toBe(true);
  391. });
  392. it('nests a file outline one level deeper, so a class shows its methods', async () => {
  393. const body = await getJson(`/api/node/${await idOf('cache.ts', 'file')}`);
  394. const byDepth = new Map<number, string[]>();
  395. for (const member of body.members.items) {
  396. byDepth.set(member.depth, [...(byDepth.get(member.depth) ?? []), member.name]);
  397. }
  398. expect(byDepth.get(1)).toContain('Cache');
  399. expect(byDepth.get(2)).toEqual(expect.arrayContaining(['read', 'write']));
  400. });
  401. it('groups incoming edges by the calling symbol, with their call sites', async () => {
  402. const readId = await idOf('read', 'method');
  403. const body = await getJson(`/api/node/${readId}`);
  404. const fromLoad = body.incoming.items.find((r: any) => r.node.name === 'load');
  405. expect(fromLoad, 'Service.load should call Cache.read').toBeTruthy();
  406. expect(fromLoad.node.file).toBe('src/service.ts');
  407. expect(fromLoad.edgeKinds).toContain('calls');
  408. expect(fromLoad.edgeCount).toBeGreaterThanOrEqual(1);
  409. expect(fromLoad.lines.length).toBeGreaterThanOrEqual(1);
  410. expect(fromLoad.lines).toEqual([...fromLoad.lines].sort((a: number, b: number) => a - b));
  411. expect(typeof fromLoad.fanIn).toBe('number');
  412. expect(fromLoad.hub).toBe(false);
  413. });
  414. it('carries every edge attribute the viewer draws with', async () => {
  415. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  416. const relation = body.incoming.items.find((r: any) => r.node.name === 'load');
  417. const edge = relation.edges[0];
  418. expect(edge.kind).toBe('calls');
  419. expect(typeof edge.line).toBe('number');
  420. expect(typeof edge.col).toBe('number');
  421. expect(typeof edge.confidence).toBe('number');
  422. expect(typeof edge.resolvedBy).toBe('string');
  423. // Confidence decides the uncertain fold; the group agrees with its edges.
  424. expect(relation.confidence).toBe(
  425. Math.max(...relation.edges.map((e: any) => e.confidence ?? -1))
  426. );
  427. expect(relation.uncertain).toBe(relation.confidence < 0.6);
  428. expect(relation.synthesized).toBe(false);
  429. });
  430. it('groups outgoing edges by the called symbol, ordered by call site', async () => {
  431. const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
  432. const names = body.outgoing.items.map((r: any) => r.node.name);
  433. expect(names).toEqual(expect.arrayContaining(['read', 'write']));
  434. const firstLines = body.outgoing.items
  435. .map((r: any) => r.lines[0])
  436. .filter((l: number | undefined) => l !== undefined);
  437. expect(firstLines).toEqual([...firstLines].sort((a, b) => a - b));
  438. });
  439. it('splits type references out of the callee rail', async () => {
  440. // Type edges attach to the MEMBER that names the type, not to its class:
  441. // `Service`'s constructor is where `Config` and `new Cache(...)` both live,
  442. // which makes it the one place both halves of the split are visible.
  443. const service = await getJson(`/api/node/${await idOf('Service', 'class')}`);
  444. const ctor = service.members.items.find((m: any) => m.name === 'constructor');
  445. const body = await getJson(`/api/node/${ctor.id}`);
  446. const typeNames = body.typesUsed.map((t: any) => t.node.name);
  447. expect(typeNames).toContain('Config');
  448. expect(body.typesUsed.every((t: any) => t.edgeKinds.includes('references'))).toBe(true);
  449. // A type reference is not also a callee row...
  450. expect(body.outgoing.items.map((r: any) => r.node.name)).not.toContain('Config');
  451. // ...but a class reached by any other edge kind still is: `new Cache(...)`
  452. // is an `instantiates` edge, and moving it would hide a real dependency.
  453. const instantiated = body.outgoing.items.find((r: any) => r.node.name === 'Cache');
  454. expect(instantiated).toBeTruthy();
  455. expect(instantiated.edgeKinds).toContain('instantiates');
  456. });
  457. it('summarizes which tests reach the symbol', async () => {
  458. const reached = await getJson(`/api/node/${await idOf('load', 'method')}`);
  459. expect(reached.tests.reached).toBe(true);
  460. expect(reached.tests.hops).toBe(1);
  461. expect(reached.tests.files).toContain('__tests__/service.test.ts');
  462. expect(reached.tests.fileCount).toBeGreaterThanOrEqual(1);
  463. expect(reached.tests.files.length).toBeLessThanOrEqual(6);
  464. expect(reached.tests.exhaustive).toBe(true);
  465. const unreached = await getJson(`/api/node/${await idOf('hot', 'function')}`);
  466. expect(unreached.tests.reached).toBe(false);
  467. expect(unreached.tests.hops).toBeNull();
  468. expect(unreached.tests.files).toEqual([]);
  469. });
  470. it('counts the calls that leave the index instead of hiding them', async () => {
  471. const body = await getJson(`/api/node/${await idOf('load', 'method')}`);
  472. expect(body.outsideIndex.total).toBeGreaterThan(0);
  473. const names = body.outsideIndex.samples.map((s: any) => s.name);
  474. expect(names).toContain('serialize');
  475. for (const sample of body.outsideIndex.samples) {
  476. expect(typeof sample.line).toBe('number');
  477. expect(typeof sample.kind).toBe('string');
  478. }
  479. });
  480. it('summarizes the blast radius at three hops', async () => {
  481. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  482. expect(body.blast.hops).toBe(3);
  483. expect(body.blast.direct).toBe(body.counts.callers);
  484. // load → handleRequest / the test both sit inside three hops of Cache.read.
  485. expect(body.blast.withinHops).toBeGreaterThan(body.blast.direct);
  486. expect(body.blast.files).toBeGreaterThanOrEqual(2);
  487. expect(body.blast.testFiles).toBeGreaterThanOrEqual(1);
  488. expect(body.blast.routes).toBe(0);
  489. expect(body.blast.topFiles[0].symbols).toBeGreaterThanOrEqual(1);
  490. });
  491. it('keeps every count equal to the list it labels', async () => {
  492. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  493. expect(body.counts.callers).toBe(body.incoming.total);
  494. expect(body.counts.callees).toBe(body.outgoing.total);
  495. expect(body.counts.typesUsed).toBe(body.typesUsed.length);
  496. expect(body.counts.members).toBe(body.members.total);
  497. expect(body.blast.direct).toBe(body.counts.callers);
  498. });
  499. it('reports fan-in, fan-out and the hub flag', async () => {
  500. const quiet = await getJson(`/api/node/${await idOf('write', 'method')}`);
  501. expect(quiet.counts.hub).toBe(false);
  502. expect(quiet.counts.callers).toBeLessThan(40);
  503. expect(quiet.counts.fanIn).toBeGreaterThanOrEqual(quiet.counts.callers);
  504. const hot = await getJson(`/api/node/${await idOf('hot', 'function')}`);
  505. expect(hot.counts.hub).toBe(true);
  506. expect(hot.counts.callers).toBeGreaterThanOrEqual(500);
  507. });
  508. it('flags nothing as drifted while the fixture is untouched', async () => {
  509. const body = await getJson(`/api/node/${await idOf('read', 'method')}`);
  510. expect(body.drift).toBe(false);
  511. });
  512. it('404s an id that names nothing, and 400s an empty one', async () => {
  513. const missing = await getStatusAndJson('/api/node/method:notarealid');
  514. expect(missing.status).toBe(404);
  515. expect(missing.body.code).toBe('not-found');
  516. expect(missing.body.hint).toBeTruthy();
  517. const empty = await getStatusAndJson('/api/node/');
  518. expect(empty.status).toBe(400);
  519. });
  520. });
  521. describe('GET /api/node/<id> — the busiest symbol', () => {
  522. it('caps the caller list, keeps the true total, and stays fast', async () => {
  523. const hotId = await idOf('hot', 'function');
  524. await request(`/api/node/${hotId}`); // warm the connection and the caches
  525. const started = performance.now();
  526. const res = await request(`/api/node/${hotId}`);
  527. const elapsed = performance.now() - started;
  528. expect(res.status).toBe(200);
  529. const body = JSON.parse(res.body);
  530. expect(body.incoming.total).toBeGreaterThanOrEqual(500);
  531. expect(body.incoming.shown).toBe(300);
  532. expect(body.incoming.truncated).toBe(true);
  533. expect(body.incoming.items).toHaveLength(300);
  534. // Grouped: one row per calling symbol, each carrying its own call sites.
  535. expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(300);
  536. expect(body.counts.callers).toBeGreaterThanOrEqual(500);
  537. expect(body.blast.direct).toBe(body.counts.callers);
  538. // 500 callers resolved one query at a time would be nowhere near this.
  539. expect(elapsed).toBeLessThan(100);
  540. });
  541. });
  542. describe('GET /api/source', () => {
  543. it('returns the requested slice with the index line numbering', async () => {
  544. const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
  545. expect(body.drift).toBe(false);
  546. expect(body.file).toBe('src/cache.ts');
  547. expect(body.language).toBe('typescript');
  548. expect(body.from).toBe(1);
  549. expect(body.to).toBe(3);
  550. expect(body.lines).toHaveLength(3);
  551. expect(body.lines[0]).toContain("import { Config, CacheKey } from './types'");
  552. expect(body.totalLines).toBeGreaterThan(3);
  553. expect(body.truncated).toBe(false);
  554. });
  555. it('serves the whole file when no range is given', async () => {
  556. const body = await getJson('/api/source?file=src/handler.ts');
  557. expect(body.from).toBe(1);
  558. expect(body.to).toBe(body.totalLines);
  559. expect(body.lines).toHaveLength(body.totalLines);
  560. });
  561. it('slices exactly the lines a symbol claims', async () => {
  562. const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
  563. const body = await getJson(
  564. `/api/source?file=${node.node.file}&from=${node.node.line}&to=${node.node.endLine}`
  565. );
  566. expect(body.lines[0]).toContain('handleRequest');
  567. expect(body.lines).toHaveLength(node.node.lines);
  568. });
  569. it('carries the classified source beside the lines, one entry per line', async () => {
  570. const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
  571. // Highlighting rides with the slice rather than behind its own endpoint:
  572. // the two are only ever wanted together, and a second round-trip would let
  573. // the code block paint unhighlighted source and then reflow it.
  574. expect(body.highlight).toBeTruthy();
  575. expect(body.highlight.classes).toEqual([
  576. 'other',
  577. 'ident',
  578. 'comment',
  579. 'string',
  580. 'keyword',
  581. 'number',
  582. ]);
  583. expect(body.highlight.lines).toHaveLength(body.lines.length);
  584. // Every line's tokens reproduce that line exactly — the code block renders
  585. // these, not the raw string.
  586. for (let i = 0; i < body.lines.length; i++) {
  587. const rebuilt = body.highlight.lines[i].map(([, text]: [number, string]) => text).join('');
  588. expect(rebuilt).toBe(body.lines[i]);
  589. }
  590. });
  591. it('refuses to slice a file that changed on disk after the last sync', async () => {
  592. const target = path.join(projectRoot, 'src', 'handler.ts');
  593. const original = fs.readFileSync(target);
  594. try {
  595. fs.writeFileSync(target, Buffer.concat([Buffer.from('// a new first line\n'), original]));
  596. const body = await getJson('/api/source?file=src/handler.ts&from=1&to=3');
  597. expect(body.drift).toBe(true);
  598. // The whole point: no slice, rather than a slice of the wrong lines.
  599. expect(body.lines).toBeUndefined();
  600. // And nothing to render it with either — a highlight with no source is
  601. // just a second way to draw the wrong lines.
  602. expect(body.highlight).toBeUndefined();
  603. expect(body.reason).toContain('changed on disk after the last index sync');
  604. // And every screen that renders indexed line ranges is told.
  605. const node = await getJson(`/api/node/${await idOf('handleRequest', 'function')}`);
  606. expect(node.drift).toBe(true);
  607. const file = await getJson('/api/file/src/handler.ts');
  608. expect(file.drift).toBe(true);
  609. } finally {
  610. fs.writeFileSync(target, original);
  611. }
  612. });
  613. it('does not call an identical rewrite drift', async () => {
  614. const target = path.join(projectRoot, 'src', 'handler.ts');
  615. const original = fs.readFileSync(target);
  616. // Same bytes, new mtime — what a checkout or a formatter no-op looks like.
  617. fs.writeFileSync(target, original);
  618. const body = await getJson('/api/source?file=src/handler.ts&from=1&to=2');
  619. expect(body.drift).toBe(false);
  620. expect(body.lines).toHaveLength(2);
  621. });
  622. it('keeps a CRLF file on the index line numbering, without the stray carriage returns', async () => {
  623. const node = await getJson(`/api/node/${await idOf('windowsStyle', 'function')}`);
  624. expect(node.node.file).toBe('src/crlf.ts');
  625. const body = await getJson('/api/source?file=src/crlf.ts');
  626. expect(body.drift).toBe(false);
  627. expect(body.totalLines).toBe(3);
  628. expect(body.lines).toEqual([
  629. 'export function windowsStyle(n: number): number {',
  630. ' return n + 1;',
  631. '}',
  632. ]);
  633. expect(body.lines.some((l: string) => l.includes('\r'))).toBe(false);
  634. // The symbol's indexed range still names its own body.
  635. const slice = await getJson(
  636. `/api/source?file=src/crlf.ts&from=${node.node.line}&to=${node.node.endLine}`
  637. );
  638. expect(slice.lines[0]).toContain('windowsStyle');
  639. });
  640. it('refuses a path that escapes the project', async () => {
  641. const traversal = await getStatusAndJson(
  642. '/api/source?file=' + encodeURIComponent('../../../etc/passwd')
  643. );
  644. expect(traversal.status).toBe(403);
  645. expect(traversal.body.code).toBe('refused');
  646. const absolute = await getStatusAndJson(
  647. '/api/source?file=' + encodeURIComponent('/etc/passwd')
  648. );
  649. expect(absolute.status).toBe(403);
  650. expect(absolute.body.code).toBe('refused');
  651. expect(absolute.body.error).toContain('absolute');
  652. });
  653. it('refuses a NUL byte in the path', async () => {
  654. const { status, body } = await getStatusAndJson(
  655. '/api/source?file=' + encodeURIComponent('src/cache.ts\u0000.png')
  656. );
  657. expect(status).toBe(403);
  658. expect(body.code).toBe('refused');
  659. });
  660. it('404s a file that exists but is not indexed', async () => {
  661. fs.writeFileSync(path.join(projectRoot, 'notes.md'), '# not indexed\n');
  662. const { status, body } = await getStatusAndJson('/api/source?file=notes.md');
  663. expect(status).toBe(404);
  664. expect(body.code).toBe('not-found');
  665. expect(body.hint).toContain('index');
  666. });
  667. it('rejects a range that names nothing', async () => {
  668. const past = await getStatusAndJson('/api/source?file=src/handler.ts&from=99999');
  669. expect(past.status).toBe(400);
  670. expect(past.body.error).toContain('past the end');
  671. const backwards = await getStatusAndJson('/api/source?file=src/handler.ts&from=10&to=4');
  672. expect(backwards.status).toBe(400);
  673. const nonNumeric = await getStatusAndJson('/api/source?file=src/handler.ts&from=abc');
  674. expect(nonNumeric.status).toBe(400);
  675. });
  676. });
  677. describe('GET /api/file/<path>', () => {
  678. it('returns the file record and its outline in source order', async () => {
  679. const body = await getJson('/api/file/src/cache.ts');
  680. expect(body.file.path).toBe('src/cache.ts');
  681. expect(body.file.language).toBe('typescript');
  682. expect(body.file.size).toBeGreaterThan(0);
  683. expect(body.file.contentHash).toMatch(/^[0-9a-f]{64}$/);
  684. expect(body.file.generated).toBe(false);
  685. expect(body.file.test).toBe(false);
  686. expect(body.file.id).toMatch(/^file:/);
  687. expect(body.drift).toBe(false);
  688. const lines = body.outline.items.map((o: any) => o.line);
  689. expect(lines).toEqual([...lines].sort((a, b) => a - b));
  690. const cacheRow = body.outline.items.find((o: any) => o.name === 'Cache');
  691. expect(cacheRow.depth).toBe(0);
  692. expect(cacheRow.parentId).toBeNull();
  693. const readRow = body.outline.items.find((o: any) => o.name === 'read');
  694. expect(readRow.depth).toBe(1);
  695. expect(readRow.parentId).toBe(cacheRow.id);
  696. expect(readRow.fanIn).toBeGreaterThanOrEqual(1);
  697. expect(typeof readRow.fanOut).toBe('number');
  698. // The file node is the subject, not a row; imports have their own rail.
  699. expect(body.outline.items.some((o: any) => o.kind === 'file')).toBe(false);
  700. expect(body.outline.items.some((o: any) => o.kind === 'import')).toBe(false);
  701. });
  702. it('maps imports and imported-by to files', async () => {
  703. const body = await getJson('/api/file/src/cache.ts');
  704. const importedByFiles = body.importedBy.items.map((r: any) => r.file);
  705. expect(importedByFiles).toContain('src/service.ts');
  706. const importFiles = body.imports.items.map((r: any) => r.file);
  707. expect(importFiles).toContain('src/types.ts');
  708. // Never itself: same-file `imports` edges (the import declarations) are dropped.
  709. expect(importFiles).not.toContain('src/cache.ts');
  710. expect(importedByFiles).not.toContain('src/cache.ts');
  711. const typesRow = body.imports.items.find((r: any) => r.file === 'src/types.ts');
  712. expect(typesRow.symbolCount).toBeGreaterThanOrEqual(1);
  713. expect(typesRow.symbols[0].name).toBeTruthy();
  714. expect(typesRow.symbols[0].id).toBeTruthy();
  715. expect(typesRow.test).toBe(false);
  716. });
  717. it('names the imports that never resolved rather than dropping them', async () => {
  718. const body = await getJson('/api/file/src/service.ts');
  719. const names = body.unresolvedImports.map((u: any) => u.name);
  720. expect(names).toContain('some-external-package');
  721. });
  722. it('reports the wider cross-file relationship too', async () => {
  723. const body = await getJson('/api/file/src/cache.ts');
  724. expect(body.dependents).toContain('src/service.ts');
  725. expect(body.dependencies).toContain('src/types.ts');
  726. });
  727. it('says whether the file runs anything at its top level', async () => {
  728. // `src/main.ts` instantiates a Service and calls two functions outside
  729. // every definition — code no outline row can show, because it belongs to
  730. // no symbol. `src/cache.ts` only defines things.
  731. const main = await getJson('/api/file/src/main.ts');
  732. expect(main.topLevel.calls).toBeGreaterThanOrEqual(2);
  733. const cache = await getJson('/api/file/src/cache.ts');
  734. expect(cache.topLevel.calls).toBe(0);
  735. });
  736. it('404s a file that is not in the index and refuses one outside the project', async () => {
  737. const missing = await getStatusAndJson('/api/file/src/nope.ts');
  738. expect(missing.status).toBe(404);
  739. expect(missing.body.code).toBe('not-found');
  740. const outside = await getStatusAndJson(
  741. '/api/file/' + encodeURIComponent('/etc/passwd')
  742. );
  743. expect(outside.status).toBe(403);
  744. expect(outside.body.code).toBe('refused');
  745. });
  746. });
  747. describe('GET /api/routes', () => {
  748. it('says plainly that this project is not a routed app', async () => {
  749. const body = await getJson('/api/routes');
  750. expect(body.routed).toBe(false);
  751. expect(body.entries).toEqual([]);
  752. expect(body.routeCount).toBe(0);
  753. expect(body.shown).toBe(0);
  754. expect(body.truncated).toBe(false);
  755. });
  756. it('refuses a limit the manifest cannot answer truthfully', async () => {
  757. // Below three, the engine's manifest reports every routed project as
  758. // unrouted — a wrong answer, so the parameter is refused instead.
  759. for (const limit of ['0', '2', '-1', 'abc']) {
  760. const { status, body } = await getStatusAndJson(`/api/routes?limit=${limit}`);
  761. expect(status, `limit=${limit}`).toBe(400);
  762. expect(body.code).toBe('bad-request');
  763. }
  764. });
  765. describe('a project that IS routed', () => {
  766. let routedApi: GraphApi;
  767. let routedServer: UiServerHandle;
  768. beforeAll(async () => {
  769. const routedRoot = path.join(tempDir, 'routed');
  770. fs.mkdirSync(path.join(routedRoot, 'src'), { recursive: true });
  771. fs.writeFileSync(
  772. path.join(routedRoot, 'src', 'routes.ts'),
  773. `import express from 'express';
  774. const app = express();
  775. export function listUsers(req: any, res: any): void { res.json([]); }
  776. export function getUser(req: any, res: any): void { res.json({}); }
  777. export function createUser(req: any, res: any): void { res.json({}); }
  778. export function deleteUser(req: any, res: any): void { res.json({}); }
  779. app.get('/users', listUsers);
  780. app.get('/users/:id', getUser);
  781. app.post('/users', createUser);
  782. app.delete('/users/:id', deleteUser);
  783. export default app;
  784. `
  785. );
  786. const routedCg = CodeGraph.initSync(routedRoot, {
  787. config: { include: ['src/**/*.ts'], exclude: [] },
  788. });
  789. await routedCg.indexAll();
  790. routedCg.resolveReferences();
  791. routedCg.close();
  792. routedApi = createGraphApi({ projectRoot: routedRoot });
  793. routedServer = await startUiServer({
  794. projectRoot: routedRoot,
  795. viewerDir,
  796. port: 0,
  797. api: routedApi.handler,
  798. });
  799. }, 120_000);
  800. afterAll(async () => {
  801. routedApi?.close();
  802. await routedServer?.close();
  803. });
  804. it('maps each URL to its handler, with a node id to navigate to', async () => {
  805. const res = await requestOn(routedServer.port, '/api/routes');
  806. const body = JSON.parse(res.body);
  807. expect(body.routed).toBe(true);
  808. expect(body.routeCount).toBe(4);
  809. expect(body.shown).toBe(4);
  810. expect(body.truncated).toBe(false);
  811. expect(body.topHandlerFile).toBe('src/routes.ts');
  812. expect(body.topHandlerFileCount).toBe(4);
  813. const urls = body.entries.map((e: any) => e.url);
  814. expect(urls).toEqual(
  815. expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
  816. );
  817. const listUsers = body.entries.find((e: any) => e.url === 'GET /users');
  818. expect(listUsers.handler).toBe('listUsers');
  819. expect(listUsers.handlerKind).toBe('function');
  820. expect(listUsers.file).toBe('src/routes.ts');
  821. expect(listUsers.line).toBeGreaterThan(0);
  822. // The manifest carries no ids of its own; resolving them is what makes a
  823. // route row clickable, so it has to actually resolve.
  824. expect(listUsers.handlerId).toBeTruthy();
  825. const handler = JSON.parse(
  826. (await requestOn(routedServer.port, `/api/node/${listUsers.handlerId}`)).body
  827. );
  828. expect(handler.node.name).toBe('listUsers');
  829. });
  830. it('offers its routes as entry points, ahead of anything derived', async () => {
  831. const res = await requestOn(routedServer.port, '/api/entrypoints');
  832. const body = JSON.parse(res.body);
  833. expect(body.routes.routed).toBe(true);
  834. expect(body.routes.routeCount).toBe(4);
  835. const urls = body.routes.items.items.map((e: any) => e.url);
  836. expect(urls).toEqual(
  837. expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
  838. );
  839. // A route row has to be navigable, or it is a label.
  840. expect(body.routes.items.items.every((e: any) => e.handlerId)).toBe(true);
  841. });
  842. it('honours the limit and says when it cut the list', async () => {
  843. const res = await requestOn(routedServer.port, '/api/routes?limit=3');
  844. const body = JSON.parse(res.body);
  845. expect(body.routed).toBe(true);
  846. expect(body.entries).toHaveLength(3);
  847. expect(body.shown).toBe(3);
  848. expect(body.truncated).toBe(true);
  849. // The headline count is the whole graph's, not the page's.
  850. expect(body.routeCount).toBe(4);
  851. });
  852. });
  853. });
  854. /**
  855. * The acceptance bar from the issue, against the engine's OWN index rather than
  856. * a fixture: `LRUCache.get` in `src/resolution/lru-cache.ts`, 500+ callers.
  857. *
  858. * `.codegraph/` is gitignored, so this only runs on a machine that has indexed
  859. * this repository. The fixture test above covers the same properties in CI; this
  860. * one is the check against the real, messy graph the number came from.
  861. */
  862. describe.runIf(CodeGraph.isInitialized(path.resolve(__dirname, '..')))(
  863. "the engine's own busiest symbol",
  864. () => {
  865. const repoRoot = path.resolve(__dirname, '..');
  866. let repoApi: GraphApi;
  867. let repoServer: UiServerHandle;
  868. beforeAll(async () => {
  869. repoApi = createGraphApi({ projectRoot: repoRoot });
  870. repoServer = await startUiServer({
  871. projectRoot: repoRoot,
  872. viewerDir,
  873. port: 0,
  874. api: repoApi.handler,
  875. });
  876. });
  877. afterAll(async () => {
  878. repoApi?.close();
  879. await repoServer?.close();
  880. });
  881. const repoGet = (requestPath: string): Promise<Response> =>
  882. requestOn(repoServer.port, requestPath);
  883. it('answers in under 100 ms with grouped, capped lists and correct counts', async () => {
  884. const search = JSON.parse(
  885. (await repoGet('/api/search?q=' + encodeURIComponent('LRUCache.get'))).body
  886. );
  887. const hit = search.results.items.find(
  888. (r: any) => r.name === 'get' && r.file.endsWith('src/resolution/lru-cache.ts')
  889. );
  890. expect(hit, 'LRUCache.get should be in the engine\'s own index').toBeTruthy();
  891. await repoGet(`/api/node/${hit.id}`); // warm
  892. const started = performance.now();
  893. const res = await repoGet(`/api/node/${hit.id}`);
  894. const elapsed = performance.now() - started;
  895. expect(res.status).toBe(200);
  896. const body = JSON.parse(res.body);
  897. expect(body.counts.fanIn).toBeGreaterThanOrEqual(500);
  898. expect(body.counts.hub).toBe(true);
  899. // Grouped by calling symbol, so the row count is the distinct-caller
  900. // count, never the edge count.
  901. expect(body.incoming.items).toHaveLength(body.incoming.shown);
  902. expect(body.incoming.shown).toBeLessThanOrEqual(300);
  903. expect(body.incoming.shown).toBe(Math.min(300, body.incoming.total));
  904. expect(body.incoming.truncated).toBe(body.incoming.total > 300);
  905. expect(new Set(body.incoming.items.map((r: any) => r.node.id)).size).toBe(
  906. body.incoming.shown
  907. );
  908. const edgesInRows = body.incoming.items.reduce(
  909. (sum: number, r: any) => sum + r.edgeCount,
  910. 0
  911. );
  912. expect(edgesInRows).toBeLessThanOrEqual(body.counts.fanIn);
  913. expect(body.blast.direct).toBe(body.counts.callers);
  914. expect(body.tests.reached).toBe(true);
  915. expect(elapsed).toBeLessThan(100);
  916. });
  917. }
  918. );
  919. describe('GET /api/entrypoints', () => {
  920. it('finds the file that runs something, and reports what it reaches', async () => {
  921. const body = await getJson('/api/entrypoints');
  922. const files = body.files.items.map((f: any) => f.file);
  923. expect(files).toContain('src/main.ts');
  924. const main = body.files.items.find((f: any) => f.file === 'src/main.ts');
  925. expect(main.kind).toBe('file');
  926. expect(main.id).toMatch(/^file:/);
  927. // `new Service(...)`, `handleRequest(...)` and `service.load(...)` all sit
  928. // at module level.
  929. expect(main.calls).toBeGreaterThanOrEqual(2);
  930. // It imports from service.ts and handler.ts, so it wires files together.
  931. expect(main.reaches).toBeGreaterThanOrEqual(2);
  932. expect(typeof main.dependents).toBe('number');
  933. });
  934. it('leaves test files out — "where do I start" never means a test', async () => {
  935. const body = await getJson('/api/entrypoints');
  936. for (const file of body.files.items) expect(file.test).toBe(false);
  937. // The fixture's test file calls its own helper at module level, so it IS a
  938. // candidate by the raw graph signal and is excluded deliberately.
  939. expect(body.files.items.map((f: any) => f.file)).not.toContain(
  940. '__tests__/service.test.ts'
  941. );
  942. for (const hub of body.hubs.items) expect(hub.test).toBe(false);
  943. });
  944. it('ranks the most depended-on symbols as hubs, with their dependent counts', async () => {
  945. const body = await getJson('/api/entrypoints');
  946. const hot = body.hubs.items.find((h: any) => h.name === 'hot');
  947. expect(hot, 'the 500-caller function should top the hubs').toBeTruthy();
  948. expect(hot.dependents).toBe(500);
  949. expect(body.hubs.items[0].name).toBe('hot');
  950. const counts = body.hubs.items.map((h: any) => h.dependents);
  951. expect(counts).toEqual([...counts].sort((a: number, b: number) => b - a));
  952. // A file or a bare import is structure, not somewhere to start reading.
  953. for (const hub of body.hubs.items) {
  954. expect(['file', 'import', 'export', 'parameter']).not.toContain(hub.kind);
  955. }
  956. });
  957. it('says a project without routes is not routed rather than failing', async () => {
  958. const body = await getJson('/api/entrypoints');
  959. expect(body.routes.routed).toBe(false);
  960. expect(body.routes.items.items).toEqual([]);
  961. expect(body.routes.routeCount).toBe(0);
  962. });
  963. it('honours limit, and keeps every list within it', async () => {
  964. const body = await getJson('/api/entrypoints?limit=1');
  965. expect(body.files.items.length).toBeLessThanOrEqual(1);
  966. expect(body.hubs.items.length).toBe(1);
  967. expect(body.hubs.total).toBeGreaterThanOrEqual(body.hubs.items.length);
  968. const bad = await getStatusAndJson('/api/entrypoints?limit=0');
  969. expect(bad.status).toBe(400);
  970. expect(bad.body.code).toBe('bad-request');
  971. });
  972. });
  973. describe('GET /api/nodes', () => {
  974. it('answers a batch of ids in the order asked, and says which are missing', async () => {
  975. const cacheId = await idOf('Cache', 'class');
  976. const loadId = await idOf('load', 'method');
  977. const body = await getJson(
  978. `/api/nodes?id=${encodeURIComponent(loadId)}&id=${encodeURIComponent(cacheId)}&id=method%3Anot-a-real-id`
  979. );
  980. expect(body.items.map((n: any) => n.id)).toEqual([loadId, cacheId]);
  981. expect(body.items[0].name).toBe('load');
  982. expect(body.items[1].name).toBe('Cache');
  983. expect(body.missing).toEqual(['method:not-a-real-id']);
  984. // The REF shape, not the Symbol view payload: a trail redraws six names,
  985. // not six rail sets.
  986. expect(body.items[0].incoming).toBeUndefined();
  987. expect(body.items[0].file).toBe('src/service.ts');
  988. });
  989. it('de-duplicates ids rather than answering twice', async () => {
  990. const cacheId = await idOf('Cache', 'class');
  991. const encoded = encodeURIComponent(cacheId);
  992. const body = await getJson(`/api/nodes?id=${encoded}&id=${encoded}`);
  993. expect(body.items).toHaveLength(1);
  994. });
  995. it('refuses an empty or oversized request with guidance', async () => {
  996. const none = await getStatusAndJson('/api/nodes');
  997. expect(none.status).toBe(400);
  998. expect(none.body.hint).toContain('id=');
  999. const ids = Array.from({ length: 61 }, (_, i) => `id=method%3A${i}`).join('&');
  1000. const many = await getStatusAndJson(`/api/nodes?${ids}`);
  1001. expect(many.status).toBe(400);
  1002. expect(many.body.error).toContain('Too many ids');
  1003. });
  1004. });
  1005. describe('an index that is not there', () => {
  1006. it('answers with the same guidance the CLI prints, not a stack trace', async () => {
  1007. const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-noindex-'));
  1008. const detached = createGraphApi({ projectRoot: emptyRoot });
  1009. const detachedServer = await startUiServer({
  1010. projectRoot: emptyRoot,
  1011. viewerDir,
  1012. port: 0,
  1013. api: detached.handler,
  1014. });
  1015. try {
  1016. const res = await requestOn(detachedServer.port, '/api/stats');
  1017. expect(res.status).toBe(503);
  1018. const body = JSON.parse(res.body);
  1019. expect(body.code).toBe('no-index');
  1020. expect(body.error).toContain('No CodeGraph index found');
  1021. expect(body.hint).toContain('codegraph init');
  1022. expect(body.error).not.toContain(' at ');
  1023. } finally {
  1024. detached.close();
  1025. await detachedServer.close();
  1026. fs.rmSync(emptyRoot, { recursive: true, force: true });
  1027. }
  1028. });
  1029. });