diff --git a/src/core/pager.ts b/src/core/pager.ts index 31737360..67b574c1 100644 --- a/src/core/pager.ts +++ b/src/core/pager.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { once } from "node:events"; /** Remove terminal escape sequences before deciding whether stdin looks like a patch. */ @@ -32,22 +32,35 @@ export function resolveTextPagerCommand(env: NodeJS.ProcessEnv = process.env) { return candidate; } +/** Minimal dependencies for testing pager behavior without spawning a real subprocess. */ +export interface PlainTextPagerDeps { + stdout: Pick; + spawnImpl: (command: string, options: SpawnOptions) => ChildProcess; +} + /** Stream plain text through a normal pager, or write directly when not attached to a terminal. */ -export async function pagePlainText(text: string, env: NodeJS.ProcessEnv = process.env) { - if (!process.stdout.isTTY) { - process.stdout.write(text); +export async function pagePlainText( + text: string, + env: NodeJS.ProcessEnv = process.env, + deps: PlainTextPagerDeps = { + stdout: process.stdout, + spawnImpl: spawn, + }, +) { + if (!deps.stdout.isTTY) { + deps.stdout.write(text); return; } const pagerCommand = resolveTextPagerCommand(env); - const pager = spawn(pagerCommand, { + const pager = deps.spawnImpl(pagerCommand, { shell: true, stdio: ["pipe", "inherit", "inherit"], env, }); pager.stdin?.end(text); - const [, code] = await once(pager, "close"); + const [code] = await once(pager, "close"); if (typeof code === "number" && code !== 0) { throw new Error(`Pager command failed: ${pagerCommand}`); diff --git a/test/pager.test.ts b/test/pager.test.ts index ecad1b63..f36e89b7 100644 --- a/test/pager.test.ts +++ b/test/pager.test.ts @@ -1,5 +1,27 @@ import { describe, expect, test } from "bun:test"; -import { looksLikePatchInput } from "../src/core/pager"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { looksLikePatchInput, pagePlainText, resolveTextPagerCommand, type PlainTextPagerDeps } from "../src/core/pager"; + +function createPagerDeps(overrides: Partial = {}): PlainTextPagerDeps { + return { + stdout: { + isTTY: true, + write() { + return true; + }, + }, + spawnImpl() { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + queueMicrotask(() => { + pager.emit("close", 0); + }); + return pager as never; + }, + ...overrides, + }; +} describe("general pager detection", () => { test("detects git-style patch input even when ANSI-colored", () => { @@ -23,3 +45,68 @@ describe("general pager detection", () => { expect(looksLikePatchInput(branchOutput)).toBe(false); }); }); + +describe("plain text pager fallback", () => { + test("falls back to less when no pager is configured", () => { + expect(resolveTextPagerCommand({})).toBe("less -R"); + }); + + test("prefers HUNK_TEXT_PAGER and avoids recursive hunk launches", () => { + expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "bat --paging=always" })).toBe("bat --paging=always"); + expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe("less -R"); + expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe("less -R"); + }); + + test("writes directly to stdout when not attached to a terminal", async () => { + let written = ""; + let spawnCalled = false; + + await pagePlainText( + "plain text output", + {}, + createPagerDeps({ + stdout: { + isTTY: false, + write(chunk) { + written += String(chunk); + return true; + }, + }, + spawnImpl() { + spawnCalled = true; + throw new Error("spawn should not be called"); + }, + }), + ); + + expect(written).toBe("plain text output"); + expect(spawnCalled).toBe(false); + }); + + test("throws when the pager exits with a non-zero status", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + let written = ""; + pager.stdin.on("data", (chunk) => { + written += String(chunk); + }); + + const promise = pagePlainText( + "needs pager", + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl(command, options) { + expect(command).toBe("less -R"); + expect(options.shell).toBe(true); + queueMicrotask(() => { + pager.emit("close", 1); + }); + return pager as never; + }, + }), + ); + + await expect(promise).rejects.toThrow("Pager command failed: less -R"); + expect(written).toBe("needs pager"); + }); +});