ui-events-api.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. /**
  2. * The viewer's live channel and its drift parity (CG-53).
  3. *
  4. * Two things are proved here that a unit test could not:
  5. *
  6. * - `GET /api/events` is a real SSE stream over the real loopback server, and
  7. * it says something the moment a source file changes and again when the index
  8. * moves underneath it. Both watchers are edge-triggered, so a test that
  9. * passed by polling would be testing the wrong thing entirely.
  10. * - `/api/source?ondrift=current` serves a drifted file's CURRENT bytes rather
  11. * than nothing, flagged `showing: 'current'` — the parity with
  12. * `codegraph_node`'s behaviour on a file that changed after its last sync.
  13. *
  14. * Every test that rewrites a fixture file restores it, because the fixture is
  15. * indexed once for the whole suite.
  16. */
  17. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  18. import * as http from 'http';
  19. import * as fs from 'fs';
  20. import * as os from 'os';
  21. import * as path from 'path';
  22. import CodeGraph from '../src/index';
  23. import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
  24. import { HEARTBEAT_MS, MAX_EVENT_FILES } from '../src/ui-server/api/events';
  25. let server: UiServerHandle;
  26. let api: GraphApi;
  27. let tempDir: string;
  28. let projectRoot: string;
  29. const ORIGINAL = `export function greet(name: string): string {
  30. return 'hello ' + name;
  31. }
  32. export function shout(name: string): string {
  33. return greet(name).toUpperCase();
  34. }
  35. `;
  36. function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
  37. return new Promise((resolve, reject) => {
  38. const req = http.request(
  39. {
  40. host: '127.0.0.1',
  41. port: server.port,
  42. path: requestPath,
  43. method: 'GET',
  44. headers: { Host: `127.0.0.1:${server.port}` },
  45. setHost: false,
  46. },
  47. (res) => {
  48. const chunks: Buffer[] = [];
  49. res.on('data', (c: Buffer) => chunks.push(c));
  50. res.on('end', () =>
  51. resolve({
  52. status: res.statusCode ?? 0,
  53. body: Buffer.concat(chunks).toString('utf-8'),
  54. type: res.headers['content-type'],
  55. })
  56. );
  57. }
  58. );
  59. req.on('error', reject);
  60. req.end();
  61. });
  62. }
  63. interface SseEvent {
  64. event: string;
  65. data: any;
  66. }
  67. /**
  68. * One open SSE connection, with the frames it has received so far.
  69. *
  70. * The parser is the whole SSE grammar this server uses: `retry:`, `event:`,
  71. * `data:` and a blank line. Comment frames (`: ping`) are counted separately —
  72. * they are the heartbeat, and a client must never see them as events.
  73. */
  74. class Stream {
  75. readonly events: SseEvent[] = [];
  76. comments = 0;
  77. status = 0;
  78. contentType: string | undefined;
  79. private buffer = '';
  80. private req: http.ClientRequest | null = null;
  81. private res: http.IncomingMessage | null = null;
  82. open(requestPath = '/api/events'): Promise<void> {
  83. return new Promise((resolve, reject) => {
  84. const req = http.request(
  85. {
  86. host: '127.0.0.1',
  87. port: server.port,
  88. path: requestPath,
  89. method: 'GET',
  90. headers: { Host: `127.0.0.1:${server.port}`, Accept: 'text/event-stream' },
  91. setHost: false,
  92. },
  93. (res) => {
  94. this.res = res;
  95. this.status = res.statusCode ?? 0;
  96. this.contentType = res.headers['content-type'];
  97. res.setEncoding('utf-8');
  98. res.on('data', (chunk: string) => this.ingest(chunk));
  99. resolve();
  100. }
  101. );
  102. this.req = req;
  103. req.on('error', reject);
  104. req.end();
  105. });
  106. }
  107. private ingest(chunk: string): void {
  108. this.buffer += chunk;
  109. let split = this.buffer.indexOf('\n\n');
  110. while (split !== -1) {
  111. const frame = this.buffer.slice(0, split);
  112. this.buffer = this.buffer.slice(split + 2);
  113. this.parse(frame);
  114. split = this.buffer.indexOf('\n\n');
  115. }
  116. // A heartbeat is its own frame and ends the same way, but node may deliver
  117. // it alone; the loop above already handled it.
  118. }
  119. private parse(frame: string): void {
  120. let name = 'message';
  121. let data = '';
  122. for (const line of frame.split('\n')) {
  123. if (line.startsWith(':')) {
  124. this.comments += 1;
  125. continue;
  126. }
  127. if (line.startsWith('event: ')) name = line.slice(7);
  128. else if (line.startsWith('data: ')) data += line.slice(6);
  129. }
  130. if (data === '') return;
  131. try {
  132. this.events.push({ event: name, data: JSON.parse(data) });
  133. } catch {
  134. this.events.push({ event: name, data });
  135. }
  136. }
  137. /** Wait for an event of `type`, or give up. Never polls the server. */
  138. async waitFor(type: string, timeoutMs = 12_000): Promise<SseEvent> {
  139. const deadline = Date.now() + timeoutMs;
  140. for (;;) {
  141. const hit = this.events.find((e) => e.event === type);
  142. if (hit) return hit;
  143. if (Date.now() > deadline) {
  144. throw new Error(
  145. `No "${type}" event within ${timeoutMs}ms. Saw: ${this.events.map((e) => e.event).join(', ') || '(nothing)'}`
  146. );
  147. }
  148. await new Promise((r) => setTimeout(r, 25));
  149. }
  150. }
  151. close(): void {
  152. this.res?.destroy();
  153. this.req?.destroy();
  154. }
  155. }
  156. function fixture(rel: string): string {
  157. return path.join(projectRoot, rel);
  158. }
  159. beforeAll(async () => {
  160. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-events-'));
  161. projectRoot = path.join(tempDir, 'project');
  162. fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true });
  163. fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
  164. fs.writeFileSync(
  165. fixture('src/other.ts'),
  166. `import { greet } from './greet';\n\nexport const hi = greet('there');\n`
  167. );
  168. const cg = CodeGraph.initSync(projectRoot, {
  169. config: { include: ['src/**/*.ts'], exclude: [] },
  170. });
  171. await cg.indexAll();
  172. cg.resolveReferences();
  173. cg.close();
  174. const viewerDir = path.join(tempDir, 'viewer');
  175. fs.mkdirSync(viewerDir, { recursive: true });
  176. fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
  177. api = createGraphApi({ projectRoot });
  178. server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
  179. }, 120_000);
  180. afterAll(async () => {
  181. api?.close();
  182. await server?.close();
  183. if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
  184. });
  185. describe('GET /api/events', () => {
  186. it('is listed by the API index', async () => {
  187. const index = JSON.parse((await request('/api')).body);
  188. const paths = index.endpoints.map((e: any) => e.path);
  189. expect(paths).toContain('/api/events');
  190. });
  191. it('answers as an event stream and opens with the index revision', async () => {
  192. const stream = new Stream();
  193. await stream.open();
  194. try {
  195. const hello = await stream.waitFor('hello');
  196. expect(stream.status).toBe(200);
  197. expect(stream.contentType).toBe('text/event-stream; charset=utf-8');
  198. expect(hello.data.type).toBe('hello');
  199. // The revision the client is synchronised against — the same numbers
  200. // /api/stats reports.
  201. expect(hello.data.index.files).toBe(2);
  202. expect(typeof hello.data.index.lastIndexedAt).toBe('number');
  203. expect(hello.data.heartbeatMs).toBe(HEARTBEAT_MS);
  204. // Whether each observer came up is stated, never implied.
  205. expect(typeof hello.data.watching.source).toBe('boolean');
  206. expect(typeof hello.data.watching.index).toBe('boolean');
  207. expect(hello.data.degraded).toBeNull();
  208. } finally {
  209. stream.close();
  210. }
  211. });
  212. it('never sends a heartbeat as an event', async () => {
  213. const stream = new Stream();
  214. await stream.open();
  215. try {
  216. await stream.waitFor('hello');
  217. // The heartbeat is a comment frame; if it ever became an event, every
  218. // client would refetch every 25 seconds forever.
  219. expect(stream.events.every((e) => e.event !== 'ping' && e.event !== 'message')).toBe(true);
  220. } finally {
  221. stream.close();
  222. }
  223. });
  224. it('answers HEAD with the stream headers and no body', async () => {
  225. const res = await new Promise<{ status: number; type?: string; body: string }>((resolve, reject) => {
  226. const req = http.request(
  227. {
  228. host: '127.0.0.1',
  229. port: server.port,
  230. path: '/api/events',
  231. method: 'HEAD',
  232. headers: { Host: `127.0.0.1:${server.port}` },
  233. setHost: false,
  234. },
  235. (r) => {
  236. const chunks: Buffer[] = [];
  237. r.on('data', (c: Buffer) => chunks.push(c));
  238. r.on('end', () =>
  239. resolve({
  240. status: r.statusCode ?? 0,
  241. type: r.headers['content-type'],
  242. body: Buffer.concat(chunks).toString('utf-8'),
  243. })
  244. );
  245. }
  246. );
  247. req.on('error', reject);
  248. req.end();
  249. });
  250. expect(res.status).toBe(200);
  251. expect(res.type).toBe('text/event-stream; charset=utf-8');
  252. expect(res.body).toBe('');
  253. });
  254. it('announces a source file that changed on disk, before any sync', async () => {
  255. const stream = new Stream();
  256. await stream.open();
  257. try {
  258. await stream.waitFor('hello');
  259. // Give the watcher a moment to install its watch before the write; an
  260. // event that predates the watch is not a bug, just an untestable one.
  261. await new Promise((r) => setTimeout(r, 300));
  262. fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const EXTRA = 1;\n`);
  263. const changed = await stream.waitFor('changed');
  264. expect(changed.data.type).toBe('changed');
  265. expect(changed.data.scan === true || changed.data.files.includes('src/greet.ts')).toBe(true);
  266. // A count always equals a list, or says it was cut.
  267. expect(changed.data.total).toBeGreaterThanOrEqual(changed.data.files.length);
  268. expect(changed.data.files.length).toBeLessThanOrEqual(MAX_EVENT_FILES);
  269. // ...and the index has NOT moved: this server watches, it never syncs.
  270. const source = JSON.parse((await request('/api/source?file=src/greet.ts')).body);
  271. expect(source.drift).toBe(true);
  272. } finally {
  273. fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
  274. stream.close();
  275. }
  276. });
  277. it('announces the index moving, and names what the sync picked up', async () => {
  278. const stream = new Stream();
  279. await stream.open();
  280. try {
  281. await stream.waitFor('hello');
  282. await new Promise((r) => setTimeout(r, 300));
  283. // Another process re-indexes — exactly what a daemon's watcher or a
  284. // `codegraph sync` does while the viewer is open.
  285. fs.writeFileSync(fixture('src/greet.ts'), `${ORIGINAL}\nexport const SYNCED = 2;\n`);
  286. const writer = CodeGraph.openSync(projectRoot);
  287. await writer.sync();
  288. writer.close();
  289. const moved = await stream.waitFor('index');
  290. expect(moved.data.type).toBe('index');
  291. expect(moved.data.index.files).toBe(2);
  292. expect(moved.data.files).toContain('src/greet.ts');
  293. expect(moved.data.total).toBeGreaterThanOrEqual(moved.data.files.length);
  294. // And the graph really did move: the new symbol is there.
  295. const search = JSON.parse((await request('/api/search?q=SYNCED')).body);
  296. expect(search.results.items.some((r: any) => r.name === 'SYNCED')).toBe(true);
  297. } finally {
  298. fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
  299. const writer = CodeGraph.openSync(projectRoot);
  300. await writer.sync();
  301. writer.close();
  302. stream.close();
  303. }
  304. }, 60_000);
  305. it('stops serving a symbol a sync in another process deleted', async () => {
  306. // A node's id contains its start line, so pushing two lines in above
  307. // `shout` gives it a different id. The old one must go — the query layer
  308. // keeps an LRU of nodes by id that only its OWN writes invalidate, so
  309. // without `GraphSession` dropping it this endpoint would keep answering
  310. // 200 with a row that is no longer in the database, while `/api/search`
  311. // beside it correctly says the symbol moved.
  312. const before = JSON.parse((await request('/api/search?q=shout')).body);
  313. const oldId = before.results.items[0].id as string;
  314. expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(200);
  315. fs.writeFileSync(fixture('src/greet.ts'), `// one
  316. // two
  317. ${ORIGINAL}`);
  318. const writer = CodeGraph.openSync(projectRoot);
  319. await writer.sync();
  320. writer.close();
  321. try {
  322. expect((await request(`/api/node/${encodeURIComponent(oldId)}`)).status).toBe(404);
  323. const after = JSON.parse((await request('/api/search?q=shout')).body);
  324. const newId = after.results.items[0].id as string;
  325. expect(newId).not.toBe(oldId);
  326. const moved = JSON.parse((await request(`/api/node/${encodeURIComponent(newId)}`)).body);
  327. expect(moved.node.line).toBe(7);
  328. // ...and its rails came back with it, rather than an empty shell — the
  329. // exact symptom of a cached row whose edges were re-keyed around it.
  330. expect(moved.counts.callees).toBeGreaterThan(0);
  331. } finally {
  332. fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
  333. const restore = CodeGraph.openSync(projectRoot);
  334. await restore.sync();
  335. restore.close();
  336. }
  337. }, 60_000);
  338. it('closes every stream when the API is closed', async () => {
  339. const own = createGraphApi({ projectRoot });
  340. const handle = await startUiServer({
  341. projectRoot,
  342. viewerDir: path.join(tempDir, 'viewer'),
  343. port: 0,
  344. api: own.handler,
  345. });
  346. const ended = new Promise<void>((resolve, reject) => {
  347. const req = http.request(
  348. {
  349. host: '127.0.0.1',
  350. port: handle.port,
  351. path: '/api/events',
  352. method: 'GET',
  353. headers: { Host: `127.0.0.1:${handle.port}` },
  354. setHost: false,
  355. },
  356. (res) => {
  357. res.resume();
  358. res.on('end', () => resolve());
  359. }
  360. );
  361. req.on('error', reject);
  362. req.end();
  363. });
  364. // Let the subscription land before pulling the rug.
  365. await new Promise((r) => setTimeout(r, 200));
  366. own.close();
  367. await ended;
  368. await handle.close();
  369. });
  370. });
  371. describe('GET /api/source?ondrift=', () => {
  372. it('omits the slice by default when the file drifted', async () => {
  373. fs.writeFileSync(fixture('src/greet.ts'), `// a new first line\n${ORIGINAL}`);
  374. try {
  375. const body = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=3')).body);
  376. expect(body.drift).toBe(true);
  377. expect(body.showing).toBe('none');
  378. expect(body.lines).toBeUndefined();
  379. expect(body.highlight).toBeUndefined();
  380. expect(body.reason).toMatch(/changed on disk/);
  381. } finally {
  382. fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
  383. }
  384. });
  385. it('serves the CURRENT bytes when asked, flagged as current', async () => {
  386. const rewritten = `// a new first line\n${ORIGINAL}`;
  387. fs.writeFileSync(fixture('src/greet.ts'), rewritten);
  388. try {
  389. const body = JSON.parse(
  390. (await request('/api/source?file=src/greet.ts&from=1&ondrift=current')).body
  391. );
  392. expect(body.drift).toBe(true);
  393. expect(body.showing).toBe('current');
  394. // The bytes on disk right now, not the ones that were indexed.
  395. expect(body.lines[0]).toBe('// a new first line');
  396. expect(body.totalLines).toBe(rewritten.replace(/\n$/, '').split('\n').length);
  397. // Highlighting rides with them, or the code block paints plain text and
  398. // then reflows.
  399. expect(body.highlight).toBeTruthy();
  400. expect(body.highlight.lines.length).toBe(body.lines.length);
  401. expect(body.reason).toMatch(/current lines/);
  402. } finally {
  403. fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
  404. }
  405. });
  406. it('says showing: indexed when there is no drift, with or without the flag', async () => {
  407. const plain = JSON.parse((await request('/api/source?file=src/greet.ts&from=1&to=2')).body);
  408. expect(plain.drift).toBe(false);
  409. expect(plain.showing).toBe('indexed');
  410. const asked = JSON.parse(
  411. (await request('/api/source?file=src/greet.ts&from=1&to=2&ondrift=current')).body
  412. );
  413. expect(asked.showing).toBe('indexed');
  414. expect(asked.lines).toEqual(plain.lines);
  415. });
  416. it('rejects an ondrift value it does not implement', async () => {
  417. const res = await request('/api/source?file=src/greet.ts&ondrift=guess');
  418. expect(res.status).toBe(400);
  419. expect(res.type).toBe('application/json; charset=utf-8');
  420. expect(JSON.parse(res.body).code).toBe('bad-request');
  421. });
  422. it('answers an empty slice rather than a 400 when a drifted file shrank', async () => {
  423. fs.writeFileSync(fixture('src/greet.ts'), 'export const only = 1;\n');
  424. try {
  425. const res = await request('/api/source?file=src/greet.ts&from=5&to=9&ondrift=current');
  426. expect(res.status).toBe(200);
  427. const body = JSON.parse(res.body);
  428. expect(body.showing).toBe('current');
  429. expect(body.lines).toEqual([]);
  430. expect(body.totalLines).toBe(1);
  431. } finally {
  432. fs.writeFileSync(fixture('src/greet.ts'), ORIGINAL);
  433. }
  434. });
  435. it('still refuses a path outside the project, ondrift or not', async () => {
  436. const res = await request('/api/source?file=/etc/passwd&ondrift=current');
  437. expect(res.status).toBe(403);
  438. expect(JSON.parse(res.body).code).toBe('refused');
  439. });
  440. });