From 992a30381812673a14b5a65c01655bc0c032c0da Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:41:08 +0800 Subject: [PATCH] fix(terminal): validate patched xterm across dev and CI --- .github/workflows/build.yml | 6 +++ scripts/github-workflow-ci.test.cjs | 6 +++ .../patch-xterm-macos-column-selection.cjs | 44 ++++++++++++++++--- ...atch-xterm-macos-column-selection.test.cjs | 28 +++++++++--- ...xterm-macos-column-selection.live.test.cjs | 12 +++-- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3aaa605eb..922a833c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,11 +71,13 @@ on: - "scripts/linux/**" - "scripts/mosh-extra-resources.cjs" - "scripts/nodePtyConptyPatch.cjs" + - "scripts/patch-xterm-macos-column-selection.cjs" - "scripts/patch-xterm-webgl-atlas.cjs" - "scripts/rebuildPatchedNodePty.cjs" - "scripts/resolve-et-bin-release.cjs" - "scripts/resolve-mosh-bin-release.cjs" - "scripts/verify-linux-*.sh" + - "scripts/xterm-macos-column-selection.live.test.cjs" - "skills/**" # A newer run for the same main push or PR cancels older in-progress work. @@ -243,6 +245,10 @@ jobs: - name: Install deps run: npm ci + - name: Test macOS Option column selection + if: matrix.name == 'macos' + run: npm run test:xterm-macos-selection + - name: Compile ConPTY test helpers if: matrix.name == 'windows' shell: pwsh diff --git a/scripts/github-workflow-ci.test.cjs b/scripts/github-workflow-ci.test.cjs index a42e5c817..d002f4235 100644 --- a/scripts/github-workflow-ci.test.cjs +++ b/scripts/github-workflow-ci.test.cjs @@ -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/**", ]) { diff --git a/scripts/patch-xterm-macos-column-selection.cjs b/scripts/patch-xterm-macos-column-selection.cjs index fa092b8d5..3a984bf75 100644 --- a/scripts/patch-xterm-macos-column-selection.cjs +++ b/scripts/patch-xterm-macos-column-selection.cjs @@ -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} 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, }; diff --git a/scripts/patch-xterm-macos-column-selection.test.cjs b/scripts/patch-xterm-macos-column-selection.test.cjs index 038907e33..e3432c2fa 100644 --- a/scripts/patch-xterm-macos-column-selection.test.cjs +++ b/scripts/patch-xterm-macos-column-selection.test.cjs @@ -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 }, + }]); +}); diff --git a/scripts/xterm-macos-column-selection.live.test.cjs b/scripts/xterm-macos-column-selection.live.test.cjs index 04375db87..406fa108d 100644 --- a/scripts/xterm-macos-column-selection.live.test.cjs +++ b/scripts/xterm-macos-column-selection.live.test.cjs @@ -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()");