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\)/);
|
|||
|
|
});
|