diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 19ea0b8daa8..49f03af2979 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -29,6 +29,7 @@ on: - 'electron.vite.config.ts' - 'config/build-plugins/**' - 'src/main/daemon/**' + - '!src/main/daemon/**/*.test.ts' - 'src/main/providers/local-pty-provider.ts' - 'src/main/providers/windows-shell-args.ts' - 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts' diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml index 592bc75bb5e..17e08fa0940 100644 --- a/.github/workflows/pi-owner-runtime.yml +++ b/.github/workflows/pi-owner-runtime.yml @@ -3,6 +3,7 @@ on: pull_request: paths: - 'src/main/pi/**' + - '!src/main/pi/**/*.test.ts' - 'tests/tools/pi-owner-runtime-smoke.mjs' - 'tests/tools/omp-completion-runtime-smoke.mjs' - '.github/workflows/pi-owner-runtime.yml' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6f957ce9fe3..375dff65e51 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1295,6 +1295,7 @@ jobs: src/relay/windows-port-scan.win32.test.ts src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts src/main/ssh/remote-node-runtime-store-windows.test.ts + config/scripts/node-pty-windows-pty-teardown-patch.test.mjs # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package diff --git a/.github/workflows/win-orcad-serve-switch-e2e.yml b/.github/workflows/win-orcad-serve-switch-e2e.yml index 19ed618dfb5..776b0db72ed 100644 --- a/.github/workflows/win-orcad-serve-switch-e2e.yml +++ b/.github/workflows/win-orcad-serve-switch-e2e.yml @@ -11,6 +11,7 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'src/main/daemon/**' + - '!src/main/daemon/**/*.test.ts' - 'src/main/orcad/**' - 'src/cli/runtime/**' - 'src/shared/orcad-local-serve-selection.ts' diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 40b240f3296..40378209d55 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -1216,10 +1216,45 @@ index 7b286d3d64..5239ba4e40 100644 }; diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js -index a358ffb177357e177661033c1b092f9c9d0e5f5a..26c2a4c58799ce649f5113131e4c52f7ed2d87ad 100644 --- a/lib/windowsPtyAgent.js +++ b/lib/windowsPtyAgent.js -@@ -136,6 +136,9 @@ var WindowsPtyAgent = /** @class */ (function () { +@@ -13,6 +13,7 @@ + var net_1 = require("net"); + var windowsConoutConnection_1 = require("./windowsConoutConnection"); + var utils_1 = require("./utils"); ++var eventEmitter2_1 = require("./eventEmitter2"); + var conptyNative; + var winptyNative; + /** +@@ -34,6 +35,7 @@ + this._useConptyDll = _useConptyDll; + this._pid = 0; + this._innerPid = 0; ++ this._onProcessExit = new eventEmitter2_1.EventEmitter2(); + if (this._useConpty === undefined || this._useConpty === true) { + this._useConpty = this._getWindowsBuildNumber() >= 18309; + } +@@ -130,12 +132,29 @@ + this._ptyNative.clear(this._pty, this._useConptyDll); + } + }; ++ Object.defineProperty(WindowsPtyAgent.prototype, "onProcessExit", { ++ get: function () { return this._onProcessExit.event; }, ++ enumerable: false, ++ configurable: true ++ }); ++ Object.defineProperty(WindowsPtyAgent.prototype, "isConpty", { ++ get: function () { return this._useConpty === true; }, ++ enumerable: false, ++ configurable: true ++ }); ++ WindowsPtyAgent.prototype.killAfterOutputClosed = function () { ++ var _this = this; ++ return this._conoutSocketWorker.disposeImmediately().then(function () { return _this.kill(); }); ++ }; + WindowsPtyAgent.prototype.kill = function () { + var _this = this; + // Tell the agent to kill the pty, this releases handles to the process if (this._useConpty) { if (!this._useConptyDll) { this._inSocket.readable = false; @@ -1229,7 +1264,7 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..26c2a4c58799ce649f5113131e4c52f7 this._outSocket.readable = false; this._getConsoleProcessList().then(function (consoleProcessList) { consoleProcessList.forEach(function (pid) { -@@ -154,9 +157,10 @@ var WindowsPtyAgent = /** @class */ (function () { +@@ -154,9 +173,10 @@ // Close the input write handle to signal the end of session. this._inSocket.destroy(); this._ptyNative.kill(this._pty, this._useConptyDll); @@ -1243,11 +1278,28 @@ index a358ffb177357e177661033c1b092f9c9d0e5f5a..26c2a4c58799ce649f5113131e4c52f7 } } else { +@@ -225,6 +245,7 @@ + this._flushDataAndCleanUp(); + this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); }); + } ++ this._onProcessExit.fire(exitCode); + }; + WindowsPtyAgent.prototype._flushDataAndCleanUp = function () { + var _this = this; diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js -index 3c38f89..e20b3e6 100644 --- a/lib/windowsTerminal.js +++ b/lib/windowsTerminal.js -@@ -50,6 +50,27 @@ var WindowsTerminal = /** @class */ (function (_super) { +@@ -45,22 +45,61 @@ + var parsedEnv = _this._parseEnv(env); + // If the terminal is ready + _this._isReady = false; ++ _this._killRequested = false; ++ _this._killComplete = false; ++ _this._isPipeReady = false; ++ _this._exitEmitted = false; ++ _this._preconnectCleanupRequested = false; + // Functions that need to run after `ready` event is emitted. + _this._deferreds = []; // Create new termal. _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); _this._socket = _this._agent.outSocket; @@ -1275,7 +1327,32 @@ index 3c38f89..e20b3e6 100644 // Not available until `ready` event emitted. _this._pid = _this._agent.innerPid; _this._fd = _this._agent.fd; -@@ -76,23 +99,6 @@ var WindowsTerminal = /** @class */ (function (_super) { + _this._pty = _this._agent.pty; ++ _this._agent.onProcessExit(function () { ++ if (_this._killRequested) _this._emitExit(); ++ }); ++ _this._socket.once('close', function () { ++ if (_this._isPipeReady && !_this._preconnectCleanupRequested && (!_this._killRequested || !_this._agent.isConpty || _this._agent.exitCode !== undefined)) _this._emitExit(); ++ _this._close(); ++ if (_this._killRequested && !_this._isPipeReady) _this._killAfterOutputClosed(); ++ }); + // The forked windows terminal is not available until `ready` event is + // emitted. + _this._socket.on('ready_datapipe', function () { ++ _this._isPipeReady = true; ++ if (_this._killRequested) { ++ _this.kill(); ++ return; ++ } + // Run deferreds and set ready state once the first data event is received. + _this._socket.once('data', function () { + // Wait until the first data event is fired then we can run deferreds. +- if (!_this._isReady) { ++ if (!_this._isReady && !_this._killRequested) { + // Terminal is now ready and we can avoid having to defer method + // calls. + _this._isReady = true; +@@ -76,33 +115,25 @@ _this._deferreds = []; } }); @@ -1296,10 +1373,13 @@ index 3c38f89..e20b3e6 100644 - throw err; - } - }); - // Cleanup after the socket is closed. - _this._socket.on('close', function () { - _this.emit('exit', _this._agent.exitCode); -@@ -103,6 +109,20 @@ var WindowsTerminal = /** @class */ (function (_super) { +- // Cleanup after the socket is closed. +- _this._socket.on('close', function () { +- _this.emit('exit', _this._agent.exitCode); +- _this._close(); +- }); + }); + _this._file = file; _this._name = name; _this._readable = true; _this._writable = true; @@ -1320,7 +1400,79 @@ index 3c38f89..e20b3e6 100644 _this._forwardEvents(); return _this; } -@@ -196,4 +216,4 @@ var WindowsTerminal = /** @class */ (function (_super) { +@@ -139,22 +170,49 @@ + }); + }; + WindowsTerminal.prototype.destroy = function () { +- var _this = this; +- this._deferNoArgs(function () { +- _this.kill(); +- }); ++ this.kill(); + }; + WindowsTerminal.prototype.kill = function (signal) { +- var _this = this; +- this._deferNoArgs(function () { +- if (signal) { +- throw new Error('Signals not supported on windows.'); +- } +- _this._close(); +- _this._agent.kill(); +- }); ++ if (signal) { ++ throw new Error('Signals not supported on windows.'); ++ } ++ // Retire input now; native close requires the forwarding pipe, not first output. ++ this._killRequested = true; ++ this._deferreds = []; ++ this._close(); ++ if (this._killComplete || this._preconnectCleanup) return; ++ if (this._preconnectCleanupRequested || (!this._isPipeReady && this._socket.destroyed)) { ++ this._killAfterOutputClosed(); ++ return; ++ } ++ if (!this._isPipeReady) return; ++ this._agent.kill(); ++ this._killComplete = true; ++ }; ++ WindowsTerminal.prototype._emitExit = function () { ++ if (this._exitEmitted) return; ++ this._exitEmitted = true; ++ this.emit('exit', this._agent.exitCode); ++ }; ++ WindowsTerminal.prototype._killAfterOutputClosed = function () { ++ var _this = this; ++ if (!this._agent.isConpty || this._killComplete || this._preconnectCleanup) return; ++ this._preconnectCleanupRequested = true; ++ this._preconnectCleanup = this._agent.killAfterOutputClosed().then(function () { ++ _this._killComplete = true; ++ if (_this._agent.exitCode !== undefined) _this._emitExit(); ++ }, function (error) { ++ _this._preconnectCleanup = undefined; ++ if (_this.listeners('error').length > 1) { ++ _this.emit('error', error); ++ } ++ }); ++ if (this._agent.exitCode !== undefined) this._emitExit(); + }; + WindowsTerminal.prototype._deferNoArgs = function (deferredFn) { ++ if (this._killRequested) { ++ return; ++ } + var _this = this; + // If the terminal is ready, execute. + if (this._isReady) { +@@ -167,6 +225,9 @@ + }); + }; + WindowsTerminal.prototype._defer = function (deferredFn, arg) { ++ if (this._killRequested) { ++ return; ++ } + var _this = this; + // If the terminal is ready, execute. + if (this._isReady) { +@@ -196,4 +257,4 @@ return WindowsTerminal; }(terminal_1.Terminal)); exports.WindowsTerminal = WindowsTerminal; @@ -1328,10 +1480,36 @@ index 3c38f89..e20b3e6 100644 \ No newline at end of file +//# sourceMappingURL=windowsTerminal.js.map diff --git a/src/windowsPtyAgent.ts b/src/windowsPtyAgent.ts -index d7054449516f0c9a62af351c2caa17331206d530..0c28a32e2e1db2b3f208ddde8443cd4e67bb1ad6 100644 --- a/src/windowsPtyAgent.ts +++ b/src/windowsPtyAgent.ts -@@ -143,6 +143,9 @@ export class WindowsPtyAgent { +@@ -12,6 +12,7 @@ + import { ArgvOrCommandLine } from './types'; + import { ConoutConnection } from './windowsConoutConnection'; + import { loadNativeModule } from './utils'; ++import { EventEmitter2, IEvent } from './eventEmitter2'; + + let conptyNative: IConptyNative; + let winptyNative: IWinptyNative; +@@ -34,6 +35,9 @@ + private _innerPid: number = 0; + private _closeTimeout: NodeJS.Timer | undefined; + private _exitCode: number | undefined; ++ private _onProcessExit = new EventEmitter2(); ++ public get onProcessExit(): IEvent { return this._onProcessExit.event; } ++ public get isConpty(): boolean { return this._useConpty === true; } + private _conoutSocketWorker: ConoutConnection; + + private _fd: any; +@@ -138,11 +142,18 @@ + } + } + ++ public killAfterOutputClosed(): Promise { ++ return this._conoutSocketWorker.disposeImmediately().then(() => this.kill()); ++ } ++ + public kill(): void { + // Tell the agent to kill the pty, this releases handles to the process if (this._useConpty) { if (!this._useConptyDll) { this._inSocket.readable = false; @@ -1341,7 +1519,7 @@ index d7054449516f0c9a62af351c2caa17331206d530..0c28a32e2e1db2b3f208ddde8443cd4e this._outSocket.readable = false; this._getConsoleProcessList().then(consoleProcessList => { consoleProcessList.forEach((pid: number) => { -@@ -159,9 +162,10 @@ export class WindowsPtyAgent { +@@ -159,9 +170,10 @@ // Close the input write handle to signal the end of session. this._inSocket.destroy(); (this._ptyNative as IConptyNative).kill(this._pty, this._useConptyDll); @@ -1355,14 +1533,44 @@ index d7054449516f0c9a62af351c2caa17331206d530..0c28a32e2e1db2b3f208ddde8443cd4e } } else { // Because pty.kill closes the handle, it will kill most processes by itself. +@@ -227,6 +239,7 @@ + this._flushDataAndCleanUp(); + this._outSocket.on('data', () => this._flushDataAndCleanUp()); + } ++ this._onProcessExit.fire(exitCode); + } + + private _flushDataAndCleanUp(): void { diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts -index 13f6c6d..eda63c8 100644 --- a/src/windowsTerminal.ts +++ b/src/windowsTerminal.ts -@@ -51,6 +51,30 @@ export class WindowsTerminal extends Terminal { +@@ -16,6 +16,12 @@ + + export class WindowsTerminal extends Terminal { + private _isReady: boolean; ++ private _killRequested: boolean; ++ private _killComplete: boolean; ++ private _isPipeReady: boolean; ++ private _exitEmitted = false; ++ private _preconnectCleanupRequested = false; ++ private _preconnectCleanup: Promise | undefined; + private _deferreds: { run: () => void }[]; + private _agent: WindowsPtyAgent; + +@@ -43,6 +49,9 @@ + + // If the terminal is ready + this._isReady = false; ++ this._killRequested = false; ++ this._killComplete = false; ++ this._isPipeReady = false; + + // Functions that need to run after `ready` event is emitted. + this._deferreds = []; +@@ -50,20 +59,58 @@ + // Create new termal. this._agent = new WindowsPtyAgent(file, args, parsedEnv, cwd, this._cols, this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor); this._socket = this._agent.outSocket; -- + + // Attach before readiness so a broken ConPTY output pipe cannot be unhandled. + this._socket.on('error', err => { @@ -1387,15 +1595,42 @@ index 13f6c6d..eda63c8 100644 + throw err; + } + }); -+ + // Not available until `ready` event emitted. this._pid = this._agent.innerPid; this._fd = this._agent.fd; -@@ -82,25 +108,6 @@ export class WindowsTerminal extends Terminal { + this._pty = this._agent.pty; + ++ this._agent.onProcessExit(() => { ++ if (this._killRequested) this._emitExit(); ++ }); ++ this._socket.once('close', () => { ++ if (this._isPipeReady && !this._preconnectCleanupRequested && (!this._killRequested || !this._agent.isConpty || this._agent.exitCode !== undefined)) this._emitExit(); ++ this._close(); ++ if (this._killRequested && !this._isPipeReady) this._killAfterOutputClosed(); ++ }); ++ + // The forked windows terminal is not available until `ready` event is + // emitted. + this._socket.on('ready_datapipe', () => { ++ this._isPipeReady = true; ++ if (this._killRequested) { ++ this.kill(); ++ return; ++ } + + // Run deferreds and set ready state once the first data event is received. + this._socket.once('data', () => { + // Wait until the first data event is fired then we can run deferreds. +- if (!this._isReady) { ++ if (!this._isReady && !this._killRequested) { + // Terminal is now ready and we can avoid having to defer method + // calls. + this._isReady = true; +@@ -82,30 +129,6 @@ } }); -- -+ + - // Shutdown if `error` event is emitted. - this._socket.on('error', err => { - // Close terminal session. @@ -1415,12 +1650,16 @@ index 13f6c6d..eda63c8 100644 - } - }); - - // Cleanup after the socket is closed. - this._socket.on('close', () => { - this.emit('exit', this._agent.exitCode); -@@ -114,6 +121,19 @@ export class WindowsTerminal extends Terminal { -- -+ +- // Cleanup after the socket is closed. +- this._socket.on('close', () => { +- this.emit('exit', this._agent.exitCode); +- this._close(); +- }); + + }); + +@@ -114,6 +137,19 @@ + this._readable = true; this._writable = true; + // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes @@ -1436,7 +1675,174 @@ index 13f6c6d..eda63c8 100644 + // The failing pipe may have raced process exit; the terminal is already unwritable. + } + }); -- -+ + this._forwardEvents(); } +@@ -156,22 +192,52 @@ + } + + public destroy(): void { +- this._deferNoArgs(() => { +- this.kill(); +- }); ++ this.kill(); + } + + public kill(signal?: string): void { +- this._deferNoArgs(() => { +- if (signal) { +- throw new Error('Signals not supported on windows.'); +- } +- this._close(); +- this._agent.kill(); +- }); ++ if (signal) { ++ throw new Error('Signals not supported on windows.'); ++ } ++ // Retire input now; native close requires the forwarding pipe, not first output. ++ this._killRequested = true; ++ this._deferreds = []; ++ this._close(); ++ if (this._killComplete || this._preconnectCleanup) return; ++ if (this._preconnectCleanupRequested || (!this._isPipeReady && this._socket.destroyed)) { ++ this._killAfterOutputClosed(); ++ return; ++ } ++ if (!this._isPipeReady) return; ++ this._agent.kill(); ++ this._killComplete = true; ++ } ++ ++ private _emitExit(): void { ++ if (this._exitEmitted) return; ++ this._exitEmitted = true; ++ this.emit('exit', this._agent.exitCode); ++ } ++ ++ private _killAfterOutputClosed(): void { ++ if (!this._agent.isConpty || this._killComplete || this._preconnectCleanup) return; ++ this._preconnectCleanupRequested = true; ++ this._preconnectCleanup = this._agent.killAfterOutputClosed().then(() => { ++ this._killComplete = true; ++ if (this._agent.exitCode !== undefined) this._emitExit(); ++ }, error => { ++ this._preconnectCleanup = undefined; ++ if (this.listeners('error').length > 1) { ++ this.emit('error', error); ++ } ++ }); ++ if (this._agent.exitCode !== undefined) this._emitExit(); + } + + private _deferNoArgs(deferredFn: () => void): void { ++ if (this._killRequested) { ++ return; ++ } + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this); +@@ -185,6 +251,9 @@ + } + + private _defer(deferredFn: (arg: A) => void, arg: A): void { ++ if (this._killRequested) { ++ return; ++ } + // If the terminal is ready, execute. + if (this._isReady) { + deferredFn.call(this, arg); + +diff --git a/src/windowsConoutConnection.ts b/src/windowsConoutConnection.ts +--- a/src/windowsConoutConnection.ts ++++ b/src/windowsConoutConnection.ts +@@ -32,6 +32,7 @@ + private _worker: Worker; + private _drainTimeout: NodeJS.Timeout | undefined; + private _isDisposed: boolean = false; ++ private _termination: Promise | undefined; + + private _onReady = new EventEmitter2(); + public get onReady(): IEvent { return this._onReady.event; } +@@ -48,6 +49,7 @@ + this._worker.on('message', (message: ConoutWorkerMessage) => { + switch (message) { + case ConoutWorkerMessage.READY: ++ if (this._termination) return; + this._onReady.fire(); + return; + default: +@@ -65,7 +67,14 @@ + this._drainDataAndClose(); + } + ++ disposeImmediately(): Promise { ++ this._isDisposed = true; ++ if (this._drainTimeout) clearTimeout(this._drainTimeout); ++ return this._destroySocket(); ++ } ++ + connectSocket(socket: Socket): void { ++ if (this._termination) return; + socket.connect(getWorkerPipeName(this._conoutPipeName)); + } + +@@ -76,7 +85,10 @@ + this._drainTimeout = setTimeout(() => this._destroySocket(), FLUSH_DATA_INTERVAL); + } + +- private async _destroySocket(): Promise { +- await this._worker.terminate(); ++ private _destroySocket(): Promise { ++ if (!this._termination) { ++ this._termination = this._worker.terminate().then(() => undefined); ++ } ++ return this._termination; + } + } + +diff --git a/lib/windowsConoutConnection.js b/lib/windowsConoutConnection.js +--- a/lib/windowsConoutConnection.js ++++ b/lib/windowsConoutConnection.js +@@ -77,6 +77,7 @@ + this._worker.on('message', function (message) { + switch (message) { + case 1 /* READY */: ++ if (_this._termination) return; + _this._onReady.fire(); + return; + default: +@@ -97,7 +98,13 @@ + // Drain all data from the socket before closing + this._drainDataAndClose(); + }; ++ ConoutConnection.prototype.disposeImmediately = function () { ++ this._isDisposed = true; ++ if (this._drainTimeout) clearTimeout(this._drainTimeout); ++ return this._destroySocket(); ++ }; + ConoutConnection.prototype.connectSocket = function (socket) { ++ if (this._termination) return; + socket.connect(conout_1.getWorkerPipeName(this._conoutPipeName)); + }; + ConoutConnection.prototype._drainDataAndClose = function () { +@@ -108,16 +115,10 @@ + this._drainTimeout = setTimeout(function () { return _this._destroySocket(); }, FLUSH_DATA_INTERVAL); + }; + ConoutConnection.prototype._destroySocket = function () { +- return __awaiter(this, void 0, void 0, function () { +- return __generator(this, function (_a) { +- switch (_a.label) { +- case 0: return [4 /*yield*/, this._worker.terminate()]; +- case 1: +- _a.sent(); +- return [2 /*return*/]; +- } +- }); +- }); ++ if (!this._termination) { ++ this._termination = this._worker.terminate().then(function () { return undefined; }); ++ } ++ return this._termination; + }; + return ConoutConnection; + }()); diff --git a/config/patches/vitest@5.0.3.patch b/config/patches/vitest@5.0.3.patch new file mode 100644 index 00000000000..2824634a548 --- /dev/null +++ b/config/patches/vitest@5.0.3.patch @@ -0,0 +1,30 @@ +diff --git a/dist/chunks/index.DpLw24bj.js b/dist/chunks/index.DpLw24bj.js +--- a/dist/chunks/index.DpLw24bj.js ++++ b/dist/chunks/index.DpLw24bj.js +@@ -12895,7 +12895,7 @@ + mergedOptions.name = resolveProjectName(mergedOptions.name, index, context.ancestors.at(-1)); + return { + viteConfig: parentViteConfig, +- projectConfig: resolveTestConfig(harness.logger, mergedOptions, parentViteConfig, parentConfig), ++ projectConfig: resolveTestConfig(harness.logger, mergedOptions, parentViteConfig, parentConfig, mergedOptions), + inline: true, + sharedServer: true, + ancestors: context.ancestors.length ? [...context.ancestors] : void 0 +@@ -14345,7 +14345,7 @@ + * + * `globalConfig` is the resolved root config, passed when resolving a project. + */ +-function resolveTestConfig(logger, options, viteConfig, globalConfig) { ++function resolveTestConfig(logger, options, viteConfig, globalConfig, rawTestConfig = viteConfig.test) { + if (options.dom) { + if (viteConfig.test?.environment != null && viteConfig.test.environment !== "happy-dom") logger.warn(withLabel("yellow", "Vitest", `Your config.test.environment ("${viteConfig.test.environment}") conflicts with --dom flag ("happy-dom"), ignoring "${viteConfig.test.environment}"`)); + options.environment = "happy-dom"; +@@ -14354,7 +14354,7 @@ + // is merged in - the merged object cannot distinguish a default from a + // user-provided value; `viteConfig.test` is not resolved yet at this point, + // the call sites assign the resolved config to it after this function returns +- const providedOptions = captureProvidedOptions(options, viteConfig.test); ++ const providedOptions = captureProvidedOptions(options, rawTestConfig); + const resolved = deepMerge({}, configDefaults, options); + resolved.root = viteConfig.root; + resolved.providedOptions = providedOptions; diff --git a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs index 2dfc48a7bf4..08b8ee3facd 100644 --- a/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs +++ b/config/relay-assets/node-pty-1.1.0-windows-pty-teardown-patch.cjs @@ -97,23 +97,136 @@ const { join, resolve } = require('node:path') const EXPECTED_NODE_PTY_VERSION = '1.1.0' +const WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS = [ + [ + ' var parsedEnv = _this._parseEnv(env);\n // If the terminal is ready\n _this._isReady = false;\n // Functions that need to run after `ready` event is emitted.\n _this._deferreds = [];\n // Create new termal.\n', + ' var parsedEnv = _this._parseEnv(env);\n // If the terminal is ready\n _this._isReady = false;\n _this._killRequested = false;\n _this._killComplete = false;\n _this._isPipeReady = false;\n // Functions that need to run after `ready` event is emitted.\n _this._deferreds = [];\n // Create new termal.\n' + ], + [ + " _this._pid = _this._agent.innerPid;\n _this._fd = _this._agent.fd;\n _this._pty = _this._agent.pty;\n // The forked windows terminal is not available until `ready` event is\n // emitted.\n _this._socket.on('ready_datapipe', function () {\n // Run deferreds and set ready state once the first data event is received.\n _this._socket.once('data', function () {\n // Wait until the first data event is fired then we can run deferreds.\n if (!_this._isReady) {\n // Terminal is now ready and we can avoid having to defer method\n // calls.\n _this._isReady = true;\n", + " _this._pid = _this._agent.innerPid;\n _this._fd = _this._agent.fd;\n _this._pty = _this._agent.pty;\n // A pre-output teardown must still publish the actual pipe close.\n _this._socket.once('close', function () {\n if (_this._isPipeReady) {\n _this.emit('exit', _this._agent.exitCode);\n }\n _this._close();\n });\n // The forked windows terminal is not available until `ready` event is\n // emitted.\n _this._socket.on('ready_datapipe', function () {\n _this._isPipeReady = true;\n if (_this._killRequested) {\n _this.kill();\n return;\n }\n // Run deferreds and set ready state once the first data event is received.\n _this._socket.once('data', function () {\n // Wait until the first data event is fired then we can run deferreds.\n if (!_this._isReady && !_this._killRequested) {\n // Terminal is now ready and we can avoid having to defer method\n // calls.\n _this._isReady = true;\n" + ], + [ + " _this._deferreds = [];\n }\n });\n // Cleanup after the socket is closed.\n _this._socket.on('close', function () {\n _this.emit('exit', _this._agent.exitCode);\n _this._close();\n });\n });\n _this._file = file;\n _this._name = name;\n", + ' _this._deferreds = [];\n }\n });\n });\n _this._file = file;\n _this._name = name;\n' + ], + [ + " });\n };\n WindowsTerminal.prototype.destroy = function () {\n var _this = this;\n this._deferNoArgs(function () {\n _this.kill();\n });\n };\n WindowsTerminal.prototype.kill = function (signal) {\n var _this = this;\n this._deferNoArgs(function () {\n if (signal) {\n throw new Error('Signals not supported on windows.');\n }\n _this._close();\n _this._agent.kill();\n });\n };\n WindowsTerminal.prototype._deferNoArgs = function (deferredFn) {\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n", + " });\n };\n WindowsTerminal.prototype.destroy = function () {\n this.kill();\n };\n WindowsTerminal.prototype.kill = function (signal) {\n if (signal) {\n throw new Error('Signals not supported on windows.');\n }\n // Retire input now; native close requires the forwarding pipe, not first output.\n this._killRequested = true;\n this._deferreds = [];\n this._close();\n if (!this._isPipeReady || this._killComplete) {\n return;\n }\n this._agent.kill();\n this._killComplete = true;\n };\n WindowsTerminal.prototype._deferNoArgs = function (deferredFn) {\n if (this._killRequested) {\n return;\n }\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n" + ], + [ + ' });\n };\n WindowsTerminal.prototype._defer = function (deferredFn, arg) {\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n', + ' });\n };\n WindowsTerminal.prototype._defer = function (deferredFn, arg) {\n if (this._killRequested) {\n return;\n }\n var _this = this;\n // If the terminal is ready, execute.\n if (this._isReady) {\n' + ] +] + +const PRECONNECT_AGENT_REPLACEMENTS = [ + [ + 'var utils_1 = require("./utils");', + 'var utils_1 = require("./utils");\nvar eventEmitter2_1 = require("./eventEmitter2");' + ], + [ + ' this._innerPid = 0;', + ' this._innerPid = 0;\n this._onProcessExit = new eventEmitter2_1.EventEmitter2();' + ], + [ + ' WindowsPtyAgent.prototype.kill = function () {', + ' Object.defineProperty(WindowsPtyAgent.prototype, "onProcessExit", {\n get: function () { return this._onProcessExit.event; },\n enumerable: false,\n configurable: true\n });\n Object.defineProperty(WindowsPtyAgent.prototype, "isConpty", {\n get: function () { return this._useConpty === true; },\n enumerable: false,\n configurable: true\n });\n WindowsPtyAgent.prototype.killAfterOutputClosed = function () {\n var _this = this;\n return this._conoutSocketWorker.disposeImmediately().then(function () { return _this.kill(); });\n };\n WindowsPtyAgent.prototype.kill = function () {' + ], + [ + " this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });\n }\n };", + " this._outSocket.on('data', function () { return _this._flushDataAndCleanUp(); });\n }\n this._onProcessExit.fire(exitCode);\n };" + ] +] + +const PRECONNECT_TERMINAL_REPLACEMENTS = [ + [ + ' _this._isPipeReady = false;', + ' _this._isPipeReady = false;\n _this._exitEmitted = false;\n _this._preconnectCleanupRequested = false;' + ], + [ + " // A pre-output teardown must still publish the actual pipe close.\n _this._socket.once('close', function () {\n if (_this._isPipeReady) {\n _this.emit('exit', _this._agent.exitCode);\n }\n _this._close();\n });", + " _this._agent.onProcessExit(function () {\n if (_this._killRequested) _this._emitExit();\n });\n _this._socket.once('close', function () {\n if (_this._isPipeReady && !_this._preconnectCleanupRequested && (!_this._killRequested || !_this._agent.isConpty || _this._agent.exitCode !== undefined)) _this._emitExit();\n _this._close();\n if (_this._killRequested && !_this._isPipeReady) _this._killAfterOutputClosed();\n });" + ], + [ + ' if (!this._isPipeReady || this._killComplete) {\n return;\n }', + ' if (this._killComplete || this._preconnectCleanup) return;\n if (this._preconnectCleanupRequested || (!this._isPipeReady && this._socket.destroyed)) {\n this._killAfterOutputClosed();\n return;\n }\n if (!this._isPipeReady) return;' + ], + [ + ' WindowsTerminal.prototype._deferNoArgs =', + " WindowsTerminal.prototype._emitExit = function () {\n if (this._exitEmitted) return;\n this._exitEmitted = true;\n this.emit('exit', this._agent.exitCode);\n };\n WindowsTerminal.prototype._killAfterOutputClosed = function () {\n var _this = this;\n if (!this._agent.isConpty || this._killComplete || this._preconnectCleanup) return;\n this._preconnectCleanupRequested = true;\n this._preconnectCleanup = this._agent.killAfterOutputClosed().then(function () {\n _this._killComplete = true;\n if (_this._agent.exitCode !== undefined) _this._emitExit();\n }, function (error) {\n _this._preconnectCleanup = undefined;\n if (_this.listeners('error').length > 1) {\n _this.emit('error', error);\n }\n });\n if (this._agent.exitCode !== undefined) this._emitExit();\n };\n WindowsTerminal.prototype._deferNoArgs =" + ] +] + +const PRECONNECT_CONOUT_REPLACEMENTS = [ + [ + ' case 1 /* READY */:\n _this._onReady.fire();', + ' case 1 /* READY */:\n if (_this._termination) return;\n _this._onReady.fire();' + ], + [ + ' ConoutConnection.prototype.connectSocket = function (socket) {\n socket.connect', + ' ConoutConnection.prototype.disposeImmediately = function () {\n this._isDisposed = true;\n if (this._drainTimeout) clearTimeout(this._drainTimeout);\n return this._destroySocket();\n };\n ConoutConnection.prototype.connectSocket = function (socket) {\n if (this._termination) return;\n socket.connect' + ], + [ + ' ConoutConnection.prototype._destroySocket = function () {\n return __awaiter(this, void 0, void 0, function () {\n return __generator(this, function (_a) {\n switch (_a.label) {\n case 0: return [4 /*yield*/, this._worker.terminate()];\n case 1:\n _a.sent();\n return [2 /*return*/];\n }\n });\n });\n };\n', + ' ConoutConnection.prototype._destroySocket = function () {\n if (!this._termination) {\n this._termination = this._worker.terminate().then(function () { return undefined; });\n }\n return this._termination;\n };\n' + ] +] + /** Each entry is one published file, its patched form, and the edits between them. */ const PATCH_TARGETS = [ + { + relativePath: ['lib', 'windowsConoutConnection.js'], + originalSha256: '1440f70908fb1f55911ac8e936a1230f68a9c00c03096fcef6788eac6aad9d62', + patchedSha256: '37fab0688764326444509f7ffb24369f99fda4fbe29db72dccfad52c457ba244', + replacements: PRECONNECT_CONOUT_REPLACEMENTS + }, { relativePath: ['lib', 'windowsPtyAgent.js'], originalSha256: '8636d16b38266112204061a22b135734177c242837982fd3a4055be726efa64a', - patchedSha256: '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280', + patchedSha256: '3c14daf8d0ec2d1e2d66435caa5fb2b629b230e237873594e623e79e6a7d1223', + previousPatchedSha256: '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280', + previousReplacements: PRECONNECT_AGENT_REPLACEMENTS, replacements: [ [ ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n', ' this._ptyNative.kill(this._pty, this._useConptyDll);\n this._conoutSocketWorker.dispose();\n // Orca: released AFTER the console-list fork and the native kill, not before them.\n // Destroying conin first aborts teardown partway -- measured on a Windows SSH relay\n // as +2 File and +1 Process handles per terminal, against +1 File unpatched.\n this._inSocket.destroy();\n' - ] + ], + ...PRECONNECT_AGENT_REPLACEMENTS ] }, { relativePath: ['lib', 'windowsTerminal.js'], originalSha256: 'c3a65716f53fed0135a8a633373d5f9c2ab092544d651f27ef0a67096dd3bcd9', - patchedSha256: '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', + patchedSha256: '5dfeb1dda46645e1d77964ad4d07072b34f0a897dfc7e785137cc39e755b4641', + additionalPreviousVariants: [ + { + sha256: '3060c6514a8e9e3285f91b9b549930e7d25d59d4cf7e1ed3a25b9a680dd1ded5', + replacements: PRECONNECT_TERMINAL_REPLACEMENTS + }, + { + sha256: '598755ee75307d041a72cd7c7c4e12ae4a4bbf35eee19da67b42b61fdae0d4d6', + replacements: [ + [ + " _this._preconnectCleanup = undefined;\n _this.emit('error', error);", + " _this._preconnectCleanup = undefined;\n if (_this.listeners('error').length > 1) {\n _this.emit('error', error);\n }" + ], + [ + ' if (_this._preconnectCleanupRequested) _this._emitExit();', + ' if (_this._killRequested) _this._emitExit();' + ], + [ + ' if (_this._isPipeReady && !_this._preconnectCleanupRequested) _this._emitExit();', + ' if (_this._isPipeReady && !_this._preconnectCleanupRequested && (!_this._killRequested || !_this._agent.isConpty || _this._agent.exitCode !== undefined)) _this._emitExit();' + ] + ] + } + ], + previousPatchedSha256: '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', + previousReplacements: [ + ...WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS, + ...PRECONNECT_TERMINAL_REPLACEMENTS + ], replacements: [ [ ' _this._agent = new windowsPtyAgent_1.WindowsPtyAgent(file, args, parsedEnv, cwd, _this._cols, _this._rows, false, opt.useConpty, opt.useConptyDll, opt.conptyInheritCursor);\n _this._socket = _this._agent.outSocket;\n // Not available until `ready` event emitted.\n _this._pid = _this._agent.innerPid;', @@ -130,7 +243,9 @@ const PATCH_TARGETS = [ [ 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map', 'exports.WindowsTerminal = WindowsTerminal;\n//# sourceMappingURL=windowsTerminal.js.map\n' - ] + ], + ...WINDOWS_TERMINAL_TEARDOWN_REPLACEMENTS, + ...PRECONNECT_TERMINAL_REPLACEMENTS ] } ] @@ -159,19 +274,27 @@ function assertPatchedNodePtyWindowsTeardown(relayDir = process.cwd()) { } function patchNodePtyWindowsTeardown(relayDir = process.cwd()) { + const pending = [] for (const target of PATCH_TARGETS) { const inspected = inspectTarget(relayDir, target) const sourceHash = sourceSha256(inspected.source) if (sourceHash === target.patchedSha256) { continue } - if (sourceHash !== target.originalSha256) { + const replacements = + sourceHash === target.originalSha256 + ? target.replacements + : sourceHash === target.previousPatchedSha256 + ? target.previousReplacements + : target.additionalPreviousVariants?.find((entry) => entry.sha256 === sourceHash) + ?.replacements + if (!replacements) { throw new Error( `Refusing to patch unexpected node-pty source in ${target.relativePath.join('/')}` ) } let patchedSource = inspected.source - for (const [from, to] of target.replacements) { + for (const [from, to] of replacements) { // Why the count check: an anchor that matched twice would patch the wrong site silently, and // the hash below would then reject a tree this script had already rewritten. if (patchedSource.split(from).length - 1 !== 1) { @@ -179,11 +302,19 @@ function patchNodePtyWindowsTeardown(relayDir = process.cwd()) { } patchedSource = patchedSource.replace(from, to) } - const temporaryPath = `${inspected.filePath}.orca-patch-${process.pid}` + if (sourceSha256(patchedSource) !== target.patchedSha256) { + throw new Error( + `Refusing to install unexpected patched node-pty source in ${target.relativePath.join('/')}` + ) + } + pending.push({ filePath: inspected.filePath, patchedSource }) + } + for (const { filePath, patchedSource } of pending) { + const temporaryPath = `${filePath}.orca-patch-${process.pid}` // Why: a terminated remote install must leave either known source version recoverable on reconnect. try { writeFileSync(temporaryPath, patchedSource) - renameSync(temporaryPath, inspected.filePath) + renameSync(temporaryPath, filePath) } finally { rmSync(temporaryPath, { force: true }) } diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index cdcc9941bf0..4039ee45b11 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -6528,7 +6528,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-client-host-reconciliation-protocol.test.ts src/main/browser/paired-runtime-browser-host-reconciliation-negotiation.test.ts src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/browser/paired-runtime-browser-client-host.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-client-host-protocol.test.ts src/main/browser/browser-client-host-attach-request.test.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/browser-client-page-command-integration.test.ts src/main/browser/browser-client-page-inventory.test.ts src/main/browser/paired-runtime-browser-client-host-composition.test.ts src/main/browser/paired-runtime-browser-client-host.test.ts src/main/browser/paired-runtime-browser-host-lease.test.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/runtime/rpc/methods/browser-client-host.test.ts src/main/runtime/browser-host-page-reconciliation-plan.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-host-reconciliation-command-order.test.ts src/main/browser/browser-client-page-reconciliation-adapters.test.ts src/main/browser/browser-route-prepared-page-rekey.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-network-capabilities.test.ts src/shared/browser-network-tunnel-protocol.test.ts src/main/browser/browser-network-tunnel-session.test.ts src/main/browser/remote-browser-socks-server.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-network-tunnel-protocol.test.ts src/main/browser/browser-network-tunnel-session.test.ts src/main/browser/remote-browser-socks-server.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-network-tunnel-client.test.ts src/main/browser/paired-runtime-browser-network-route.test.ts src/main/runtime/runtime-binary-message-router.test.ts src/main/runtime/rpc/methods/browser-network-tunnel.test.ts src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts src/main/browser/browser-network-tunnel-client-memory-budget.test.ts src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts src/shared/ws-outbound-backpressure-queue.test.ts src/shared/remote-runtime-client.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-client-host-protocol.test.ts src/shared/ws-outbound-backpressure-queue.test.ts src/shared/remote-runtime-client.test.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/runtime/runtime-rpc-browser-host-admission.test.ts src/main/runtime/rpc/methods/browser-client-host.test.ts src/main/runtime/rpc/methods/browser-network-tunnel.test.ts src/main/browser/paired-runtime-browser-host-lease.test.ts src/main/browser/paired-runtime-browser-network-route.test.ts src/main/runtime/browser-network-tunnel-paired-runtime.integration.test.ts src/main/browser/browser-network-tunnel-client.test.ts src/main/browser/browser-network-tunnel-client-memory-budget.test.ts src/main/browser/browser-network-tunnel-session.test.ts src/main/browser/browser-network-tunnel-session-aggregate-memory.test.ts src/main/browser/browser-network-tunnel-outbound-memory-budget.test.ts src/main/browser/remote-browser-socks-server.test.ts", @@ -6544,7 +6544,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-client-host-protocol.test.ts src/main/browser/browser-client-host-attach-request.test.ts src/main/browser/browser-client-network-route-registry.test.ts src/main/browser/paired-runtime-browser-client-host-composition.test.ts src/main/browser/paired-runtime-browser-client-host.test.ts src/main/browser/paired-runtime-browser-client-host-reconnect.test.ts src/main/browser/paired-runtime-browser-host-lease.test.ts src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts src/main/browser/browser-host-lease-reconnect-delay.test.ts src/main/browser/paired-runtime-browser-network-route.test.ts src/main/runtime/browser-host-command-ledger.test.ts src/main/runtime/browser-host-command-ledger-capacity.test.ts src/main/runtime/browser-host-lease-placement-retirement.test.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/runtime/runtime-rpc-browser-host-admission.test.ts src/main/runtime/rpc/methods/browser-client-host.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-network-route-registry.test.ts src/main/browser/paired-runtime-browser-client-host-composition.test.ts src/main/browser/paired-runtime-browser-client-host-registry.test.ts src/main/browser/paired-runtime-browser-client-host-runtime.test.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/browser-session-startup.test.ts src/main/ipc/runtime-environments-subscription-teardown.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/browser-host-command-ledger.test.ts src/main/runtime/browser-host-command-ledger-capacity.test.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/runtime/rpc/methods/browser-client-host.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/browser/browser-network-deferred-socket.test.ts src/main/browser/browser-network-execution-route.test.ts src/main/browser/paired-runtime-browser-network-route.test.ts src/main/runtime/rpc/methods/browser-network-tunnel.test.ts src/main/browser/ssh-browser-network-execution-route.test.ts src/main/browser/system-ssh-socks-client-socket.test.ts src/main/ssh/system-ssh-dynamic-forward-process.test.ts src/shared/browser-client-host-protocol.test.ts src/shared/browser-network-capabilities.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ssh/ssh-system-fallback.test.ts src/main/ssh/ssh-port-forward.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/browser/browser-network-deferred-socket.test.ts src/main/browser/browser-network-execution-route.test.ts src/main/browser/paired-runtime-browser-network-route.test.ts src/main/runtime/rpc/methods/browser-network-tunnel.test.ts src/main/browser/ssh-browser-network-execution-route.test.ts src/main/browser/system-ssh-socks-client-socket.test.ts src/main/ssh/system-ssh-dynamic-forward-process.test.ts src/shared/browser-client-host-protocol.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ssh/ssh-system-fallback.test.ts src/main/ssh/ssh-port-forward.test.ts", "ORCA_RUN_DOCKER_SSH_BROWSER_E2E=1 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts", "ORCA_RUN_DOCKER_SSH_BROWSER_E2E=1 node_modules/.bin/vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" ], @@ -6554,7 +6554,6 @@ "src/main/runtime/runtime-browser-client-page-recovery.test.ts", "src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts", "src/main/runtime/rpc/methods/session-tab-browser-placement-projection.test.ts", - "src/shared/browser-network-capabilities.test.ts", "src/shared/browser-client-host-protocol.test.ts", "src/shared/browser-client-host-reconciliation-protocol.test.ts", "src/shared/browser-network-tunnel-protocol.test.ts", @@ -7251,15 +7250,6 @@ "durationSeconds": 0.2, "summary": "Four files passed 26 two-phase retirement, exact replacement, pre-selection admission, runtime/epoch/host/page authority, live-lease fencing, monotonic generation, invalid-identity, default 256-placement capacity, and stale-settlement tests." }, - { - "date": "2026-08-14", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/browser-host-lease-registry.test.ts src/main/browser/browser-network-deferred-socket.test.ts src/main/browser/browser-network-execution-route.test.ts src/main/browser/paired-runtime-browser-network-route.test.ts src/main/runtime/rpc/methods/browser-network-tunnel.test.ts src/main/browser/ssh-browser-network-execution-route.test.ts src/main/browser/system-ssh-socks-client-socket.test.ts src/main/ssh/system-ssh-dynamic-forward-process.test.ts src/shared/browser-client-host-protocol.test.ts src/shared/browser-network-capabilities.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ssh/ssh-system-fallback.test.ts src/main/ssh/ssh-port-forward.test.ts", - "result": "passed", - "durationSeconds": 2.6, - "summary": "Thirteen files passed 142 structural route-key, native-shape, live lease-bound SSH grant, startup cancellation, diagnostic redaction, deferred-socket release, capability negotiation, exact SSH authority and client, ssh2 forwarding, standalone system-SSH dynamic-forward, active-probe cleanup, static-forward option reuse, internal SOCKS domain, and exact release tests." - }, { "date": "2026-08-14", "runner": "local", @@ -7296,15 +7286,6 @@ "durationSeconds": 3.4, "summary": "Eleven files passed 67 lease, reserved long-poll and host admission, token-safe placement retirement, authority, capability, route-generation, adversarial-revocation, lifecycle, flow-control, SOCKS, and real paired E2EE tests." }, - { - "date": "2026-08-13", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-network-capabilities.test.ts src/shared/browser-network-tunnel-protocol.test.ts src/main/browser/browser-network-tunnel-session.test.ts src/main/browser/remote-browser-socks-server.test.ts", - "result": "passed", - "durationSeconds": 0.4, - "summary": "Four files passed 25 deterministic capability, codec, credit, generation, half-close, SOCKS routing, remote-DNS, and fail-closed tests." - }, { "date": "2026-08-14", "runner": "local", diff --git a/config/scripts/ci-shard-assignment.test.mjs b/config/scripts/ci-shard-assignment.test.mjs index ac8ce049d24..5c83bdc272a 100644 --- a/config/scripts/ci-shard-assignment.test.mjs +++ b/config/scripts/ci-shard-assignment.test.mjs @@ -98,26 +98,6 @@ describe('timing-weighted shard selection', () => { expect.arrayContaining([...NODE_RUNTIME_INCLUDE, measurementFile]) ) } - for (const project of projects) { - expect(project.extends).toBe(false) - expect(project.define).toEqual(config.define) - expect(project.resolve).toEqual(config.resolve) - expect(project.test.execArgv).toEqual(['--no-experimental-webstorage', '--expose-gc']) - expect(project.test.setupFiles).toEqual([ - resolve('config/scripts/vitest-real-agent-home-write-guard.ts'), - resolve('config/scripts/vitest-bun-node-builtins.ts'), - resolve('config/scripts/happy-dom-offscreen-canvas.ts'), - resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), - resolve('config/scripts/vitest-host-ports-setup.ts'), - resolve('config/scripts/vitest-caller-identity-env-setup.ts') - ]) - expect(project.test).toMatchObject({ - environment: 'node', - server: { deps: { inline: ['zod'] } }, - hookTimeout: 60_000, - testTimeout: 30_000 - }) - } }) it('keeps nested/serial E2E files atomic and fails closed on discovery errors', () => { diff --git a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs index 43f42aca655..3a85ffd0657 100644 --- a/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs +++ b/config/scripts/foreign-sqlite-reader-worker-smoke.test.mjs @@ -1,5 +1,5 @@ import { build } from 'esbuild' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -54,12 +54,4 @@ describe('foreign SQLite reader build smoke', () => { ) expect(() => smokeForeignSqliteReaderWorker(directory)).toThrow('smoke failed') }) - - it('runs in the orcad build under both runtimes', () => { - const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') - expect(source).toContain( - "{ label: 'foreign SQLite reader worker', smoke: smokeForeignSqliteReaderWorker }" - ) - expect(source).toContain('await smoke(OUT_DIR, { runtimePath: nodeRuntimePath })') - }) }) diff --git a/config/scripts/mobile-mirrored-storage-write-path.test.mjs b/config/scripts/mobile-mirrored-storage-write-path.test.mjs deleted file mode 100644 index b67c82ceea7..00000000000 --- a/config/scripts/mobile-mirrored-storage-write-path.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -/** - * One owner for the mirror the hybrid shell reads (ruling 35). - * - * `mirrored-storage-keys.ts` holds the map the shell builds every `init` from, synchronously, and - * before this the fourteen writers of a mirrored key noted it themselves — first, then persisted. - * On the page a persist can be refused, so twelve of them left the map holding a value no store - * had taken and the next `init` handed the page exactly that; the other two undid it by hand. - * - * Source-scanning rather than behavioural, and about existence rather than shape: what a - * behavioural case cannot say is that no thirteenth writer appears next week. Each row below is a - * module that owns a mirrored key, so deleting its write path reds that row by name, and every - * failure quotes the line it found. - */ -import { globSync, readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) - -const MIRROR_MODULE = 'src/storage/mirrored-storage-keys.ts' - -/** Every module that persists a key the shell mirrors, with the constant each one writes. */ -const MIRRORED_WRITERS = [ - { - file: 'src/storage/preferences.ts', - keys: ['TEXT_SCALE_KEY', 'SIDEBAR_WIDTH_KEY', 'DOCK_WIDTH_KEY'] - }, - { file: 'src/storage/session-view-preferences.ts', keys: ['DEFAULT_SESSION_VIEW_KEY'] }, - { - file: 'src/terminal/terminal-accessory-layout.ts', - keys: ['TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY'] - }, - { file: 'src/components/CustomKeyModal.tsx', keys: ['CUSTOM_ACCESSORY_KEYS_STORAGE_KEY'] }, - { - file: 'src/worktree/last-visited-worktree-repo.ts', - keys: ['LAST_VISITED_WORKTREE_STORAGE_KEY'] - } -] - -/** - * The one caller of the note-then-persist path, which is the shell taking a value the page has - * already applied into a store that refuses nothing. - * - * Counted rather than described: the module says the census holds it to one caller, and until - * this row nothing did. A second caller is either a writer that wants the ordering without the - * store that earns it, or a page-reachable module that would note a refusal as an accepted write. - */ -const NOTE_FIRST_CALLER = 'src/mobile-web-shell/use-page-host-snapshot.ts' - -/** Every module under `mobile/src`, so a new caller cannot arrive in a file no row names. */ -function mobileSources() { - return globSync('src/**/*.{ts,tsx}', { cwd: mobileDir }).sort() -} - -/** The line a match sits on, so a failure names what it found rather than only that it found one. */ -function linesMatching(source, pattern) { - return source - .split('\n') - .map((line, index) => ({ line: line.trim(), at: index + 1 })) - .filter((entry) => pattern.test(entry.line)) -} - -function read(file) { - return readFileSync(new URL(file, new URL(mobileDir, 'file:///')), 'utf8') -} - -describe('the mirrored storage write path', () => { - it('is the only thing that writes the map, which no other module can reach', () => { - const owner = read(MIRROR_MODULE) - // The map itself: a second module holding a reference to it would be a second owner, and the - // map is not exported, so this is what says so. - expect(linesMatching(owner, /^export (const|let) mirror\b/)).toEqual([]) - expect(linesMatching(owner, /^export function note\b/)).toEqual([]) - }) - - it(`calls the note-first path from ${NOTE_FIRST_CALLER} and nowhere else`, () => { - const callers = mobileSources().filter((file) => { - if (file === MIRROR_MODULE) { - return false - } - return linesMatching(read(file), /\bwriteMirroredStorage\(/).length > 0 - }) - expect(callers).toEqual([NOTE_FIRST_CALLER]) - }) - - for (const row of MIRRORED_WRITERS) { - it(`writes ${row.file} through the one path and never around it`, () => { - const source = read(row.file) - expect(linesMatching(source, /\bpersistMirrored\(/).length).toBeGreaterThan(0) - // Around it would be a store call naming a key the map holds, which is the shape every one - // of these had before: note the map, then persist, and nothing between the two agreeing. - for (const key of row.keys) { - expect( - linesMatching(source, new RegExp(`AsyncStorage\\.(setItem|removeItem)\\(\\s*${key}\\b`)), - `${row.file} writes ${key} past the mirror` - ).toEqual([]) - } - expect( - linesMatching(source, /\bnoteMirroredWrite\b/), - `${row.file} notes the map itself` - ).toEqual([]) - }) - } -}) diff --git a/config/scripts/mobile-set-native-props-web-siblings.test.mjs b/config/scripts/mobile-set-native-props-web-siblings.test.mjs deleted file mode 100644 index 791f0c6336b..00000000000 --- a/config/scripts/mobile-set-native-props-web-siblings.test.mjs +++ /dev/null @@ -1,200 +0,0 @@ -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -/** - * Every `setNativeProps` write the mobile app makes, and the web sibling it owes. - * - * `setNativeProps` is a React Native host-component method. On React Native Web a ref is the DOM - * node itself and has no such method, so the call is not a write that does nothing — it is a - * `TypeError`. Where it is made from a render or an effect, that throw reaches the page's fault - * boundary and takes the whole screen down; the shell then tears the view out from under the - * route, which is how one imperative write becomes a 0x0 terminal and a keyboard that never - * opens. - * - * Fenced by absence rather than by a list of approved callers: a module that starts writing this - * way is held to the rule without anyone remembering to add it here. The rule names the file - * rather than the call, because a `.web.ts(x)` sibling is what the page bundler resolves in its - * place, and that substitution is per module. - * - * Both trees the bundler resolves out of are scanned, and `app` is judged more strictly: a route - * file may not make the call at all, because expo-router registers a `.web.tsx` beside a route as - * a second route rather than as a platform sibling, so the split has to live in `src`. - * - * Two offences this caught, both invisible natively and both now fixed by one seam: - * `use-terminal-live-pending-input-flush.ts` cleared the live-input field from the session route's - * mount effect, and `use-terminal-live-accessory-input-commit.ts` wrote the shortened text into it - * when the accessory bar erased a character. Both now call - * `src/terminal/terminal-live-input-text-write.ts`, which ships the sibling. - */ - -const mobileDir = join(fileURLToPath(new URL('../../', import.meta.url)), 'mobile') - -/** - * Both trees the page bundler resolves modules out of. `app` is the route tree the shell serves, - * and it is judged by a stricter rule: expo-router registers a `.web.tsx` beside a route as a - * second route rather than as a platform sibling, so a route file cannot own a platform split at - * all and the write has to move into a `src` module that can. - */ -const SCANNED = ['src', 'app'] - -const SOURCE = /\.tsx?$/ -const WEB_SIBLING = /\.web\.tsx?$/ -const TEST = /\.(test|spec)\.tsx?$/ -/** The call, not the word: a docstring naming it is not a write. */ -const NATIVE_WRITE = /\.setNativeProps\(/ - -async function listFiles(directory) { - const out = [] - for (const entry of await readdir(directory, { withFileTypes: true })) { - if (entry.name === 'node_modules') { - continue - } - const entryPath = join(directory, entry.name) - if (entry.isDirectory()) { - out.push(...(await listFiles(entryPath))) - } else if (entry.isFile() && SOURCE.test(entry.name) && !TEST.test(entry.name)) { - out.push(entryPath) - } - } - return out -} - -const posix = (path) => path.split('\\').join('/') - -const exists = (path) => - readFile(path).then( - () => true, - () => false - ) - -/** Takes the root so the census can be run against a scratch tree and shown to fail. */ -export async function nativePropsWriteSites(rootDir) { - const found = [] - for (const tree of SCANNED) { - const directory = join(rootDir, tree) - if (!existsSync(directory)) { - continue - } - for (const file of await listFiles(directory)) { - if (NATIVE_WRITE.test(await readFile(file, 'utf8'))) { - found.push(posix(relative(rootDir, file))) - } - } - } - return found.sort() -} - -/** Every `src` module that writes this way and has no `.web.ts(x)` to be replaced by. */ -export async function nativePropsWritesWithoutWebSibling(rootDir) { - const offenders = [] - for (const site of await nativePropsWriteSites(rootDir)) { - if (WEB_SIBLING.test(site) || !site.startsWith('src/')) { - continue - } - const base = join(rootDir, site).replace(SOURCE, '') - const hasSibling = (await exists(`${base}.web.ts`)) || (await exists(`${base}.web.tsx`)) - if (!hasSibling) { - offenders.push(site) - } - } - return offenders -} - -/** Every route file that writes this way, which no sibling can rescue. */ -export async function routeTreeNativePropsWrites(rootDir) { - return (await nativePropsWriteSites(rootDir)).filter((site) => site.startsWith('app/')) -} - -describe('the setNativeProps writes the mobile app makes', () => { - it('gives every one of them a web sibling for the page to resolve instead', async () => { - expect(await nativePropsWritesWithoutWebSibling(mobileDir)).toEqual([]) - }) - - it('makes none of them from a route file, which can carry no platform sibling', async () => { - expect(await routeTreeNativePropsWrites(mobileDir)).toEqual([]) - }) - - it('makes none of them from a web sibling, where the method does not exist at all', async () => { - // The other half of the same rule. A sibling that kept the native call would satisfy the case - // above — the file it replaces has one — and still throw on the page. - expect( - (await nativePropsWriteSites(mobileDir)).filter((site) => WEB_SIBLING.test(site)) - ).toEqual([]) - }) - - it('reads writes at all, so the two rules above are not vacuous', async () => { - // An empty offender list is also what a tree with no `setNativeProps` in it produces. These - // are the three seams that own every such write today, each beside its own `.web.ts`, and - // the list spans both scanned trees so `app` having none is a reading rather than a gap. - expect(await nativePropsWriteSites(mobileDir)).toEqual([ - 'src/browser/browser-frame-layer-paint.ts', - 'src/terminal/terminal-live-input-text-write.ts', - 'src/terminal/terminal-settings-scroll-lock.ts' - ]) - }) -}) - -async function withScratch(run) { - const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-set-native-props-')) - try { - return await run(scratch) - } finally { - await rm(scratch, { recursive: true, force: true }) - } -} - -async function plant(scratch, file, source) { - await mkdir(join(scratch, file, '..'), { recursive: true }) - await writeFile(join(scratch, file), source, 'utf8') -} - -const WRITES = 'export const paint = (ref) => ref.current?.setNativeProps({ text: "" })\n' - -// A census that happens to be run against a tree with no offence in it passes for the wrong -// reason. These plant the shapes it claims to judge and show what it would report. -describe('the census scan', () => { - it('names a native module that writes with no sibling beside it', async () => { - await withScratch(async (scratch) => { - await plant(scratch, 'src/terminal/use-terminal-live-pending-input-flush.ts', WRITES) - await plant(scratch, 'src/terminal/use-terminal-live-accessory-input-commit.ts', WRITES) - expect(await nativePropsWritesWithoutWebSibling(scratch)).toEqual([ - 'src/terminal/use-terminal-live-accessory-input-commit.ts', - 'src/terminal/use-terminal-live-pending-input-flush.ts' - ]) - }) - }) - - it('clears a native module once its sibling is on disk, for .ts and for .tsx', async () => { - await withScratch(async (scratch) => { - await plant(scratch, 'src/terminal/write.ts', WRITES) - await plant(scratch, 'src/terminal/write.web.ts', 'export const paint = () => {}\n') - await plant(scratch, 'src/browser/Pane.tsx', WRITES) - await plant(scratch, 'src/browser/Pane.web.tsx', 'export const paint = () => {}\n') - expect(await nativePropsWritesWithoutWebSibling(scratch)).toEqual([]) - }) - }) - - it('names a route file that writes, and is not satisfied by a sibling beside it', async () => { - await withScratch(async (scratch) => { - await plant(scratch, 'app/terminal-settings.tsx', WRITES) - // Planted to show the stricter rule is not the sibling rule wearing a different name: a - // route with a `.web.tsx` next to it is still an offence, because expo-router reads that - // file as a second route. - await plant(scratch, 'app/terminal-settings.web.tsx', 'export default () => null\n') - expect(await routeTreeNativePropsWrites(scratch)).toEqual(['app/terminal-settings.tsx']) - expect(await nativePropsWritesWithoutWebSibling(scratch)).toEqual([]) - }) - }) - - it('skips tests, which write this way to prove the native call throws', async () => { - await withScratch(async (scratch) => { - await plant(scratch, 'src/terminal/write.test.ts', WRITES) - await plant(scratch, 'src/terminal/write.spec.tsx', WRITES) - expect(await nativePropsWriteSites(scratch)).toEqual([]) - }) - }) -}) diff --git a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs index d9ee133a70e..547c9b16f3d 100644 --- a/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs +++ b/config/scripts/node-pty-windows-pty-teardown-patch.test.mjs @@ -13,6 +13,7 @@ // (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no // user-visible pane does. The divergence pinned below is about which // branch each host runs for terminals -- not about a regression in the panes users open. +import { createHash } from 'node:crypto' import { createRequire } from 'node:module' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' @@ -26,7 +27,7 @@ const { const projectDir = resolve(import.meta.dirname, '..', '..') const cleanupDirs = [] -const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js'] +const PATCHED_FILES = ['windowsPtyAgent.js', 'windowsTerminal.js', 'windowsConoutConnection.js'] /** The hunks config/patches/node-pty@1.1.0.patch adds to the installed desktop tree. */ const DESKTOP_HUNKS = { @@ -138,6 +139,126 @@ describe('Windows SSH relay node-pty ConPTY teardown patch', () => { ) }) + it.each([ + '8247ecd69be8b18257050fb026b290024612c5ffc6d492ff1d46f81e613be2cf', + '598755ee75307d041a72cd7c7c4e12ae4a4bbf35eee19da67b42b61fdae0d4d6' + ])( + 'upgrades the exact %s Windows terminal patch without changing relay agent ordering', + (legacySha256) => { + const fixture = writeNodePtyFixture('1.1.0') + const asset = readFileSync( + join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), + 'utf8' + ) + const { PATCH_TARGETS } = loadPatchTargets(asset) + const target = PATCH_TARGETS.find( + (entry) => entry.relativePath.at(-1) === 'windowsTerminal.js' + ) + const publishedPath = join(fixture.libDir, 'windowsTerminal.js') + const publishedTerminal = readFileSync(publishedPath, 'utf8') + patchNodePtyWindowsTeardown(fixture.root) + const relayAgentPath = join(fixture.libDir, 'windowsPtyAgent.js') + const relayAgent = readFileSync(relayAgentPath, 'utf8') + expect(createHash('sha256').update(relayAgent).digest('hex')).toBe( + '3c14daf8d0ec2d1e2d66435caa5fb2b629b230e237873594e623e79e6a7d1223' + ) + let legacy = publishedTerminal + const variant = target.additionalPreviousVariants.find( + (entry) => entry.sha256 === legacySha256 + ) + const priorReplacements = + legacySha256 === target.previousPatchedSha256 + ? target.replacements.slice( + 0, + target.replacements.length - target.previousReplacements.length + ) + : target.replacements + for (const [from, to] of priorReplacements) { + expect(legacy.split(from).length - 1).toBe(1) + legacy = legacy.replace(from, to) + } + if (variant) { + for (const [from, to] of variant.replacements.toReversed()) { + expect(legacy.split(to).length - 1).toBe(1) + legacy = legacy.replace(to, from) + } + } + expect(createHash('sha256').update(legacy).digest('hex')).toBe(legacySha256) + writeFileSync(publishedPath, legacy) + patchNodePtyWindowsTeardown(fixture.root) + expect(readFileSync(relayAgentPath, 'utf8')).toBe(relayAgent) + expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() + expect(readFileSync(publishedPath, 'utf8')).toBe( + readFileSync(desktopPath('windowsTerminal.js'), 'utf8') + ) + const installed = PATCHED_FILES.map((file) => + readFileSync(join(fixture.libDir, file), 'utf8') + ) + patchNodePtyWindowsTeardown(fixture.root) + expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual( + installed + ) + } + ) + + it('finishes interrupted previous relay upgrades in dependency order', () => { + const fixture = writeNodePtyFixture('1.1.0') + const originalConout = readFileSync(join(fixture.libDir, 'windowsConoutConnection.js'), 'utf8') + const asset = readFileSync( + join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), + 'utf8' + ) + const { PATCH_TARGETS } = loadPatchTargets(asset) + patchNodePtyWindowsTeardown(fixture.root) + for (const file of ['windowsPtyAgent.js', 'windowsTerminal.js']) { + const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file) + const variant = + file === 'windowsPtyAgent.js' + ? { sha256: target.previousPatchedSha256, replacements: target.previousReplacements } + : target.additionalPreviousVariants[0] + let previous = readFileSync(join(fixture.libDir, file), 'utf8') + for (const [from, to] of variant.replacements.toReversed()) { + expect(previous.split(to).length - 1).toBe(1) + previous = previous.replace(to, from) + } + expect(createHash('sha256').update(previous).digest('hex')).toBe( + file === 'windowsPtyAgent.js' + ? '1e23ef480569e73706e3ab4f5482c7e553c76f51414ae8e7b0bdcc2fd75f7280' + : '3060c6514a8e9e3285f91b9b549930e7d25d59d4cf7e1ed3a25b9a680dd1ded5' + ) + writeFileSync(join(fixture.libDir, file), previous) + } + writeFileSync(join(fixture.libDir, 'windowsConoutConnection.js'), originalConout) + const previous = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8')) + const completed = [] + const installOrder = ['windowsConoutConnection.js', 'windowsPtyAgent.js', 'windowsTerminal.js'] + patchNodePtyWindowsTeardown(fixture.root) + for (const file of installOrder) { + completed.push(readFileSync(join(fixture.libDir, file), 'utf8')) + } + for (let prefix = 0; prefix < installOrder.length; prefix += 1) { + for (const [index, file] of PATCHED_FILES.entries()) { + writeFileSync(join(fixture.libDir, file), previous[index]) + } + for (let index = 0; index < prefix; index += 1) { + writeFileSync(join(fixture.libDir, installOrder[index]), completed[index]) + } + patchNodePtyWindowsTeardown(fixture.root) + expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() + } + expect(() => assertPatchedNodePtyWindowsTeardown(fixture.root)).not.toThrow() + for (const file of ['windowsTerminal.js', 'windowsConoutConnection.js']) { + expect(readFileSync(join(fixture.libDir, file), 'utf8')).toBe( + readFileSync(desktopPath(file), 'utf8') + ) + } + const once = PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8')) + patchNodePtyWindowsTeardown(fixture.root) + expect(PATCHED_FILES.map((file) => readFileSync(join(fixture.libDir, file), 'utf8'))).toEqual( + once + ) + }) + it('refuses a different package version or unexpected source', () => { const wrongVersion = writeNodePtyFixture('1.2.0-beta.11') expect(() => patchNodePtyWindowsTeardown(wrongVersion.root)).toThrow('expected 1.1.0') @@ -146,7 +267,11 @@ describe('Windows SSH relay node-pty ConPTY teardown patch', () => { const drifted = writeNodePtyFixture('1.1.0') const path = join(drifted.libDir, file) writeFileSync(path, `${readFileSync(path, 'utf8')}\n// drift`) + const before = PATCHED_FILES.map((entry) => readFileSync(join(drifted.libDir, entry), 'utf8')) expect(() => patchNodePtyWindowsTeardown(drifted.root)).toThrow('unexpected node-pty') + expect( + PATCHED_FILES.map((entry) => readFileSync(join(drifted.libDir, entry), 'utf8')) + ).toEqual(before) } }) @@ -170,7 +295,7 @@ function writeNodePtyFixture(version) { writeFileSync(join(root, 'node_modules', 'node-pty', 'package.json'), JSON.stringify({ version })) for (const file of PATCHED_FILES) { const desktop = readFileSync(desktopPath(file), 'utf8') - for (const [marker] of DESKTOP_HUNKS[file]) { + for (const [marker] of DESKTOP_HUNKS[file] || []) { expect(desktop).toContain(marker) } writeFileSync(join(libDir, file), unapplyDesktopHunks(file, desktop)) @@ -188,7 +313,17 @@ function writeNodePtyFixture(version) { */ function unapplyDesktopHunks(file, desktop) { if (file === 'windowsPtyAgent.js') { + const asset = readFileSync( + join(projectDir, 'config', 'relay-assets', 'node-pty-1.1.0-windows-pty-teardown-patch.cjs'), + 'utf8' + ) + const { PATCH_TARGETS } = loadPatchTargets(asset) + const target = PATCH_TARGETS.find((entry) => entry.relativePath.at(-1) === file) let published = desktop + for (const [from, to] of target.replacements.slice(1).toReversed()) { + expect(published.split(to).length - 1).toBe(1) + published = published.replace(to, from) + } for (const [patched, original] of DESKTOP_HUNKS[file]) { expect(published.split(patched).length - 1).toBe(1) published = published.replace(patched, original) diff --git a/config/scripts/pnpm-cli-invocation.test.mjs b/config/scripts/pnpm-cli-invocation.test.mjs index 70b496e2932..f52c00cd78f 100644 --- a/config/scripts/pnpm-cli-invocation.test.mjs +++ b/config/scripts/pnpm-cli-invocation.test.mjs @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' @@ -216,16 +215,3 @@ describe('resolvePnpmCliInvocation', () => { expect(fileExists).not.toHaveBeenCalled() }) }) - -describe('pnpm 12 native-cli callers', () => { - it('reinvokes pnpm through the helper rather than `node $npm_execpath`', () => { - for (const file of [ - './build-native-for-platform.mjs', - './run-ssh-docker-bulk-open-freeze-e2e.mjs' - ]) { - const source = readFileSync(new URL(file, import.meta.url), 'utf8') - expect(source).toContain("from './pnpm-cli-invocation.mjs'") - expect(source).not.toMatch(/process\.execPath,\s*\[\s*(?:pnpmEntry|npmExecPath)/) - } - }) -}) diff --git a/config/scripts/project-renderer-web-client.test.mjs b/config/scripts/project-renderer-web-client.test.mjs index c31cd940e19..bdb813b5893 100644 --- a/config/scripts/project-renderer-web-client.test.mjs +++ b/config/scripts/project-renderer-web-client.test.mjs @@ -92,12 +92,6 @@ describe('renderer web client projection', () => { expect(output.nM()).toBe(3) }) - it('keeps the build-only manifest out of packaged apps', () => { - const builderConfig = readFileSync(resolve('config/electron-builder.config.cjs'), 'utf8') - - expect(builderConfig).toContain("'!out/renderer/.vite{,/**/*}'") - }) - it('copies and minifies only the web dependency closure', async () => { const root = createRendererFixture() const result = await projectFixture(root) diff --git a/config/scripts/release-javascript-workflow.test.mjs b/config/scripts/release-javascript-workflow.test.mjs deleted file mode 100644 index 720c8f3d7cd..00000000000 --- a/config/scripts/release-javascript-workflow.test.mjs +++ /dev/null @@ -1,91 +0,0 @@ -import { readFileSync } from 'node:fs' -import { parse } from 'yaml' -import { describe, expect, it } from 'vitest' - -const release = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8')) -const mac = parse(readFileSync('.github/workflows/release-mac-build.yml', 'utf8')) -const javascript = parse(readFileSync('.github/workflows/release-javascript.yml', 'utf8')) -const comparison = parse(readFileSync('.github/workflows/release-javascript-benchmark.yml', 'utf8')) - -describe('release JavaScript job boundaries', () => { - it('compiles the release tag while blocking gates run, with no publishing permission', () => { - const job = release.jobs['release-javascript'] - expect(job.needs).toBe('cut') - expect(job.permissions).toEqual({ contents: 'read' }) - expect(job.with.ref).toBe('refs/tags/${{ needs.cut.outputs.tag }}') - expect(javascript.permissions).toEqual({ contents: 'read' }) - expect(javascript.jobs.bundle['runs-on']).toBe('ubuntu-latest') - expect(javascript.jobs.bundle.steps[0].with.ref).toBe('${{ inputs.ref }}') - expect(javascript.env.ORCA_BACKGROUND_LAUNCH).toBe('1') - }) - - it.each(['build', 'build-mac'])( - 'keeps %s behind signing gates and the bundle verdict', - (name) => { - const job = release.jobs[name] - expect(job.needs).toEqual( - expect.arrayContaining([ - 'cut', - 'create-release', - 'release-preflight', - 'relay-windows-process-tree', - 'release-javascript' - ]) - ) - expect(job.if).toContain("needs.release-preflight.result == 'success'") - expect(job.if).toContain("needs.release-javascript.result == 'success'") - } - ) - - it('retains full compilation for old refs but fails rather than rebuilding a broken shared artifact', () => { - const job = javascript.jobs.bundle - const support = job.steps.find((step) => step.id === 'source') - expect(support.run).toContain('build:release:javascript') - expect(support.run).toContain('build:release:host') - const compile = job.steps.find((step) => step.name === 'Build and archive release JavaScript') - expect(compile.if).toBe("steps.source.outputs.supported == 'true'") - const download = release.jobs.build.steps.find( - (step) => step.name === 'Download release JavaScript' - ) - expect(download.if).toBe("needs.release-javascript.outputs.supported == 'true'") - expect(download['continue-on-error']).toBeUndefined() - const build = release.jobs.build.steps.find((step) => step.name === 'Build app') - expect(build.run).toMatch(/artifact\.mjs restore\s+pnpm run build:release:host/) - expect(build.run).toMatch(/else\s+pnpm run build:release/) - expect(build['continue-on-error']).toBeUndefined() - }) - - it('restores the same parent-run bundle on macOS and retains native and runtime checks', () => { - const download = mac.jobs['build-mac'].steps.find( - (step) => step.name === 'Download release JavaScript from the release run' - ) - expect(download.with['run-id']).toBe('${{ inputs.release_run_id }}') - expect(download.with.name).toBe('release-javascript') - const build = mac.jobs['build-mac'].steps.find((step) => step.name === 'Build app') - expect(build.env.ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA).toBe('${{ inputs.javascript_source_sha }}') - expect(build.run).toContain('pnpm run build:release:host') - for (const job of [release.jobs.build, mac.jobs['build-mac']]) { - expect(job.steps.map((step) => step.name)).toEqual( - expect.arrayContaining([ - 'Gate runtime file-watcher process isolation', - 'Gate SSH relay watcher process isolation' - ]) - ) - } - }) - - it('compares both build paths on all packaging hosts without publishing credentials', () => { - expect(comparison.jobs.measure.strategy.matrix.mode).toEqual(['baseline', 'shared']) - expect(comparison.jobs.measure.strategy.matrix.host).toHaveLength(4) - expect(comparison.permissions).toEqual({ contents: 'read' }) - expect(comparison.env.ORCA_POSTHOG_WRITE_KEY).toBe('ci-build-comparison') - expect(comparison.jobs.bundle.secrets.ORCA_POSTHOG_WRITE_KEY).toBe('ci-build-comparison') - const steps = comparison.jobs.measure.steps - expect(steps.find((step) => step.name === 'Download shared JavaScript').if).toBe( - "matrix.mode == 'shared'" - ) - expect(steps.find((step) => step.name === 'Measure release build').run).toContain( - 'release-javascript-benchmark.mjs' - ) - }) -}) diff --git a/config/scripts/session-scanner-service-smoke.test.mjs b/config/scripts/session-scanner-service-smoke.test.mjs index b2e05158247..08b72b60336 100644 --- a/config/scripts/session-scanner-service-smoke.test.mjs +++ b/config/scripts/session-scanner-service-smoke.test.mjs @@ -1,5 +1,5 @@ import { build } from 'esbuild' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -63,12 +63,4 @@ describe('session scanner service build smoke', () => { ) expect(() => smokeSessionScannerService(directory)).toThrow('smoke failed') }) - - it('runs in the orcad build under both runtimes', () => { - const source = readFileSync(resolve('config/scripts/build-orcad.mjs'), 'utf8') - expect(source).toContain( - "{ label: 'session scanner service', smoke: smokeSessionScannerService }" - ) - expect(source).toContain('await smoke(OUT_DIR, { runtimePath: nodeRuntimePath })') - }) }) diff --git a/config/scripts/windows-pty-closed-forwarding-teardown.cjs b/config/scripts/windows-pty-closed-forwarding-teardown.cjs new file mode 100644 index 00000000000..a32fb5f1382 --- /dev/null +++ b/config/scripts/windows-pty-closed-forwarding-teardown.cjs @@ -0,0 +1,231 @@ +'use strict' + +const assert = require('node:assert/strict') +const { createHash } = require('node:crypto') +const { readFileSync, writeSync } = require('node:fs') +const { dirname, resolve } = require('node:path') +const pty = require('node-pty') +const utilsPath = require.resolve('node-pty/lib/utils') +const loaded = require(utilsPath).loadNativeModule('conpty') +const native = loaded.module +const [backend, faultMode] = process.argv.slice(2) +assert.equal(process.platform, 'win32') +assert.ok(backend === 'dll' || backend === 'inbox') +assert.ok(faultMode === undefined || faultMode === 'retry') +const retryFault = faultMode === 'retry' +assert.equal(native.assignCurrentProcessToJob(), true, 'Host crash cleanup must own descendants') + +function alive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error.code === 'EPERM' + } +} + +function report(phase, details) { + writeSync(1, `${JSON.stringify({ phase, ...details })}\n`) +} + +async function exercise() { + const { createStressObserver, loadedStressInputHashes } = + await import('./windows-pty-table-stress-observer.mjs') + const processTree = require('@vscode/windows-process-tree') + const { + assertWindowsProcessTreeCreationTime + } = require('./windows-process-tree-creation-time.cjs') + assertWindowsProcessTreeCreationTime({ module: processTree }) + const addonPath = resolve(dirname(utilsPath), loaded.dir, 'conpty.node') + const extraInputs = [ + require.resolve('node-pty/lib/windowsConoutConnection'), + require.resolve('node-pty/lib/worker/conoutSocketWorker'), + require.resolve('@vscode/windows-process-tree') + ].map((path) => { + const bytes = readFileSync(path) + return { path, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') } + }) + report('inputs', { + backend, + node: process.version, + inputs: loadedStressInputHashes(addonPath, require.resolve), + extraInputs + }) + const term = pty.spawn( + process.execPath, + ['-e', 'process.stdin.resume();setInterval(() => {}, 1000)'], + { + cwd: process.cwd(), + cols: 80, + rows: 24, + useConpty: true, + useConptyDll: backend === 'dll', + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + } + ) + const pid = term.pid + let dataCallbacks = 0 + let exitCallbacks = 0 + const unexpectedExits = [] + let nativeExitCallbacks = 0 + let forwardingConnections = 0 + let faultApplied = false + const errors = [] + const unhandledRejections = [] + let firstKillFault = false + let retried = false + const record = { + proc: term, + exited: false, + get closed() { + return term._killRequested === true + } + } + term.onData(() => { + dataCallbacks += 1 + }) + term.onExit((event) => { + if ( + nativeExitCallbacks !== 1 || + !Number.isInteger(event.exitCode) || + event.exitCode !== term._agent.exitCode + ) { + unexpectedExits.push({ + nativeExitCallbacks, + exitCode: event.exitCode ?? null, + nativeExitCode: term._agent.exitCode ?? null + }) + } + exitCallbacks += 1 + record.exited = true + }) + if (retryFault) { + assert.equal(term.listeners('error').length, 1, 'Only the constructor error listener may exist') + process.on('unhandledRejection', (error) => { + unhandledRejections.push(String(error)) + report('unhandled-cleanup-rejection', { error: String(error) }) + }) + const originalKill = term._agent.kill.bind(term._agent) + term._agent.kill = () => { + if (!firstKillFault) { + firstKillFault = true + report('injected-first-kill-failure', { pid }) + throw new Error('ORCA_FIRST_PRECONNECT_KILL_FAILURE') + } + originalKill() + } + } else { + term.on('error', (error) => { + errors.push(String(error)) + report('terminal-error', { error: String(error) }) + }) + } + const observer = createStressObserver((phase, details) => { + if (phase === 'native-exit-callback') { + nativeExitCallbacks += 1 + } + report(phase, details) + }) + observer.watch(record, { round: 0, slot: 0 }) + term._socket.on('ready_datapipe', () => { + forwardingConnections += 1 + }) + const originalCreationTimeMs = processTree.getProcessCreationTime(pid) ?? null + assert.ok(Number.isSafeInteger(originalCreationTimeMs) && originalCreationTimeMs > 0) + assert.equal(alive(pid), true) + assert.ok(native.listJobProcessIds(term._pty, pid)?.includes(pid)) + report('child-incarnation', { pid, originalCreationTimeMs }) + // The Agent's earlier listener starts the real connection before this resource fault. + term._agent._conoutSocketWorker.onReady(() => { + assert.equal(term._isPipeReady, false, 'The resource fault must precede forwarding readiness') + assert.equal(dataCallbacks, 0, 'The resource fault must precede delivered output') + report('before-closed-forwarding-teardown', { pid, dataCallbacks, forwardingConnections }) + term.destroy() + term._socket.destroy() + faultApplied = true + report('closed-forwarding-teardown', { + pid, + outputDestroyed: term._socket.destroyed, + killRequested: term._killRequested === true + }) + }) + const deadline = Date.now() + 15_000 + while ((!faultApplied || alive(pid) || exitCallbacks === 0) && Date.now() < deadline) { + if (retryFault && firstKillFault && !retried) { + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(processTree.getProcessCreationTime(pid), originalCreationTimeMs) + assert.equal(alive(pid), true, 'The failed first kill must leave the actual child live') + assert.ok(native.listJobProcessIds(term._pty, pid)?.includes(pid)) + assert.equal(nativeExitCallbacks, 0) + assert.equal(exitCallbacks, 0) + retried = true + report('retry-after-first-kill-failure', { pid, nativeExitCallbacks, exitCallbacks }) + term.destroy() + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + const currentCreationTimeMs = processTree.getProcessCreationTime(pid) ?? null + report('retirement-state', { + backend, + pid, + originalCreationTimeMs, + currentCreationTimeMs, + incarnation: + Number.isSafeInteger(currentCreationTimeMs) && currentCreationTimeMs > 0 + ? currentCreationTimeMs === originalCreationTimeMs + ? 'same' + : 'different' + : 'unverifiable', + faultApplied, + pidIsAlive: alive(pid), + ownedJobProcessIds: native.listJobProcessIds(term._pty, pid) ?? null, + nativeExitCallbacks, + nativeExitCode: Number.isInteger(term._agent.exitCode) ? term._agent.exitCode : null, + publicExitCallbacks: exitCallbacks, + unexpectedExits, + forwardingConnections, + dataCallbacks, + inputDestroyed: term._agent.inSocket.destroyed, + outputDestroyed: term._socket.destroyed, + pipeReady: term._isPipeReady === true, + firstDataReady: term._isReady === true, + killRequested: term._killRequested === true, + killReturned: term._killComplete === true, + errors, + retryFault, + firstKillFault, + retried, + unhandledRejections + }) + assert.equal(faultApplied, true, 'The actual worker-ready resource fault must be reached') + assert.equal(forwardingConnections, 0, 'A retired forwarding connection must not reopen') + assert.equal(alive(pid), false, 'Closed-forwarding public teardown must kill the real child') + assert.equal( + nativeExitCallbacks, + 1, + 'The actual native exit acknowledgment must arrive exactly once' + ) + assert.equal(exitCallbacks, 1, 'Actual public exit must arrive exactly once') + assert.deepEqual( + unexpectedExits, + [], + 'Requested ConPTY teardown must publish the actual native acknowledgment and exit code' + ) + assert.equal(term._agent.inSocket.destroyed, true) + assert.deepEqual(errors, []) + if (retryFault) { + assert.equal(firstKillFault, true, 'The actual first kill failure must be reached') + assert.equal(retried, true, 'Public teardown must retry the failed native cleanup') + assert.deepEqual( + unhandledRejections, + [], + 'Cleanup failure must not create an unhandled rejection' + ) + } + report('complete', { backend, pid, nativeExitCallbacks, exitCallbacks, retryFault }) +} + +exercise().catch((error) => { + writeSync(2, `${error.stack}\n`) + process.exitCode = 1 +}) diff --git a/config/scripts/windows-pty-pre-ready-teardown.cjs b/config/scripts/windows-pty-pre-ready-teardown.cjs new file mode 100644 index 00000000000..a5225427d05 --- /dev/null +++ b/config/scripts/windows-pty-pre-ready-teardown.cjs @@ -0,0 +1,226 @@ +'use strict' + +const assert = require('node:assert/strict') +const { createHash } = require('node:crypto') +const { readFileSync, writeSync } = require('node:fs') +const { dirname, resolve } = require('node:path') +const pty = require('node-pty') +const utilsPath = require.resolve('node-pty/lib/utils') +const loaded = require(utilsPath).loadNativeModule('conpty') +const native = loaded.module +const [operation, fence] = process.argv.slice(2) +assert.equal(process.platform, 'win32') +assert.ok(operation === 'kill' || operation === 'destroy') +assert.ok(fence === 'immediate' || fence === 'connected') +assert.equal(native.assignCurrentProcessToJob(), true, 'Host crash cleanup must own descendants') + +function alive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error.code === 'EPERM' + } +} + +async function exercise() { + const { createStressObserver, loadedStressInputHashes } = + await import('./windows-pty-table-stress-observer.mjs') + const processTree = require('@vscode/windows-process-tree') + const { + assertWindowsProcessTreeCreationTime + } = require('./windows-process-tree-creation-time.cjs') + assertWindowsProcessTreeCreationTime({ module: processTree }) + const processTreeEntry = require.resolve('@vscode/windows-process-tree') + const identityInputs = [ + processTreeEntry, + resolve(dirname(processTreeEntry), '..', 'build', 'Release', 'windows_process_tree.node'), + require.resolve('./windows-process-tree-creation-time.cjs') + ].map((path) => { + const bytes = readFileSync(path) + return { path, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') } + }) + const addonPath = resolve(dirname(utilsPath), loaded.dir, 'conpty.node') + writeSync( + 1, + `${JSON.stringify({ + phase: 'inputs', + node: process.version, + inputs: loadedStressInputHashes(addonPath, require.resolve), + identityInputs + })}\n` + ) + const term = pty.spawn( + process.execPath, + ['-e', 'process.stdin.resume();setInterval(() => {}, 1000)'], + { + cwd: process.cwd(), + cols: 80, + rows: 24, + useConpty: true, + useConptyDll: true, + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + } + ) + let dataCallbacks = 0 + let exitCallbacks = 0 + const unexpectedExits = [] + let nativeExitCallbacks = 0 + const record = { + proc: term, + exited: false, + get closed() { + return term._killRequested === true + } + } + const observer = createStressObserver((phase, details) => { + if (phase === 'native-exit-callback') { + nativeExitCallbacks += 1 + } + writeSync(1, `${JSON.stringify({ phase, ...details })}\n`) + }) + term.onData(() => { + dataCallbacks += 1 + }) + term.onExit((event) => { + if ( + nativeExitCallbacks !== 1 || + !Number.isInteger(event.exitCode) || + event.exitCode !== term._agent.exitCode + ) { + unexpectedExits.push({ + nativeExitCallbacks, + exitCode: event.exitCode ?? null, + nativeExitCode: term._agent.exitCode ?? null + }) + } + exitCallbacks += 1 + record.exited = true + }) + observer.watch(record, { round: 0, slot: 0 }) + const pid = term.pid + const originalCreationTimeMs = processTree.getProcessCreationTime(pid) ?? null + writeSync(1, `${JSON.stringify({ phase: 'child-incarnation', pid, originalCreationTimeMs })}\n`) + assert.ok( + Number.isSafeInteger(originalCreationTimeMs) && originalCreationTimeMs > 0, + 'The actual child creation time must be readable before teardown' + ) + function reportFence(stage) { + writeSync( + 1, + `${JSON.stringify({ + phase: 'fence', + stage, + operation, + fence, + pid, + dataCallbacks, + inputDestroyed: term._agent.inSocket.destroyed + })}\n` + ) + } + assert.ok(Number.isInteger(pid) && pid > 0) + assert.equal(alive(pid), true) + assert.ok(native.listJobProcessIds(term._pty, pid)?.includes(pid)) + assert.equal(dataCallbacks, 0) + term.write('queued input') + + const connected = new Promise((resolve, reject) => { + // Observe the real forwarding connection; do not suppress or fabricate output. + term._socket.once('ready_datapipe', () => { + try { + assert.equal(dataCallbacks, 0, 'The fence must precede delivered first data') + reportFence('forwarding-connected') + if (fence === 'connected') { + assert.equal(alive(pid), true) + assert.ok(native.listJobProcessIds(term._pty, pid)?.includes(pid)) + reportFence('before-public-teardown') + term[operation]() + reportFence('after-public-teardown') + } + // This is actual pipe retirement; old queued teardown leaves it open here. + reportFence('pre-first-data-retirement') + assert.equal( + term._agent.inSocket.destroyed, + true, + 'Public teardown must retire the actual input pipe at forwarding connection before first data' + ) + term[operation]() + term.write('retired input') + term.resize(81, 24) + term.clear() + resolve() + } catch (error) { + reject(error) + } + }) + }) + if (fence === 'immediate') { + reportFence('before-public-teardown') + term[operation]() + reportFence('after-public-teardown') + term[operation]() + } + const deadline = Date.now() + 15_000 + let timeout + try { + await Promise.race([ + connected, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error('Forwarding connection did not arrive')), + 15_000 + ) + }) + ]) + } finally { + clearTimeout(timeout) + } + while ((alive(pid) || exitCallbacks === 0) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + const currentCreationTimeMs = processTree.getProcessCreationTime(pid) ?? null + writeSync( + 1, + `${JSON.stringify({ + phase: 'retirement-state', + operation, + fence, + pid, + originalCreationTimeMs, + currentCreationTimeMs, + incarnation: + Number.isSafeInteger(currentCreationTimeMs) && currentCreationTimeMs > 0 + ? currentCreationTimeMs === originalCreationTimeMs + ? 'same' + : 'different' + : 'unverifiable', + pidIsAlive: alive(pid), + ownedJobProcessIds: native.listJobProcessIds(term._pty, pid) ?? null, + nativeExitCallbacks, + nativeExitCode: Number.isInteger(term._agent.exitCode) ? term._agent.exitCode : null, + publicExitCallbacks: exitCallbacks, + unexpectedExits, + dataCallbacks, + inputDestroyed: term._agent.inSocket.destroyed, + outputDestroyed: term._socket.destroyed, + pipeReady: term._isPipeReady === true, + firstDataReady: term._isReady === true, + killRequested: term._killRequested === true, + killReturned: term._killComplete === true + })}\n` + ) + assert.equal(alive(pid), false, 'Public teardown must kill the real child') + assert.equal(exitCallbacks, 1, 'Actual public exit must arrive exactly once') + assert.deepEqual( + unexpectedExits, + [], + 'Requested ConPTY teardown must publish the actual native acknowledgment and exit code' + ) + writeSync(1, `${JSON.stringify({ phase: 'complete', operation, fence, pid, exitCallbacks })}\n`) +} + +exercise().catch((error) => { + writeSync(2, `${error.stack}\n`) + process.exitCode = 1 +}) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 4e49cfc175e..8a96bdf8352 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -42,10 +42,8 @@ const testOptions = { testTimeout: 30_000 } const nodeProject = { - extends: false, - ...transforms, + extends: true, test: { - ...testOptions, name: process.versions.bun ? 'node-runtime' : 'node', env: { ORCA_VITEST_RUNTIME: process.versions.bun ? 'node-runtime' : 'node' }, include: process.versions.bun ? NODE_RUNTIME_INCLUDE : UNIT_INCLUDE, @@ -60,13 +58,12 @@ const projects = [ ...(process.versions.bun ? [ { - extends: false, - ...transforms, + extends: true, test: { - ...testOptions, name: 'bun', env: { ORCA_VITEST_RUNTIME: 'bun' }, pool: 'forks', + include: UNIT_INCLUDE, exclude: [...testOptions.exclude, ...NODE_RUNTIME_INCLUDE, measurementFile], sequence: { groupOrder: 1 } } @@ -90,8 +87,32 @@ const projects = [ export default defineConfig({ ...transforms, + plugins: [ + { + name: 'orca-test-container-selectors', + enforce: 'post', + config: { + order: 'post', + handler(config) { + if (!config.test?.projects) { + return + } + // Keep root coverage exclusions without inheriting duplicate project selectors. + if (config.test.include?.length === 0) { + config.test.include = testOptions.include + } + if (config.test.exclude?.length === 0) { + config.test.exclude = testOptions.exclude + } + } + } + } + ], test: { ...testOptions, + // Inline projects own their selectors; empty root arrays avoid concatenating them. + include: [], + exclude: [], sequence: { sequencer: balancedShards ? TimingSequencer : RuntimeSequencer }, ...(balancedShards ? { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 544ad1f899e..6c93ba2044c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,7 +175,8 @@ patchedDependencies: '@xterm/xterm@6.1.0-beta.303': dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393 i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 - node-pty@1.1.0: 92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192 + node-pty@1.1.0: 64f39642b03aeb629d562c834bf0e2fdaf1ccba5cf90b1a3e2f4c63654bb7d74 + vitest@5.0.3: 3d512e2cb30542921690d9fa925edf503d92dd7d58a4c4dd2aa2fa69e9a243f9 ws@8.22.0: 9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14 importers: @@ -217,7 +218,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192) + version: 1.1.0(patch_hash=64f39642b03aeb629d562c834bf0e2fdaf1ccba5cf90b1a3e2f4c63654bb7d74) posthog-node: specifier: ^5.52.5 version: 5.52.5 @@ -611,7 +612,7 @@ importers: version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) vitest: specifier: 5.0.3 - version: 5.0.3(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) + version: 5.0.3(patch_hash=3d512e2cb30542921690d9fa925edf503d92dd7d58a4c4dd2aa2fa69e9a243f9)(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -13799,7 +13800,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=92c95cffab383d86b3b13a460c75a08074468ebf1f3911db8e874e083201f192): + node-pty@1.1.0(patch_hash=64f39642b03aeb629d562c834bf0e2fdaf1ccba5cf90b1a3e2f4c63654bb7d74): dependencies: node-addon-api: 7.1.1 @@ -15327,7 +15328,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vitest@5.0.3(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): + vitest@5.0.3(patch_hash=3d512e2cb30542921690d9fa925edf503d92dd7d58a4c4dd2aa2fa69e9a243f9)(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(happy-dom@20.14.5)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.3(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d673455655..08df4e76223 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -64,3 +64,4 @@ patchedDependencies: '@vscode/windows-process-tree@0.8.0': config/patches/@vscode__windows-process-tree@0.8.0.patch i18next-cli@1.74.2: config/patches/i18next-cli@1.74.2.patch ws@8.22.0: config/patches/ws@8.22.0.patch + vitest@5.0.3: config/patches/vitest@5.0.3.patch diff --git a/src/main/app-relaunch.ts b/src/main/app-relaunch.ts index ce96af1e96f..57bcea0a50e 100644 --- a/src/main/app-relaunch.ts +++ b/src/main/app-relaunch.ts @@ -16,3 +16,17 @@ export function relaunchApp(reason: AppRelaunchReason, data?: CrashReportBreadcr recordDurableCrashBreadcrumb('app_relaunch_requested', { ...data, reason }) runWithLaunchPath(() => app.relaunch()) } + +export async function runBeforeRelaunchCleanup( + onBeforeRelaunch?: () => void | Promise +): Promise { + try { + await onBeforeRelaunch?.() + } catch (error) { + // Why: best-effort cleanup must never block relaunch; log only error.name to avoid leaking secrets. + console.warn( + '[app] Pre-relaunch cleanup failed; continuing relaunch:', + error instanceof Error ? error.name : typeof error + ) + } +} diff --git a/src/main/crash-reporting/self-initiated-tree-kill-log.ts b/src/main/crash-reporting/self-initiated-tree-kill-log.ts index d684bd874ef..30234a4b3f1 100644 --- a/src/main/crash-reporting/self-initiated-tree-kill-log.ts +++ b/src/main/crash-reporting/self-initiated-tree-kill-log.ts @@ -38,12 +38,6 @@ import { recordCoalescedDurableCrashBreadcrumb } from './durable-crash-breadcrum * taskkill (neither runs in main); and the browser-route Electron probes, * which are reached only from `*.electron.test.ts`. * - * `main-process-tree-kill-gate.test.ts` is the ratchet that keeps that list - * closed: it counts `/pid` call sites against gate admissions per file, so a new - * pid-addressed kill fails it whether it lands in a new file or inside a family - * that already asks the gate. It does not see a `/pid` argument built from a - * variable. - * * A daemon or relay kill missing from the count is a diagnostics gap, not a * missed suspect: those hosts cannot reach a Chromium pid in the first place * (see `orca-chromium-process-pids.ts`), and a group or Job-Object kill can diff --git a/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts b/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts index d716cb9372d..b9a84880074 100644 --- a/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts +++ b/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts @@ -8,6 +8,8 @@ import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' import type { DaemonFileLog } from './daemon-file-log' import { HistoryReader } from './history-reader' +import { getHistorySessionDirName } from './history-paths' +import { readTerminalHistoryCheckpoint } from './terminal-history-checkpoint-reader' import type { HistoryCheckpointResult } from './terminal-history-manager-options' import type { SubprocessHandle } from './session-subprocess-handle' import type { TerminalSnapshot } from './types' @@ -16,6 +18,8 @@ const REATTACH_BUDGET_MS = 2_000 // Why above DURABLE_HISTORY_OVERLAY_DEADLINE_MS: this asserts the reattach gives up on its own // stalled checkpoint, so the window must outlast the deadline it is proving. const DEADLINE_BUDGET_MS = 20_000 +const scheduleBudgetTimeout = setTimeout +const clearBudgetTimeout = clearTimeout function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) => void } { let onData: ((data: string) => void) | undefined @@ -46,12 +50,12 @@ function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) = async function withinBudget(work: Promise, budgetMs: number): Promise { let timer: ReturnType | undefined const deadline = new Promise<'timed-out'>((resolve) => { - timer = setTimeout(() => resolve('timed-out'), budgetMs) + timer = scheduleBudgetTimeout(() => resolve('timed-out'), budgetMs) }) try { return await Promise.race([work, deadline]) } finally { - clearTimeout(timer) + clearBudgetTimeout(timer) } } @@ -95,12 +99,22 @@ describe('STA-4173 reattach isolation from a stalled checkpoint', () => { */ async function stallCheckpointFor(stalledSessionId: string): Promise<{ entered: () => number + enteredPromise: Promise + completedPromise: Promise release: () => void }> { const manager = adapter.getHistoryManager() expect(manager).not.toBeNull() const original = manager!.checkpoint.bind(manager!) let entered = 0 + let resolveEntered = (): void => {} + const enteredPromise = new Promise((resolve) => { + resolveEntered = resolve + }) + let resolveCompleted: (result: HistoryCheckpointResult) => void = () => {} + const completedPromise = new Promise((resolve) => { + resolveCompleted = resolve + }) let release = (): void => {} const stalled = new Promise((resolve) => { release = resolve @@ -115,11 +129,14 @@ describe('STA-4173 reattach isolation from a stalled checkpoint', () => { return await original(sessionId, snapshot, opts) } entered += 1 + resolveEntered() await stalled - return await original(sessionId, snapshot, opts) + const result = await original(sessionId, snapshot, opts) + resolveCompleted(result) + return result } ) - return { entered: () => entered, release } + return { entered: () => entered, enteredPromise, completedPromise, release } } async function spawnWithOutput(sessionId: string, output: string): Promise { @@ -159,10 +176,36 @@ describe('STA-4173 reattach isolation from a stalled checkpoint', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { - const reattach = await withinBudget( + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + const pendingReattach = withinBudget( adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }), DEADLINE_BUDGET_MS ) + let settled = false + void pendingReattach.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + const checkpointEntry = await withinBudget( + Promise.race([ + stall.enteredPromise.then(() => 'entered' as const), + pendingReattach.then(() => 'reattach-settled' as const) + ]), + DEADLINE_BUDGET_MS + ) + expect(checkpointEntry, 'Reattach must enter its checkpoint before settling').toBe('entered') + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + expect(warn).not.toHaveBeenCalledWith( + '[history] durable snapshot overlay deadline exceeded:', + stalledId + ) + await vi.advanceTimersByTimeAsync(1) + const reattach = await pendingReattach expect(stall.entered()).toBeGreaterThan(0) expect(reattach).not.toBe('timed-out') @@ -174,6 +217,7 @@ describe('STA-4173 reattach isolation from a stalled checkpoint', () => { stalledId ) } finally { + vi.useRealTimers() stall.release() warn.mockRestore() } @@ -226,13 +270,48 @@ describe('STA-4173 reattach isolation from a stalled checkpoint', () => { const stalledId = await spawnWithOutput('resumed-session', 'RESUMED_OUTPUT\r\n') const stall = await stallCheckpointFor(stalledId) - const reattach = await withinBudget( - adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }), - DEADLINE_BUDGET_MS + try { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + const pendingReattach = withinBudget( + adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }), + DEADLINE_BUDGET_MS + ) + let settled = false + void pendingReattach.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + const checkpointEntry = await withinBudget( + Promise.race([ + stall.enteredPromise.then(() => 'entered' as const), + pendingReattach.then(() => 'reattach-settled' as const) + ]), + DEADLINE_BUDGET_MS + ) + expect(checkpointEntry, 'Reattach must enter its checkpoint before settling').toBe('entered') + await vi.advanceTimersByTimeAsync(4_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + const reattach = await pendingReattach + expect(reattach).not.toBe('timed-out') + } finally { + vi.useRealTimers() + stall.release() + } + expect(await withinBudget(stall.completedPromise, DEADLINE_BUDGET_MS)).toBe('committed') + const checkpointRead = await readTerminalHistoryCheckpoint( + join(dir, 'history', getHistorySessionDirName(stalledId), 'checkpoint.json') ) - expect(reattach).not.toBe('timed-out') - - stall.release() + if (checkpointRead.status !== 'readable') { + throw new Error(`Abandoned checkpoint is ${checkpointRead.status}`) + } + expect( + `${checkpointRead.checkpoint.scrollbackAnsi}${checkpointRead.checkpoint.snapshotAnsi}` + ).toContain('RESUMED_OUTPUT') // Why this is the whole point of not cancelling: the reattach degraded, and the durable write // the deadline walked away from still lands on disk for the next restore. await vi.waitFor(async () => { diff --git a/src/main/daemon/issue-6814-daemon-failure-classification.test.ts b/src/main/daemon/issue-6814-daemon-failure-classification.test.ts index b1c36a2c7f5..e513e035874 100644 --- a/src/main/daemon/issue-6814-daemon-failure-classification.test.ts +++ b/src/main/daemon/issue-6814-daemon-failure-classification.test.ts @@ -17,8 +17,44 @@ import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { DaemonServer } from './daemon-server' import { checkDaemonHealth } from './daemon-health' +import { createNdjsonParser } from './ndjson' import type { SubprocessHandle } from './session-subprocess-handle' +const scheduleHealthEntryTimeout = setTimeout +const cancelHealthEntryTimeout = clearTimeout + +async function waitForHealthEntry(entry: Promise, health: Promise): Promise { + let timeout: ReturnType | undefined + let healthSettled = false + const earlySettlement = health.then( + () => { + healthSettled = true + throw new Error('Daemon health settled before its required socket/RPC entry') + }, + (error) => { + healthSettled = true + throw error + } + ) + const entryBudget = new Promise((_resolve, reject) => { + timeout = scheduleHealthEntryTimeout( + () => reject(new Error('Daemon health socket/RPC entry exceeded its real 3000ms budget')), + 3_000 + ) + }) + try { + return await Promise.race([entry, earlySettlement, entryBudget]) + } catch (error) { + // Let the real health owner close its socket before server teardown. + if (!healthSettled) { + await vi.advanceTimersByTimeAsync(3_000) + } + throw error + } finally { + cancelHealthEntryTimeout(timeout) + } +} + function createMockSubprocess(): SubprocessHandle { return { pid: 55555, @@ -60,22 +96,45 @@ describe('issue #6814 repro: daemon failure-mode classification', () => { // never returns) cannot be distinguished by a richer status. It times out // and classifies as 'unreachable', the SAME bucket as a dead daemon. it('WEDGED: a daemon whose health RPC never resolves classifies as unreachable (NOT degraded)', async () => { + let resolveHealthEntered = (): void => {} + const healthEntered = new Promise((resolve) => { + resolveHealthEntered = resolve + }) const server = new DaemonServer({ socketPath, tokenPath, // Why: simulate a hung event loop — the probe never settles. - ptySpawnHealthCheck: vi.fn(() => new Promise(() => {})), + ptySpawnHealthCheck: vi.fn(() => { + resolveHealthEntered() + return new Promise(() => {}) + }), spawnSubprocess: () => createMockSubprocess() }) await server.start() try { - const health = await checkDaemonHealth(socketPath, tokenPath) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + const pendingHealth = checkDaemonHealth(socketPath, tokenPath) + let settled = false + void pendingHealth.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await waitForHealthEntry(healthEntered, pendingHealth) + await vi.advanceTimersByTimeAsync(2_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + const health = await pendingHealth // This is the key finding: wedged != degraded. #6830's degraded fallback // does NOT engage here; recovery still depends on the unreachable-path // (grace-bounded preserve-if-live-else-replace) logic, not the degraded // provider. expect(health).toBe('unreachable') } finally { + vi.useRealTimers() await server.shutdown() } }, 15000) @@ -85,17 +144,43 @@ describe('issue #6814 repro: daemon failure-mode classification', () => { // classifies as 'unreachable' — the launcher's grace-bounded path must // eventually replace it rather than preserve it forever. it('WEDGED-HELLO: a daemon that accepts connections but never answers hello classifies as unreachable (#8689)', async () => { + let resolveHello: (message: unknown) => void = () => {} + const helloReceived = new Promise((resolve) => { + resolveHello = resolve + }) const wedged = createServer((sock) => { // Swallow the hello bytes but never write a response — the exact wedge. - sock.on('data', () => {}) + const parser = createNdjsonParser(resolveHello) + sock.on('data', (chunk) => parser.feed(chunk.toString())) }) await new Promise((resolve) => wedged.listen(socketPath, () => resolve())) // The health check reads the token before connecting, so it must exist. writeFileSync(tokenPath, 'wedged-token') try { - const health = await checkDaemonHealth(socketPath, tokenPath) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldClearNativeTimers: true }) + const pendingHealth = checkDaemonHealth(socketPath, tokenPath) + let settled = false + void pendingHealth.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + expect(await waitForHealthEntry(helloReceived, pendingHealth)).toMatchObject({ + type: 'hello', + token: 'wedged-token', + clientId: 'health-check', + role: 'control' + }) + await vi.advanceTimersByTimeAsync(2_999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + const health = await pendingHealth expect(health).toBe('unreachable') } finally { + vi.useRealTimers() await new Promise((resolve) => wedged.close(() => resolve())) } }, 15000) diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-readers-import-boundary.test.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-readers-import-boundary.test.ts deleted file mode 100644 index a50bc922ced..00000000000 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-readers-import-boundary.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { dirname, join, relative, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { scanSourceTree, type ScannedFile } from '../../shared/source-scan/source-tree-scan' - -/** - * Readers open other apps' databases synchronously, so only the worker thread - * may call them. This keeps a reader from being imported onto the main thread. - */ -const ALLOWED_IMPORTERS = new Set([ - 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts', - 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts' -]) -const READERS_DIRECTORY = 'src/main/foreign-sqlite-readers/readers/' -const SCANNED_EXTENSIONS = /\.(?:[cm]?ts|tsx|[cm]?js)$/ -const SPECIFIER_PATTERN = - /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bvi\.mock\s*\(\s*|^\s*import\s+)['"]([^'"]+)['"]/gm - -function toRepoPath(repoRoot: string, path: string): string { - return relative(repoRoot, path).split('\\').join('/') -} - -function importsReaders(repoRoot: string, file: ScannedFile): boolean { - for (const match of file.source.matchAll(SPECIFIER_PATTERN)) { - const specifier = match[1] ?? '' - const target = specifier.startsWith('.') - ? toRepoPath(repoRoot, resolve(dirname(file.path), specifier)) - : specifier - if (`${target}/`.includes(READERS_DIRECTORY)) { - return true - } - } - return false -} - -describe('foreign SQLite readers import boundary', () => { - const repoRoot = resolve(__dirname, '..', '..', '..') - const files = ['src', 'config', 'tests'].flatMap((root) => - scanSourceTree(join(repoRoot, root), { includeTests: true, extensions: SCANNED_EXTENSIONS }) - ) - const importers = files - .filter((file) => !toRepoPath(repoRoot, file.path).startsWith(READERS_DIRECTORY)) - .filter((file) => importsReaders(repoRoot, file)) - .map((file) => toRepoPath(repoRoot, file.path)) - - it('scans a plausible number of files', () => { - // A broken root or extension list would make the guard silently vacuous. - expect(files.length).toBeGreaterThan(500) - }) - - it('finds the importers it allows', () => { - // Presence check: a pattern that matched nothing would pass the next test trivially. - expect(importers).toContain('src/main/foreign-sqlite-readers/foreign-sqlite-reader-dispatch.ts') - }) - - it('has no importer outside the worker entry and its dispatch', () => { - expect( - importers.filter((path) => !ALLOWED_IMPORTERS.has(path)), - 'Readers run only on the foreign SQLite reader worker; call them through foreign-sqlite-reader-spawn.ts.' - ).toEqual([]) - }) -}) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 07b8b82f9a7..c7ee26436af 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AppRelaunch from '../app-relaunch' const { handlers, @@ -97,7 +98,8 @@ vi.mock('../tray/system-tray', () => ({ destroySystemTray: destroySystemTrayMock })) -vi.mock('../app-relaunch', () => ({ +vi.mock('../app-relaunch', async (importOriginal) => ({ + ...(await importOriginal()), relaunchApp: relaunchAppMock })) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 3e9d9517dad..7623c4642d4 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -6,7 +6,7 @@ import { is } from '@electron-toolkit/utils' import type { AppIdentity } from '../../shared/app-identity' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' -import { relaunchApp } from '../app-relaunch' +import { relaunchApp, runBeforeRelaunchCleanup } from '../app-relaunch' import { quitProcess } from '../startup/process-quit-request' import type { Store } from '../persistence' import { getDevInstanceIdentity } from '../startup/dev-instance-identity' @@ -186,17 +186,3 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp pickFloatingWorkspaceDirectory(event, store) ) } - -async function runBeforeRelaunchCleanup( - onBeforeRelaunch?: () => void | Promise -): Promise { - try { - await onBeforeRelaunch?.() - } catch (error) { - // Why: best-effort cleanup must never block relaunch; log only error.name to avoid leaking secrets. - console.warn( - '[app] Pre-relaunch cleanup failed; continuing relaunch:', - error instanceof Error ? error.name : typeof error - ) - } -} diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts index 2c36fd9f2bb..afe6519dfc8 100644 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' +import type * as SshTargetRegistry from '../ssh/ssh-target-registry' const handlers = new Map Promise>() const { @@ -31,7 +32,10 @@ vi.mock('fs/promises', () => ({ copyFile: copyFileMock, readdir: readdirMock })) -vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) +vi.mock('../ssh/ssh-target-registry', async () => { + const actual = await vi.importActual('../ssh/ssh-target-registry') + return { ...actual, getSshConnectionManager: getConnMgrMock } +}) import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts index 3a6b0f2f86c..6e1841facee 100644 --- a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts +++ b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' +import type * as SshTargetRegistry from '../ssh/ssh-target-registry' const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({ getConnMgrMock: vi.fn(), @@ -18,7 +19,10 @@ vi.mock('node:fs/promises', () => ({ vi.mock('./filesystem-path-containment', () => ({ isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT' })) -vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) +vi.mock('../ssh/ssh-target-registry', async () => { + const actual = await vi.importActual('../ssh/ssh-target-registry') + return { ...actual, getSshConnectionManager: getConnMgrMock } +}) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ requireSshFilesystemProvider: providerMock })) diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index 3c18eea6981..80c801cfd56 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -3,6 +3,7 @@ import { constants } from 'node:fs' import { Readable, Writable } from 'node:stream' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' +import type * as SshTargetRegistry from '../ssh/ssh-target-registry' const handlers = new Map Promise>() const { @@ -43,7 +44,10 @@ vi.mock('fs/promises', () => ({ unlink: unlinkMock, rm: vi.fn() })) -vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock })) +vi.mock('../ssh/ssh-target-registry', async () => { + const actual = await vi.importActual('../ssh/ssh-target-registry') + return { ...actual, getSshConnectionManager: getConnMgrMock } +}) import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index ad8025d82fd..99b21fa8eae 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,7 +1,7 @@ import { lstat } from 'node:fs/promises' import { basename, posix, resolve } from 'node:path' import { isENOENT } from './filesystem-path-containment' -import { getSshConnectionManager } from './ssh' +import { getSshConnectionManager } from '../ssh/ssh-target-registry' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { FileUploadSession, IFilesystemProvider } from '../providers/types' import type { ImportItemResult } from '../../shared/filesystem-import-result-types' diff --git a/src/main/main-process-tree-kill-gate.test.ts b/src/main/main-process-tree-kill-gate.test.ts deleted file mode 100644 index 40b1421c32f..00000000000 --- a/src/main/main-process-tree-kill-gate.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { readFileSync, readdirSync, statSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * The ratchet behind the guard's claim to be a choke point. - * - * `admitSelfInitiatedTreeKill` is only "one decision" for as long as every - * pid-addressed `taskkill /pid /t /f` in Electron main asks it. Each such - * kill can land on a recycled pid that is now one of Orca's own Chromium - * processes (#10680), and an ungated one is also invisible to - * `selfInitiatedTreeKillCount`, which makes a zero read as exculpatory when it - * is not. A new family fails here rather than in the field. - * - * Exactly what is enforced, so no comment elsewhere claims more: per file, the - * number of gate admissions must be at least the number of `/pid` call sites. - * Counting sites rather than files is the point — a file-granular scan would let - * a second, ungated taskkill land inside a family that already mentions the gate, - * which is the shape the six highest-risk files now have. What it still cannot - * see: a site that pairs an ungated kill with a second admission of an already - * gated one in the same file, and a kill whose `/pid` argument is itself built - * from a variable. - */ -const REPOSITORY_ROOT = resolve(__dirname, '..', '..') -const MAIN_DIRECTORY = 'src/main/' -const SCANNED_EXTENSIONS = ['.ts', '.tsx'] -const IGNORED_DIRECTORIES = new Set([ - 'node_modules', - 'dist', - 'out', - 'build', - '.git', - '__fixtures__' -]) - -/** - * One match per call site. Keyed on the `/pid` argument rather than the program - * name because `/pid ` is what makes the kill pid-addressed — it walks - * whatever tree owns that pid *now* — and because the literal survives a - * `taskkill` spawned through a constant or a variable, which a quoted-program - * pattern misses entirely. - */ -const PID_ADDRESSED_KILL_SITE = /['"]\/pid['"]/gi - -/** - * A call, not an import or a comment: `admitSelfInitiatedTreeKill` in main, and - * `admitProcessTreeKill` for the `src/shared` seam main installs the same gate - * into, which shared code cannot import directly. - */ -const GATE_ADMISSION = /\badmit(?:SelfInitiatedTreeKill|ProcessTreeKill)\s*\(/g - -function countMatches(source: string, pattern: RegExp): number { - return source.match(pattern)?.length ?? 0 -} - -/** Sites left over once each admission in the file has claimed one. */ -function ungatedKillSiteCount(source: string): number { - return Math.max( - countMatches(source, PID_ADDRESSED_KILL_SITE) - countMatches(source, GATE_ADMISSION), - 0 - ) -} - -/** - * Only ever shrinks. Each entry states why the gate cannot reach it — never - * "not got to yet", which is what a new ungated family would also look like. - */ -const UNGATED_TASKKILL_ALLOWLIST = new Map([ - [ - 'src/main/browser/browser-route-egress-electron-launch.ts', - 'Electron probe reached only from *.electron.test.ts; kills the probe Electron it spawned' - ], - [ - 'src/main/browser/browser-route-persisted-worker-electron-process.ts', - 'Electron probe reached only from *.electron.test.ts; kills the probe Electron it spawned' - ], - [ - 'src/cli/handlers/interactive-login-interruption.ts', - 'CLI host: no Chromium pid on the machine to reach, and no reader for the ring' - ], - [ - 'src/relay/subprocess-tree-termination.ts', - 'Relay host: same, and the relay cannot import the main-process gate' - ] -]) - -function isTestFile(path: string): boolean { - return /\.(?:test|spec)\.tsx?$/.test(path) || /(?:test-harness|test-fixture|fixture)/.test(path) -} - -function scanSourceFiles(directory: string, found: string[] = []): string[] { - for (const entry of readdirSync(directory)) { - if (IGNORED_DIRECTORIES.has(entry)) { - continue - } - const path = join(directory, entry) - if (statSync(path).isDirectory()) { - scanSourceFiles(path, found) - continue - } - if (SCANNED_EXTENSIONS.some((extension) => entry.endsWith(extension)) && !isTestFile(path)) { - found.push(path) - } - } - return found -} - -// Only the Node-side hosts: a renderer or preload cannot spawn a process at all. -const SCANNED_HOSTS = ['src/main', 'src/shared', 'src/cli', 'src/relay'] - -/** Scanned once at import: 10k files is seconds, and every case below reuses it. */ -const PID_ADDRESSED_KILL_FILES = SCANNED_HOSTS.flatMap((host) => - scanSourceFiles(join(REPOSITORY_ROOT, host)) - .map((path) => ({ - path: relative(REPOSITORY_ROOT, path).split('\\').join('/'), - source: readFileSync(path, 'utf8') - })) - .filter((file) => countMatches(file.source, PID_ADDRESSED_KILL_SITE) > 0) -) - -function pidAddressedKillFiles(): { path: string; source: string }[] { - return PID_ADDRESSED_KILL_FILES -} - -describe('main-process tree-kill gate', () => { - it('finds the taskkill families it is meant to police', () => { - // Falsifiable: a scanner that matched nothing would pass every case below. - expect(pidAddressedKillFiles().map((file) => file.path)).toContain( - 'src/main/windows-process-tree-kill.ts' - ) - }) - - it('routes every pid-addressed taskkill in Electron main through the gate', () => { - const ungated = pidAddressedKillFiles() - .filter((file) => file.path.startsWith(MAIN_DIRECTORY)) - .filter((file) => ungatedKillSiteCount(file.source) > 0) - .map((file) => file.path) - .filter((path) => !UNGATED_TASKKILL_ALLOWLIST.has(path)) - - expect(ungated).toEqual([]) - }) - - it('leaves no pid-addressed taskkill outside main unaccounted for', () => { - const unaccounted = pidAddressedKillFiles() - .filter((file) => !file.path.startsWith(MAIN_DIRECTORY)) - .filter((file) => ungatedKillSiteCount(file.source) > 0) - .map((file) => file.path) - .filter((path) => !UNGATED_TASKKILL_ALLOWLIST.has(path)) - - expect(unaccounted).toEqual([]) - }) - - it('counts call sites, not files: a second ungated kill in a gated file is caught', () => { - // The failure a file-granular scan let through: one gate mention exempting - // every taskkill in the file. - const gated = ` - import { admitSelfInitiatedTreeKill } from './own-chromium-tree-kill-guard' - if (admitSelfInitiatedTreeKill({ pid, site: 's', scope: 'win-taskkill-tree' })) { - spawn('taskkill', ['/pid', String(pid), '/t', '/f']) - } - ` - - expect(ungatedKillSiteCount(gated)).toBe(0) - expect( - ungatedKillSiteCount(`${gated}\nspawn('taskkill', ['/pid', String(other), '/t', '/f'])`) - ).toBe(1) - }) - - it('sees a kill whose program name comes from a constant', () => { - // A quoted-program pattern misses this shape; the `/pid` argument does not. - expect( - ungatedKillSiteCount(` - const KILLER = 'taskkill' - spawn(KILLER, ['/pid', String(pid), '/t', '/f']) - `) - ).toBe(1) - }) - - it('keeps the allowlist honest: every entry still spawns a taskkill', () => { - const spawning = new Set(pidAddressedKillFiles().map((file) => file.path)) - - expect([...UNGATED_TASKKILL_ALLOWLIST.keys()].filter((path) => !spawning.has(path))).toEqual([]) - }) -}) diff --git a/src/main/observability/architecture.test.ts b/src/main/observability/architecture.test.ts deleted file mode 100644 index 4e94b7d3fe3..00000000000 --- a/src/main/observability/architecture.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Architectural-invariant test (telemetry-error-tracking.md §Architecture): -// -// "Nothing in `src/main/telemetry/` imports from `src/main/observability/` -// or vice versa. The two lanes never share a code path." -// -// Cross-contamination is the failure mode the entire two-lane split is -// counter-designed against. oxlint's plugin set does not include -// `import-x`, and adding eslint just for this rule is a heavier lift than -// the rule warrants. A vitest test that grep-scans the two directories is -// adequate, runs in <50 ms, and fails CI loudly if a future PR adds the -// wrong import. -// -// What it catches: -// - any `from '../observability'` / `from '../observability/...'` inside -// `src/main/telemetry/` -// - any `from '../telemetry'` / `from '../telemetry/...'` inside -// `src/main/observability/` -// - the same with deeper relative paths (`../../observability/...`) -// - the same with absolute-from-src forms if anyone introduces them -// -// What it does NOT catch: -// - dynamic `await import()` / `require()` — neither lane uses these, -// and adding a regex catch would create false positives. If a future -// change introduces dynamic loading, extend the regex set. -// - re-exports through a third module. The simplest workaround -// (re-export `observability` from a "neutral" module) is the same -// anti-pattern this test is preventing; reviewers should reject it. -// -// Both directions of the rule are checked symmetrically — the asymmetric -// alternative (only one-way) leaves the door open to a `setOptIn` → -// `bundle` callback chain that smuggles consent state across the boundary. - -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' - -const REPO_ROOT = join(__dirname, '..', '..', '..') -const TELEMETRY_DIR = join(REPO_ROOT, 'src', 'main', 'telemetry') -const OBSERVABILITY_DIR = join(REPO_ROOT, 'src', 'main', 'observability') - -function listTsFiles(dir: string): string[] { - const out: string[] = [] - for (const entry of readdirSync(dir)) { - const full = join(dir, entry) - if (statSync(full).isDirectory()) { - out.push(...listTsFiles(full)) - } else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) { - out.push(full) - } - } - return out -} - -function findOffendingImports(file: string, forbiddenSegment: string): string[] { - const text = readFileSync(file, 'utf8') - // Match `from ''`, `from ""`, dynamic `import('')` and - // `require('')`. The `` capture is what we inspect. - const importRe = /(?:from\s+|import\(|require\()\s*(['"])([^'"]+)\1/g - const offenders: string[] = [] - let m: RegExpExecArray | null - while ((m = importRe.exec(text)) !== null) { - const spec = m[2] - if (spec.includes(forbiddenSegment)) { - offenders.push(spec) - } - } - return offenders -} - -describe('architectural invariant — telemetry / observability lane isolation', () => { - it('no file in src/main/telemetry/ imports from observability', () => { - const files = listTsFiles(TELEMETRY_DIR) - expect(files.length).toBeGreaterThan(0) // sanity: directory exists - const violations = files.flatMap((f) => { - const bad = findOffendingImports(f, 'observability') - return bad.map((spec) => `${relative(REPO_ROOT, f)}: imports '${spec}'`) - }) - expect(violations, violations.join('\n')).toEqual([]) - }) - - it('no file in src/main/observability/ imports from telemetry', () => { - const files = listTsFiles(OBSERVABILITY_DIR) - expect(files.length).toBeGreaterThan(0) - const violations = files.flatMap((f) => { - // Allow this very file — the test references the path string for its - // own message, and the whitelist is one specific filename rather than - // a directory exemption. - if (f.endsWith('architecture.test.ts')) { - return [] - } - const bad = findOffendingImports(f, 'telemetry') - return bad.map((spec) => `${relative(REPO_ROOT, f)}: imports '${spec}'`) - }) - expect(violations, violations.join('\n')).toEqual([]) - }) -}) diff --git a/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts b/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts index 2e3c4c51d1e..6f402c2fafd 100644 --- a/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts @@ -244,18 +244,3 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage' await cleanup?.() }) }) - -describe('OpenCode 2 session client shim', () => { - it('keeps the enveloped SDK result untouched for the OpenCode 1 server path', () => { - const source = _internals.getOpenCodePluginSource() - - // The shim only wraps a bare session record; server() still hands the raw SDK client through. - expect(source).toContain( - 'return result && typeof result.id === "string" ? { data: result } : result;' - ) - expect(source).toContain( - 'handleLifecycleEvent(client, normalizeNextLifecycleEvent(event), factoryID)' - ) - expect(source).toContain('const client = _ctx?.client;') - }) -}) diff --git a/src/main/own-chromium-tree-kill-guard.ts b/src/main/own-chromium-tree-kill-guard.ts index 24b6a4b7327..b594912e317 100644 --- a/src/main/own-chromium-tree-kill-guard.ts +++ b/src/main/own-chromium-tree-kill-guard.ts @@ -16,8 +16,7 @@ import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kil * lifetimes (sync, fire-and-forget, timeout ladder), and three more live in * `src/shared` and reach this through `process-tree-kill-gate`, so a guard that * only lived in the tree-kill helper would cover one of nine. - * `main-process-tree-kill-gate.test.ts` holds that set closed by counting `/pid` - * call sites against gate admissions per file, not by file. Returns false + * Returns false * when the caller must not walk that pid's tree; the caller still kills its own * root through the child handle (`refused-tree-kill-root-termination.test.ts`), * so a refusal is never a process leak. diff --git a/src/main/pty/node-pty-master-fd-retirement.test.ts b/src/main/pty/node-pty-master-fd-retirement.test.ts index a0b537de1ca..e209d412cb4 100644 --- a/src/main/pty/node-pty-master-fd-retirement.test.ts +++ b/src/main/pty/node-pty-master-fd-retirement.test.ts @@ -61,7 +61,6 @@ async function retiredPty( if (destroy) { ;(term as unknown as { destroy?: () => void }).destroy?.() } - await new Promise((resolve) => setTimeout(resolve, 400)) return { term, spawnFd } } diff --git a/src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts b/src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts index 6a04228b905..89b3f231731 100644 --- a/src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts +++ b/src/main/runtime/client-hosted-page-reconciliation-hold.integration.test.ts @@ -94,8 +94,4 @@ describe('client-hosted reconciliation hold on published session tabs', () => { (await runtime.listMobileSessionTabs(`id:${WT}`, DEVICE_B)).clientHostedPagesUnreconciled ).toBe(undefined) }) - - // The fan-out sites (`emitMobileSessionTabsSnapshot`, the notify loops) share this one seam, and - // the projection census in client-hosted-page-reconciliation-window.test.ts is what keeps them - // from growing a second, unheld path. }) diff --git a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts index d263c7a0742..fe010ebec00 100644 --- a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts +++ b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts @@ -1,5 +1,3 @@ -import { readFileSync, readdirSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { ClientHostedPageReconciliationWindow, @@ -122,41 +120,3 @@ describe('holdFor', () => { expect(window.holdFor(stamped, DEVICE_A, OPENED_AT + WINDOW_MS)).toEqual(frame) }) }) - -// The hold is only correct if every snapshot leaving the runtime for a client goes through the -// per-client seam. A `project` call that bypasses it publishes an unheld -- or another client's -- -// answer, which is invisible to any behavioral test that does not happen to cover that call site. -describe('session-tabs projection census', () => { - it('routes every client projection in orca-runtime through the per-client seam', () => { - const source = readOrcaRuntimeSourceFamily() - const direct = source.match(/this\.clientSessionTabSelections\.project\(/g) ?? [] - - // Exactly two: inside `projectMobileSessionTabsForClient` itself, and the removed-worktree - // frame, which announces a deletion the client asked for rather than answering about contents. - expect(direct).toHaveLength(2) - expect(source).toContain('this.clientSessionTabSelections.project(removed,') - }) - - it('keeps the hold flags out of every other runtime publication site', () => { - const source = readOrcaRuntimeSourceFamily() - - expect(source).not.toContain('clientHostedPagesUnreconciled') - expect(source).not.toContain('agentSessionsUnverifiable') - }) -}) - -function readOrcaRuntimeSourceFamily(): string { - return readdirSync(import.meta.dirname) - .filter( - (name) => - (name === 'orca-runtime.ts' || name.startsWith('orca-runtime-')) && - name.endsWith('.ts') && - !name.includes('.test.') && - !name.endsWith('-fixtures.ts') && - !name.endsWith('-test-harness.ts') && - !name.endsWith('-mock-registry.ts') - ) - .sort() - .map((name) => readFileSync(join(import.meta.dirname, name), 'utf8')) - .join('\n') -} diff --git a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts index 16dcdd613f1..4da9fd8d21c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts @@ -308,7 +308,8 @@ describe('writing a launch prompt into a terminal agent', () => { handle: 'term_1', agent, freshLaunch: true, - text: 'do the thing' + text: 'do the thing', + clock: fakeClock() }) expect(delivered).toBe(true) @@ -368,7 +369,8 @@ describe('writing a launch prompt into a terminal agent', () => { handle: 'term_existing', agent: 'grok', freshLaunch: false, - text: 'do the thing' + text: 'do the thing', + clock: fakeClock() }) expect(delivered).toBe(true) diff --git a/src/main/startup/single-instance-lock-headless-exit.test.ts b/src/main/startup/single-instance-lock-headless-exit.test.ts deleted file mode 100644 index 519b57742b1..00000000000 --- a/src/main/startup/single-instance-lock-headless-exit.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -// Why #11935: a pre-`ready` graceful quit is deferred, so a lock-losing headless `orca serve` -// kept booting into Linux Ozone/X11 init, died with SIGSEGV, and systemd restarted it forever -// until the leaked AppImage FUSE mounts hit the kernel's 1000-mount ceiling. - -describe('headless lock-loss exit contract', () => { - const preflightSource = readFileSync( - join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), - 'utf8' - ) - const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - - it('exits the lock-losing launch immediately instead of scheduling a graceful quit', () => { - const gateStart = preflightSource.indexOf('if (!hasLock) {') - // Why: bound the anchor — an unresolved indexOf slices to EOF and passes vacuously. - expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = preflightSource.indexOf('\n }', gateStart) - expect(gateEnd).toBeGreaterThan(gateStart) - - const gate = preflightSource.slice(gateStart, gateEnd) - expect(gate).toContain('app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)') - expect(gate).not.toContain('app.quit()') - }) - - it('keeps a duplicate serve launch from promoting the live server to a desktop window', () => { - const activationStart = entrySource.indexOf('function requestDesktopActivation(') - expect(activationStart).toBeGreaterThanOrEqual(0) - const activationEnd = entrySource.indexOf('\n}', activationStart) - expect(activationEnd).toBeGreaterThan(activationStart) - - expect(entrySource.slice(activationStart, activationEnd)).toContain( - 'shouldActivateDesktopForSecondInstance(argv)' - ) - }) -}) diff --git a/src/main/windows/windows-pty-job.win32.test.ts b/src/main/windows/windows-pty-job.win32.test.ts index 3af01c41796..627110d95da 100644 --- a/src/main/windows/windows-pty-job.win32.test.ts +++ b/src/main/windows/windows-pty-job.win32.test.ts @@ -103,6 +103,98 @@ describeOnWindows('ConPTY job ownership', () => { expect(isPtyJobOwnershipAvailable()).toBe(true) }) + it.each([ + ['kill', 'connected'], + ['destroy', 'immediate'] + ] as const)( + 'retires a quiet child with %s at the %s fence', + async (operation, fence) => { + const result = await runProcess({ + program: process.execPath, + args: [ + join(process.cwd(), 'config', 'scripts', 'windows-pty-pre-ready-teardown.cjs'), + operation, + fence + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20_000 + }) + if (result.code === 0 && !result.timedOut) { + console.log(result.stdout) + if (result.stderr) { + console.error(result.stderr) + } + } + expect(result, `${result.stdout}\n${result.stderr}`).toMatchObject({ + code: 0, + timedOut: false + }) + expect(result.stdout).toContain('"phase":"complete"') + expect(result.stdout).toContain('"exitCallbacks":1') + }, + 30_000 + ) + + it.each(['dll', 'inbox'] as const)( + 'retires a closed forwarding connection with the %s backend', + async (backend) => { + const result = await runProcess({ + program: process.execPath, + args: [ + join(process.cwd(), 'config', 'scripts', 'windows-pty-closed-forwarding-teardown.cjs'), + backend + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20_000 + }) + if (result.code === 0 && !result.timedOut) { + console.log(result.stdout) + if (result.stderr) { + console.error(result.stderr) + } + } + expect(result, `${result.stdout}\n${result.stderr}`).toMatchObject({ + code: 0, + timedOut: false + }) + expect(result.stdout).toContain('"phase":"complete"') + expect(result.stdout).toContain('"nativeExitCallbacks":1') + expect(result.stdout).toContain('"exitCallbacks":1') + }, + 30_000 + ) + + it.each(['dll', 'inbox'] as const)( + 'retries closed-forwarding cleanup after a failed first kill with the %s backend', + async (backend) => { + const result = await runProcess({ + program: process.execPath, + args: [ + join(process.cwd(), 'config', 'scripts', 'windows-pty-closed-forwarding-teardown.cjs'), + backend, + 'retry' + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20_000 + }) + if (result.code === 0 && !result.timedOut) { + console.log(result.stdout) + if (result.stderr) { + console.error(result.stderr) + } + } + expect(result, `${result.stdout}\n${result.stderr}`).toMatchObject({ + code: 0, + timedOut: false + }) + expect(result.stdout).toContain('"phase":"complete"') + expect(result.stdout).toContain('"nativeExitCallbacks":1') + expect(result.stdout).toContain('"exitCallbacks":1') + expect(result.stdout).toContain('"retryFault":true') + }, + 30_000 + ) + it('keeps the native table intact while shell cleanup overlaps new terminals', async () => { const result = await runProcess({ program: process.execPath, diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts index c265f02cff6..b6f1c4a9901 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.test.ts @@ -1,6 +1,4 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { applyBrowserPageViewportLayout, @@ -19,15 +17,6 @@ import { syncBrowserPageChromeInset } from './browser-page-viewport' -function readPresetViewportCssRule(): string { - const css = readFileSync(resolve(import.meta.dirname, '../../../assets/main.css'), 'utf8') - const body = - css.match(new RegExp(`\\.${BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME}\\s*\\{(?[^}]*)\\}`)) - ?.groups?.body ?? '' - - return body.replace(/\s+/g, ' ').trim() -} - function mountSlotViewport(workspaceTabId: string): HTMLDivElement { const root = document.createElement('div') root.className = 'relative flex min-h-0 flex-1 flex-col' @@ -113,13 +102,6 @@ describe('ensureBrowserPageViewport', () => { expect(viewport.content.style.height).toBe('') }) - it('divides the preset DIP size by the live UI zoom factor', () => { - expect(readPresetViewportCssRule()).toBe( - 'width: calc(var(--browser-page-viewport-width) / var(--ui-zoom-factor, 1)); ' + - 'height: calc(var(--browser-page-viewport-height) / var(--ui-zoom-factor, 1));' - ) - }) - it('clears preset dimensions when no preset is active', () => { mountSlotViewport('workspace-1') const viewport = ensureBrowserPageViewport('page-1', 'workspace-1')! diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts index d46fd15ac91..597d59e0882 100644 --- a/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-progress.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { FeatureWallSetupProgressInput } from './feature-wall-setup-progress' import { getFeatureWallSetupProgress } from './feature-wall-setup-progress' @@ -80,19 +78,6 @@ describe('getFeatureWallSetupProgress', () => { ]) }) - it('renders Setup before Milestones and numbers Milestones after Setup', () => { - const source = readFileSync( - join(process.cwd(), 'src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx'), - 'utf8' - ) - const setupSectionIndex = source.indexOf('steps={setupSteps}') - const milestonesSectionIndex = source.indexOf('steps={parallelWorkSteps}') - - expect(setupSectionIndex).toBeGreaterThanOrEqual(0) - expect(milestonesSectionIndex).toBeGreaterThan(setupSectionIndex) - expect(source).toContain('startOrdinal={setupSteps.length + 1}') - }) - it('auto-selects incomplete parallel work after setup steps are complete', () => { const progress = getFeatureWallSetupProgress( makeInput({ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 945f776cc6d..a4a2ca227ce 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -444,18 +444,4 @@ describe('native chat composer drop scoping', () => { ]) expect(readNativeChatAttachmentCache('chat-b')).toEqual([]) }) - - it('control: an unowned control drop does not attach images to either chat', async () => { - const view = render( - <> - -