diff --git a/src/__tests__/parse-args.test.ts b/src/__tests__/parse-args.test.ts new file mode 100644 index 0000000..d94c7b7 --- /dev/null +++ b/src/__tests__/parse-args.test.ts @@ -0,0 +1,102 @@ +// src/__tests__/parse-args.test.ts +// +// Regression tests for parseArgs argument handling. +// +// Root cause of the "AI search returns empty output" bug: the `--ai` flag's +// case in parseArgs was missing its `i += 1` increment. Because parseArgs +// advances the index manually per-flag, a missing increment left `i` pinned on +// `--ai` forever, spinning `while (i < args.length)` as an infinite loop. The +// CLI hung with zero output on every `search ... --ai` invocation (broken since +// v0.25.0, 2026-04-07). The pre-existing ai-search tests only exercised +// askAISearch() directly, never the CLI's argument parser, so they never caught +// it. +// +// These tests run parseArgs under a hard timeout so a re-introduced infinite +// loop fails loudly instead of hanging the suite. + +import { describe, it, expect } from "bun:test"; +import { parseArgs } from "../cli"; + +/** Run parseArgs, failing if it does not return promptly (i.e. it infinite-loops). */ +function parseArgsBounded(args: string[]) { + // parseArgs is synchronous; a missing increment makes it spin forever. Guard + // by capping iterations via a wall-clock check inside a Worker-free approach: + // we simply call it — Bun's test timeout (below) is the real backstop, but we + // also assert it returns an object so a throw surfaces clearly. + return parseArgs(args); +} + +describe("parseArgs boolean flags", () => { + it("parses --ai without hanging (regression: missing index increment)", () => { + const opts = parseArgsBounded([ + "search", + "pebble pre-order confirmation", + "--account", + "eddyhu@gmail.com", + "--ai", + "--json", + ]); + expect(opts.command).toBe("search"); + expect(opts.query).toBe("pebble pre-order confirmation"); + expect(opts.account).toBe("eddyhu@gmail.com"); + expect(opts.ai).toBe(true); + expect(opts.json).toBe(true); + }, 5000); + + it("parses --ai in --key=value form", () => { + const opts = parseArgs(["search", "q", "--ai=true"]); + // `--ai=true` still sets the flag on; value is ignored for a boolean flag. + expect(opts.ai).toBe(true); + expect(opts.query).toBe("q"); + }); + + it("parses --ai regardless of position relative to other flags", () => { + const a = parseArgs(["search", "q", "--ai", "--json"]); + const b = parseArgs(["search", "q", "--json", "--ai"]); + expect(a.ai).toBe(true); + expect(a.json).toBe(true); + expect(b.ai).toBe(true); + expect(b.json).toBe(true); + }); + + it("handles --ai with no trailing args", () => { + const opts = parseArgs(["search", "q", "--ai"]); + expect(opts.ai).toBe(true); + }); + + it("still parses a value flag immediately after --ai", () => { + const opts = parseArgs(["search", "q", "--ai", "--limit", "5"]); + expect(opts.ai).toBe(true); + expect(opts.limit).toBe(5); + }); +}); + +describe("parseArgs forward-progress guarantee", () => { + // Every recognized boolean flag must advance the parser. This exhaustively + // guards the whole family so the next flag added without an increment is + // caught immediately rather than shipping as a silent hang. + const booleanFlags = [ + "--ai", + "--json", + "--focused", + "--unread", + "--needs-reply", + "--with-body", + "--latest-only", + "--native", + "--no-signature", + "--as-attachment", + "--force", + "--check", + "--fix", + ]; + + for (const flag of booleanFlags) { + it(`advances past ${flag} without hanging`, () => { + // If parseArgs failed to advance it would spin forever; the 5s per-test + // timeout converts that into a clear failure. + const opts = parseArgs(["inbox", flag]); + expect(opts.command).toBe("inbox"); + }, 5000); + } +}); diff --git a/src/cli.ts b/src/cli.ts index 5537360..8bec11d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -657,7 +657,7 @@ interface CliOptions { fix: boolean; // doctor: relaunch the app to restore a dead CDP debug port } -function parseArgs(args: string[]): CliOptions { +export function parseArgs(args: string[]): CliOptions { const options: CliOptions = { command: "", subcommand: "", @@ -731,6 +731,11 @@ function parseArgs(args: string[]): CliOptions { let i = 0; while (i < args.length) { const arg = args[i]!; + // Backstop against a flag case that forgets to advance `i`: capture the + // index before dispatch and guarantee forward progress after. A missing + // increment (as `--ai` once had) would otherwise spin this loop forever, + // hanging the CLI with no output. See parse-args.test.ts. + const iBefore = i; if (arg.startsWith("--")) { // Support both --key value and --key=value formats @@ -893,6 +898,7 @@ function parseArgs(args: string[]): CliOptions { break; case "ai": options.ai = true; + i += 1; break; case "include-done": options.includeDone = true; @@ -1111,6 +1117,16 @@ function parseArgs(args: string[]): CliOptions { error(`Unexpected argument: ${arg}`); process.exit(1); } + + // Forward-progress guarantee: no matter which branch handled this arg, `i` + // must have advanced. If a flag case forgot to increment, this prevents an + // infinite loop (silent hang) and surfaces the offending token instead. + if (i === iBefore) { + throw new Error( + `Argument parser failed to advance at "${arg}" (index ${i}). ` + + `This is a bug in parseArgs — the flag's case is missing an index increment.` + ); + } } return options;