Files
Netcatty/electron/bridges/terminalBridge.inputEncoding.test.cjs
陈大猫 0b8206aecb fix(terminal): encode input with the session charset (#1216) (#1222)
The terminal output path decodes remote bytes with an iconv decoder built
from the user's configured charset (GB18030, etc.), but the input path
serialized keystrokes as UTF-8 unconditionally. On a non-UTF-8 device that
made input and output asymmetric: with GB18030 selected the device's command
completion decoded correctly while manually typed Chinese went out as UTF-8
bytes and showed up garbled (and the reverse with UTF-8 selected).

Make input symmetric with output:

- Add electron/bridges/terminalEncoding.cjs as the single source of truth for
  charset normalization plus encodeTerminalInput(), which encodes a keystroke
  string with the same iconv charset (returning the string untouched for UTF-8
  and for unset/unknown encodings so the transport's native serialization and
  the Mosh/local-PTY paths are unchanged). ASCII control bytes and CSI escape
  sequences pass through byte-for-byte under GB18030.
- terminalBridge.writeToSession() now encodes outgoing data via
  encodeTerminalInput(session.encoding) before writing to the SSH stream,
  telnet socket, or serial port. Telnet IAC 0xFF escaping still runs on the
  encoded bytes.
- session.encoding (the input charset) is now kept in lock-step with the
  output decoder everywhere the decoder is configured:
    * Telnet/serial already stored session.encoding; writeToSession now reads
      it for input too.
    * SSH mirrors session.encoding wherever it sets sessionEncodings: the
      GB-variant pre-seed at session start and the runtime setEncoding handler.
      The pre-seed stays gated to GB variants to match the renderer's two-value
      encoding state, so behavior for other/arbitrary charsets is unchanged —
      the renderer still pushes the effective encoding via setEncoding on
      attach, and that handler keeps both halves in sync.
- The SSH startup command is encoded with the same charset as interactive
  input.

Mosh stays UTF-8 (mosh-client is UTF-8-only and sets LANG accordingly), and
local PTY stays UTF-8 — neither sets session.encoding, so their input is
untouched.

Adds unit tests for the encoding helper (GB18030/UTF-8 round-trips, ASCII
control preservation, symmetry with the output decoder) and integration tests
driving writeToSession over a raw TCP device to assert GB18030 bytes on the
wire and a UTF-8 regression guard.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:08:21 +08:00

191 lines
5.6 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const iconv = require("iconv-lite");
const terminalBridge = require("./terminalBridge.cjs");
function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve(server.address().port);
});
});
}
function waitFor(predicate, timeoutMs = 1000) {
const startedAt = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
if (predicate()) {
resolve();
return;
}
if (Date.now() - startedAt > timeoutMs) {
reject(new Error("Timed out waiting for telnet input bytes"));
return;
}
setTimeout(tick, 10);
};
tick();
});
}
// These tests drive the real terminalBridge.writeToSession path over a raw TCP
// "device" that never speaks the Telnet protocol (no IAC bytes), so the bytes
// captured server-side are exactly what the input path serialized — proving
// the keystroke encoding without IAC-escaping noise. They guard issue #1216:
// input must use the SAME charset the output decoder uses.
function initBridge(sessions) {
terminalBridge.init({
sessions,
electronModule: {
webContents: {
fromId: () => ({ send() {} }),
},
},
});
}
test("Telnet input is encoded with the session's GB18030 charset", async () => {
const chunks = [];
const sockets = new Set();
let serverSocket = null;
const server = net.createServer((socket) => {
serverSocket = socket;
sockets.add(socket);
socket.on("error", () => {});
socket.on("close", () => sockets.delete(socket));
socket.on("data", (buf) => chunks.push(buf));
});
const port = await listen(server);
const sessions = new Map();
initBridge(sessions);
try {
await terminalBridge.startTelnetSession(
{ sender: { id: 1 } },
{
sessionId: "telnet-gb18030-input",
hostname: "127.0.0.1",
port,
// No saved credentials → auto-login stays idle and does not inject bytes.
charset: "GB18030",
},
);
await waitFor(() => serverSocket);
terminalBridge.writeToSession(
{},
{ sessionId: "telnet-gb18030-input", data: "你好\r" },
);
await waitFor(() => Buffer.concat(chunks).length >= 5);
const received = Buffer.concat(chunks);
assert.deepEqual([...received], [...iconv.encode("你好\r", "gb18030")]);
// It must NOT be the UTF-8 serialization that the old code always sent.
assert.notDeepEqual([...received], [...Buffer.from("你好\r", "utf8")]);
} finally {
terminalBridge.cleanupAllSessions();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
}
});
test("Telnet input stays UTF-8 when no charset is configured", async () => {
const chunks = [];
const sockets = new Set();
let serverSocket = null;
const server = net.createServer((socket) => {
serverSocket = socket;
sockets.add(socket);
socket.on("error", () => {});
socket.on("close", () => sockets.delete(socket));
socket.on("data", (buf) => chunks.push(buf));
});
const port = await listen(server);
const sessions = new Map();
initBridge(sessions);
try {
await terminalBridge.startTelnetSession(
{ sender: { id: 1 } },
{
sessionId: "telnet-utf8-input",
hostname: "127.0.0.1",
port,
},
);
await waitFor(() => serverSocket);
terminalBridge.writeToSession(
{},
{ sessionId: "telnet-utf8-input", data: "你好\r" },
);
await waitFor(() => Buffer.concat(chunks).length >= 7);
const received = Buffer.concat(chunks);
assert.deepEqual([...received], [...Buffer.from("你好\r", "utf8")]);
} finally {
terminalBridge.cleanupAllSessions();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
}
});
test("setSessionEncoding switches the Telnet input charset at runtime", async () => {
const chunks = [];
const sockets = new Set();
let serverSocket = null;
const server = net.createServer((socket) => {
serverSocket = socket;
sockets.add(socket);
socket.on("error", () => {});
socket.on("close", () => sockets.delete(socket));
socket.on("data", (buf) => chunks.push(buf));
});
const port = await listen(server);
const sessions = new Map();
initBridge(sessions);
try {
await terminalBridge.startTelnetSession(
{ sender: { id: 1 } },
{
sessionId: "telnet-switch-input",
hostname: "127.0.0.1",
port,
},
);
await waitFor(() => serverSocket);
const switchResult = terminalBridge.setSessionEncoding(
{},
{ sessionId: "telnet-switch-input", encoding: "gbk" },
);
// "gbk" normalizes onto the gb18030 superset and is mirrored to
// session.encoding so the input path picks it up immediately.
assert.deepEqual(switchResult, { ok: true, encoding: "gb18030" });
assert.equal(sessions.get("telnet-switch-input").encoding, "gb18030");
terminalBridge.writeToSession(
{},
{ sessionId: "telnet-switch-input", data: "测试\r" },
);
await waitFor(() => Buffer.concat(chunks).length >= 5);
const received = Buffer.concat(chunks);
assert.deepEqual([...received], [...iconv.encode("测试\r", "gb18030")]);
} finally {
terminalBridge.cleanupAllSessions();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
}
});