initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
result
|
||||
70
AGENTS.md
Normal file
70
AGENTS.md
Normal file
@@ -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 <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.
|
||||
|
||||
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=<server>` support for connecting to an existing WebDriver server.
|
||||
- `--timeout=<ms>` is a top-level option for command waits and defaults to `10000`.
|
||||
- `--wait-js=<code>` and `--wait-until=<state>` are top-level wait options available to every `glimpse` subcommand.
|
||||
- `--js=<code>` and `--script=<file>` are top-level options available to every `glimpse` subcommand and run before command-specific behavior.
|
||||
- Prefer structured JSON output for objects/arrays.
|
||||
- CLI errors should be structured JSON on stderr with `ok: false`, stable `error.code`, `error.message`, and `elapsedMs`.
|
||||
|
||||
Current `glimpse` subcommands:
|
||||
|
||||
- `snapshot <url>` - return an agent-friendly page snapshot as JSON
|
||||
- `exec <url> --js=<code>` or `--script=<file>` - execute JavaScript and return the result
|
||||
- `screenshot <url> --output=<file>` - save a PNG screenshot
|
||||
- `reader <url>` - open Firefox Reader View and output readable content as Markdown
|
||||
- `search <query>` - search with a supported provider and output JSON results
|
||||
|
||||
## 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=<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.
|
||||
268
README.md
Normal file
268
README.md
Normal file
@@ -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 <command> [options]
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
- `--no-headless` - show Firefox instead of running headless
|
||||
- `--url=<server>` - connect to an existing WebDriver server
|
||||
- `--timeout=<ms>` - maximum wait time in milliseconds for command waits (default: `10000`)
|
||||
- `--wait-js=<code>` - poll JavaScript until it returns a truthy value before command-specific behavior
|
||||
- `--wait-until=<state>` - wait for document readiness: `none`, `interactive`, or `complete` (default: `none`)
|
||||
- `--js=<code>` - execute inline JavaScript after loading the page and before command-specific behavior
|
||||
- `--script=<file>` - 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=<format>` - output `markdown`, `html`, `text`, or `json` (default: `markdown`)
|
||||
- `--output=<file>` - 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=<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=<provider>` - search provider: `kagi` (default: `kagi`)
|
||||
- `--token=<token>` - Kagi token (default: `KAGI_TOKEN`)
|
||||
- `--no-headless` - show Firefox instead of running headless
|
||||
- `--url=<server>` - connect to an existing WebDriver server
|
||||
- `--timeout=<ms>` - 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,<title>Hello</title><h1>Hello</h1>'
|
||||
node index.js exec 'data:text/html,<title>Hello</title>' --js='return document.title'
|
||||
node index.js screenshot 'data:text/html,<title>Hello</title>' --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
|
||||
50
driver.js
Normal file
50
driver.js
Normal file
@@ -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<import("selenium-webdriver").WebDriver>}
|
||||
*/
|
||||
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();
|
||||
}
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -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
|
||||
}
|
||||
72
flake.nix
Normal file
72
flake.nix
Normal file
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
555
index.js
Executable file
555
index.js
Executable file
@@ -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 <command> <url> [options]
|
||||
|
||||
Commands:
|
||||
snapshot <url> [options] Return an agent-friendly page snapshot as JSON
|
||||
exec <url> [options] Execute JavaScript on a page and return the result
|
||||
screenshot <url> [options] Save a PNG screenshot of a page
|
||||
reader <url> [options] Extract Firefox Reader View content as Markdown
|
||||
search <query> [options] Search using a supported provider and return JSON results
|
||||
|
||||
Common Options:
|
||||
--help Show this help
|
||||
--no-headless Show Firefox instead of running headless
|
||||
--url=<server> Connect to an existing WebDriver server
|
||||
--timeout=<ms> Maximum wait time in milliseconds (default: 10000)
|
||||
--wait-js=<code> Poll JS until it returns a truthy value
|
||||
--wait-until=<state> Wait for readiness: none, interactive, complete (default: none)
|
||||
--js=<code> Execute inline JS before command logic
|
||||
--script=<file> Execute JS from a file before command logic
|
||||
|
||||
Exec Options:
|
||||
--js=<code> Return the top-level JS result
|
||||
--script=<file> Return the top-level script result
|
||||
|
||||
Screenshot Options:
|
||||
--output=<file> Output PNG path (default: screenshot.png)
|
||||
|
||||
Reader Options:
|
||||
--format=<format> Output format: markdown, html, text, json (default: markdown)
|
||||
--output=<file> Write output to a file
|
||||
|
||||
Search Options:
|
||||
--provider=<provider> Search provider: kagi (default: kagi)
|
||||
--token=<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 <command> <url> [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);
|
||||
});
|
||||
65
kagi.js
Normal file
65
kagi.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
4
oxlintrc.json
Normal file
4
oxlintrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": ["node_modules"]
|
||||
}
|
||||
548
package-lock.json
generated
Normal file
548
package-lock.json
generated
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
package.json
Normal file
32
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
293
test/smoke.js
Executable file
293
test/smoke.js
Executable file
@@ -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 <command> <url> \[options\]/);
|
||||
assert.match(result.stdout, /snapshot <url>/);
|
||||
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 <command> <url> \[options\]/);
|
||||
assert.match(result.stdout, /--wait-js=<code>/);
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
test("snapshot returns page metadata and content", ["snapshot"], () => {
|
||||
const output = expectSuccess([
|
||||
"snapshot",
|
||||
dataHtml('<title>Hello</title><h1>Main</h1><a href="/x">X</a><button>Go</button>'),
|
||||
]);
|
||||
|
||||
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('<title>Hello</title><div role="heading" aria-level="2">ARIA</div>'),
|
||||
]);
|
||||
|
||||
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("<title>Hello</title><h1>Old</h1>"),
|
||||
"--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("<title>Hello</title>"),
|
||||
"--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("<title>Hello</title>"),
|
||||
`--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("<title>Hello</title>")]);
|
||||
|
||||
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("<title>Hello</title>"),
|
||||
"--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("<title>Hello</title>"),
|
||||
"--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("<title>Hello</title>"),
|
||||
'--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("<title>Hello</title>"),
|
||||
"--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("<title>Hello</title>"),
|
||||
'--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("<title>Hello</title>"),
|
||||
'--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);
|
||||
});
|
||||
Reference in New Issue
Block a user