| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- /**
- * `GET /api/filecode` — everything the whole-file view draws (CG-52).
- *
- * Against a real indexed fixture over a real loopback server, like the rest of
- * the viewer's API suite. The fixture is shaped around the four claims this
- * endpoint makes that a hand-written payload could not prove:
- *
- * - a call group is one (CALLER, CALLEE) pair, not one per callee — the same
- * helper reached from two functions has to come back as two rows, because a
- * row is anchored to a line and there is no line that is both,
- * - `intraFileCalls` counts exactly the arcs the viewer can draw from `calls`,
- * so the header and the picture under it cannot disagree,
- * - top-level code has an owner (the file node), which is the only way a
- * statement outside every definition gets a port at all,
- * - a reference that resolves to nothing still comes back, so a line calling a
- * runtime builtin shows a hollow port instead of an empty gutter.
- *
- * The pure geometry is tested without a server in `ui-filecode-model.test.ts`.
- */
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
- import * as http from 'http';
- import * as fs from 'fs';
- import * as os from 'os';
- import * as path from 'path';
- import CodeGraph from '../src/index';
- import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
- import { MAX_FILE_CALL_GROUPS, MAX_FILE_OUTSIDE_REFS } from '../src/ui-server/api/filecode';
- let server: UiServerHandle;
- let api: GraphApi;
- let tempDir: string;
- let projectRoot: string;
- function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
- return new Promise((resolve, reject) => {
- const req = http.request(
- {
- host: '127.0.0.1',
- port: server.port,
- path: requestPath,
- method: 'GET',
- headers: { Host: `127.0.0.1:${server.port}` },
- setHost: false,
- },
- (res) => {
- const chunks: Buffer[] = [];
- res.on('data', (c: Buffer) => chunks.push(c));
- res.on('end', () =>
- resolve({
- status: res.statusCode ?? 0,
- body: Buffer.concat(chunks).toString('utf-8'),
- type: res.headers['content-type'],
- })
- );
- }
- );
- req.on('error', reject);
- req.end();
- });
- }
- async function getCode(file: string, expected = 200): Promise<any> {
- const res = await request(`/api/filecode/${file}`);
- expect(res.type).toBe('application/json; charset=utf-8');
- expect(res.status).toBe(expected);
- return JSON.parse(res.body);
- }
- function write(root: string, rel: string, body: string): void {
- const full = path.join(root, rel);
- fs.mkdirSync(path.dirname(full), { recursive: true });
- fs.writeFileSync(full, body);
- }
- /** Rows as `caller -> callee`, which is how the rail reads. */
- function pairs(payload: any): string[] {
- const names = new Map<string, string>(
- payload.outline.items.map((e: any) => [e.id, e.name] as [string, string])
- );
- return payload.calls.items.map(
- (c: any) => `${names.get(c.ownerId) ?? 'file'} -> ${c.relation.node.name}`
- );
- }
- beforeAll(async () => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-filecode-'));
- projectRoot = path.join(tempDir, 'project');
- // `format` is called by TWO functions in this file and by one in another, and
- // `render` calls it twice from two different lines — every grouping case in
- // one file.
- write(
- projectRoot,
- 'src/report.ts',
- `import { widen } from './widen';
- export function format(value: string): string {
- return value.trim();
- }
- export function render(a: string, b: string): string {
- const left = format(a);
- const right = format(b);
- return left + right;
- }
- export function summarise(rows: string[]): string {
- const head = format(rows[0] ?? '');
- console.log(head);
- return widen(head);
- }
- render('a', 'b');
- `
- );
- write(
- projectRoot,
- 'src/widen.ts',
- `export function widen(text: string): string {
- return text + ' ';
- }
- `
- );
- // Nothing in it reaches anything: the empty-rail, no-arc case.
- write(projectRoot, 'src/quiet.ts', `export const NAME = 'quiet';\n`);
- const cg = CodeGraph.initSync(projectRoot, {
- config: { include: ['src/**/*.ts'], exclude: [] },
- });
- await cg.indexAll();
- cg.resolveReferences();
- cg.close();
- const viewerDir = path.join(tempDir, 'viewer');
- fs.mkdirSync(viewerDir, { recursive: true });
- fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
- api = createGraphApi({ projectRoot });
- server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
- }, 120_000);
- afterAll(async () => {
- api?.close();
- await server?.close();
- if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
- });
- describe('GET /api/filecode', () => {
- it('describes the file and its length, which is the view\'s layout', async () => {
- const payload = await getCode('src/report.ts');
- expect(payload.file.path).toBe('src/report.ts');
- expect(payload.file.language).toBe('typescript');
- expect(payload.file.id).toBe('file:src/report.ts');
- expect(payload.drift).toBe(false);
- // The count comes from disk, not from the index: it is the height of the
- // scrolling document, and the source itself is paged in separately.
- const onDisk = fs.readFileSync(path.join(projectRoot, 'src/report.ts'), 'utf-8');
- expect(payload.file.totalLines).toBe(onDisk.replace(/\n$/, '').split('\n').length);
- });
- it('returns the same outline rows the File view draws', async () => {
- const code = await getCode('src/report.ts');
- const file = JSON.parse((await request('/api/file/src/report.ts')).body);
- expect(code.outline.total).toBe(file.outline.total);
- expect(code.outline.items.map((e: any) => e.name)).toEqual(
- file.outline.items.map((e: any) => e.name)
- );
- // A rail that disagreed with the source beside it would be worse than none.
- for (const entry of code.outline.items) {
- expect(entry.line).toBeGreaterThan(0);
- expect(entry.endLine).toBeGreaterThanOrEqual(entry.line);
- }
- });
- it('groups by the PAIR, so one callee reached from two functions is two rows', async () => {
- const payload = await getCode('src/report.ts');
- const rows = pairs(payload);
- expect(rows).toContain('render -> format');
- expect(rows).toContain('summarise -> format');
- // …and the two lines `render` calls it from stay ONE row, with both lines.
- const renderRow = payload.calls.items.find(
- (c: any) =>
- c.relation.node.name === 'format' &&
- payload.outline.items.find((e: any) => e.id === c.ownerId)?.name === 'render'
- );
- expect(renderRow.relation.lines.length).toBe(2);
- expect(renderRow.relation.lines[0]).toBeLessThan(renderRow.relation.lines[1]);
- });
- it('rows are in call-site order — the only ordering the screen has', async () => {
- const payload = await getCode('src/report.ts');
- const firstLines = payload.calls.items.map((c: any) => c.relation.lines[0] ?? Infinity);
- const sorted = [...firstLines].sort((a: number, b: number) => a - b);
- expect(firstLines).toEqual(sorted);
- });
- it('gives top-level code an owner, so a statement outside every definition has a port', async () => {
- const payload = await getCode('src/report.ts');
- const topLevel = payload.calls.items.filter((c: any) => c.ownerId === payload.file.id);
- // `render('a', 'b')` at the bottom of the file belongs to no symbol.
- expect(topLevel.map((c: any) => c.relation.node.name)).toContain('render');
- });
- it('counts exactly the arcs the payload can draw', async () => {
- const payload = await getCode('src/report.ts');
- // Recompute the arc list the way the viewer does, from `calls` alone.
- let arcs = 0;
- for (const call of payload.calls.items) {
- if (call.relation.node.file !== payload.file.path) continue;
- for (const line of call.relation.lines) {
- if (line !== call.relation.node.line) arcs++;
- }
- }
- expect(payload.intraFileCalls).toBe(arcs);
- // render x2, summarise x1, top-level render x1 — every call that stays home.
- expect(payload.intraFileCalls).toBeGreaterThanOrEqual(4);
- });
- it('does not count a cross-file call as an arc', async () => {
- const payload = await getCode('src/report.ts');
- const widen = payload.calls.items.find((c: any) => c.relation.node.name === 'widen');
- expect(widen).toBeDefined();
- expect(widen.relation.node.file).toBe('src/widen.ts');
- });
- it('returns references that resolved to nothing, with a line and a plain name', async () => {
- const payload = await getCode('src/report.ts');
- const names = payload.outside.items.map((r: any) => r.name);
- // `console.log` reaches a runtime builtin; the gutter must still show it.
- expect(names).toContain('log');
- for (const ref of payload.outside.items) {
- expect(ref.line).toBeGreaterThan(0);
- expect(ref.name).toMatch(/^[A-Za-z_$][\w$]*$/);
- }
- expect(payload.outside.total).toBe(payload.outside.items.length);
- expect(payload.outside.shown).toBeLessThanOrEqual(MAX_FILE_OUTSIDE_REFS);
- });
- it('answers for a file that reaches nothing without inventing rows', async () => {
- const payload = await getCode('src/quiet.ts');
- expect(payload.calls.total).toBe(0);
- expect(payload.calls.items).toEqual([]);
- expect(payload.intraFileCalls).toBe(0);
- expect(payload.file.totalLines).toBe(1);
- });
- it('every capped list still reports its real total', async () => {
- const payload = await getCode('src/report.ts');
- for (const list of [payload.outline, payload.calls, payload.outside]) {
- expect(list.shown).toBe(list.items.length);
- expect(list.total).toBeGreaterThanOrEqual(list.shown);
- expect(list.truncated).toBe(list.shown < list.total);
- }
- expect(payload.calls.shown).toBeLessThanOrEqual(MAX_FILE_CALL_GROUPS);
- });
- it('refuses a path outside the project before it looks in the index', async () => {
- // The chokepoint answers "outside the project", not "not indexed" — the
- // order is what makes that true by construction. See `resolveRequestedFile`.
- const res = await request('/api/filecode//etc/passwd');
- expect(res.status).toBe(403);
- expect(JSON.parse(res.body).code).toBe('refused');
- });
- it('answers 404 for a file that is fine but not indexed', async () => {
- const payload = await getCode('src/nope.ts', 404);
- expect(payload.code).toBe('not-found');
- expect(payload.error).toMatch(/not in this CodeGraph index/);
- });
- it('says what the endpoint wants when given no path', async () => {
- const res = await request('/api/filecode');
- expect(res.status).toBe(400);
- expect(JSON.parse(res.body).error).toMatch(/\/api\/filecode\/<path>/);
- });
- it('is listed on the API index', async () => {
- const body = JSON.parse((await request('/api')).body);
- expect(body.endpoints.find((e: any) => e.path === '/api/filecode/<path>')).toBeDefined();
- // The shorter route must still resolve to the File view's own endpoint.
- expect(body.endpoints.find((e: any) => e.path === '/api/file/<path>')).toBeDefined();
- });
- });
- describe('drift', () => {
- it('flags a file that changed on disk and withholds its length', async () => {
- const file = path.join(projectRoot, 'src/widen.ts');
- const original = fs.readFileSync(file, 'utf-8');
- try {
- fs.writeFileSync(file, `// a new first line\n${original}`);
- const payload = await getCode('src/widen.ts');
- expect(payload.drift).toBe(true);
- expect(payload.reason).toMatch(/changed on disk/);
- // The rows are still true about the graph; only the line numbers are not,
- // which is exactly why the view draws the banner instead of the source.
- expect(payload.outline.total).toBeGreaterThan(0);
- } finally {
- fs.writeFileSync(file, original);
- }
- });
- });
|