| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479 |
- /**
- * The viewer's live channel and its drift parity (CG-53).
- *
- * Two things are proved here that a unit test could not:
- *
- * - `GET /api/events` is a real SSE stream over the real loopback server, and
- * it says something the moment a source file changes and again when the index
- * moves underneath it. Both watchers are edge-triggered, so a test that
- * passed by polling would be testing the wrong thing entirely.
- * - `/api/source?ondrift=current` serves a drifted file's CURRENT bytes rather
- * than nothing, flagged `showing: 'current'` — the parity with
- * `codegraph_node`'s behaviour on a file that changed after its last sync.
- *
- * Every test that rewrites a fixture file restores it, because the fixture is
- * indexed once for the whole suite.
- */
- 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 { HEARTBEAT_MS, MAX_EVENT_FILES } from '../src/ui-server/api/events';
- let server: UiServerHandle;
- let api: GraphApi;
- let tempDir: string;
- let projectRoot: string;
- const ORIGINAL = `export function greet(name: string): string {
- return 'hello ' + name;
- }
- export function shout(name: string): string {
- return greet(name).toUpperCase();
- }
- `;
- 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();
- });
- }
- interface SseEvent {
- event: string;
- data: any;
- }
- /**
- * One open SSE connection, with the frames it has received so far.
- *
- * The parser is the whole SSE grammar this server uses: `retry:`, `event:`,
- * `data:` and a blank line. Comment frames (`: ping`) are counted separately —
- * they are the heartbeat, and a client must never see them as events.
- */
- class Stream {
- readonly events: SseEvent[] = [];
- comments = 0;
- status = 0;
- contentType: string | undefined;
- private buffer = '';
- private req: http.ClientRequest | null = null;
- private res: http.IncomingMessage | null = null;
- open(requestPath = '/api/events'): Promise<void> {
- 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}`, Accept: 'text/event-stream' },
- setHost: false,
- },
- (res) => {
- this.res = res;
- this.status = res.statusCode ?? 0;
- this.contentType = res.headers['content-type'];
- res.setEncoding('utf-8');
- res.on('data', (chunk: string) => this.ingest(chunk));
- resolve();
- }
- );
- this.req = req;
- req.on('error', reject);
- req.end();
- });
- }
- private ingest(chunk: string): void {
- this.buffer += chunk;
- let split = this.buffer.indexOf('\n\n');
- while (split !== -1) {
- const frame = this.buffer.slice(0, split);
- this.buffer = this.buffer.slice(split + 2);
- this.parse(frame);
- split = this.buffer.indexOf('\n\n');
- }
- // A heartbeat is its own frame and ends the same way, but node may deliver
- // it alone; the loop above already handled it.
- }
- private parse(frame: string): void {
- let name = 'message';
- let data = '';
- for (const line of frame.split('\n')) {
- if (line.startsWith(':')) {
- this.comments += 1;
- continue;
- }
- if (line.startsWith('event: ')) name = line.slice(7);
- else if (line.startsWith('data: ')) data += line.slice(6);
- }
- if (data === '') return;
- try {
- this.events.push({ event: name, data: JSON.parse(data) });
- } catch {
- this.events.push({ event: name, data });
- }
- }
- /** Wait for an event of `type`, or give up. Never polls the server. */
- async waitFor(type: string, timeoutMs = 12_000): Promise<SseEvent> {
- const deadline = Date.now() + timeoutMs;
- for (;;) {
- const hit = this.events.find((e) => e.event === type);
- if (hit) return hit;
- if (Date.now() > deadline) {
- throw new Error(
- `No "${type}" event within ${timeoutMs}ms. Saw: ${this.events.map((e) => e.event).join(', ') || '(nothing)'}`
- );
- }
- await new Promise((r) => setTimeout(r, 25));
- }
- }
- close(): void {
- this.res?.destroy();
- this.req?.destroy();
- }
- }
- function fixture(rel: string): string {
- return path.join(projectRoot, rel);
- }
- beforeAll(async () => {
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-events-'));
- projectRoot = path.join(tempDir, 'project');
- fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
- fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
- fs.writeFileSync(
- fixture('src/other.ts'),
- `import { greet } from './greet';\n\nexport const hi = greet('there');\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/events', () => {
- it('is listed by the API index', async () => {
- const index = JSON.parse((await request('/api')).body);
- const paths = index.endpoints.map((e: any) => e.path);
- expect(paths).toContain('/api/events');
- });
- it('answers as an event stream and opens with the index revision', async () => {
- const stream = new Stream();
- await stream.open();
- try {
- const hello = await stream.waitFor('hello');
- expect(stream.status).toBe(200);
- expect(stream.contentType).toBe('text/event-stream; charset=utf-8');
- expect(hello.data.type).toBe('hello');
- // The revision the client is synchronised against — the same numbers
- // /api/stats reports.
- expect(hello.data.index.files).toBe(2);
- expect(typeof hello.data.index.lastIndexedAt).toBe('number');
- expect(hello.data.heartbeatMs).toBe(HEARTBEAT_MS);
- // Whether each observer came up is stated, never implied.
- expect(typeof hello.data.watching.source).toBe('boolean');
- expect(typeof hello.data.watching.index).toBe('boolean');
- expect(hello.data.degraded).toBeNull();
- } finally {
- stream.close();
- }
- });
- it('never sends a heartbeat as an event', async () => {
- const stream = new Stream();
- await stream.open();
- try {
- await stream.waitFor('hello');
- // The heartbeat is a comment frame; if it ever became an event, every
- // client would refetch every 25 seconds forever.
- expect(stream.events.every((e) => e.event !== 'ping' && e.event !== 'message')).toBe(true);
- } finally {
- stream.close();
- }
- });
- it('answers HEAD with the stream headers and no body', async () => {
- const res = await new Promise<{ status: number; type?: string; body: string }>((resolve, reject) => {
- const req = http.request(
- {
- host: '127.0.0.1',
- port: server.port,
- path: '/api/events',
- method: 'HEAD',
- headers: { Host: `127.0.0.1:${server.port}` },
- setHost: false,
- },
- (r) => {
- const chunks: Buffer[] = [];
- r.on('data', (c: Buffer) => chunks.push(c));
- r.on('end', () =>
- resolve({
- status: r.statusCode ?? 0,
- type: r.headers['content-type'],
- body: Buffer.concat(chunks).toString('utf-8'),
- })
- );
- }
- );
- req.on('error', reject);
- req.end();
- });
- expect(res.status).toBe(200);
- expect(res.type).toBe('text/event-stream; charset=utf-8');
- expect(res.body).toBe('');
- });
- it('announces a source file that changed on disk, before any sync', async () => {
- const stream = new Stream();
- await stream.open();
- try {
- await stream.waitFor('hello');
- // Give the watcher a moment to install its watch before the write; an
- // event that predates the watch is not a bug, just an untestable one.
- await new Promise((r) => setTimeout(r, 300));
- fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const EXTRA = 1;\n`);
- const changed = await stream.waitFor('changed');
- expect(changed.data.type).toBe('changed');
- expect(changed.data.scan === true || changed.data.files.includes('src/greet.ts')).toBe(true);
- // A count always equals a list, or says it was cut.
- expect(changed.data.total).toBeGreaterThanOrEqual(changed.data.files.length);
- expect(changed.data.files.length).toBeLessThanOrEqual(MAX_EVENT_FILES);
- // ...and the index has NOT moved: this server watches, it never syncs.
- const source = JSON.parse((await request('/api/source?file=src/greet.ts')).body);
- expect(source.drift).toBe(true);
- } finally {
- fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
- stream.close();
- }
- });
- it('announces the index moving, and names what the sync picked up', async () => {
- const stream = new Stream();
- await stream.open();
- try {
- await stream.waitFor('hello');
- await new Promise((r) => setTimeout(r, 300));
- // Another process re-indexes — exactly what a daemon's watcher or a
- // `codegraph sync` does while the viewer is open.
- fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const SYNCED = 2;\n`);
- const writer = CodeGraph.openSync(projectRoot);
- await writer.sync();
- writer.close();
- const moved = await stream.waitFor('index');
- expect(moved.data.type).toBe('index');
- expect(moved.data.index.files).toBe(2);
- expect(moved.data.files).toContain('src/greet.ts');
- expect(moved.data.total).toBeGreaterThanOrEqual(moved.data.files.length);
- // And the graph really did move: the new symbol is there.
- const search = JSON.parse((await request('/api/search?q=SYNCED')).body);
- expect(search.results.items.some((r: any) => r.name === 'SYNCED')).toBe(true);
- } finally {
- fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
- const writer = CodeGraph.openSync(projectRoot);
- await writer.sync();
- writer.close();
- stream.close();
- }
- }, 60_000);
- it('stops serving a symbol a sync in another process deleted', async () => {
- // A node's id contains its start line, so pushing two lines in above
- // `shout` gives it a different id. The old one must go — the query layer
- // keeps an LRU of nodes by id that only its OWN writes invalidate, so
- // without `GraphSession` dropping it this endpoint would keep answering
- // 200 with a row that is no longer in the database, while `/api/search`
- // beside it correctly says the symbol moved.
- const before = JSON.parse((await request('/api/search?q=shout')).body);
- const oldId = before.results.items[0].id as string;
- expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(200);
- fs.writeFileSync(fixture('src/greet.ts'), `// one
- // two
- ${ORIGINAL}`);
- const writer = CodeGraph.openSync(projectRoot);
- await writer.sync();
- writer.close();
- try {
- expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(404);
- const after = JSON.parse((await request('/api/search?q=shout')).body);
- const newId = after.results.items[0].id as string;
- expect(newId).not.toBe(oldId);
- const moved = JSON.parse((await request(`/api/node/${encodeURIComponent(newId)}`)).body);
- expect(moved.node.line).toBe(7);
- // ...and its rails came back with it, rather than an empty shell — the
- // exact symptom of a cached row whose edges were re-keyed around it.
- expect(moved.counts.callees).toBeGreaterThan(0);
- } finally {
- fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
- const restore = CodeGraph.openSync(projectRoot);
- await restore.sync();
- restore.close();
- }
- }, 60_000);
- it('closes every stream when the API is closed', async () => {
- const own = createGraphApi({ projectRoot });
- const handle = await startUiServer({
- projectRoot,
- viewerDir: path.join(tempDir, 'viewer'),
- port: 0,
- api: own.handler,
- });
- const ended = new Promise<void>((resolve, reject) => {
- const req = http.request(
- {
- host: '127.0.0.1',
- port: handle.port,
- path: '/api/events',
- method: 'GET',
- headers: { Host: `127.0.0.1:${handle.port}` },
- setHost: false,
- },
- (res) => {
- res.resume();
- res.on('end', () => resolve());
- }
- );
- req.on('error', reject);
- req.end();
- });
- // Let the subscription land before pulling the rug.
- await new Promise((r) => setTimeout(r, 200));
- own.close();
- await ended;
- await handle.close();
- });
- });
- describe('GET /api/source?ondrift=', () => {
- it('omits the slice by default when the file drifted', async () => {
- fs.writeFileSync(fixture('src/greet.ts'), `// a new first line\n${ORIGINAL}`);
- try {
- const body = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=3')).body);
- expect(body.drift).toBe(true);
- expect(body.showing).toBe('none');
- expect(body.lines).toBeUndefined();
- expect(body.highlight).toBeUndefined();
- expect(body.reason).toMatch(/changed on disk/);
- } finally {
- fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
- }
- });
- it('serves the CURRENT bytes when asked, flagged as current', async () => {
- const rewritten = `// a new first line\n${ORIGINAL}`;
- fs.writeFileSync(fixture('src/greet.ts'), rewritten);
- try {
- const body = JSON.parse(
- (await request('/api/source?file=src/greet.ts&from=1&ondrift=current')).body
- );
- expect(body.drift).toBe(true);
- expect(body.showing).toBe('current');
- // The bytes on disk right now, not the ones that were indexed.
- expect(body.lines[0]).toBe('// a new first line');
- expect(body.totalLines).toBe(rewritten.replace(/\n$/, '').split('\n').length);
- // Highlighting rides with them, or the code block paints plain text and
- // then reflows.
- expect(body.highlight).toBeTruthy();
- expect(body.highlight.lines.length).toBe(body.lines.length);
- expect(body.reason).toMatch(/current lines/);
- } finally {
- fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
- }
- });
- it('says showing: indexed when there is no drift, with or without the flag', async () => {
- const plain = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=2')).body);
- expect(plain.drift).toBe(false);
- expect(plain.showing).toBe('indexed');
- const asked = JSON.parse(
- (await request('/api/source?file=src/greet.ts&from=1&to=2&ondrift=current')).body
- );
- expect(asked.showing).toBe('indexed');
- expect(asked.lines).toEqual(plain.lines);
- });
- it('rejects an ondrift value it does not implement', async () => {
- const res = await request('/api/source?file=src/greet.ts&ondrift=guess');
- expect(res.status).toBe(400);
- expect(res.type).toBe('application/json; charset=utf-8');
- expect(JSON.parse(res.body).code).toBe('bad-request');
- });
- it('answers an empty slice rather than a 400 when a drifted file shrank', async () => {
- fs.writeFileSync(fixture('src/greet.ts'), 'export const only = 1;\n');
- try {
- const res = await request('/api/source?file=src/greet.ts&from=5&to=9&ondrift=current');
- expect(res.status).toBe(200);
- const body = JSON.parse(res.body);
- expect(body.showing).toBe('current');
- expect(body.lines).toEqual([]);
- expect(body.totalLines).toBe(1);
- } finally {
- fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
- }
- });
- it('still refuses a path outside the project, ondrift or not', async () => {
- const res = await request('/api/source?file=/etc/passwd&ondrift=current');
- expect(res.status).toBe(403);
- expect(JSON.parse(res.body).code).toBe('refused');
- });
- });
|