mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34836d0810 | ||
|
|
8c50c4a439 | ||
|
|
0fac80202e |
@@ -0,0 +1,75 @@
|
||||
// Open the Android app from this checkout with agent-device and focus a shell terminal.
|
||||
// node packages/app/e2e/mobile/terminal-input/hardware-enter.android.mjs <serial> <daemon-home> <terminal-id>
|
||||
// Requires a local daemon, Node.js in the terminal, and the native terminal input focused.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , serial, home, terminalId] = process.argv;
|
||||
assert(serial && home && terminalId, "Pass adb serial, daemon home, terminal ID");
|
||||
const cli = fileURLToPath(new URL("../../../../cli/dist/index.js", import.meta.url));
|
||||
function terminal(...args) {
|
||||
return execFileSync(process.execPath, [cli, "--home", home, "terminal", ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
function key(code) {
|
||||
execFileSync("adb", ["-s", serial, "shell", "input", "keyevent", code], { timeout: 10_000 });
|
||||
}
|
||||
function capture() {
|
||||
return JSON.parse(terminal("capture", terminalId, "--json")).lines.map((line) => line.trim());
|
||||
}
|
||||
async function waitForLine(line) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
do {
|
||||
if (capture().includes(line)) return;
|
||||
await delay(100);
|
||||
} while (Date.now() < deadline);
|
||||
assert.fail(`Terminal did not produce ${line}`);
|
||||
}
|
||||
|
||||
// Inspect bytes at the PTY rather than counting prompts, which depend on the shell theme.
|
||||
// Keep reading after Enter to catch its key-up event and to check the following character.
|
||||
const reader = [
|
||||
"process.stdin.setRawMode(true)",
|
||||
"let hex = ''",
|
||||
"console.log('REA' + 'DY')",
|
||||
"process.stdin.on('data', data => {",
|
||||
"if (data.includes(3)) { process.stdin.setRawMode(false); process.exit(0); }",
|
||||
"hex += data.toString('hex'); console.log('HE' + 'X:' + hex);",
|
||||
"})",
|
||||
].join(";");
|
||||
const quotedReader = "'" + reader.replaceAll("'", "'\\''") + "'";
|
||||
const command = `node -e ${quotedReader}`;
|
||||
const failures = [];
|
||||
for (const enter of ["KEYCODE_ENTER", "KEYCODE_NUMPAD_ENTER"]) {
|
||||
terminal("send-keys", terminalId, "--literal", command);
|
||||
terminal("send-keys", terminalId, "Enter");
|
||||
try {
|
||||
await waitForLine("READY");
|
||||
key(enter);
|
||||
await waitForLine("HEX:0d");
|
||||
// The second event is native key-up, so allow it to arrive before the assertion.
|
||||
await delay(500);
|
||||
assert(!capture().includes("HEX:0d0d"), `${enter} submitted twice`);
|
||||
key("KEYCODE_X");
|
||||
await waitForLine("HEX:0d78");
|
||||
console.log(`PASS: ${enter} sends one carriage return and preserves the next character`);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
console.error(error.message);
|
||||
console.error(
|
||||
"Observed:",
|
||||
capture().filter((line) => line.startsWith("HEX:")),
|
||||
);
|
||||
} finally {
|
||||
terminal("send-keys", terminalId, "C-c");
|
||||
await delay(300);
|
||||
terminal("send-keys", terminalId, "--literal", "clear");
|
||||
terminal("send-keys", terminalId, "Enter");
|
||||
await delay(300);
|
||||
}
|
||||
}
|
||||
assert.equal(failures.length, 0, "Hardware Enter regression");
|
||||
@@ -40,7 +40,17 @@ describe("native terminal typed input", () => {
|
||||
expect(input.receiveKeyPress("p")).toEqual({ data: "p", shouldClear: false });
|
||||
expect(input.receiveKeyPress("w")).toEqual({ data: "w", shouldClear: false });
|
||||
expect(input.receiveKeyPress("d")).toEqual({ data: "d", shouldClear: false });
|
||||
expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true });
|
||||
expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true });
|
||||
});
|
||||
|
||||
it("waits for native submit instead of submitting Return keypresses", () => {
|
||||
const input = createTerminalTextInputState();
|
||||
input.receiveTextChange("한글");
|
||||
|
||||
for (const key of ["Enter", "Return", "return"]) {
|
||||
expect(input.receiveKeyPress(key)).toEqual({ data: "", shouldClear: false });
|
||||
}
|
||||
expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true });
|
||||
});
|
||||
|
||||
it("does not duplicate printable keypresses when the native text change also arrives", () => {
|
||||
@@ -97,7 +107,7 @@ describe("native terminal typed input", () => {
|
||||
for (const key of "echo hello") {
|
||||
input.receiveKeyPress(key);
|
||||
}
|
||||
expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true });
|
||||
expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true });
|
||||
|
||||
input.reset();
|
||||
|
||||
@@ -105,17 +115,37 @@ describe("native terminal typed input", () => {
|
||||
expect(input.receiveTextChange("y")).toEqual({ data: "y", shouldClear: false });
|
||||
});
|
||||
|
||||
it("ignores late newline text change after Return keypress submits", () => {
|
||||
it("clears a late newline text change without submitting the command again", () => {
|
||||
const input = createTerminalTextInputState();
|
||||
|
||||
for (const key of "echo hello") {
|
||||
input.receiveKeyPress(key);
|
||||
}
|
||||
expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true });
|
||||
expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true });
|
||||
|
||||
input.reset();
|
||||
|
||||
expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: false });
|
||||
expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: true });
|
||||
});
|
||||
|
||||
it("clears a late submit update before the next Hangul composition starts", () => {
|
||||
const input = createTerminalTextInputState();
|
||||
input.receiveTextChange("echo hello");
|
||||
expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true });
|
||||
input.reset();
|
||||
|
||||
// Gboard sends this after the Enter handler clears the native input.
|
||||
// Leaving it in the editor makes the next ㅎ arrive as "echo hello\nㅎ",
|
||||
// which is discarded as multiline input instead of starting 한글.
|
||||
expect(input.receiveTextChange("echo hello\n")).toEqual({ data: "", shouldClear: true });
|
||||
input.reset();
|
||||
|
||||
expect(input.receiveTextChange("ㅎ").data).toBe("ㅎ");
|
||||
expect(input.receiveTextChange("하").data).toBe("\x7f하");
|
||||
expect(input.receiveTextChange("한").data).toBe("\x7f한");
|
||||
expect(input.receiveTextChange("한ㄱ").data).toBe("ㄱ");
|
||||
expect(input.receiveTextChange("한그").data).toBe("\x7f그");
|
||||
expect(input.receiveTextChange("한글").data).toBe("\x7f글");
|
||||
});
|
||||
|
||||
it("accepts the same long keyboard clipboard item twice", () => {
|
||||
@@ -133,9 +163,7 @@ describe("native terminal typed input", () => {
|
||||
const input = createTerminalTextInputState();
|
||||
|
||||
expect(input.receiveKeyPress("Backspace")).toEqual({ data: "\x7f", shouldClear: false });
|
||||
expect(input.receiveKeyPress("Enter")).toEqual({ data: "\r", shouldClear: true });
|
||||
expect(input.receiveKeyPress("Return")).toEqual({ data: "\r", shouldClear: true });
|
||||
expect(input.receiveKeyPress("return")).toEqual({ data: "\r", shouldClear: true });
|
||||
expect(input.receiveSubmit()).toEqual({ data: "\r", shouldClear: true });
|
||||
expect(input.receiveTextChange("hello\n")).toEqual({ data: "", shouldClear: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface TerminalTextInputChange {
|
||||
|
||||
export interface TerminalTextInputState {
|
||||
receiveKeyPress: (key: string) => TerminalTextInputChange;
|
||||
receiveSubmit: () => TerminalTextInputChange;
|
||||
receiveTextChange: (text: string) => TerminalTextInputChange;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -104,7 +105,6 @@ export function resolveTerminalInputFocusRequest(input: {
|
||||
|
||||
export function createTerminalTextInputState(): TerminalTextInputState {
|
||||
let previousText = "";
|
||||
let submittedText: string | null = null;
|
||||
// A swallowed replacement leaves the terminal holding text the buffer no
|
||||
// longer describes. A composition rubout deletes from the terminal, so it
|
||||
// would delete whatever is actually sitting there rather than the character
|
||||
@@ -124,33 +124,24 @@ export function createTerminalTextInputState(): TerminalTextInputState {
|
||||
}
|
||||
return { data: "\x7f", shouldClear: false };
|
||||
}
|
||||
if (key === "Enter" || key === "Return" || key === "return") {
|
||||
submittedText = previousText;
|
||||
return { data: "\r", shouldClear: true };
|
||||
}
|
||||
if (isPrintableKey(key)) {
|
||||
previousText += key;
|
||||
return { data: key, shouldClear: false };
|
||||
}
|
||||
return { data: "", shouldClear: false };
|
||||
},
|
||||
receiveSubmit(): TerminalTextInputChange {
|
||||
return { data: "\r", shouldClear: true };
|
||||
},
|
||||
receiveTextChange(text: string): TerminalTextInputChange {
|
||||
if (submittedText !== null) {
|
||||
const lateSubmitText = `${submittedText}\n`;
|
||||
submittedText = null;
|
||||
if (text === lateSubmitText) {
|
||||
previousText = "";
|
||||
replacementDesynced = false;
|
||||
return { data: "", shouldClear: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (text.length === 0) {
|
||||
previousText = "";
|
||||
return { data: "", shouldClear: false };
|
||||
}
|
||||
|
||||
if (text.includes("\n") || text.includes("\r")) {
|
||||
// A late submit update can restore the native buffer after Enter
|
||||
// cleared it. Clear it again before the IME starts its next word.
|
||||
previousText = "";
|
||||
replacementDesynced = false;
|
||||
return { data: "", shouldClear: true };
|
||||
@@ -289,6 +280,12 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
[inputState, onInput, resetNativeInput],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const change = inputState.receiveSubmit();
|
||||
onInput?.(change.data);
|
||||
resetNativeInput();
|
||||
}, [inputState, onInput, resetNativeInput]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
const change = inputState.receiveKeyPress(event.nativeEvent.key);
|
||||
@@ -325,8 +322,10 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleSubmit}
|
||||
showSoftInputOnFocus={true}
|
||||
spellCheck={false}
|
||||
submitBehavior="submit"
|
||||
style={inputStyle}
|
||||
testID="terminal-native-input"
|
||||
textContentType="none"
|
||||
|
||||
Reference in New Issue
Block a user