feat: add persistent browser server with auto-discovery

Add `glimpse serve` which starts geckodriver + Firefox and proxies
WebDriver requests through a Unix socket at
$XDG_RUNTIME_DIR/glimpse-<uid>.sock. All commands auto-discover the
socket and reuse the running browser session (~300ms vs ~2-3s per
command).

The proxy intercepts session create/delete to keep Firefox alive:
new session requests return the existing session ID, delete session
navigates to about:blank instead of closing Firefox.

- `glimpse serve` starts in foreground, logs to stderr
- `glimpse serve --stop` sends shutdown via socket
- `glimpse serve --status` prints JSON status
- SIGTERM/SIGINT do full cleanup (Firefox + geckodriver + socket)
- Second instance detected and rejected with exit code 1
- GLIMPSE_SOCKET_PATH env var for test isolation
- Three new smoke tests for serve lifecycle
This commit is contained in:
2026-05-02 20:32:23 -04:00
parent d02df19469
commit e3d7c28820
5 changed files with 587 additions and 3 deletions

View File

@@ -10,7 +10,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { spawnSync, spawn } from "node:child_process";
import assert from "node:assert/strict";
const cliPath = new URL("../src/index.ts", import.meta.url).pathname;
@@ -163,6 +163,119 @@ test(
},
);
// Serve Helper - Starts a glimpse serve process with an isolated socket,
// waits for the "server listening" message, and returns a handle with stop().
function startServe(extraEnv = {}) {
const sock = join(tempDir, `serve-${Date.now()}.sock`);
const env = { ...process.env, GLIMPSE_SOCKET_PATH: sock, ...extraEnv };
const child = spawn(process.execPath, ["--import", "tsx", cliPath, "serve"], {
encoding: "utf-8",
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const ready = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`serve did not start: ${stderr}`)), 15000);
child.stderr.on("data", () => {
if (stderr.includes("server listening")) {
clearTimeout(timeout);
resolve();
}
});
child.on("exit", (code) => {
clearTimeout(timeout);
reject(new Error(`serve exited early (code ${code}): ${stderr}`));
});
});
function runWithSocket(args) {
return spawnSync(process.execPath, ["--import", "tsx", cliPath, ...args], {
encoding: "utf-8",
env,
timeout: 30000,
});
}
function stop() {
runWithSocket(["serve", "--stop"]);
return new Promise((resolve) => {
child.on("exit", () => resolve());
setTimeout(() => { child.kill(); resolve(); }, 5000);
});
}
return { ready, stop, runWithSocket, sock, child };
}
test(
"serve starts and stops cleanly",
["serve"],
async () => {
const server = startServe();
await server.ready;
// Status Should Report Running
const status = server.runWithSocket(["serve", "--status"]);
assert.equal(status.status, 0, status.stderr);
const info = parseJson(status.stdout);
assert.equal(info.ok, true);
assert.equal(typeof info.sessionId, "string");
assert.equal(info.socket, server.sock);
await server.stop();
assert.equal(existsSync(server.sock), false, "socket should be cleaned up");
},
);
test(
"serve reuses browser session across commands",
["serve"],
async () => {
const server = startServe();
await server.ready;
// First Command
const r1 = server.runWithSocket([
"reader",
dataHtml("<h1>First</h1>"),
"--no-reader",
]);
assert.equal(r1.status, 0, r1.stderr);
assert.match(r1.stdout, /# First/);
// Second Command
const r2 = server.runWithSocket([
"reader",
dataHtml("<h1>Second</h1>"),
"--no-reader",
]);
assert.equal(r2.status, 0, r2.stderr);
assert.match(r2.stdout, /# Second/);
await server.stop();
},
);
test(
"serve rejects second instance",
["serve", "errors"],
async () => {
const server = startServe();
await server.ready;
// Second Instance Should Fail
const result = server.runWithSocket(["serve"]);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /already running/);
await server.stop();
},
);
test("search validates kagi token in provider", ["search", "errors"], () => {
const env = { ...process.env };
delete env.KAGI_TOKEN;