Compare commits
5 Commits
6b3ec32b3a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d7c28820 | |||
| d02df19469 | |||
| 6adb5111de | |||
| eb1de23f4e | |||
| 8db4ed1370 |
10
AGENTS.md
10
AGENTS.md
@@ -4,7 +4,7 @@
|
||||
|
||||
This project provides small Firefox/Selenium browser utilities packaged by Nix:
|
||||
|
||||
- `glimpse` - generic page utilities with subcommands, including provider-backed search
|
||||
- `glimpse` - headless browser CLI with subcommands for page extraction, JS execution, screenshots, and search
|
||||
|
||||
Keep the tools simple, scriptable, and JSON-friendly.
|
||||
|
||||
@@ -18,7 +18,7 @@ npm run test:list
|
||||
node test/smoke.js <tag-or-name>
|
||||
```
|
||||
|
||||
For smoke testing without external network dependencies, use focused tags or scripts such as `npm run test:snapshot`, `npm run test:wait`, `npm run test:errors`, or `node test/smoke.js snapshot js`. Run `npm test` and `nix build .#default --no-link` when the change is broad, touches packaging, or needs full validation. Smoke tests require Firefox and geckodriver on `PATH` and use local `data:` HTML pages.
|
||||
For smoke testing without external network dependencies, use focused tags or scripts such as `npm run test:wait`, `npm run test:errors`, or `node test/smoke.js reader js`. Run `npm test` and `nix build .#default --no-link` when the change is broad, touches packaging, or needs full validation. Smoke tests require Firefox and geckodriver on `PATH` and use local `data:` HTML pages.
|
||||
|
||||
Do not attempt a live Kagi test unless `KAGI_TOKEN` is available.
|
||||
|
||||
@@ -30,7 +30,7 @@ Do not attempt a live Kagi test unless `KAGI_TOKEN` is available.
|
||||
- Browser execution should be headless by default.
|
||||
- Use `--no-headless` as the opt-out.
|
||||
- Keep `--url=<server>` support for connecting to an existing WebDriver server.
|
||||
- `--timeout=<ms>` is a top-level option for command waits and defaults to `10000`.
|
||||
- `--timeout=<seconds>` is a top-level option for command waits and defaults to `10`.
|
||||
- `--wait-js=<code>` and `--wait-until=<state>` are top-level wait options available to every `glimpse` subcommand.
|
||||
- `--js=<code>` and `--script=<file>` are top-level options available to every `glimpse` subcommand and run before command-specific behavior.
|
||||
- Prefer structured JSON output for objects/arrays.
|
||||
@@ -38,11 +38,11 @@ Do not attempt a live Kagi test unless `KAGI_TOKEN` is available.
|
||||
|
||||
Current `glimpse` subcommands:
|
||||
|
||||
- `snapshot <url>` - return an agent-friendly page snapshot as JSON
|
||||
- `reader <url>` - extract page content as Markdown (tries Firefox Reader View, falls back to raw Turndown conversion); supports `--no-reader` to skip Reader View, `--format=json` for structured output
|
||||
- `exec <url> --js=<code>` or `--script=<file>` - execute JavaScript and return the result
|
||||
- `screenshot <url> --output=<file>` - save a PNG screenshot
|
||||
- `reader <url>` - open Firefox Reader View and output readable content as Markdown
|
||||
- `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
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
nodejs_22
|
||||
firefox
|
||||
geckodriver
|
||||
typescript-language-server
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import http from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { Builder, type WebDriver } from "selenium-webdriver";
|
||||
import { Builder, WebDriver } from "selenium-webdriver";
|
||||
export { type WebDriver };
|
||||
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 {
|
||||
headless?: boolean;
|
||||
existingUrl?: string;
|
||||
}
|
||||
|
||||
// Find Geckodriver
|
||||
function findGeckodriver(): string {
|
||||
try {
|
||||
return execFileSync("which", ["geckodriver"], { encoding: "utf-8" }).trim();
|
||||
@@ -17,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({
|
||||
headless = false,
|
||||
existingUrl,
|
||||
}: 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();
|
||||
|
||||
// Configure Headless
|
||||
|
||||
331
src/index.ts
331
src/index.ts
@@ -1,16 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadConfig, type GlimpseConfig } from "./config.js";
|
||||
import { createDriver } from "./driver.js";
|
||||
import { searchKagi } from "./providers/kagi.js";
|
||||
import { createDriver, type WebDriver } from "./driver.js";
|
||||
import { searchKagi, type SearchResult } from "./providers/kagi.js";
|
||||
import { startServer, stopServer, serverStatus } from "./serve.js";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import TurndownService from "turndown";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10000;
|
||||
const DEFAULT_TIMEOUT_S = 10;
|
||||
const POLL_INTERVAL_MS = 200;
|
||||
const startTime = Date.now();
|
||||
const runContext: { targetUrl?: string; currentUrl?: string } = {};
|
||||
|
||||
interface ReaderArticle {
|
||||
title?: string;
|
||||
byline?: string;
|
||||
siteName?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
readerUrl?: string;
|
||||
sourceUrl?: string;
|
||||
finalUrl?: string;
|
||||
markdown?: string;
|
||||
method?: "reader" | "raw";
|
||||
}
|
||||
|
||||
// Parse CLI Args
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
const headless = !args.includes("--no-headless");
|
||||
@@ -21,9 +35,9 @@ const waitJs = getOption("--wait-js");
|
||||
const waitUntil = getOption("--wait-until") ?? "none";
|
||||
const configPath = getOption("--config");
|
||||
let appConfig: GlimpseConfig = {};
|
||||
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
||||
let timeoutMs: number;
|
||||
|
||||
function getOption(name) {
|
||||
function getOption(name: string) {
|
||||
const prefix = `${name}=`;
|
||||
return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
|
||||
}
|
||||
@@ -36,14 +50,14 @@ function elapsedMs() {
|
||||
return Date.now() - startTime;
|
||||
}
|
||||
|
||||
function printResult(result) {
|
||||
function printResult(result: unknown) {
|
||||
if (result === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputValue =
|
||||
result && typeof result === "object" && !Array.isArray(result)
|
||||
? { ...result, elapsedMs: result.elapsedMs ?? elapsedMs() }
|
||||
? { ...result, elapsedMs: (result as any).elapsedMs ?? elapsedMs() }
|
||||
: result;
|
||||
const output =
|
||||
typeof outputValue === "object"
|
||||
@@ -56,18 +70,18 @@ class CliError extends Error {
|
||||
code: string;
|
||||
details: Record<string, unknown>;
|
||||
|
||||
constructor(code, message, details = {}) {
|
||||
constructor(code: string, message: string, details = {}) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function cliError(code, message, details = {}) {
|
||||
function cliError(code: string, message: string, details = {}) {
|
||||
throw new CliError(code, message, details);
|
||||
}
|
||||
|
||||
function unknownCommand(name) {
|
||||
function unknownCommand(name: string) {
|
||||
cliError("UNKNOWN_COMMAND", `Unknown command: ${name}`);
|
||||
}
|
||||
|
||||
@@ -75,17 +89,17 @@ function helpText() {
|
||||
return `Usage: glimpse <command> <url> [options]
|
||||
|
||||
Commands:
|
||||
snapshot <url> [options] Return an agent-friendly page snapshot as JSON
|
||||
reader <url> [options] Extract page content as Markdown (Reader View with raw fallback)
|
||||
exec <url> [options] Execute JavaScript on a page and return the result
|
||||
screenshot <url> [options] Save a PNG screenshot of a page
|
||||
reader <url> [options] Extract Firefox Reader View content as Markdown
|
||||
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:
|
||||
--help Show this help
|
||||
--no-headless Show Firefox instead of running headless
|
||||
--url=<server> Connect to an existing WebDriver server
|
||||
--timeout=<ms> Maximum wait time in milliseconds (default: 10000)
|
||||
--timeout=<seconds> Maximum wait time in seconds (default: 10)
|
||||
--wait-js=<code> Poll JS until it returns a truthy value
|
||||
--wait-until=<state> Wait for readiness: none, interactive, complete (default: none)
|
||||
--js=<code> Execute inline JS before command logic
|
||||
@@ -102,18 +116,28 @@ Screenshot Options:
|
||||
Reader Options:
|
||||
--format=<format> Output format: markdown, html, text, json (default: markdown)
|
||||
--output=<file> Write output to a file
|
||||
--no-reader Skip Reader View and use raw page extraction
|
||||
|
||||
Serve Options:
|
||||
--stop Stop a running server
|
||||
--status Show server status
|
||||
|
||||
Search Options:
|
||||
--provider=<provider> Search provider: kagi (default: config or kagi)
|
||||
--token=<token> Kagi token (default: KAGI_TOKEN or config)
|
||||
--format=<format> Output format: markdown, json (default: markdown)
|
||||
|
||||
Examples:
|
||||
glimpse snapshot https://example.com
|
||||
glimpse reader https://example.com
|
||||
glimpse reader https://example.com --no-reader
|
||||
glimpse reader https://example.com/article --output=article.md
|
||||
glimpse exec https://example.com --js="return document.title"
|
||||
glimpse exec https://example.com --script=extract.js
|
||||
glimpse screenshot https://example.com --js="document.body.style.zoom = '80%'" --output=example.png
|
||||
glimpse reader https://example.com/article --script=prepare.js --output=article.md
|
||||
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() {
|
||||
@@ -130,15 +154,15 @@ function usage() {
|
||||
function parseTimeout() {
|
||||
const value = getOption("--timeout");
|
||||
if (value === undefined) {
|
||||
return DEFAULT_TIMEOUT_MS;
|
||||
return DEFAULT_TIMEOUT_S * 1000;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
|
||||
cliError("INVALID_OPTION", "--timeout must be a positive integer.");
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
cliError("INVALID_OPTION", "--timeout must be a positive number.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
return Math.round(parsed * 1000);
|
||||
}
|
||||
|
||||
function validateCommonOptions() {
|
||||
@@ -166,8 +190,8 @@ function getPreludeScriptSource() {
|
||||
return inlineJs;
|
||||
}
|
||||
|
||||
async function withDriver(action) {
|
||||
let driver;
|
||||
async function withDriver(action: (driver: WebDriver) => Promise<unknown>) {
|
||||
let driver: WebDriver;
|
||||
|
||||
try {
|
||||
driver = await createDriver({ headless, existingUrl });
|
||||
@@ -182,16 +206,16 @@ async function withDriver(action) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForReadyState(driver) {
|
||||
async function waitForReadyState(driver: WebDriver) {
|
||||
if (waitUntil === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await driver.wait(async () => {
|
||||
const readyState = await driver.executeScript(
|
||||
const readyState = (await driver.executeScript(
|
||||
"return document.readyState",
|
||||
);
|
||||
)) as string;
|
||||
return waitUntil === "interactive"
|
||||
? ["interactive", "complete"].includes(readyState)
|
||||
: readyState === "complete";
|
||||
@@ -199,19 +223,19 @@ async function waitForReadyState(driver) {
|
||||
} catch {
|
||||
cliError(
|
||||
"WAIT_TIMEOUT",
|
||||
`Timed out after ${timeoutMs}ms waiting for --wait-until=${waitUntil}`,
|
||||
`Timed out after ${timeoutMs / 1000}s waiting for --wait-until=${waitUntil}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJs(driver) {
|
||||
async function waitForJs(driver: WebDriver) {
|
||||
if (!waitJs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
let result;
|
||||
let result: unknown;
|
||||
|
||||
try {
|
||||
result = await driver.executeScript(waitJs);
|
||||
@@ -228,11 +252,11 @@ async function waitForJs(driver) {
|
||||
|
||||
cliError(
|
||||
"WAIT_TIMEOUT",
|
||||
`Timed out after ${timeoutMs}ms waiting for --wait-js`,
|
||||
`Timed out after ${timeoutMs / 1000}s waiting for --wait-js`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runPreludeScript(driver) {
|
||||
async function runPreludeScript(driver: WebDriver) {
|
||||
const scriptSource = getPreludeScriptSource();
|
||||
if (!scriptSource) {
|
||||
return undefined;
|
||||
@@ -245,10 +269,13 @@ async function runPreludeScript(driver) {
|
||||
}
|
||||
}
|
||||
|
||||
async function withPage(targetUrl, action) {
|
||||
async function withPage(
|
||||
targetUrl: string,
|
||||
action: (driver: WebDriver, scriptResult: unknown) => Promise<unknown>,
|
||||
) {
|
||||
runContext.targetUrl = targetUrl;
|
||||
|
||||
return withDriver(async (driver) => {
|
||||
return withDriver(async (driver: WebDriver) => {
|
||||
// Navigate To Page
|
||||
try {
|
||||
await driver.get(targetUrl);
|
||||
@@ -268,101 +295,12 @@ async function withPage(targetUrl, action) {
|
||||
});
|
||||
}
|
||||
|
||||
const snapshotScript = `
|
||||
const normalize = (value) => String(value || "").replace(/\\s+/g, " ").trim();
|
||||
const visibleText = (element) => normalize(element?.innerText || element?.textContent || "");
|
||||
const safeValue = (input) => ["password", "hidden"].includes(input.type) ? "" : input.value || "";
|
||||
const labelText = (input) => {
|
||||
const labels = Array.from(input.labels || []).map((label) => visibleText(label)).filter(Boolean);
|
||||
if (labels.length > 0) return labels.join(" ");
|
||||
|
||||
if (input.id) {
|
||||
const label = Array.from(document.querySelectorAll("label[for]"))
|
||||
.find((candidate) => candidate.getAttribute("for") === input.id);
|
||||
if (label) return visibleText(label);
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
const inputSummary = (input) => ({
|
||||
type: input.type || input.tagName.toLowerCase(),
|
||||
name: input.name || "",
|
||||
id: input.id || "",
|
||||
placeholder: input.placeholder || "",
|
||||
value: safeValue(input),
|
||||
label: labelText(input),
|
||||
});
|
||||
const collectHeadings = () => {
|
||||
try {
|
||||
return Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6,[role='heading']"))
|
||||
.map((heading) => {
|
||||
const tagLevel = heading.tagName.match(/^H([1-6])$/i)?.[1];
|
||||
const ariaLevel = heading.getAttribute("aria-level");
|
||||
const level = Number.parseInt(tagLevel || ariaLevel || "0", 10);
|
||||
const text = visibleText(heading);
|
||||
|
||||
return text ? { level: level || null, text } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
text: normalize(document.body?.innerText || ""),
|
||||
headings: collectHeadings(),
|
||||
links: Array.from(document.querySelectorAll("a[href]"))
|
||||
.map((link) => ({ text: visibleText(link), href: link.href }))
|
||||
.filter((link) => link.text || link.href),
|
||||
buttons: Array.from(document.querySelectorAll("button,input[type='button'],input[type='submit'],input[type='reset'],[role='button']"))
|
||||
.map((button) => ({
|
||||
text: visibleText(button) || button.value || button.getAttribute("aria-label") || "",
|
||||
type: button.type || button.getAttribute("role") || "button",
|
||||
name: button.name || "",
|
||||
id: button.id || "",
|
||||
}))
|
||||
.filter((button) => button.text || button.name || button.id),
|
||||
inputs: Array.from(document.querySelectorAll("input,textarea,select"))
|
||||
.map(inputSummary),
|
||||
forms: Array.from(document.querySelectorAll("form"))
|
||||
.map((form) => ({
|
||||
action: form.action || "",
|
||||
method: (form.method || "get").toLowerCase(),
|
||||
text: visibleText(form),
|
||||
inputs: Array.from(form.querySelectorAll("input,textarea,select")).map(inputSummary),
|
||||
})),
|
||||
};
|
||||
`;
|
||||
|
||||
async function snapshotCommand() {
|
||||
const [targetUrl] = getPositionalArgs();
|
||||
|
||||
if (!targetUrl) usage();
|
||||
|
||||
return withPage(targetUrl, async (driver) => {
|
||||
// Capture Page Metadata
|
||||
const [url, title, result] = await Promise.all([
|
||||
driver.getCurrentUrl(),
|
||||
driver.getTitle(),
|
||||
driver.executeScript(snapshotScript),
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
url,
|
||||
title,
|
||||
result,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function execCommand() {
|
||||
const [targetUrl] = getPositionalArgs();
|
||||
|
||||
if (!targetUrl || (!inlineJs && !scriptPath)) usage();
|
||||
|
||||
return withPage(targetUrl, async (_driver, scriptResult) => scriptResult);
|
||||
return withPage(targetUrl, async (_driver: WebDriver, scriptResult: unknown) => scriptResult);
|
||||
}
|
||||
|
||||
async function screenshotCommand() {
|
||||
@@ -371,7 +309,7 @@ async function screenshotCommand() {
|
||||
|
||||
if (!targetUrl) usage();
|
||||
|
||||
return withPage(targetUrl, async (driver) => {
|
||||
return withPage(targetUrl, async (driver: WebDriver) => {
|
||||
// Save Screenshot
|
||||
const image = await driver.takeScreenshot();
|
||||
writeFileSync(outputPath, image, "base64");
|
||||
@@ -385,11 +323,11 @@ async function screenshotCommand() {
|
||||
});
|
||||
}
|
||||
|
||||
function markdownTitle(text) {
|
||||
function markdownTitle(text: string) {
|
||||
return text.replaceAll(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function articleToMarkdown(article) {
|
||||
function articleToMarkdown(article: ReaderArticle) {
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
codeBlockStyle: "fenced",
|
||||
@@ -413,7 +351,7 @@ function articleToMarkdown(article) {
|
||||
return `${parts.join("\n\n").trim()}\n`;
|
||||
}
|
||||
|
||||
function renderReaderOutput(article, format) {
|
||||
function renderReaderOutput(article: ReaderArticle, format: string) {
|
||||
switch (format) {
|
||||
case "markdown":
|
||||
return article.markdown;
|
||||
@@ -431,17 +369,33 @@ function renderReaderOutput(article, format) {
|
||||
}
|
||||
}
|
||||
|
||||
function searchResultsToMarkdown(results: SearchResult[]): string {
|
||||
return results
|
||||
.map((r) => `## [${r.title}](${r.url})\n> ${r.description}`)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function searchCommand() {
|
||||
const provider =
|
||||
getOption("--provider") ?? appConfig.search?.provider ?? "kagi";
|
||||
const query = getPositionalArgs().join(" ");
|
||||
const format = getOption("--format") ?? "markdown";
|
||||
|
||||
if (!query) usage();
|
||||
|
||||
if (!["markdown", "json"].includes(format)) {
|
||||
cliError(
|
||||
"INVALID_OPTION",
|
||||
`Unsupported search format: ${format}. Expected markdown, json.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Run Provider Search
|
||||
let results: SearchResult[];
|
||||
switch (provider) {
|
||||
case "kagi":
|
||||
return searchKagi({
|
||||
results = await searchKagi({
|
||||
query,
|
||||
token: getOption("--token"),
|
||||
config: appConfig,
|
||||
@@ -449,35 +403,36 @@ async function searchCommand() {
|
||||
existingUrl,
|
||||
timeoutMs,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
cliError(
|
||||
"UNSUPPORTED_SEARCH_PROVIDER",
|
||||
`Unsupported search provider: ${provider}. Expected kagi.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Render Output
|
||||
switch (format) {
|
||||
case "markdown":
|
||||
return searchResultsToMarkdown(results);
|
||||
case "json":
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
async function readerCommand() {
|
||||
const [targetUrl] = getPositionalArgs();
|
||||
const outputPath = getOption("--output");
|
||||
const format = getOption("--format") ?? "markdown";
|
||||
// Try Reader View Extraction
|
||||
async function tryReaderView(
|
||||
driver: WebDriver,
|
||||
finalUrl: string,
|
||||
targetUrl: string,
|
||||
): Promise<ReaderArticle | null> {
|
||||
const readerUrl = `about:reader?url=${encodeURIComponent(finalUrl)}`;
|
||||
await driver.get(readerUrl);
|
||||
|
||||
if (!targetUrl) usage();
|
||||
|
||||
return withPage(targetUrl, async (driver) => {
|
||||
// Capture Final Url
|
||||
const finalUrl = await driver.getCurrentUrl();
|
||||
|
||||
// Open Firefox Reader View
|
||||
const readerUrl = `about:reader?url=${encodeURIComponent(finalUrl)}`;
|
||||
await driver.get(readerUrl);
|
||||
|
||||
// Wait For Reader Content
|
||||
let article;
|
||||
try {
|
||||
article = await driver.wait(
|
||||
async () => {
|
||||
return driver.executeScript(`
|
||||
try {
|
||||
return await driver.wait(
|
||||
async () => {
|
||||
return driver.executeScript(`
|
||||
const content = document.querySelector("#moz-reader-content, .moz-reader-content");
|
||||
const error = document.querySelector(".reader-error");
|
||||
const text = content?.innerText?.trim() || "";
|
||||
@@ -499,15 +454,67 @@ async function readerCommand() {
|
||||
|
||||
return null;
|
||||
`);
|
||||
},
|
||||
timeoutMs,
|
||||
`No readable article content found for URL: ${targetUrl}`,
|
||||
);
|
||||
} catch (err) {
|
||||
cliError("TIMEOUT", err.message);
|
||||
},
|
||||
timeoutMs,
|
||||
`No readable article content found for URL: ${targetUrl}`,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Raw Page Extraction Fallback
|
||||
async function extractRawPage(
|
||||
driver: WebDriver,
|
||||
originalUrl?: string,
|
||||
): Promise<ReaderArticle> {
|
||||
// Navigate Back If Needed (e.g. after failed Reader View)
|
||||
if (originalUrl) {
|
||||
await driver.get(originalUrl);
|
||||
}
|
||||
|
||||
const result = (await driver.executeScript(`
|
||||
return {
|
||||
title: document.title || "",
|
||||
html: document.body?.innerHTML || "",
|
||||
text: document.body?.innerText?.trim() || "",
|
||||
};
|
||||
`)) as { title: string; html: string; text: string };
|
||||
|
||||
return {
|
||||
title: result.title,
|
||||
html: result.html,
|
||||
text: result.text,
|
||||
};
|
||||
}
|
||||
|
||||
async function readerCommand() {
|
||||
const [targetUrl] = getPositionalArgs();
|
||||
const outputPath = getOption("--output");
|
||||
const format = getOption("--format") ?? "markdown";
|
||||
const skipReader = args.includes("--no-reader");
|
||||
|
||||
if (!targetUrl) usage();
|
||||
|
||||
return withPage(targetUrl, async (driver: WebDriver) => {
|
||||
const finalUrl = await driver.getCurrentUrl();
|
||||
|
||||
// Extract Page Content
|
||||
let article: ReaderArticle;
|
||||
if (!skipReader) {
|
||||
article = await tryReaderView(driver, finalUrl, targetUrl);
|
||||
}
|
||||
|
||||
// Render Output
|
||||
// Fallback To Raw Extraction
|
||||
if (!article) {
|
||||
// Navigate back only if Reader View was attempted
|
||||
article = await extractRawPage(driver, skipReader ? undefined : finalUrl);
|
||||
article.method = "raw";
|
||||
} else {
|
||||
article.method = "reader";
|
||||
}
|
||||
|
||||
// Build Output
|
||||
article.sourceUrl = targetUrl;
|
||||
article.finalUrl = finalUrl;
|
||||
article.markdown = articleToMarkdown(article);
|
||||
@@ -530,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() {
|
||||
if (!command || command === "--help") {
|
||||
printHelp();
|
||||
@@ -542,8 +559,6 @@ async function main() {
|
||||
appConfig = loadConfig({ path: configPath });
|
||||
|
||||
switch (command) {
|
||||
case "snapshot":
|
||||
return snapshotCommand();
|
||||
case "exec":
|
||||
return execCommand();
|
||||
case "screenshot":
|
||||
@@ -552,6 +567,8 @@ async function main() {
|
||||
return readerCommand();
|
||||
case "search":
|
||||
return searchCommand();
|
||||
case "serve":
|
||||
return serveCommand();
|
||||
default:
|
||||
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();
|
||||
});
|
||||
}
|
||||
218
test/smoke.js
218
test/smoke.js
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-nocheck
|
||||
|
||||
import {
|
||||
mkdtempSync,
|
||||
@@ -9,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;
|
||||
@@ -73,7 +74,7 @@ test("no args prints help", ["help", "cli"], () => {
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /Usage: glimpse <command> <url> \[options\]/);
|
||||
assert.match(result.stdout, /snapshot <url>/);
|
||||
assert.match(result.stdout, /reader <url>/);
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
@@ -86,48 +87,50 @@ test("help flag prints help", ["help", "cli"], () => {
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
test("snapshot returns page metadata and content", ["snapshot"], () => {
|
||||
const output = expectSuccess([
|
||||
"snapshot",
|
||||
test("reader extracts page content as markdown", ["reader"], () => {
|
||||
const result = runCli([
|
||||
"reader",
|
||||
dataHtml(
|
||||
'<title>Hello</title><h1>Main</h1><a href="/x">X</a><button>Go</button>',
|
||||
'<title>Hello</title><h1>Main</h1><p>Some text</p><a href="https://example.com">Link</a>',
|
||||
),
|
||||
"--no-reader",
|
||||
]);
|
||||
|
||||
assert.equal(output.ok, true);
|
||||
assert.equal(output.title, "Hello");
|
||||
assert.equal(typeof output.elapsedMs, "number");
|
||||
assert.deepEqual(output.result.headings, [{ level: 1, text: "Main" }]);
|
||||
assert.deepEqual(output.result.links, [{ href: "/x", text: "X" }]);
|
||||
assert.equal(output.result.buttons[0].text, "Go");
|
||||
assert.match(output.result.text, /Main/);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const output = result.stdout.trim();
|
||||
assert.match(output, /# Main/);
|
||||
assert.match(output, /Some text/);
|
||||
assert.match(output, /\[Link\]\(https:\/\/example\.com\/??\)/);
|
||||
});
|
||||
|
||||
test("snapshot extracts aria headings", ["snapshot"], () => {
|
||||
test("reader returns json format with method field", ["reader"], () => {
|
||||
const output = expectSuccess([
|
||||
"snapshot",
|
||||
dataHtml(
|
||||
'<title>Hello</title><div role="heading" aria-level="2">ARIA</div>',
|
||||
),
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title><h1>Main</h1><p>World</p>"),
|
||||
"--no-reader",
|
||||
"--format=json",
|
||||
]);
|
||||
|
||||
assert.equal(output.ok, true);
|
||||
assert.deepEqual(output.result.headings, [{ level: 2, text: "ARIA" }]);
|
||||
assert.equal(output.title, "Hello");
|
||||
assert.equal(output.method, "raw");
|
||||
assert.equal(typeof output.markdown, "string");
|
||||
assert.match(output.markdown, /# Main/);
|
||||
assert.match(output.text, /Main/);
|
||||
});
|
||||
|
||||
test(
|
||||
"snapshot runs top-level javascript before extraction",
|
||||
["snapshot", "js"],
|
||||
"reader runs top-level javascript before extraction",
|
||||
["reader", "js"],
|
||||
() => {
|
||||
const output = expectSuccess([
|
||||
"snapshot",
|
||||
const result = runCli([
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title><h1>Old</h1>"),
|
||||
"--no-reader",
|
||||
"--js=document.querySelector('h1').textContent = 'New'",
|
||||
]);
|
||||
|
||||
assert.equal(output.ok, true);
|
||||
assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]);
|
||||
assert.equal(output.result.text, "New");
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /# New/);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -160,10 +163,123 @@ 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;
|
||||
const result = runCli(["search", "example query"], { env });
|
||||
const result = runCli(["search", "--config=/nonexistent/path", "example query"], { env });
|
||||
const output = parseJson(result.stderr);
|
||||
|
||||
assert.notEqual(result.status, 0, result.stdout || result.stderr);
|
||||
@@ -182,8 +298,9 @@ test(
|
||||
writeFileSync(configPath, "not json");
|
||||
|
||||
const output = expectFailure([
|
||||
"snapshot",
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
"--no-reader",
|
||||
`--config=${configPath}`,
|
||||
]);
|
||||
|
||||
@@ -201,8 +318,9 @@ test(
|
||||
writeFileSync(configPath, JSON.stringify({ search: { provider: 42 } }));
|
||||
|
||||
const output = expectFailure([
|
||||
"snapshot",
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
"--no-reader",
|
||||
`--config=${configPath}`,
|
||||
]);
|
||||
|
||||
@@ -218,17 +336,17 @@ test("empty home config is accepted", ["config"], () => {
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(join(configDir, "config.json"), "{}");
|
||||
|
||||
const output = expectSuccess(
|
||||
["snapshot", dataHtml("<title>Hello</title><h1>Main</h1>")],
|
||||
const result = runCli(
|
||||
["reader", dataHtml("<title>Hello</title><h1>Main</h1>"), "--no-reader"],
|
||||
{ env: { ...process.env, XDG_CONFIG_HOME: configHome } },
|
||||
);
|
||||
|
||||
assert.equal(output.ok, true);
|
||||
assert.equal(output.title, "Hello");
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /# Main/);
|
||||
});
|
||||
|
||||
test("unknown command returns structured error", ["errors", "cli"], () => {
|
||||
const output = expectFailure(["nope", dataHtml("<title>Hello</title>")]);
|
||||
const output = expectFailure(["nope", dataHtml("<title>Hello</title>"), "--no-reader"]);
|
||||
|
||||
assert.equal(output.ok, false);
|
||||
assert.equal(output.error.code, "UNKNOWN_COMMAND");
|
||||
@@ -241,22 +359,24 @@ test(
|
||||
["errors", "timeout"],
|
||||
() => {
|
||||
const output = expectFailure([
|
||||
"snapshot",
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
"--no-reader",
|
||||
"--timeout=abc",
|
||||
]);
|
||||
|
||||
assert.equal(output.ok, false);
|
||||
assert.equal(output.error.code, "INVALID_OPTION");
|
||||
assert.match(output.error.message, /--timeout must be a positive integer/);
|
||||
assert.match(output.error.message, /--timeout must be a positive number/);
|
||||
assert.equal(typeof output.elapsedMs, "number");
|
||||
},
|
||||
);
|
||||
|
||||
test("invalid wait-until returns invalid option", ["errors", "wait"], () => {
|
||||
const output = expectFailure([
|
||||
"snapshot",
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
"--no-reader",
|
||||
"--wait-until=loaded",
|
||||
]);
|
||||
|
||||
@@ -266,22 +386,24 @@ test("invalid wait-until returns invalid option", ["errors", "wait"], () => {
|
||||
});
|
||||
|
||||
test("wait-js succeeds when condition is true", ["wait"], () => {
|
||||
const output = expectSuccess([
|
||||
"snapshot",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
const result = runCli([
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title><h1>Main</h1>"),
|
||||
"--no-reader",
|
||||
'--wait-js=return document.title === "Hello"',
|
||||
]);
|
||||
|
||||
assert.equal(output.ok, true);
|
||||
assert.equal(output.title, "Hello");
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /# Main/);
|
||||
});
|
||||
|
||||
test("wait-js timeout returns wait timeout", ["wait", "errors"], () => {
|
||||
const output = expectFailure([
|
||||
"snapshot",
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
"--no-reader",
|
||||
"--wait-js=return false",
|
||||
"--timeout=1",
|
||||
"--timeout=0.001",
|
||||
]);
|
||||
|
||||
assert.equal(output.ok, false);
|
||||
@@ -296,8 +418,9 @@ test(
|
||||
["wait", "errors", "js"],
|
||||
() => {
|
||||
const output = expectFailure([
|
||||
"snapshot",
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
"--no-reader",
|
||||
'--wait-js=throw new Error("boom")',
|
||||
]);
|
||||
|
||||
@@ -313,8 +436,9 @@ test(
|
||||
["errors", "js"],
|
||||
() => {
|
||||
const output = expectFailure([
|
||||
"snapshot",
|
||||
"reader",
|
||||
dataHtml("<title>Hello</title>"),
|
||||
"--no-reader",
|
||||
'--js=throw new Error("boom")',
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["test", "node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user