Remove the snapshot command and enhance reader to try Firefox Reader View first, falling back to raw Turndown conversion of document.body when Reader View fails or is skipped via --no-reader. - reader always returns markdown by default (--format=json for structured) - JSON output includes method: 'reader' | 'raw' to signal extraction path - --no-reader skips Reader View (stays on loaded page, preserving JS mutations) - Add @ts-nocheck to test/smoke.js and exclude test/ from tsconfig - Update all tests from snapshot to reader with --no-reader for data URIs - Update AGENTS.md and help text BREAKING CHANGE: snapshot subcommand removed; use reader instead.
389 lines
11 KiB
JavaScript
Executable File
389 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// @ts-nocheck
|
|
|
|
import {
|
|
mkdtempSync,
|
|
rmSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
writeFileSync,
|
|
} 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("../src/index.ts", 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, ["--import", "tsx", 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, options = {}) {
|
|
const result = runCli(args, options);
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
return parseJson(result.stdout);
|
|
}
|
|
|
|
function expectFailure(args, options = {}) {
|
|
const result = runCli(args, options);
|
|
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, /reader <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("reader extracts page content as markdown", ["reader"], () => {
|
|
const result = runCli([
|
|
"reader",
|
|
dataHtml(
|
|
'<title>Hello</title><h1>Main</h1><p>Some text</p><a href="https://example.com">Link</a>',
|
|
),
|
|
"--no-reader",
|
|
]);
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
const output = result.stdout.trim();
|
|
assert.match(output, /# Main/);
|
|
assert.match(output, /Some text/);
|
|
assert.match(output, /\[Link\]\(https:\/\/example\.com\/??\)/);
|
|
});
|
|
|
|
test("reader returns json format with method field", ["reader"], () => {
|
|
const output = expectSuccess([
|
|
"reader",
|
|
dataHtml("<title>Hello</title><h1>Main</h1><p>World</p>"),
|
|
"--no-reader",
|
|
"--format=json",
|
|
]);
|
|
|
|
assert.equal(output.title, "Hello");
|
|
assert.equal(output.method, "raw");
|
|
assert.equal(typeof output.markdown, "string");
|
|
assert.match(output.markdown, /# Main/);
|
|
assert.match(output.text, /Main/);
|
|
});
|
|
|
|
test(
|
|
"reader runs top-level javascript before extraction",
|
|
["reader", "js"],
|
|
() => {
|
|
const result = runCli([
|
|
"reader",
|
|
dataHtml("<title>Hello</title><h1>Old</h1>"),
|
|
"--no-reader",
|
|
"--js=document.querySelector('h1').textContent = 'New'",
|
|
]);
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /# 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", "--config=/nonexistent/path", "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.match(output.error.message, /config token/);
|
|
assert.equal(typeof output.elapsedMs, "number");
|
|
});
|
|
|
|
test(
|
|
"invalid config returns structured error before browser startup",
|
|
["config", "errors"],
|
|
() => {
|
|
const configPath = join(tempDir, "bad-config.json");
|
|
writeFileSync(configPath, "not json");
|
|
|
|
const output = expectFailure([
|
|
"reader",
|
|
dataHtml("<title>Hello</title>"),
|
|
"--no-reader",
|
|
`--config=${configPath}`,
|
|
]);
|
|
|
|
assert.equal(output.ok, false);
|
|
assert.equal(output.error.code, "CONFIG_READ_FAILED");
|
|
assert.match(output.error.message, /Failed to read config file/);
|
|
},
|
|
);
|
|
|
|
test(
|
|
"invalid config schema returns structured error",
|
|
["config", "errors"],
|
|
() => {
|
|
const configPath = join(tempDir, "bad-schema.json");
|
|
writeFileSync(configPath, JSON.stringify({ search: { provider: 42 } }));
|
|
|
|
const output = expectFailure([
|
|
"reader",
|
|
dataHtml("<title>Hello</title>"),
|
|
"--no-reader",
|
|
`--config=${configPath}`,
|
|
]);
|
|
|
|
assert.equal(output.ok, false);
|
|
assert.equal(output.error.code, "INVALID_CONFIG");
|
|
assert.match(output.error.message, /search\.provider must be a string/);
|
|
},
|
|
);
|
|
|
|
test("empty home config is accepted", ["config"], () => {
|
|
const configHome = join(tempDir, "config-home");
|
|
const configDir = join(configHome, "glimpse");
|
|
mkdirSync(configDir, { recursive: true });
|
|
writeFileSync(join(configDir, "config.json"), "{}");
|
|
|
|
const result = runCli(
|
|
["reader", dataHtml("<title>Hello</title><h1>Main</h1>"), "--no-reader"],
|
|
{ env: { ...process.env, XDG_CONFIG_HOME: configHome } },
|
|
);
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /# Main/);
|
|
});
|
|
|
|
test("unknown command returns structured error", ["errors", "cli"], () => {
|
|
const output = expectFailure(["nope", dataHtml("<title>Hello</title>"), "--no-reader"]);
|
|
|
|
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([
|
|
"reader",
|
|
dataHtml("<title>Hello</title>"),
|
|
"--no-reader",
|
|
"--timeout=abc",
|
|
]);
|
|
|
|
assert.equal(output.ok, false);
|
|
assert.equal(output.error.code, "INVALID_OPTION");
|
|
assert.match(output.error.message, /--timeout must be a positive integer/);
|
|
assert.equal(typeof output.elapsedMs, "number");
|
|
},
|
|
);
|
|
|
|
test("invalid wait-until returns invalid option", ["errors", "wait"], () => {
|
|
const output = expectFailure([
|
|
"reader",
|
|
dataHtml("<title>Hello</title>"),
|
|
"--no-reader",
|
|
"--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 result = runCli([
|
|
"reader",
|
|
dataHtml("<title>Hello</title><h1>Main</h1>"),
|
|
"--no-reader",
|
|
'--wait-js=return document.title === "Hello"',
|
|
]);
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /# Main/);
|
|
});
|
|
|
|
test("wait-js timeout returns wait timeout", ["wait", "errors"], () => {
|
|
const output = expectFailure([
|
|
"reader",
|
|
dataHtml("<title>Hello</title>"),
|
|
"--no-reader",
|
|
"--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([
|
|
"reader",
|
|
dataHtml("<title>Hello</title>"),
|
|
"--no-reader",
|
|
'--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([
|
|
"reader",
|
|
dataHtml("<title>Hello</title>"),
|
|
"--no-reader",
|
|
'--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);
|
|
});
|