mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-03 22:57:40 +08:00
`codegraph ui` re-execs itself with `--liftoff-only`; the shim the user started blocks in spawnSync and cannot forward a signal. Killing it by pid — a process manager, an IDE task, `kill` — left the re-exec'd server serving its port forever (found during a sweep: servers from hours earlier were still answering on ports 4801–4808 against deleted indexes, holding their stdout pipes so the harness that started them never exited). Ctrl+C was unaffected (the signal reaches the whole process group). `index`/`init` already guard this with the #277 PPID watchdog. Factor it out of installCommandSupervision as `watchParent(onLost)` and use it in `ui` with the same shutdown as SIGINT/SIGTERM (close the index, then the socket). The liveness watchdog is deliberately not installed for `ui`: a long Steps computation can legitimately hold the event loop. Test: POSIX end-to-end through the real relaunch (CODEGRAPH_WASM_RELAUNCHED unset, 200ms poll) — SIGKILL the shim, the port stops answering; confirmed failing without the fix. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
/**
|
|
* `codegraph ui` — the CLI face of the viewer server (CG-41).
|
|
*
|
|
* Exercised end-to-end against the built binary, because the things worth
|
|
* pinning here are the ones that only exist once commander, the project
|
|
* resolver and the server are wired together: the help text, the friendly
|
|
* "not indexed" guidance, the sensitive-directory refusal, and whether
|
|
* `--no-open` actually stops a browser from being launched.
|
|
*
|
|
* The browser check works by pointing `CODEGRAPH_BROWSER` at a script that
|
|
* touches a marker file — so "did it try to open a browser" becomes an
|
|
* observable fact rather than a promise.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { execFileSync, spawn, type ChildProcess } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as http from 'http';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { DEFAULT_UI_PORT as DEFAULT_PORT } from '../src/ui-server/constants';
|
|
|
|
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
|
|
const BASE_ENV = {
|
|
...process.env,
|
|
// The viewer is opt-in until it is released (see src/bin/viewer-gate.ts).
|
|
CODEGRAPH_UI: '1',
|
|
CODEGRAPH_NO_DAEMON: '1',
|
|
CODEGRAPH_WASM_RELAUNCHED: '1',
|
|
NO_COLOR: '1',
|
|
};
|
|
|
|
/** Run the CLI to completion, capturing stdout+stderr and the exit code. */
|
|
function runCli(args: string[], env: Record<string, string> = {}): { code: number; output: string } {
|
|
try {
|
|
const output = execFileSync(process.execPath, [BIN, ...args], {
|
|
encoding: 'utf-8',
|
|
env: { ...BASE_ENV, ...env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
return { code: 0, output };
|
|
} catch (err) {
|
|
const e = err as { status?: number; stdout?: string; stderr?: string };
|
|
return { code: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` };
|
|
}
|
|
}
|
|
|
|
/** GET a path from a running viewer, with a valid loopback Host. */
|
|
function get(port: number, requestPath: string): Promise<{ status: number; body: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{ host: '127.0.0.1', port, path: requestPath, method: 'GET' },
|
|
(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') })
|
|
);
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start `codegraph ui` and wait for the URL it prints.
|
|
*
|
|
* The banner IS the readiness signal: the server is bound before the URL is
|
|
* printed, so anything the test does after this line is talking to a live
|
|
* socket.
|
|
*/
|
|
function startViewer(
|
|
args: string[],
|
|
env: Record<string, string>
|
|
): Promise<{ child: ChildProcess; port: number; output: () => string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [BIN, 'ui', ...args], {
|
|
env: { ...BASE_ENV, ...env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let output = '';
|
|
const timer = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
reject(new Error(`codegraph ui never printed a URL. Output:\n${output}`));
|
|
}, 30_000);
|
|
|
|
const onChunk = (chunk: Buffer): void => {
|
|
output += chunk.toString('utf-8');
|
|
const match = output.match(/http:\/\/127\.0\.0\.1:(\d+)/);
|
|
if (match?.[1]) {
|
|
clearTimeout(timer);
|
|
resolve({ child, port: Number(match[1]), output: () => output });
|
|
}
|
|
};
|
|
child.stdout?.on('data', onChunk);
|
|
child.stderr?.on('data', onChunk);
|
|
child.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
child.on('exit', (code) => {
|
|
clearTimeout(timer);
|
|
reject(new Error(`codegraph ui exited with ${code} before serving. Output:\n${output}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopViewer(child: ChildProcess): Promise<void> {
|
|
if (child.exitCode !== null) return;
|
|
await new Promise<void>((resolve) => {
|
|
child.once('exit', () => resolve());
|
|
child.kill('SIGTERM');
|
|
// A viewer that ignores SIGTERM must not hang the suite.
|
|
setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
resolve();
|
|
}, 5_000).unref();
|
|
});
|
|
}
|
|
|
|
describe('codegraph ui — help', () => {
|
|
it('reads well and documents the flags', () => {
|
|
const { code, output } = runCli(['ui', '--help']);
|
|
expect(code).toBe(0);
|
|
expect(output).toContain('--port');
|
|
expect(output).toContain('--no-open');
|
|
expect(output).toContain('4747');
|
|
expect(output).toContain('127.0.0.1');
|
|
expect(output).toContain('read-only');
|
|
expect(output).toContain('Examples:');
|
|
expect(output).toContain('CODEGRAPH_BROWSER');
|
|
});
|
|
|
|
it('works through `codegraph help ui`', () => {
|
|
const viaHelpCommand = runCli(['help', 'ui']);
|
|
const viaFlag = runCli(['ui', '--help']);
|
|
expect(viaHelpCommand.code).toBe(0);
|
|
expect(viaHelpCommand.output).toBe(viaFlag.output);
|
|
});
|
|
|
|
it('is listed in the top-level help, and `web` is an alias', () => {
|
|
const top = runCli(['--help']);
|
|
expect(top.output).toContain('ui|web [options] [path]');
|
|
const viaAlias = runCli(['help', 'web']);
|
|
expect(viaAlias.code).toBe(0);
|
|
expect(viaAlias.output).toContain('--no-open');
|
|
});
|
|
|
|
it('rejects a nonsense --port with a plain message, not a stack trace', () => {
|
|
const { code, output } = runCli(['ui', '--port', 'banana']);
|
|
expect(code).toBe(1);
|
|
expect(output).toContain('--port must be a whole number');
|
|
expect(output).not.toContain('at Object.');
|
|
});
|
|
});
|
|
|
|
describe('codegraph ui — refusals', () => {
|
|
let unindexed: string;
|
|
|
|
beforeAll(() => {
|
|
unindexed = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-unindexed-'));
|
|
fs.writeFileSync(path.join(unindexed, 'a.ts'), 'export const a = 1;\n');
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(unindexed, { recursive: true, force: true });
|
|
});
|
|
|
|
it('gives friendly guidance — never a stack trace — when there is no index', () => {
|
|
const { code, output } = runCli(['ui', unindexed]);
|
|
expect(code).toBe(1);
|
|
expect(output).toContain('No CodeGraph index found');
|
|
expect(output).toContain('codegraph init');
|
|
expect(output).not.toContain('at Object.');
|
|
expect(output).not.toContain('Error:');
|
|
});
|
|
|
|
// `/etc` is only sensitive on POSIX; on Windows it resolves to a
|
|
// non-existent `C:\etc` and the "no index" path handles it instead.
|
|
it.runIf(process.platform !== 'win32')('refuses a sensitive system directory', () => {
|
|
const { code, output } = runCli(['ui', '/etc']);
|
|
expect(code).toBe(1);
|
|
expect(output).toContain('Refusing to operate on sensitive');
|
|
});
|
|
});
|
|
|
|
describe('codegraph ui — serving', () => {
|
|
let projectDir: string;
|
|
let markerDir: string;
|
|
let opener: string;
|
|
|
|
beforeAll(async () => {
|
|
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-cli-'));
|
|
fs.mkdirSync(path.join(projectDir, 'src'));
|
|
fs.writeFileSync(
|
|
path.join(projectDir, 'src', 'auth.ts'),
|
|
'export function parseToken(t: string){ return t.trim(); }\n'
|
|
);
|
|
const cg = CodeGraph.initSync(projectDir);
|
|
await cg.indexAll();
|
|
cg.close();
|
|
|
|
// A stand-in browser: records that it was launched, and with what.
|
|
markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-open-'));
|
|
const markerFile = path.join(markerDir, 'opened.txt');
|
|
if (process.platform === 'win32') {
|
|
opener = path.join(markerDir, 'open.cmd');
|
|
fs.writeFileSync(opener, `@echo %1 > "${markerFile}"\r\n`);
|
|
} else {
|
|
opener = path.join(markerDir, 'open.sh');
|
|
fs.writeFileSync(opener, `#!/bin/sh\nprintf '%s' "$1" > "${markerFile}"\n`);
|
|
fs.chmodSync(opener, 0o755);
|
|
}
|
|
}, 120_000);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
fs.rmSync(markerDir, { recursive: true, force: true });
|
|
});
|
|
|
|
const markerFile = (): string => path.join(markerDir, 'opened.txt');
|
|
|
|
/** The opener is async (detached); give it a moment before concluding. */
|
|
async function waitForMarker(timeoutMs: number): Promise<string | null> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8');
|
|
if (Date.now() > deadline) return null;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
}
|
|
|
|
it('serves the viewer and prints where it is', async () => {
|
|
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
|
|
try {
|
|
const res = await get(viewer.port, '/');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toContain('<div id="app">');
|
|
|
|
const banner = viewer.output();
|
|
expect(banner).toContain('CodeGraph viewer');
|
|
expect(banner).toContain(projectDir);
|
|
expect(banner).toContain('this machine only');
|
|
} finally {
|
|
await stopViewer(viewer.child);
|
|
}
|
|
}, 60_000);
|
|
|
|
it('honours --no-open: no browser is launched', async () => {
|
|
fs.rmSync(markerFile(), { force: true });
|
|
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {
|
|
CODEGRAPH_BROWSER: opener,
|
|
});
|
|
try {
|
|
// Confirm the server is genuinely up before concluding "nothing opened" —
|
|
// otherwise this passes for the wrong reason.
|
|
expect((await get(viewer.port, '/')).status).toBe(200);
|
|
expect(await waitForMarker(1_500)).toBeNull();
|
|
expect(viewer.output()).toContain('Open that URL in a browser');
|
|
expect(viewer.output()).not.toContain('Opening your browser');
|
|
} finally {
|
|
await stopViewer(viewer.child);
|
|
}
|
|
}, 60_000);
|
|
|
|
it('opens the browser at the served URL when --no-open is absent', async () => {
|
|
fs.rmSync(markerFile(), { force: true });
|
|
const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: opener });
|
|
try {
|
|
const opened = await waitForMarker(10_000);
|
|
expect(opened).not.toBeNull();
|
|
expect(opened?.trim()).toContain(`http://127.0.0.1:${viewer.port}`);
|
|
expect(viewer.output()).toContain('Opening your browser');
|
|
} finally {
|
|
await stopViewer(viewer.child);
|
|
}
|
|
}, 60_000);
|
|
|
|
it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => {
|
|
fs.rmSync(markerFile(), { force: true });
|
|
const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' });
|
|
try {
|
|
expect((await get(viewer.port, '/')).status).toBe(200);
|
|
expect(await waitForMarker(1_000)).toBeNull();
|
|
} finally {
|
|
await stopViewer(viewer.child);
|
|
}
|
|
}, 60_000);
|
|
|
|
it('moves off the default port when it is busy', async () => {
|
|
// Occupy 4747 so the fallback has something to fall back FROM. If a
|
|
// developer's own viewer already holds it, the bind fails and the
|
|
// assertion below is still exactly the right one: the new viewer must not
|
|
// be on 4747 either way.
|
|
const blocker = http.createServer(() => {});
|
|
const bound = await new Promise<boolean>((resolve) => {
|
|
blocker.once('error', () => resolve(false));
|
|
blocker.listen(DEFAULT_PORT, '127.0.0.1', () => resolve(true));
|
|
});
|
|
|
|
try {
|
|
const viewer = await startViewer(['--no-open', projectDir], {});
|
|
try {
|
|
expect(viewer.port).not.toBe(DEFAULT_PORT);
|
|
expect((await get(viewer.port, '/')).status).toBe(200);
|
|
} finally {
|
|
await stopViewer(viewer.child);
|
|
}
|
|
} finally {
|
|
if (bound) await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
}
|
|
}, 60_000);
|
|
|
|
it('refuses to move off a port the user pinned with --port', async () => {
|
|
const blocker = http.createServer(() => {});
|
|
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
|
|
const taken = (blocker.address() as { port: number }).port;
|
|
try {
|
|
const { code, output } = runCli(['ui', '--no-open', '--port', String(taken), projectDir]);
|
|
expect(code).toBe(1);
|
|
expect(output).toContain('already in use');
|
|
expect(output).not.toContain('at Object.');
|
|
} finally {
|
|
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
|
}
|
|
}, 60_000);
|
|
|
|
// `codegraph ui` re-execs itself with `--liftoff-only`, and the command the
|
|
// user started blocks in spawnSync, unable to forward a signal: killing it by
|
|
// pid (a process manager, an IDE task, `kill`) used to leave the server
|
|
// serving the port forever. POSIX-only: it relies on reparenting.
|
|
it.runIf(process.platform !== 'win32')('stops serving when the command it was started as is killed', async () => {
|
|
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {
|
|
CODEGRAPH_WASM_RELAUNCHED: '',
|
|
CODEGRAPH_PPID_POLL_MS: '200',
|
|
});
|
|
expect((await get(viewer.port, '/api/stats')).status).toBe(200);
|
|
viewer.child.kill('SIGKILL');
|
|
const deadline = Date.now() + 15_000;
|
|
let serving = true;
|
|
while (serving && Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
serving = await get(viewer.port, '/api/stats').then(
|
|
() => true,
|
|
() => false
|
|
);
|
|
}
|
|
expect(serving).toBe(false);
|
|
}, 60_000);
|
|
|
|
it('refuses a foreign Host end-to-end', async () => {
|
|
const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {});
|
|
try {
|
|
const res = await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
|
const req = http.request(
|
|
{
|
|
host: '127.0.0.1',
|
|
port: viewer.port,
|
|
path: '/',
|
|
headers: { Host: 'evil.example' },
|
|
setHost: false,
|
|
},
|
|
(r) => {
|
|
const chunks: Buffer[] = [];
|
|
r.on('data', (c: Buffer) => chunks.push(c));
|
|
r.on('end', () =>
|
|
resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') })
|
|
);
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).not.toContain('<div id="app">');
|
|
} finally {
|
|
await stopViewer(viewer.child);
|
|
}
|
|
}, 60_000);
|
|
});
|