Compare commits

...

5 Commits

Author SHA1 Message Date
e3d7c28820 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
2026-05-02 20:32:23 -04:00
d02df19469 feat: change --timeout from milliseconds to seconds
Accept seconds (including decimals like 0.5) instead of milliseconds
for the --timeout flag. Converts to ms internally. Default is now 10
(seconds) instead of 10000. Error messages display seconds.

Update AGENTS.md, tests, and skill docs to match.
2026-05-02 20:10:20 -04:00
6adb5111de refactor!: replace snapshot with reader fallback, collapse commands
Remove the snapshot command and enhance reader to try Firefox Reader
View first, falling back to raw Turndown conversion of document.body
when Reader View fails or is skipped via --no-reader.

- reader always returns markdown by default (--format=json for structured)
- JSON output includes method: 'reader' | 'raw' to signal extraction path
- --no-reader skips Reader View (stays on loaded page, preserving JS mutations)
- Add @ts-nocheck to test/smoke.js and exclude test/ from tsconfig
- Update all tests from snapshot to reader with --no-reader for data URIs
- Update AGENTS.md and help text

BREAKING CHANGE: snapshot subcommand removed; use reader instead.
2026-05-02 20:05:27 -04:00
eb1de23f4e fix(test): prevent config file from leaking kagi token into search test
The search token validation test only cleared KAGI_TOKEN from the
environment but still loaded the user config file, which could supply
the token and cause the test to pass incorrectly. Pass
--config=/nonexistent/path so loadConfig returns an empty object.

Also includes search command improvements: markdown/json format output
and --format flag.
2026-05-02 19:29:53 -04:00
8db4ed1370 style: add explicit types to eliminate implicit any in index.ts 2026-05-02 18:47:08 -04:00
7 changed files with 804 additions and 211 deletions

View File

@@ -4,7 +4,7 @@
This project provides small Firefox/Selenium browser utilities packaged by Nix: 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. Keep the tools simple, scriptable, and JSON-friendly.
@@ -18,7 +18,7 @@ npm run test:list
node test/smoke.js <tag-or-name> 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. 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. - Browser execution should be headless by default.
- Use `--no-headless` as the opt-out. - Use `--no-headless` as the opt-out.
- Keep `--url=<server>` support for connecting to an existing WebDriver server. - 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. - `--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. - `--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. - 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: 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 - `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
- `reader <url>` - open Firefox Reader View and output readable content as Markdown
- `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

View File

@@ -65,6 +65,7 @@
nodejs_22 nodejs_22
firefox firefox
geckodriver geckodriver
typescript-language-server
]; ];
}; };
} }

View File

@@ -1,12 +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 };
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();
@@ -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({ 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

View File

@@ -1,16 +1,30 @@
#!/usr/bin/env node #!/usr/bin/env node
import { loadConfig, type GlimpseConfig } from "./config.js"; import { loadConfig, type GlimpseConfig } from "./config.js";
import { createDriver } from "./driver.js"; import { createDriver, type WebDriver } from "./driver.js";
import { searchKagi } 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";
const DEFAULT_TIMEOUT_MS = 10000; const DEFAULT_TIMEOUT_S = 10;
const POLL_INTERVAL_MS = 200; const POLL_INTERVAL_MS = 200;
const startTime = Date.now(); const startTime = Date.now();
const runContext: { targetUrl?: string; currentUrl?: string } = {}; 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 // Parse CLI Args
const [command, ...args] = process.argv.slice(2); const [command, ...args] = process.argv.slice(2);
const headless = !args.includes("--no-headless"); const headless = !args.includes("--no-headless");
@@ -21,9 +35,9 @@ const waitJs = getOption("--wait-js");
const waitUntil = getOption("--wait-until") ?? "none"; const waitUntil = getOption("--wait-until") ?? "none";
const configPath = getOption("--config"); const configPath = getOption("--config");
let appConfig: GlimpseConfig = {}; let appConfig: GlimpseConfig = {};
let timeoutMs = DEFAULT_TIMEOUT_MS; let timeoutMs: number;
function getOption(name) { function getOption(name: string) {
const prefix = `${name}=`; const prefix = `${name}=`;
return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
} }
@@ -36,14 +50,14 @@ function elapsedMs() {
return Date.now() - startTime; return Date.now() - startTime;
} }
function printResult(result) { function printResult(result: unknown) {
if (result === undefined) { if (result === undefined) {
return; return;
} }
const outputValue = const outputValue =
result && typeof result === "object" && !Array.isArray(result) result && typeof result === "object" && !Array.isArray(result)
? { ...result, elapsedMs: result.elapsedMs ?? elapsedMs() } ? { ...result, elapsedMs: (result as any).elapsedMs ?? elapsedMs() }
: result; : result;
const output = const output =
typeof outputValue === "object" typeof outputValue === "object"
@@ -56,18 +70,18 @@ class CliError extends Error {
code: string; code: string;
details: Record<string, unknown>; details: Record<string, unknown>;
constructor(code, message, details = {}) { constructor(code: string, message: string, details = {}) {
super(message); super(message);
this.code = code; this.code = code;
this.details = details; this.details = details;
} }
} }
function cliError(code, message, details = {}) { function cliError(code: string, message: string, details = {}) {
throw new CliError(code, message, details); throw new CliError(code, message, details);
} }
function unknownCommand(name) { function unknownCommand(name: string) {
cliError("UNKNOWN_COMMAND", `Unknown command: ${name}`); cliError("UNKNOWN_COMMAND", `Unknown command: ${name}`);
} }
@@ -75,17 +89,17 @@ function helpText() {
return `Usage: glimpse <command> <url> [options] return `Usage: glimpse <command> <url> [options]
Commands: 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 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
reader <url> [options] Extract Firefox Reader View content as Markdown
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
--no-headless Show Firefox instead of running headless --no-headless Show Firefox instead of running headless
--url=<server> Connect to an existing WebDriver server --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-js=<code> Poll JS until it returns a truthy value
--wait-until=<state> Wait for readiness: none, interactive, complete (default: none) --wait-until=<state> Wait for readiness: none, interactive, complete (default: none)
--js=<code> Execute inline JS before command logic --js=<code> Execute inline JS before command logic
@@ -102,18 +116,28 @@ Screenshot Options:
Reader Options: Reader Options:
--format=<format> Output format: markdown, html, text, json (default: markdown) --format=<format> Output format: markdown, html, text, json (default: markdown)
--output=<file> Write output to a file --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: 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)
--format=<format> Output format: markdown, json (default: markdown)
Examples: 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 --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
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() { function printHelp() {
@@ -130,15 +154,15 @@ function usage() {
function parseTimeout() { function parseTimeout() {
const value = getOption("--timeout"); const value = getOption("--timeout");
if (value === undefined) { if (value === undefined) {
return DEFAULT_TIMEOUT_MS; return DEFAULT_TIMEOUT_S * 1000;
} }
const parsed = Number.parseInt(value, 10); const parsed = Number.parseFloat(value);
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) { if (!Number.isFinite(parsed) || parsed <= 0) {
cliError("INVALID_OPTION", "--timeout must be a positive integer."); cliError("INVALID_OPTION", "--timeout must be a positive number.");
} }
return parsed; return Math.round(parsed * 1000);
} }
function validateCommonOptions() { function validateCommonOptions() {
@@ -166,8 +190,8 @@ function getPreludeScriptSource() {
return inlineJs; return inlineJs;
} }
async function withDriver(action) { async function withDriver(action: (driver: WebDriver) => Promise<unknown>) {
let driver; let driver: WebDriver;
try { try {
driver = await createDriver({ headless, existingUrl }); 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") { if (waitUntil === "none") {
return; return;
} }
try { try {
await driver.wait(async () => { await driver.wait(async () => {
const readyState = await driver.executeScript( const readyState = (await driver.executeScript(
"return document.readyState", "return document.readyState",
); )) as string;
return waitUntil === "interactive" return waitUntil === "interactive"
? ["interactive", "complete"].includes(readyState) ? ["interactive", "complete"].includes(readyState)
: readyState === "complete"; : readyState === "complete";
@@ -199,19 +223,19 @@ async function waitForReadyState(driver) {
} catch { } catch {
cliError( cliError(
"WAIT_TIMEOUT", "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) { if (!waitJs) {
return; return;
} }
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
let result; let result: unknown;
try { try {
result = await driver.executeScript(waitJs); result = await driver.executeScript(waitJs);
@@ -228,11 +252,11 @@ async function waitForJs(driver) {
cliError( cliError(
"WAIT_TIMEOUT", "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(); const scriptSource = getPreludeScriptSource();
if (!scriptSource) { if (!scriptSource) {
return undefined; 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; runContext.targetUrl = targetUrl;
return withDriver(async (driver) => { return withDriver(async (driver: WebDriver) => {
// Navigate To Page // Navigate To Page
try { try {
await driver.get(targetUrl); 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() { async function execCommand() {
const [targetUrl] = getPositionalArgs(); const [targetUrl] = getPositionalArgs();
if (!targetUrl || (!inlineJs && !scriptPath)) usage(); if (!targetUrl || (!inlineJs && !scriptPath)) usage();
return withPage(targetUrl, async (_driver, scriptResult) => scriptResult); return withPage(targetUrl, async (_driver: WebDriver, scriptResult: unknown) => scriptResult);
} }
async function screenshotCommand() { async function screenshotCommand() {
@@ -371,7 +309,7 @@ async function screenshotCommand() {
if (!targetUrl) usage(); if (!targetUrl) usage();
return withPage(targetUrl, async (driver) => { return withPage(targetUrl, async (driver: WebDriver) => {
// Save Screenshot // Save Screenshot
const image = await driver.takeScreenshot(); const image = await driver.takeScreenshot();
writeFileSync(outputPath, image, "base64"); writeFileSync(outputPath, image, "base64");
@@ -385,11 +323,11 @@ async function screenshotCommand() {
}); });
} }
function markdownTitle(text) { function markdownTitle(text: string) {
return text.replaceAll(/\s+/g, " ").trim(); return text.replaceAll(/\s+/g, " ").trim();
} }
function articleToMarkdown(article) { function articleToMarkdown(article: ReaderArticle) {
const turndown = new TurndownService({ const turndown = new TurndownService({
headingStyle: "atx", headingStyle: "atx",
codeBlockStyle: "fenced", codeBlockStyle: "fenced",
@@ -413,7 +351,7 @@ function articleToMarkdown(article) {
return `${parts.join("\n\n").trim()}\n`; return `${parts.join("\n\n").trim()}\n`;
} }
function renderReaderOutput(article, format) { function renderReaderOutput(article: ReaderArticle, format: string) {
switch (format) { switch (format) {
case "markdown": case "markdown":
return article.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() { async function searchCommand() {
const provider = const provider =
getOption("--provider") ?? appConfig.search?.provider ?? "kagi"; getOption("--provider") ?? appConfig.search?.provider ?? "kagi";
const query = getPositionalArgs().join(" "); const query = getPositionalArgs().join(" ");
const format = getOption("--format") ?? "markdown";
if (!query) usage(); if (!query) usage();
if (!["markdown", "json"].includes(format)) {
cliError(
"INVALID_OPTION",
`Unsupported search format: ${format}. Expected markdown, json.`,
);
}
// Run Provider Search // Run Provider Search
let results: SearchResult[];
switch (provider) { switch (provider) {
case "kagi": case "kagi":
return searchKagi({ results = await searchKagi({
query, query,
token: getOption("--token"), token: getOption("--token"),
config: appConfig, config: appConfig,
@@ -449,33 +403,34 @@ async function searchCommand() {
existingUrl, existingUrl,
timeoutMs, timeoutMs,
}); });
break;
default: default:
cliError( cliError(
"UNSUPPORTED_SEARCH_PROVIDER", "UNSUPPORTED_SEARCH_PROVIDER",
`Unsupported search provider: ${provider}. Expected kagi.`, `Unsupported search provider: ${provider}. Expected kagi.`,
); );
} }
// Render Output
switch (format) {
case "markdown":
return searchResultsToMarkdown(results);
case "json":
return results;
}
} }
async function readerCommand() { // Try Reader View Extraction
const [targetUrl] = getPositionalArgs(); async function tryReaderView(
const outputPath = getOption("--output"); driver: WebDriver,
const format = getOption("--format") ?? "markdown"; finalUrl: string,
targetUrl: string,
if (!targetUrl) usage(); ): Promise<ReaderArticle | null> {
return withPage(targetUrl, async (driver) => {
// Capture Final Url
const finalUrl = await driver.getCurrentUrl();
// Open Firefox Reader View
const readerUrl = `about:reader?url=${encodeURIComponent(finalUrl)}`; const readerUrl = `about:reader?url=${encodeURIComponent(finalUrl)}`;
await driver.get(readerUrl); await driver.get(readerUrl);
// Wait For Reader Content
let article;
try { try {
article = await driver.wait( return await driver.wait(
async () => { async () => {
return driver.executeScript(` return driver.executeScript(`
const content = document.querySelector("#moz-reader-content, .moz-reader-content"); const content = document.querySelector("#moz-reader-content, .moz-reader-content");
@@ -503,11 +458,63 @@ async function readerCommand() {
timeoutMs, timeoutMs,
`No readable article content found for URL: ${targetUrl}`, `No readable article content found for URL: ${targetUrl}`,
); );
} catch (err) { } catch {
cliError("TIMEOUT", err.message); 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);
} }
// Render Output 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);
}
// 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.sourceUrl = targetUrl;
article.finalUrl = finalUrl; article.finalUrl = finalUrl;
article.markdown = articleToMarkdown(article); 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() { async function main() {
if (!command || command === "--help") { if (!command || command === "--help") {
printHelp(); printHelp();
@@ -542,8 +559,6 @@ async function main() {
appConfig = loadConfig({ path: configPath }); appConfig = loadConfig({ path: configPath });
switch (command) { switch (command) {
case "snapshot":
return snapshotCommand();
case "exec": case "exec":
return execCommand(); return execCommand();
case "screenshot": case "screenshot":
@@ -552,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
View 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();
});
}

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// @ts-nocheck
import { import {
mkdtempSync, mkdtempSync,
@@ -9,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;
@@ -73,7 +74,7 @@ test("no args prints help", ["help", "cli"], () => {
assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /Usage: glimpse <command> <url> \[options\]/); 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, ""); assert.equal(result.stderr, "");
}); });
@@ -86,48 +87,50 @@ test("help flag prints help", ["help", "cli"], () => {
assert.equal(result.stderr, ""); assert.equal(result.stderr, "");
}); });
test("snapshot returns page metadata and content", ["snapshot"], () => { test("reader extracts page content as markdown", ["reader"], () => {
const output = expectSuccess([ const result = runCli([
"snapshot", "reader",
dataHtml( 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(result.status, 0, result.stderr || result.stdout);
assert.equal(output.title, "Hello"); const output = result.stdout.trim();
assert.equal(typeof output.elapsedMs, "number"); assert.match(output, /# Main/);
assert.deepEqual(output.result.headings, [{ level: 1, text: "Main" }]); assert.match(output, /Some text/);
assert.deepEqual(output.result.links, [{ href: "/x", text: "X" }]); assert.match(output, /\[Link\]\(https:\/\/example\.com\/??\)/);
assert.equal(output.result.buttons[0].text, "Go");
assert.match(output.result.text, /Main/);
}); });
test("snapshot extracts aria headings", ["snapshot"], () => { test("reader returns json format with method field", ["reader"], () => {
const output = expectSuccess([ const output = expectSuccess([
"snapshot", "reader",
dataHtml( dataHtml("<title>Hello</title><h1>Main</h1><p>World</p>"),
'<title>Hello</title><div role="heading" aria-level="2">ARIA</div>', "--no-reader",
), "--format=json",
]); ]);
assert.equal(output.ok, true); assert.equal(output.title, "Hello");
assert.deepEqual(output.result.headings, [{ level: 2, text: "ARIA" }]); assert.equal(output.method, "raw");
assert.equal(typeof output.markdown, "string");
assert.match(output.markdown, /# Main/);
assert.match(output.text, /Main/);
}); });
test( test(
"snapshot runs top-level javascript before extraction", "reader runs top-level javascript before extraction",
["snapshot", "js"], ["reader", "js"],
() => { () => {
const output = expectSuccess([ const result = runCli([
"snapshot", "reader",
dataHtml("<title>Hello</title><h1>Old</h1>"), dataHtml("<title>Hello</title><h1>Old</h1>"),
"--no-reader",
"--js=document.querySelector('h1').textContent = 'New'", "--js=document.querySelector('h1').textContent = 'New'",
]); ]);
assert.equal(output.ok, true); assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]); assert.match(result.stdout, /# New/);
assert.equal(output.result.text, "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"], () => { 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;
const result = runCli(["search", "example query"], { env }); const result = runCli(["search", "--config=/nonexistent/path", "example query"], { env });
const output = parseJson(result.stderr); const output = parseJson(result.stderr);
assert.notEqual(result.status, 0, result.stdout || result.stderr); assert.notEqual(result.status, 0, result.stdout || result.stderr);
@@ -182,8 +298,9 @@ test(
writeFileSync(configPath, "not json"); writeFileSync(configPath, "not json");
const output = expectFailure([ const output = expectFailure([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title>"),
"--no-reader",
`--config=${configPath}`, `--config=${configPath}`,
]); ]);
@@ -201,8 +318,9 @@ test(
writeFileSync(configPath, JSON.stringify({ search: { provider: 42 } })); writeFileSync(configPath, JSON.stringify({ search: { provider: 42 } }));
const output = expectFailure([ const output = expectFailure([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title>"),
"--no-reader",
`--config=${configPath}`, `--config=${configPath}`,
]); ]);
@@ -218,17 +336,17 @@ test("empty home config is accepted", ["config"], () => {
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, "config.json"), "{}"); writeFileSync(join(configDir, "config.json"), "{}");
const output = expectSuccess( const result = runCli(
["snapshot", dataHtml("<title>Hello</title><h1>Main</h1>")], ["reader", dataHtml("<title>Hello</title><h1>Main</h1>"), "--no-reader"],
{ env: { ...process.env, XDG_CONFIG_HOME: configHome } }, { env: { ...process.env, XDG_CONFIG_HOME: configHome } },
); );
assert.equal(output.ok, true); assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(output.title, "Hello"); assert.match(result.stdout, /# Main/);
}); });
test("unknown command returns structured error", ["errors", "cli"], () => { 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.ok, false);
assert.equal(output.error.code, "UNKNOWN_COMMAND"); assert.equal(output.error.code, "UNKNOWN_COMMAND");
@@ -241,22 +359,24 @@ test(
["errors", "timeout"], ["errors", "timeout"],
() => { () => {
const output = expectFailure([ const output = expectFailure([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title>"),
"--no-reader",
"--timeout=abc", "--timeout=abc",
]); ]);
assert.equal(output.ok, false); assert.equal(output.ok, false);
assert.equal(output.error.code, "INVALID_OPTION"); 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"); assert.equal(typeof output.elapsedMs, "number");
}, },
); );
test("invalid wait-until returns invalid option", ["errors", "wait"], () => { test("invalid wait-until returns invalid option", ["errors", "wait"], () => {
const output = expectFailure([ const output = expectFailure([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title>"),
"--no-reader",
"--wait-until=loaded", "--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"], () => { test("wait-js succeeds when condition is true", ["wait"], () => {
const output = expectSuccess([ const result = runCli([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title><h1>Main</h1>"),
"--no-reader",
'--wait-js=return document.title === "Hello"', '--wait-js=return document.title === "Hello"',
]); ]);
assert.equal(output.ok, true); assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(output.title, "Hello"); assert.match(result.stdout, /# Main/);
}); });
test("wait-js timeout returns wait timeout", ["wait", "errors"], () => { test("wait-js timeout returns wait timeout", ["wait", "errors"], () => {
const output = expectFailure([ const output = expectFailure([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title>"),
"--no-reader",
"--wait-js=return false", "--wait-js=return false",
"--timeout=1", "--timeout=0.001",
]); ]);
assert.equal(output.ok, false); assert.equal(output.ok, false);
@@ -296,8 +418,9 @@ test(
["wait", "errors", "js"], ["wait", "errors", "js"],
() => { () => {
const output = expectFailure([ const output = expectFailure([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title>"),
"--no-reader",
'--wait-js=throw new Error("boom")', '--wait-js=throw new Error("boom")',
]); ]);
@@ -313,8 +436,9 @@ test(
["errors", "js"], ["errors", "js"],
() => { () => {
const output = expectFailure([ const output = expectFailure([
"snapshot", "reader",
dataHtml("<title>Hello</title>"), dataHtml("<title>Hello</title>"),
"--no-reader",
'--js=throw new Error("boom")', '--js=throw new Error("boom")',
]); ]);

View File

@@ -11,5 +11,6 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"],
"exclude": ["test", "node_modules", "dist"]
} }