diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00e11d955d..0d3454b567 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: - name: Install shell test dependencies run: | sudo apt-get update - sudo apt-get install -y fish + sudo apt-get install -y fish xvfb - name: Install deps run: npm ci @@ -49,5 +49,8 @@ jobs: - name: Test run: npm test + - name: Test synchronized terminal rendering + run: xvfb-run -a npm run test:xterm-sync-render + - name: Build run: npm run build diff --git a/package.json b/package.json index 90a6450843..c895047cab 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never", "pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never", "pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never", - "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs", + "postinstall": "patch-package && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs && node scripts/patch-xterm-sync-render.cjs && node scripts/clean-vite-cache.cjs", "rebuild": "electron-builder install-app-deps", "tool:cli": "node electron/cli/netcatty-tool-cli.cjs", "generate:capability-tools": "node scripts/generate-capability-tools.cjs", @@ -62,7 +62,8 @@ "bench:sync-crdt": "tsx scripts/bench-sync-crdt.ts", "test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs", "test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs", - "test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs" + "test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs", + "test:xterm-sync-render": "cross-env NETCATTY_XTERM_SYNC_RENDER_LIVE=1 node scripts/xterm-sync-render.live.test.cjs" }, "dependencies": { "@eslint-community/regexpp": "4.12.2", diff --git a/scripts/patch-xterm-sync-render.cjs b/scripts/patch-xterm-sync-render.cjs new file mode 100644 index 0000000000..bf77388e44 --- /dev/null +++ b/scripts/patch-xterm-sync-render.cjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/* global process, console */ +/** + * Render each completed DEC 2026 synchronized-output frame immediately. + * + * xterm normally routes a mode-close refresh through requestAnimationFrame. + * If the next synchronized frame starts before that callback, rendering is + * suppressed until xterm's one-second safety timeout. This patch marks the + * mode-close refresh as synchronous and carries that signal to RenderService. + * It also renders a flushed synchronized-output buffer synchronously, matching + * the pending upstream proposal for frames split across input chunks. + * + * Upstream: https://github.com/xtermjs/xterm.js/pull/6073. Applied to the + * installed minified builds like patch-xterm-webgl-atlas.cjs. The exact package + * version and complete surrounding expressions are checked. Both CJS and ESM + * builds are validated and staged before either is atomically replaced. + * + * Idempotent. + */ +"use strict"; +const fs = require("node:fs"); +const path = require("node:path"); + +const EXPECTED_VERSION = "6.1.0-beta.220"; +const VERSION_FILE = "node_modules/@xterm/xterm/package.json"; +const REFRESH_MARKER = "/*netcatty:sync-render*/"; +const LISTENER_MARKER = "/*netcatty:sync-render-listener*/"; +const CLOSE_MARKER = "/*netcatty:sync-render-close*/"; + +const markedMethod = (value) => `${value.slice(0, -1)}${REFRESH_MARKER}}`; +const markedExpression = (value, marker) => `${value}${marker}`; + +const TARGETS = [ + { + file: "node_modules/@xterm/xterm/lib/xterm.js", + edits: [ + { + from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + mark: markedMethod, + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1,e?.sync??!1)))", + mark: (value) => markedExpression(value, LISTENER_MARKER), + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", + mark: (value) => markedExpression(value, CLOSE_MARKER), + legacy: [ + "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", + ], + }, + ], + }, + { + file: "node_modules/@xterm/xterm/lib/xterm.mjs", + edits: [ + { + from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + mark: markedMethod, + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1,t?.sync??!1)))", + mark: (value) => markedExpression(value, LISTENER_MARKER), + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", + mark: (value) => markedExpression(value, CLOSE_MARKER), + legacy: [ + "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", + ], + }, + ], + }, +]; + +let already = 0; +let upstream = 0; +let missing = 0; +const writes = []; + +const count = (source, value) => source.split(value).length - 1; +const warnInvalid = (file, detail) => { + console.warn(`[patch-xterm-sync-render] ERROR: ${detail} in ${file}. ` + + "Refresh the exact targets before upgrading @xterm/xterm."); + missing++; +}; + +try { + const versionPath = path.resolve(process.cwd(), VERSION_FILE); + const version = JSON.parse(fs.readFileSync(versionPath, "utf8")).version; + if (version !== EXPECTED_VERSION) { + warnInvalid(VERSION_FILE, `expected version ${EXPECTED_VERSION}, found ${version}`); + } +} catch { + warnInvalid(VERSION_FILE, "package version is missing or invalid"); +} + +for (const target of TARGETS) { + const abs = path.resolve(process.cwd(), target.file); + let source; + let stat; + try { + source = fs.readFileSync(abs, "utf8"); + stat = fs.statSync(abs); + } catch { + warnInvalid(target.file, "target is missing"); + continue; + } + + let output = source; + let markedEdits = 0; + let upstreamEdits = 0; + let pendingEdits = 0; + let invalid = false; + for (const edit of target.edits) { + const marked = edit.mark(edit.to); + const markedMatches = count(source, marked); + const fromMatches = count(source, edit.from); + const toMatches = count(source, edit.to); + const legacyMatches = edit.legacy?.filter((value) => count(source, value) > 0) ?? []; + const legacy = legacyMatches.find((value) => count(source, value) === 1); + if (markedMatches === 1) { + markedEdits++; + } else if (legacy && legacyMatches.length === 1 && fromMatches === 0 && toMatches === 0) { + output = output.replace(legacy, marked); + pendingEdits++; + } else if (fromMatches === 1 && toMatches === 0) { + output = output.replace(edit.from, marked); + pendingEdits++; + } else if (fromMatches === 0 && toMatches === 1) { + upstreamEdits++; + } else { + invalid = true; + break; + } + } + + if (invalid || (upstreamEdits > 0 && upstreamEdits !== target.edits.length)) { + warnInvalid(target.file, "complete synchronized-render contexts were not found in one consistent state"); + } else if (upstreamEdits === target.edits.length) { + upstream++; + } else if (markedEdits === target.edits.length) { + already++; + } else if (markedEdits + pendingEdits === target.edits.length && pendingEdits > 0) { + writes.push({ abs, file: target.file, mode: stat.mode, source, output }); + } else { + warnInvalid(target.file, "synchronized-render edits were incomplete"); + } +} + +let patched = 0; +if (missing === 0 && writes.length > 0) { + const staged = []; + const committed = []; + try { + for (const write of writes) { + const temp = `${write.abs}.netcatty-${process.pid}-${staged.length}.tmp`; + fs.writeFileSync(temp, write.output, { encoding: "utf8", flag: "wx", mode: write.mode }); + staged.push({ ...write, temp }); + } + for (const write of staged) { + fs.renameSync(write.temp, write.abs); + committed.push(write); + } + patched = committed.length; + } catch (error) { + console.warn(`[patch-xterm-sync-render] ERROR: atomic replacement failed: ${error.message}`); + missing++; + for (const write of committed.reverse()) { + try { + const rollback = `${write.abs}.netcatty-${process.pid}-rollback.tmp`; + fs.writeFileSync(rollback, write.source, { encoding: "utf8", flag: "wx", mode: write.mode }); + fs.renameSync(rollback, write.abs); + } catch (rollbackError) { + console.warn(`[patch-xterm-sync-render] ERROR: rollback failed for ${write.file}: ${rollbackError.message}`); + } + } + } finally { + for (const write of staged) { + try { + fs.rmSync(write.temp, { force: true }); + } catch {} + } + } +} + +console.log( + `[patch-xterm-sync-render] patched=${patched} already=${already} upstream=${upstream} missing=${missing}`, +); + +if (missing > 0) process.exitCode = 1; diff --git a/scripts/patch-xterm-sync-render.test.cjs b/scripts/patch-xterm-sync-render.test.cjs new file mode 100644 index 0000000000..60b12cae18 --- /dev/null +++ b/scripts/patch-xterm-sync-render.test.cjs @@ -0,0 +1,176 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); +const script = path.resolve(__dirname, "patch-xterm-sync-render.cjs"); +const version = "6.1.0-beta.220"; +const markers = [ + "/*netcatty:sync-render*/", + "/*netcatty:sync-render-listener*/", + "/*netcatty:sync-render-close*/", +]; +const targets = [ + { + file: "node_modules/@xterm/xterm/lib/xterm.js", + edits: [ + { + from: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),(i||r)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1,e?.sync??!1)))", + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", + legacy: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", + }, + ], + }, + { + file: "node_modules/@xterm/xterm/lib/xterm.mjs", + edits: [ + { + from: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + to: "refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),(r||o)?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}", + }, + { + from: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1)))", + to: "this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1,t?.sync??!1)))", + }, + { + from: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire(void 0);break", + to: "case 2026:this._coreService.decPrivateModes.synchronizedOutput?(this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0})):this._onRequestRefreshRows.fire(void 0);break", + legacy: "case 2026:this._coreService.decPrivateModes.synchronizedOutput=!1,this._onRequestRefreshRows.fire({sync:!0});break/*netcatty:sync-render-close*/", + }, + ], + }, +]; + +const makeTmp = (t, packageVersion = version) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-sync-patch-")); + const packageFile = path.join(dir, "node_modules/@xterm/xterm/package.json"); + fs.mkdirSync(path.dirname(packageFile), { recursive: true }); + fs.writeFileSync(packageFile, JSON.stringify({ version: packageVersion })); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +}; + +const sourceFor = (target, state) => target.edits + .map((edit, index) => state === "from" ? edit.from : `${edit.to}${markers[index]}`) + .join(" separator "); + +const markedFor = (target) => target.edits + .map((edit, index) => index === 0 + ? `${edit.to.slice(0, -1)}${markers[index]}}` + : `${edit.to}${markers[index]}`) + .join(" separator "); + +const writeBuild = (root, target, source = sourceFor(target, "from")) => { + const file = path.join(root, target.file); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `prefix ${source} suffix`); +}; + +test("patches both xterm builds and is idempotent", async (t) => { + const root = makeTmp(t); + for (const target of targets) writeBuild(root, target); + + const first = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(first.stdout, /patched=2 already=0 upstream=0 missing=0/); + assert.equal(first.stderr, ""); + const afterFirstRun = targets.map((target) => + fs.readFileSync(path.join(root, target.file), "utf8") + ); + for (const source of afterFirstRun) { + for (const marker of markers) assert.equal(source.includes(marker), true); + } + + const second = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(second.stdout, /patched=0 already=2 upstream=0 missing=0/); + assert.deepEqual( + targets.map((target) => fs.readFileSync(path.join(root, target.file), "utf8")), + afterFirstRun, + ); +}); + +test("leaves the complete upstream-equivalent fix untouched", async (t) => { + const root = makeTmp(t); + for (const target of targets) { + writeBuild(root, target, target.edits.map((edit) => edit.to).join(" separator ")); + } + const result = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(result.stdout, /patched=0 already=0 upstream=2 missing=0/); + for (const target of targets) { + const source = fs.readFileSync(path.join(root, target.file), "utf8"); + for (const marker of markers) assert.equal(source.includes(marker), false); + } +}); + +test("upgrades the previous unconditional close marker", async (t) => { + const root = makeTmp(t); + for (const target of targets) { + const source = markedFor(target).replace(`${target.edits[2].to}${markers[2]}`, target.edits[2].legacy); + writeBuild(root, target, source); + } + const result = await execFileAsync(process.execPath, [script], { cwd: root }); + assert.match(result.stdout, /patched=2 already=0 upstream=0 missing=0/); + for (const target of targets) { + const source = fs.readFileSync(path.join(root, target.file), "utf8"); + assert.equal(source.includes(target.edits[2].legacy), false); + assert.equal(source.includes(`${target.edits[2].to}${markers[2]}`), true); + } +}); + +test("validates every build before changing either one", async (t) => { + const root = makeTmp(t); + writeBuild(root, targets[0]); + writeBuild(root, targets[1], "unknown xterm build"); + const original = fs.readFileSync(path.join(root, targets[0].file), "utf8"); + + await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => { + assert.equal(error.code, 1); + assert.match(error.stdout, /patched=0 already=0 upstream=0 missing=1/); + return true; + }); + assert.equal(fs.readFileSync(path.join(root, targets[0].file), "utf8"), original); +}); + +test("rejects partial, ambiguous, and unexpected-version builds", async (t) => { + const cases = [ + { + version, + cjs: `${sourceFor(targets[0], "from")} ${targets[0].edits[0].from}`, + esm: sourceFor(targets[1], "from"), + }, + { + version, + cjs: targets[0].edits.map((edit, index) => index === 0 ? edit.to : edit.from).join(" separator "), + esm: sourceFor(targets[1], "from"), + }, + { + version: "6.1.0-beta.221", + cjs: sourceFor(targets[0], "from"), + esm: sourceFor(targets[1], "from"), + }, + ]; + + for (const entry of cases) { + const root = makeTmp(t, entry.version); + writeBuild(root, targets[0], entry.cjs); + writeBuild(root, targets[1], entry.esm); + await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => { + assert.equal(error.code, 1); + assert.match(error.stdout, /patched=0/); + return true; + }); + } +}); diff --git a/scripts/xterm-sync-render.live.test.cjs b/scripts/xterm-sync-render.live.test.cjs new file mode 100644 index 0000000000..d767d4cc9c --- /dev/null +++ b/scripts/xterm-sync-render.live.test.cjs @@ -0,0 +1,197 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const LIVE_ENV = "NETCATTY_XTERM_SYNC_RENDER_LIVE"; +const USER_DATA_ENV = "NETCATTY_XTERM_SYNC_RENDER_USER_DATA"; +const MODULE_ROOT_ENV = "NETCATTY_XTERM_SYNC_RENDER_MODULE_ROOT"; +const EXPECT_UNPATCHED_ENV = "NETCATTY_XTERM_SYNC_RENDER_EXPECT_UNPATCHED"; + +if (!process.versions.electron && process.env[LIVE_ENV] !== "1") { + const test = require("node:test"); + test("closed synchronized-output frames render before the next frame opens", { + skip: "run npm run test:xterm-sync-render for the Electron behavior test", + }, () => {}); +} else if (!process.versions.electron) { + const { spawnSync } = require("node:child_process"); + const electronPath = require("electron"); + const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-sync-render-")); + const result = spawnSync(electronPath, [__filename], { + cwd: path.resolve(__dirname, ".."), + env: { ...process.env, [USER_DATA_ENV]: userData }, + stdio: "inherit", + timeout: 30_000, + }); + fs.rmSync(userData, { recursive: true, force: true }); + if (result.error) { + console.error(result.error); + process.exitCode = 1; + } else if (result.status !== 0) { + process.exitCode = result.status ?? 1; + } +} else { + const assert = require("node:assert/strict"); + const { pathToFileURL } = require("node:url"); + const electron = require("electron"); + + const appRoot = path.resolve(__dirname, ".."); + const userData = process.env[USER_DATA_ENV]; + assert.ok(userData, `${USER_DATA_ENV} is required`); + electron.app.setPath("userData", userData); + electron.app.commandLine.appendSwitch("disable-gpu"); + + let window; + let finished = false; + const finish = (exitCode, error) => { + if (finished) return; + finished = true; + clearTimeout(hardTimeout); + if (error) console.error(error); + try { + if (window && !window.isDestroyed()) window.destroy(); + } finally { + electron.app.exit(exitCode); + } + }; + const hardTimeout = setTimeout(() => { + finish(1, new Error("xterm synchronized-render Electron test timed out")); + }, 20_000); + + void electron.app.whenReady().then(async () => { + window = new electron.BrowserWindow({ + show: false, + width: 640, + height: 360, + paintWhenInitiallyHidden: true, + webPreferences: { + contextIsolation: false, + nodeIntegration: true, + sandbox: false, + }, + }); + const htmlFile = path.join(userData, "xterm-sync-render.html"); + fs.writeFileSync( + htmlFile, + "
", + ); + await window.loadFile(htmlFile); + + const moduleRoot = process.env[MODULE_ROOT_ENV] || appRoot; + const expectUnpatched = process.env[EXPECT_UNPATCHED_ENV] === "1"; + const cjsPath = require.resolve("@xterm/xterm", { paths: [moduleRoot] }); + const esmPath = path.join(path.dirname(cjsPath), "xterm.mjs"); + const loaders = [ + { name: "cjs", expression: `require(${JSON.stringify(cjsPath)})` }, + { name: "esm", expression: `await import(${JSON.stringify(pathToFileURL(esmPath).href)})` }, + ]; + const scenarios = [ + { + name: "split-input", + chunks: [ + "\x1b[?2026h\x1b[1;1HAAAAAAAAAA", + "\x1b[2;1HBBBBBBBBBB\x1b[?2026l", + "\x1b[?2026h\x1b[1;1HCC", + ], + }, + { + name: "close-and-next-open-together", + chunks: [ + "\x1b[?2026h\x1b[1;1HAAAAAAAAAA", + "\x1b[2;1HBBBBBBBBBB\x1b[?2026l\x1b[?2026h\x1b[1;1HCC", + ], + }, + { + name: "complete-and-next-frame-together", + chunks: [ + "\x1b[?2026h\x1b[1;1HAAAAAAAAAA\x1b[2;1HBBBBBBBBBB\x1b[?2026l\x1b[?2026h\x1b[1;1HCC", + ], + }, + ]; + for (const scenario of scenarios) { + for (const chunk of scenario.chunks) { + assert.equal(chunk.charCodeAt(0), 0x1b, `${scenario.name} must start with a real ESC byte`); + } + } + const results = []; + + for (const loader of loaders) { + for (const scenario of scenarios) { + const result = await window.webContents.executeJavaScript(`(async () => { + const { Terminal } = ${loader.expression}; + const target = document.getElementById("terminal"); + target.replaceChildren(); + const term = new Terminal({ cols: 10, rows: 2, cursorBlink: false }); + term.open(target); + + const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + await nextFrame(); + await nextFrame(); + + const renders = []; + const renderSubscription = term.onRender(event => renders.push(event)); + const write = data => new Promise(resolve => term.write(data, resolve)); + for (const chunk of ${JSON.stringify(scenario.chunks)}) await write(chunk); + await nextFrame(); + await nextFrame(); + const rendersBeforeSecondFrameClose = renders.length; + + await write("\\x1b[?2026l"); + await nextFrame(); + renderSubscription.dispose(); + term.dispose(); + return { rendersBeforeSecondFrameClose }; + })()`); + if (expectUnpatched) { + assert.equal( + result.rendersBeforeSecondFrameClose, + 0, + `${loader.name}/${scenario.name} unexpectedly passed without the patch: ${JSON.stringify(result)}`, + ); + } else { + assert.ok( + result.rendersBeforeSecondFrameClose > 0, + `${loader.name}/${scenario.name} did not render the completed first frame before the second opened: ${JSON.stringify(result)}`, + ); + } + results.push({ build: loader.name, scenario: scenario.name, ...result }); + } + + const redundantClose = await window.webContents.executeJavaScript(`(async () => { + const { Terminal } = ${loader.expression}; + const target = document.getElementById("terminal"); + target.replaceChildren(); + const term = new Terminal({ cols: 10, rows: 2, cursorBlink: false }); + term.open(target); + const nextFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + await nextFrame(); + await nextFrame(); + const renders = []; + const renderSubscription = term.onRender(event => renders.push(event)); + await new Promise(resolve => term.write("\\x1b[?2026l".repeat(100), resolve)); + const immediate = renders.length; + await nextFrame(); + await nextFrame(); + const afterFrame = renders.length; + renderSubscription.dispose(); + term.dispose(); + return { immediate, afterFrame }; + })()`); + assert.equal( + redundantClose.immediate, + 0, + `${loader.name} rendered redundant synchronized-output closes immediately: ${JSON.stringify(redundantClose)}`, + ); + assert.ok( + redundantClose.afterFrame > 0, + `${loader.name} did not preserve the normal deferred refresh for redundant closes: ${JSON.stringify(redundantClose)}`, + ); + results.push({ build: loader.name, scenario: "redundant-close", ...redundantClose }); + } + + const label = expectUnpatched ? "XTERM_SYNC_RENDER_BASELINE_OK" : "XTERM_SYNC_RENDER_OK"; + process.stdout.write(`${label} ${JSON.stringify(results)}\n`); + finish(0); + }).catch((error) => finish(1, error)); +}