From fe1244ad0b2c3b58c52dc73c53145638f937e320 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 26 Apr 2026 12:39:57 -0400 Subject: [PATCH] initial commit --- .gitignore | 2 + AGENTS.md | 70 ++++++ README.md | 268 ++++++++++++++++++++++ driver.js | 50 +++++ flake.lock | 61 +++++ flake.nix | 72 ++++++ index.js | 555 ++++++++++++++++++++++++++++++++++++++++++++++ kagi.js | 65 ++++++ oxlintrc.json | 4 + package-lock.json | 548 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 32 +++ test/smoke.js | 293 ++++++++++++++++++++++++ 12 files changed, 2020 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 driver.js create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 index.js create mode 100644 kagi.js create mode 100644 oxlintrc.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 test/smoke.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5f87c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +result diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..70630b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,70 @@ +# Project Guidelines + +## Purpose + +This project provides small Firefox/Selenium browser utilities packaged by Nix: + +- `glimpse` - generic page utilities with subcommands, including provider-backed search + +Keep the tools simple, scriptable, and JSON-friendly. + +## Commands + +Prefer relevant checks after changes because the full suite can take time: + +```bash +npm run lint +npm run test:list +node test/smoke.js +``` + +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. + +Do not attempt a live Kagi test unless `KAGI_TOKEN` is available. + +## CLI Conventions + +- `glimpse` is the generic CLI and should use subcommands. +- Keep search as the `glimpse search` subcommand. +- Do not reintroduce a top-level `--search` flag to `glimpse`. +- Browser execution should be headless by default. +- Use `--no-headless` as the opt-out. +- Keep `--url=` support for connecting to an existing WebDriver server. +- `--timeout=` is a top-level option for command waits and defaults to `10000`. +- `--wait-js=` and `--wait-until=` are top-level wait options available to every `glimpse` subcommand. +- `--js=` and `--script=` are top-level options available to every `glimpse` subcommand and run before command-specific behavior. +- Prefer structured JSON output for objects/arrays. +- CLI errors should be structured JSON on stderr with `ok: false`, stable `error.code`, `error.message`, and `elapsedMs`. + +Current `glimpse` subcommands: + +- `snapshot ` - return an agent-friendly page snapshot as JSON +- `exec --js=` or `--script=` - execute JavaScript and return the result +- `screenshot --output=` - save a PNG screenshot +- `reader ` - open Firefox Reader View and output readable content as Markdown +- `search ` - search with a supported provider and output JSON results + +## Runtime Requirements + +The Nix package must ensure both `firefox` and `geckodriver` are available to installed binaries. The current wrapper does this by prefixing `PATH` for `glimpse`. + +If running outside Nix, document that Firefox and geckodriver must be on `PATH`. + +## Code Style + +- Use ES modules. +- Keep code direct and minimal; avoid abstractions until they are needed. +- Add short Title Case comments above cohesive logic blocks. +- Prefer exact, actionable error messages. +- Keep command-line parsing simple unless a real need for a parser library appears. + +## Nix Notes + +- Update `npmDepsHash` whenever `package-lock.json` changes. +- Keep the source filter excluding `.git`, `.direnv`, `node_modules`, and `result`. +- `packages.default` should contain `glimpse`. +- `apps.default` should run `glimpse`. + +## Kagi Notes + +Kagi search requires `--token=` or `KAGI_TOKEN`. The token is validated by the Kagi provider and passed to Kagi as the `token` query parameter. The agent may not have this token, so live search validation is best-effort only. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9269771 --- /dev/null +++ b/README.md @@ -0,0 +1,268 @@ +# glimpse + +Small Firefox/Selenium browser utilities packaged with Nix. + +The project provides a `glimpse` CLI with subcommands for page automation and provider-backed search. It runs Firefox headless by default. The Nix package wraps the binary so `firefox` and `geckodriver` are available on `PATH`. + +## Requirements + +### Nix Usage + +Nix is the easiest way to run this project. It provides Node.js, Firefox, and geckodriver. + +```bash +nix run .#glimpse -- exec https://example.com --js='return document.title' +``` + +### Local Node Usage + +If running directly with Node.js, install dependencies and make sure `firefox` and `geckodriver` are available on `PATH`. + +```bash +npm install +node index.js exec https://example.com --js='return document.title' +``` + +## Glimpse CLI + +```bash +glimpse [options] +``` + +Common options: + +- `--no-headless` - show Firefox instead of running headless +- `--url=` - connect to an existing WebDriver server +- `--timeout=` - maximum wait time in milliseconds for command waits (default: `10000`) +- `--wait-js=` - poll JavaScript until it returns a truthy value before command-specific behavior +- `--wait-until=` - wait for document readiness: `none`, `interactive`, or `complete` (default: `none`) +- `--js=` - execute inline JavaScript after loading the page and before command-specific behavior +- `--script=` - execute JavaScript from a file after loading the page and before command-specific behavior + +Running `glimpse` with no arguments or with `--help` prints human-readable help. Runtime and validation errors are emitted as structured JSON on stderr with `ok: false`, a stable error `code`, a human-readable `message`, and `elapsedMs`. + +### Snapshot A Page + +Return an agent-friendly structured view of a page. + +```bash +nix run .#glimpse -- snapshot https://example.com +``` + +Wait for asynchronous page state before extracting the snapshot: + +```bash +nix run .#glimpse -- snapshot https://example.com --wait-until=complete --wait-js='return document.body.innerText.length > 0' +``` + +Output: + +```json +{ + "ok": true, + "url": "https://example.com/", + "title": "Example Domain", + "result": { + "text": "Example Domain\nThis domain is for use in illustrative examples...", + "headings": [ + { + "level": 1, + "text": "Example Domain" + } + ], + "links": [ + { + "text": "More information...", + "href": "https://www.iana.org/domains/example" + } + ], + "buttons": [], + "inputs": [], + "forms": [] + } +} +``` + +Snapshot includes page text, headings, links, buttons, inputs, and forms. Heading extraction is best-effort and does not fail the snapshot if heading metadata cannot be extracted. + +### Reader View To Markdown + +Open a page with Firefox Reader View and print the readable article content as Markdown. + +```bash +nix run .#glimpse -- reader https://example.com/article +``` + +Write Markdown to a file: + +```bash +nix run .#glimpse -- reader https://example.com/article --output=article.md +``` + +Other formats are available: + +```bash +nix run .#glimpse -- reader https://example.com/article --format=json +nix run .#glimpse -- reader https://example.com/article --format=html +nix run .#glimpse -- reader https://example.com/article --format=text +``` + +Options: + +- `--format=` - output `markdown`, `html`, `text`, or `json` (default: `markdown`) +- `--output=` - write output to a file + +### Execute JavaScript + +JavaScript execution is available as a top-level option for every `glimpse` command. The script runs after loading the page and before command-specific behavior. + +Use the `exec` command when you only want to print the returned JavaScript value. + +Inline JavaScript: + +```bash +nix run .#glimpse -- exec https://example.com --js='return document.title' +``` + +JavaScript from a file: + +```bash +nix run .#glimpse -- exec https://example.com --script=extract.js +``` + +The script should explicitly return a value: + +```javascript +return { + title: document.title, + links: Array.from(document.querySelectorAll("a")).map((a) => a.href), +}; +``` + +Objects and arrays are printed as formatted JSON. Primitive values are printed directly. + +### Screenshot A Page + +Save a PNG screenshot after loading a page. + +```bash +nix run .#glimpse -- screenshot https://example.com --output=example.png +``` + +Run JavaScript before taking the screenshot: + +```bash +nix run .#glimpse -- screenshot https://example.com --js='document.body.style.zoom = "80%"' --output=example.png +``` + +If `--output` is omitted, the screenshot is saved to `screenshot.png`. + +Output: + +```json +{ + "ok": true, + "result": { + "path": "example.png" + }, + "elapsedMs": 842 +} +``` + +### Search With A Provider + +Search using a supported provider and print a JSON array of results. Currently only Kagi is supported. + +Kagi requires `--token=` or a `KAGI_TOKEN` environment variable. The token is validated by the Kagi provider and sent to Kagi as the `token` query parameter. + +```bash +KAGI_TOKEN=... nix run .#glimpse -- search --provider=kagi "nix flakes selenium webdriver" +``` + +Local usage: + +```bash +KAGI_TOKEN=... ./result/bin/glimpse search "nix flakes selenium webdriver" +``` + +Options: + +- `--provider=` - search provider: `kagi` (default: `kagi`) +- `--token=` - Kagi token (default: `KAGI_TOKEN`) +- `--no-headless` - show Firefox instead of running headless +- `--url=` - connect to an existing WebDriver server +- `--timeout=` - wait time for results before returning `[]` (default: `10000`) + +Output is a JSON array of search results: + +```json +[ + { + "title": "Result title", + "url": "https://example.com", + "description": "Result description" + } +] +``` + +## Build + +Build the default package, which contains `glimpse`: + +```bash +nix build .#default +``` + +Run the built tool: + +```bash +./result/bin/glimpse exec https://example.com --js='return document.title' +KAGI_TOKEN=... ./result/bin/glimpse search "example query" +``` + +## Development + +Enter the dev shell: + +```bash +nix develop +``` + +Run linting: + +```bash +npm run lint +``` + +Run smoke tests. These require Firefox and geckodriver on `PATH` and use local `data:` HTML pages. + +```bash +npm test +``` + +Run focused smoke tests by tag when iterating on a specific area: + +```bash +npm run test:list +npm run test:snapshot +npm run test:wait +npm run test:errors +node test/smoke.js snapshot js +``` + +Useful local commands: + +```bash +node index.js snapshot 'data:text/html,Hello

Hello

' +node index.js exec 'data:text/html,Hello' --js='return document.title' +node index.js screenshot 'data:text/html,Hello' --output=/tmp/page.png +node index.js reader 'https://example.com/article' +``` + +## Project Structure + +- `index.js` - `glimpse` CLI with subcommands, including Firefox Reader View extraction and provider-backed search +- `driver.js` - Firefox WebDriver creation and geckodriver resolution +- `kagi.js` - reusable Kagi search provider implementation +- `flake.nix` - Nix dev shell, package, wrappers, and apps +- `KAGI.md` - Kagi-specific notes diff --git a/driver.js b/driver.js new file mode 100644 index 0000000..5532528 --- /dev/null +++ b/driver.js @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { Builder } from "selenium-webdriver"; +import firefox from "selenium-webdriver/firefox.js"; + +/** + * Resolve the geckodriver path from $PATH. + * + * @returns {string} + */ +function findGeckodriver() { + 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).", + ); + } +} + +/** + * Create a Firefox WebDriver instance. + * + * @param {object} opts + * @param {boolean} [opts.headless=false] - Run Firefox in headless mode. + * @param {string} [opts.existingUrl] - Connect to an already-running + * WebDriver server (e.g. "http://localhost:4444"). + * @returns {Promise} + */ +export async function createDriver({ headless = false, existingUrl } = {}) { + const options = new firefox.Options(); + + // Configure Headless + if (headless) { + options.addArguments("--headless"); + } + + const builder = new Builder().forBrowser("firefox").setFirefoxOptions(options); + + // Connect to Existing Server + if (existingUrl) { + builder.usingServer(existingUrl); + } else { + // Use System Geckodriver - Bypasses the bundled selenium-manager which + // is x86-64 only and doesn't work on aarch64. + const service = new firefox.ServiceBuilder(findGeckodriver()); + builder.setFirefoxService(service); + } + + return builder.build(); +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d42bc35 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776734388, + "narHash": "sha256-vl3dkhlE5gzsItuHoEMVe+DlonsK+0836LIRDnm6MXQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4206703 --- /dev/null +++ b/flake.nix @@ -0,0 +1,72 @@ +{ + description = "Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self + , nixpkgs + , flake-utils + , + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + runtimePath = pkgs.lib.makeBinPath [ + pkgs.firefox + pkgs.geckodriver + ]; + source = builtins.path { + path = ./.; + name = "glimpse-source"; + filter = + path: type: + let + name = baseNameOf path; + in + !(builtins.elem name [ + ".direnv" + ".git" + "node_modules" + "result" + ]); + }; + in + { + packages.default = pkgs.buildNpmPackage { + pname = "glimpse"; + version = "1.0.0"; + + src = source; + npmDepsHash = "sha256-jtwQb8TDdvzyeMBN/ubUQWRtBIJuO/QDtKW9ep19N6Q="; + dontNpmBuild = true; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + postInstall = '' + wrapProgram $out/bin/glimpse \ + --prefix PATH : ${runtimePath} + ''; + }; + + apps.glimpse = { + type = "app"; + program = "${self.packages.${system}.default}/bin/glimpse"; + }; + + apps.default = self.apps.${system}.glimpse; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + nodejs_22 + firefox + geckodriver + ]; + }; + } + ); +} diff --git a/index.js b/index.js new file mode 100755 index 0000000..33463a0 --- /dev/null +++ b/index.js @@ -0,0 +1,555 @@ +#!/usr/bin/env node + +import { createDriver } from "./driver.js"; +import { searchKagi } from "./kagi.js"; +import { readFileSync, writeFileSync } from "node:fs"; +import TurndownService from "turndown"; + +const DEFAULT_TIMEOUT_MS = 10000; +const POLL_INTERVAL_MS = 200; +const startTime = Date.now(); +const runContext = {}; + +// Parse CLI Args +const [command, ...args] = process.argv.slice(2); +const headless = !args.includes("--no-headless"); +const existingUrl = getOption("--url"); +const inlineJs = getOption("--js"); +const scriptPath = getOption("--script"); +const waitJs = getOption("--wait-js"); +const waitUntil = getOption("--wait-until") ?? "none"; +let timeoutMs = DEFAULT_TIMEOUT_MS; + +function getOption(name) { + const prefix = `${name}=`; + return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +} + +function getPositionalArgs() { + return args.filter((arg) => !arg.startsWith("--")); +} + +function elapsedMs() { + return Date.now() - startTime; +} + +function printResult(result) { + if (result === undefined) { + return; + } + + const outputValue = + result && typeof result === "object" && !Array.isArray(result) + ? { ...result, elapsedMs: result.elapsedMs ?? elapsedMs() } + : result; + const output = + typeof outputValue === "object" + ? JSON.stringify(outputValue, null, 2) + : String(outputValue); + process.stdout.write(output.endsWith("\n") ? output : `${output}\n`); +} + +class CliError extends Error { + constructor(code, message, details = {}) { + super(message); + this.code = code; + this.details = details; + } +} + +function cliError(code, message, details = {}) { + throw new CliError(code, message, details); +} + +function unknownCommand(name) { + cliError("UNKNOWN_COMMAND", `Unknown command: ${name}`); +} + +function helpText() { + return `Usage: glimpse [options] + +Commands: + snapshot [options] Return an agent-friendly page snapshot as JSON + exec [options] Execute JavaScript on a page and return the result + screenshot [options] Save a PNG screenshot of a page + reader [options] Extract Firefox Reader View content as Markdown + search [options] Search using a supported provider and return JSON results + +Common Options: + --help Show this help + --no-headless Show Firefox instead of running headless + --url= Connect to an existing WebDriver server + --timeout= Maximum wait time in milliseconds (default: 10000) + --wait-js= Poll JS until it returns a truthy value + --wait-until= Wait for readiness: none, interactive, complete (default: none) + --js= Execute inline JS before command logic + --script= Execute JS from a file before command logic + +Exec Options: + --js= Return the top-level JS result + --script= Return the top-level script result + +Screenshot Options: + --output= Output PNG path (default: screenshot.png) + +Reader Options: + --format= Output format: markdown, html, text, json (default: markdown) + --output= Write output to a file + +Search Options: + --provider= Search provider: kagi (default: kagi) + --token= Kagi token (default: KAGI_TOKEN) + +Examples: + glimpse snapshot https://example.com + glimpse exec https://example.com --js="return document.title" + glimpse exec https://example.com --script=extract.js + glimpse screenshot https://example.com --js="document.body.style.zoom = '80%'" --output=example.png + glimpse reader https://example.com/article --script=prepare.js --output=article.md + KAGI_TOKEN=... glimpse search --provider=kagi "node.js browser automation"`; +} + +function printHelp() { + process.stdout.write(`${helpText()}\n`); +} + +function usage() { + cliError("USAGE_ERROR", "Usage: glimpse [options]. Run glimpse --help for details."); +} + +function parseTimeout() { + const value = getOption("--timeout"); + if (value === undefined) { + return DEFAULT_TIMEOUT_MS; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) { + cliError("INVALID_OPTION", "--timeout must be a positive integer."); + } + + return parsed; +} + +function validateCommonOptions() { + if (inlineJs && scriptPath) { + cliError("INVALID_OPTION", "Use either --js or --script, not both."); + } + + // Validate Timeout + timeoutMs = parseTimeout(); + + // Validate Wait State + if (!["none", "interactive", "complete"].includes(waitUntil)) { + cliError( + "INVALID_OPTION", + `Unsupported --wait-until value: ${waitUntil}. Expected none, interactive, or complete.`, + ); + } +} + +function getPreludeScriptSource() { + if (scriptPath) { + return readFileSync(scriptPath, "utf-8"); + } + + return inlineJs; +} + +async function withDriver(action) { + let driver; + + try { + driver = await createDriver({ headless, existingUrl }); + } catch (err) { + cliError("BROWSER_START_FAILED", err.message); + } + + try { + return await action(driver); + } finally { + await driver.quit(); + } +} + +async function waitForReadyState(driver) { + if (waitUntil === "none") { + return; + } + + try { + await driver.wait(async () => { + const readyState = await driver.executeScript("return document.readyState"); + return waitUntil === "interactive" + ? ["interactive", "complete"].includes(readyState) + : readyState === "complete"; + }, timeoutMs); + } catch { + cliError( + "WAIT_TIMEOUT", + `Timed out after ${timeoutMs}ms waiting for --wait-until=${waitUntil}`, + ); + } +} + +async function waitForJs(driver) { + if (!waitJs) { + return; + } + + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + let result; + + try { + result = await driver.executeScript(waitJs); + } catch (err) { + cliError("SCRIPT_FAILED", `--wait-js failed: ${err.message}`); + } + + if (result) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + cliError("WAIT_TIMEOUT", `Timed out after ${timeoutMs}ms waiting for --wait-js`); +} + +async function runPreludeScript(driver) { + const scriptSource = getPreludeScriptSource(); + if (!scriptSource) { + return undefined; + } + + try { + return await driver.executeScript(scriptSource); + } catch (err) { + cliError("SCRIPT_FAILED", `Prelude script failed: ${err.message}`); + } +} + +async function withPage(targetUrl, action) { + runContext.targetUrl = targetUrl; + + return withDriver(async (driver) => { + // Navigate To Page + try { + await driver.get(targetUrl); + runContext.currentUrl = await driver.getCurrentUrl(); + } catch (err) { + cliError("NAVIGATION_FAILED", err.message); + } + + // Wait For Page Readiness + await waitForReadyState(driver); + await waitForJs(driver); + + // Run Prelude Script + const scriptResult = await runPreludeScript(driver); + + return action(driver, scriptResult); + }); +} + +const snapshotScript = ` +const normalize = (value) => String(value || "").replace(/\\s+/g, " ").trim(); +const visibleText = (element) => normalize(element?.innerText || element?.textContent || ""); +const safeValue = (input) => ["password", "hidden"].includes(input.type) ? "" : input.value || ""; +const labelText = (input) => { + const labels = Array.from(input.labels || []).map((label) => visibleText(label)).filter(Boolean); + if (labels.length > 0) return labels.join(" "); + + if (input.id) { + const label = Array.from(document.querySelectorAll("label[for]")) + .find((candidate) => candidate.getAttribute("for") === input.id); + if (label) return visibleText(label); + } + + return ""; +}; +const inputSummary = (input) => ({ + type: input.type || input.tagName.toLowerCase(), + name: input.name || "", + id: input.id || "", + placeholder: input.placeholder || "", + value: safeValue(input), + label: labelText(input), +}); +const collectHeadings = () => { + try { + return Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6,[role='heading']")) + .map((heading) => { + const tagLevel = heading.tagName.match(/^H([1-6])$/i)?.[1]; + const ariaLevel = heading.getAttribute("aria-level"); + const level = Number.parseInt(tagLevel || ariaLevel || "0", 10); + const text = visibleText(heading); + + return text ? { level: level || null, text } : null; + }) + .filter(Boolean); + } catch { + return []; + } +}; + +return { + text: normalize(document.body?.innerText || ""), + headings: collectHeadings(), + links: Array.from(document.querySelectorAll("a[href]")) + .map((link) => ({ text: visibleText(link), href: link.href })) + .filter((link) => link.text || link.href), + buttons: Array.from(document.querySelectorAll("button,input[type='button'],input[type='submit'],input[type='reset'],[role='button']")) + .map((button) => ({ + text: visibleText(button) || button.value || button.getAttribute("aria-label") || "", + type: button.type || button.getAttribute("role") || "button", + name: button.name || "", + id: button.id || "", + })) + .filter((button) => button.text || button.name || button.id), + inputs: Array.from(document.querySelectorAll("input,textarea,select")) + .map(inputSummary), + forms: Array.from(document.querySelectorAll("form")) + .map((form) => ({ + action: form.action || "", + method: (form.method || "get").toLowerCase(), + text: visibleText(form), + inputs: Array.from(form.querySelectorAll("input,textarea,select")).map(inputSummary), + })), +}; +`; + +async function snapshotCommand() { + const [targetUrl] = getPositionalArgs(); + + if (!targetUrl) usage(); + + return withPage(targetUrl, async (driver) => { + // Capture Page Metadata + const [url, title, result] = await Promise.all([ + driver.getCurrentUrl(), + driver.getTitle(), + driver.executeScript(snapshotScript), + ]); + + return { + ok: true, + url, + title, + result, + }; + }); +} + +async function execCommand() { + const [targetUrl] = getPositionalArgs(); + + if (!targetUrl || (!inlineJs && !scriptPath)) usage(); + + return withPage(targetUrl, async (_driver, scriptResult) => scriptResult); +} + +async function screenshotCommand() { + const [targetUrl] = getPositionalArgs(); + const outputPath = getOption("--output") ?? "screenshot.png"; + + if (!targetUrl) usage(); + + return withPage(targetUrl, async (driver) => { + // Save Screenshot + const image = await driver.takeScreenshot(); + writeFileSync(outputPath, image, "base64"); + + return { + ok: true, + result: { + path: outputPath, + }, + }; + }); +} + +function markdownTitle(text) { + return text.replaceAll(/\s+/g, " ").trim(); +} + +function articleToMarkdown(article) { + const turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + }); + + // Convert Reader HTML + const body = turndown.turndown(article.html).trim(); + const parts = []; + + // Add Article Metadata + if (article.title) { + parts.push(`# ${markdownTitle(article.title)}`); + } + if (article.byline) { + parts.push(`_${markdownTitle(article.byline)}_`); + } + if (body) { + parts.push(body); + } + + return `${parts.join("\n\n").trim()}\n`; +} + +function renderReaderOutput(article, format) { + switch (format) { + case "markdown": + return article.markdown; + case "html": + return article.html; + case "text": + return article.text; + case "json": + return article; + default: + cliError( + "INVALID_OPTION", + `Unsupported reader format: ${format}. Expected markdown, html, text, or json.`, + ); + } +} + +async function searchCommand() { + const provider = getOption("--provider") ?? "kagi"; + const query = getPositionalArgs().join(" "); + + if (!query) usage(); + + // Run Provider Search + switch (provider) { + case "kagi": + return searchKagi({ + query, + token: getOption("--token"), + headless, + existingUrl, + timeoutMs, + }); + default: + cliError( + "UNSUPPORTED_SEARCH_PROVIDER", + `Unsupported search provider: ${provider}. Expected kagi.`, + ); + } +} + +async function readerCommand() { + const [targetUrl] = getPositionalArgs(); + const outputPath = getOption("--output"); + const format = getOption("--format") ?? "markdown"; + + if (!targetUrl) usage(); + + return withPage(targetUrl, async (driver) => { + // Capture Final Url + const finalUrl = await driver.getCurrentUrl(); + + // Open Firefox Reader View + const readerUrl = `about:reader?url=${encodeURIComponent(finalUrl)}`; + await driver.get(readerUrl); + + // Wait For Reader Content + let article; + try { + article = await driver.wait(async () => { + return driver.executeScript(` + const content = document.querySelector("#moz-reader-content, .moz-reader-content"); + const error = document.querySelector(".reader-error"); + const text = content?.innerText?.trim() || ""; + + if (text) { + return { + title: document.querySelector("h1.reader-title")?.textContent?.trim() || document.title || "", + byline: document.querySelector(".reader-byline, .reader-credits")?.textContent?.trim() || "", + siteName: document.querySelector(".reader-domain")?.textContent?.trim() || "", + html: content.innerHTML, + text, + readerUrl: location.href, + }; + } + + if (error?.textContent?.trim()) { + throw new Error(error.textContent.trim()); + } + + return null; + `); + }, timeoutMs, `No readable article content found for URL: ${targetUrl}`); + } catch (err) { + cliError("TIMEOUT", err.message); + } + + // Render Output + article.sourceUrl = targetUrl; + article.finalUrl = finalUrl; + article.markdown = articleToMarkdown(article); + const output = renderReaderOutput(article, format); + + if (outputPath) { + writeFileSync( + outputPath, + typeof output === "object" ? JSON.stringify(output, null, 2) : output, + ); + return { + ok: true, + result: { + path: outputPath, + }, + }; + } + + return output; + }); +} + +async function main() { + if (!command || command === "--help") { + printHelp(); + return undefined; + } + + validateCommonOptions(); + + switch (command) { + case "snapshot": + return snapshotCommand(); + case "exec": + return execCommand(); + case "screenshot": + return screenshotCommand(); + case "reader": + return readerCommand(); + case "search": + return searchCommand(); + default: + unknownCommand(command); + } +} + +main() + .then(printResult) + .catch((err) => { + const code = err.code || "COMMAND_FAILED"; + const output = { + ok: false, + error: { + code, + message: err.message, + }, + elapsedMs: elapsedMs(), + }; + + if (runContext.currentUrl || runContext.targetUrl) { + output.url = runContext.currentUrl || runContext.targetUrl; + } + + console.error(JSON.stringify(output, null, 2)); + process.exit(1); + }); diff --git a/kagi.js b/kagi.js new file mode 100644 index 0000000..3962c78 --- /dev/null +++ b/kagi.js @@ -0,0 +1,65 @@ +import { createDriver } from "./driver.js"; + +export class SearchProviderError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +// Kagi Search Extraction +export const kagiSearchScript = `return Array.from(document.querySelectorAll("div > .__sri-title")) + .map((i) => i.parentElement) + .map((i) => ({ + title: i.children[0].querySelector("a").innerText, + url: i.children[0].querySelector("a").getAttribute("href"), + description: i.querySelector(".__sri-body").innerText, + }));`; + +// Build Kagi Search Url +export function buildKagiSearchUrl(query, token) { + return `https://kagi.com/search?token=${encodeURIComponent(token)}&q=${encodeURIComponent(query)}`; +} + +// Search Kagi +export async function searchKagi({ + query, + token = process.env.KAGI_TOKEN, + headless = true, + existingUrl, + timeoutMs = 5000, + intervalMs = 200, +} = {}) { + if (!query) { + throw new SearchProviderError("QUERY_REQUIRED", "query is required"); + } + + // Validate Kagi Token + if (!token) { + throw new SearchProviderError( + "KAGI_TOKEN_REQUIRED", + "Kagi search requires --token or the KAGI_TOKEN environment variable.", + ); + } + + const driver = await createDriver({ headless, existingUrl }); + + try { + // Navigate To Kagi + await driver.get(buildKagiSearchUrl(query, token)); + + // Poll For Results + let result = []; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + result = await driver.executeScript(kagiSearchScript); + if (Array.isArray(result) && result.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + return result; + } finally { + await driver.quit(); + } +} diff --git a/oxlintrc.json b/oxlintrc.json new file mode 100644 index 0000000..fe06e16 --- /dev/null +++ b/oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "ignorePatterns": ["node_modules"] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2113407 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,548 @@ +{ + "name": "glimpse", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "glimpse", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "oxlint": "^1.61.0", + "selenium-webdriver": "^4.43.0", + "turndown": "^7.2.4" + }, + "bin": { + "glimpse": "index.js" + } + }, + "node_modules/@bazel/runfiles": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", + "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", + "license": "Apache-2.0" + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", + "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", + "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", + "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", + "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", + "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", + "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", + "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", + "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", + "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", + "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", + "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", + "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", + "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.61.0.tgz", + "integrity": "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", + "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", + "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", + "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", + "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", + "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/oxlint": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.61.0.tgz", + "integrity": "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==", + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.61.0", + "@oxlint/binding-android-arm64": "1.61.0", + "@oxlint/binding-darwin-arm64": "1.61.0", + "@oxlint/binding-darwin-x64": "1.61.0", + "@oxlint/binding-freebsd-x64": "1.61.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", + "@oxlint/binding-linux-arm-musleabihf": "1.61.0", + "@oxlint/binding-linux-arm64-gnu": "1.61.0", + "@oxlint/binding-linux-arm64-musl": "1.61.0", + "@oxlint/binding-linux-ppc64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-musl": "1.61.0", + "@oxlint/binding-linux-s390x-gnu": "1.61.0", + "@oxlint/binding-linux-x64-gnu": "1.61.0", + "@oxlint/binding-linux-x64-musl": "1.61.0", + "@oxlint/binding-openharmony-arm64": "1.61.0", + "@oxlint/binding-win32-arm64-msvc": "1.61.0", + "@oxlint/binding-win32-ia32-msvc": "1.61.0", + "@oxlint/binding-win32-x64-msvc": "1.61.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.18.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/selenium-webdriver": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz", + "integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@bazel/runfiles": "^6.5.0", + "jszip": "^3.10.1", + "tmp": "^0.2.5", + "ws": "^8.20.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f272030 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "glimpse", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "index.js", + "bin": { + "glimpse": "./index.js" + }, + "scripts": { + "lint": "oxlint --ignore-pattern node_modules --ignore-pattern .direnv .", + "start": "node index.js", + "test": "node test/smoke.js", + "test:smoke": "node test/smoke.js", + "test:list": "node test/smoke.js --list", + "test:help": "node test/smoke.js help", + "test:snapshot": "node test/smoke.js snapshot", + "test:exec": "node test/smoke.js exec", + "test:screenshot": "node test/smoke.js screenshot", + "test:wait": "node test/smoke.js wait", + "test:errors": "node test/smoke.js errors", + "test:js": "node test/smoke.js js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "oxlint": "^1.61.0", + "selenium-webdriver": "^4.43.0", + "turndown": "^7.2.4" + } +} diff --git a/test/smoke.js b/test/smoke.js new file mode 100755 index 0000000..fc41f35 --- /dev/null +++ b/test/smoke.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import assert from "node:assert/strict"; + +const cliPath = new URL("../index.js", import.meta.url).pathname; +const tempDir = mkdtempSync(join(tmpdir(), "glimpse-smoke-")); +const filters = process.argv.slice(2).filter((arg) => arg !== "--list"); +const shouldList = process.argv.includes("--list"); +const tests = []; + +function test(name, tags, fn) { + tests.push({ name, tags, fn }); +} + +function dataHtml(html) { + return `data:text/html,${html}`; +} + +function runCli(args, options = {}) { + return spawnSync(process.execPath, [cliPath, ...args], { + encoding: "utf-8", + env: options.env ?? process.env, + timeout: 30000, + }); +} + +function parseJson(text) { + try { + return JSON.parse(text); + } catch (err) { + throw new Error(`Failed to parse JSON: ${err.message}\n${text}`); + } +} + +function expectSuccess(args) { + const result = runCli(args); + assert.equal(result.status, 0, result.stderr || result.stdout); + return parseJson(result.stdout); +} + +function expectFailure(args) { + const result = runCli(args); + assert.notEqual(result.status, 0, result.stdout || result.stderr); + return parseJson(result.stderr); +} + +function matchesFilters(entry) { + if (filters.length === 0) { + return true; + } + + return filters.some((filter) => { + const normalized = filter.toLowerCase(); + return ( + entry.name.toLowerCase().includes(normalized) || + entry.tags.includes(normalized) + ); + }); +} + +test("no args prints help", ["help", "cli"], () => { + const result = runCli([]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Usage: glimpse \[options\]/); + assert.match(result.stdout, /snapshot /); + assert.equal(result.stderr, ""); +}); + +test("help flag prints help", ["help", "cli"], () => { + const result = runCli(["--help"]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Usage: glimpse \[options\]/); + assert.match(result.stdout, /--wait-js=/); + assert.equal(result.stderr, ""); +}); + +test("snapshot returns page metadata and content", ["snapshot"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml('Hello

Main

X'), + ]); + + assert.equal(output.ok, true); + assert.equal(output.title, "Hello"); + assert.equal(typeof output.elapsedMs, "number"); + assert.deepEqual(output.result.headings, [{ level: 1, text: "Main" }]); + assert.deepEqual(output.result.links, [{ href: "/x", text: "X" }]); + assert.equal(output.result.buttons[0].text, "Go"); + assert.match(output.result.text, /Main/); +}); + +test("snapshot extracts aria headings", ["snapshot"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml('Hello
ARIA
'), + ]); + + assert.equal(output.ok, true); + assert.deepEqual(output.result.headings, [{ level: 2, text: "ARIA" }]); +}); + +test("snapshot runs top-level javascript before extraction", ["snapshot", "js"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml("Hello

Old

"), + "--js=document.querySelector('h1').textContent = 'New'", + ]); + + assert.equal(output.ok, true); + assert.deepEqual(output.result.headings, [{ level: 1, text: "New" }]); + assert.equal(output.result.text, "New"); +}); + +test("exec returns javascript result", ["exec", "js"], () => { + const result = runCli([ + "exec", + dataHtml("Hello"), + "--js=return document.title", + ]); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stdout.trim(), "Hello"); +}); + +test("screenshot returns standard success envelope and writes file", ["screenshot"], () => { + const outputPath = join(tempDir, "page.png"); + const output = expectSuccess([ + "screenshot", + dataHtml("Hello"), + `--output=${outputPath}`, + ]); + + assert.equal(output.ok, true); + assert.equal(output.result.path, outputPath); + assert.equal(typeof output.elapsedMs, "number"); + assert.equal(existsSync(outputPath), true); +}); + +test("search validates kagi token in provider", ["search", "errors"], () => { + const env = { ...process.env }; + delete env.KAGI_TOKEN; + const result = runCli(["search", "example query"], { env }); + const output = parseJson(result.stderr); + + assert.notEqual(result.status, 0, result.stdout || result.stderr); + assert.equal(output.ok, false); + assert.equal(output.error.code, "KAGI_TOKEN_REQUIRED"); + assert.match(output.error.message, /Kagi search requires/); + assert.equal(typeof output.elapsedMs, "number"); +}); + +test("unknown command returns structured error", ["errors", "cli"], () => { + const output = expectFailure(["nope", dataHtml("Hello")]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "UNKNOWN_COMMAND"); + assert.match(output.error.message, /Unknown command: nope/); + assert.equal(typeof output.elapsedMs, "number"); +}); + +test("invalid timeout returns invalid option before browser startup", ["errors", "timeout"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + "--timeout=abc", + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "INVALID_OPTION"); + assert.match(output.error.message, /--timeout must be a positive integer/); + assert.equal(typeof output.elapsedMs, "number"); +}); + +test("invalid wait-until returns invalid option", ["errors", "wait"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + "--wait-until=loaded", + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "INVALID_OPTION"); + assert.match(output.error.message, /Unsupported --wait-until value: loaded/); +}); + +test("wait-js succeeds when condition is true", ["wait"], () => { + const output = expectSuccess([ + "snapshot", + dataHtml("Hello"), + '--wait-js=return document.title === "Hello"', + ]); + + assert.equal(output.ok, true); + assert.equal(output.title, "Hello"); +}); + +test("wait-js timeout returns wait timeout", ["wait", "errors"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + "--wait-js=return false", + "--timeout=1", + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "WAIT_TIMEOUT"); + assert.match(output.error.message, /waiting for --wait-js/); + assert.equal(typeof output.elapsedMs, "number"); + assert.match(output.url, /^data:text\/html,/); +}); + +test("wait-js exception returns script failed", ["wait", "errors", "js"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + '--wait-js=throw new Error("boom")', + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "SCRIPT_FAILED"); + assert.match(output.error.message, /--wait-js failed/); + assert.match(output.error.message, /boom/); +}); + +test("top-level javascript exception returns script failed", ["errors", "js"], () => { + const output = expectFailure([ + "snapshot", + dataHtml("Hello"), + '--js=throw new Error("boom")', + ]); + + assert.equal(output.ok, false); + assert.equal(output.error.code, "SCRIPT_FAILED"); + assert.match(output.error.message, /Prelude script failed/); + assert.match(output.error.message, /boom/); +}); + +function listTests() { + const tags = [...new Set(tests.flatMap((entry) => entry.tags))].sort(); + console.log(`Tags: ${tags.join(", ")}`); + for (const entry of tests) { + console.log(`${entry.name} [${entry.tags.join(", ")}]`); + } +} + +async function main() { + if (shouldList) { + listTests(); + return; + } + + const selectedTests = tests.filter(matchesFilters); + if (selectedTests.length === 0) { + console.error(`No tests matched: ${filters.join(", ")}`); + process.exit(1); + } + + let failed = 0; + + // Run Tests + for (const { name, fn } of selectedTests) { + try { + await fn(); + console.log(`ok - ${name}`); + } catch (err) { + failed += 1; + console.error(`not ok - ${name}`); + console.error(err.stack || err.message); + } + } + + // Clean Temporary Files + rmSync(tempDir, { recursive: true, force: true }); + + if (failed > 0) { + console.error(`${failed} test(s) failed`); + process.exit(1); + } + + console.log(`${selectedTests.length} test(s) passed`); +} + +main().catch((err) => { + rmSync(tempDir, { recursive: true, force: true }); + console.error(err.stack || err.message); + process.exit(1); +});