mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
Merge pull request #48 from only-cli/test/cache-and-cli-dispatch
test: cover the day cache and cli dispatch directly
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, utimesSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const { cachedFile } = await import('../src/cache.js');
|
||||
|
||||
// The cache fetches through fetchPage, which honors HTTP_PROXY, so a local
|
||||
// proxy stands in for the network: it records every request and answers with
|
||||
// whatever body the test hands it. A public IP literal keeps the target guard
|
||||
// offline (no DNS), same as the fetch tests. Nothing leaves the machine.
|
||||
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy'];
|
||||
const URL_JSON = 'http://1.1.1.1/docs/all.json';
|
||||
const parseJSON = (text) => JSON.parse(text);
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
// Runs fn with a fresh OC_HOME and a proxy serving `body`. Returns what the
|
||||
// proxy saw so a test can prove the network was, or was not, touched.
|
||||
async function withCache(body, fn) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'oc-cache-'));
|
||||
const seen = [];
|
||||
const proxy = http.createServer((req, res) => {
|
||||
seen.push(req.url);
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(body);
|
||||
});
|
||||
const port = await listen(proxy);
|
||||
const prevHome = process.env.OC_HOME;
|
||||
const prev = Object.fromEntries(PROXY_ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
for (const k of PROXY_ENV_KEYS) delete process.env[k];
|
||||
process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
|
||||
process.env.OC_HOME = home;
|
||||
try {
|
||||
await fn({ home, seen, file: join(home, 'sphinx', '1.1.1.1.json') });
|
||||
} finally {
|
||||
proxy.close();
|
||||
for (const k of PROXY_ENV_KEYS) {
|
||||
if (prev[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = prev[k];
|
||||
}
|
||||
if (prevHome === undefined) delete process.env.OC_HOME;
|
||||
else process.env.OC_HOME = prevHome;
|
||||
}
|
||||
}
|
||||
|
||||
test('a miss fetches, parses, and writes the file under host and extension', () => withCache('{"n":1}', async ({ seen, file }) => {
|
||||
const { data, via } = await cachedFile('sphinx', URL_JSON, parseJSON);
|
||||
assert.deepEqual(data, { n: 1 });
|
||||
assert.equal(via, 'network');
|
||||
assert.deepEqual(seen, [URL_JSON]);
|
||||
// One directory per backend, one file per host, the URL's own extension.
|
||||
assert.equal(readFileSync(file, 'utf8'), '{"n":1}');
|
||||
}));
|
||||
|
||||
test('a fresh file is served from disk and the network is never asked', () => withCache('{"n":"from network"}', async ({ home, seen, file }) => {
|
||||
mkdirSync(join(home, 'sphinx'), { recursive: true });
|
||||
writeFileSync(file, '{"n":"from disk"}');
|
||||
const { data, via } = await cachedFile('sphinx', URL_JSON, parseJSON);
|
||||
assert.deepEqual(data, { n: 'from disk' });
|
||||
assert.equal(via, 'cache');
|
||||
assert.equal(seen.length, 0);
|
||||
}));
|
||||
|
||||
test('a file older than a day is refetched and replaced', () => withCache('{"n":"fresh"}', async ({ home, seen, file }) => {
|
||||
mkdirSync(join(home, 'sphinx'), { recursive: true });
|
||||
writeFileSync(file, '{"n":"stale"}');
|
||||
const dayAgo = (Date.now() - 25 * 60 * 60 * 1000) / 1000;
|
||||
utimesSync(file, dayAgo, dayAgo);
|
||||
const { data, via } = await cachedFile('sphinx', URL_JSON, parseJSON);
|
||||
assert.deepEqual(data, { n: 'fresh' });
|
||||
assert.equal(via, 'network');
|
||||
assert.deepEqual(seen, [URL_JSON]);
|
||||
assert.equal(readFileSync(file, 'utf8'), '{"n":"fresh"}');
|
||||
}));
|
||||
|
||||
test('a body the parser rejects is not written, so a block page cannot poison the cache', () => withCache('<html>please verify you are human</html>', async ({ seen, file }) => {
|
||||
await assert.rejects(() => cachedFile('sphinx', URL_JSON, parseJSON), SyntaxError);
|
||||
assert.deepEqual(seen, [URL_JSON]);
|
||||
assert.ok(!existsSync(file), 'the unparseable body was written to the cache');
|
||||
}));
|
||||
|
||||
test('a stale copy survives a refetch whose body the parser rejects', () => withCache('not the index', async ({ home, file }) => {
|
||||
// The disk copy is too old to serve, but it is also the only good copy, and
|
||||
// the file is parsed before it is written, so the bad fetch leaves it alone.
|
||||
mkdirSync(join(home, 'sphinx'), { recursive: true });
|
||||
writeFileSync(file, '{"n":"stale but real"}');
|
||||
const dayAgo = (Date.now() - 25 * 60 * 60 * 1000) / 1000;
|
||||
utimesSync(file, dayAgo, dayAgo);
|
||||
await assert.rejects(() => cachedFile('sphinx', URL_JSON, parseJSON), SyntaxError);
|
||||
assert.equal(readFileSync(file, 'utf8'), '{"n":"stale but real"}');
|
||||
}));
|
||||
|
||||
test('a cache directory that cannot be created costs only the refetch', () => withCache('{"n":2}', async ({ home, seen }) => {
|
||||
// A regular file where the backend directory should be makes mkdir fail.
|
||||
// The same policy as session state: the answer still comes back, and the
|
||||
// next call pays for the network again rather than failing.
|
||||
writeFileSync(join(home, 'sphinx'), 'in the way');
|
||||
let result = await cachedFile('sphinx', URL_JSON, parseJSON);
|
||||
assert.deepEqual(result.data, { n: 2 });
|
||||
assert.equal(result.via, 'network');
|
||||
result = await cachedFile('sphinx', URL_JSON, parseJSON);
|
||||
assert.equal(result.via, 'network');
|
||||
assert.deepEqual(seen, [URL_JSON, URL_JSON]);
|
||||
}));
|
||||
|
||||
test('a URL with no extension caches under the bare host, and kinds do not share files', () => withCache('{"n":3}', async ({ home }) => {
|
||||
await cachedFile('nodedoc', 'http://1.1.1.1/api/all', parseJSON);
|
||||
assert.ok(existsSync(join(home, 'nodedoc', '1.1.1.1')));
|
||||
assert.ok(!existsSync(join(home, 'sphinx')), 'a nodedoc fetch created the sphinx directory');
|
||||
}));
|
||||
@@ -0,0 +1,222 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
// Dispatch tests: the first word of argv reaches the right handler with the
|
||||
// right arguments, and every wrong first word fails in one line that names the
|
||||
// way out. Each case spawns the real binary against a throwaway OC_HOME, and
|
||||
// none of them fetches: the page under test is seeded straight into a session
|
||||
// file, so 'read', 'next', 'find', and 'do' on text all have something to
|
||||
// answer with. Auth commands have their own file (cli-auth.test.js).
|
||||
const OC_HOME = mkdtempSync(join(tmpdir(), 'oc-cli-'));
|
||||
process.env.OC_HOME = OC_HOME;
|
||||
|
||||
const { distill } = await import('../src/distill.js');
|
||||
const { render } = await import('../src/render.js');
|
||||
const { saveSession, sessionFromPage } = await import('../src/session.js');
|
||||
|
||||
const bin = new URL('../src/cli.js', import.meta.url).pathname;
|
||||
const newsHtml = readFileSync(new URL('./pages/news.html', import.meta.url), 'utf8');
|
||||
|
||||
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy'];
|
||||
|
||||
function oc(args, envExtra = {}) {
|
||||
const env = { ...process.env, OC_HOME, ...envExtra };
|
||||
for (const k of PROXY_ENV_KEYS) if (!(k in envExtra)) delete env[k];
|
||||
return spawnSync(process.execPath, [bin, ...args], { encoding: 'utf8', env });
|
||||
}
|
||||
|
||||
// Seeds a session the way 'oc open' would have, and hands back the page so a
|
||||
// test can pick a number that means what it needs.
|
||||
function seed(name = 'default') {
|
||||
const page = distill(newsHtml, 'https://example.test/news');
|
||||
const { stats } = render(page, { budget: 500 });
|
||||
saveSession(name, sessionFromPage(page, null, { cursor: stats.next }));
|
||||
return page;
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
test('no command, --help, and -h all print the usage and exit 0', () => {
|
||||
for (const args of [[], ['--help'], ['-h']]) {
|
||||
const r = oc(args);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /^only-cli: the web as a compact terminal/);
|
||||
assert.match(r.stdout, /usage: oc <command> \[args\] \[flags\]/);
|
||||
}
|
||||
});
|
||||
|
||||
test('the usage names every dispatchable command exactly once', () => {
|
||||
// The help text and the dispatch table live a hundred lines apart. A command
|
||||
// that dispatches but is not in the help is undiscoverable; one in the help
|
||||
// that does not dispatch is a wasted turn.
|
||||
const { stdout } = oc(['--help']);
|
||||
for (const command of ['open', 'find', 'next', 'read', 'raw', 'do', 'fill', 'submit', 'back', 'login', 'logout', 'session', 'sites']) {
|
||||
const listed = stdout.split('\n').filter((line) => new RegExp(`^ ${command}( |$)`).test(line));
|
||||
assert.equal(listed.length, 1, `'${command}' should be listed once in --help, found ${listed.length}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unknown first word fails in one line that points at --help', () => {
|
||||
const r = oc(['frobnicate']);
|
||||
assert.equal(r.status, 1);
|
||||
assert.equal(r.stdout, '');
|
||||
assert.equal(r.stderr.trim(), "oc: unknown command 'frobnicate', run oc --help");
|
||||
});
|
||||
|
||||
test('a site name is tried as a shortcut before it is called unknown', () => {
|
||||
// The shortcut resolver owns the error here, so a wrong verb reports the
|
||||
// site's verbs, not 'unknown command'.
|
||||
const r = oc(['hn', 'frobnicate']);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /^oc: 'frobnicate' is not a news\.ycombinator\.com shortcut, try: /);
|
||||
assert.doesNotMatch(r.stderr, /unknown command/);
|
||||
});
|
||||
|
||||
test('--budget must be a positive number, checked before any command runs', () => {
|
||||
// The negative case uses the '=' form: as a separate token, parseArgs reads
|
||||
// '-5' as a flag and refuses it itself before oc sees a value.
|
||||
for (const flag of [['--budget', 'abc'], ['--budget', '0'], ['--budget=-5']]) {
|
||||
const r = oc(['sites', ...flag]);
|
||||
assert.equal(r.status, 1, `${flag.join(' ')} should fail`);
|
||||
assert.equal(r.stderr.trim(), 'oc: --budget must be a positive number');
|
||||
assert.equal(r.stdout, '', `${flag.join(' ')} still ran the command`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a session name that is a path is refused before anything is read or written', () => {
|
||||
for (const bad of ['../etc', 'a/b', '.', '..']) {
|
||||
const r = oc(['next', '--session', bad]);
|
||||
assert.equal(r.status, 1, `--session ${bad} should fail`);
|
||||
assert.match(r.stderr, /^oc: invalid session name/);
|
||||
}
|
||||
});
|
||||
|
||||
test('read, next, find, and do with nothing open say to run open first', () => {
|
||||
for (const args of [['read', '1'], ['next'], ['find', 'anything'], ['do', '1']]) {
|
||||
const r = oc([...args, '--session', 'never-opened']);
|
||||
assert.equal(r.status, 1, args.join(' '));
|
||||
assert.equal(r.stderr.trim(), "oc: nothing open in this session yet, run 'oc open <url>' first", args.join(' '));
|
||||
}
|
||||
});
|
||||
|
||||
test('open and raw with no URL and nothing open print a usage line', () => {
|
||||
for (const command of ['open', 'raw']) {
|
||||
const r = oc([command, '--session', 'never-opened']);
|
||||
assert.equal(r.status, 1);
|
||||
assert.equal(r.stderr.trim(), `oc: usage: oc ${command} <url>`);
|
||||
}
|
||||
});
|
||||
|
||||
test('read <n> prints the region at n from the saved page', () => {
|
||||
const page = seed('reading');
|
||||
const block = page.blocks.find((b) => b.n != null && b.type === 'heading');
|
||||
const r = oc(['read', String(block.n), '--session', 'reading']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.ok(r.stdout.includes(block.text), `read ${block.n} should print the block text:\n${r.stdout}`);
|
||||
});
|
||||
|
||||
test('read without a valid number fails with usage rather than a stack trace', () => {
|
||||
seed('reading');
|
||||
for (const args of [['read'], ['read', 'abc'], ['read', '0']]) {
|
||||
const r = oc([...args, '--session', 'reading']);
|
||||
assert.equal(r.status, 1, args.join(' '));
|
||||
assert.match(r.stderr, /^oc: usage: oc read <n>/);
|
||||
assert.doesNotMatch(r.stderr, /\n\s+at /, 'stack trace leaked to stderr');
|
||||
}
|
||||
});
|
||||
|
||||
test('next continues the saved page and reports the end when nothing is left', () => {
|
||||
seed('paging');
|
||||
// The news fixture fits in one render, so the saved cursor is already null
|
||||
// and next has nothing more to show. Either branch of next is one line an
|
||||
// agent can act on; this fixture exercises the end.
|
||||
const r = oc(['next', '--session', 'paging']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /^end of https:\/\/example\.test\/news, nothing left to render/);
|
||||
});
|
||||
|
||||
test('find joins the rest of argv into one query', () => {
|
||||
seed('finding');
|
||||
// Unquoted words reach find as separate argv entries; the footer offers
|
||||
// 'find <query>' without quotes, so this is how agents type it.
|
||||
const r = oc(['find', 'Show', 'HN', '--session', 'finding']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /^1 match for "Show HN"|^\d+ matches for "Show HN"/);
|
||||
});
|
||||
|
||||
test('find with no query fails with usage', () => {
|
||||
seed('finding');
|
||||
const r = oc(['find', '--session', 'finding']);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /^oc: usage: oc find <query>/);
|
||||
});
|
||||
|
||||
test('do on a text number reads it in place and never fetches', async () => {
|
||||
const page = seed('doing');
|
||||
const block = page.blocks.find((b) => b.n != null && b.type === 'text' && !b.href);
|
||||
assert.ok(block, 'the news fixture should have a numbered text block');
|
||||
// A proxy that records requests is the proof: if do decided to fetch, the
|
||||
// request would land here.
|
||||
const seen = [];
|
||||
const proxy = http.createServer((req, res) => {
|
||||
seen.push(req.url);
|
||||
res.writeHead(200, { 'content-type': 'text/html' });
|
||||
res.end('<html>should not be fetched</html>');
|
||||
});
|
||||
const port = await listen(proxy);
|
||||
try {
|
||||
const r = oc(['do', String(block.n), '--session', 'doing'], {
|
||||
HTTP_PROXY: `http://127.0.0.1:${port}`,
|
||||
HTTPS_PROXY: `http://127.0.0.1:${port}`,
|
||||
});
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.ok(r.stdout.includes(block.text), `do ${block.n} should print the text at [${block.n}]:\n${r.stdout}`);
|
||||
assert.equal(seen.length, 0, 'do on text sent a request');
|
||||
} finally {
|
||||
proxy.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('do without a number, or with one the page does not have, fails in one line', () => {
|
||||
seed('doing');
|
||||
let r = oc(['do', '--session', 'doing']);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /^oc: usage: oc do <n>/);
|
||||
r = oc(['do', '9999', '--session', 'doing']);
|
||||
assert.equal(r.status, 1);
|
||||
assert.match(r.stderr, /^oc: no \[9999\] on https:\/\/example\.test\/news \(handles 1-\d+\), run 'oc open <url>' again/);
|
||||
});
|
||||
|
||||
test('the planned commands fail with the same one-line message, naming themselves', () => {
|
||||
seed('stubs');
|
||||
for (const args of [['fill', '1', 'hello'], ['submit'], ['submit', '1'], ['back'], ['session', 'ls']]) {
|
||||
const r = oc([...args, '--session', 'stubs']);
|
||||
assert.equal(r.status, 1, args.join(' '));
|
||||
assert.equal(r.stdout, '', `${args[0]} printed to stdout`);
|
||||
assert.equal(r.stderr.trim(), `oc: 'oc ${args[0]}' is not available yet. Until then use 'oc open' and 'oc raw'.`);
|
||||
}
|
||||
});
|
||||
|
||||
test('sites lists the bundled shortcuts and exits 0', () => {
|
||||
const r = oc(['sites']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /news\.ycombinator\.com/);
|
||||
assert.match(r.stdout, /\bhn\b/);
|
||||
});
|
||||
|
||||
test('flags are accepted anywhere in argv, before or after the command', () => {
|
||||
seed('flags');
|
||||
const before = oc(['--session', 'flags', 'next']);
|
||||
const after = oc(['next', '--session', 'flags']);
|
||||
assert.equal(before.status, 0, before.stderr);
|
||||
assert.equal(before.stdout, after.stdout);
|
||||
});
|
||||
Reference in New Issue
Block a user