Accept seconds (including decimals like 0.5) instead of milliseconds for the --timeout flag. Converts to ms internally. Default is now 10 (seconds) instead of 10000. Error messages display seconds. Update AGENTS.md, tests, and skill docs to match.
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 number/);
|
|
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=0.001",
|
|
]);
|
|
|
|
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);
|
|
});
|