build(xterm): land the patch regeneration harness

The five dependency patches under config/patches/ shipped with no tracked
way to regenerate any of them. The xterm one is the hard case: it is derived
from an upstream build, so no fix could be made without rebuilding, and the
tooling to rebuild lived only in one machine's scratch directory. That
blocked a measured fix for a live keystroke-loss bug, and the EditContext
reduction an OSS survey identified as the only real one available.

Adds the regenerator, the upstream pin, the hand-written source patch the
bundle hunks derive from, tests, docs, and a PR job that verifies the
shipped patches still match the pinned build. The job caches the shallow
clone keyed on the manifest, so a cold run is minutes and a warm one under
one. Round-trip verified: regenerating from a clean checkout reproduces the
shipped patch byte-for-byte.

Marks the emitted patch -diff -text. pnpm hashes it byte-for-byte, so a
CRLF checkout would break install on Windows, and its minified bundle lines
make a diff nobody can read — review the source patch instead.

Also rejects unknown flags. --check was the fallback for any unrecognised
argument, so a typo, or --help, silently triggered a full upstream build
instead of what the caller asked for.
This commit is contained in:
Neil
2026-08-06 09:29:28 -07:00
parent ef0565efcc
commit 29117bf776
9 changed files with 1760 additions and 1 deletions
+5
View File
@@ -12,3 +12,8 @@
/src/cli/bundled-skill-guides.ts text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
/resources/plugins/** text eol=lf
# pnpm hashes this generated patch byte-for-byte, so a CRLF checkout breaks the install;
# its minified bundle lines also make a diff nobody can read. Review the hand-written
# source patch under xterm-src/ instead.
/config/patches/@xterm__xterm@*.patch -diff -text
/config/patches/xterm-src/*.patch text eol=lf
+31
View File
@@ -173,6 +173,34 @@ jobs:
done
exit "$status"
xterm_patch_sync:
name: xterm patch sync
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: the check rebuilds xterm.js from a pinned upstream commit. Caching the
# npm metadata and the shallow clone turns a ~4 min cold run into well under a
# minute; the key is the manifest, so a commit or toolchain bump invalidates it.
- name: Restore upstream xterm build inputs
uses: actions/cache@v4
with:
path: |
~/.npm
${{ runner.temp }}/xterm-patch-build/upstream/.git
key: xterm-upstream-${{ hashFiles('config/patches/xterm-upstream.json') }}
- name: Verify xterm patches match the pinned upstream build
env:
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
@@ -398,6 +426,7 @@ jobs:
- root_directory_guard
- typecheck
- git_compatibility
- xterm_patch_sync
- shell_contracts
- test
- package
@@ -419,6 +448,7 @@ jobs:
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
PACKAGE: ${{ needs.package.result }}
@@ -429,6 +459,7 @@ jobs:
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$PACKAGE" \
+1
View File
@@ -100,6 +100,7 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/xterm-patch-regeneration.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -0,0 +1,247 @@
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
index 4557e1652c34737fdf853436bd9328d9918eee2b..c16096341dadfd215a8086e13f7a0551025c0e77 100644
--- a/src/browser/CoreBrowserTerminal.ts
+++ b/src/browser/CoreBrowserTerminal.ts
@@ -735,6 +735,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
paste(data, this.textarea!, this.coreService, this.optionsService);
}
+ public override input(data: string, wasUserInput: boolean = true): void {
+ if (!wasUserInput || !this._compositionHelper?.handleCompositionInput(data, false)) super.input(data, wasUserInput);
+ }
+
public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void {
this._customKeyEventHandler = customKeyEventHandler;
}
@@ -1029,6 +1033,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
// support reading out character input which can doubling up input characters
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
+ if (ev.data && ev.inputType === 'insertText' && this._compositionHelper?.handleCompositionInput(ev.data, true)) return true;
if (ev.data && ev.inputType === 'insertText' && (!ev.composed || !this._keyDownSeen) && !this.optionsService.rawOptions.screenReaderMode) {
if (this._keyPressHandled) {
return false;
diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts
index 7b346459521dd77f9de25d43fba3c36a6f8459b2..4837a7c1a3eb36ec6f6223cbcec0f2dd6cc4e235 100644
--- a/src/browser/TestUtils.test.ts
+++ b/src/browser/TestUtils.test.ts
@@ -342,6 +342,9 @@ export class MockViewport implements IViewport {
}
export class MockCompositionHelper implements ICompositionHelper {
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ throw new Error('Method not implemented.');
+ }
public get isComposing(): boolean {
return false;
}
diff --git a/src/browser/Types.ts b/src/browser/Types.ts
index 497afcf535f3eaca00889525a77e15eb633ccd96..e3cad77734795f6cf34bb7120264fe81070941ed 100644
--- a/src/browser/Types.ts
+++ b/src/browser/Types.ts
@@ -39,6 +39,7 @@ export type LineData = CharData[];
export interface ICompositionHelper {
readonly isComposing: boolean;
+ handleCompositionInput(data: string, nativeCommit: boolean): boolean;
compositionstart(): void;
compositionupdate(ev: CompositionEvent): void;
compositionend(): void;
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..912430b4e977cb61feacdaa39ea84155eeabe838 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -5,7 +5,6 @@
import { IRenderService } from '../services/Services';
import { IBufferService, ICoreService, IOptionsService } from '../../common/services/Services';
-import { C0 } from '../../common/data/EscapeSequences';
interface IPosition {
start: number;
@@ -42,15 +41,8 @@ export class CompositionHelper {
*/
private _isSendingComposition: boolean;
- /**
- * Data already sent due to keydown event.
- */
- private _dataAlreadySent: string;
-
- /**
- * The pending textarea change timer, if any.
- */
- private _textareaChangeTimer?: number;
+ private _pendingCompositionStart?: number;
+ private _pendingInput = '';
constructor(
private readonly _textarea: HTMLTextAreaElement,
@@ -64,7 +56,6 @@ export class CompositionHelper {
this._isSendingComposition = false;
this._compositionPosition = { start: 0, end: 0 };
this._compositionSuffix = '';
- this._dataAlreadySent = '';
}
/**
@@ -80,10 +71,24 @@ export class CompositionHelper {
this._compositionPosition.end = Math.max(start, end);
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
this._compositionView.textContent = '';
- this._dataAlreadySent = '';
this._compositionView.classList.add('active');
}
+ public handleCompositionInput(data: string, nativeCommit: boolean): boolean {
+ if (nativeCommit) {
+ if (!this._isSendingComposition) return false;
+ const input = (this._pendingCompositionStart === undefined ? '' : data) + this._pendingInput;
+ this._pendingCompositionStart = undefined;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ this._isSendingComposition = false;
+ return true;
+ }
+ if (!this._isComposing && !this._isSendingComposition) return false;
+ this._pendingInput += data;
+ return true;
+ }
+
/**
* Handles the compositionupdate event, updating the composition view.
* @param ev The event.
@@ -129,9 +134,6 @@ export class CompositionHelper {
}
if (ev.keyCode === 229) {
- // If the "composition character" is used but gets to this point it means a non-composition
- // character (eg. numbers and punctuation) was pressed when the IME was active.
- this._handleAnyTextareaChanges();
return false;
}
@@ -153,7 +155,11 @@ export class CompositionHelper {
if (!waitForPropagation) {
// Cancel any delayed composition send requests and send the input immediately.
this._isSendingComposition = false;
- const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
+ const start = this._pendingCompositionStart ?? this._compositionPosition.start;
+ const end = Math.max(start, this._textarea.selectionEnd ?? this._compositionPosition.end);
+ this._pendingCompositionStart = undefined;
+ const input = this._textarea.value.substring(start, end) + this._pendingInput;
+ this._pendingInput = '';
this._coreService.triggerDataEvent(input, true);
} else {
// Make a deep copy of the composition position here as a new compositionstart event may
@@ -163,6 +169,7 @@ export class CompositionHelper {
end: this._compositionPosition.end
};
const currentCompositionSuffix = this._compositionSuffix;
+ this._pendingCompositionStart ??= currentCompositionPosition.start;
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -175,12 +182,10 @@ export class CompositionHelper {
this._isSendingComposition = true;
setTimeout(() => {
// Ensure that the input has not already been sent
- if (this._isSendingComposition) {
- this._isSendingComposition = false;
+ if (this._isSendingComposition && this._pendingCompositionStart !== undefined) {
+ currentCompositionPosition.start = this._pendingCompositionStart;
+ this._pendingCompositionStart = undefined;
let input;
- // Add length of data already sent due to keydown event,
- // otherwise input characters can be duplicated. (Issue #3191)
- currentCompositionPosition.start += this._dataAlreadySent.length;
if (this._isComposing) {
// Use the start position of the new composition to get the string
// if a new composition has started.
@@ -195,47 +200,21 @@ export class CompositionHelper {
: value.length;
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
}
- if (input.length > 0) {
- this._coreService.triggerDataEvent(input, true);
- }
+ input += this._pendingInput;
+ this._pendingInput = '';
+ if (input.length > 0) this._coreService.triggerDataEvent(input, true);
+ setTimeout(() => {
+ if (this._pendingCompositionStart === undefined) {
+ if (this._pendingInput.length > 0) this._coreService.triggerDataEvent(this._pendingInput, true);
+ this._pendingInput = '';
+ this._isSendingComposition = false;
+ }
+ }, 0);
}
}, 0);
}
}
- /**
- * Apply any changes made to the textarea after the current event chain is allowed to complete.
- * This should be called when not currently composing but a keydown event with the "composition
- * character" (229) is triggered, in order to allow non-composition text to be entered when an
- * IME is active.
- */
- private _handleAnyTextareaChanges(): void {
- if (this._textareaChangeTimer) {
- return;
- }
- const oldValue = this._textarea.value;
- this._textareaChangeTimer = window.setTimeout(() => {
- this._textareaChangeTimer = undefined;
- // Ignore if a composition has started since the timeout
- if (!this._isComposing) {
- const newValue = this._textarea.value;
-
- const diff = newValue.replace(oldValue, '');
-
- this._dataAlreadySent = diff;
-
- if (newValue.length > oldValue.length) {
- this._coreService.triggerDataEvent(diff, true);
- } else if (newValue.length < oldValue.length) {
- this._coreService.triggerDataEvent(`${C0.DEL}`, true);
- } else if ((newValue.length === oldValue.length) && (newValue !== oldValue)) {
- this._coreService.triggerDataEvent(newValue, true);
- }
-
- }
- }, 0);
- }
-
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644
--- a/src/common/SortedList.ts
+++ b/src/common/SortedList.ts
@@ -87,6 +87,24 @@ export class SortedList<T> {
if (key === undefined) {
return false;
}
+ if (this._deleteAtKey(value, key)) {
+ return true;
+ }
+ // A pending deletion whose key mutated after `delete()` (disposing a marker
+ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of
+ // order, so the binary search above can miss a value that is present.
+ // Compacting those entries out restores the order; retry before reporting
+ // the value absent, else its `onDecorationRemoved` never fires and the
+ // decoration paints forever. Miss path only, so the common bulk delete
+ // keeps its O(log n) search and deferred-compaction batching.
+ if (this._deletedIndices.length === 0) {
+ return false;
+ }
+ this._flushCleanupDeleted();
+ return this._deleteAtKey(value, key);
+ }
+
+ private _deleteAtKey(value: T, key: number): boolean {
i = this._search(key);
if (i === -1) {
return false;
+35
View File
@@ -0,0 +1,35 @@
{
"$schemaNote": "Consumed by config/scripts/regenerate-xterm-patches.mjs. See docs/reference/xterm-patch-regeneration.md.",
"upstream": {
"repository": "https://github.com/xtermjs/xterm.js.git",
"commit": "53a98a720ae4a973e384fa2440880d09537132f3",
"commitSource": "bin/publish.js stamps package.json.commit before npm publish, so the published tarball names its own commit. The generator asserts the two agree."
},
"sourcemaps": {
"policy": "delete",
"why": "The shipped .js and .mjs move under the patch, so a retained map would need to move with it: excluding just the map hunks ships offsets that no longer line up, which is the defect the addon patches still have. Deleting the maps is the honest form of that saving — the patch drops from 7.3MB to 1.5MB, the bundles stay byte-identical, and nothing in this repo consumes the maps at build or run time."
},
"toolchain": {
"why": "Pinned by the upstream package-lock at the commit above. The generator asserts these resolve as expected so a silent upstream resolution change surfaces as a toolchain error rather than a mystery patch diff.",
"esbuild": "0.28.1",
"webpack": "5.107.0",
"terser": "5.47.1",
"@typescript/native-preview": "7.0.0-dev.20260521.1"
},
"packages": [
{
"name": "@xterm/xterm",
"version": "6.1.0-beta.287",
"packageDir": ".",
"versionStampFile": "src/common/Version.ts",
"sourcePatch": "config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch",
"patch": "config/patches/@xterm__xterm@6.1.0-beta.287.patch",
"generatedPaths": ["lib/"],
"build": [{ "cwd": ".", "command": "npm", "args": ["run", "package"] }]
}
],
"forbiddenBuildScripts": {
"why": "`npm run setup` runs a development esbuild (minify:false), so calling it after the packaging build overwrites lib/*.mjs with an unminified bundle and a mismatched map. Publish order is: stamp Version.ts, then `npm run package` only.",
"scripts": ["setup", "presetup", "postsetup", "esbuild", "esbuild-watch", "dev"]
}
}
@@ -130,7 +130,12 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
for (const jobName of [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync'
]) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
@@ -176,6 +181,7 @@ describe('PR workflow parallelism', () => {
'root_directory_guard',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'package',
+727
View File
@@ -0,0 +1,727 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
/**
* Flags pnpm@10 passes to `git diff` in its own `diffFolders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
]
/**
* The same formatting as PNPM_DIFF_FLAGS minus `--no-index`, so a diff taken
* inside the upstream checkout is byte-comparable with the emitted patch.
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
/** Blanks the vars pnpm blanks so user and system git config cannot reach the diff. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
}
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function trimSurroundingSlashes(value) {
return value[0] === '/' || value.endsWith('/') ? value.replace(/^\/|\/$/g, '') : value
}
/**
* Reproduces pnpm's post-processing of the raw `git diff` output: strip the two
* scratch folder prefixes, drop a trailing no-newline marker, and remove
* .DS_Store entries a macOS run would otherwise smuggle in.
*/
export function normalizePnpmDiff(stdout, folderA, folderB) {
const a = folderA.replace(/\\/g, '/')
const b = folderB.replace(/\\/g, '/')
return stdout
.replace(new RegExp(`(a|b)(${escapeRegExp(`/${trimSurroundingSlashes(a)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeRegExp(`/${trimSurroundingSlashes(b)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeRegExp(`${a}/`), 'g'), '')
.replace(new RegExp(escapeRegExp(`${b}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
/** Splits a patch into one entry per `diff --git` stanza, keeping the raw text. */
export function splitPatchEntries(patchText) {
return patchText
.split(/^(?=diff --git )/m)
.filter((entry) => entry.startsWith('diff --git '))
.map((text) => {
const header = text.slice(0, text.indexOf('\n'))
const match = /^diff --git a\/(.+) b\/\1$/.exec(header)
if (!match) {
throw new Error(`Unsupported diff header (renames are not supported): ${header}`)
}
return { path: match[1], text }
})
}
export function selectPatchEntries(patchText, matches) {
return splitPatchEntries(patchText)
.filter((entry) => matches(entry.path))
.map((entry) => entry.text)
.join('')
}
/** The hand-editable half of a patch: everything under `src/`. */
export function sourceHunks(patchText) {
return selectPatchEntries(patchText, (file) => file.startsWith('src/'))
}
/**
* The emitted patch can only ever name files the registry publishes, and
* upstream's `.npmignore` strips `src/**\/*.test.ts`. Deriving the source patch
* from the emitted patch therefore deletes any hunk against those files on the
* next `--write` — including the `MockCompositionHelper` implementation the
* patched `ICompositionHelper` requires to type-check. The two derivations must
* still agree everywhere they can both speak, or the source patch and the
* shipped patch have drifted.
*/
export function assertSourceDerivationsAgree(checkoutSource, patchText, publishedPaths) {
const published = selectPatchEntries(checkoutSource, (file) => publishedPaths.has(file))
const emitted = sourceHunks(patchText)
if (published === emitted) {
return
}
throw new Error(
[
'The checkout diff and the emitted patch disagree on a published source file.',
` from checkout: [${splitPatchEntries(published)
.map((e) => e.path)
.join(', ')}]`,
` from patch: [${splitPatchEntries(emitted)
.map((e) => e.path)
.join(', ')}]`,
` first difference at character ${firstDifferenceIndex(published, emitted)}`,
'',
'This is a generator bug, not a patch problem: the same content diffed two',
'ways must produce the same bytes.'
].join('\n')
)
}
/** The derived half of a patch: build output, never edited by hand. */
export function generatedHunks(patchText, generatedPaths) {
return selectPatchEntries(patchText, (file) =>
generatedPaths.some((prefix) => file.startsWith(prefix))
)
}
export function stampVersionSource(source, version) {
const stamped = source.replace(
/export const XTERM_VERSION = '[^']+';/,
`export const XTERM_VERSION = '${version}';`
)
if (stamped === source && !source.includes(`'${version}'`)) {
throw new Error('Version stamp file does not declare XTERM_VERSION')
}
return stamped
}
/**
* The published tarball names the commit it was built from, so a version bump
* that forgets the manifest fails here instead of producing a patch against the
* wrong tree.
*/
export function assertPublishedCommit(publishedPackageJson, packageEntry, upstreamCommit) {
if (publishedPackageJson.version !== packageEntry.version) {
throw new Error(
`${packageEntry.name}: registry served ${publishedPackageJson.version}, manifest pins ${packageEntry.version}`
)
}
if (publishedPackageJson.commit !== upstreamCommit) {
throw new Error(
[
`${packageEntry.name}@${packageEntry.version} was published from commit`,
` ${publishedPackageJson.commit ?? '(absent)'}`,
`but ${MANIFEST_RELATIVE_PATH} pins`,
` ${upstreamCommit}`,
'Update upstream.commit in the manifest to the published commit, then rerun with --write.'
].join('\n')
)
}
}
/** Guards the publish-order trap: a dev esbuild pass would silently de-minify lib/*.mjs. */
export function assertBuildStepsAllowed(manifest) {
const forbidden = new Set(manifest.forbiddenBuildScripts?.scripts ?? [])
for (const packageEntry of manifest.packages) {
for (const step of packageEntry.build) {
const script = step.command === 'npm' && step.args[0] === 'run' ? step.args[1] : undefined
if (script !== undefined && forbidden.has(script)) {
throw new Error(
`${packageEntry.name}: build step \`npm run ${script}\` is forbidden. ${manifest.forbiddenBuildScripts.why}`
)
}
}
}
}
export const SOURCEMAP_POLICIES = new Set(['include', 'delete'])
/** A typo would fall through to `include` and re-inflate the patch by 5.8 MB. */
export function assertSourcemapPolicy(manifest) {
const policy = manifest.sourcemaps?.policy
if (!SOURCEMAP_POLICIES.has(policy)) {
throw new Error(
`sourcemaps.policy must be one of ${[...SOURCEMAP_POLICIES].join(', ')}, got ${JSON.stringify(policy)}`
)
}
return policy
}
/**
* pnpm keys the patched package directory and the lockfile entry by the
* sha256 of the patch file itself, so a regenerated patch that leaves
* pnpm-lock.yaml alone fails `--frozen-lockfile` on every machine but the
* author's.
*/
export function patchHash(patchText) {
return createHash('sha256').update(patchText, 'utf8').digest('hex')
}
function lockfilePatchHashPattern(packageKey) {
// Unscoped keys such as `node-pty@1.1.0` are emitted unquoted.
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?:\\n hash: )([0-9a-f]{64})$`, 'm')
}
export function readLockfilePatchHash(lockfileText, packageKey) {
const match = lockfilePatchHashPattern(packageKey).exec(lockfileText)
if (!match) {
throw new Error(`pnpm-lock.yaml has no patchedDependencies entry for '${packageKey}'`)
}
return match[2]
}
function lockfileResolutionHashPattern(packageKey) {
const separator = packageKey.lastIndexOf('@')
const name = escapeRegExp(packageKey.slice(0, separator))
const version = escapeRegExp(packageKey.slice(separator + 1))
// Two spellings: `name@version(patch_hash=…)` in dependency keys, and a bare
// `: version(patch_hash=…)` under `version:` and in resolved dependency maps.
return new RegExp(`(?:${name}@|: )${version}\\(patch_hash=([0-9a-f]{64})\\)`, 'g')
}
/**
* pnpm repeats the hash inside every resolution key that depends on the patched
* package, not just in `patchedDependencies`. Updating one and not the other leaves
* a lockfile that installs on a warm store and drifts on a cold one, which is CI.
*/
export function readLockfileResolutionHashes(lockfileText, packageKey) {
return Array.from(
lockfileText.matchAll(lockfileResolutionHashPattern(packageKey)),
(match) => match[1]
)
}
export function lockfilePatchHashIsStale(lockfileText, packageKey, hash) {
return (
readLockfilePatchHash(lockfileText, packageKey) !== hash ||
readLockfileResolutionHashes(lockfileText, packageKey).some((value) => value !== hash)
)
}
export function updateLockfilePatchHash(lockfileText, packageKey, hash) {
readLockfilePatchHash(lockfileText, packageKey)
return lockfileText
.replace(lockfilePatchHashPattern(packageKey), `$1${hash}`)
.replace(lockfileResolutionHashPattern(packageKey), (match, current) =>
match.replace(`patch_hash=${current}`, `patch_hash=${hash}`)
)
}
export function firstDifferenceIndex(left, right) {
const limit = Math.min(left.length, right.length)
for (let index = 0; index < limit; index += 1) {
if (left[index] !== right[index]) {
return index
}
}
return left.length === right.length ? -1 : limit
}
export function formatCheckFailure({ name, patchPath, committed, regenerated }) {
const index = firstDifferenceIndex(committed, regenerated)
const committedFiles = splitPatchEntries(committed).map((entry) => entry.path)
const regeneratedFiles = splitPatchEntries(regenerated).map((entry) => entry.path)
return [
`${name}: ${patchPath} is not what the pinned upstream build produces.`,
` committed: ${Buffer.byteLength(committed)} bytes, files [${committedFiles.join(', ')}]`,
` regenerated: ${Buffer.byteLength(regenerated)} bytes, files [${regeneratedFiles.join(', ')}]`,
` first difference at character ${index}`,
'',
'The bundle hunks are generated. Do not edit them. Change the source patch',
'instead and regenerate both files:',
'',
' node config/scripts/regenerate-xterm-patches.mjs --write',
'',
'See docs/reference/xterm-patch-regeneration.md.'
].join('\n')
}
function run(command, args, options = {}) {
return execFileSync(command, args, {
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'inherit'],
...options
})
}
function listFilesRelative(root, base = root) {
const files = []
for (const entry of readdirSync(base, { withFileTypes: true })) {
const absolute = path.join(base, entry.name)
if (entry.isDirectory()) {
files.push(...listFilesRelative(root, absolute))
} else if (entry.isFile()) {
files.push(path.relative(root, absolute))
}
}
return files.sort()
}
function sameBytes(left, right) {
return (
statSync(left).size === statSync(right).size && readFileSync(left).equals(readFileSync(right))
)
}
function fetchPristinePackage(packageEntry, workDir) {
const target = path.join(workDir, 'pristine', packageEntry.name.replace(/[@/]/g, '_'))
rmSync(target, { recursive: true, force: true })
mkdirSync(target, { recursive: true })
const spec = `${packageEntry.name}@${packageEntry.version}`
const output = run('npm', ['pack', spec, '--pack-destination', target, '--silent'], {
cwd: workDir
})
const tarball = path.join(target, output.trim().split('\n').at(-1).trim())
run('tar', ['xzf', tarball, '-C', target])
return path.join(target, 'package')
}
function hasCommit(root, commit) {
try {
return run('git', ['cat-file', '-t', commit], { cwd: root, stdio: 'pipe' }).trim() === 'commit'
} catch {
return false
}
}
function ensureUpstreamCheckout(manifest, workDir) {
const root = path.join(workDir, 'upstream')
const { repository, commit } = manifest.upstream
if (!existsSync(path.join(root, '.git'))) {
mkdirSync(root, { recursive: true })
run('git', ['init', '--quiet'], { cwd: root })
run('git', ['remote', 'add', 'origin', repository], { cwd: root })
}
if (!hasCommit(root, commit)) {
run('git', ['fetch', '--depth=1', 'origin', commit], { cwd: root, stdio: 'inherit' })
}
run('git', ['checkout', '--quiet', '--detach', commit], { cwd: root })
run('git', ['reset', '--quiet', '--hard', commit], { cwd: root })
return root
}
function ensureDependencies(upstreamRoot, manifest) {
const lockfile = path.join(upstreamRoot, 'package-lock.json')
const stamp = path.join(upstreamRoot, 'node_modules', '.orca-xterm-install-stamp')
const want = `${manifest.upstream.commit}\n${statSync(lockfile).size}\n`
if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) {
return
}
run('npm', ['ci'], {
cwd: upstreamRoot,
env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', PUPPETEER_SKIP_DOWNLOAD: '1' }
})
assertToolchain(upstreamRoot, manifest)
writeFileSync(stamp, want)
}
function assertToolchain(upstreamRoot, manifest) {
const expected = manifest.toolchain
for (const [name, version] of Object.entries(expected)) {
if (name === 'why') {
continue
}
const installed = path.join(upstreamRoot, 'node_modules', name, 'package.json')
if (!existsSync(installed)) {
throw new Error(
`Upstream install is missing ${name}. The pinned toolchain is no longer resolvable; see the tsgo note in docs/reference/xterm-patch-regeneration.md.`
)
}
const actual = JSON.parse(readFileSync(installed, 'utf8')).version
if (actual !== version) {
throw new Error(
`Upstream ${name} resolved to ${actual}, manifest expects ${version}. Update the toolchain block only together with a verified rebuild.`
)
}
}
}
/**
* The published src/ must equal the pinned commit's src/ apart from the version
* stamp publish.js rewrites. If it does not, the manifest points at the wrong
* commit and every hunk below would be nonsense.
*/
function assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry) {
const stampFile = packageEntry.versionStampFile
const sourceRoot = path.join(pristineDir, 'src')
const drifted = listFilesRelative(sourceRoot)
.map((relative) => path.join('src', relative))
.filter((relative) => relative !== stampFile)
.filter(
(relative) =>
!sameBytes(
path.join(pristineDir, relative),
path.join(upstreamRoot, packageEntry.packageDir, relative)
)
)
if (drifted.length > 0) {
throw new Error(
`Published src/ does not match ${packageEntry.packageDir} at the pinned commit: ${drifted.join(', ')}`
)
}
}
function buildPackage(upstreamRoot, packageEntry, manifest) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const directory of ['lib', 'out', 'out-esbuild']) {
rmSync(path.join(packageRoot, directory), { recursive: true, force: true })
}
const stampPath = path.join(packageRoot, packageEntry.versionStampFile)
writeFileSync(
stampPath,
stampVersionSource(readFileSync(stampPath, 'utf8'), packageEntry.version)
)
assertBuildStepsAllowed(manifest)
for (const step of packageEntry.build) {
run(step.command, step.args, { cwd: path.join(packageRoot, step.cwd), stdio: 'inherit' })
}
}
/** Proves the pinned toolchain still reproduces the untouched published bundles. */
function assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry) {
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
const drifted = listFilesRelative(pristineDir)
.filter((relative) =>
packageEntry.generatedPaths.some((prefix) => toPosix(relative).startsWith(prefix))
)
.filter(
(relative) => !sameBytes(path.join(pristineDir, relative), path.join(packageRoot, relative))
)
if (drifted.length > 0) {
throw new Error(
[
`Rebuilding ${packageEntry.name}@${packageEntry.version} from the pinned commit did not reproduce the published bundles:`,
...drifted.map((relative) => ` ${relative}`),
'',
'Refusing to emit a patch. Either the toolchain drifted or the build ran in the',
'wrong order (a dev `npm run setup` pass de-minifies lib/*.mjs).'
].join('\n')
)
}
}
function toPosix(value) {
return value.split(path.sep).join('/')
}
function overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, destination) {
rmSync(destination, { recursive: true, force: true })
cpSync(pristineDir, destination, { recursive: true })
const packageRoot = path.join(upstreamRoot, packageEntry.packageDir)
for (const relative of listFilesRelative(pristineDir)) {
// package.json carries the registry's version/commit stamp, which the build
// tree has no way to reproduce and which we never want to patch.
if (relative === 'package.json') {
continue
}
const built = path.join(packageRoot, relative)
if (!existsSync(built)) {
throw new Error(`Published file has no build-tree counterpart: ${relative}`)
}
copyFileSync(built, path.join(destination, relative))
}
}
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
maxBuffer: 512 * 1024 * 1024,
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
// `git diff --no-index` exits 1 whenever it finds differences.
if (error.status !== 1 || error.stderr?.length > 0) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
// Why: dropping only the map hunks would ship maps whose offsets no longer line
// up with the patched bundle. Deleting the maps outright is the honest form of
// the same size saving, and the diff carries it as a file-deletion stanza.
function deleteGeneratedSourcemaps(patchedDir, packageEntry) {
for (const relative of listFilesRelative(patchedDir)) {
const posix = toPosix(relative)
if (!packageEntry.generatedPaths.some((prefix) => posix.startsWith(prefix))) {
continue
}
const absolute = path.join(patchedDir, relative)
if (posix.endsWith('.map')) {
rmSync(absolute)
continue
}
// The reference outlives the file it points at, so it goes with it.
const text = readFileSync(absolute, 'utf8')
const stripped = text.replace(/\n\/\/# sourceMappingURL=[^\n]*\n?$/, '')
if (stripped !== text) {
writeFileSync(absolute, stripped)
}
}
}
/** The source of truth for the hand-written half: what the checkout itself holds. */
function diffCheckoutSource(packageRoot) {
return run('git', [...CHECKOUT_DIFF_FLAGS, '--', 'src/'], {
cwd: packageRoot,
env: pnpmDiffEnvironment(),
maxBuffer: 64 * 1024 * 1024
})
}
function publishedSourcePaths(pristineDir) {
return new Set(
listFilesRelative(path.join(pristineDir, 'src')).map((relative) =>
toPosix(path.join('src', relative))
)
)
}
function regeneratePackage(packageEntry, manifest, context) {
const { workDir, repoRoot } = context
const pristineDir = fetchPristinePackage(packageEntry, workDir)
const published = JSON.parse(readFileSync(path.join(pristineDir, 'package.json'), 'utf8'))
assertPublishedCommit(published, packageEntry, manifest.upstream.commit)
const upstreamRoot = ensureUpstreamCheckout(manifest, workDir)
ensureDependencies(upstreamRoot, manifest)
assertPristineSourceMatches(pristineDir, upstreamRoot, packageEntry)
buildPackage(upstreamRoot, packageEntry, manifest)
assertReproducesPristineBundles(pristineDir, upstreamRoot, packageEntry)
run('git', ['reset', '--quiet', '--hard', manifest.upstream.commit], { cwd: upstreamRoot })
run('git', ['apply', '--whitespace=nowarn', path.join(repoRoot, packageEntry.sourcePatch)], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
buildPackage(upstreamRoot, packageEntry, manifest)
const patchedDir = path.join(workDir, 'patched', packageEntry.name.replace(/[@/]/g, '_'))
overlayBuildOutput(pristineDir, upstreamRoot, packageEntry, patchedDir)
if (assertSourcemapPolicy(manifest) === 'delete') {
deleteGeneratedSourcemaps(patchedDir, packageEntry)
}
// Leave the checkout diffable: the pinned commit plus the source patch, with
// no publish-time version stamp mixed in, so `git diff` there is the source
// patch and nothing else.
run('git', ['checkout', '--', packageEntry.versionStampFile], {
cwd: path.join(upstreamRoot, packageEntry.packageDir)
})
const source = diffCheckoutSource(path.join(upstreamRoot, packageEntry.packageDir))
const patch = diffFolders(pristineDir, patchedDir)
assertSourceDerivationsAgree(source, patch, publishedSourcePaths(pristineDir))
return { patch, source }
}
export function regenerateXtermPatches({
mode,
repoRoot = DEFAULT_REPO_ROOT,
workDir = path.join(tmpdir(), 'orca-xterm-patch-build'),
log = console.info
} = {}) {
const manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8'))
assertBuildStepsAllowed(manifest)
assertSourcemapPolicy(manifest)
mkdirSync(workDir, { recursive: true })
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml')
let lockfile = readFileSync(lockfilePath, 'utf8')
let lockfileChanged = false
const failures = []
for (const packageEntry of manifest.packages) {
const shortCommit = manifest.upstream.commit.slice(0, 12)
log(`${packageEntry.name}@${packageEntry.version}: regenerating from ${shortCommit}`)
const { patch: regenerated, source: canonicalSource } = regeneratePackage(
packageEntry,
manifest,
{ workDir, repoRoot }
)
const patchPath = path.join(repoRoot, packageEntry.patch)
const sourcePatchPath = path.join(repoRoot, packageEntry.sourcePatch)
const packageKey = `${packageEntry.name}@${packageEntry.version}`
const hash = patchHash(regenerated)
if (mode === 'write') {
writeFileSync(patchPath, regenerated)
writeFileSync(sourcePatchPath, canonicalSource)
log(` wrote ${packageEntry.patch} (${Buffer.byteLength(regenerated)} bytes)`)
log(` wrote ${packageEntry.sourcePatch} (${Buffer.byteLength(canonicalSource)} bytes)`)
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
lockfile = updateLockfilePatchHash(lockfile, packageKey, hash)
lockfileChanged = true
log(` updated pnpm-lock.yaml patch hash to ${hash}`)
}
continue
}
if (lockfilePatchHashIsStale(lockfile, packageKey, hash)) {
const stale = Array.from(
new Set(readLockfileResolutionHashes(lockfile, packageKey).filter((v) => v !== hash))
)
failures.push(
[
`${packageKey}: pnpm-lock.yaml records a stale patch hash.`,
` patchedDependencies: ${readLockfilePatchHash(lockfile, packageKey)}`,
` resolution keys: ${stale.length > 0 ? stale.join(', ') : 'in sync'}`,
` patch: ${hash}`,
'',
'pnpm keys the patched package by the sha256 of the patch file, so',
'`pnpm install --frozen-lockfile` will fail. Rerun with --write.'
].join('\n')
)
}
const committed = readFileSync(patchPath, 'utf8')
if (committed !== regenerated) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.patch,
committed,
regenerated
})
)
continue
}
const committedSource = readFileSync(sourcePatchPath, 'utf8')
if (committedSource !== canonicalSource) {
failures.push(
formatCheckFailure({
name: packageEntry.name,
patchPath: packageEntry.sourcePatch,
committed: committedSource,
regenerated: canonicalSource
})
)
continue
}
log(` in sync (${Buffer.byteLength(regenerated)} bytes)`)
}
if (lockfileChanged) {
writeFileSync(lockfilePath, lockfile)
}
if (failures.length > 0) {
throw new Error(failures.join('\n\n'))
}
}
const USAGE =
'Usage: regenerate-xterm-patches.mjs [--check | --write] [--work-dir=<path>]\n' +
' --check (default) verifies the shipped patches match the pinned upstream build;\n' +
' --write regenerates them from config/patches/xterm-src/. Build outside this repo:\n' +
' tsc otherwise walks up into our node_modules. See\n' +
' docs/reference/xterm-patch-regeneration.md.'
function main(argv) {
if (argv.includes('--help') || argv.includes('-h')) {
console.info(USAGE)
return
}
// --check is the default, so an unrecognised flag would otherwise silently run a full
// upstream build instead of whatever the caller meant.
const known = (v) =>
!v.startsWith('-') || ['--write', '--check'].includes(v) || v.startsWith('--work-dir=')
const unknown = argv.filter((value) => !known(value))
if (unknown.length > 0) {
throw new Error(`Unknown option: ${unknown.join(', ')}\n${USAGE}`)
}
const write = argv.includes('--write')
const check = argv.includes('--check') || !write
if (write && argv.includes('--check')) {
throw new Error('Pass either --write or --check, not both')
}
const workDirArgument = argv.find((value) => value.startsWith('--work-dir='))
regenerateXtermPatches({
mode: write ? 'write' : 'check',
workDir: workDirArgument ? path.resolve(workDirArgument.slice('--work-dir='.length)) : undefined
})
if (check) {
console.info('xterm patches are in sync with the pinned upstream build.')
}
}
// realpathSync so a symlinked checkout path still registers as a direct run.
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
try {
main(process.argv.slice(2))
} catch (error) {
console.error(`\n${error.message}\n`)
process.exit(1)
}
}
@@ -0,0 +1,478 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CHECKOUT_DIFF_FLAGS,
PNPM_DIFF_FLAGS,
assertBuildStepsAllowed,
assertPublishedCommit,
assertSourceDerivationsAgree,
assertSourcemapPolicy,
firstDifferenceIndex,
formatCheckFailure,
generatedHunks,
lockfilePatchHashIsStale,
normalizePnpmDiff,
patchHash,
pnpmDiffEnvironment,
readLockfilePatchHash,
readLockfileResolutionHashes,
selectPatchEntries,
sourceHunks,
splitPatchEntries,
stampVersionSource,
updateLockfilePatchHash
} from './regenerate-xterm-patches.mjs'
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_PATH = path.join(REPO_ROOT, 'config', 'patches', 'xterm-upstream.json')
const temporaryDirectories = []
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
async function createDirectory() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-xterm-patch-'))
temporaryDirectories.push(directory)
return directory
}
async function writeTree(root, files) {
for (const [relative, contents] of Object.entries(files)) {
const target = path.join(root, relative)
await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, contents)
}
}
/** The three exported diff pieces, composed the way the generator composes them. */
function diffFolders(folderA, folderB) {
let stdout
try {
stdout = execFileSync('git', [...PNPM_DIFF_FLAGS, folderA, folderB], {
encoding: 'utf8',
env: pnpmDiffEnvironment(),
stdio: ['ignore', 'pipe', 'pipe']
})
} catch (error) {
if (error.status !== 1) {
throw error
}
stdout = error.stdout
}
return normalizePnpmDiff(stdout, folderA, folderB)
}
const PRISTINE = {
'src/Widget.ts': 'export function widget(): number {\n return 1\n}\n',
'src/Other.ts': 'export const other = 0\n',
'lib/widget.js': 'function widget(){return 1}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAA"}\n',
'package.json': '{\n "name": "@scope/widget"\n}\n'
}
const PATCHED = {
...PRISTINE,
'src/Widget.ts': 'export function widget(): number {\n return 2\n}\n',
'lib/widget.js': 'function widget(){return 2}\n',
'lib/widget.js.map': '{"version":3,"sources":["../src/Widget.ts"],"mappings":"AAAC"}\n'
}
describe('pnpm diff format', () => {
it('keeps the exact git flags pnpm uses, so patches survive `pnpm patch-commit`', () => {
expect(PNPM_DIFF_FLAGS).toEqual([
'-c',
'core.safecrlf=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
'--ignore-cr-at-eol',
'--irreversible-delete',
'--full-index',
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
])
})
it('blanks the config-bearing environment variables', () => {
const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' })
expect(environment).toMatchObject({
PATH: '/usr/bin',
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
})
})
it('strips both scratch folder prefixes from headers and index lines', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(patch).not.toContain(root)
expect(patch).toContain('diff --git a/lib/widget.js b/lib/widget.js')
expect(patch).toContain('--- a/src/Widget.ts')
expect(patch).toContain('+++ b/src/Widget.ts')
})
it('drops a trailing no-newline marker and .DS_Store entries', () => {
const withMarker = 'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n'
expect(normalizePnpmDiff(withMarker, '/a', '/b')).toBe(
'diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n'
)
const withJunk = [
'diff --git a/.DS_Store b/.DS_Store\n',
'index 000..111\n',
'Binary files differ\n',
'diff --git a/lib/x.js b/lib/x.js\n',
'@@ -1 +1 @@\n-a\n+b\n'
].join('')
expect(normalizePnpmDiff(withJunk, '/a', '/b')).toBe(
'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n'
)
})
})
describe('patch entry splitting', () => {
it('separates hand-edited source hunks from generated bundle hunks', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(splitPatchEntries(patch).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map',
'src/Widget.ts'
])
expect(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path)).toEqual([
'src/Widget.ts'
])
expect(splitPatchEntries(generatedHunks(patch, ['lib/'])).map((entry) => entry.path)).toEqual([
'lib/widget.js',
'lib/widget.js.map'
])
})
it('rejects renames rather than emitting a header it cannot round-trip', () => {
expect(() => splitPatchEntries('diff --git a/old.ts b/new.ts\n')).toThrow(
/renames are not supported/
)
})
it('concatenating the two halves reproduces the whole patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
expect(generatedHunks(patch, ['lib/']) + sourceHunks(patch)).toBe(patch)
})
})
describe('round-trip stability', () => {
it('re-diffing an applied patch yields the identical patch', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patch = diffFolders(folderA, folderB)
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
const patchFile = path.join(root, 'round-trip.patch')
await writeFile(patchFile, patch)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PATCHED['lib/widget.js']
)
expect(diffFolders(folderA, replay)).toBe(patch)
})
it('applying only the source half leaves the bundle untouched', async () => {
const root = await createDirectory()
const folderA = path.join(root, 'pristine')
const folderB = path.join(root, 'patched')
await writeTree(folderA, PRISTINE)
await writeTree(folderB, PATCHED)
const patchFile = path.join(root, 'src.patch')
await writeFile(patchFile, sourceHunks(diffFolders(folderA, folderB)))
const replay = path.join(root, 'replay')
await writeTree(replay, PRISTINE)
execFileSync('git', ['apply', '-p1', '--whitespace=nowarn', patchFile], { cwd: replay })
expect(await readFile(path.join(replay, 'src/Widget.ts'), 'utf8')).toBe(
PATCHED['src/Widget.ts']
)
expect(await readFile(path.join(replay, 'lib/widget.js'), 'utf8')).toBe(
PRISTINE['lib/widget.js']
)
})
})
// The source patch is derived from the upstream checkout, not from the emitted
// patch, so a hunk against an unpublished file survives `--write` instead of
// deleting itself on the next run.
describe('unpublished source files', () => {
const publishedEntry = [
'diff --git a/src/browser/Types.ts b/src/browser/Types.ts',
'index 1111111..2222222 100644',
'--- a/src/browser/Types.ts',
'+++ b/src/browser/Types.ts',
'@@ -1 +1,2 @@',
' interface ICompositionHelper {',
'+ handleCompositionInput(data: string): boolean;',
''
].join('\n')
const unpublishedEntry = [
'diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts',
'index 3333333..4444444 100644',
'--- a/src/browser/TestUtils.test.ts',
'+++ b/src/browser/TestUtils.test.ts',
'@@ -1 +1,2 @@',
' class MockCompositionHelper {',
'+ public handleCompositionInput(): boolean { return false; }',
''
].join('\n')
const published = new Set(['src/browser/Types.ts'])
it('accepts a source diff that carries an extra unpublished file', () => {
expect(() =>
assertSourceDerivationsAgree(publishedEntry + unpublishedEntry, publishedEntry, published)
).not.toThrow()
})
it('fails when the two derivations disagree on a published file', () => {
expect(() =>
assertSourceDerivationsAgree(
publishedEntry.replace('boolean;', 'void;'),
publishedEntry,
published
)
).toThrow(/disagree on a published source file/)
})
it('diffs the checkout with pnpm formatting so the two halves stay comparable', () => {
expect(CHECKOUT_DIFF_FLAGS).toEqual(PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index'))
expect(CHECKOUT_DIFF_FLAGS).toContain('--full-index')
expect(CHECKOUT_DIFF_FLAGS).not.toContain('--no-index')
})
})
describe('manifest guards', () => {
const packageEntry = { name: '@xterm/xterm', version: '6.1.0-beta.287' }
const commit = '53a98a720ae4a973e384fa2440880d09537132f3'
it('accepts a tarball that names the pinned commit', () => {
const published = { version: '6.1.0-beta.287', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).not.toThrow()
})
it('fails when a version bump moved the upstream commit', () => {
const published = { version: '6.1.0-beta.287', commit: 'f'.repeat(40) }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(
/was published from commit[\s\S]*Update upstream\.commit/
)
})
it('fails when the registry serves a different version than the manifest pins', () => {
const published = { version: '6.1.0-beta.288', commit }
expect(() => assertPublishedCommit(published, packageEntry, commit)).toThrow(/registry served/)
})
it('fails when the tarball carries no commit stamp at all', () => {
expect(() =>
assertPublishedCommit({ version: '6.1.0-beta.287' }, packageEntry, commit)
).toThrow(/\(absent\)/)
})
it('refuses a build step that would de-minify the bundle', () => {
const manifest = {
forbiddenBuildScripts: { why: 'dev esbuild', scripts: ['setup'] },
packages: [
{
name: '@xterm/xterm',
build: [
{ cwd: '.', command: 'npm', args: ['run', 'setup'] },
{ cwd: '.', command: 'npm', args: ['run', 'package'] }
]
}
]
}
expect(() => assertBuildStepsAllowed(manifest)).toThrow(/`npm run setup` is forbidden/)
})
it('refuses a sourcemap policy it does not implement', () => {
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'delete' } })).toBe('delete')
expect(assertSourcemapPolicy({ sourcemaps: { policy: 'include' } })).toBe('include')
expect(() => assertSourcemapPolicy({ sourcemaps: { policy: 'exclude' } })).toThrow(
/must be one of include, delete, got "exclude"/
)
expect(() => assertSourcemapPolicy({})).toThrow(/got undefined/)
})
it('stamps the published version into the version source', () => {
const source = "export const XTERM_VERSION = '6.0.0';\n"
expect(stampVersionSource(source, '6.1.0-beta.287')).toBe(
"export const XTERM_VERSION = '6.1.0-beta.287';\n"
)
expect(() => stampVersionSource('export const OTHER = 1\n', '6.1.0')).toThrow(/XTERM_VERSION/)
})
})
describe('lockfile coupling', () => {
const lockfile = [
'patchedDependencies:',
" '@xterm/xterm@6.1.0-beta.287':",
` hash: ${'0'.repeat(64)}`,
' path: config/patches/@xterm__xterm@6.1.0-beta.287.patch',
' node-pty@1.1.0:',
` hash: ${'1'.repeat(64)}`,
' path: config/patches/node-pty@1.1.0.patch',
'snapshots:',
` '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=${'0'.repeat(64)}))':`,
` '@xterm/xterm': 6.1.0-beta.287(patch_hash=${'0'.repeat(64)})`,
` node-pty@1.1.0(patch_hash=${'1'.repeat(64)}):`,
''
].join('\n')
it('hashes the patch the way pnpm keys the store directory', () => {
expect(patchHash('diff --git a/x b/x\n')).toBe(
createHash('sha256').update('diff --git a/x b/x\n').digest('hex')
)
})
it('reads quoted and unquoted package keys', () => {
expect(readLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287')).toBe('0'.repeat(64))
expect(readLockfilePatchHash(lockfile, 'node-pty@1.1.0')).toBe('1'.repeat(64))
})
it('rewrites only the targeted entry', () => {
const updated = updateLockfilePatchHash(lockfile, '@xterm/xterm@6.1.0-beta.287', 'a'.repeat(64))
expect(readLockfilePatchHash(updated, '@xterm/xterm@6.1.0-beta.287')).toBe('a'.repeat(64))
expect(readLockfilePatchHash(updated, 'node-pty@1.1.0')).toBe('1'.repeat(64))
expect(updated.split('\n')).toHaveLength(lockfile.split('\n').length)
})
// pnpm repeats the hash in every resolution key. Rewriting only patchedDependencies
// installs fine on a warm store and drifts on a cold one, so it fails in CI only.
it('rewrites the resolution keys as well as patchedDependencies', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
expect(readLockfileResolutionHashes(lockfile, key)).toEqual(['0'.repeat(64), '0'.repeat(64)])
const updated = updateLockfilePatchHash(lockfile, key, 'a'.repeat(64))
expect(readLockfileResolutionHashes(updated, key)).toEqual(['a'.repeat(64), 'a'.repeat(64)])
expect(readLockfileResolutionHashes(updated, 'node-pty@1.1.0')).toEqual(['1'.repeat(64)])
expect(updated).not.toContain('0'.repeat(64))
})
it('reports a lockfile stale in its resolution keys alone', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
const halfUpdated = lockfile.replace(`hash: ${'0'.repeat(64)}`, `hash: ${'a'.repeat(64)}`)
expect(readLockfilePatchHash(halfUpdated, key)).toBe('a'.repeat(64))
expect(lockfilePatchHashIsStale(halfUpdated, key, 'a'.repeat(64))).toBe(true)
expect(lockfilePatchHashIsStale(lockfile, key, '0'.repeat(64))).toBe(false)
})
it('fails loudly when the package is not patched at all', () => {
expect(() => readLockfilePatchHash(lockfile, '@xterm/addon-webgl@0.20.0-beta.286')).toThrow(
/no patchedDependencies entry/
)
})
})
describe('check-mode reporting', () => {
it('points at the source patch instead of the bundle', () => {
const message = formatCheckFailure({
name: '@xterm/xterm',
patchPath: 'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
committed: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+b\n',
regenerated: 'diff --git a/lib/x.js b/lib/x.js\n@@ -1 +1 @@\n-a\n+c\n'
})
expect(message).toContain('Do not edit them')
expect(message).toContain('--write')
expect(message).toContain('docs/reference/xterm-patch-regeneration.md')
expect(message).toContain('files [lib/x.js]')
})
it('locates the first differing character', () => {
expect(firstDifferenceIndex('abc', 'abd')).toBe(2)
expect(firstDifferenceIndex('abc', 'abc')).toBe(-1)
expect(firstDifferenceIndex('abc', 'abcd')).toBe(3)
})
})
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const key = `${packageEntry.name}@${packageEntry.version}`
expect(readLockfilePatchHash(lockfile, key)).toBe(patchHash(patch))
}
})
it('keeps the source patch equal to the full patch on every published file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
const published = new Set(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path))
expect(selectPatchEntries(source, (file) => published.has(file))).toBe(sourceHunks(patch))
expect(generatedHunks(patch, packageEntry.generatedPaths)).not.toBe('')
}
})
// Why: the source patch is deliberately a superset. Anything extra must be a
// file upstream's .npmignore strips, because a hunk against a published file
// that never reached the shipped patch would be a hunk that is not installed.
it('only exceeds the full patch on files the registry does not publish', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
for (const packageEntry of manifest.packages) {
const patch = await readFile(path.join(REPO_ROOT, packageEntry.patch), 'utf8')
const source = await readFile(path.join(REPO_ROOT, packageEntry.sourcePatch), 'utf8')
const published = new Set(splitPatchEntries(sourceHunks(patch)).map((entry) => entry.path))
const unpublished = splitPatchEntries(source)
.map((entry) => entry.path)
.filter((file) => !published.has(file))
for (const file of unpublished) {
expect(file, `${file} is not an unpublished test file`).toMatch(/\.test\.ts$/)
}
}
})
it('pins a full upstream commit and a buildable package entry', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
expect(manifest.upstream.commit).toMatch(/^[0-9a-f]{40}$/)
expect(manifest.packages.length).toBeGreaterThan(0)
expect(() => assertBuildStepsAllowed(manifest)).not.toThrow()
})
})
+229
View File
@@ -0,0 +1,229 @@
# xterm Patch Regeneration
## Scope
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
not taken: the IME composition hooks, the `xterm-composition-*` custom events
they raise, the `ITerminal` surface those events widen, and a `SortedList`
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
That patch touches eight files. Four are hand-authored source
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
generated, and this document exists so nobody edits it by hand.
The source patch carries a fifth file, `src/browser/TestUtils.test.ts`, which
the shipped patch does not and cannot; see
[The Source Patch Is a Superset](#the-source-patch-is-a-superset).
`config/patches/xterm-src/@xterm__xterm@<version>.src.patch` is the source of
truth. Everything else is derived from it by
`config/scripts/regenerate-xterm-patches.mjs`, which is pinned to the exact
upstream commit the published tarball was built from.
This policy covers `@xterm/xterm` only. The addon patches
(`@xterm/addon-webgl`, `@xterm/addon-serialize`) are still hand-edited bundles
and are tracked separately; see [Known Gaps](#known-gaps).
## Rules
1. Never edit `config/patches/@xterm__xterm@<version>.patch`. Edit the source
patch and regenerate.
2. Never edit `lib/` inside a patched `node_modules` tree and re-run
`pnpm patch-commit`. That is how bundle hunks stop matching their sources.
3. Every source change must land together with the regenerated bundle hunks and
the `pnpm-lock.yaml` hash bump, in one commit.
4. The upstream commit lives in `config/patches/xterm-upstream.json`, not in a
comment. A version bump that leaves it stale fails the generator, it does not
silently patch the wrong tree.
5. Sourcemaps are deleted, not patched and not silently omitted. The patch moves
the bundle, so a retained map would have to move with it; dropping only the
map hunks ships offsets that point at the wrong code. Deletion is the honest
form of that saving, and `sourcemaps.policy` in the manifest controls it.
6. `--check` is the authority on the lockfile, not `pnpm install`. pnpm writes the
patch hash in two places — `patchedDependencies` and every resolution key that
depends on the patched package — and on a warm store it will leave the
resolution keys at their previous value while reporting success. That installs
locally and drifts on CI's cold store. Always finish on step 4, and if it
reports a stale hash after an install, rerun `--write`.
## Workflow
```sh
# 1. Edit the source hunks.
$EDITOR config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
# 2. Rebuild the bundle hunks, the full patch, and the lockfile hash.
node config/scripts/regenerate-xterm-patches.mjs --write
# 3. Reinstall so node_modules picks up the new patch hash.
pnpm install
# 4. Confirm the tree is self-consistent.
node config/scripts/regenerate-xterm-patches.mjs --check
```
Editing a patch file by hand is awkward for anything larger than a one-liner.
For a substantial change, work in the generator's own checkout instead — after
any run it is left at the pinned commit with the source patch applied:
```sh
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir=/tmp/xterm
$EDITOR /tmp/xterm/upstream/src/browser/input/CompositionHelper.ts
git -C /tmp/xterm/upstream diff -- src/ > config/patches/xterm-src/@xterm__xterm@6.1.0-beta.287.src.patch
node config/scripts/regenerate-xterm-patches.mjs --write --work-dir=/tmp/xterm
```
`--write` rewrites the source patch into the canonical form it would emit on a
re-diff, so a hand-produced `git diff` gets normalized on the first run rather
than fighting `--check` forever.
Run the checkout outside this repository. A build tree underneath it makes
`tsgo` walk up into Orca's own `node_modules` and fail with `TS2300: Duplicate
identifier`, which is a symptom of where the tree sits and not of the patch.
## How the Commit Is Known
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
each published tarball names the commit that built it. The generator asserts
that stamp against `xterm-upstream.json` and then compares the tarball's `src/`
against the checkout file by file. Only `src/common/Version.ts` may differ,
because `publish.js` rewrites the version immediately before packaging; the
generator applies the same stamp.
That pair of checks is what makes the rebuild trustworthy. Without them a wrong
commit would still produce a plausible-looking 7 MB patch.
## The Source Patch Is a Superset
Patching `ICompositionHelper` widens an interface, so every implementor has to
follow — including `MockCompositionHelper` in upstream's
`src/browser/TestUtils.test.ts`. Without that hunk the patched checkout does not
type-check and `npm run package` never reaches webpack, so the generator cannot
build the patched bundles at all.
Upstream's `.npmignore` strips `*.test.ts`, so that file is not in the published
tarball. The shipped patch is a diff against the published tarball, and it
therefore *cannot* name the file — correctly, since pnpm has nothing there to
patch.
That is why the source patch is derived from the upstream checkout
(`git diff -- src/`) and not from the emitted patch. Deriving it from the
emitted patch is the trap: `--write` would filter the hunk out through the
published file set and delete it, so the fix that makes the build work would
erase itself on the first run that used it.
The two derivations are still cross-checked. `assertSourceDerivationsAgree`
requires them to be byte-identical on every file the tarball publishes, so the
carve-out stays confined to files upstream does not ship rather than becoming a
place where the source patch and the shipped patch can quietly disagree. The
checkout diff uses pnpm's own formatting flags minus `--no-index`, which is what
makes that byte comparison meaningful.
## Build Order
Upstream's publish path is `npm ci` → stamp `Version.ts``npm run package`.
`npm run package` runs webpack for `lib/xterm.js` and then, via `postpackage`,
`bin/esbuild_all.mjs --prod` for `lib/xterm.mjs`.
**Do not run `npm run setup` after the packaging build.** `setup` is the
development esbuild pass with `minify: false`. Running it afterwards overwrites
`lib/xterm.mjs` with an unminified bundle and a map that no longer matches, and
the resulting patch is silently wrong — the failure mode is a `.mjs` that is
50% larger than the published one, which is easy to miss inside a 7 MB diff.
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
to run a build step that names one of those scripts.
The generator also builds the *unmodified* commit first and asserts that it
reproduces the published `lib/` byte for byte before it emits anything. A
toolchain or build-order problem therefore surfaces as an explicit "did not
reproduce the published bundles" error rather than as 7 MB of mystery diff.
## The Lockfile Moves With the Patch
pnpm derives the `patchedDependencies` hash in `pnpm-lock.yaml` — and the
`.pnpm/@xterm+xterm@<version>_patch_hash=<hash>/` store directory name — from
the sha256 of the patch file itself. A regenerated patch without the lockfile
bump fails `pnpm install --frozen-lockfile` on every machine except the
author's. `--write` makes that edit; `--check` fails if it is missing.
`config/scripts/regenerate-xterm-patches.test.mjs` asserts the same thing
without a network or a build, so the ordinary test job catches lockfile drift
in milliseconds even though the full rebuild runs in its own CI lane.
## Toolchain Pin
`toolchain` in the manifest records what upstream's `package-lock.json` resolves
at the pinned commit, and the generator fails if `npm ci` produces something
else. The entry that matters is `@typescript/native-preview`
(`tsgo`), which upstream pins to a **dated development build**
`7.0.0-dev.20260521.1` at the time of writing. It is a real published version
and npm does not prune old releases, but it is the one dependency of this scheme
that is not a stable release.
If that version ever becomes unresolvable the generator fails with a toolchain
error naming it. Recovery is to move the pin to the next upstream commit whose
`package-lock.json` resolves, re-verify that the rebuild still reproduces the
published bundles, and regenerate. The committed patch keeps working the whole
time — only regeneration is blocked, so this is never an outage.
## Version Bumps
Bumping `@xterm/xterm` is:
1. Update the version in `package.json` and run `pnpm install`.
2. Rename both patch files to the new version and update `patch`,
`sourcePatch`, and `version` in `xterm-upstream.json`.
3. Update `upstream.commit` to the `commit` field of the new tarball's
`package.json`, and `toolchain` to whatever the new `package-lock.json`
resolves.
4. `node config/scripts/regenerate-xterm-patches.mjs --write`.
Step 4 is where a real upstream conflict shows up: `git apply` of the source
patch fails against the new tree. Resolve it in the checkout, re-diff, and
rerun. The bundle hunks need no attention at any point.
## Why Not Vendor a Fork
A vendored `@xterm/xterm` fork removes the patch entirely, but it moves Orca off
the published package, so every upstream beta becomes a merge rather than a
version bump, and Orca inherits responsibility for building and publishing a
package it does not own. The patch is four small source hunks against a commit
that reproduces byte for byte; a fork is a much larger standing cost for the
same result.
## Why Not Handle Composition at Runtime
`CompositionHelper` hooks four private call sites upstream of `onData`, and
`SortedList` has no public surface at all. There is no supported extension point
that reaches either, so a runtime shim would mean reaching into `_core`
internals that upstream renames freely between betas. The patch is the smaller
risk.
## CI Contract
`xterm_patch_sync` in `.github/workflows/pr.yml` runs
`regenerate-xterm-patches.mjs --check` on every PR and is part of the `verify`
aggregate. It clones the pinned commit, installs upstream's toolchain, builds
twice, and byte-compares the result against the committed patch. A warm run is
about eight seconds of work around the clone and install.
`config/scripts/regenerate-xterm-patches.test.mjs` covers the pure pieces —
pnpm's diff flags and normalization, hunk splitting, round-trip stability, the
commit and build-order assertions, and lockfile coupling — with no network and
no build, so they run in the ordinary test shards.
## Known Gaps
`@xterm/addon-webgl` and `@xterm/addon-serialize` are still hand-edited minified
bundles. Their patches carry a literal `/* PATCH(orca): ... */` comment inside
minified code and parser round-trip artifacts, and neither patch touches its
`.map` file, so both addons currently ship sourcemaps whose offsets do not match
the shipped bundle — the defect `sourcemaps.policy` now avoids for `@xterm/xterm`
and which folding them into this manifest would also fix. Both addons build from
the same pinned commit and reproduce
byte for byte, so they can be folded into this manifest as additional `packages`
entries; that change needs e2e sign-off because, unlike `@xterm/xterm`, it will
not be a byte-for-byte no-op.