Compare commits
5 Commits
6b3ec32b3a
...
e3d7c28820
| 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:
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
nodejs_22
|
nodejs_22
|
||||||
firefox
|
firefox
|
||||||
geckodriver
|
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 { 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
|
||||||
|
|||||||
331
src/index.ts
331
src/index.ts
@@ -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,35 +403,36 @@ 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,
|
||||||
|
): Promise<ReaderArticle | null> {
|
||||||
|
const readerUrl = `about:reader?url=${encodeURIComponent(finalUrl)}`;
|
||||||
|
await driver.get(readerUrl);
|
||||||
|
|
||||||
if (!targetUrl) usage();
|
try {
|
||||||
|
return await driver.wait(
|
||||||
return withPage(targetUrl, async (driver) => {
|
async () => {
|
||||||
// Capture Final Url
|
return driver.executeScript(`
|
||||||
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(`
|
|
||||||
const content = document.querySelector("#moz-reader-content, .moz-reader-content");
|
const content = document.querySelector("#moz-reader-content, .moz-reader-content");
|
||||||
const error = document.querySelector(".reader-error");
|
const error = document.querySelector(".reader-error");
|
||||||
const text = content?.innerText?.trim() || "";
|
const text = content?.innerText?.trim() || "";
|
||||||
@@ -499,15 +454,67 @@ async function readerCommand() {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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
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
|
#!/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")',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,6 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["test", "node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user