Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1032 lines
36 KiB
TypeScript
1032 lines
36 KiB
TypeScript
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
|
||
import {
|
||
createSudoPasswordAutofill,
|
||
prepareSudoAutofillInput,
|
||
} from "./terminalSudoAutofill";
|
||
import {
|
||
recordTerminalCommandExecution,
|
||
resolveSubmittedShellCommand,
|
||
} from "./terminalCommandExecution";
|
||
import { createPromptLineBreakState } from "./promptLineBreak";
|
||
|
||
function createFakeTerm(
|
||
lineText = "$ echo ok",
|
||
cursorX = lineText.length,
|
||
options?: {
|
||
ghostFrom?: number;
|
||
/** Per-cell fg overrides for accepted text (syntax highlighting). */
|
||
fgAt?: (x: number) => number;
|
||
},
|
||
) {
|
||
const ghostFrom = options?.ghostFrom;
|
||
const fgAt = options?.fgAt;
|
||
return {
|
||
buffer: {
|
||
active: {
|
||
cursorX,
|
||
cursorY: 0,
|
||
baseY: 0,
|
||
getLine(line: number) {
|
||
if (line !== 0) return undefined;
|
||
return {
|
||
isWrapped: false,
|
||
translateToString() {
|
||
return lineText;
|
||
},
|
||
getCell(x: number) {
|
||
const isGhost = ghostFrom != null && x >= ghostFrom;
|
||
return {
|
||
getWidth: () => 1,
|
||
getChars: () => lineText[x] ?? "",
|
||
isDim: () => (isGhost ? 1 : 0),
|
||
getFgColorMode: () => 0,
|
||
getFgColor: () => {
|
||
if (isGhost) return 8;
|
||
return fgAt?.(x) ?? 15;
|
||
},
|
||
};
|
||
},
|
||
};
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||
return {
|
||
cols,
|
||
buffer: {
|
||
active: {
|
||
cursorX,
|
||
cursorY,
|
||
baseY: 0,
|
||
getLine(line: number) {
|
||
const lineText = rows[line];
|
||
if (lineText === undefined) return undefined;
|
||
return {
|
||
isWrapped: line > 0,
|
||
translateToString() {
|
||
return lineText;
|
||
},
|
||
};
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
test("sudo autofill input preparation arms on a submitted sudo command without altering input", () => {
|
||
const writes: string[] = [];
|
||
const autofill = createSudoPasswordAutofill({
|
||
password: "secret",
|
||
write: (data) => writes.push(data),
|
||
onHint: () => true,
|
||
});
|
||
|
||
assert.equal(prepareSudoAutofillInput("\r", "sudo whoami", autofill), "\r");
|
||
autofill.handleOutput("[sudo] password for alice: ");
|
||
autofill.confirmFill();
|
||
assert.deepEqual(writes, ["secret\n"]);
|
||
});
|
||
|
||
test("sudo autofill input preparation arms on a single-line pasted sudo command", () => {
|
||
const writes: string[] = [];
|
||
const autofill = createSudoPasswordAutofill({
|
||
password: "secret",
|
||
write: (data) => writes.push(data),
|
||
onHint: () => true,
|
||
});
|
||
|
||
assert.equal(prepareSudoAutofillInput("sudo whoami\n", null, autofill), "sudo whoami\n");
|
||
autofill.handleOutput("[sudo] password for alice: ");
|
||
autofill.confirmFill();
|
||
assert.deepEqual(writes, ["secret\n"]);
|
||
});
|
||
|
||
test("sudo autofill input preparation preserves bracketed pasted sudo commands", () => {
|
||
const autofill = createSudoPasswordAutofill({
|
||
password: "secret",
|
||
write: () => {},
|
||
});
|
||
|
||
assert.equal(
|
||
prepareSudoAutofillInput("\x1b[200~sudo whoami\n\x1b[201~", null, autofill),
|
||
"\x1b[200~sudo whoami\n\x1b[201~",
|
||
);
|
||
});
|
||
|
||
test("sudo autofill input preparation leaves ordinary commands unchanged", () => {
|
||
const autofill = createSudoPasswordAutofill({
|
||
password: "secret",
|
||
write: () => {},
|
||
});
|
||
|
||
assert.equal(prepareSudoAutofillInput("\r", "echo ok", autofill), "\r");
|
||
assert.equal(prepareSudoAutofillInput("x", "sudo whoami", autofill), "x");
|
||
assert.equal(prepareSudoAutofillInput("sudo whoami\nsudo id\n", null, autofill), "sudo whoami\nsudo id\n");
|
||
});
|
||
|
||
test("command execution arms prompt line break even without command history callback", () => {
|
||
const promptState = createPromptLineBreakState();
|
||
const commandBufferRef = { current: "echo ok" };
|
||
|
||
const recordedCommand = recordTerminalCommandExecution("echo ok", {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
promptLineBreakStateRef: { current: promptState },
|
||
});
|
||
|
||
assert.equal(commandBufferRef.current, "");
|
||
assert.equal(recordedCommand, "echo ok");
|
||
assert.equal(promptState.pendingCommand, true);
|
||
});
|
||
|
||
test("sensitive terminal input never reaches command or semantic callbacks", () => {
|
||
const promptState = createPromptLineBreakState();
|
||
const commandBufferRef = { current: "correct horse battery staple" };
|
||
const submitted: string[] = [];
|
||
const executed: string[] = [];
|
||
|
||
const recordedCommand = recordTerminalCommandExecution(
|
||
commandBufferRef.current,
|
||
{
|
||
host: { id: "host-1", label: "Host" },
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
promptLineBreakStateRef: { current: promptState },
|
||
onCommandSubmitted: (command) => submitted.push(command),
|
||
onCommandExecuted: (command) => executed.push(command),
|
||
},
|
||
createFakeTerm("Password: correct horse battery staple") as never,
|
||
{ sensitive: true },
|
||
);
|
||
|
||
assert.equal(recordedCommand, null);
|
||
assert.equal(commandBufferRef.current, "");
|
||
assert.deepEqual(submitted, []);
|
||
assert.deepEqual(executed, []);
|
||
assert.equal(promptState.pendingCommand, false);
|
||
});
|
||
|
||
test("resolveSubmittedShellCommand recovers ↑ history when keystroke buffer is empty (#2191)", () => {
|
||
// Shell history redraws the line remotely; commandBuffer never sees "su -".
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("user@host:~$ su -") as never),
|
||
"su -",
|
||
);
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("user@host:~$ sudo whoami") as never),
|
||
"sudo whoami",
|
||
);
|
||
// Prefer the typed buffer when present
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("ls", createFakeTerm("user@host:~$ ls") as never),
|
||
"ls",
|
||
);
|
||
});
|
||
|
||
test("resolveSubmittedShellCommand strips themed prompt chrome without stale cache (#2191)", () => {
|
||
// Themed decoration must not become the command (#806); peel via reconcile.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("➜ git su -") as never),
|
||
"su -",
|
||
);
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("➜ ~ sudo whoami") as never),
|
||
"sudo whoami",
|
||
);
|
||
// Cached prompt still works when present (including after prompt-changing cd).
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("➜ git su -") as never,
|
||
"➜ git ",
|
||
),
|
||
"su -",
|
||
);
|
||
// Stale cache from before `cd` must not block themed history recall.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("➜ git su -") as never,
|
||
"➜ ~ ",
|
||
),
|
||
"su -",
|
||
);
|
||
// Partial cache after git status appears must peel remaining decoration.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("➜ netcatty git:(main) ✗ su -") as never,
|
||
"➜ netcatty ",
|
||
),
|
||
"su -",
|
||
);
|
||
// Complete Powerline prompts isolate multiword sudo already.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("\uE0B6 root \uE0B0 ~ \uE0B0 sudo whoami") as never,
|
||
),
|
||
"sudo whoami",
|
||
);
|
||
// Empty Enter on themed decoration must not invent a command (#2191 review).
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("➜ git ") as never),
|
||
"",
|
||
);
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("➜ netcatty git:(main) ✗ ") as never,
|
||
),
|
||
"",
|
||
);
|
||
// One-word su at a glyph-only prompt must still arm (#2191 review).
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("❯ su") as never),
|
||
"su",
|
||
);
|
||
// Double-space after glyph must not peel "su" into decoration.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("❯ su -") as never),
|
||
"su -",
|
||
);
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("❯ sudo whoami") as never),
|
||
"sudo whoami",
|
||
);
|
||
// Multi-word themed directory without cache.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("➜ My Project su -") as never),
|
||
"su -",
|
||
);
|
||
// Directory token "su " with trailing pad is chrome, not a command.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("➜ su ") as never),
|
||
"",
|
||
);
|
||
// Absolute path commands on normal prompts are real, not decoration.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("user@host:~$ /bin/ls") as never,
|
||
),
|
||
"/bin/ls",
|
||
);
|
||
// Cursor mid-line: empty buffer does not absorb post-cursor paint (autosuggest).
|
||
// At EOL with empty buffer, the full recalled line is already in userInput.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("user@host:~$ sudo whoami") as never,
|
||
),
|
||
"sudo whoami",
|
||
);
|
||
// zsh-style suggestion after the cursor must not be recorded as input.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"git",
|
||
createFakeTerm("user@host:~$ git status", "user@host:~$ git".length) as never,
|
||
),
|
||
"git",
|
||
);
|
||
// Same-token autosuggest (typed "g", paint "git status") must stay "g".
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"g",
|
||
createFakeTerm("user@host:~$ git status", "user@host:~$ g".length) as never,
|
||
),
|
||
"g",
|
||
);
|
||
// Stale typed prefix after history to privilege command.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"s",
|
||
createFakeTerm("user@host:~$ su -") as never,
|
||
),
|
||
"su -",
|
||
);
|
||
// Double-space glyph + non-privilege history keeps the first word.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("❯ git status") as never),
|
||
"git status",
|
||
);
|
||
// Unicode / punctuated themed directories before su.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("➜ 项目 su -") as never),
|
||
"su -",
|
||
);
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("➜ Project (old) su -") as never),
|
||
"su -",
|
||
);
|
||
// Ordinary commands ending in "su -" must not be peeled to privilege-only.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("❯ echo su -") as never),
|
||
"echo su -",
|
||
);
|
||
// No-space prompt on first history recall (no lastPromptText cache).
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("user@host:~$su -") as never),
|
||
"su -",
|
||
);
|
||
// Themed multi-word dir resolves and still records for arming.
|
||
{
|
||
const commandBufferRef = { current: "" };
|
||
const recorded = recordTerminalCommandExecution("", {
|
||
host: { id: "h", label: "H" },
|
||
sessionId: "s",
|
||
commandBufferRef,
|
||
}, createFakeTerm("➜ My Project su -") as never);
|
||
assert.equal(recorded, "su -");
|
||
}
|
||
// Incomplete remote echo of a longer typed word: trust keystrokes.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"sudo",
|
||
createFakeTerm("user@host:~$ su") as never,
|
||
),
|
||
"sudo",
|
||
);
|
||
// History shortened a multi-word typed buffer to a different short command.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"sudo whoami",
|
||
createFakeTerm("user@host:~$ su") as never,
|
||
),
|
||
"su",
|
||
);
|
||
// No trailing space after $: recover via lastPromptText (#2191 review).
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("user@host:~$su -") as never,
|
||
"user@host:~$",
|
||
),
|
||
"su -",
|
||
);
|
||
// Stale partial cache on empty themed prompt must not record git chrome.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm("➜ netcatty git:(main) ✗ ") as never,
|
||
"➜ netcatty ",
|
||
),
|
||
"",
|
||
);
|
||
// Prefixed themed terminator + cwd token is not a command.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("", createFakeTerm("⚡ ➜ git ") as never),
|
||
"",
|
||
);
|
||
// Stale buffer aligned to mid-line prefix after history recall.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"s",
|
||
createFakeTerm("user@host:~$ su -", "user@host:~$ s".length) as never,
|
||
),
|
||
"su -",
|
||
);
|
||
});
|
||
|
||
test("resolveSubmittedShellCommand prefers live line when history replaces a typed prefix (#2191)", () => {
|
||
// User typed "s" then ↑ recalled "su -" — buffer still holds the stale prefix.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("s", createFakeTerm("user@host:~$ su -") as never),
|
||
"su -",
|
||
);
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("s", createFakeTerm("➜ git su -") as never),
|
||
"su -",
|
||
);
|
||
// Echo lag: buffer ahead of live echo keeps the typed command.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand("su -", createFakeTerm("user@host:~$ su") as never),
|
||
"su -",
|
||
);
|
||
});
|
||
|
||
test("resolveSubmittedShellCommand records full line after mid-line history edit (#2850)", () => {
|
||
// ↑ recalled "systemctl stop firewalld", cursor moved onto "stop", replaced
|
||
// with "start", Enter while still mid-line. Keystroke buffer only saw the
|
||
// replacement token; Enter still submits the whole zle line.
|
||
const line = "user@host:~$ systemctl start firewalld";
|
||
const cursorAfterStart = "user@host:~$ systemctl start".length;
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"start",
|
||
createFakeTerm(line, cursorAfterStart) as never,
|
||
),
|
||
"systemctl start firewalld",
|
||
);
|
||
// Empty buffer + mid-line cursor after recall/edit (no typed suffix).
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"",
|
||
createFakeTerm(line, cursorAfterStart) as never,
|
||
),
|
||
"systemctl start firewalld",
|
||
);
|
||
// Per-token syntax highlighting must not strip the accepted suffix after
|
||
// the cursor (command / arg / path colors differ across the line).
|
||
{
|
||
const prompt = "user@host:~$ ";
|
||
const cmd = "systemctl start firewalld";
|
||
const painted = `${prompt}${cmd}`;
|
||
const cursor = `${prompt}systemctl start`.length;
|
||
const systemctlEnd = `${prompt}systemctl`.length;
|
||
const startEnd = `${prompt}systemctl start`.length;
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"start",
|
||
createFakeTerm(painted, cursor, {
|
||
fgAt: (x) => {
|
||
if (x < prompt.length) return 15;
|
||
if (x < systemctlEnd) return 2; // command
|
||
if (x < startEnd) return 3; // arg
|
||
return 4; // differently colored accepted suffix
|
||
},
|
||
}) as never,
|
||
),
|
||
"systemctl start firewalld",
|
||
);
|
||
}
|
||
// zsh autosuggest paint past the cursor must stay on the typed prefix.
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"git",
|
||
createFakeTerm("user@host:~$ git status", "user@host:~$ git".length) as never,
|
||
),
|
||
"git",
|
||
);
|
||
// ↑ recalled "git", then typed " st" with zsh ghost "atus". Enter submits
|
||
// "git st", not the unaccepted suggestion "git status".
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
" st",
|
||
createFakeTerm(
|
||
"user@host:~$ git status",
|
||
"user@host:~$ git st".length,
|
||
{ ghostFrom: "user@host:~$ git st".length },
|
||
) as never,
|
||
),
|
||
"git st",
|
||
);
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
"st",
|
||
createFakeTerm(
|
||
"user@host:~$ git status",
|
||
"user@host:~$ git st".length,
|
||
{ ghostFrom: "user@host:~$ git st".length },
|
||
) as never,
|
||
),
|
||
"git st",
|
||
);
|
||
// Cross-token autosuggest after history: ↑ "sudo", typed " apt", ghost
|
||
// " upgrade". Enter submits "sudo apt", not "sudo apt upgrade".
|
||
assert.equal(
|
||
resolveSubmittedShellCommand(
|
||
" apt",
|
||
createFakeTerm(
|
||
"user@host:~$ sudo apt upgrade",
|
||
"user@host:~$ sudo apt".length,
|
||
{ ghostFrom: "user@host:~$ sudo apt".length },
|
||
) as never,
|
||
),
|
||
"sudo apt",
|
||
);
|
||
// End-to-end: history callback must receive the full painted command.
|
||
{
|
||
const commandBufferRef = { current: "start" };
|
||
const recorded: string[] = [];
|
||
const recordedCommand = recordTerminalCommandExecution("start", {
|
||
host: { id: "host-1", label: "Host" },
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(cmd) {
|
||
recorded.push(cmd);
|
||
},
|
||
}, createFakeTerm(line, cursorAfterStart) as never);
|
||
assert.equal(recordedCommand, "systemctl start firewalld");
|
||
assert.deepEqual(recorded, ["systemctl start firewalld"]);
|
||
}
|
||
});
|
||
|
||
test("recordTerminalCommandExecution arms su after empty-buffer history recall (#2191)", () => {
|
||
const commandBufferRef = { current: "" };
|
||
const recorded: string[] = [];
|
||
const recordedCommand = recordTerminalCommandExecution("", {
|
||
host: { id: "host-1", label: "Host" },
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(cmd) {
|
||
recorded.push(cmd);
|
||
},
|
||
}, createFakeTerm("user@host:~$ su -") as never);
|
||
|
||
assert.equal(recordedCommand, "su -");
|
||
assert.deepEqual(recorded, ["su -"]);
|
||
assert.equal(commandBufferRef.current, "");
|
||
|
||
// Full Enter path: empty buffer + live line still arms sudo autofill
|
||
const writes: string[] = [];
|
||
const autofill = createSudoPasswordAutofill({
|
||
mode: "picker",
|
||
candidates: [
|
||
{ id: "host", label: "Host", password: "host-secret" },
|
||
{ id: "identity:root", label: "Root", password: "root-secret" },
|
||
],
|
||
write: (d) => writes.push(d),
|
||
onPicker: () => true,
|
||
});
|
||
prepareSudoAutofillInput("\r", recordedCommand, autofill);
|
||
autofill.handleOutput("Password: ");
|
||
assert.equal(autofill.isPickerPending(), true);
|
||
autofill.confirmFill("host");
|
||
assert.deepEqual(writes, ["host-secret\n"]);
|
||
});
|
||
|
||
test("recordTerminalCommandExecution arms su from themed history using lastPromptText (#2191)", () => {
|
||
const promptState = createPromptLineBreakState();
|
||
promptState.lastPromptText = "➜ git ";
|
||
const commandBufferRef = { current: "" };
|
||
const recordedCommand = recordTerminalCommandExecution("", {
|
||
host: { id: "host-1", label: "Host" },
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
promptLineBreakStateRef: { current: promptState },
|
||
}, createFakeTerm("➜ git su -") as never);
|
||
|
||
assert.equal(recordedCommand, "su -");
|
||
|
||
const autofill = createSudoPasswordAutofill({
|
||
mode: "picker",
|
||
candidates: [
|
||
{ id: "host", label: "Host", password: "host-secret" },
|
||
{ id: "identity:root", label: "Root", password: "root-secret" },
|
||
],
|
||
write: () => {},
|
||
onPicker: () => true,
|
||
});
|
||
prepareSudoAutofillInput("\r", recordedCommand, autofill);
|
||
autofill.handleOutput("Password: ");
|
||
assert.equal(autofill.isPickerPending(), true);
|
||
});
|
||
|
||
test("command execution caches the current prompt instead of prompt-like command text", () => {
|
||
const promptState = createPromptLineBreakState();
|
||
const commandBufferRef = { current: "echo > out" };
|
||
|
||
recordTerminalCommandExecution("echo > out", {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
promptLineBreakStateRef: { current: promptState },
|
||
}, createFakeTerm("$ echo > out") as never);
|
||
|
||
assert.equal(promptState.lastPromptText, "$ ");
|
||
assert.equal(promptState.pendingCommand, true);
|
||
});
|
||
|
||
test("command execution does not write interactive program input to shell history", () => {
|
||
const cases = [
|
||
{ lineText: "sftp> get file", command: "get file" },
|
||
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
||
{ lineText: "hive (default)> select 1", command: "select 1" },
|
||
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
||
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
||
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
||
{ lineText: "pry(main)> whereami", command: "whereami" },
|
||
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
||
{ lineText: "SQL> select 1", command: "select 1" },
|
||
{ lineText: "test> db.stats()", command: "db.stats()" },
|
||
{ lineText: "test> db", command: "db" },
|
||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
||
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||
{ lineText: "test> print(1)", command: "print(1)" },
|
||
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
||
{ lineText: "test> rs.status()", command: "rs.status()" },
|
||
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
||
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
||
];
|
||
|
||
for (const { lineText, command } of cases) {
|
||
const commandBufferRef = { current: command };
|
||
const promptState = createPromptLineBreakState();
|
||
const recorded: string[] = [];
|
||
|
||
const recordedCommand = recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
promptLineBreakStateRef: { current: promptState },
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createFakeTerm(lineText) as never);
|
||
|
||
assert.deepEqual(recorded, [], lineText);
|
||
assert.equal(recordedCommand, null, lineText);
|
||
assert.equal(commandBufferRef.current, "", lineText);
|
||
assert.equal(promptState.lastPromptText, "", lineText);
|
||
assert.equal(promptState.pendingCommand, true, lineText);
|
||
}
|
||
});
|
||
|
||
test("command execution does not record interactive input before echo appears", () => {
|
||
const cases = [
|
||
{ lineText: "test> ", command: "rs.status()" },
|
||
{ lineText: "test> ", command: "db" },
|
||
{ lineText: "test> ", command: "const x = 1" },
|
||
{ lineText: "test> ", command: "await db.users.findOne()" },
|
||
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
||
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
||
{ lineText: "rs0 [direct: primary] test> ", command: "db.stats()" },
|
||
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
||
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
||
];
|
||
|
||
for (const { lineText, command } of cases) {
|
||
const commandBufferRef = { current: command };
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createFakeTerm(lineText) as never);
|
||
|
||
assert.deepEqual(recorded, [], lineText);
|
||
assert.equal(commandBufferRef.current, "", lineText);
|
||
}
|
||
});
|
||
|
||
test("command execution publishes submitted commands even when history recording is skipped", () => {
|
||
const commandBufferRef = { current: "cd /srv/app" };
|
||
const history: string[] = [];
|
||
const submitted: string[] = [];
|
||
|
||
const recordedCommand = recordTerminalCommandExecution("cd /srv/app", {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
history.push(nextCommand);
|
||
},
|
||
onCommandSubmitted(nextCommand) {
|
||
submitted.push(nextCommand);
|
||
},
|
||
}, createFakeTerm("sftp> cd /srv/app") as never);
|
||
|
||
assert.deepEqual(history, []);
|
||
assert.deepEqual(submitted, ["cd /srv/app"]);
|
||
assert.equal(recordedCommand, null);
|
||
assert.equal(commandBufferRef.current, "");
|
||
});
|
||
|
||
test("command execution does not record wrapped interactive program input", () => {
|
||
const cases = [
|
||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||
{ rows: ["test> d", "b"], command: "db" },
|
||
];
|
||
|
||
for (const { rows, command } of cases) {
|
||
const commandBufferRef = { current: command };
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||
|
||
assert.deepEqual(recorded, [], rows[0]);
|
||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||
}
|
||
});
|
||
|
||
test("command execution records non-Mongo-looking default-name greater-than prompts", () => {
|
||
const prompts = ["test> ", "admin> ", "local> ", "config> "];
|
||
const commands = ["deploy", "exit", "help", "show dbs"];
|
||
|
||
for (const prompt of prompts) {
|
||
for (const command of commands) {
|
||
const commandBufferRef = { current: command };
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||
|
||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("command execution records wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
||
const cases = [
|
||
{ rows: ["test> hel", "p"], command: "help" },
|
||
{ rows: ["test> show ", "dbs"], command: "show dbs" },
|
||
{ rows: ["admin> ex", "it"], command: "exit" },
|
||
{ rows: ["local> dep", "loy"], command: "deploy" },
|
||
];
|
||
|
||
for (const { rows, command } of cases) {
|
||
const commandBufferRef = { current: command };
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||
|
||
assert.deepEqual(recorded, [command], rows[0]);
|
||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||
}
|
||
});
|
||
|
||
test("command execution records short commands when standard prompt echo lags by one character", () => {
|
||
const cases = [
|
||
{ lineText: "$ l", command: "ls" },
|
||
{ lineText: "$ c", command: "cd" },
|
||
{ lineText: "prod-web> l", command: "ls" },
|
||
{ lineText: "prod> l", command: "ls" },
|
||
{ lineText: "prod.web> l", command: "ls" },
|
||
{ lineText: "user@host:~$ l", command: "ls" },
|
||
{ lineText: "[user@host ~]$ l", command: "ls" },
|
||
{ lineText: "➜ netcatty $ l", command: "ls" },
|
||
{ lineText: "➜ git l", command: "ls" },
|
||
{ lineText: "➜ git np", command: "npm" },
|
||
];
|
||
|
||
for (const { lineText, command } of cases) {
|
||
const commandBufferRef = { current: command };
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createFakeTerm(lineText) as never);
|
||
|
||
assert.deepEqual(recorded, [command], lineText);
|
||
assert.equal(commandBufferRef.current, "", lineText);
|
||
}
|
||
});
|
||
|
||
test("command execution records direct sends from themed bare directory prompts", () => {
|
||
const cases = [
|
||
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
||
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
||
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
||
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
||
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
||
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
||
];
|
||
|
||
for (const { lineText, command, promptText } of cases) {
|
||
const commandBufferRef = { current: command };
|
||
const promptState = createPromptLineBreakState();
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
promptLineBreakStateRef: { current: promptState },
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createFakeTerm(lineText) as never);
|
||
|
||
assert.deepEqual(recorded, [command], lineText);
|
||
assert.equal(commandBufferRef.current, "", lineText);
|
||
assert.equal(promptState.lastPromptText, promptText, lineText);
|
||
assert.equal(promptState.pendingCommand, true, lineText);
|
||
}
|
||
});
|
||
|
||
test("command execution still records host-style greater-than prompts", () => {
|
||
const prompts = [
|
||
"prod-web> ",
|
||
"prod> ",
|
||
"prod.web> ",
|
||
"server> ",
|
||
"staging> ",
|
||
"webdb> ",
|
||
"prod.db> ",
|
||
];
|
||
const commands = ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)", "db.stats()"];
|
||
|
||
for (const prompt of prompts) {
|
||
for (const command of commands) {
|
||
const commandBufferRef = { current: command };
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||
|
||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("command execution records direct sends from host-style greater-than prompts", () => {
|
||
const cases = [
|
||
{ lineText: "server> ", command: "exit" },
|
||
{ lineText: "staging> ", command: "show dbs" },
|
||
{ lineText: "server> ", command: "db.stats()" },
|
||
{ lineText: "webdb> ", command: "deploy" },
|
||
{ lineText: "prod.db> ", command: "deploy" },
|
||
{ lineText: "test> ", command: "deploy" },
|
||
{ lineText: "test> ", command: "exit" },
|
||
{ lineText: "test> ", command: "help" },
|
||
{ lineText: "test> ", command: "show dbs" },
|
||
{ lineText: "admin> ", command: "deploy" },
|
||
];
|
||
|
||
for (const { lineText, command } of cases) {
|
||
const commandBufferRef = { current: command };
|
||
const recorded: string[] = [];
|
||
|
||
recordTerminalCommandExecution(command, {
|
||
host: {
|
||
id: "host-1",
|
||
label: "Host",
|
||
},
|
||
sessionId: "session-1",
|
||
commandBufferRef,
|
||
onCommandExecuted(nextCommand) {
|
||
recorded.push(nextCommand);
|
||
},
|
||
}, createFakeTerm(lineText) as never);
|
||
|
||
assert.deepEqual(recorded, [command], lineText);
|
||
assert.equal(commandBufferRef.current, "", lineText);
|
||
}
|
||
});
|
||
|
||
test("alternate-screen history preview stays selectable and copyable", async () => {
|
||
const { readFileSync } = await import("node:fs");
|
||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||
|
||
assert.match(source, /pointerEvents:\s*"auto"/);
|
||
assert.match(source, /userSelect:\s*"text"/);
|
||
assert.match(source, /shouldHideHistoryPreviewOnMouseDown/);
|
||
assert.match(source, /shouldKeepHistoryPreviewOnKey/);
|
||
assert.match(source, /previewSelection \|\| getTerminalSelectionForClipboard/);
|
||
assert.match(source, /hasCopyableSelection = term\.hasSelection\(\) \|\| Boolean\(previewSelection\)/);
|
||
assert.match(source, /shouldUseUrgentTerminalInterrupt\(e, \{ hasSelection: hasCopyableSelection \}\)/);
|
||
assert.match(source, /isHistoryPreviewDismissClick/);
|
||
assert.match(source, /HISTORY_PREVIEW_WRAP_ATTR/);
|
||
assert.match(source, /HISTORY_PREVIEW_HIDE_EVENT/);
|
||
assert.match(source, /document\.addEventListener\("copy", handlePreviewNativeCopy, true\)/);
|
||
});
|
||
|
||
test("alternate-screen history preview falls back to the captured session output", async () => {
|
||
const { readFileSync } = await import("node:fs");
|
||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||
|
||
// The fallback only applies while the app owns the alternate buffer and the
|
||
// normal buffer has no scrollback above the viewport (#2516).
|
||
assert.match(source, /bufferHasPreviewScrollback\(normalBuffer\)/);
|
||
assert.match(source, /outputHistory\.getPreviewRowCount\(term\.cols\)/);
|
||
assert.match(source, /outputHistory\.getPreviewRows\(\{/);
|
||
assert.match(source, /nextOutputHistoryPreviewTop\(/);
|
||
});
|
||
|
||
test("xterm scroll and line-feed reposition the autocomplete popup (#3061)", async () => {
|
||
const { readFileSync } = await import("node:fs");
|
||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||
const terminalSource = readFileSync(new URL("../../Terminal.tsx", import.meta.url), "utf8");
|
||
const effectsSource = readFileSync(new URL("../useTerminalEffects.ts", import.meta.url), "utf8");
|
||
assert.match(source, /onAutocompleteReposition\?: \(\) => void/);
|
||
assert.match(source, /term\.onScroll\(scheduleAutocompleteReposition\)/);
|
||
assert.match(source, /term\.onLineFeed\?\.\(scheduleAutocompleteReposition\)/);
|
||
assert.match(source, /if \(autocompleteRepositionFrame\) return;/);
|
||
assert.match(source, /cancelAutocompleteReposition\(\)/);
|
||
assert.match(terminalSource, /onAutocompleteReposition: \(\) => autocompleteRepositionRef\.current\?\.\(\)/);
|
||
assert.match(effectsSource, /onAutocompleteReposition: \(\) => autocompleteRepositionRef\.current\?\.\(\)/);
|
||
});
|
||
|
||
test("multi-character plain text goes out as per-character writes (#3077)", async () => {
|
||
const { readFileSync } = await import("node:fs");
|
||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||
|
||
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
|
||
// drop multi-character chunks, so IME commits and short raw pastes must be
|
||
// sent one character per write instead of one write per commit.
|
||
assert.match(source, /perCharacterWrites\?: boolean/);
|
||
assert.match(
|
||
source,
|
||
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
|
||
);
|
||
// Every chunk carries the same write options as the original single write.
|
||
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
|
||
assert.ok(writeLoopIdx >= 0);
|
||
const writeLoop = source.slice(writeLoopIdx, writeLoopIdx + 320);
|
||
assert.match(writeLoop, /for \(const chunk of/);
|
||
assert.match(writeLoop, /writeToSession\(id, chunk, \{ sensitive \}\)/);
|
||
|
||
// IME commits split the committed glyph, not the Kitty CSI-u encoding.
|
||
assert.match(
|
||
source,
|
||
/handleTerminalInputData\(text, \{ perCharacterWrites: shouldSplitImeTextInputForWire\(text\) \}\)/,
|
||
);
|
||
// Raw onData (unbracketed paste / committed composition) uses the capped
|
||
// raw-paste rule; the composition fallback uses the uncapped IME rule.
|
||
assert.match(
|
||
source,
|
||
/handleTerminalInputData\(sanitizedRawData, \{\s*perCharacterWrites: shouldSplitRawPasteInputForWire\(sanitizedRawData\),?\s*\}\)/,
|
||
);
|
||
assert.match(
|
||
source,
|
||
/handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\)/,
|
||
);
|
||
// Negotiated Kitty paths keep their single write: CSI-u associated text and
|
||
// forwarded key sequences must never be split across writes.
|
||
const compositionIdx = source.indexOf(
|
||
"handleTerminalInputData(encoded, { source: \"kitty\" })",
|
||
);
|
||
assert.ok(compositionIdx >= 0);
|
||
assert.doesNotMatch(source.slice(0, compositionIdx + 60), /handleTerminalInputData\(encoded, \{ perCharacterWrites/);
|
||
assert.doesNotMatch(source, /handleTerminalInputData\(sequence, \{ perCharacterWrites/);
|
||
// Bookkeeping sees the original string exactly once: the command buffer and
|
||
// broadcast keep using the unsplit payload.
|
||
const writeSite = source.slice(writeLoopIdx - 600, writeLoopIdx + 320);
|
||
assert.match(writeSite, /ctx\.onOutputTriggerUserInputRef\?\.current\?\.\(outData\)/);
|
||
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId\)/);
|
||
});
|