Files
Netcatty/electron/bridges/ai/shellUtils.test.cjs
2026-06-12 17:27:52 +08:00

615 lines
24 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const test = require("node:test");
const assert = require("node:assert/strict");
const {
addCodexExecutableEnvForSdk,
buildWindowsShellCommandLine,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isPlausibleCliVersionOutput,
looksLikeIdleAutoLogout,
prepareCommandForSpawn,
resolveWindowsShimToNativeExe,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
parseRegQueryPath,
expandWindowsEnvRefs,
mergeWindowsPath,
readWindowsRegistryPath,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
test("extracts a trailing PowerShell idle prompt", () => {
assert.equal(
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice>"),
"PS C:\\Users\\alice>",
);
});
test("preserves trailing whitespace on a captured PowerShell prompt", () => {
// The wrapper-selection logic trims this, but the suffix-match logic in
// hasExpectedPromptSuffix() compares against raw PTY bytes, so the trailing
// space PowerShell emits after `>` must round-trip unchanged.
assert.equal(
extractTrailingIdlePrompt("Microsoft Windows...\r\nPS C:\\Users\\alice> "),
"PS C:\\Users\\alice> ",
);
});
test("extracts a bare PowerShell prompt with no working directory", () => {
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS>"), "PS>");
});
test("does not extract content that merely looks PowerShell-ish", () => {
// Any non-prompt output ending in `PSO>` or `ZIPS>` would have produced a
// trailing newline before the next prompt; this guards against the regex
// accidentally matching command output that just happens to contain "PS".
assert.equal(extractTrailingIdlePrompt("nope\r\nPSO>"), "");
assert.equal(extractTrailingIdlePrompt("nope\r\nZIPS>"), "");
});
test("rejects `PS >` (literal `PS` + space + `>`) so spoofed scripts can't masquerade as a default prompt", () => {
// Default PowerShell never emits this shape; rejecting it makes the
// override harder to coerce via printed output.
assert.equal(extractTrailingIdlePrompt("welcome\r\nPS >"), "");
});
test("treats CR repaints as line breaks so only the redrawn line is captured", () => {
// PSReadLine / ConPTY emit bare `\r` to repaint the current line. The
// captured prompt must equal the visible last line, not the
// concatenation of every overwritten frame, so hasExpectedPromptSuffix
// can still match the live PTY tail later.
assert.equal(
extractTrailingIdlePrompt("PS C:\\old>\rPS C:\\new>"),
"PS C:\\new>",
);
});
test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alikes", () => {
assert.equal(isDefaultPowerShellPromptLine("PS C:\\Users\\alice>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS /home/alice>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS>"), true);
assert.equal(isDefaultPowerShellPromptLine("PS >"), false);
assert.equal(isDefaultPowerShellPromptLine("PSO>"), false);
assert.equal(isDefaultPowerShellPromptLine("ZIPS>"), false);
assert.equal(isDefaultPowerShellPromptLine(""), false);
assert.equal(isDefaultPowerShellPromptLine(null), false);
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
assert.equal(isPlausibleCliVersionOutput("file:///opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js:95"), false);
assert.equal(isPlausibleCliVersionOutput("TypeError: Cannot read properties of undefined"), false);
assert.equal(isPlausibleCliVersionOutput(" at runCli (cli.js:10:1)"), false);
assert.equal(isPlausibleCliVersionOutput("permission denied"), false);
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), false);
});
test("buildWindowsShellCommandLine quotes command paths and args with spaces", () => {
assert.equal(
buildWindowsShellCommandLine("C:\\Program Files\\Codex\\codex.cmd", ["login", "status"]),
"\"C:\\Program Files\\Codex\\codex.cmd\" \"login\" \"status\"",
);
});
test("prepareCommandForSpawn wraps Windows cmd shims as a single shell command", () => {
const result = prepareCommandForSpawn("C:\\Program Files\\Codex\\codex.cmd", ["--version"]);
if (process.platform === "win32") {
assert.deepEqual(result, {
command: "\"C:\\Program Files\\Codex\\codex.cmd\" \"--version\"",
args: [],
shell: true,
});
} else {
assert.deepEqual(result, {
command: "C:\\Program Files\\Codex\\codex.cmd",
args: ["--version"],
shell: false,
});
}
});
test("resolveClaudeCodeExecutableForSdk maps Windows npm cmd shim to Claude Code cli.js", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-shim-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const scriptPath = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "cli.js");
fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
fs.writeFileSync(scriptPath, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%basedir%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n',
"utf8",
);
assert.equal(resolveClaudeCodeExecutableForSdk(shimPath, "win32"), scriptPath);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutableForSdk leaves non-Windows Claude paths unchanged", () => {
assert.equal(
resolveClaudeCodeExecutableForSdk("/usr/local/bin/claude", "darwin"),
"/usr/local/bin/claude",
);
});
test("resolveClaudeCodeExecutableForSdk keeps Windows cmd shim when Claude Code cli.js is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-missing-cli-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%basedir%\\node_modules\\@anthropic-ai\\claude-code\\cli.js" %*\r\n',
"utf8",
);
assert.equal(resolveClaudeCodeExecutableForSdk(shimPath, "win32"), shimPath);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveClaudeCodeExecutableForSdk maps Windows npm cmd shim to native claude.exe when cli.js is absent", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-claude-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
assert.equal(resolveClaudeCodeExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveWindowsShimToNativeExe resolves npm .cmd shim to native exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-shim-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
// Single backslashes in the .cmd content (%~dp0 expands to the shim dir)
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
const resolved = resolveWindowsShimToNativeExe(shimPath, "win32");
assert.equal(resolved, nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("prepareCommandForSpawn resolves Windows cmd shim to native exe with shell:false", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-spawn-native-"));
try {
const shimPath = path.join(tmp, "claude.cmd");
const nativeExe = path.join(tmp, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
fs.writeFileSync(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*\r\n',
"utf8",
);
const result = prepareCommandForSpawn(shimPath, ["--version"]);
if (process.platform === "win32") {
assert.deepEqual(result, {
command: nativeExe,
args: ["--version"],
shell: false,
});
} else {
// On non-Windows, resolveWindowsShimToNativeExe is skipped; verify win32 behavior explicitly.
assert.equal(resolveWindowsShimToNativeExe(shimPath, "win32"), nativeExe);
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
function writeCodexWin32NativeLayout(globalPrefix, arch = process.arch === "arm64" ? "arm64" : "x64") {
const triple = arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc";
const platformPackage = arch === "arm64" ? "@openai/codex-win32-arm64" : "@openai/codex-win32-x64";
const nativeExe = path.join(
globalPrefix,
"node_modules",
platformPackage,
"vendor",
triple,
"bin",
"codex.exe",
);
fs.mkdirSync(path.dirname(nativeExe), { recursive: true });
fs.writeFileSync(nativeExe, "", "utf8");
return nativeExe;
}
test("resolveCodexExecutableForSdk maps Windows npm cmd shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-shim-"));
try {
const shimPath = path.join(tmp, "codex.cmd");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows local npm bin shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-local-shim-"));
try {
const shimPath = path.join(tmp, "node_modules", ".bin", "codex.cmd");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.mkdirSync(path.dirname(shimPath), { recursive: true });
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\..\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk leaves non-Windows Codex paths unchanged", () => {
assert.equal(
resolveCodexExecutableForSdk("/usr/local/bin/codex", "darwin"),
"/usr/local/bin/codex",
);
});
test("resolveCodexExecutableForSdk returns null for Windows cmd shim when native codex.exe is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-missing-native-"));
try {
const shimPath = path.join(tmp, "codex.cmd");
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), null);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps Windows PowerShell shim to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-ps1-shim-"));
try {
const shimPath = path.join(tmp, "codex.ps1");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.writeFileSync(
shimPath,
'& "$basedir/node_modules/@openai/codex/bin/codex.js" $args\r\n',
"utf8",
);
assert.equal(resolveCodexExecutableForSdk(shimPath, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodexExecutableForSdk maps codex.js entry to native codex.exe", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-js-entry-"));
try {
const codexJs = path.join(tmp, "node_modules", "@openai", "codex", "bin", "codex.js");
const nativeExe = writeCodexWin32NativeLayout(tmp);
fs.mkdirSync(path.dirname(codexJs), { recursive: true });
fs.writeFileSync(codexJs, "", "utf8");
assert.equal(resolveCodexExecutableForSdk(codexJs, "win32"), nativeExe);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("addCodexExecutableEnvForSdk prepends bundled Codex path dir on Windows", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-env-path-"));
try {
const nativeExe = writeCodexWin32NativeLayout(tmp);
const pathDir = path.join(path.dirname(path.dirname(nativeExe)), "codex-path");
fs.mkdirSync(pathDir, { recursive: true });
const env = addCodexExecutableEnvForSdk({ Path: "C:\\Windows\\System32" }, nativeExe, "win32");
assert.equal(env.Path, `${pathDir};C:\\Windows\\System32`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
function writeCodebuddyWin32BinLayout(dir) {
const binJs = path.join(dir, "node_modules", "@tencent-ai", "codebuddy-code", "bin", "codebuddy");
fs.mkdirSync(path.dirname(binJs), { recursive: true });
fs.writeFileSync(binJs, "#!/usr/bin/env node\n", "utf8");
return binJs;
}
test("resolveCodebuddyExecutableForSdk leaves non-Windows CodeBuddy paths unchanged", () => {
assert.equal(
resolveCodebuddyExecutableForSdk("/usr/local/bin/codebuddy", "darwin"),
"/usr/local/bin/codebuddy",
);
});
test("resolveCodebuddyExecutableForSdk maps Windows npm cmd shim to package bin/codebuddy", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-shim-"));
try {
const shimPath = path.join(tmp, "codebuddy.cmd");
const binJs = writeCodebuddyWin32BinLayout(tmp);
fs.writeFileSync(
shimPath,
'@ECHO off\r\nnode "%~dp0\\node_modules\\@tencent-ai\\codebuddy-code\\bin\\codebuddy" %*\r\n',
"utf8",
);
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodebuddyExecutableForSdk maps extensionless Windows shim to package bin/codebuddy", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-noext-"));
try {
const shimPath = path.join(tmp, "codebuddy");
const binJs = writeCodebuddyWin32BinLayout(tmp);
fs.writeFileSync(shimPath, "#!/bin/sh\n", "utf8");
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), binJs);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodebuddyExecutableForSdk returns null for Windows cmd shim when package JS is missing", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codebuddy-missing-"));
try {
const shimPath = path.join(tmp, "codebuddy.cmd");
fs.writeFileSync(shimPath, "@ECHO off\r\nnode foo %*\r\n", "utf8");
assert.equal(resolveCodebuddyExecutableForSdk(shimPath, "win32"), null);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("resolveCodebuddyExecutableForSdk passes through a native exe path", () => {
assert.equal(
resolveCodebuddyExecutableForSdk("C:\\tools\\codebuddy.exe", "win32"),
"C:\\tools\\codebuddy.exe",
);
});
test("parseRegQueryPath extracts the Path value from reg query output", () => {
const out = parseRegQueryPath(
"\r\nHKEY_CURRENT_USER\\Environment\r\n Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools\r\n",
);
assert.equal(out, "C:\\Users\\me\\AppData\\Roaming\\npm;C:\\tools");
});
test("parseRegQueryPath handles REG_SZ and missing value", () => {
assert.equal(parseRegQueryPath(" Path REG_SZ C:\\bin"), "C:\\bin");
assert.equal(parseRegQueryPath("HKEY_CURRENT_USER\\Environment\r\n Temp REG_SZ C:\\Temp"), "");
});
test("expandWindowsEnvRefs expands %VAR% case-insensitively", () => {
assert.equal(
expandWindowsEnvRefs("%AppData%\\npm;%Other%", { APPDATA: "C:\\Users\\me\\AppData\\Roaming" }),
"C:\\Users\\me\\AppData\\Roaming\\npm;%Other%",
);
});
test("mergeWindowsPath dedupes case-insensitively and trims trailing slashes", () => {
const out = mergeWindowsPath(
"C:\\Windows\\System32;C:\\tools\\",
"c:\\windows\\system32;C:\\tools;C:\\new",
);
assert.equal(out, "C:\\Windows\\System32;C:\\tools\\;C:\\new");
});
test("mergeWindowsPath keeps refreshed Windows PATH entries ahead of stale process entries", () => {
const out = mergeWindowsPath(
"C:\\new-codebuddy;C:\\Windows\\System32",
"C:\\Users\\me\\AppData\\Roaming\\npm",
"C:\\old-codebuddy;C:\\Windows\\System32",
);
assert.equal(out, "C:\\new-codebuddy;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Roaming\\npm;C:\\old-codebuddy");
});
test("readWindowsRegistryPath merges HKCU and HKLM and expands refs", async () => {
const exec = async (cmd, args) => {
assert.equal(cmd, "reg");
const hive = args[1];
if (hive === "HKCU\\Environment") {
return { stdout: " Path REG_EXPAND_SZ %APPDATA%\\npm\r\n" };
}
return { stdout: " Path REG_EXPAND_SZ C:\\Windows\\System32\r\n" };
};
const out = await readWindowsRegistryPath({ exec, env: { APPDATA: "C:\\Roaming" } });
assert.equal(out, "C:\\Roaming\\npm;C:\\Windows\\System32");
});
test("readWindowsRegistryPath tolerates a failing hive query", async () => {
const exec = async (cmd, args) => {
if (args[1] === "HKCU\\Environment") throw new Error("ERROR: cannot read");
return { stdout: " Path REG_SZ C:\\tools\r\n" };
};
const out = await readWindowsRegistryPath({ exec, env: {} });
assert.equal(out, "C:\\tools");
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};
const prompt = trackSessionIdlePrompt(session, "Last login...\r\nPS C:\\Windows\\System32>");
assert.equal(prompt, "PS C:\\Windows\\System32>");
assert.equal(session.lastIdlePrompt, "PS C:\\Windows\\System32>");
assert.equal(typeof session.lastIdlePromptAt, "number");
});
test("getFreshIdlePrompt returns the cached prompt when the live tail still ends with it", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "Microsoft Windows...\r\nPS C:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
});
test("getFreshIdlePrompt drops a stale prompt when the live tail has moved on (e.g. exited PowerShell)", () => {
// Simulates: SSH session entered PowerShell, captured `PS C:\>`, then
// user `exit`-ed back into a shell with a custom prompt the regex
// doesn't recognize. lastIdlePrompt is still the old PS line, but the
// visible tail now shows the new prompt — we must NOT keep handing
// the stale value to resolveEffectiveShellKind.
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "PS C:\\Users\\alice>\r\nexit\r\nlogout\r\n ",
};
assert.equal(getFreshIdlePrompt(session), "");
});
test("getFreshIdlePrompt drops a stale prompt when the live tail switched to cmd.exe", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "PS C:\\Users\\alice>\r\ncmd\r\nMicrosoft Windows...\r\nC:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "");
});
test("getFreshIdlePrompt tolerates ANSI colour codes that wrap the prompt in either side", () => {
const session = {
lastIdlePrompt: "PS C:\\Users\\alice>",
_promptTrackTail: "stuff\r\nPS C:\\Users\\alice>",
};
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
});
test("getFreshIdlePrompt returns empty string when the session has no cached prompt or tail", () => {
assert.equal(getFreshIdlePrompt(null), "");
assert.equal(getFreshIdlePrompt(undefined), "");
assert.equal(getFreshIdlePrompt({}), "");
assert.equal(getFreshIdlePrompt({ lastIdlePrompt: "PS C:\\>" }), "");
assert.equal(
getFreshIdlePrompt({ lastIdlePrompt: "", _promptTrackTail: "anything" }),
"",
);
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
trackSessionIdlePrompt(session, "Microsoft Windows...\r\nPS C:\\Users\\alice>");
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
// (2) User runs `exit` and the shell now shows an unrecognized prompt.
// trackSessionIdlePrompt does not update lastIdlePrompt (the new shape
// doesn't match POSIX or PowerShell regexes), so the cache is stale.
trackSessionIdlePrompt(session, "\r\nexit\r\nlogout\r\n ");
assert.equal(session.lastIdlePrompt, "PS C:\\Users\\alice>"); // unchanged
// The freshness check rescues us: the visible tail no longer ends
// with the cached PS line, so downstream wrapper selection sees "".
assert.equal(getFreshIdlePrompt(session), "");
});
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
// bash prints this immediately before a TMOUT auto-logout exit. The exit
// itself is a clean shell exit (code 0, no signal), so the banner is the
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
assert.equal(
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
});
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
assert.equal(
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
// and must still auto-close the tab.
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
});
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
// "auto-logout" scrolled past long ago; the user then ran more commands and
// exited normally. Only the tail end is inspected, so this is not a timeout.
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
// Investigating TMOUT: the user greps the profile (output mentions
// "auto-logout"), reads it, then exits on purpose. The banner is not the
// final line, so the tab must still auto-close. Guards against matching an
// unanchored substring anywhere in the recent output.
const tail =
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
"root@h:~# exit\r\nlogout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
// The banner can share a line with the trailing prompt after ANSI/control
// bytes are stripped (observed over real SSH); anchoring on the line end
// must still match.
const tail =
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
assert.equal(looksLikeIdleAutoLogout(tail), true);
});
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
assert.equal(looksLikeIdleAutoLogout(""), false);
assert.equal(looksLikeIdleAutoLogout(undefined), false);
assert.equal(looksLikeIdleAutoLogout(null), false);
});
function withExecPath(fakePath, fn) {
const original = process.execPath;
Object.defineProperty(process, "execPath", { value: fakePath, configurable: true, writable: true });
try {
return fn();
} finally {
Object.defineProperty(process, "execPath", { value: original, configurable: true, writable: true });
}
}