Files
Netcatty/components/terminal/runtime/shiftEnterText.test.ts
T

337 lines
15 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
decodeTerminalTextEscapes,
doesKittyEncodingPreserveShiftEnter,
getShiftEnterSubmittedInput,
nativeKittyKeyboardPreservesShiftEnter,
isBareShiftEnterLineEnding,
isShiftEnterLineContinuationText,
resolveShiftEnterText,
resolveWin32ForcedShiftEnterText,
SHIFT_ENTER_CSI_U_SEQUENCE,
shouldClaimShiftEnterForText,
shouldSendShiftEnterText,
} from "./shiftEnterText";
const keyEvent = (overrides: Partial<KeyboardEvent> = {}) => ({
type: "keydown",
key: "Enter",
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
isComposing: false,
...overrides,
}) as KeyboardEvent;
test("shift enter text defaults to the legacy Alt+Enter encoding (ESC + CR)", () => {
assert.equal(resolveShiftEnterText(), "\u001b\r");
});
test("shift enter text decodes newline, tab, carriage return, and backslash escapes", () => {
assert.equal(
decodeTerminalTextEscapes("line\\nnext\\tindent\\rreturn\\\\slash"),
"line\nnext\tindent\rreturn\\slash",
);
});
test("shift enter text decodes the escape escape as ESC", () => {
assert.equal(decodeTerminalTextEscapes("\\e\r"), "\u001b\r");
assert.equal(decodeTerminalTextEscapes("\\e[13;2u"), SHIFT_ENTER_CSI_U_SEQUENCE);
});
test("shift enter text can represent Tabby-style shell continuation", () => {
assert.equal(decodeTerminalTextEscapes(" \\\\\\n"), " \\\n");
});
test("shift enter continuation detection only matches backslash-newline endings", () => {
assert.equal(isShiftEnterLineContinuationText(" \\\n"), true);
assert.equal(isShiftEnterLineContinuationText(" \\\r\n"), true);
assert.equal(isShiftEnterLineContinuationText(" \\\r"), true);
assert.equal(isShiftEnterLineContinuationText("foo\n"), false);
assert.equal(isShiftEnterLineContinuationText("\r\n"), false);
});
test("shift enter submitted input detects single command text with a line ending", () => {
assert.deepEqual(getShiftEnterSubmittedInput("\n"), {
text: "",
lineEnding: "\n",
});
assert.deepEqual(getShiftEnterSubmittedInput("\r\n"), {
text: "",
lineEnding: "\r\n",
});
assert.deepEqual(getShiftEnterSubmittedInput("sudo whoami\n"), {
text: "sudo whoami",
lineEnding: "\n",
});
assert.equal(getShiftEnterSubmittedInput(" \\\n"), null);
assert.equal(getShiftEnterSubmittedInput("foo\nbar\n"), null);
});
test("Alt+Enter ESC+CR is a non-submitting logical line break", () => {
assert.equal(getShiftEnterSubmittedInput("\u001b\r"), null);
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r"), null);
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r\n"), null);
assert.deepEqual(getShiftEnterSubmittedInput("echo hi\r"), {
text: "echo hi",
lineEnding: "\r",
});
assert.deepEqual(getShiftEnterSubmittedInput("\u001b\n"), {
text: "\u001b",
lineEnding: "\n",
});
});
test("shift enter handler only matches plain Shift+Enter keydown", () => {
assert.equal(shouldSendShiftEnterText(keyEvent()), true);
assert.equal(shouldSendShiftEnterText(keyEvent({ type: "keyup" })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ key: "NumpadEnter" })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ ctrlKey: true })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ metaKey: true })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ altKey: true })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ shiftKey: false })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ isComposing: true })), false);
});
test("shift enter handler respects the terminal setting toggle", () => {
assert.equal(
shouldSendShiftEnterText(keyEvent(), { shiftEnterNewlineEnabled: false }),
false,
);
});
test("bare line-ending Shift+Enter text is detected for TUI passthrough", () => {
assert.equal(isBareShiftEnterLineEnding("\n"), true);
assert.equal(isBareShiftEnterLineEnding("\r"), true);
assert.equal(isBareShiftEnterLineEnding("\r\n"), true);
assert.equal(isBareShiftEnterLineEnding(" \\\n"), false);
assert.equal(isBareShiftEnterLineEnding("sudo whoami\n"), false);
assert.equal(isBareShiftEnterLineEnding(""), false);
});
test("Kitty encodings that collapse Shift+Enter to CR/LF do not preserve the chord", () => {
assert.equal(doesKittyEncodingPreserveShiftEnter(null), false);
assert.equal(doesKittyEncodingPreserveShiftEnter(""), false);
assert.equal(doesKittyEncodingPreserveShiftEnter("\r"), false);
assert.equal(doesKittyEncodingPreserveShiftEnter("\n"), false);
assert.equal(doesKittyEncodingPreserveShiftEnter(SHIFT_ENTER_CSI_U_SEQUENCE), true);
});
test("force-text opt-out only overrides the Win32 hand-off", () => {
const forced = {
shiftEnterNewlineEnabled: true,
shiftEnterForceText: true,
};
const notForced = { ...forced, shiftEnterForceText: false };
// Regression: a negotiated Kitty encoding that preserves Shift+Enter (an
// SSH/TUI session outside ConPTY) must keep that encoding even when the
// Win32 opt-out is enabled.
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), forced, {
win32InputMode: false,
kittySequenceForKeyDown: SHIFT_ENTER_CSI_U_SEQUENCE,
}),
false,
);
// Outside Win32 mode the existing collapse fallback is unchanged by the
// opt-out in either direction.
for (const settings of [forced, notForced]) {
for (const kittySequenceForKeyDown of ["\r", null]) {
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), settings, {
win32InputMode: false,
kittySequenceForKeyDown,
}),
true,
);
}
}
// Win32 input mode keeps the native record unless the user opted out.
const win32 = { win32InputMode: true, kittySequenceForKeyDown: null };
assert.equal(shouldClaimShiftEnterForText(keyEvent(), notForced, win32), false);
assert.equal(shouldClaimShiftEnterForText(keyEvent(), forced, win32), true);
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), { ...forced, shiftEnterNewlineEnabled: false }, win32),
false,
);
assert.equal(shouldClaimShiftEnterForText(keyEvent({ ctrlKey: true }), forced, win32), false);
});
test("native kitty Shift+Enter steps aside only for CSI-u flag sets", () => {
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0), false);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0001), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0010), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b1000), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0100), false);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b10000), false);
const settings = { shiftEnterNewlineEnabled: true, shiftEnterForceText: false };
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), settings, {
win32InputMode: false,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: true,
}),
false,
);
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), settings, {
win32InputMode: false,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: false,
}),
true,
);
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), { ...settings, shiftEnterForceText: true }, {
win32InputMode: true,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: true,
}),
true,
);
});
test("Win32 forced Shift+Enter text requires the opt-out and non-empty text", () => {
const settings = {
shiftEnterNewlineEnabled: true,
shiftEnterNewlineText: "\\e\\r",
shiftEnterForceText: true,
};
assert.equal(resolveWin32ForcedShiftEnterText(keyEvent(), settings), "\u001b\r");
assert.equal(
resolveWin32ForcedShiftEnterText(keyEvent(), { ...settings, shiftEnterForceText: false }),
null,
);
assert.equal(
resolveWin32ForcedShiftEnterText(keyEvent(), { ...settings, shiftEnterNewlineText: "" }),
null,
);
assert.equal(resolveWin32ForcedShiftEnterText(keyEvent({ type: "keyup" }), settings), null);
});
test("runtime routes Shift+Enter text through the shared input handler", () => {
const source = readFileSync(
new URL("./createXTermRuntime.ts", import.meta.url),
"utf8",
);
assert.match(
source,
/const handleTerminalInputData = \(\s+data: string,\s+options\?: \{\s+source\?: "terminal" \| "shift-enter" \| "kitty";\s+[\s\S]*?skipBroadcast\?: boolean;\s+[\s\S]*?perCharacterWrites\?: boolean;\s+\},\s+\) => \{/s,
);
// Remap when Kitty encoding does not preserve Shift+Enter (not merely flags===0),
// skipping the ConPTY Win32 hand-off only when the force-text opt-out is on.
assert.match(
source,
/if \(\s*shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current, \{\s*win32InputMode: term\.modes\.win32InputMode,\s*kittySequenceForKeyDown,\s*nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),\s*\}\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
);
// The gate above only decides whether Shift+Enter is *eligible* for send-text.
// It must then claim the keydown only when resolveShiftEnterText() returned
// actual text: preventDefault/write/broadcast/return false all belong inside
// `if (shiftEnterText)`. Empty configured text falls through to the Win32 and
// Kitty paths below, so the press still yields a plain Enter instead of being
// consumed with nothing written — which looks like a dead feature.
const gateBlock = source.slice(
source.search(/shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current/),
source.indexOf("if (kittySequenceForKeyDown) {"),
);
const interceptionStart = gateBlock.indexOf("if (shiftEnterText) {");
assert.notEqual(interceptionStart, -1);
const interception = gateBlock.slice(interceptionStart);
assert.doesNotMatch(
gateBlock.slice(0, interceptionStart),
/e\.preventDefault\(\)|return false/,
);
assert.match(interception, /^if \(shiftEnterText\) \{\s*e\.preventDefault\(\);/);
assert.match(interception, /return false;\s*\}\s*\}\s*\}\s*$/);
assert.match(
source,
/shiftEnterForceText opts out of the Win32 hand-off for runtimes that/s,
);
assert.match(
source,
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*kittyKeyboard: true,\s*\},/s,
);
assert.match(
source,
/const kittySequenceForKeyDown =\s*!term\.modes\.win32InputMode &&\s*kittyKeyboardProtocolEnabled/s,
);
assert.match(
source,
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?const sourceSensitivePrompt = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}, false, undefined, sourceSensitivePrompt \? \{ sourceSensitive: true \} : undefined\);/s,
);
assert.match(
source,
/const canBroadcastInput = shouldBroadcastDuringSensitivePrompt\(\{[\s\S]*?broadcastPasswordBypass: ctx\.broadcastPasswordBypassRef\?\.current === true && options\?\.sensitive !== true,[\s\S]*?\}\) &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
);
assert.match(
source,
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),[\s\S]*?\}\),/s,
);
assert.doesNotMatch(source, /resolveShiftEnterText\([\s\S]*?alternateScreen:/s);
assert.match(source, /getShiftEnterSubmittedInput\(logicalData\)/);
assert.match(source, /inputSource !== "shift-enter"/);
assert.match(
source,
/if \(shouldSendShiftEnterText\(e, ctx\.terminalSettingsRef\.current\)\) \{\s+sudoAutofill\.cancelHint\(\);/s,
);
assert.match(
source,
/term\.onData\(\(data\) => \{[\s\S]*const sanitizedRawData = sanitizeTerminalInput\(data\);[\s\S]*handleTerminalInputData\(sanitizedRawData, \{\s*perCharacterWrites: shouldSplitRawPasteInputForWire\(sanitizedRawData\),?\s*\}\);\s+\}\);/,
);
assert.match(
source,
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = term\.modes\.win32InputMode\s*\? null\s*:\s*encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
);
assert.match(
source,
/if \(term\.modes\.win32InputMode\) \{[\s\S]*win32InputModePendingEvent = \{\s*event: normalizedKittyEvent,\s*logicalData: resolveWin32InputLogicalData\(/s,
);
assert.match(
source,
/const broadcastInput: KittyKeyboardBroadcastInput = \{\s*kind: "win32",\s*data,\s*event: win32Input\.event,[\s\S]*handleTerminalInputData\(data, \{\s*logicalData: win32Input\.logicalData,\s*skipBroadcast: true,/s,
);
assert.match(
source,
/const hasForwardedWin32KeyDown = win32InputModeForwardedKeys\.delete\(identity\);[\s\S]*if \(term\.modes\.win32InputMode\) \{[\s\S]*releaseForwardedKittyPress\([\s\S]*if \(!hasForwardedWin32KeyDown\) \{[\s\S]*win32InputModePendingEvent = null;[\s\S]*return false;[\s\S]*logicalData: null,[\s\S]*return true;[\s\S]*releaseForwardedKittyPress/s,
);
assert.match(
source,
/if \(win32Input\.event\.type === "keydown"\) \{\s*upsertKittyKeyboardForwardedPress\(\s*win32InputModeForwardedKeys,\s*win32Input\.event\.code \|\| win32Input\.event\.key,\s*win32Input\.event,\s*\[\],/s,
);
assert.match(
source,
/if \(term\.modes\.win32InputMode\) \{[\s\S]*flushKittyKeyboardBroadcastReleases\(\s*win32InputModeForwardedKeys,[\s\S]*writeWin32InputModeEvent\(input\.event, null\);/s,
);
assert.match(source, /win32InputMode: term\.modes\.win32InputMode,/);
assert.match(
source,
/const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>\(\);/,
);
assert.match(
source,
/const forwardedPress = win32BroadcastForwardedKeys\.get\(identity\);[\s\S]*broadcastKittyInput\(\s*broadcastInput,\s*true,\s*forwardedPress\.targetSessionIds,/s,
);
assert.match(
source,
/upsertKittyKeyboardForwardedPress\(\s*win32BroadcastForwardedKeys,[\s\S]*forwarded\.targetSessionIds,/s,
);
assert.match(source, /ctx\.container\.addEventListener\("input", markKittyTextInput, true\);/);
assert.match(
source,
/if \(shouldMarkKittyTextInputEvent\(event\)\) markKittyCompositionPending\(true\);/,
);
assert.match(
source,
/flushKittyKeyboardBroadcastReleases\(\s+kittyForwardedKeys,[\s\S]*encodeKittyKeyEvent\(kittyKeyboardMode, input\.event\)[\s\S]*handleTerminalInputData\(sequence, \{ source: "kitty" \}\)/,
);
assert.doesNotMatch(source, /writeToSession\(id, textToSend\)/);
});