cli-ui-command.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. /**
  2. * `codegraph ui` — the CLI face of the viewer server (CG-41).
  3. *
  4. * Exercised end-to-end against the built binary, because the things worth
  5. * pinning here are the ones that only exist once commander, the project
  6. * resolver and the server are wired together: the help text, the friendly
  7. * "not indexed" guidance, the sensitive-directory refusal, and whether
  8. * `--no-open` actually stops a browser from being launched.
  9. *
  10. * The browser check works by pointing `CODEGRAPH_BROWSER` at a script that
  11. * touches a marker file — so "did it try to open a browser" becomes an
  12. * observable fact rather than a promise.
  13. */
  14. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  15. import { execFileSync, spawn, type ChildProcess } from 'child_process';
  16. import * as fs from 'fs';
  17. import * as http from 'http';
  18. import * as os from 'os';
  19. import * as path from 'path';
  20. import { CodeGraph } from '../src';
  21. import { DEFAULT_UI_PORT as DEFAULT_PORT } from '../src/ui-server/constants';
  22. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  23. const BASE_ENV = {
  24. ...process.env,
  25. CODEGRAPH_NO_DAEMON: '1',
  26. CODEGRAPH_WASM_RELAUNCHED: '1',
  27. NO_COLOR: '1',
  28. };
  29. /** Run the CLI to completion, capturing stdout+stderr and the exit code. */
  30. function runCli(args: string[], env: Record<string, string> = {}): { code: number; output: string } {
  31. try {
  32. const output = execFileSync(process.execPath, [BIN, ...args], {
  33. encoding: 'utf-8',
  34. env: { ...BASE_ENV, ...env },
  35. stdio: ['ignore', 'pipe', 'pipe'],
  36. });
  37. return { code: 0, output };
  38. } catch (err) {
  39. const e = err as { status?: number; stdout?: string; stderr?: string };
  40. return { code: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` };
  41. }
  42. }
  43. /** GET a path from a running viewer, with a valid loopback Host. */
  44. function get(port: number, requestPath: string): Promise<{ status: number; body: string }> {
  45. return new Promise((resolve, reject) => {
  46. const req = http.request(
  47. { host: '127.0.0.1', port, path: requestPath, method: 'GET' },
  48. (res) => {
  49. const chunks: Buffer[] = [];
  50. res.on('data', (c: Buffer) => chunks.push(c));
  51. res.on('end', () =>
  52. resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
  53. );
  54. }
  55. );
  56. req.on('error', reject);
  57. req.end();
  58. });
  59. }
  60. /**
  61. * Start `codegraph ui` and wait for the URL it prints.
  62. *
  63. * The banner IS the readiness signal: the server is bound before the URL is
  64. * printed, so anything the test does after this line is talking to a live
  65. * socket.
  66. */
  67. function startViewer(
  68. args: string[],
  69. env: Record<string, string>
  70. ): Promise<{ child: ChildProcess; port: number; output: () => string }> {
  71. return new Promise((resolve, reject) => {
  72. const child = spawn(process.execPath, [BIN, 'ui', ...args], {
  73. env: { ...BASE_ENV, ...env },
  74. stdio: ['ignore', 'pipe', 'pipe'],
  75. });
  76. let output = '';
  77. const timer = setTimeout(() => {
  78. child.kill('SIGKILL');
  79. reject(new Error(`codegraph ui never printed a URL. Output:\n${output}`));
  80. }, 30_000);
  81. const onChunk = (chunk: Buffer): void => {
  82. output += chunk.toString('utf-8');
  83. const match = output.match(/http:\/\/127\.0\.0\.1:(\d+)/);
  84. if (match?.[1]) {
  85. clearTimeout(timer);
  86. resolve({ child, port: Number(match[1]), output: () => output });
  87. }
  88. };
  89. child.stdout?.on('data', onChunk);
  90. child.stderr?.on('data', onChunk);
  91. child.on('error', (err) => {
  92. clearTimeout(timer);
  93. reject(err);
  94. });
  95. child.on('exit', (code) => {
  96. clearTimeout(timer);
  97. reject(new Error(`codegraph ui exited with ${code} before serving. Output:\n${output}`));
  98. });
  99. });
  100. }
  101. async function stopViewer(child: ChildProcess): Promise<void> {
  102. if (child.exitCode !== null) return;
  103. await new Promise<void>((resolve) => {
  104. child.once('exit', () => resolve());
  105. child.kill('SIGTERM');
  106. // A viewer that ignores SIGTERM must not hang the suite.
  107. setTimeout(() => {
  108. child.kill('SIGKILL');
  109. resolve();
  110. }, 5_000).unref();
  111. });
  112. }
  113. describe('codegraph ui — help', () => {
  114. it('reads well and documents the flags', () => {
  115. const { code, output } = runCli(['ui', '--help']);
  116. expect(code).toBe(0);
  117. expect(output).toContain('--port');
  118. expect(output).toContain('--no-open');
  119. expect(output).toContain('4747');
  120. expect(output).toContain('127.0.0.1');
  121. expect(output).toContain('read-only');
  122. expect(output).toContain('Examples:');
  123. expect(output).toContain('CODEGRAPH_BROWSER');
  124. });
  125. it('works through `codegraph help ui`', () => {
  126. const viaHelpCommand = runCli(['help', 'ui']);
  127. const viaFlag = runCli(['ui', '--help']);
  128. expect(viaHelpCommand.code).toBe(0);
  129. expect(viaHelpCommand.output).toBe(viaFlag.output);
  130. });
  131. it('is listed in the top-level help, and `web` is an alias', () => {
  132. const top = runCli(['--help']);
  133. expect(top.output).toContain('ui|web [options] [path]');
  134. const viaAlias = runCli(['help', 'web']);
  135. expect(viaAlias.code).toBe(0);
  136. expect(viaAlias.output).toContain('--no-open');
  137. });
  138. it('rejects a nonsense --port with a plain message, not a stack trace', () => {
  139. const { code, output } = runCli(['ui', '--port', 'banana']);
  140. expect(code).toBe(1);
  141. expect(output).toContain('--port must be a whole number');
  142. expect(output).not.toContain('at Object.');
  143. });
  144. });
  145. describe('codegraph ui — refusals', () => {
  146. let unindexed: string;
  147. beforeAll(() => {
  148. unindexed = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-unindexed-'));
  149. fs.writeFileSync(path.join(unindexed, 'a.ts'), 'export const a = 1;\n');
  150. });
  151. afterAll(() => {
  152. fs.rmSync(unindexed, { recursive: true, force: true });
  153. });
  154. it('gives friendly guidance — never a stack trace — when there is no index', () => {
  155. const { code, output } = runCli(['ui', unindexed]);
  156. expect(code).toBe(1);
  157. expect(output).toContain('No CodeGraph index found');
  158. expect(output).toContain('codegraph init');
  159. expect(output).not.toContain('at Object.');
  160. expect(output).not.toContain('Error:');
  161. });
  162. // `/etc` is only sensitive on POSIX; on Windows it resolves to a
  163. // non-existent `C:\etc` and the "no index" path handles it instead.
  164. it.runIf(process.platform !== 'win32')('refuses a sensitive system directory', () => {
  165. const { code, output } = runCli(['ui', '/etc']);
  166. expect(code).toBe(1);
  167. expect(output).toContain('Refusing to operate on sensitive');
  168. });
  169. });
  170. describe('codegraph ui — serving', () => {
  171. let projectDir: string;
  172. let markerDir: string;
  173. let opener: string;
  174. beforeAll(async () => {
  175. projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-cli-'));
  176. fs.mkdirSync(path.join(projectDir, 'src'));
  177. fs.writeFileSync(
  178. path.join(projectDir, 'src', 'auth.ts'),
  179. 'export function parseToken(t: string){ return t.trim(); }\n'
  180. );
  181. const cg = CodeGraph.initSync(projectDir);
  182. await cg.indexAll();
  183. cg.close();
  184. // A stand-in browser: records that it was launched, and with what.
  185. markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-open-'));
  186. const markerFile = path.join(markerDir, 'opened.txt');
  187. if (process.platform === 'win32') {
  188. opener = path.join(markerDir, 'open.cmd');
  189. fs.writeFileSync(opener, `@echo %1 > "${markerFile}"\r\n`);
  190. } else {
  191. opener = path.join(markerDir, 'open.sh');
  192. fs.writeFileSync(opener, `#!/bin/sh\nprintf '%s' "$1" > "${markerFile}"\n`);
  193. fs.chmodSync(opener, 0o755);
  194. }
  195. }, 120_000);
  196. afterAll(() => {
  197. fs.rmSync(projectDir, { recursive: true, force: true });
  198. fs.rmSync(markerDir, { recursive: true, force: true });
  199. });
  200. const markerFile = (): string => path.join(markerDir, 'opened.txt');
  201. /** The opener is async (detached); give it a moment before concluding. */
  202. async function waitForMarker(timeoutMs: number): Promise<string | null> {
  203. const deadline = Date.now() + timeoutMs;
  204. for (;;) {
  205. if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8');
  206. if (Date.now() > deadline) return null;
  207. await new Promise((r) => setTimeout(r, 50));
  208. }
  209. }
  210. it('serves the viewer and prints where it is', async () => {
  211. const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
  212. try {
  213. const res = await get(viewer.port, '/');
  214. expect(res.status).toBe(200);
  215. expect(res.body).toContain('<div id="app">');
  216. const banner = viewer.output();
  217. expect(banner).toContain('CodeGraph viewer');
  218. expect(banner).toContain(projectDir);
  219. expect(banner).toContain('this machine only');
  220. } finally {
  221. await stopViewer(viewer.child);
  222. }
  223. }, 60_000);
  224. it('honours --no-open: no browser is launched', async () => {
  225. fs.rmSync(markerFile(), { force: true });
  226. const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {
  227. CODEGRAPH_BROWSER: opener,
  228. });
  229. try {
  230. // Confirm the server is genuinely up before concluding "nothing opened" —
  231. // otherwise this passes for the wrong reason.
  232. expect((await get(viewer.port, '/')).status).toBe(200);
  233. expect(await waitForMarker(1_500)).toBeNull();
  234. expect(viewer.output()).toContain('Open that URL in a browser');
  235. expect(viewer.output()).not.toContain('Opening your browser');
  236. } finally {
  237. await stopViewer(viewer.child);
  238. }
  239. }, 60_000);
  240. it('opens the browser at the served URL when --no-open is absent', async () => {
  241. fs.rmSync(markerFile(), { force: true });
  242. const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: opener });
  243. try {
  244. const opened = await waitForMarker(10_000);
  245. expect(opened).not.toBeNull();
  246. expect(opened?.trim()).toContain(`http://127.0.0.1:${viewer.port}`);
  247. expect(viewer.output()).toContain('Opening your browser');
  248. } finally {
  249. await stopViewer(viewer.child);
  250. }
  251. }, 60_000);
  252. it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => {
  253. fs.rmSync(markerFile(), { force: true });
  254. const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' });
  255. try {
  256. expect((await get(viewer.port, '/')).status).toBe(200);
  257. expect(await waitForMarker(1_000)).toBeNull();
  258. } finally {
  259. await stopViewer(viewer.child);
  260. }
  261. }, 60_000);
  262. it('moves off the default port when it is busy', async () => {
  263. // Occupy 4747 so the fallback has something to fall back FROM. If a
  264. // developer's own viewer already holds it, the bind fails and the
  265. // assertion below is still exactly the right one: the new viewer must not
  266. // be on 4747 either way.
  267. const blocker = http.createServer(() => {});
  268. const bound = await new Promise<boolean>((resolve) => {
  269. blocker.once('error', () => resolve(false));
  270. blocker.listen(DEFAULT_PORT, '127.0.0.1', () => resolve(true));
  271. });
  272. try {
  273. const viewer = await startViewer(['--no-open', projectDir], {});
  274. try {
  275. expect(viewer.port).not.toBe(DEFAULT_PORT);
  276. expect((await get(viewer.port, '/')).status).toBe(200);
  277. } finally {
  278. await stopViewer(viewer.child);
  279. }
  280. } finally {
  281. if (bound) await new Promise<void>((resolve) => blocker.close(() => resolve()));
  282. }
  283. }, 60_000);
  284. it('refuses to move off a port the user pinned with --port', async () => {
  285. const blocker = http.createServer(() => {});
  286. await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
  287. const taken = (blocker.address() as { port: number }).port;
  288. try {
  289. const { code, output } = runCli(['ui', '--no-open', '--port', String(taken), projectDir]);
  290. expect(code).toBe(1);
  291. expect(output).toContain('already in use');
  292. expect(output).not.toContain('at Object.');
  293. } finally {
  294. await new Promise<void>((resolve) => blocker.close(() => resolve()));
  295. }
  296. }, 60_000);
  297. it('refuses a foreign Host end-to-end', async () => {
  298. const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
  299. try {
  300. const res = await new Promise<{ status: number; body: string }>((resolve, reject) => {
  301. const req = http.request(
  302. {
  303. host: '127.0.0.1',
  304. port: viewer.port,
  305. path: '/',
  306. headers: { Host: 'evil.example' },
  307. setHost: false,
  308. },
  309. (r) => {
  310. const chunks: Buffer[] = [];
  311. r.on('data', (c: Buffer) => chunks.push(c));
  312. r.on('end', () =>
  313. resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
  314. );
  315. }
  316. );
  317. req.on('error', reject);
  318. req.end();
  319. });
  320. expect(res.status).toBe(403);
  321. expect(res.body).not.toContain('<div id="app">');
  322. } finally {
  323. await stopViewer(viewer.child);
  324. }
  325. }, 60_000);
  326. });