Ctrl K
ComunidadGuías…

server/cli-prompts.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { PassThrough } from "node:stream";
import { stripVTControlCharacters } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultSetupIo, SetupCancelled } from "./cli-prompts.ts";

function terminal(raw = false, display: { isTTY?: boolean; columns?: number; rows?: number } = {}) {
  const input = Object.assign(new PassThrough(), {
    isTTY: true,
    isRaw: raw,
    setRawMode: vi.fn((enabled: boolean) => { input.isRaw = enabled; }),
  });
  input.pause();
  const output = Object.assign(new PassThrough(), { isTTY: true, columns: 80, rows: 24 }, display);
  let transcript = "";
  output.on("data", (chunk) => { transcript += String(chunk); });
  return { input, output, io: defaultSetupIo(input, output), text: () => transcript };
}

const signals = ["SIGINT", "SIGTERM", "exit"] as const;
const signalCounts = () => signals.map((signal) => process.listenerCount(signal));
function expectClean(fixture: ReturnType<typeof terminal>, raw = false) {
  expect(fixture.input.isRaw).toBe(raw);
  if (!fixture.input.readableEnded) expect(fixture.input.isPaused()).toBe(true);
  expect(fixture.input.listenerCount("data")).toBe(0);
  expect(fixture.input.listenerCount("keypress")).toBe(0);
  expect(fixture.output.listenerCount("resize")).toBe(0);
}

describe("Clack setup adapter", () => {
  beforeEach(() => {
    vi.stubEnv("TERM", "xterm-256color");
    vi.stubEnv("NO_COLOR", undefined);
  });
  afterEach(() => vi.unstubAllEnvs());

  it("uses the default index and Clack arrow navigation, including split sequences", async () => {
    const fixture = terminal();
    const answer = fixture.io.choose("Provider", ["First", "Second", "Third"], 1);
    expect(stripVTControlCharacters(fixture.text())).toContain("↑/↓");
    fixture.input.write("\u001b");
    fixture.input.write("[");
    fixture.input.write("B");
    fixture.input.write("\r");
    expect(await answer).toBe(2);
    expectClean(fixture);
  });

  it("selects the first item by default and preserves explicit defaults", async () => {
    const fixture = terminal();
    const first = fixture.io.choose("Model", ["First", "Second"]);
    fixture.input.write("\r");
    expect(await first).toBe(0);
    const second = fixture.io.choose("Model", ["First", "Second"], 1);
    fixture.input.write("\r");
    expect(await second).toBe(1);
    expectClean(fixture);
  });

  it("rejects unavailable choices and invalid defaults before entering raw mode", async () => {
    const fixture = terminal();
    await expect(fixture.io.choose("Model", [])).rejects.toThrow("no choices");
    for (const invalid of [-1, 2, 0.5, NaN]) {
      await expect(fixture.io.choose("Model", ["First", "Second"], invalid)).rejects.toThrow("default choice");
    }
    expect(fixture.input.setRawMode).not.toHaveBeenCalled();
    expect(fixture.text()).toBe("");
  });

  it("lets Clack bound long lists and render narrow terminal labels", async () => {
    const fixture = terminal(false, { columns: 38, rows: 12 });
    const answer = fixture.io.choose("Choose a model", Array.from({ length: 50 }, (_, i) => `Model ${i + 1}: ${"long label ".repeat(6)}`));
    expect(fixture.text()).not.toContain("Model 8:");
    fixture.input.write("\u001b[A\r");
    expect(await answer).toBe(49); // Clack wraps at the end of the option list.
    expect(stripVTControlCharacters(fixture.text())).toContain("Model 50:");
    expectClean(fixture);
  });

  it("uses Clack text input without changing whitespace or empty answers", async () => {
    const fixture = terminal();
    const entered = fixture.io.ask("Account: ");
    fixture.input.write("  name@example.com  \r");
    expect(await entered).toBe("  name@example.com  ");
    const empty = fixture.io.ask("Optional: ");
    fixture.input.write("\r");
    expect(await empty).toBe("");
    expectClean(fixture);
  });

  it("keeps confirmation default-negative and supports defaults, arrows and y/n", async () => {
    const fixture = terminal();
    const declined = fixture.io.confirm("Replace?");
    fixture.input.write("\r");
    expect(await declined).toBe(false);
    const accepted = fixture.io.confirm("Save?", true);
    fixture.input.write("\r");
    expect(await accepted).toBe(true);
    const arrow = fixture.io.confirm("Continue?");
    fixture.input.write("\u001b[A\r");
    expect(await arrow).toBe(true);
    const yes = fixture.io.confirm("Continue?");
    fixture.input.write("y");
    expect(await yes).toBe(true);
    const no = fixture.io.confirm("Replace?", true);
    fixture.input.write("n");
    expect(await no).toBe(false);
    expectClean(fixture);
  });

  it.each([true, false])("never echoes pasted secrets with rich output=%s", async (rich) => {
    const fixture = terminal(false, { isTTY: rich });
    const answer = fixture.io.secret("API key: ");
    expect(fixture.input.isRaw).toBe(true);
    fixture.input.write("\u001b[20");
    fixture.input.write("0~sk-private-pasted-key\u001b[201~\r");
    expect(await answer).toBe("sk-private-pasted-key");
    expect(fixture.text()).not.toContain("sk-private");
    expect(fixture.text()).not.toContain("pasted-key");
    if (rich) expect(fixture.text()).toContain("*");
    else expect(fixture.text()).toBe("API key: \n");
    expectClean(fixture);
  });

  it.each([true, false])("supports hidden corrections and split UTF-8 with rich output=%s", async (rich) => {
    const fixture = terminal(true, { isTTY: rich });
    const answer = fixture.io.secret("Key: ");
    fixture.input.write("mistake\u0015sk-secrex\u007ft");
    const encoded = Buffer.from("é");
    fixture.input.write(encoded.subarray(0, 1));
    fixture.input.write(encoded.subarray(1));
    fixture.input.write("\r");
    expect(await answer).toBe("sk-secreté");
    expect(fixture.text()).not.toContain("sk-secret");
    expectClean(fixture, true);
  });

  it.each(["ask", "secret", "choose", "confirm"] as const)("cancels %s on Ctrl-C/D and restores all prompt resources", async (kind) => {
    for (const rich of [true, false]) for (const key of ["\u0003", "\u0004"]) {
      const fixture = terminal(true, { isTTY: rich });
      const before = signalCounts();
      const pending = kind === "choose" ? fixture.io.choose("Provider", ["First", "Second"]) : fixture.io[kind]("Question");
      const rejected = expect(pending).rejects.toBeInstanceOf(SetupCancelled);
      fixture.input.write("\u001b[");
      fixture.input.write(key);
      await rejected;
      expectClean(fixture, true);
      expect(signalCounts()).toEqual(before);
    }
  });

  it("translates Clack's Escape cancellation into SetupCancelled", async () => {
    const fixture = terminal();
    const pending = fixture.io.choose("Provider", ["First"]);
    const rejected = expect(pending).rejects.toBeInstanceOf(SetupCancelled);
    fixture.input.write("\u001b");
    await rejected;
    expectClean(fixture);
  });

  it.each(["end", "close", "error", "output-error", "SIGINT", "SIGTERM"])("cleans up a hidden prompt on %s", async (kind) => {
    for (const rich of [true, false]) {
      const fixture = terminal(false, { isTTY: rich });
      const previous = new Set(process.listeners(kind));
      const before = signalCounts();
      const pending = fixture.io.secret("Key: ");
      const rejected = expect(pending).rejects.toThrow(kind.includes("error") ? "disconnected" : "Setup cancelled");
      fixture.input.write("sk-do-not-print");
      if (kind === "end") fixture.input.end();
      else if (kind === "close") fixture.input.emit("close");
      else if (kind === "error") fixture.input.emit("error", new Error("input disconnected"));
      else if (kind === "output-error") fixture.output.emit("error", new Error("output disconnected"));
      else process.listeners(kind).find((listener) => !previous.has(listener))!();
      await rejected;
      expect(fixture.text()).not.toContain("sk-do-not-print");
      expectClean(fixture);
      expect(signalCounts()).toEqual(before);
    }
  });

  it("preserves an already-flowing input stream", async () => {
    const fixture = terminal();
    fixture.input.resume();
    const pending = fixture.io.choose("Provider", ["First"]);
    fixture.input.write("\r");
    await pending;
    expect(fixture.input.readableFlowing).toBe(true);
    expect(fixture.input.isRaw).toBe(false);
  });

  it("does not retain a text decoder that could echo the following secret", async () => {
    const fixture = terminal();
    const selected = fixture.io.choose("Provider", ["API key"]);
    fixture.input.write("\r");
    await selected;
    const account = fixture.io.ask("Name: ");
    fixture.input.write("My account\r");
    await account;
    const secret = fixture.io.secret("Key: ");
    fixture.input.write("sk-never-echo-this\r");
    expect(await secret).toBe("sk-never-echo-this");
    expect(fixture.text()).not.toContain("sk-never-echo-this");
    expectClean(fixture);
  });

  it("sanitizes provider labels, log output and questions before rendering", async () => {
    const fixture = terminal();
    fixture.io.log("Provider \u001b[31mred\u001b[0m\u0007");
    expect(fixture.text()).toBe("Provider red\n");
    const selected = fixture.io.choose("Model\u001b[2J", ["Untrusted\u001b[2J\nlabel"]);
    fixture.input.write("\r");
    await selected;
    const answer = fixture.io.ask("Account\u001b[2J\u0007: ");
    fixture.input.write("name\r");
    await answer;
    expect(fixture.text()).not.toContain("\u001b[2J");
    expect(fixture.text()).not.toContain("\u0007");
    expect(stripVTControlCharacters(fixture.text())).toContain("Untrusted label");
  });

  it("strips OSC 8 hyperlink sequences without leaking their URIs", () => {
    const fixture = terminal();
    fixture.io.log("Open \u001b]8;;https://example.com/(v2)+guide?q=1\u0007the guide\u001b]8;;\u0007 now");
    expect(fixture.text()).toBe("Open the guide now\n");
    fixture.io.log("Alt \u001b]8;;https://example.com/(x)\u001b\\label\u001b]8;;\u001b\\ end");
    expect(fixture.text()).toContain("Alt label end\n");
    fixture.io.log("C1 \u009d8;;https://example.com/(c1)+uri\u0007label\u009d8;;\u0007 end");
    expect(fixture.text()).toContain("C1 label end\n");
    expect(fixture.text()).not.toContain("example.com");
  });

  it("refuses hidden input without a terminal before printing or changing raw mode", () => {
    const fixture = terminal();
    fixture.input.isTTY = false;
    expect(() => fixture.io.secret("Key: ")).toThrow("interactive terminal");
    expect(fixture.text()).toBe("");
    expect(fixture.input.setRawMode).not.toHaveBeenCalled();
  });

  it.each(["dumb", "NO_COLOR", "redirected", "narrow", "short"])("uses an escape-free numeric fallback for %s output", async (mode) => {
    if (mode === "dumb") vi.stubEnv("TERM", "dumb");
    if (mode === "NO_COLOR") vi.stubEnv("NO_COLOR", "");
    const fixture = terminal(false, { isTTY: mode !== "redirected", columns: mode === "narrow" ? 16 : 80, rows: mode === "short" ? 5 : 24 });
    const pending = fixture.io.choose("Provider", ["First", "Second"], 0);
    expect(fixture.text()).toContain("1. First (default)");
    fixture.input.write("9\r");
    await vi.waitFor(() => expect(fixture.text()).toContain("Enter a number from 1 to 2."));
    fixture.input.write("2\r");
    expect(await pending).toBe(1);
    expect(fixture.text()).not.toContain("\u001b");
    expectClean(fixture);
  });

  it("pages plain long lists and accepts multi-digit numeric choices", async () => {
    const fixture = terminal(false, { isTTY: false });
    const pending = fixture.io.choose("Model", Array.from({ length: 50 }, (_, index) => `Model ${index + 1}`));
    expect(fixture.text()).not.toContain("21. Model");
    fixture.input.write("n\r");
    await vi.waitFor(() => expect(fixture.text()).toContain("21. Model"));
    fixture.input.write("42\r");
    expect(await pending).toBe(41);
    expectClean(fixture);
  });

  it("preserves choice and consent defaults in plain mode and retries invalid confirmations", async () => {
    const fixture = terminal(false, { isTTY: false });
    const select = fixture.io.choose("Model", ["First", "Second"], 1);
    fixture.input.write("\r");
    expect(await select).toBe(1);
    const confirmed = fixture.io.confirm("Save?", true);
    fixture.input.write("\r");
    expect(await confirmed).toBe(true);
    const declined = fixture.io.confirm("Replace?");
    fixture.input.write("\r");
    expect(await declined).toBe(false);
    const retried = fixture.io.confirm("Continue?");
    fixture.input.write("maybe\r");
    await vi.waitFor(() => expect(fixture.text()).toContain("Enter yes or no."));
    fixture.input.write("yes\r");
    expect(await retried).toBe(true);
    expectClean(fixture);
  });
});