Files
codegraph/__tests__/ui-repeated-keys.test.ts
T
danusha2345andClaude Opus 5.5 33dc2ae72b fix(viewer): the routes list's limit counts routes, not one route's calls
The routing manifest has a row per (route, symbol it reaches), and the
routes list behind Entry points and the Steps picker folds those rows to
one per route. To have rows to fold, it fetched limit * 4 + 1 of them,
so an inline handler, which has a row per call in its body, could use
up the window by itself, and the routes after it were never fetched. On
an Express app with one 24-call inline handler before two routes,
limit=3 listed only GET /dense.

getRoutingManifest takes a perRoute flag that applies its limit to
routes, in the same file and line order, and returns every row of those
routes. Without the flag the query and its result are unchanged.

Added on top of the contributor's change: the window is still
over-fetched, four routes per route shown, because some routes are
dropped only after their rows come back (a test or generated handler,
an inline route written in a test). A window of exactly limit + 1
routes could hold only those. On hono, whose routes are nearly all
registered in test files, Entry points then listed 0 routes instead of
main's 28, and the routes list 21 instead of 32. With the over-fetch,
every route main lists is still listed at each limit tried (3, 12, 60,
200 and 300), and hono's Entry points list 32. truncated also keeps
main's check that the window came back full.

Validation: the new limit=3 test fails on main (only GET /dense) and
passes here. A project whose test-file routes sort first fails with the
PR as first written (it reads as unrouted) and passes here and on main.
Routes listed on Wiki.js (132 routes) and realworld-express are
identical to main, and on hono they differ only by the additions above.
The ui suites and the other route consumers: 34 files, 723 passed, 0
failed, 3 skipped (environment-gated). The changelog line is in
docs/viewer-launch-changelog.md, since the viewer is not released yet.

Original PR #2370 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:56:54 +00:00

164 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The viewer draws its lists keyed by id, and a repeated id stops the page
* from drawing at all — Entry points on hono, the Symbol view of every Vue /
* Svelte / Astro component. Two payloads repeated themselves:
*
* - the routes list was a row per (route, edge), so an inline handler came
* back once per call in its body (`GET /stream/text` as `streamText`,
* `writeln` and `sleep`). A route is one row: its bound handler, or the
* route itself as an inline handler;
* - a component's members listed its script's symbols twice, and the
* component and its file among them.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { buildRoutes } from '../src/ui-server/api/routes';
import { buildNode } from '../src/ui-server/api/node';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-repeated-'));
const files: Record<string, string> = {
'package.json': JSON.stringify({ name: 'app', private: true, dependencies: { express: '^4.0.0', astro: '^4.0.0' } }),
'dense/app.js': `const express = require('express'); const app = express();
${Array.from({ length: 24 }, (_, i) => `function task${i}() { return ${i}; }`).join('\n')}
function second(req, res) { res.end(); }
function third(req, res) { res.end(); }
app.get('/dense', (req, res) => {
${Array.from({ length: 24 }, (_, i) => `task${i}();`).join('\n')}
});
app.get('/second', second);
app.get('/third', third);
`,
'server/app.js': `const express = require('express');
const app = express();
function loadUser(id) { return { id }; }
function audit(id) { return id; }
function render(user) { return user; }
function listUsers(req, res) { res.json([]); }
function showUser(req, res) { res.json(loadUser(req.params.id)); }
app.get('/users/:id/card', async (req, res) => {
const user = loadUser(req.params.id);
audit(user.id);
res.send(render(user));
});
app.get('/users', listUsers);
app.get('/users/:id', showUser);
`,
'src/components/Card.astro': `---
import Badge from './Badge.astro';
import { format } from '../lib/format';
const title = format('card');
---
<div><Badge />{title}</div>
`,
'src/components/Badge.astro': `<span>badge</span>
`,
'src/lib/format.ts': `export function format(s: string): string {
return s.trim();
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
describe('the routes list', () => {
it('applies its limit to routes, not to the calls of one inline handler', () => {
const payload = buildRoutes(cg, new URLSearchParams('limit=3'));
expect(payload.entries.map((e) => e.url)).toEqual(['GET /dense', 'GET /second', 'GET /third']);
expect(payload.shown).toBe(3);
expect(payload.truncated).toBe(true);
});
it('still calls a project routed when its one inline route has three calls', async () => {
const singleRoot = path.join(root, 'one-route');
fs.mkdirSync(singleRoot, { recursive: true });
fs.writeFileSync(path.join(singleRoot, 'package.json'), JSON.stringify({ dependencies: { express: '^4.0.0' } }));
fs.writeFileSync(path.join(singleRoot, 'app.js'), `const app = require('express')();
function first() {} function second() {} function third() {}
app.get('/one', () => { first(); second(); third(); });
`);
const single = await CodeGraph.init(singleRoot, { index: true });
try {
const payload = buildRoutes(single, new URLSearchParams('limit=3'));
expect(payload.routed).toBe(true);
expect(payload.entries.map((e) => e.url)).toEqual(['GET /one']);
} finally {
single.close();
}
});
it('fills the page past routes it drops after reading them, like a test file\'s', async () => {
const testsFirst = path.join(root, 'tests-first');
const put = (rel: string, text: string) => {
fs.mkdirSync(path.dirname(path.join(testsFirst, rel)), { recursive: true });
fs.writeFileSync(path.join(testsFirst, rel), text);
};
put('package.json', JSON.stringify({ dependencies: { express: '^4.0.0' } }));
// Sorts before src/, and every handler is test code, so these rows are
// dropped only once they are read: they must not use up the window.
put('__tests__/routes.test.js', `const app = require('express')();
${Array.from({ length: 6 }, (_, i) => `function t${i}(req, res) { res.end(); }\napp.get('/t${i}', t${i});`).join('\n')}
`);
put('src/app.js', `const app = require('express')();
function a(req, res) { res.end(); }
function b(req, res) { res.end(); }
function c(req, res) { res.end(); }
app.get('/a', a);
app.get('/b', b);
app.get('/c', c);
`);
const project = await CodeGraph.init(testsFirst, { index: true });
try {
const payload = buildRoutes(project, new URLSearchParams('limit=3'));
expect(payload.routed).toBe(true);
expect(payload.entries.map((e) => e.url)).toEqual(['GET /a', 'GET /b', 'GET /c']);
expect(payload.truncated).toBe(false);
} finally {
project.close();
}
});
it('has one row per route', () => {
const { entries } = buildRoutes(cg, new URLSearchParams());
const ids = entries.map((e) => e.routeId);
expect(new Set(ids).size).toBe(ids.length);
});
it('names an inline handler as one, and a bound handler by its name', () => {
const { entries } = buildRoutes(cg, new URLSearchParams());
const card = entries.find((e) => e.url === 'GET /users/:id/card')!;
expect(card).toMatchObject({ handler: 'inline handler', inline: true, handlerId: card.routeId });
expect(entries.find((e) => e.url === 'GET /users/:id')).toMatchObject({ handler: 'showUser', inline: false });
});
});
describe('a component’s members', () => {
it('lists each symbol once, and never the component or a file', async () => {
const component = cg.getNodesByKind('component').find((n) => n.name === 'Card')!;
const payload = (await buildNode(cg, root, component.id)) as { members: { items: Array<{ id: string; kind: string }> } };
const ids = payload.members.items.map((m) => m.id);
expect(ids.length).toBeGreaterThan(0);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).not.toContain(component.id);
expect(payload.members.items.map((m) => m.kind)).not.toContain('file');
});
});