fix(terminal): validate patched xterm across dev and CI

This commit is contained in:
bincxz committed 2026-08-23 22:41:08 +08:00
1 parent 21880ef1ea
commit 992a303818
5 files changed
+82 -14

No files matched your search

+6
View File
@@ -78,6 +78,10 @@ test("PR validation runs once per commit and includes a production build", () =>
testWorkflow,
/- name: Test terminal keyword highlight performance\s*\n\s*env:\s*\n\s*NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"\s*\n\s*# GitHub-hosted runners do not configure Electron's SUID sandbox helper\.\s*\n\s*run: xvfb-run -a \.\/node_modules\/\.bin\/electron --no-sandbox scripts\/xterm-keyword-highlight-performance\.live\.test\.cjs/,
);
assert.match(
buildWorkflow,
/- name: Test macOS Option column selection\s*\n\s*if: matrix\.name == 'macos'\s*\n\s*run: npm run test:xterm-macos-selection/,
);
assert.match(testWorkflow, /- name: Build\s*\n\s*run: npm run build/);
assert.doesNotMatch(testWorkflow, /\n mosh-windows-conpty:/);
});
@@ -112,6 +116,8 @@ test("package validation avoids duplicate branch runs and scopes PR builds", ()
"scripts/afterPackMacUuid.cjs",
"scripts/beforePackCursorSdk.cjs",
"scripts/nodePtyConptyPatch.cjs",
"scripts/patch-xterm-macos-column-selection.cjs",
"scripts/xterm-macos-column-selection.live.test.cjs",
"scripts/linux/**",
"skills/**",
]) {
+38 -6
View File
@@ -30,16 +30,19 @@ const PATCHES = [
file: "node_modules/@xterm/xterm/src/browser/services/SelectionService.ts",
original: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);",
replacement: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionIsMeta);",
preserveStatementLength: true,
},
{
file: "node_modules/@xterm/xterm/lib/xterm.js.map",
original: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);",
replacement: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionIsMeta);",
preserveStatementLength: true,
},
{
file: "node_modules/@xterm/xterm/lib/xterm.mjs.map",
original: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);",
replacement: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionIsMeta);",
preserveStatementLength: true,
},
];
@@ -49,11 +52,26 @@ function sameLengthExpression(expression, length) {
return `${expression}${" ".repeat(length - expression.length)}`;
}
function sameLengthStatement(statement, length) {
if (!statement.endsWith(";") || statement.length > length) {
throw new Error("replacement statement cannot preserve the target length");
}
return `${statement.slice(0, -1)}${" ".repeat(length - statement.length)};`;
}
function replacementForPatch(patch) {
if (!patch.replacement) throw new Error(`missing replacement for ${patch.file}`);
if (patch.preserveLength) {
return sameLengthExpression(patch.replacement, patch.original.length);
}
if (patch.preserveStatementLength) {
return sameLengthStatement(patch.replacement, patch.original.length);
}
return patch.replacement;
}
function patchXtermSource(source, patch) {
const replacement = patch.preserveLength
? sameLengthExpression(patch.replacement, patch.original.length)
: patch.replacement;
if (!replacement) throw new Error(`missing replacement for ${patch.file}`);
const replacement = replacementForPatch(patch);
const originalCount = source.split(patch.original).length - 1;
const patchedCount = source.split(replacement).length - 1;
@@ -69,6 +87,16 @@ function patchXtermSource(source, patch) {
);
}
/**
* @param {string} root
* @param {Pick<typeof fs, "rmSync">} fsImpl
*/
function invalidateViteCache(root = process.cwd(), fsImpl = fs) {
const cachePath = path.resolve(root, "node_modules/.vite");
fsImpl.rmSync(cachePath, { recursive: true, force: true });
return cachePath;
}
function patchInstalledXterm(root = process.cwd()) {
const packageJsonPath = path.resolve(root, "node_modules/@xterm/xterm/package.json");
const version = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).version;
@@ -88,7 +116,8 @@ function patchInstalledXterm(root = process.cwd()) {
changed++;
}
}
return { changed, checked: PATCHES.length, version };
const viteCachePath = invalidateViteCache(root);
return { changed, checked: PATCHES.length, version, viteCachePath };
}
if (require.main === module) {
@@ -96,7 +125,7 @@ if (require.main === module) {
const result = patchInstalledXterm();
console.log(
`[patch-xterm-macos-column-selection] version=${result.version} ` +
`changed=${result.changed} checked=${result.checked}`,
`changed=${result.changed} checked=${result.checked} vite-cache=invalidated`,
);
} catch (error) {
console.error(`[patch-xterm-macos-column-selection] ERROR: ${error.message}`);
@@ -107,7 +136,10 @@ if (require.main === module) {
module.exports = {
EXPECTED_XTERM_VERSION,
PATCHES,
invalidateViteCache,
patchInstalledXterm,
patchXtermSource,
replacementForPatch,
sameLengthExpression,
sameLengthStatement,
};
@@ -1,11 +1,14 @@
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
const test = require("node:test");
const {
PATCHES,
invalidateViteCache,
patchXtermSource,
replacementForPatch,
sameLengthExpression,
} = require("./patch-xterm-macos-column-selection.cjs");
@@ -25,17 +28,17 @@ test("patches both distributed bundles without shifting source-map offsets", ()
test("patches readable xterm sources and source-map content", () => {
for (const patch of PATCHES.filter((entry) => !entry.preserveLength)) {
const result = patchXtermSource(`before:${patch.original}:after`, patch);
const input = `before:${patch.original}:after`;
const result = patchXtermSource(input, patch);
assert.equal(result.changed, true);
assert.equal(result.source, `before:${patch.replacement}:after`);
assert.equal(result.source, `before:${replacementForPatch(patch)}:after`);
assert.equal(result.source.length, input.length);
}
});
test("is idempotent and fails closed on an unknown package shape", () => {
for (const patch of PATCHES) {
const replacement = patch.preserveLength
? sameLengthExpression(patch.replacement, patch.original.length)
: patch.replacement;
const replacement = replacementForPatch(patch);
assert.deepEqual(patchXtermSource(`before:${replacement}:after`, patch), {
source: `before:${replacement}:after`,
changed: false,
@@ -53,3 +56,18 @@ test("fails closed when a generated-bundle replacement would shift offsets", ()
/replacement expression is too long/,
);
});
test("invalidates Vite's optimized dependency cache after patching xterm", () => {
const calls = [];
const cachePath = invalidateViteCache("/repo", {
rmSync(target, options) {
calls.push({ target, options });
},
});
assert.equal(cachePath, path.resolve("/repo/node_modules/.vite"));
assert.deepEqual(calls, [{
target: cachePath,
options: { recursive: true, force: true },
}]);
});
@@ -13,6 +13,7 @@ if (!process.versions.electron) {
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");
@@ -46,6 +47,8 @@ if (!process.versions.electron) {
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
// The harness imports the shipped ESM bundle directly from disk.
webSecurity: false,
backgroundThrottling: false,
},
});
@@ -56,8 +59,9 @@ if (!process.versions.electron) {
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
await window.webContents.executeJavaScript(`(() => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
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,
@@ -114,8 +118,10 @@ if (!process.versions.electron) {
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 });
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()");