/** * Tests for stdin abstraction (IOStreams) * * Tests the streams module and its integration with AgentRuntime: * - Creating and using IOStreams * - Piping content via stdin * - Detecting TTY vs pipe mode * - Combining stdin with file arguments */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; import { createRuntime, createTestStreams, stringToStream, createCaptureStream, readStream, readStdinFromStreams, isInteractive, createDefaultStreams, } from "./runtime"; import { clearConfigCache } from "./config"; import type { IOStreams } from "./types"; describe("stdin abstraction", () => { let tempDir: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "stdin-test-")); clearConfigCache(); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); describe("stringToStream", () => { it("creates a readable stream from a string", async () => { const stream = stringToStream("Hello, World!"); const content = await readStream(stream); expect(content).toBe("Hello, World!"); }); it("handles empty string", async () => { const stream = stringToStream(""); const content = await readStream(stream); expect(content).toBe(""); }); it("handles multiline content", async () => { const multiline = "line1\nline2\nline3"; const stream = stringToStream(multiline); const content = await readStream(stream); expect(content).toBe(multiline); }); it("handles unicode content", async () => { const unicode = "Hello \u{1F60A} World \u{1F680}"; const stream = stringToStream(unicode); const content = await readStream(stream); expect(content).toBe(unicode); }); }); describe("createCaptureStream", () => { it("captures written content", () => { const { stream, getOutput } = createCaptureStream(); stream.write("Hello "); stream.write("World!"); stream.end(); expect(getOutput()).toBe("Hello World!"); }); it("handles multiple writes", () => { const { stream, getOutput } = createCaptureStream(); stream.write("a"); stream.write("b"); stream.write("c"); stream.end(); expect(getOutput()).toBe("abc"); }); it("returns empty string when nothing written", () => { const { stream, getOutput } = createCaptureStream(); stream.end(); expect(getOutput()).toBe(""); }); }); describe("createTestStreams", () => { it("creates streams with simulated stdin content", async () => { const { streams, getStdout, getStderr } = createTestStreams("piped content"); expect(streams.stdin).not.toBeNull(); expect(streams.isTTY).toBe(false); const content = await readStdinFromStreams(streams); expect(content).toBe("piped content"); }); it("creates streams with null stdin for TTY mode", async () => { const { streams } = createTestStreams(null); expect(streams.stdin).toBeNull(); expect(streams.isTTY).toBe(true); const content = await readStdinFromStreams(streams); expect(content).toBe(""); }); it("captures stdout and stderr", () => { const { streams, getStdout, getStderr } = createTestStreams(); streams.stdout.write("stdout content"); streams.stderr.write("stderr content"); expect(getStdout()).toBe("stdout content"); expect(getStderr()).toBe("stderr content"); }); }); describe("readStdinFromStreams", () => { it("returns empty string when stdin is null (TTY mode)", async () => { const streams: IOStreams = { stdin: null, stdout: process.stdout, stderr: process.stderr, isTTY: true, }; const content = await readStdinFromStreams(streams); expect(content).toBe(""); }); it("reads content when stdin is a stream", async () => { const streams: IOStreams = { stdin: stringToStream("test content") as NodeJS.ReadableStream, stdout: process.stdout, stderr: process.stderr, isTTY: false, }; const content = await readStdinFromStreams(streams); expect(content).toBe("test content"); }); it("trims whitespace from stdin content", async () => { const streams: IOStreams = { stdin: stringToStream(" content with whitespace \n") as NodeJS.ReadableStream, stdout: process.stdout, stderr: process.stderr, isTTY: false, }; const content = await readStdinFromStreams(streams); expect(content).toBe("content with whitespace"); }); }); describe("isInteractive", () => { it("returns true for TTY streams", () => { const { streams } = createTestStreams(null); expect(isInteractive(streams)).toBe(true); }); it("returns false for piped streams", () => { const { streams } = createTestStreams("piped"); expect(isInteractive(streams)).toBe(false); }); }); describe("AgentRuntime with streams", () => { it("reads stdin from streams option", async () => { const filePath = join(tempDir, "stdin-test.claude.md"); await writeFile(filePath, `---\n---\nBody content`); const { streams } = createTestStreams("piped input data"); const runtime = createRuntime(); const result = await runtime.run(filePath, { streams, dryRun: true, }); expect(result.exitCode).toBe(0); // In dry run, the stdin content should be included in the output }); it("prefers stdinContent over streams.stdin", async () => { const filePath = join(tempDir, "stdin-prefer.claude.md"); await writeFile(filePath, `---\n---\nBody`); const { streams } = createTestStreams("from streams"); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context); // When stdinContent is explicitly provided, it takes precedence const finalBody = "from direct option"; expect(finalBody).toBe("from direct option"); }); it("handles TTY mode with null stdin", async () => { const filePath = join(tempDir, "tty-test.claude.md"); await writeFile(filePath, `---\n---\nBody only`); const { streams } = createTestStreams(null); // TTY mode const runtime = createRuntime(); const result = await runtime.run(filePath, { streams, dryRun: true, }); expect(result.exitCode).toBe(0); }); it("wraps stdin content in tags when present", async () => { const filePath = join(tempDir, "stdin-tags.claude.md"); await writeFile(filePath, `---\n---\nProcess this:`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context); // Simulate what execute does with stdin const stdinContent = "input data"; let finalBody = processed.body; if (stdinContent) { finalBody = `\n${stdinContent}\n\n\n${finalBody}`; } expect(finalBody).toContain(""); expect(finalBody).toContain("input data"); expect(finalBody).toContain(""); expect(finalBody).toContain("Process this:"); }); it("combines template vars with stdin", async () => { const filePath = join(tempDir, "combined.claude.md"); await writeFile(filePath, `--- args: - name --- Hello {{ name }}!`); const { streams } = createTestStreams("context from pipe"); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context, { passthroughArgs: ["World"], }); expect(processed.body).toBe("Hello World!"); expect(processed.templateVars).toEqual({ name: "World" }); }); }); describe("createDefaultStreams", () => { it("returns IOStreams with process streams", () => { const streams = createDefaultStreams(); expect(streams.stdout).toBe(process.stdout); expect(streams.stderr).toBe(process.stderr); // isTTY depends on the test environment expect(typeof streams.isTTY).toBe("boolean"); }); }); describe("Edge cases", () => { it("handles large stdin content", async () => { const largeContent = "x".repeat(100_000); const stream = stringToStream(largeContent); const content = await readStream(stream); expect(content.length).toBe(100_000); }); it("handles stdin with special characters", async () => { const special = "Tab:\t Newline:\n Carriage:\r Quote:\" Backslash:\\"; const stream = stringToStream(special); const content = await readStream(stream); expect(content).toBe(special); }); it("handles binary-like content", async () => { // Content with null bytes and high bytes const binaryLike = "start\x00middle\xFFend"; const stream = stringToStream(binaryLike); const content = await readStream(stream); expect(content.length).toBeGreaterThan(0); }); }); }); describe("Integration: runtime.run with streams", () => { let tempDir: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "stdin-integration-")); clearConfigCache(); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); it("full pipeline with piped stdin in dry run", async () => { const filePath = join(tempDir, "full.claude.md"); await writeFile(filePath, `--- model: test-model --- Analyze the input`); const { streams } = createTestStreams("data to analyze"); const runtime = createRuntime(); const result = await runtime.run(filePath, { streams, dryRun: true, }); expect(result.exitCode).toBe(0); expect(result.dryRun).toBe(true); }); it("empty stdin behaves like TTY mode", async () => { const filePath = join(tempDir, "empty.claude.md"); await writeFile(filePath, `---\n---\nNo stdin`); // Empty string stdin should be trimmed to empty const { streams } = createTestStreams(""); const runtime = createRuntime(); const result = await runtime.run(filePath, { streams, dryRun: true, }); expect(result.exitCode).toBe(0); }); it("stdin with pre hook output", async () => { const filePath = join(tempDir, "pre-stdin.claude.md"); await writeFile(filePath, `--- pre: echo "PRE_OUTPUT" --- Body here`); const { streams } = createTestStreams("stdin content"); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); expect(context.preHookOutput).toBe("PRE_OUTPUT\n"); // Both pre hook and stdin should be included const processed = await runtime.processTemplate(context); let finalBody = processed.body; if (context.preHookOutput) { finalBody = `${context.preHookOutput.trim()}\n\n${finalBody}`; } const stdinContent = "stdin content"; finalBody = `\n${stdinContent}\n\n\n${finalBody}`; expect(finalBody).toContain("PRE_OUTPUT"); expect(finalBody).toContain(""); expect(finalBody).toContain("stdin content"); expect(finalBody).toContain("Body here"); }); });