/** * text-input.test.tsx — Tests for the TextInput Ink component. * * Verifies: * - Renders with initial value and placeholder * - Single-line mode: Enter does NOT insert newline (no-op for single-line) * - Multi-line mode: Enter inserts newline * - Ctrl+S triggers onSubmit callback * - Arrow keys navigate the cursor * - Home/End navigate within line * - Backspace/Delete remove characters * - Character insertion at cursor position * - Focus/blur behavior */ import { describe, test, expect, mock } from "bun:test"; import React from "react"; import { render, renderToString } from "ink"; import { TextInput } from "./text-input"; import { PassThrough } from "node:stream"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Create fake stdin/stdout streams for testing Ink render. */ function createTestStreams() { const stdout = new PassThrough() as unknown as NodeJS.WriteStream; (stdout as any).columns = 80; (stdout as any).rows = 24; const stdin = new PassThrough() as unknown as NodeJS.ReadStream; (stdin as any).isTTY = true; (stdin as any).setRawMode = () => stdin; (stdin as any).ref = () => stdin; (stdin as any).unref = () => stdin; return { stdin, stdout }; } /** Capture rendered output as accumulated string. */ function renderLive(element: React.ReactElement) { const { stdin, stdout } = createTestStreams(); const chunks: string[] = []; stdout.on("data", (chunk: Buffer) => { chunks.push(chunk.toString()); }); const instance = render(element, { stdout, stdin, debug: true, exitOnCtrlC: false, patchConsole: false, }); return { instance, stdin: stdin as any as PassThrough, getOutput: () => chunks.join(""), cleanup: async () => { instance.unmount(); await instance.waitUntilExit(); }, }; } // --------------------------------------------------------------------------- // Static render tests // --------------------------------------------------------------------------- describe("TextInput (static rendering)", () => { test("renders with default empty value", () => { const output = renderToString(); // Should render something (at minimum a cursor indicator) expect(typeof output).toBe("string"); }); test("renders initial value", () => { const output = renderToString(); expect(output).toContain("hello"); }); test("renders placeholder when value is empty", () => { const output = renderToString( ); expect(output).toContain("Type here..."); }); test("does not render placeholder when value is non-empty", () => { const output = renderToString( ); expect(output).not.toContain("Type here..."); }); }); // --------------------------------------------------------------------------- // Callback tests // --------------------------------------------------------------------------- describe("TextInput callbacks", () => { test("calls onChange when characters are typed", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Type a character stdin.write("a"); await new Promise((r) => setTimeout(r, 50)); expect(onChange).toHaveBeenCalledWith("a"); await cleanup(); }); test("calls onSubmit with Ctrl+S", async () => { const onSubmit = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Ctrl+S is character \x13 stdin.write("\x13"); await new Promise((r) => setTimeout(r, 50)); expect(onSubmit).toHaveBeenCalledWith("hello"); await cleanup(); }); test("Enter does NOT submit in either mode", async () => { const onSubmit = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Enter key stdin.write("\r"); await new Promise((r) => setTimeout(r, 50)); // Enter should NOT call onSubmit (Ctrl+S is the submit key) expect(onSubmit).not.toHaveBeenCalled(); await cleanup(); }); }); // --------------------------------------------------------------------------- // Single-line mode // --------------------------------------------------------------------------- describe("TextInput (single-line mode)", () => { test("Enter does not insert newline in single-line mode", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("\r"); await new Promise((r) => setTimeout(r, 50)); // onChange should NOT have been called with a newline // In single-line mode, Enter is a no-op const calls = onChange.mock.calls; const hasNewline = calls.some( (call: any) => typeof call[0] === "string" && call[0].includes("\n") ); expect(hasNewline).toBe(false); await cleanup(); }); }); // --------------------------------------------------------------------------- // Multi-line mode // --------------------------------------------------------------------------- describe("TextInput (multi-line mode)", () => { test("Enter inserts newline in multi-line mode", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("\r"); await new Promise((r) => setTimeout(r, 50)); // onChange should have been called with value containing newline expect(onChange).toHaveBeenCalled(); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toContain("\n"); await cleanup(); }); }); // --------------------------------------------------------------------------- // Character input // --------------------------------------------------------------------------- describe("TextInput character input", () => { test("typing characters appends to value", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("h"); await new Promise((r) => setTimeout(r, 50)); expect(onChange).toHaveBeenCalledWith("h"); await cleanup(); }); test("backspace removes last character", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // In Ink, \x08 (Ctrl+H) is parsed as backspace stdin.write("\x08"); await new Promise((r) => setTimeout(r, 50)); expect(onChange).toHaveBeenCalledWith("hell"); await cleanup(); }); }); // --------------------------------------------------------------------------- // Focus // --------------------------------------------------------------------------- describe("TextInput focus", () => { test("does not process input when not focused", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("x"); await new Promise((r) => setTimeout(r, 50)); expect(onChange).not.toHaveBeenCalled(); await cleanup(); }); }); // --------------------------------------------------------------------------- // Cursor display // --------------------------------------------------------------------------- describe("TextInput cursor rendering", () => { test("shows cursor indicator when focused", () => { const output = renderToString(); // The component should render the value — exact cursor rendering is implementation-dependent expect(output).toContain("hello"); }); }); // --------------------------------------------------------------------------- // Arrow key navigation // --------------------------------------------------------------------------- describe("TextInput arrow key navigation", () => { test("left arrow moves cursor, subsequent typing inserts at cursor", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Send left arrow (ESC [ D) to move cursor left once stdin.write("\x1b[D"); await new Promise((r) => setTimeout(r, 50)); // Type 'b' — should insert before 'c' stdin.write("b"); await new Promise((r) => setTimeout(r, 50)); // onChange should have been called with "abc" (inserted 'b' at position 1) const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toBe("abc"); await cleanup(); }); test("right arrow moves cursor forward", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Move left twice stdin.write("\x1b[D"); stdin.write("\x1b[D"); await new Promise((r) => setTimeout(r, 50)); // Move right once stdin.write("\x1b[C"); await new Promise((r) => setTimeout(r, 50)); // Type 'X' — should insert between 'b' and 'c' stdin.write("X"); await new Promise((r) => setTimeout(r, 50)); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toBe("abXc"); await cleanup(); }); test("up arrow moves cursor to previous line in multiline", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Cursor starts at end of "def" (position 7) // Move up should go to line 0, col 3 -> position 3 stdin.write("\x1b[A"); await new Promise((r) => setTimeout(r, 50)); // Type 'X' at position 3 (end of "abc") stdin.write("X"); await new Promise((r) => setTimeout(r, 50)); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toBe("abcX\ndef"); await cleanup(); }); test("down arrow moves cursor to next line in multiline", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Move to start first: Home goes to start of current line stdin.write("\x1b[H"); await new Promise((r) => setTimeout(r, 50)); // That puts us at start of "def" (line 1, col 0 = position 4) // Actually we need to go to line 0 first. Move up. stdin.write("\x1b[A"); await new Promise((r) => setTimeout(r, 50)); // Now at start of "abc" (line 0, col 0 = position 0) // Move right to col 1 stdin.write("\x1b[C"); await new Promise((r) => setTimeout(r, 50)); // Move down — should go to line 1, col 1 = position 5 stdin.write("\x1b[B"); await new Promise((r) => setTimeout(r, 50)); // Type 'X' at position 5 (between 'd' and 'ef') stdin.write("X"); await new Promise((r) => setTimeout(r, 50)); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toBe("abc\ndXef"); await cleanup(); }); }); // --------------------------------------------------------------------------- // Home / End keys // --------------------------------------------------------------------------- describe("TextInput Home/End keys", () => { test("Home moves cursor to start of line", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Home key stdin.write("\x1b[H"); await new Promise((r) => setTimeout(r, 50)); // Type 'X' — should insert at start stdin.write("X"); await new Promise((r) => setTimeout(r, 50)); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toBe("Xhello"); await cleanup(); }); test("End moves cursor to end of line", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // First move Home, then End stdin.write("\x1b[H"); await new Promise((r) => setTimeout(r, 50)); stdin.write("\x1b[F"); await new Promise((r) => setTimeout(r, 50)); // Type 'X' — should insert at end stdin.write("X"); await new Promise((r) => setTimeout(r, 50)); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toBe("helloX"); await cleanup(); }); }); // --------------------------------------------------------------------------- // Delete key // --------------------------------------------------------------------------- describe("TextInput delete key", () => { test("delete key removes character after cursor", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Move to start stdin.write("\x1b[H"); await new Promise((r) => setTimeout(r, 50)); // Delete key (ESC [ 3 ~) stdin.write("\x1b[3~"); await new Promise((r) => setTimeout(r, 50)); const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1] as any; expect(lastCall[0]).toBe("ello"); await cleanup(); }); }); // --------------------------------------------------------------------------- // Ctrl+E external editor // --------------------------------------------------------------------------- describe("TextInput Ctrl+E", () => { test("Ctrl+E triggers onEditorRequest callback", async () => { const onEditorRequest = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Ctrl+E is character \x05 stdin.write("\x05"); await new Promise((r) => setTimeout(r, 50)); expect(onEditorRequest).toHaveBeenCalledWith("hello"); await cleanup(); }); test("Ctrl+E does nothing when onEditorRequest is not provided", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Ctrl+E should not crash or change value stdin.write("\x05"); await new Promise((r) => setTimeout(r, 50)); expect(onChange).not.toHaveBeenCalled(); await cleanup(); }); }); // --------------------------------------------------------------------------- // Multiline rendering // --------------------------------------------------------------------------- describe("TextInput multiline rendering", () => { test("renders multiline value with line breaks", () => { const output = renderToString( ); expect(output).toContain("line1"); expect(output).toContain("line2"); expect(output).toContain("line3"); }); }); // --------------------------------------------------------------------------- // Edge cases // --------------------------------------------------------------------------- describe("TextInput edge cases", () => { test("renders without any props", () => { const output = renderToString(); expect(typeof output).toBe("string"); }); test("handles rapid sequential typing", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("a"); stdin.write("b"); stdin.write("c"); await new Promise((r) => setTimeout(r, 100)); // At minimum, onChange should have been called expect(onChange).toHaveBeenCalled(); await cleanup(); }); test("control characters are filtered out", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); // Send Ctrl+A (not a mapped control key) stdin.write("\x01"); await new Promise((r) => setTimeout(r, 50)); // onChange should not have been called since ctrl chars are filtered expect(onChange).not.toHaveBeenCalled(); await cleanup(); }); test("Tab key is ignored", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("\t"); await new Promise((r) => setTimeout(r, 50)); expect(onChange).not.toHaveBeenCalled(); await cleanup(); }); test("Escape key is ignored", async () => { const onChange = mock(() => {}); const onSubmit = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("\x1b"); await new Promise((r) => setTimeout(r, 50)); expect(onChange).not.toHaveBeenCalled(); expect(onSubmit).not.toHaveBeenCalled(); await cleanup(); }); test("backspace on empty value is a no-op", async () => { const onChange = mock(() => {}); const { stdin, cleanup } = renderLive( ); stdin.write("\x08"); await new Promise((r) => setTimeout(r, 50)); // Buffer has nothing to delete, so onChange should not fire // (value is still empty) const calls = onChange.mock.calls; if (calls.length > 0) { // If onChange was called, it should still be empty const lastCall = calls[calls.length - 1] as any; expect(lastCall[0]).toBe(""); } await cleanup(); }); test("placeholder is shown when unfocused and empty", () => { const output = renderToString( ); expect(output).toContain("Enter text..."); }); test("placeholder is shown with cursor when focused and empty", () => { const output = renderToString( ); expect(output).toContain("Enter text..."); }); });