Files
NetMesh/scripts/xterm-macos-column-selection.live.test.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

192 lines
7.7 KiB
JavaScript

"use strict";
if (!process.versions.electron) {
const test = require("node:test");
test("macOS Option drag keeps rectangular and remote mouse behavior", {
skip: "run with Electron on macOS",
}, () => {});
} else if (process.platform !== "darwin") {
const skipElectron = require("electron");
process.stdout.write("XTERM_MACOS_COLUMN_SELECTION_SKIP platform is not macOS\n");
skipElectron.app.exit(0);
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const electron = require("electron");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(
`${tempDirBridge.getTempFilePath("xterm-macos-selection")}-`,
);
electron.app.setPath("userData", userData);
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) window.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm selection test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
void electron.app.whenReady().then(async () => {
window = new electron.BrowserWindow({
show: process.env.NETCATTY_XTERM_SELECTION_SHOW_WINDOW === "1",
width: 860,
height: 400,
paintWhenInitiallyHidden: true,
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
// The harness imports the shipped ESM bundle directly from disk.
webSecurity: false,
backgroundThrottling: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body{margin:0;background:#111}#terminal{width:800px;height:320px;padding:20px}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const xtermEsmUrl = pathToFileURL(path.join(path.dirname(xtermPath), "xterm.mjs")).href;
await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = await import(${JSON.stringify(xtermEsmUrl)});
const term = new Terminal({
cols: 20,
rows: 6,
fontFamily: "Menlo",
fontSize: 18,
allowProposedApi: true,
macOptionClickForcesSelection: true,
macOptionIsMeta: false,
altClickMovesCursor: true,
});
let data = "";
term.onData(value => { data += value; });
term.open(document.getElementById("terminal"));
const content = [
"ABCDEFGHIJKLMNOPQRST",
"abcdefghijklmnopqrst",
"01234567890123456789",
"!@#$%^&*()_+-=[]{}|;",
].join("\\r\\n");
window.harness = {
reset: (mouseTracking, optionAsMeta) => new Promise(resolve => {
data = "";
term.reset();
term.clearSelection();
term.options.macOptionIsMeta = optionAsMeta;
term.options.altClickMovesCursor = !optionAsMeta;
term.write((mouseTracking ? "\\x1b[?1002h\\x1b[?1006h" : "\\x1b[?1002l\\x1b[?1006l") + content, resolve);
}),
geometry: () => {
const screen = term.element.querySelector(".xterm-screen").getBoundingClientRect();
return {
left: screen.left,
top: screen.top,
cellWidth: screen.width / term.cols,
cellHeight: screen.height / term.rows,
};
},
state: () => ({
selection: term.getSelection(),
data,
mouseTrackingMode: term.modes.mouseTrackingMode,
optionAsMeta: term.options.macOptionIsMeta,
}),
};
})()`);
const drag = async ({ alt = false } = {}) => {
const geometry = await window.webContents.executeJavaScript("window.harness.geometry()");
const point = (column, row) => ({
x: Math.round(geometry.left + geometry.cellWidth * (column + 0.5)),
y: Math.round(geometry.top + geometry.cellHeight * (row + 0.5)),
});
const start = point(1, 0);
const end = point(5, 2);
/** @type {Array<"alt">} */
const modifiers = alt ? ["alt"] : [];
/** @type {Array<"alt" | "leftbuttondown">} */
const moveModifiers = alt ? ["alt", "leftbuttondown"] : ["leftbuttondown"];
window.webContents.sendInputEvent({ type: "mouseDown", ...start, button: "left", clickCount: 1, modifiers });
window.webContents.sendInputEvent({ type: "mouseMove", ...end, button: "left", modifiers: moveModifiers });
window.webContents.sendInputEvent({ type: "mouseUp", ...end, button: "left", clickCount: 1, modifiers });
await delay(100);
return window.webContents.executeJavaScript("window.harness.state()");
};
const runScenario = async (mouseTracking, alt, optionAsMeta = false) => {
await window.webContents.executeJavaScript(
`window.harness.reset(${mouseTracking}, ${optionAsMeta})`,
);
return drag({ alt });
};
const normal = await runScenario(false, false);
const option = await runScenario(false, true);
const remote = await runScenario(true, false);
const remoteOption = await runScenario(true, true);
const optionAsMeta = await runScenario(false, true, true);
const remoteOptionAsMeta = await runScenario(true, true, true);
const optionAfterMeta = await runScenario(false, true, false);
const expectedColumn = "BCDEF\nbcdef\n12345";
const expectedNormal = "BCDEFGHIJKLMNOPQRST\nabcdefghijklmnopqrst\n012345";
assert.equal(
normal.selection,
expectedNormal,
`ordinary drag must remain a normal selection: ${JSON.stringify(normal)}`,
);
assert.equal(option.selection, expectedColumn, `Option drag must select columns: ${JSON.stringify(option)}`);
assert.equal(remote.selection, "", `remote drag must not create a local selection: ${JSON.stringify(remote)}`);
assert.match(remote.data, /\x1b\[<0;\d+;\d+M/, `remote drag must report mouse down: ${JSON.stringify(remote)}`);
assert.match(remote.data, /\x1b\[<32;\d+;\d+M/, `remote drag must report mouse movement: ${JSON.stringify(remote)}`);
assert.match(remote.data, /\x1b\[<0;\d+;\d+m/, `remote drag must report mouse up: ${JSON.stringify(remote)}`);
assert.equal(
remoteOption.selection,
expectedColumn,
`Option drag must force rectangular selection in mouse mode: ${JSON.stringify(remoteOption)}`,
);
assert.equal(remoteOption.data, "", `forced local selection must not emit mouse reports: ${JSON.stringify(remoteOption)}`);
assert.equal(
optionAsMeta.selection,
expectedNormal,
`Option-as-Meta must disable rectangular selection: ${JSON.stringify(optionAsMeta)}`,
);
assert.equal(
remoteOptionAsMeta.selection,
expectedNormal,
`Option-as-Meta must disable rectangular selection in mouse mode: ${JSON.stringify(remoteOptionAsMeta)}`,
);
assert.equal(
remoteOptionAsMeta.data,
"",
`Option must still force local selection in mouse mode: ${JSON.stringify(remoteOptionAsMeta)}`,
);
assert.equal(
optionAfterMeta.selection,
expectedColumn,
`turning Option-as-Meta off must restore rectangular selection: ${JSON.stringify(optionAfterMeta)}`,
);
process.stdout.write(
`XTERM_MACOS_COLUMN_SELECTION_OK ${JSON.stringify({ normal, option, remote, remoteOption, optionAsMeta, remoteOptionAsMeta, optionAfterMeta })}\n`,
);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}