feat(ui): entry points — routes, executable files and tests as flow starting points (CG-54)
`#/entry` answers "where does anything start" at full length, and turns any row that names a symbol into a flow. Server. `/api/entrypoints` gains `frameworks` (from `getDetectedFrameworks`), a `tests` list, a `routes` limit of its own, and a cache keyed on the index build — nothing here is read from disk, so unlike `/api/source` a cached answer cannot be stale about drift. `routes.items` is now a `WireList` like every other list on the payload. Routes carry where the URL is REGISTERED as well as where it is served: `getRoutingManifest` selects the route node's id, file and line, and `buildRoutes` splits the verb off the name against a fixed list (never "the first word", which would take the head off a file-routed `/blog/[slug]`). All four payroll-go routes register in one router file and three are served from another — group by the handler file and one router becomes two groups plus an orphan. `isTestFile` is split into `isTestPath` (test filename and directory conventions) + the non-production catch-all, byte-identical at every existing call site. The Tests list uses the narrow half: an example, a benchmark or a fixture is off-target for ranking but is not a test, and a heading that says "Tests" must not quietly count them. Tests rank by REACH — distinct other files touched — because Go, Rust and Java put test work inside functions where a module-level-calls ranking sees nothing. Two read-only engine queries make that affordable: `getFileReachCounts` (the mirror of `getFileDependentCounts`, driven from `nodes` by path so the cost follows the files asked about rather than the edge table) and `getFileNodes`. Viewer. `ui/src/lib/entry-model.ts` folds the four lists into file groups — pure, and `panel.rows` stays exactly the sections it draws. `EntryView` + `EntrySection` render them with the caller rail's `.filegroup` / `.row` shapes rather than a second visual language for the same idea. A row that names a callable symbol carries a `Flow ›` chip; the other end is typed or picked with `→ here` on another row. File and test rows carry none: `/api/flow` searches by name, and a file has none the path finder can look up. A project with fewer than three resolvable routes gets no Routes heading at all, not an empty one. Typing into the search box now also returns matching entry points under their own heading below the symbol matches, so a URL comes back with its handler attached; rows already in the results are dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dc7f1e590e
commit
94f4e287e6
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* The entry-points panel's grouping, without a browser (CG-54).
|
||||
*
|
||||
* The half of `ui-entrypoints-api.test.ts` that needs no index: given a
|
||||
* payload, which rows exist, what they say, where they group, and which of them
|
||||
* can be clicked or turned into a flow. The rules worth pinning are the ones a
|
||||
* refactor would quietly break:
|
||||
*
|
||||
* - `panel.rows` is exactly the sections' rows in draw order (the same identity
|
||||
* the search palette rests its keyboard on).
|
||||
* - A route with no resolved handler still appears, but carries no target — a
|
||||
* row that looks clickable and is not is worse than a row that says so.
|
||||
* - Only a row that names a callable symbol offers a flow.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEntryPanel,
|
||||
directoryOf,
|
||||
flowPair,
|
||||
frameworkPhrase,
|
||||
groupRows,
|
||||
matchEntries,
|
||||
originLabel,
|
||||
routeRow,
|
||||
type EntryRow,
|
||||
} from '../ui/src/lib/entry-model';
|
||||
import type {
|
||||
WireEntryFile,
|
||||
WireEntryHub,
|
||||
WireEntryPoints,
|
||||
WireEntryRoute,
|
||||
WireEntryTest,
|
||||
WireNodeRef,
|
||||
} from '../ui/src/lib/api';
|
||||
|
||||
/* ------------------------------------------------------------- fixtures -- */
|
||||
|
||||
function ref(over: Partial<WireNodeRef> = {}): WireNodeRef {
|
||||
return {
|
||||
id: 'function:x',
|
||||
name: 'x',
|
||||
kind: 'function',
|
||||
qualifiedName: 'x',
|
||||
file: 'src/x.ts',
|
||||
line: 1,
|
||||
endLine: 2,
|
||||
language: 'typescript',
|
||||
signature: null,
|
||||
exported: true,
|
||||
generated: false,
|
||||
test: false,
|
||||
...over,
|
||||
} as WireNodeRef;
|
||||
}
|
||||
|
||||
function route(over: Partial<WireEntryRoute> = {}): WireEntryRoute {
|
||||
return {
|
||||
url: 'POST /v1/payroll/cycles/{cycleID}/run',
|
||||
method: 'POST',
|
||||
path: '/v1/payroll/cycles/{cycleID}/run',
|
||||
handler: 'RunCycle',
|
||||
handlerKind: 'method',
|
||||
file: 'internal/transport/httpapi/payroll_handler.go',
|
||||
line: 34,
|
||||
handlerId: 'method:RunCycle',
|
||||
routeFile: 'internal/transport/httpapi/router.go',
|
||||
routeLine: 9,
|
||||
routeId: 'route:router.go:9:POST:/v1/payroll/cycles/{cycleID}/run',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function file(over: Partial<WireEntryFile> = {}): WireEntryFile {
|
||||
return {
|
||||
...ref({ id: 'file:src/bin/cli.ts', kind: 'file', name: 'cli.ts', file: 'src/bin/cli.ts' }),
|
||||
calls: 9,
|
||||
reaches: 37,
|
||||
dependents: 3,
|
||||
...over,
|
||||
} as WireEntryFile;
|
||||
}
|
||||
|
||||
function test(over: Partial<WireEntryTest> = {}): WireEntryTest {
|
||||
return {
|
||||
...ref({
|
||||
id: 'file:__tests__/a.test.ts',
|
||||
kind: 'file',
|
||||
name: 'a.test.ts',
|
||||
file: '__tests__/a.test.ts',
|
||||
}),
|
||||
reaches: 12,
|
||||
refs: 40,
|
||||
...over,
|
||||
} as WireEntryTest;
|
||||
}
|
||||
|
||||
function hub(over: Partial<WireEntryHub> = {}): WireEntryHub {
|
||||
return {
|
||||
...ref({ id: 'interface:Node', name: 'Node', kind: 'interface', file: 'src/types.ts', line: 42 }),
|
||||
dependents: 264,
|
||||
...over,
|
||||
} as WireEntryHub;
|
||||
}
|
||||
|
||||
function payload(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
|
||||
return {
|
||||
frameworks: ['go'],
|
||||
routes: {
|
||||
routed: true,
|
||||
routeCount: 4,
|
||||
items: { total: 2, shown: 2, truncated: false, items: [route(), route({
|
||||
url: 'GET /healthz',
|
||||
method: 'GET',
|
||||
path: '/healthz',
|
||||
handler: 'health',
|
||||
handlerKind: 'function',
|
||||
file: 'internal/transport/httpapi/router.go',
|
||||
line: 16,
|
||||
handlerId: 'function:health',
|
||||
routeLine: 12,
|
||||
routeId: 'route:router.go:12:GET:/healthz',
|
||||
})] },
|
||||
},
|
||||
files: { total: 92, shown: 1, truncated: true, items: [file()] },
|
||||
tests: { total: 1, shown: 1, truncated: false, items: [test()] },
|
||||
hubs: { total: 351, shown: 1, truncated: true, items: [hub()] },
|
||||
index: { lastIndexedAt: 1, files: 20 },
|
||||
timing: { elapsedMs: 3, cached: false },
|
||||
...over,
|
||||
} as WireEntryPoints;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- panel -- */
|
||||
|
||||
describe('the entry-points panel', () => {
|
||||
it('draws every section it has data for, in reading order', () => {
|
||||
const panel = buildEntryPanel(payload());
|
||||
expect(panel.sections.map((s) => s.id)).toEqual(['routes', 'files', 'tests', 'hubs']);
|
||||
expect(panel.sections.map((s) => s.title)).toEqual([
|
||||
'Routes',
|
||||
'Top-level files with calls',
|
||||
'Tests',
|
||||
'Most depended on',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps `rows` exactly the sections it draws', () => {
|
||||
const panel = buildEntryPanel(payload());
|
||||
expect(panel.rows).toEqual(panel.sections.flatMap((s) => s.groups.flatMap((g) => g.rows)));
|
||||
expect(panel.rows).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('names the framework beside the route count', () => {
|
||||
const panel = buildEntryPanel(payload());
|
||||
expect(panel.sections[0]?.meta).toBe('2 · go');
|
||||
});
|
||||
|
||||
it('groups routes by where they are REGISTERED, not where they are served', () => {
|
||||
const panel = buildEntryPanel(payload());
|
||||
const routes = panel.sections[0];
|
||||
// Two routes served from two different files, one router.
|
||||
expect(routes?.groups).toHaveLength(1);
|
||||
expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
|
||||
expect(routes?.groups[0]?.file).toBe('internal/transport/httpapi/router.go');
|
||||
});
|
||||
|
||||
it('says a list was cut, and whether the total is a floor', () => {
|
||||
const panel = buildEntryPanel(payload());
|
||||
expect(panel.sections.find((s) => s.id === 'files')?.meta).toBe('1 of at least 92');
|
||||
expect(panel.sections.find((s) => s.id === 'tests')?.meta).toBe('1');
|
||||
expect(panel.sections.find((s) => s.id === 'hubs')?.floor).toBe(true);
|
||||
expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
|
||||
});
|
||||
|
||||
it('draws no Routes heading when the project is not a routed app', () => {
|
||||
const panel = buildEntryPanel(
|
||||
payload({
|
||||
routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
|
||||
})
|
||||
);
|
||||
// The fallback is the point: an empty box under a heading reads as a
|
||||
// failure, and a library legitimately has no routes.
|
||||
expect(panel.sections.map((s) => s.id)).toEqual(['files', 'tests', 'hubs']);
|
||||
expect(panel.empty).toBeNull();
|
||||
});
|
||||
|
||||
it('says what is missing when there is nothing at all', () => {
|
||||
const panel = buildEntryPanel(
|
||||
payload({
|
||||
routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
|
||||
files: { total: 0, shown: 0, truncated: false, items: [] },
|
||||
tests: { total: 0, shown: 0, truncated: false, items: [] },
|
||||
hubs: { total: 0, shown: 0, truncated: false, items: [] },
|
||||
})
|
||||
);
|
||||
expect(panel.sections).toEqual([]);
|
||||
expect(panel.empty).toMatch(/no routes/);
|
||||
});
|
||||
|
||||
it('draws nothing at all before the answer arrives', () => {
|
||||
const panel = buildEntryPanel(null);
|
||||
expect(panel.sections).toEqual([]);
|
||||
// Not an "empty" message: nothing is known yet, and saying "this index has
|
||||
// nothing" while the request is in flight would be a claim, not a state.
|
||||
expect(panel.empty).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ rows -- */
|
||||
|
||||
describe('an entry-point row', () => {
|
||||
it('leads a route with its verb and names the handler in the meta', () => {
|
||||
const row = routeRow(route());
|
||||
expect(row.method).toBe('POST');
|
||||
expect(row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
|
||||
expect(row.meta).toBe('RunCycle · payroll_handler.go:34');
|
||||
expect(row.title).toContain('registered at internal/transport/httpapi/router.go:9');
|
||||
});
|
||||
|
||||
it('keeps an unplaceable route but does not pretend it opens', () => {
|
||||
const row = routeRow(route({ handlerId: null }));
|
||||
expect(row.target).toBeNull();
|
||||
expect(row.flowFrom).toBeNull();
|
||||
expect(row.meta).toBe('RunCycle · not in the index');
|
||||
});
|
||||
|
||||
it('offers a flow only from a row that names a callable symbol', () => {
|
||||
const panel = buildEntryPanel(payload());
|
||||
const byId = (id: string) => panel.sections.find((s) => s.id === id);
|
||||
expect(byId('routes')?.groups[0]?.rows[0]?.flowFrom).toBe('RunCycle');
|
||||
expect(byId('hubs')?.groups[0]?.rows[0]?.flowFrom).toBe('Node');
|
||||
// A file has no name `/api/flow` can look up; a chip here would always fail.
|
||||
expect(byId('files')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
|
||||
expect(byId('tests')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
|
||||
});
|
||||
|
||||
it('sends a file row to the File view and a symbol row to the symbol', () => {
|
||||
const panel = buildEntryPanel(payload());
|
||||
expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.target).toEqual({
|
||||
type: 'file',
|
||||
path: 'src/bin/cli.ts',
|
||||
});
|
||||
expect(panel.sections.find((s) => s.id === 'hubs')?.groups[0]?.rows[0]?.target).toEqual({
|
||||
type: 'symbol',
|
||||
id: 'interface:Node',
|
||||
name: 'Node',
|
||||
kind: 'interface',
|
||||
});
|
||||
});
|
||||
|
||||
it('says when nothing imports an executable file', () => {
|
||||
const panel = buildEntryPanel(
|
||||
payload({ files: { total: 1, shown: 1, truncated: false, items: [file({ dependents: 0 })] } })
|
||||
);
|
||||
expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.meta).toBe(
|
||||
'9 calls at module level · reaches 37 files · nothing imports it'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------- grouping -- */
|
||||
|
||||
describe('grouping', () => {
|
||||
it('folds by path in first-seen order, so the ranking stays visible', () => {
|
||||
const row = (id: string): EntryRow => ({
|
||||
id,
|
||||
name: id,
|
||||
method: null,
|
||||
meta: '',
|
||||
kind: 'file',
|
||||
target: null,
|
||||
flowFrom: null,
|
||||
title: id,
|
||||
});
|
||||
const groups = groupRows([
|
||||
{ row: row('b1'), path: 'b', file: null },
|
||||
{ row: row('a1'), path: 'a', file: null },
|
||||
{ row: row('b2'), path: 'b', file: null },
|
||||
]);
|
||||
expect(groups.map((g) => g.path)).toEqual(['b', 'a']);
|
||||
expect(groups[0]?.rows.map((r) => r.id)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
it('names the directory, or the project root', () => {
|
||||
expect(directoryOf('src/bin/cli.ts')).toBe('src/bin');
|
||||
expect(directoryOf('package.json')).toBe('project root');
|
||||
});
|
||||
});
|
||||
|
||||
/* --------------------------------------------------------------- palette -- */
|
||||
|
||||
describe('entry points under a typed query', () => {
|
||||
it('matches on anything the row draws, including the handler', () => {
|
||||
const matches = matchEntries(payload(), 'runcycle', 6);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0]?.origin).toBe('route');
|
||||
expect(matches[0]?.row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
|
||||
});
|
||||
|
||||
it('matches a URL a search for the path would find, and a verb one would not', () => {
|
||||
expect(matchEntries(payload(), 'healthz', 6)).toHaveLength(1);
|
||||
expect(matchEntries(payload(), 'post ', 6)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('honours the cap and answers nothing for an empty query', () => {
|
||||
expect(matchEntries(payload(), '', 6)).toEqual([]);
|
||||
expect(matchEntries(null, 'x', 6)).toEqual([]);
|
||||
expect(matchEntries(payload(), '.', 1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says where each match came from', () => {
|
||||
expect(originLabel('route')).toBe('route');
|
||||
expect(originLabel('file')).toBe('runs at module level');
|
||||
expect(originLabel('test')).toBe('test');
|
||||
expect(originLabel('hub')).toBe('depended on');
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ flow -- */
|
||||
|
||||
describe('starting a flow from a row', () => {
|
||||
it('refuses a pair that is not a question', () => {
|
||||
expect(flowPair('RunCycle', '')).toBeNull();
|
||||
expect(flowPair('', 'Upsert')).toBeNull();
|
||||
// `/api/flow` refuses this with a 400; disabling the button is kinder.
|
||||
expect(flowPair('Upsert', 'upsert')).toBeNull();
|
||||
});
|
||||
|
||||
it('trims what was typed', () => {
|
||||
expect(flowPair(' RunCycle ', ' Upsert ')).toEqual({ from: 'RunCycle', to: 'Upsert' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming the frameworks', () => {
|
||||
it('reads as a sentence, however many there are', () => {
|
||||
expect(frameworkPhrase([])).toBe('');
|
||||
expect(frameworkPhrase(['gin'])).toBe('gin');
|
||||
expect(frameworkPhrase(['gin', 'spring'])).toBe('gin and spring');
|
||||
expect(frameworkPhrase(['gin', 'spring', 'rails'])).toBe('gin, spring and rails');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* `GET /api/entrypoints` and the panel it draws (CG-54).
|
||||
*
|
||||
* Two indexed projects over two real loopback servers, because the two answers
|
||||
* this endpoint has to get right are opposites:
|
||||
*
|
||||
* - **A routed service.** `__tests__/fixtures/payroll-go` is a Go HTTP service
|
||||
* whose four routes are registered in one router file and served from
|
||||
* another, which is exactly the shape that makes "group routes by file"
|
||||
* ambiguous — and the reason the payload carries the registration site as
|
||||
* well as the handler. It is also the issue's acceptance case: the routes
|
||||
* appear with their handlers, and the route's own handler reaches the store
|
||||
* as a flow.
|
||||
* - **A library.** A TypeScript project with no routes at all, where the panel
|
||||
* must fall back to the files that run something and the tests that exercise
|
||||
* them, and must NOT draw an empty Routes box: "this isn't a web app" is an
|
||||
* answer, not a failure.
|
||||
*
|
||||
* The grouping itself is pure and lives in `ui/src/lib/entry-model.ts`; it is
|
||||
* driven here from the real payload so a wire change that the pure tests would
|
||||
* happily keep passing still fails somewhere.
|
||||
*/
|
||||
|
||||
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 { resetEntryPointsCache } from '../src/ui-server/api/entrypoints';
|
||||
import { splitRouteName } from '../src/ui-server/api/routes';
|
||||
import { isTestFile, isTestPath } from '../src/search/query-utils';
|
||||
import { buildEntryPanel, frameworkPhrase } from '../ui/src/lib/entry-model';
|
||||
import type { WireEntryPoints } from '../ui/src/lib/api';
|
||||
|
||||
const FIXTURE_GO = path.join(__dirname, 'fixtures', 'payroll-go');
|
||||
|
||||
interface Instance {
|
||||
dir: string;
|
||||
root: string;
|
||||
cg: CodeGraph;
|
||||
api: GraphApi;
|
||||
server: UiServerHandle;
|
||||
}
|
||||
|
||||
function request(port: number, requestPath: string): Promise<{ status: number; body: string; type?: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: requestPath,
|
||||
method: 'GET',
|
||||
headers: { Host: `127.0.0.1:${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 getJson(instance: Instance, requestPath: string, expected = 200): Promise<any> {
|
||||
const res = await request(instance.server.port, requestPath);
|
||||
expect(res.type).toBe('application/json; charset=utf-8');
|
||||
expect(res.status).toBe(expected);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
async function serve(root: string, dir: string, cg: CodeGraph): Promise<Instance> {
|
||||
const api = createGraphApi({ projectRoot: root });
|
||||
const server = await startUiServer({ projectRoot: root, port: 0, api: api.handler });
|
||||
return { dir, root, cg, api, server };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function stop(instance: Instance | undefined): Promise<void> {
|
||||
if (!instance) return;
|
||||
await instance.server.close();
|
||||
instance.api.close();
|
||||
instance.cg.destroy();
|
||||
fs.rmSync(instance.dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* A routed Go service — the issue's acceptance case */
|
||||
/* ======================================================================== */
|
||||
|
||||
describe('entry points on a routed service', () => {
|
||||
let go: Instance;
|
||||
let payload: WireEntryPoints;
|
||||
|
||||
beforeAll(async () => {
|
||||
resetEntryPointsCache();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-entry-go-'));
|
||||
fs.cpSync(FIXTURE_GO, dir, { recursive: true });
|
||||
// A stray index in the checked-in tree would be copied in and reused.
|
||||
fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
|
||||
|
||||
const cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
go = await serve(dir, dir, cg);
|
||||
payload = (await getJson(go, '/api/entrypoints')) as WireEntryPoints;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await stop(go);
|
||||
});
|
||||
|
||||
it('names the framework the route list came from', () => {
|
||||
expect(payload.frameworks).toContain('go');
|
||||
expect(frameworkPhrase(payload.frameworks)).toContain('go');
|
||||
});
|
||||
|
||||
it('lists every route with the symbol that serves it', () => {
|
||||
expect(payload.routes.routed).toBe(true);
|
||||
expect(payload.routes.routeCount).toBe(4);
|
||||
|
||||
const rows = payload.routes.items.items;
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows.map((r) => r.url)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'POST /v1/payroll/cycles/{cycleID}/run',
|
||||
'GET /v1/payroll/cycles/{cycleID}',
|
||||
'GET /v1/payroll/cycles/{cycleID}/payslips',
|
||||
'GET /healthz',
|
||||
])
|
||||
);
|
||||
|
||||
const run = rows.find((r) => r.url.startsWith('POST '));
|
||||
expect(run).toBeDefined();
|
||||
expect(run?.method).toBe('POST');
|
||||
expect(run?.path).toBe('/v1/payroll/cycles/{cycleID}/run');
|
||||
expect(run?.handler).toBe('RunCycle');
|
||||
expect(run?.file).toBe('internal/transport/httpapi/payroll_handler.go');
|
||||
// A row has to be navigable, or it is a label.
|
||||
expect(run?.handlerId).toBeTruthy();
|
||||
expect(rows.every((r) => r.handlerId)).toBe(true);
|
||||
});
|
||||
|
||||
it('carries where each URL is registered, which is not where it is served', () => {
|
||||
const rows = payload.routes.items.items;
|
||||
// Every route is registered by NewRouter; three of the four are served
|
||||
// from a different file. Without the registration site there is nothing
|
||||
// to group four routes under.
|
||||
expect(new Set(rows.map((r) => r.routeFile))).toEqual(
|
||||
new Set(['internal/transport/httpapi/router.go'])
|
||||
);
|
||||
expect(new Set(rows.map((r) => r.file)).size).toBe(2);
|
||||
expect(rows.every((r) => r.routeLine > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('groups the panel by the router file, with the handler in the meta line', () => {
|
||||
const panel = buildEntryPanel(payload);
|
||||
const routes = panel.sections.find((s) => s.id === 'routes');
|
||||
expect(routes).toBeDefined();
|
||||
expect(routes?.groups).toHaveLength(1);
|
||||
expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
|
||||
expect(routes?.groups[0]?.rows).toHaveLength(4);
|
||||
// The framework rides in the section header, beside the count.
|
||||
expect(routes?.meta).toContain('go');
|
||||
|
||||
const run = routes?.groups[0]?.rows.find((r) => r.method === 'POST');
|
||||
expect(run?.name).toBe('/v1/payroll/cycles/{cycleID}/run');
|
||||
expect(run?.meta).toBe('RunCycle · payroll_handler.go:34');
|
||||
expect(run?.target).toEqual({
|
||||
type: 'symbol',
|
||||
id: expect.any(String),
|
||||
name: 'RunCycle',
|
||||
kind: 'method',
|
||||
});
|
||||
// A route names a callable symbol, so it can start a flow.
|
||||
expect(run?.flowFrom).toBe('RunCycle');
|
||||
});
|
||||
|
||||
it('draws the flow from a route handler down to the store', async () => {
|
||||
// The issue's "route -> insertNode-style flow": the POST handler reaching
|
||||
// the row that lands in the database.
|
||||
const flow = await getJson(go, '/api/flow?from=RunCycle&to=Upsert');
|
||||
expect(flow.flows.length).toBeGreaterThan(0);
|
||||
const hops = flow.flows[0].hops.map((h: any) => h.node.name);
|
||||
expect(hops[0]).toBe('RunCycle');
|
||||
expect(hops[hops.length - 1]).toBe('Upsert');
|
||||
expect(hops).toContain('runPayrollCycleAll');
|
||||
// Every hop after the first carries the edge that got there.
|
||||
expect(flow.flows[0].hops.slice(1).every((h: any) => h.edge)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers a second time from the cache', async () => {
|
||||
const again = await getJson(go, '/api/entrypoints');
|
||||
expect(again.timing.cached).toBe(true);
|
||||
expect(again.routes.items.items).toEqual(payload.routes.items.items);
|
||||
});
|
||||
|
||||
it('refuses a route window it cannot answer truthfully', async () => {
|
||||
// Under three rows the engine's own "is this routed" test cannot run, so
|
||||
// the parameter is floored rather than silently answering "not routed".
|
||||
const body = await getJson(go, '/api/entrypoints?routes=2', 400);
|
||||
expect(body.error).toMatch(/routes/);
|
||||
});
|
||||
});
|
||||
|
||||
/* ======================================================================== */
|
||||
/* A library — no routes, and no empty Routes box */
|
||||
/* ======================================================================== */
|
||||
|
||||
describe('entry points on a project with no routes', () => {
|
||||
let lib: Instance;
|
||||
let payload: WireEntryPoints;
|
||||
|
||||
beforeAll(async () => {
|
||||
resetEntryPointsCache();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-entry-lib-'));
|
||||
const root = path.join(dir, 'project');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
|
||||
write(
|
||||
root,
|
||||
'src/store.ts',
|
||||
`export function insertNode(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
export function readNode(name: string): string {
|
||||
return insertNode(name);
|
||||
}
|
||||
`
|
||||
);
|
||||
// Module-level statements: the only reason an executable root is visible.
|
||||
write(
|
||||
root,
|
||||
'src/main.ts',
|
||||
`import { insertNode, readNode } from './store';
|
||||
|
||||
const first = insertNode('boot');
|
||||
const second = readNode('warm');
|
||||
|
||||
export const started = [first, second];
|
||||
`
|
||||
);
|
||||
write(
|
||||
root,
|
||||
'__tests__/store.test.ts',
|
||||
`import { insertNode } from '../src/store';
|
||||
|
||||
export function exercisesTheStore(): string {
|
||||
return insertNode('x');
|
||||
}
|
||||
|
||||
exercisesTheStore();
|
||||
`
|
||||
);
|
||||
// A fixture is not a test, even though the ranking treats it as one.
|
||||
write(root, '__tests__/fixtures/sample.ts', `export const sample = 1;\n`);
|
||||
|
||||
const cg = CodeGraph.initSync(root, {
|
||||
config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
|
||||
});
|
||||
await cg.indexAll();
|
||||
cg.resolveReferences();
|
||||
lib = await serve(root, dir, cg);
|
||||
payload = (await getJson(lib, '/api/entrypoints')) as WireEntryPoints;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await stop(lib);
|
||||
});
|
||||
|
||||
it('says it is not a routed app instead of drawing an empty list', () => {
|
||||
expect(payload.routes.routed).toBe(false);
|
||||
expect(payload.routes.items.items).toEqual([]);
|
||||
expect(payload.routes.items.total).toBe(0);
|
||||
|
||||
const panel = buildEntryPanel(payload);
|
||||
// No Routes heading at all — an empty box under a heading reads as a
|
||||
// failure, and this is the ordinary shape of a library.
|
||||
expect(panel.sections.map((s) => s.id)).not.toContain('routes');
|
||||
// …and the panel is not empty: it fell back to what does exist.
|
||||
expect(panel.empty).toBeNull();
|
||||
expect(panel.sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('falls back to the file that runs something at module level', () => {
|
||||
const files = payload.files.items.map((f) => f.file);
|
||||
expect(files).toContain('src/main.ts');
|
||||
expect(files).not.toContain('__tests__/store.test.ts');
|
||||
|
||||
const main = payload.files.items.find((f) => f.file === 'src/main.ts');
|
||||
expect(main?.calls).toBeGreaterThan(0);
|
||||
expect(main?.reaches).toBeGreaterThan(0);
|
||||
|
||||
const panel = buildEntryPanel(payload);
|
||||
const section = panel.sections.find((s) => s.id === 'files');
|
||||
expect(section?.title).toBe('Top-level files with calls');
|
||||
expect(section?.groups[0]?.path).toBe('src');
|
||||
// A file has no name the path finder can look up, so no flow chip.
|
||||
expect(section?.groups[0]?.rows.every((r) => r.flowFrom === null)).toBe(true);
|
||||
expect(section?.groups[0]?.rows[0]?.target).toEqual({ type: 'file', path: 'src/main.ts' });
|
||||
});
|
||||
|
||||
it('lists the tests by what they exercise', () => {
|
||||
const tests = payload.tests.items.map((t) => t.file);
|
||||
expect(tests).toContain('__tests__/store.test.ts');
|
||||
// A fixture reaches nothing and is not a test; either reason keeps it out.
|
||||
expect(tests).not.toContain('__tests__/fixtures/sample.ts');
|
||||
|
||||
const suite = payload.tests.items.find((t) => t.file === '__tests__/store.test.ts');
|
||||
expect(suite?.reaches).toBeGreaterThan(0);
|
||||
expect(suite?.refs).toBeGreaterThanOrEqual(suite?.reaches ?? 0);
|
||||
|
||||
const panel = buildEntryPanel(payload);
|
||||
const section = panel.sections.find((s) => s.id === 'tests');
|
||||
expect(section?.title).toBe('Tests');
|
||||
expect(section?.groups[0]?.rows[0]?.meta).toMatch(/^exercises \d+ files? · \d+ references?$/);
|
||||
});
|
||||
|
||||
it('counts the tests exactly, and the derived lists as a floor', () => {
|
||||
// Every count equals a list in the same payload, or is labelled a floor.
|
||||
expect(payload.tests.total).toBe(payload.tests.items.length);
|
||||
expect(payload.files.total).toBeGreaterThanOrEqual(payload.files.items.length);
|
||||
expect(payload.hubs.total).toBeGreaterThanOrEqual(payload.hubs.items.length);
|
||||
|
||||
const panel = buildEntryPanel(payload);
|
||||
expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
|
||||
expect(panel.sections.find((s) => s.id === 'files')?.floor).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/* ======================================================================== */
|
||||
/* The narrow test predicate */
|
||||
/* ======================================================================== */
|
||||
|
||||
describe('what counts as a test', () => {
|
||||
it('keeps the suites and drops the examples', () => {
|
||||
for (const suite of [
|
||||
'foo_test.go',
|
||||
'src/foo.test.ts',
|
||||
'src/__tests__/foo.ts',
|
||||
'test/foo.rb',
|
||||
'src/FooTest.java',
|
||||
'app/src/jvmTest/Bar.kt',
|
||||
]) {
|
||||
expect(isTestPath(suite), suite).toBe(true);
|
||||
expect(isTestFile(suite), suite).toBe(true);
|
||||
}
|
||||
|
||||
// Examples, benchmarks and fixtures are still off-target for RANKING —
|
||||
// nothing about this change moves that — but they are not tests, and a
|
||||
// heading that says "Tests" must not gather them.
|
||||
for (const other of ['examples/demo.ts', 'benchmarks/run.ts', 'fixtures/a.ts']) {
|
||||
expect(isTestFile(other), other).toBe(true);
|
||||
expect(isTestPath(other), other).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Route names */
|
||||
/* ======================================================================== */
|
||||
|
||||
describe('splitting a route name', () => {
|
||||
it('takes the verb off when there is one', () => {
|
||||
expect(splitRouteName('POST /v1/users')).toEqual({ method: 'POST', path: '/v1/users' });
|
||||
expect(splitRouteName('ANY /healthz')).toEqual({ method: 'ANY', path: '/healthz' });
|
||||
});
|
||||
|
||||
it('leaves a file-routed page whole', () => {
|
||||
// A verb column invented out of the first path segment would be a lie, and
|
||||
// the URL would lose its head.
|
||||
expect(splitRouteName('/blog/[slug]')).toEqual({ method: null, path: '/blog/[slug]' });
|
||||
expect(splitRouteName('user.created handler')).toEqual({
|
||||
method: null,
|
||||
path: 'user.created handler',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -186,7 +186,15 @@ describe('the palette', () => {
|
||||
|
||||
function entryPoints(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
|
||||
return {
|
||||
routes: { routed: false, routeCount: 0, items: [] },
|
||||
frameworks: [],
|
||||
routes: {
|
||||
routed: false,
|
||||
routeCount: 0,
|
||||
items: { total: 0, shown: 0, truncated: false, items: [] },
|
||||
},
|
||||
tests: { total: 0, shown: 0, truncated: false, items: [] },
|
||||
index: { lastIndexedAt: null, files: 0 },
|
||||
timing: { elapsedMs: 0, cached: false },
|
||||
files: {
|
||||
total: 2,
|
||||
shown: 2,
|
||||
@@ -232,15 +240,26 @@ describe('the entry points', () => {
|
||||
routes: {
|
||||
routed: true,
|
||||
routeCount: 4,
|
||||
items: [
|
||||
{
|
||||
url: 'GET /users',
|
||||
handler: 'listUsers',
|
||||
file: 'src/routes.ts',
|
||||
line: 11,
|
||||
handlerId: 'function:listUsers',
|
||||
},
|
||||
],
|
||||
items: {
|
||||
total: 1,
|
||||
shown: 1,
|
||||
truncated: false,
|
||||
items: [
|
||||
{
|
||||
url: 'GET /users',
|
||||
method: 'GET',
|
||||
path: '/users',
|
||||
handler: 'listUsers',
|
||||
handlerKind: 'function',
|
||||
file: 'src/routes.ts',
|
||||
line: 11,
|
||||
handlerId: 'function:listUsers',
|
||||
routeFile: 'src/routes.ts',
|
||||
routeLine: 4,
|
||||
routeId: 'route:src/routes.ts:4:GET:/users',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -265,6 +284,67 @@ describe('the entry points', () => {
|
||||
expect(buildEntryPalette(many).items).toHaveLength(11);
|
||||
});
|
||||
|
||||
it('offers entry points under a typed query, BELOW the symbol matches', () => {
|
||||
const entries = entryPoints({
|
||||
routes: {
|
||||
routed: true,
|
||||
routeCount: 3,
|
||||
items: {
|
||||
total: 1,
|
||||
shown: 1,
|
||||
truncated: false,
|
||||
items: [
|
||||
{
|
||||
url: 'POST /users',
|
||||
method: 'POST',
|
||||
path: '/users',
|
||||
handler: 'createUser',
|
||||
handlerKind: 'function',
|
||||
file: 'src/handlers.ts',
|
||||
line: 8,
|
||||
handlerId: 'function:createUser',
|
||||
routeFile: 'src/routes.ts',
|
||||
routeLine: 4,
|
||||
routeId: 'route:src/routes.ts:4:POST:/users',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const palette = buildSearchPalette(
|
||||
[answer([result({ id: 'class:Users', name: 'Users', kind: 'class' })])],
|
||||
null,
|
||||
{ entries, query: 'users', entryRows: 6 }
|
||||
);
|
||||
|
||||
// Symbol matches keep the top: someone typing a name asked for the name.
|
||||
expect(palette.sections[0]?.title).toBe('Class');
|
||||
const last = palette.sections[palette.sections.length - 1];
|
||||
expect(last?.title).toBe('Entry points');
|
||||
const row = last?.items[0];
|
||||
expect(row?.type).toBe('entry');
|
||||
// The row a plain search cannot produce: the URL WITH its handler.
|
||||
expect(row?.name).toBe('POST /users');
|
||||
expect(row?.meta).toBe('createUser · handlers.ts:8');
|
||||
expect(row?.location).toBe('route');
|
||||
// The keyboard's flat list still equals what is drawn.
|
||||
expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
|
||||
});
|
||||
|
||||
it('does not repeat a symbol the search above already found', () => {
|
||||
const hub = { ...result({ id: 'method:get', name: 'get' }), dependents: 264 };
|
||||
const entries = entryPoints({
|
||||
hubs: { total: 1, shown: 1, truncated: false, items: [hub] as any },
|
||||
});
|
||||
const palette = buildSearchPalette([answer([result({ id: 'method:get', name: 'get' })])], null, {
|
||||
entries,
|
||||
query: 'get',
|
||||
entryRows: 6,
|
||||
});
|
||||
expect(palette.sections.map((s) => s.title)).not.toContain('Entry points');
|
||||
});
|
||||
|
||||
it('draws nothing at all before the answer arrives', () => {
|
||||
const palette = buildEntryPalette(null);
|
||||
expect(palette.sections).toEqual([]);
|
||||
|
||||
@@ -973,12 +973,12 @@ export default app;
|
||||
|
||||
expect(body.routes.routed).toBe(true);
|
||||
expect(body.routes.routeCount).toBe(4);
|
||||
const urls = body.routes.items.map((e: any) => e.url);
|
||||
const urls = body.routes.items.items.map((e: any) => e.url);
|
||||
expect(urls).toEqual(
|
||||
expect.arrayContaining(['GET /users', 'GET /users/:id', 'POST /users', 'DELETE /users/:id'])
|
||||
);
|
||||
// A route row has to be navigable, or it is a label.
|
||||
expect(body.routes.items.every((e: any) => e.handlerId)).toBe(true);
|
||||
expect(body.routes.items.items.every((e: any) => e.handlerId)).toBe(true);
|
||||
});
|
||||
|
||||
it('honours the limit and says when it cut the list', async () => {
|
||||
@@ -1118,7 +1118,7 @@ describe('GET /api/entrypoints', () => {
|
||||
it('says a project without routes is not routed rather than failing', async () => {
|
||||
const body = await getJson('/api/entrypoints');
|
||||
expect(body.routes.routed).toBe(false);
|
||||
expect(body.routes.items).toEqual([]);
|
||||
expect(body.routes.items.items).toEqual([]);
|
||||
expect(body.routes.routeCount).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user