ui-filecode-api.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /**
  2. * `GET /api/filecode` — everything the whole-file view draws (CG-52).
  3. *
  4. * Against a real indexed fixture over a real loopback server, like the rest of
  5. * the viewer's API suite. The fixture is shaped around the four claims this
  6. * endpoint makes that a hand-written payload could not prove:
  7. *
  8. * - a call group is one (CALLER, CALLEE) pair, not one per callee — the same
  9. * helper reached from two functions has to come back as two rows, because a
  10. * row is anchored to a line and there is no line that is both,
  11. * - `intraFileCalls` counts exactly the arcs the viewer can draw from `calls`,
  12. * so the header and the picture under it cannot disagree,
  13. * - top-level code has an owner (the file node), which is the only way a
  14. * statement outside every definition gets a port at all,
  15. * - a reference that resolves to nothing still comes back, so a line calling a
  16. * runtime builtin shows a hollow port instead of an empty gutter.
  17. *
  18. * The pure geometry is tested without a server in `ui-filecode-model.test.ts`.
  19. */
  20. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  21. import * as http from 'http';
  22. import * as fs from 'fs';
  23. import * as os from 'os';
  24. import * as path from 'path';
  25. import CodeGraph from '../src/index';
  26. import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
  27. import { MAX_FILE_CALL_GROUPS, MAX_FILE_OUTSIDE_REFS } from '../src/ui-server/api/filecode';
  28. let server: UiServerHandle;
  29. let api: GraphApi;
  30. let tempDir: string;
  31. let projectRoot: string;
  32. function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
  33. return new Promise((resolve, reject) => {
  34. const req = http.request(
  35. {
  36. host: '127.0.0.1',
  37. port: server.port,
  38. path: requestPath,
  39. method: 'GET',
  40. headers: { Host: `127.0.0.1:${server.port}` },
  41. setHost: false,
  42. },
  43. (res) => {
  44. const chunks: Buffer[] = [];
  45. res.on('data', (c: Buffer) => chunks.push(c));
  46. res.on('end', () =>
  47. resolve({
  48. status: res.statusCode ?? 0,
  49. body: Buffer.concat(chunks).toString('utf-8'),
  50. type: res.headers['content-type'],
  51. })
  52. );
  53. }
  54. );
  55. req.on('error', reject);
  56. req.end();
  57. });
  58. }
  59. async function getCode(file: string, expected = 200): Promise<any> {
  60. const res = await request(`/api/filecode/${file}`);
  61. expect(res.type).toBe('application/json; charset=utf-8');
  62. expect(res.status).toBe(expected);
  63. return JSON.parse(res.body);
  64. }
  65. function write(root: string, rel: string, body: string): void {
  66. const full = path.join(root, rel);
  67. fs.mkdirSync(path.dirname(full), { recursive: true });
  68. fs.writeFileSync(full, body);
  69. }
  70. /** Rows as `caller -> callee`, which is how the rail reads. */
  71. function pairs(payload: any): string[] {
  72. const names = new Map<string, string>(
  73. payload.outline.items.map((e: any) => [e.id, e.name] as [string, string])
  74. );
  75. return payload.calls.items.map(
  76. (c: any) => `${names.get(c.ownerId) ?? 'file'} -> ${c.relation.node.name}`
  77. );
  78. }
  79. beforeAll(async () => {
  80. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-filecode-'));
  81. projectRoot = path.join(tempDir, 'project');
  82. // `format` is called by TWO functions in this file and by one in another, and
  83. // `render` calls it twice from two different lines — every grouping case in
  84. // one file.
  85. write(
  86. projectRoot,
  87. 'src/report.ts',
  88. `import { widen } from './widen';
  89. export function format(value: string): string {
  90. return value.trim();
  91. }
  92. export function render(a: string, b: string): string {
  93. const left = format(a);
  94. const right = format(b);
  95. return left + right;
  96. }
  97. export function summarise(rows: string[]): string {
  98. const head = format(rows[0] ?? '');
  99. console.log(head);
  100. return widen(head);
  101. }
  102. render('a', 'b');
  103. `
  104. );
  105. write(
  106. projectRoot,
  107. 'src/widen.ts',
  108. `export function widen(text: string): string {
  109. return text + ' ';
  110. }
  111. `
  112. );
  113. // Nothing in it reaches anything: the empty-rail, no-arc case.
  114. write(projectRoot, 'src/quiet.ts', `export const NAME = 'quiet';\n`);
  115. const cg = CodeGraph.initSync(projectRoot, {
  116. config: { include: ['src/**/*.ts'], exclude: [] },
  117. });
  118. await cg.indexAll();
  119. cg.resolveReferences();
  120. cg.close();
  121. const viewerDir = path.join(tempDir, 'viewer');
  122. fs.mkdirSync(viewerDir, { recursive: true });
  123. fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
  124. api = createGraphApi({ projectRoot });
  125. server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
  126. }, 120_000);
  127. afterAll(async () => {
  128. api?.close();
  129. await server?.close();
  130. if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  131. });
  132. describe('GET /api/filecode', () => {
  133. it('describes the file and its length, which is the view\'s layout', async () => {
  134. const payload = await getCode('src/report.ts');
  135. expect(payload.file.path).toBe('src/report.ts');
  136. expect(payload.file.language).toBe('typescript');
  137. expect(payload.file.id).toBe('file:src/report.ts');
  138. expect(payload.drift).toBe(false);
  139. // The count comes from disk, not from the index: it is the height of the
  140. // scrolling document, and the source itself is paged in separately.
  141. const onDisk = fs.readFileSync(path.join(projectRoot, 'src/report.ts'), 'utf-8');
  142. expect(payload.file.totalLines).toBe(onDisk.replace(/\n$/, '').split('\n').length);
  143. });
  144. it('returns the same outline rows the File view draws', async () => {
  145. const code = await getCode('src/report.ts');
  146. const file = JSON.parse((await request('/api/file/src/report.ts')).body);
  147. expect(code.outline.total).toBe(file.outline.total);
  148. expect(code.outline.items.map((e: any) => e.name)).toEqual(
  149. file.outline.items.map((e: any) => e.name)
  150. );
  151. // A rail that disagreed with the source beside it would be worse than none.
  152. for (const entry of code.outline.items) {
  153. expect(entry.line).toBeGreaterThan(0);
  154. expect(entry.endLine).toBeGreaterThanOrEqual(entry.line);
  155. }
  156. });
  157. it('groups by the PAIR, so one callee reached from two functions is two rows', async () => {
  158. const payload = await getCode('src/report.ts');
  159. const rows = pairs(payload);
  160. expect(rows).toContain('render -> format');
  161. expect(rows).toContain('summarise -> format');
  162. // …and the two lines `render` calls it from stay ONE row, with both lines.
  163. const renderRow = payload.calls.items.find(
  164. (c: any) =>
  165. c.relation.node.name === 'format' &&
  166. payload.outline.items.find((e: any) => e.id === c.ownerId)?.name === 'render'
  167. );
  168. expect(renderRow.relation.lines.length).toBe(2);
  169. expect(renderRow.relation.lines[0]).toBeLessThan(renderRow.relation.lines[1]);
  170. });
  171. it('rows are in call-site order — the only ordering the screen has', async () => {
  172. const payload = await getCode('src/report.ts');
  173. const firstLines = payload.calls.items.map((c: any) => c.relation.lines[0] ?? Infinity);
  174. const sorted = [...firstLines].sort((a: number, b: number) => a - b);
  175. expect(firstLines).toEqual(sorted);
  176. });
  177. it('gives top-level code an owner, so a statement outside every definition has a port', async () => {
  178. const payload = await getCode('src/report.ts');
  179. const topLevel = payload.calls.items.filter((c: any) => c.ownerId === payload.file.id);
  180. // `render('a', 'b')` at the bottom of the file belongs to no symbol.
  181. expect(topLevel.map((c: any) => c.relation.node.name)).toContain('render');
  182. });
  183. it('counts exactly the arcs the payload can draw', async () => {
  184. const payload = await getCode('src/report.ts');
  185. // Recompute the arc list the way the viewer does, from `calls` alone.
  186. let arcs = 0;
  187. for (const call of payload.calls.items) {
  188. if (call.relation.node.file !== payload.file.path) continue;
  189. for (const line of call.relation.lines) {
  190. if (line !== call.relation.node.line) arcs++;
  191. }
  192. }
  193. expect(payload.intraFileCalls).toBe(arcs);
  194. // render x2, summarise x1, top-level render x1 — every call that stays home.
  195. expect(payload.intraFileCalls).toBeGreaterThanOrEqual(4);
  196. });
  197. it('does not count a cross-file call as an arc', async () => {
  198. const payload = await getCode('src/report.ts');
  199. const widen = payload.calls.items.find((c: any) => c.relation.node.name === 'widen');
  200. expect(widen).toBeDefined();
  201. expect(widen.relation.node.file).toBe('src/widen.ts');
  202. });
  203. it('returns references that resolved to nothing, with a line and a plain name', async () => {
  204. const payload = await getCode('src/report.ts');
  205. const names = payload.outside.items.map((r: any) => r.name);
  206. // `console.log` reaches a runtime builtin; the gutter must still show it.
  207. expect(names).toContain('log');
  208. for (const ref of payload.outside.items) {
  209. expect(ref.line).toBeGreaterThan(0);
  210. expect(ref.name).toMatch(/^[A-Za-z_$][\w$]*$/);
  211. }
  212. expect(payload.outside.total).toBe(payload.outside.items.length);
  213. expect(payload.outside.shown).toBeLessThanOrEqual(MAX_FILE_OUTSIDE_REFS);
  214. });
  215. it('answers for a file that reaches nothing without inventing rows', async () => {
  216. const payload = await getCode('src/quiet.ts');
  217. expect(payload.calls.total).toBe(0);
  218. expect(payload.calls.items).toEqual([]);
  219. expect(payload.intraFileCalls).toBe(0);
  220. expect(payload.file.totalLines).toBe(1);
  221. });
  222. it('every capped list still reports its real total', async () => {
  223. const payload = await getCode('src/report.ts');
  224. for (const list of [payload.outline, payload.calls, payload.outside]) {
  225. expect(list.shown).toBe(list.items.length);
  226. expect(list.total).toBeGreaterThanOrEqual(list.shown);
  227. expect(list.truncated).toBe(list.shown < list.total);
  228. }
  229. expect(payload.calls.shown).toBeLessThanOrEqual(MAX_FILE_CALL_GROUPS);
  230. });
  231. it('refuses a path outside the project before it looks in the index', async () => {
  232. // The chokepoint answers "outside the project", not "not indexed" — the
  233. // order is what makes that true by construction. See `resolveRequestedFile`.
  234. const res = await request('/api/filecode//etc/passwd');
  235. expect(res.status).toBe(403);
  236. expect(JSON.parse(res.body).code).toBe('refused');
  237. });
  238. it('answers 404 for a file that is fine but not indexed', async () => {
  239. const payload = await getCode('src/nope.ts', 404);
  240. expect(payload.code).toBe('not-found');
  241. expect(payload.error).toMatch(/not in this CodeGraph index/);
  242. });
  243. it('says what the endpoint wants when given no path', async () => {
  244. const res = await request('/api/filecode');
  245. expect(res.status).toBe(400);
  246. expect(JSON.parse(res.body).error).toMatch(/\/api\/filecode\/<path>/);
  247. });
  248. it('is listed on the API index', async () => {
  249. const body = JSON.parse((await request('/api')).body);
  250. expect(body.endpoints.find((e: any) => e.path === '/api/filecode/<path>')).toBeDefined();
  251. // The shorter route must still resolve to the File view's own endpoint.
  252. expect(body.endpoints.find((e: any) => e.path === '/api/file/<path>')).toBeDefined();
  253. });
  254. });
  255. describe('drift', () => {
  256. it('flags a file that changed on disk and withholds its length', async () => {
  257. const file = path.join(projectRoot, 'src/widen.ts');
  258. const original = fs.readFileSync(file, 'utf-8');
  259. try {
  260. fs.writeFileSync(file, `// a new first line\n${original}`);
  261. const payload = await getCode('src/widen.ts');
  262. expect(payload.drift).toBe(true);
  263. expect(payload.reason).toMatch(/changed on disk/);
  264. // The rows are still true about the graph; only the line numbers are not,
  265. // which is exactly why the view draws the banner instead of the source.
  266. expect(payload.outline.total).toBeGreaterThan(0);
  267. } finally {
  268. fs.writeFileSync(file, original);
  269. }
  270. });
  271. });