Files
cline/apps/cli/src/cli.interactive.e2e.test.ts
T
Robin Newhouse 6364792c47 fix(cli): isolate history resume renderer ENG-2190 (#11502)
* fix(cli): isolate history resume renderer

* fix(cli): avoid duplicate history resume signals
2026-06-15 10:04:23 -07:00

275 lines
8.8 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const cliRoot = path.resolve(__dirname, "..");
const cliEntry = path.join(cliRoot, "src", "index.ts");
const bunExec = process.env.BUN_EXEC_PATH ?? "bun";
type CliResult = ReturnType<typeof spawnSync>;
interface KeyStep {
delaySeconds: number;
input: string;
}
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
const POST_ACTION_SETTLE_SECONDS = 1.0;
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
function normalizeTerminalOutput(output: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
const ansiCsiRegex = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips OSC sequences
const ansiOscRegex = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g;
const carriageReturnRegex = /\r/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips backspace control bytes
const backspaceRegex = /\u0008/g;
return (
output
// Strip ANSI CSI/OSC escapes.
.replace(ansiCsiRegex, "")
.replace(ansiOscRegex, "")
// Remove CR + backspace artifacts from `script`.
.replace(carriageReturnRegex, "")
.replace(backspaceRegex, "")
);
}
function toShellSingleQuotedLiteral(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
const quietFlag = "-q";
if (process.platform === "linux") {
return `(${scriptedInput}) | script ${quietFlag} /dev/null -- ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
function createCliEnv(): NodeJS.ProcessEnv {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
};
}
function runInteractiveCli(
steps: KeyStep[],
options?: {
launchConfigView?: boolean;
launchArgs?: string[];
env?: NodeJS.ProcessEnv;
},
): CliResult {
const env = options?.env ?? createCliEnv();
const scriptedInput = [
...steps,
// Exit each interactive run explicitly so tests do not idle until timeout.
{ delaySeconds: 0.2, input: "\u0003" },
]
.map(
(step) =>
`sleep ${step.delaySeconds}; printf ${toShellSingleQuotedLiteral(step.input)}`,
)
.join("; ");
const baseArgs = [
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
];
const launchArgs = (
options?.launchArgs
? [cliEntry, ...options.launchArgs]
: options?.launchConfigView
? [...baseArgs, "config"]
: baseArgs
)
.map((arg) => toShellSingleQuotedLiteral(arg))
.join(" ");
const command = buildScriptCommand(scriptedInput, launchArgs);
return spawnSync("bash", ["-lc", command], {
cwd: cliRoot,
encoding: "utf8",
env,
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
}
function outputOf(result: CliResult): string {
return normalizeTerminalOutput(
`${typeof result.stdout === "string" ? result.stdout : result.stdout.toString("utf8")}\n${
typeof result.stderr === "string"
? result.stderr
: result.stderr.toString("utf8")
}`,
);
}
const tempDirs: string[] = [];
describe("cli interactive e2e", () => {
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("shows the interactive chat view on launch", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("What can I do for you?");
expect(output).toContain("○ Plan ● Act (Tab)");
expect(output).toContain("Auto-approve all enabled (Shift+Tab)");
});
it("toggles plan/act mode with Tab", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "\t" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("○ Plan ● Act (Tab)");
expect(output).toContain("● Plan ○ Act (Tab)");
});
it("toggles auto-approve-all with Shift+Tab", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "\u001b[Z" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("Auto-approve all enabled (Shift+Tab)");
expect(output).toContain("Auto-approve all disabled (Shift+Tab)");
});
it("opens /settings and navigates tabs with Tab", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "/settings" },
{ delaySeconds: 0.25, input: "\r" }, // accept slash completion
{ delaySeconds: 0.25, input: "\r" }, // submit command
{ delaySeconds: 0.7, input: "\t" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain("Configuration");
expect(output).toContain("[Tools] Plugins Agents Hooks Skills Rules MCP");
expect(output).toContain("Tools [Plugins] Agents Hooks Skills Rules MCP");
});
it("closes /settings with Escape", () => {
const result = runInteractiveCli([
{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "/settings" },
{ delaySeconds: 0.25, input: "\r" },
{ delaySeconds: 0.25, input: "\r" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "\u001b" },
{ delaySeconds: POST_ACTION_SETTLE_SECONDS, input: "" },
]);
const output = outputOf(result);
expect(output).toContain(
"Config mode: Tab tabs · ↑/↓ navigate · Esc close",
);
expect(output).toContain("/ for commands · @ for files");
});
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
timeout: 120_000,
}, () => {
const env = createCliEnv();
// Seed one session; the invalid key makes the run fail fast while
// still persisting a resumable session record.
const seed = spawnSync(
bunExec,
[
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
"hello",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
expect(seed.error).toBeUndefined();
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
cwd: cliRoot,
encoding: "utf8",
env,
timeout: 60_000,
});
expect(history.error).toBeUndefined();
expect(history.status).toBe(0);
const historyRows = JSON.parse(history.stdout) as unknown[];
expect(historyRows.length).toBeGreaterThan(0);
// history picker -> Enter resumes the seeded session in the
// interactive TUI -> double Ctrl+C exits it. Regression guard for
// the Bun "panic(main thread): Segmentation fault" that occurred
// when the resumed TUI shared the picker's process (a second
// OpenTUI renderer in one process crashes natively on teardown).
const result = runInteractiveCli(
[
// Select the seeded session in the picker.
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
// Give the resumed TUI time to start, then double-press
// Ctrl+C; the harness appends the final press 0.2s later.
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
],
{ launchArgs: ["history"], env },
);
const output = outputOf(result);
// The exit summary only prints after the resumed interactive TUI ran
// and shut down cleanly; the history picker alone never prints it.
expect(output).toContain("Session Summary");
expect(output).not.toContain("panic(");
expect(output).not.toContain("Segmentation fault");
expect(result.status).toBe(0);
});
it("launches config view directly with `cline config`", () => {
const result = runInteractiveCli(
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
{
launchConfigView: true,
},
);
const output = outputOf(result);
expect(output).toContain("Configuration");
expect(output).toContain("[Tools] Plugins Agents Hooks Skills Rules MCP");
});
});