Skip to content

Commit 88634b3

Browse files
authored
Merge pull request #48 from itsjling/codex/issue-026-headless-presenter
Make the presenter headless and automation-friendly
2 parents 2dc921d + f65050c commit 88634b3

6 files changed

Lines changed: 323 additions & 14 deletions

File tree

‎scripts/cli-args.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const valueOptions = new Set([
1818
'--batch-size',
1919
'--jobs',
2020
'--port',
21+
'--host',
2122
]);
2223
const flagOptions = new Set([
2324
'--help',
@@ -26,6 +27,7 @@ const flagOptions = new Set([
2627
'--no-agent',
2728
'--force',
2829
'--worktree',
30+
'--no-browser',
2931
]);
3032
const pathOptions = new Set([
3133
'--summaries',
@@ -61,6 +63,8 @@ Options:
6163
--force Regenerate all agent notes
6264
--remote NAME|URL Git remote (default: origin)
6365
--port NUMBER Local page port (default: 2299)
66+
--host ADDRESS Page bind address (default: localhost)
67+
--no-browser Do not open the page in a browser
6468
-h, --help Show this help
6569
-v, --version Show the installed version
6670
@@ -344,6 +348,8 @@ export function parseCliArgs(
344348
agentArgs,
345349
port: Number(portValue),
346350
portWasPassed: options.has('--port'),
351+
host: options.get('--host') || 'localhost',
352+
browserEnabled: !options.has('--no-browser'),
347353
forceSummaryRegeneration: options.has('--force'),
348354
};
349355
}

‎scripts/present.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ if (cli.doctor) {
4848
process.exit(report.ready ? 0 : 1);
4949
}
5050

51-
const { agentEnabled, port } = cli;
51+
const { agentEnabled, browserEnabled, host, port } = cli;
5252
const feedArgs = [...cli.feedArgs];
5353
const agentArgs = [...cli.agentArgs];
5454
if (agentEnabled) {
@@ -168,6 +168,8 @@ function startSite() {
168168
outputPath,
169169
'--port',
170170
String(port),
171+
'--host',
172+
host,
171173
'--project',
172174
projectKey,
173175
...(!cli.portWasPassed ? ['--increment-port'] : []),
@@ -190,7 +192,7 @@ function startSite() {
190192
}
191193
console.log(line);
192194
const match = line.match(/^Diffsplain: (http:\/\/\S+)$/);
193-
if (!browserOpened && !browserOpenTimer && match) {
195+
if (browserEnabled && !browserOpened && !browserOpenTimer && match) {
194196
browserOpenTimer = setTimeout(() => {
195197
browserOpenTimer = undefined;
196198
browserOpened = true;

‎scripts/serve-built.mjs‎

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function option(name, fallback) {
2424
const output = resolve(option('--output', resolve(root, '.cache/diff-data.json')));
2525
const project = option('--project', '');
2626
const portValue = option('--port', '2299');
27+
const host = option('--host', 'localhost');
2728
if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
2829
throw new Error('--port must be a number from 0 to 65535');
2930
}
@@ -86,6 +87,15 @@ async function fetchAsset(request) {
8687
return fileResponse(file);
8788
}
8889

90+
function jsonResponse(value) {
91+
return new Response(JSON.stringify(value), {
92+
headers: {
93+
'cache-control': 'no-store',
94+
'content-type': 'application/json; charset=utf-8',
95+
},
96+
});
97+
}
98+
8999
function nodeRequest(request) {
90100
const host = request.headers.host || 'localhost';
91101
const init = {
@@ -111,15 +121,20 @@ async function send(nodeResponse, response) {
111121
const server = createServer(async (request, response) => {
112122
try {
113123
const webRequest = nodeRequest(request);
114-
if (new URL(webRequest.url).pathname === '/events') {
124+
const url = new URL(webRequest.url);
125+
if (url.pathname === '/health') {
126+
await send(response, jsonResponse(readyState));
127+
return;
128+
}
129+
if (url.pathname === '/events') {
115130
response.writeHead(200, {
116131
'content-type': 'text/event-stream',
117132
'cache-control': 'no-store',
118133
connection: 'keep-alive',
119134
});
120135
response.write('retry: 250\nevent: ready\ndata: {}\n\n');
121136
eventClients.add(response);
122-
const requestProject = new URL(webRequest.url).searchParams.get('project');
137+
const requestProject = url.searchParams.get('project');
123138
if (project && requestProject === project) {
124139
console.log('Diffsplain tab: connected');
125140
}
@@ -148,19 +163,53 @@ watchFile(output, { interval: 100 }, (current, previous) => {
148163
});
149164

150165
let selectedPort = Number(portValue);
166+
let readyState;
167+
let closing = false;
168+
169+
function urlFor(address, port) {
170+
const formattedAddress = address.includes(':') ? `[${address}]` : address;
171+
return `http://${formattedAddress}:${port}`;
172+
}
173+
174+
function isLoopback(address) {
175+
return (
176+
address === 'localhost' ||
177+
address === '::1' ||
178+
address === '::ffff:127.0.0.1' ||
179+
/^127(?:\.\d{1,3}){3}$/.test(address)
180+
);
181+
}
151182

152183
function listen() {
153-
server.listen(selectedPort, 'localhost');
184+
server.listen(selectedPort, host);
154185
}
155186

156187
server.on('listening', () => {
188+
if (closing) {
189+
server.close();
190+
return;
191+
}
157192
const address = server.address();
193+
const readyAddress =
194+
address && typeof address === 'object' ? address.address : host;
158195
const readyPort =
159196
address && typeof address === 'object' ? address.port : selectedPort;
197+
const url = urlFor(host, readyPort);
160198
const projectHash = project
161199
? `#project=${encodeURIComponent(project)}`
162200
: '';
163-
console.log(`Diffsplain: http://localhost:${readyPort}${projectHash}`);
201+
readyState = {
202+
status: 'ok',
203+
address: readyAddress,
204+
port: readyPort,
205+
};
206+
if (!isLoopback(readyAddress)) {
207+
console.warn(
208+
`Warning: Diffsplain is listening on ${readyAddress}. Anyone who can reach this address can view this review.`,
209+
);
210+
}
211+
console.log(`Diffsplain: ${url}${projectHash}`);
212+
console.log(JSON.stringify({ event: 'ready', ...readyState, url }));
164213
});
165214

166215
server.on('error', (error) => {
@@ -175,22 +224,20 @@ server.on('error', (error) => {
175224
return;
176225
}
177226
console.error(`Could not start Diffsplain: ${error.message}`);
178-
process.exitCode = 1;
227+
close(1);
179228
});
180229

181230
listen();
182231

183-
let closing = false;
184-
function close() {
232+
function close(exitCode = 0) {
185233
if (closing) return;
186234
closing = true;
235+
process.exitCode = exitCode;
187236
unwatchFile(output);
188237
for (const client of eventClients) client.end();
189238
eventClients.clear();
190-
server.close(() => {
191-
process.exitCode = 0;
192-
});
239+
if (server.listening) server.close();
193240
}
194241

195-
process.on('SIGINT', close);
196-
process.on('SIGTERM', close);
242+
process.on('SIGINT', () => close());
243+
process.on('SIGTERM', () => close());

‎tests/cli-args.test.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,22 @@ test('leaves agent selection open when no agent is passed', () => {
1717
assert.equal(parsed.agent, undefined);
1818
assert.equal(parsed.port, 2299);
1919
assert.equal(parsed.portWasPassed, false);
20+
assert.equal(parsed.host, 'localhost');
21+
assert.equal(parsed.browserEnabled, true);
2022
assert.deepEqual(parsed.feedArgs, ['--repo', cwd, '--checkout']);
2123
assert.deepEqual(parsed.agentArgs, ['--repo', cwd, '--checkout']);
2224
});
2325

26+
test('accepts headless browser and explicit bind options', () => {
27+
const parsed = parseCliArgs(['--no-browser', '--host', '0.0.0.0'], {
28+
callerDirectory: cwd,
29+
pathExists: missing,
30+
});
31+
32+
assert.equal(parsed.browserEnabled, false);
33+
assert.equal(parsed.host, '0.0.0.0');
34+
});
35+
2436
test('accepts a GitHub owner/name repo and a branch', () => {
2537
const parsed = parseCliArgs(['acme/widgets', '--branch', 'feature/search'], {
2638
callerDirectory: cwd,

‎tests/present-instances.test.mjs‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ function waitForUrl(child) {
6060
});
6161
}
6262

63+
function waitForText(stream, pattern) {
64+
return new Promise((resolve, reject) => {
65+
let output = '';
66+
const timer = setTimeout(() => {
67+
reject(new Error(`Did not find ${pattern}: ${output}`));
68+
}, 12_000);
69+
stream.on('data', (chunk) => {
70+
output += chunk;
71+
if (pattern.test(output)) {
72+
clearTimeout(timer);
73+
resolve(output);
74+
}
75+
});
76+
});
77+
}
78+
6379
async function waitFor(read, timeout = 8_000) {
6480
const deadline = Date.now() + timeout;
6581
let lastError;
@@ -308,3 +324,73 @@ test('reuses a matching project tab when it reconnects', async () => {
308324
await rm(root, { recursive: true, force: true });
309325
}
310326
});
327+
328+
test('stays available when browser launch fails and skips it when asked', async () => {
329+
const root = await mkdtemp(join(tmpdir(), 'diffsplain-headless-'));
330+
const browserLog = join(root, 'browser.log');
331+
const browser = join(root, 'browser');
332+
let failingPresenter;
333+
let headlessPresenter;
334+
335+
try {
336+
const repo = await makeRepo(root, 'repo', 'file.txt');
337+
failingPresenter = spawn(
338+
process.execPath,
339+
[
340+
script,
341+
'--repo',
342+
repo,
343+
'--worktree',
344+
'--no-agent',
345+
'--port',
346+
'0',
347+
],
348+
{
349+
cwd: root,
350+
env: { ...process.env, BROWSER: join(root, 'missing-browser') },
351+
stdio: ['ignore', 'pipe', 'pipe'],
352+
},
353+
);
354+
const browserFailure = waitForText(
355+
failingPresenter.stderr,
356+
/Could not open the browser:/,
357+
);
358+
const failingUrl = await waitForUrl(failingPresenter);
359+
await browserFailure;
360+
assert.equal((await fetch(new URL('health', failingUrl))).status, 200);
361+
assert.equal(failingPresenter.exitCode, null);
362+
363+
await writeFile(browser, '#!/bin/sh\nprintf opened > "$BROWSER_LOG"\n');
364+
await chmod(browser, 0o755);
365+
headlessPresenter = spawn(
366+
process.execPath,
367+
[
368+
script,
369+
'--repo',
370+
repo,
371+
'--worktree',
372+
'--no-agent',
373+
'--no-browser',
374+
'--port',
375+
'0',
376+
],
377+
{
378+
cwd: root,
379+
env: { ...process.env, BROWSER: browser, BROWSER_LOG: browserLog },
380+
stdio: ['ignore', 'pipe', 'pipe'],
381+
},
382+
);
383+
const headlessUrl = await waitForUrl(headlessPresenter);
384+
assert.equal((await fetch(new URL('health', headlessUrl))).status, 200);
385+
await new Promise((resolve) => setTimeout(resolve, 900));
386+
await assert.rejects(readFile(browserLog, 'utf8'));
387+
} finally {
388+
if (failingPresenter && failingPresenter.exitCode === null) {
389+
await stop(failingPresenter);
390+
}
391+
if (headlessPresenter && headlessPresenter.exitCode === null) {
392+
await stop(headlessPresenter);
393+
}
394+
await rm(root, { recursive: true, force: true });
395+
}
396+
});

0 commit comments

Comments
 (0)