diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index bba558f..3835f9d 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -1,144 +1,113 @@ -import { execFileSync } from 'node:child_process'; -import { DEFAULT_POLL_INTERVAL_SECONDS } from '../commands/async-task'; -import { bin, runCli, TEST_API_KEY } from './helpers/run-cli'; +import { runCli, TEST_API_KEY } from './helpers/run-cli'; describe('linkup CLI', () => { - it('--version prints a semver string and exits 0', () => { - const output = execFileSync('node', [bin, '--version']).toString().trim(); - expect(output).toMatch(/^\d+\.\d+\.\d+/); - }); - - it('-v prints a semver string and exits 0', () => { - const output = execFileSync('node', [bin, '-v']).toString().trim(); - expect(output).toMatch(/^\d+\.\d+\.\d+/); - }); + it.each(['--version', '-v'])('%s prints a semver string and exits 0', flag => { + const { status, stdout } = runCli([flag]); - it('--help exits 0 and mentions linkup', () => { - const output = execFileSync('node', [bin, '--help']).toString(); - expect(output).toContain('linkup'); - expect(output).not.toContain('--api-key'); - expect(output).toContain('-j, --json'); - expect(output).toContain('Examples:'); + expect(status).toBe(0); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); }); - it('prints help when invoked with no subcommand', () => { - const output = execFileSync('node', [bin]).toString(); - expect(output).toContain('Usage:'); - expect(output).toContain('linkup'); - }); + it.each([ + { + args: [], + excludes: [], + includes: ['Usage:', 'linkup', 'logout'], + name: 'root command', + }, + { + args: ['--help'], + excludes: ['--api-key'], + includes: ['linkup', 'Examples:'], + name: 'root help', + }, + { + args: ['search', '--help'], + excludes: [], + includes: ['search', 'Examples:'], + name: 'search help', + }, + { + args: ['fetch', '--help'], + excludes: [], + includes: ['url', 'Examples:'], + name: 'fetch help', + }, + { + args: ['research', '--help'], + excludes: [], + includes: ['get', 'list', 'Examples:'], + name: 'research help', + }, + { + args: ['tasks', '--help'], + excludes: [], + includes: ['create', 'get', 'list'], + name: 'tasks help', + }, + ])('$name exits 0 and includes representative help text', ({ args, excludes, includes }) => { + const { status, stdout } = runCli(args); - it('search --help lists depth and output choices', () => { - const output = execFileSync('node', [bin, 'search', '--help']).toString(); - expect(output).toMatch(/"fast", "standard",\s+"deep"/); - expect(output).toContain('"sourced-answer"'); - expect(output).toContain('"search-results"'); - expect(output).toContain('"structured"'); - expect(output).toContain('--include-domains'); - expect(output).toContain('--exclude-domains'); - expect(output).toContain('--from-date'); - expect(output).toContain('--to-date'); - expect(output).toContain('--include-images'); - expect(output).toContain('--max-results'); - expect(output).toContain('--async'); - expect(output).toContain('-w, --wait'); - expect(output).toContain('Examples:'); + expect(status).toBe(0); + for (const text of includes) { + expect(stdout).toContain(text); + } + for (const text of excludes) { + expect(stdout).not.toContain(text); + } }); it.each([ { args: ['search', 'q', '-d', 'superdeep'], expectedError: /depth|superdeep|choice/i, - name: 'invalid --depth', + name: 'invalid search --depth', }, { args: ['search', 'q', '-o', 'garbage'], expectedError: /output|garbage|choice/i, - name: 'invalid --output', + name: 'invalid search --output', }, { args: ['search', 'q', '-o', 'sourcedAnswer'], expectedError: /sourcedAnswer|choice/i, - name: 'old camelCase --output', + name: 'old camelCase search --output', }, { args: ['search', 'q', '--from-date', 'not-a-date'], expectedError: /from-date|valid date/i, - name: 'invalid --from-date', + name: 'invalid search --from-date', }, { args: ['search', 'q', '--max-results', '0'], expectedError: /max-results|positive integer/i, - name: 'invalid --max-results', + name: 'invalid search --max-results', }, - ])('rejects $name', ({ args, expectedError }) => { - const { status, stderr } = runCli(args); - expect(status).not.toBe(0); - expect(stderr).toMatch(expectedError); - }); - - it('errors when --file points at a missing file', () => { - const { status, stderr } = runCli(['search', '-f', '/no/such/query.txt'], { - ...process.env, - LINKUP_API_KEY: TEST_API_KEY, - }); - expect(status).toBe(1); - expect(stderr).toContain('Could not read query file'); - }); - - it('prints usage when no query can be resolved', () => { - const { status, stderr } = runCli(['search'], { - ...process.env, - LINKUP_API_KEY: TEST_API_KEY, - }); - expect(status).toBe(1); - expect(stderr).toContain('No query provided'); - }); - - it('fetch --help documents the url argument', () => { - const output = execFileSync('node', [bin, 'fetch', '--help']).toString(); - expect(output).toContain('url'); - expect(output).toContain('Fetch'); - expect(output).toContain('--render-js'); - expect(output).toContain('--include-raw-html'); - expect(output).toContain('--extract-images'); - expect(output).toContain('--async'); - expect(output).toContain('-w, --wait'); - expect(output).toContain('Examples:'); - }); - - it.each([ { args: ['fetch'], expectedError: /url|argument|missing/i, - name: 'missing url argument', + name: 'missing fetch url argument', }, { args: ['fetch', 'not-a-url'], expectedError: /valid URL/i, - name: 'invalid URL value', + name: 'invalid fetch URL value', }, - ])('fetch rejects $name', ({ args, expectedError }) => { + { + args: ['research', 'q', '--mode', 'turbo'], + expectedError: /mode|turbo|choice/i, + name: 'invalid research --mode', + }, + { + args: ['research', 'get'], + expectedError: /id|argument|missing/i, + name: 'missing research get id argument', + }, + ])('rejects $name', ({ args, expectedError }) => { const { status, stderr } = runCli(args); - expect(status).not.toBe(0); - expect(stderr).toMatch(expectedError); - }); - - it('lists logout in root help', () => { - const output = execFileSync('node', [bin, '--help']).toString(); - expect(output).toContain('logout'); - }); - it('tasks exposes create, get, and list subcommands', () => { - const output = execFileSync('node', [bin, 'tasks', '--help']).toString(); - expect(output).toContain('create'); - expect(output).toContain('get'); - expect(output).toContain('list'); - }); - - it('tasks list rejects invalid status filters', () => { - const { status, stderr } = runCli(['tasks', 'list', '--status', 'queued']); expect(status).not.toBe(0); - expect(stderr).toMatch(/invalid status|queued/i); + expect(stderr).toMatch(expectedError); }); it('tasks create requires file or stdin input', () => { @@ -149,61 +118,4 @@ describe('linkup CLI', () => { expect(status).toBe(1); expect(stderr).toMatch(/No tasks provided|Tasks JSON is empty/); }); - - it('research --help lists output, mode, reasoning, and wait options', () => { - const output = execFileSync('node', [bin, 'research', '--help']).toString(); - expect(output).toContain('"sourced-answer"'); - expect(output).toContain('"structured"'); - expect(output).toContain('-m, --mode'); - expect(output).toContain('--reasoning-depth'); - expect(output).toContain('default: "L"'); - expect(output).toContain('-w, --wait'); - expect(output).toContain('--poll-interval'); - expect(output).toContain(`default: ${DEFAULT_POLL_INTERVAL_SECONDS}`); - expect(output).toContain('--timeout'); - expect(output).toContain('default: 1200 (20 minutes)'); - expect(output).toContain('Examples:'); - expect(output).not.toContain('--reasoning-depth L'); - }); - - it('research --help lists the file query source', () => { - const output = execFileSync('node', [bin, 'research', '--help']).toString(); - expect(output).toContain('--file'); - }); - - it('research exposes get and list subcommands', () => { - const output = execFileSync('node', [bin, 'research', '--help']).toString(); - expect(output).toContain('get'); - expect(output).toContain('list'); - }); - - it('research requires a schema for structured output', () => { - const { status, stderr } = runCli(['research', 'q', '--output', 'structured'], { - ...process.env, - LINKUP_API_KEY: TEST_API_KEY, - }); - expect(status).toBe(1); - expect(stderr).toContain('--output structured requires --schema-file or --schema'); - }); - - it('research prints usage when no query is provided', () => { - const { status, stderr } = runCli(['research'], { - ...process.env, - LINKUP_API_KEY: TEST_API_KEY, - }); - expect(status).toBe(1); - expect(stderr).toContain('No query provided'); - }); - - it('research rejects invalid --mode', () => { - const { status, stderr } = runCli(['research', 'q', '--mode', 'turbo']); - expect(status).not.toBe(0); - expect(stderr).toMatch(/mode|turbo|choice/i); - }); - - it('research get requires an id argument', () => { - const { status, stderr } = runCli(['research', 'get']); - expect(status).not.toBe(0); - expect(stderr).toMatch(/id|argument|missing/i); - }); }); diff --git a/src/__tests__/query-input.test.ts b/src/__tests__/query-input.test.ts index 107c0f1..2274393 100644 --- a/src/__tests__/query-input.test.ts +++ b/src/__tests__/query-input.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { resolveQueryOrExit } from '../commands/query-input'; import type { QueryReaders } from '../input/query'; @@ -30,18 +33,15 @@ afterEach(() => { }); describe('resolveQueryOrExit', () => { - it('returns a query joined from positional args', async () => { - await expect( - resolveQueryOrExit({ args: ['hello', 'world'] }, usageLines, stubReaders, true), - ).resolves.toBe('hello world'); - }); - - it('reads from stdin when input is piped', async () => { - const readers: QueryReaders = { ...stubReaders, stdin: async () => ' piped query\n' }; + it('prints notices and returns the resolved query', async () => { + const dir = mkdtempSync(join(tmpdir(), 'linkup-query-input-test-')); + const filePath = join(dir, 'query.txt'); + writeFileSync(filePath, ' what is linkup? \n'); - await expect(resolveQueryOrExit({ args: [] }, usageLines, readers, false)).resolves.toBe( - 'piped query', - ); + await expect( + resolveQueryOrExit({ args: [], file: filePath }, usageLines, stubReaders, true), + ).resolves.toBe('what is linkup?'); + expect(errorSpy).toHaveBeenCalledWith(`Read query from ${filePath}`); }); it('exits with usage (code 1) when no query resolves', async () => { @@ -51,7 +51,7 @@ describe('resolveQueryOrExit', () => { expect(errorSpy).toHaveBeenCalledWith('Error: No query provided'); }); - it('exits with code 1 when multiple query sources are provided', async () => { + it('exits with a formatted error when query resolution fails', async () => { await expect( resolveQueryOrExit({ args: ['typed'], file: '/ignored' }, usageLines, stubReaders, true), ).rejects.toMatchObject({ code: 1 }); diff --git a/src/commands/async-task.ts b/src/commands/async-task.ts index ed43407..21a5f98 100644 --- a/src/commands/async-task.ts +++ b/src/commands/async-task.ts @@ -6,7 +6,7 @@ import { startSpinner } from '../output/spinner'; import { formatTask, formatTasksSubmitted } from '../output/tasks'; import { parsePositiveInt } from './option-parsers'; -export const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const DEFAULT_POLL_INTERVAL_SECONDS = 5; const DEFAULT_TIMEOUT_SECONDS = 20 * 60; export type PollTaskStatus = 'completed' | 'failed' | 'timeout';