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:
@@ -42,6 +42,7 @@ Current `glimpse` subcommands:
|
|||||||
- `exec <url> --js=<code>` or `--script=<file>` - execute JavaScript and return the result
|
- `exec <url> --js=<code>` or `--script=<file>` - execute JavaScript and return the result
|
||||||
- `screenshot <url> --output=<file>` - save a PNG screenshot
|
- `screenshot <url> --output=<file>` - save a PNG screenshot
|
||||||
- `search <query>` - search with a supported provider and output JSON results
|
- `search <query>` - search with a supported provider and output JSON results
|
||||||
|
- `serve` - start a persistent browser server (geckodriver + Firefox) for faster repeat commands; auto-discovered via Unix socket, `--stop` to shut down, `--status` to check
|
||||||
|
|
||||||
## Runtime Requirements
|
## Runtime Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { Builder, type WebDriver } from "selenium-webdriver";
|
import { Builder, WebDriver } from "selenium-webdriver";
|
||||||
export { type WebDriver };
|
export { type WebDriver };
|
||||||
import firefox from "selenium-webdriver/firefox.js";
|
import firefox from "selenium-webdriver/firefox.js";
|
||||||
|
import { isServerRunning, socketPath } from "./serve.js";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { HttpClient, Executor } = require("selenium-webdriver/http");
|
||||||
|
|
||||||
export interface DriverOptions {
|
export interface DriverOptions {
|
||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
existingUrl?: string;
|
existingUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find Geckodriver
|
||||||
function findGeckodriver(): string {
|
function findGeckodriver(): string {
|
||||||
try {
|
try {
|
||||||
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
|
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
|
||||||
@@ -18,10 +25,31 @@ function findGeckodriver(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create Driver - Connects to a glimpse serve socket if available, falls
|
||||||
|
// back to an explicit --url server, or spins up an ad-hoc geckodriver.
|
||||||
export async function createDriver({
|
export async function createDriver({
|
||||||
headless = false,
|
headless = false,
|
||||||
existingUrl,
|
existingUrl,
|
||||||
}: DriverOptions = {}): Promise<WebDriver> {
|
}: DriverOptions = {}): Promise<WebDriver> {
|
||||||
|
// Check For Glimpse Server Socket
|
||||||
|
if (!existingUrl) {
|
||||||
|
const sock = socketPath();
|
||||||
|
const running = await isServerRunning(sock);
|
||||||
|
if (running) {
|
||||||
|
const agent = new http.Agent(
|
||||||
|
// @ts-ignore — socketPath is supported by Node but not in the type defs
|
||||||
|
{ socketPath: sock },
|
||||||
|
);
|
||||||
|
// URL is required by HttpClient but the agent routes via socket
|
||||||
|
const client = new HttpClient("http://localhost", agent);
|
||||||
|
const executor = new Executor(client);
|
||||||
|
return WebDriver.createSession(
|
||||||
|
executor,
|
||||||
|
new firefox.Options(),
|
||||||
|
) as unknown as WebDriver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const options = new firefox.Options();
|
const options = new firefox.Options();
|
||||||
|
|
||||||
// Configure Headless
|
// Configure Headless
|
||||||
|
|||||||
23
src/index.ts
23
src/index.ts
@@ -3,6 +3,7 @@
|
|||||||
import { loadConfig, type GlimpseConfig } from "./config.js";
|
import { loadConfig, type GlimpseConfig } from "./config.js";
|
||||||
import { createDriver, type WebDriver } from "./driver.js";
|
import { createDriver, type WebDriver } from "./driver.js";
|
||||||
import { searchKagi, type SearchResult } from "./providers/kagi.js";
|
import { searchKagi, type SearchResult } from "./providers/kagi.js";
|
||||||
|
import { startServer, stopServer, serverStatus } from "./serve.js";
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
import TurndownService from "turndown";
|
import TurndownService from "turndown";
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ Commands:
|
|||||||
exec <url> [options] Execute JavaScript on a page and return the result
|
exec <url> [options] Execute JavaScript on a page and return the result
|
||||||
screenshot <url> [options] Save a PNG screenshot of a page
|
screenshot <url> [options] Save a PNG screenshot of a page
|
||||||
search <query> [options] Search using a supported provider and return JSON results
|
search <query> [options] Search using a supported provider and return JSON results
|
||||||
|
serve [options] Start a persistent browser server for faster repeat commands
|
||||||
|
|
||||||
Common Options:
|
Common Options:
|
||||||
--help Show this help
|
--help Show this help
|
||||||
@@ -116,6 +118,10 @@ Reader Options:
|
|||||||
--output=<file> Write output to a file
|
--output=<file> Write output to a file
|
||||||
--no-reader Skip Reader View and use raw page extraction
|
--no-reader Skip Reader View and use raw page extraction
|
||||||
|
|
||||||
|
Serve Options:
|
||||||
|
--stop Stop a running server
|
||||||
|
--status Show server status
|
||||||
|
|
||||||
Search Options:
|
Search Options:
|
||||||
--provider=<provider> Search provider: kagi (default: config or kagi)
|
--provider=<provider> Search provider: kagi (default: config or kagi)
|
||||||
--token=<token> Kagi token (default: KAGI_TOKEN or config)
|
--token=<token> Kagi token (default: KAGI_TOKEN or config)
|
||||||
@@ -128,7 +134,10 @@ Examples:
|
|||||||
glimpse exec https://example.com --js="return document.title"
|
glimpse exec https://example.com --js="return document.title"
|
||||||
glimpse exec https://example.com --script=extract.js
|
glimpse exec https://example.com --script=extract.js
|
||||||
glimpse screenshot https://example.com --js="document.body.style.zoom = '80%'" --output=example.png
|
glimpse screenshot https://example.com --js="document.body.style.zoom = '80%'" --output=example.png
|
||||||
KAGI_TOKEN=... glimpse search --provider=kagi "node.js browser automation"`;
|
KAGI_TOKEN=... glimpse search --provider=kagi "node.js browser automation"
|
||||||
|
glimpse serve
|
||||||
|
glimpse serve --stop
|
||||||
|
glimpse serve --status`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
@@ -528,6 +537,16 @@ async function readerCommand() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function serveCommand() {
|
||||||
|
if (args.includes("--stop")) {
|
||||||
|
return stopServer();
|
||||||
|
}
|
||||||
|
if (args.includes("--status")) {
|
||||||
|
return serverStatus();
|
||||||
|
}
|
||||||
|
return startServer({ headless });
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
if (!command || command === "--help") {
|
if (!command || command === "--help") {
|
||||||
printHelp();
|
printHelp();
|
||||||
@@ -548,6 +567,8 @@ async function main() {
|
|||||||
return readerCommand();
|
return readerCommand();
|
||||||
case "search":
|
case "search":
|
||||||
return searchCommand();
|
return searchCommand();
|
||||||
|
case "serve":
|
||||||
|
return serveCommand();
|
||||||
default:
|
default:
|
||||||
unknownCommand(command);
|
unknownCommand(command);
|
||||||
}
|
}
|
||||||
|
|||||||
421
src/serve.ts
Normal file
421
src/serve.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
const GECKODRIVER_STARTUP_TIMEOUT = 5000;
|
||||||
|
// Socket Path - Per-user socket in XDG_RUNTIME_DIR (tmpfs, auto-cleaned on
|
||||||
|
// logout) with tmpdir fallback. GLIMPSE_SOCKET_PATH overrides for tests.
|
||||||
|
export function socketPath(): string {
|
||||||
|
if (process.env.GLIMPSE_SOCKET_PATH) return process.env.GLIMPSE_SOCKET_PATH;
|
||||||
|
const uid =
|
||||||
|
typeof process.getuid === "function" ? String(process.getuid()) : "0";
|
||||||
|
const dir = process.env.XDG_RUNTIME_DIR ?? os.tmpdir();
|
||||||
|
return path.join(dir, `glimpse-${uid}.sock`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Connect - Attempts to connect to an existing socket. Resolves with
|
||||||
|
// true if something is listening, false otherwise.
|
||||||
|
export function isServerRunning(sock = socketPath()): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const conn = net.createConnection(sock);
|
||||||
|
conn.on("connect", () => {
|
||||||
|
conn.destroy();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
conn.on("error", () => resolve(false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Geckodriver
|
||||||
|
function findGeckodriver(): string {
|
||||||
|
try {
|
||||||
|
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"geckodriver not found on $PATH. Install it (e.g. via Nix or your package manager).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Geckodriver - Spawns geckodriver on a random port and waits for it
|
||||||
|
// to be ready. Returns the child process and the resolved port.
|
||||||
|
async function startGeckodriver(): Promise<{
|
||||||
|
process: ChildProcess;
|
||||||
|
port: number;
|
||||||
|
}> {
|
||||||
|
const binary = findGeckodriver();
|
||||||
|
const child = spawn(binary, ["--port", "0"], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait For Port
|
||||||
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill();
|
||||||
|
reject(new Error("Timed out waiting for geckodriver to start"));
|
||||||
|
}, GECKODRIVER_STARTUP_TIMEOUT);
|
||||||
|
|
||||||
|
let output = "";
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
const match = output.match(/Listening on [^\s]*:(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(Number.parseInt(match[1], 10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error(`geckodriver exited with code ${code}: ${output}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { process: child, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Firefox Session - Creates a new WebDriver session via the
|
||||||
|
// geckodriver HTTP API directly, keeping Firefox alive.
|
||||||
|
async function createSession(
|
||||||
|
geckodriverPort: number,
|
||||||
|
headless: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
capabilities: {
|
||||||
|
alwaysMatch: {
|
||||||
|
browserName: "firefox",
|
||||||
|
"moz:firefoxOptions": {
|
||||||
|
args: headless ? ["--headless"] : [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: geckodriverPort,
|
||||||
|
path: "/session",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.value?.sessionId) {
|
||||||
|
resolve(parsed.value.sessionId);
|
||||||
|
} else {
|
||||||
|
reject(
|
||||||
|
new Error(`Failed to create session: ${data.slice(0, 500)}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`Invalid response from geckodriver: ${data.slice(0, 500)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Session - Closes the Firefox session via WebDriver API.
|
||||||
|
async function deleteSession(
|
||||||
|
geckodriverPort: number,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: geckodriverPort,
|
||||||
|
path: `/session/${sessionId}`,
|
||||||
|
method: "DELETE",
|
||||||
|
},
|
||||||
|
() => resolve(),
|
||||||
|
);
|
||||||
|
req.on("error", () => resolve());
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy Request - Forwards an HTTP request to geckodriver over TCP.
|
||||||
|
function proxyRequest(
|
||||||
|
clientReq: http.IncomingMessage,
|
||||||
|
clientRes: http.ServerResponse,
|
||||||
|
geckodriverPort: number,
|
||||||
|
) {
|
||||||
|
// Rewrite Host Header - The client sends Host: localhost via the socket
|
||||||
|
// but geckodriver expects the actual host:port.
|
||||||
|
const headers = { ...clientReq.headers, host: `127.0.0.1:${geckodriverPort}` };
|
||||||
|
|
||||||
|
const proxyReq = http.request(
|
||||||
|
{
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: geckodriverPort,
|
||||||
|
path: clientReq.url,
|
||||||
|
method: clientReq.method,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(proxyRes) => {
|
||||||
|
clientRes.writeHead(proxyRes.statusCode ?? 500, proxyRes.headers);
|
||||||
|
proxyRes.pipe(clientRes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
proxyReq.on("error", (err) => {
|
||||||
|
clientRes.writeHead(502);
|
||||||
|
clientRes.end(JSON.stringify({ error: err.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
clientReq.pipe(proxyReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept Session Management - The proxy intercepts session creation and
|
||||||
|
// deletion to reuse the persistent Firefox session. New session requests
|
||||||
|
// return the existing session ID. Delete session requests are turned into
|
||||||
|
// navigations to about:blank instead of actually closing Firefox.
|
||||||
|
function handleRequest(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
geckodriverPort: number,
|
||||||
|
sessionId: string,
|
||||||
|
) {
|
||||||
|
const method = req.method?.toUpperCase();
|
||||||
|
const urlPath = req.url ?? "";
|
||||||
|
|
||||||
|
// Intercept New Session - Return the existing session ID
|
||||||
|
if (method === "POST" && urlPath === "/session") {
|
||||||
|
// Drain the request body
|
||||||
|
req.resume();
|
||||||
|
req.on("end", () => {
|
||||||
|
const response = {
|
||||||
|
value: {
|
||||||
|
sessionId,
|
||||||
|
capabilities: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const body = JSON.stringify(response);
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": String(Buffer.byteLength(body)),
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept Delete Session - Navigate to about:blank instead
|
||||||
|
if (
|
||||||
|
method === "DELETE" &&
|
||||||
|
urlPath === `/session/${sessionId}`
|
||||||
|
) {
|
||||||
|
const navBody = JSON.stringify({ url: "about:blank" });
|
||||||
|
const navReq = http.request(
|
||||||
|
{
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: geckodriverPort,
|
||||||
|
path: `/session/${sessionId}/url`,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": String(Buffer.byteLength(navBody)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(navRes) => {
|
||||||
|
let data = "";
|
||||||
|
navRes.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||||
|
navRes.on("end", () => {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ value: null }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
navReq.on("error", () => {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ value: null }));
|
||||||
|
});
|
||||||
|
navReq.end(navBody);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy Everything Else
|
||||||
|
proxyRequest(req, res, geckodriverPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown Command - Sent by `glimpse serve --stop` via a special
|
||||||
|
// non-WebDriver endpoint.
|
||||||
|
const SHUTDOWN_PATH = "/__glimpse/shutdown";
|
||||||
|
const STATUS_PATH = "/__glimpse/status";
|
||||||
|
|
||||||
|
export interface ServeOptions {
|
||||||
|
headless?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Server - Main entry point for `glimpse serve`.
|
||||||
|
export async function startServer({
|
||||||
|
headless = true,
|
||||||
|
}: ServeOptions = {}): Promise<void> {
|
||||||
|
const sock = socketPath();
|
||||||
|
|
||||||
|
// Check For Existing Server
|
||||||
|
const running = await isServerRunning(sock);
|
||||||
|
if (running) {
|
||||||
|
console.error(`glimpse: server already running on ${sock}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean Stale Socket
|
||||||
|
if (fs.existsSync(sock)) {
|
||||||
|
fs.unlinkSync(sock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Geckodriver
|
||||||
|
console.error("glimpse: starting geckodriver...");
|
||||||
|
const geckodriver = await startGeckodriver();
|
||||||
|
console.error(`glimpse: geckodriver listening on port ${geckodriver.port}`);
|
||||||
|
|
||||||
|
// Create Persistent Firefox Session
|
||||||
|
console.error(
|
||||||
|
`glimpse: starting Firefox${headless ? " (headless)" : ""}...`,
|
||||||
|
);
|
||||||
|
const sessionId = await createSession(geckodriver.port, headless);
|
||||||
|
console.error(`glimpse: Firefox session ${sessionId} created`);
|
||||||
|
|
||||||
|
// Create Proxy Server
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const urlPath = req.url ?? "";
|
||||||
|
|
||||||
|
// Handle Shutdown
|
||||||
|
if (urlPath === SHUTDOWN_PATH) {
|
||||||
|
req.resume();
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Status
|
||||||
|
if (urlPath === STATUS_PATH) {
|
||||||
|
req.resume();
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
geckodriverPort: geckodriver.port,
|
||||||
|
socket: sock,
|
||||||
|
headless,
|
||||||
|
pid: process.pid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRequest(req, res, geckodriver.port, sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(sock, () => {
|
||||||
|
console.error(`glimpse: server listening on ${sock}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup Handler
|
||||||
|
let shuttingDown = false;
|
||||||
|
async function shutdown() {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
|
||||||
|
console.error("glimpse: shutting down...");
|
||||||
|
|
||||||
|
// Close Proxy Server
|
||||||
|
server.close();
|
||||||
|
|
||||||
|
// Delete Firefox Session
|
||||||
|
try {
|
||||||
|
await deleteSession(geckodriver.port, sessionId);
|
||||||
|
} catch {
|
||||||
|
// Firefox may already be gone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill Geckodriver
|
||||||
|
geckodriver.process.kill();
|
||||||
|
|
||||||
|
// Remove Socket
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(sock);
|
||||||
|
} catch {
|
||||||
|
// Socket may already be gone
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("glimpse: stopped");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop Server - Connects to the socket and sends a shutdown request.
|
||||||
|
export async function stopServer(): Promise<void> {
|
||||||
|
const sock = socketPath();
|
||||||
|
const running = await isServerRunning(sock);
|
||||||
|
|
||||||
|
if (!running) {
|
||||||
|
console.error("glimpse: no server running");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{ socketPath: sock, path: SHUTDOWN_PATH, method: "POST" },
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
res.on("end", () => {
|
||||||
|
console.error("glimpse: server stopped");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server Status - Connects to the socket and queries status.
|
||||||
|
export async function serverStatus(): Promise<void> {
|
||||||
|
const sock = socketPath();
|
||||||
|
const running = await isServerRunning(sock);
|
||||||
|
|
||||||
|
if (!running) {
|
||||||
|
console.error("glimpse: no server running");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{ socketPath: sock, path: STATUS_PATH, method: "GET" },
|
||||||
|
(res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||||
|
res.on("end", () => {
|
||||||
|
process.stdout.write(data + "\n");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
115
test/smoke.js
115
test/smoke.js
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync, spawn } from "node:child_process";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
const cliPath = new URL("../src/index.ts", import.meta.url).pathname;
|
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"], () => {
|
test("search validates kagi token in provider", ["search", "errors"], () => {
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
delete env.KAGI_TOKEN;
|
delete env.KAGI_TOKEN;
|
||||||
|
|||||||
Reference in New Issue
Block a user