mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Reduce repeated test work and fix Windows cleanup and IME confirmation (#26608)
* ci: share test transforms and remove six redundant checks * test: remove obsolete project copies and two build string checks * test: use the injected clock for exact launch readiness budgets * fix: retain IME confirmation and retire disconnected Windows PTYs * ci: include relay installer in existing Windows boundaries * perf: import the canonical SSH manager for filesystem operations * test: remove 42 source-spelling checks * test: advance deadlines after real entry and await actual PTY exit * Preserve native Windows exit acknowledgments and skip daemon-only smoke triggers
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<number>();
|
||||
+ public get onProcessExit(): IEvent<number> { 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<void> {
|
||||
+ 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<void> | 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<A>(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<A>(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<void> | undefined;
|
||||
|
||||
private _onReady = new EventEmitter2<void>();
|
||||
public get onReady(): IEvent<void> { 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<void> {
|
||||
+ 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<void> {
|
||||
- await this._worker.terminate();
|
||||
+ private _destroySocket(): Promise<void> {
|
||||
+ 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;
|
||||
}());
|
||||
|
||||
@@ -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;
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 })')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
+27
-6
@@ -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
|
||||
? {
|
||||
|
||||
Generated
+6
-5
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>
|
||||
): Promise<void> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(work: Promise<T>, budgetMs: number): Promise<T | 'timed-out'> {
|
||||
let timer: ReturnType<typeof setTimeout> | 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<void>
|
||||
completedPromise: Promise<HistoryCheckpointResult>
|
||||
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<void>((resolve) => {
|
||||
resolveEntered = resolve
|
||||
})
|
||||
let resolveCompleted: (result: HistoryCheckpointResult) => void = () => {}
|
||||
const completedPromise = new Promise<HistoryCheckpointResult>((resolve) => {
|
||||
resolveCompleted = resolve
|
||||
})
|
||||
let release = (): void => {}
|
||||
const stalled = new Promise<void>((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<string> {
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<T>(entry: Promise<T>, health: Promise<unknown>): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | 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<never>((_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<void>((resolve) => {
|
||||
resolveHealthEntered = resolve
|
||||
})
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
// Why: simulate a hung event loop — the probe never settles.
|
||||
ptySpawnHealthCheck: vi.fn(() => new Promise<void>(() => {})),
|
||||
ptySpawnHealthCheck: vi.fn(() => {
|
||||
resolveHealthEntered()
|
||||
return new Promise<void>(() => {})
|
||||
}),
|
||||
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<unknown>((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<void>((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<void>((resolve) => wedged.close(() => resolve()))
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<typeof AppRelaunch>()),
|
||||
relaunchApp: relaunchAppMock
|
||||
}))
|
||||
|
||||
|
||||
+1
-15
@@ -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<void>
|
||||
): Promise<void> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
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<typeof SshTargetRegistry>('../ssh/ssh-target-registry')
|
||||
return { ...actual, getSshConnectionManager: getConnMgrMock }
|
||||
})
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
|
||||
@@ -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<typeof SshTargetRegistry>('../ssh/ssh-target-registry')
|
||||
return { ...actual, getSshConnectionManager: getConnMgrMock }
|
||||
})
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: providerMock
|
||||
}))
|
||||
|
||||
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
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<typeof SshTargetRegistry>('../ssh/ssh-target-registry')
|
||||
return { ...actual, getSshConnectionManager: getConnMgrMock }
|
||||
})
|
||||
|
||||
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
|
||||
import {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 <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 <n>` 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<string, string>([
|
||||
[
|
||||
'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([])
|
||||
})
|
||||
})
|
||||
@@ -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 '<path>'`, `from "<path>"`, dynamic `import('<path>')` and
|
||||
// `require('<path>')`. The `<path>` 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([])
|
||||
})
|
||||
})
|
||||
@@ -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;')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -61,7 +61,6 @@ async function retiredPty(
|
||||
if (destroy) {
|
||||
;(term as unknown as { destroy?: () => void }).destroy?.()
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 400))
|
||||
return { term, spawnFd }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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*\\{(?<body>[^}]*)\\}`))
|
||||
?.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')!
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
<ComposerProbe pane="chat-a" />
|
||||
<ComposerProbe pane="chat-b" hidden />
|
||||
<div data-testid="unowned-editor" />
|
||||
</>
|
||||
)
|
||||
await dropTwoImages(view.container.querySelector('[data-testid="unowned-editor"]')!)
|
||||
expect(electron.send).not.toHaveBeenCalled()
|
||||
expect(readNativeChatAttachmentCache('chat-a')).toEqual([])
|
||||
expect(readNativeChatAttachmentCache('chat-b')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -356,15 +356,24 @@ describe('workspace linked work editor', () => {
|
||||
it.each([
|
||||
{ key: 'Enter', keyCode: 13, isComposing: true },
|
||||
{ key: 'Enter', keyCode: 229 },
|
||||
{ key: 'Enter', keyCode: 13 }
|
||||
{ key: 'Enter', keyCode: 13 },
|
||||
null
|
||||
])('keeps notes open through IME confirmation and redispatch: %j', async (event) => {
|
||||
openDialog()
|
||||
const notes = screen.getByRole('textbox', { name: 'Notes' })
|
||||
fireEvent.compositionStart(notes)
|
||||
fireEvent.change(notes, { target: { value: '確定' } })
|
||||
fireEvent.keyDown(notes, event)
|
||||
if (event) {
|
||||
fireEvent.keyDown(notes, event)
|
||||
} else {
|
||||
fireEvent.keyUp(notes, { key: 'f', keyCode: 70, isComposing: true })
|
||||
}
|
||||
fireEvent.compositionEnd(notes)
|
||||
fireEvent.keyUp(notes, { key: 'Enter', keyCode: 13 })
|
||||
if (event) {
|
||||
fireEvent.keyUp(notes, { key: 'Enter', keyCode: 13 })
|
||||
} else {
|
||||
fireEvent.input(notes, { data: '定', inputType: 'insertText', isComposing: false })
|
||||
}
|
||||
fireEvent.keyDown(notes, { key: 'Enter', keyCode: 13 })
|
||||
expect(updateWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().activeModal).toBe('edit-meta')
|
||||
|
||||
-18
@@ -4,8 +4,6 @@
|
||||
* input sources on the same device do run, non-Hangul scripts, desktop
|
||||
* platforms, and anything else that writes to the PTY.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer'
|
||||
import {
|
||||
@@ -185,22 +183,6 @@ describe('the iPadOS Hangul path alongside everything else', () => {
|
||||
expect(rig.emitted).toEqual(['ㅎ'])
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the platform once, so no second gate can drift from it', () => {
|
||||
const lifecycle = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
expect(lifecycle.match(/const isIosWeb =/g)).toHaveLength(1)
|
||||
expect(lifecycle.match(/isCurrentPlatformIosWeb\(/g)).toHaveLength(1)
|
||||
// And that the one gate is what installs the controller.
|
||||
expect(lifecycle).toMatch(
|
||||
/const iosHangulPreedit = isIosWeb\s*\?\s*installTerminalIosHangulPreedit\(/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('composes Hangul after a paste written straight to the transport', async () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ export function useImeEnterGestureOwnership(): {
|
||||
isComposing: () => boolean
|
||||
ownsKeyDown: (event: ImeEnterGestureEvent) => boolean
|
||||
onKeyUp: (event: ImeEnterGestureEvent) => void
|
||||
onCompositionEnd: () => void
|
||||
reset: () => void
|
||||
setComposing: (active: boolean) => void
|
||||
} {
|
||||
@@ -48,6 +49,16 @@ export function useImeEnterGestureOwnership(): {
|
||||
const reset = (): void => {
|
||||
stateRef.current = { composing: false, pendingEnter: null }
|
||||
}
|
||||
const expirePendingEnter = (): void => {
|
||||
const pendingEnter = stateRef.current.pendingEnter
|
||||
if (pendingEnter) {
|
||||
requestAnimationFrame(() => {
|
||||
if (stateRef.current.pendingEnter === pendingEnter) {
|
||||
stateRef.current.pendingEnter = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Shift+Enter is a newline, never a submit — it must never be owned or swallowed.
|
||||
const isPlainEnter = (event: ImeEnterGestureEvent): boolean =>
|
||||
event.key === 'Enter' && event.keyCode === 13 && !event.shiftKey
|
||||
@@ -93,13 +104,15 @@ export function useImeEnterGestureOwnership(): {
|
||||
},
|
||||
onKeyUp: (): void => {
|
||||
// Any keyup can precede the redispatch, including an IME-owned Process/229 release.
|
||||
const pendingEnter = stateRef.current.pendingEnter
|
||||
if (pendingEnter) {
|
||||
requestAnimationFrame(() => {
|
||||
if (stateRef.current.pendingEnter === pendingEnter) {
|
||||
stateRef.current.pendingEnter = null
|
||||
}
|
||||
})
|
||||
expirePendingEnter()
|
||||
},
|
||||
onCompositionEnd: () => {
|
||||
const compositionWasActive = stateRef.current.composing
|
||||
stateRef.current.composing = false
|
||||
if (compositionWasActive && !stateRef.current.pendingEnter) {
|
||||
// IBus can finish composition before its only unmarked confirming Enter.
|
||||
stateRef.current.pendingEnter = {}
|
||||
expirePendingEnter()
|
||||
}
|
||||
},
|
||||
reset,
|
||||
|
||||
@@ -46,6 +46,28 @@ describe.each([Input, Textarea, ImeInput, ImeTextarea])('IME text field %s', (Fi
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['confirm', 'selection'])(
|
||||
'releases completion-first composition ownership after %s',
|
||||
(finish) => {
|
||||
const action = vi.fn()
|
||||
const { getByRole } = render(<Field onKeyDown={action} />)
|
||||
const field = getByRole('textbox')
|
||||
fireEvent.compositionStart(field)
|
||||
fireEvent.change(field, { target: { value: '한글' } })
|
||||
fireEvent.keyUp(field, { key: 'f', keyCode: 70, isComposing: true })
|
||||
fireEvent.compositionEnd(field)
|
||||
fireEvent.input(field, { data: '글', inputType: 'insertText', isComposing: false })
|
||||
if (finish === 'confirm') {
|
||||
expect(fireEvent.keyDown(field, enter)).toBe(false)
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
} else {
|
||||
vi.advanceTimersByTime(20)
|
||||
}
|
||||
expect(fireEvent.keyDown(field, enter)).toBe(true)
|
||||
expect(action).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('guards active composition, preserves lifecycle callbacks, and releases on blur', () => {
|
||||
const action = vi.fn()
|
||||
const start = vi.fn(),
|
||||
|
||||
@@ -26,7 +26,7 @@ export function useImeTextFieldProps<T extends HTMLInputElement | HTMLTextAreaEl
|
||||
props.onCompositionStart?.(event)
|
||||
},
|
||||
onCompositionEnd(event) {
|
||||
ime.setComposing(false)
|
||||
ime.onCompositionEnd()
|
||||
props.onCompositionEnd?.(event)
|
||||
},
|
||||
onBlur(event) {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* The renderer runs sandboxed with contextIsolation: `node:*` builtins do not resolve and even a
|
||||
* bare `process` read throws. A module that reaches one is not a degraded feature — the chunk
|
||||
* fails at evaluation, React never mounts, and the window stays blank with `workspaceSessionReady`
|
||||
* stuck false (#18742 did exactly this by importing one constant out of a `node:child_process`
|
||||
* module). Bundling hides it: the offending module can sit in a shared chunk far from the import
|
||||
* that pulled it in.
|
||||
*
|
||||
* So walk the import graph from every renderer entry — lazy routes included, since a `node:`
|
||||
* builtin behind one is just a blank route instead of a blank app — and refuse any builtin.
|
||||
*/
|
||||
const RENDERER_SRC = import.meta.dirname
|
||||
const REPO_SRC = path.resolve(RENDERER_SRC, '../..')
|
||||
const ENTRIES = ['main.tsx', 'popout.tsx', 'web/main.tsx']
|
||||
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']
|
||||
|
||||
/** `import`/`export ... from` and `import(...)` specifiers, minus type-only ones, which erase. */
|
||||
function collectValueImportSpecifiers(source: string): string[] {
|
||||
const specifiers: string[] = []
|
||||
const pattern =
|
||||
/(?:^|[\s;}])(?:import|export)(\s+type\s|\s*\{[^}]*\}|[^'"]*?)?\s*from\s*['"]([^'"]+)['"]|(?:^|[\s;}])import\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const clause = match[1] ?? ''
|
||||
const specifier = match[2] ?? match[3] ?? match[4]
|
||||
if (!specifier || /^\s*type\s/.test(clause)) {
|
||||
continue
|
||||
}
|
||||
// A brace clause whose every binding is `type`-prefixed also erases entirely.
|
||||
const bindings = clause.trim().startsWith('{') ? clause.trim().slice(1, -1).split(',') : null
|
||||
if (bindings && bindings.some((b) => b.trim()) && bindings.every((b) => /^\s*type\s/.test(b))) {
|
||||
continue
|
||||
}
|
||||
specifiers.push(specifier)
|
||||
}
|
||||
return specifiers
|
||||
}
|
||||
|
||||
function resolveModule(specifier: string, fromFile: string): string | null {
|
||||
let base: string
|
||||
if (specifier.startsWith('@renderer/')) {
|
||||
base = path.join(RENDERER_SRC, specifier.slice('@renderer/'.length))
|
||||
} else if (specifier.startsWith('@/')) {
|
||||
base = path.join(RENDERER_SRC, specifier.slice(2))
|
||||
} else if (specifier.startsWith('.')) {
|
||||
base = path.resolve(path.dirname(fromFile), specifier)
|
||||
} else {
|
||||
// Bare package specifiers are npm dependencies, not first-party source.
|
||||
return null
|
||||
}
|
||||
for (const candidate of [
|
||||
...EXTENSIONS.map((ext) => `${base}${ext}`),
|
||||
...EXTENSIONS.map((ext) => path.join(base, `index${ext}`))
|
||||
]) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function walkRendererImportGraph(): Map<string, string[]> {
|
||||
/** file -> the chain of first-party importers that reached it, entry first. */
|
||||
const pathToFile = new Map<string, string[]>()
|
||||
const queue: string[] = []
|
||||
for (const entry of ENTRIES) {
|
||||
const file = path.join(RENDERER_SRC, entry)
|
||||
pathToFile.set(file, [file])
|
||||
queue.push(file)
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const file = queue.shift() as string
|
||||
const chain = pathToFile.get(file) as string[]
|
||||
for (const specifier of collectValueImportSpecifiers(readFileSync(file, 'utf8'))) {
|
||||
const resolved = resolveModule(specifier, file)
|
||||
if (!resolved || pathToFile.has(resolved)) {
|
||||
continue
|
||||
}
|
||||
pathToFile.set(resolved, [...chain, resolved])
|
||||
queue.push(resolved)
|
||||
}
|
||||
}
|
||||
return pathToFile
|
||||
}
|
||||
|
||||
describe('renderer node-builtin boundary', () => {
|
||||
it('reaches no module that imports a node: builtin', () => {
|
||||
const graph = walkRendererImportGraph()
|
||||
const offenders: string[] = []
|
||||
for (const [file, chain] of graph) {
|
||||
const builtins = collectValueImportSpecifiers(readFileSync(file, 'utf8')).filter(
|
||||
(specifier) => specifier.startsWith('node:')
|
||||
)
|
||||
if (builtins.length === 0) {
|
||||
continue
|
||||
}
|
||||
const relativeChain = chain.map((step) => path.relative(REPO_SRC, step)).join('\n -> ')
|
||||
offenders.push(`${builtins.join(', ')} via\n ${relativeChain}`)
|
||||
}
|
||||
expect(offenders.join('\n\n')).toBe('')
|
||||
})
|
||||
|
||||
it('walks a real graph, so an empty offender list means something', () => {
|
||||
const graph = walkRendererImportGraph()
|
||||
expect(graph.size).toBeGreaterThan(3_000)
|
||||
expect(graph.has(path.join(REPO_SRC, 'shared/process-table-snapshot.ts'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -239,11 +239,12 @@ Fix dispatch fallback preview for normalized status prompts`
|
||||
})
|
||||
|
||||
it('accepts custom non-empty agentType values', () => {
|
||||
const result = parseAgentStatusPayload('{"state":"working","agentType":"cursor"}')
|
||||
const agentType: AgentType = 'some-in-house-agent'
|
||||
const result = parseAgentStatusPayload(JSON.stringify({ state: 'working', agentType }))
|
||||
expect(result).toEqual({
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'cursor'
|
||||
agentType
|
||||
})
|
||||
})
|
||||
|
||||
@@ -731,11 +732,6 @@ describe('WellKnownAgentType', () => {
|
||||
'rovo'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps AgentType open to custom agent names', () => {
|
||||
const custom: AgentType = 'some-in-house-agent'
|
||||
expect(custom).toBe('some-in-house-agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the main agent field on a status payload', () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
|
||||
BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY,
|
||||
BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY,
|
||||
BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
|
||||
RUNTIME_CAPABILITIES
|
||||
} from './protocol-version'
|
||||
|
||||
describe('browser network capabilities', () => {
|
||||
it('keeps client hosting, network tunneling, and screencast independent', () => {
|
||||
expect(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY).toBe('browser.clientHost.v1')
|
||||
expect(BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY).toBe('network.browserTunnel.v1')
|
||||
expect(BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY).toBe(
|
||||
'network.browserTunnel.executionHosts.v1'
|
||||
)
|
||||
expect(BROWSER_SCREENCAST_RUNTIME_CAPABILITY).toBe('browser.screencast.v1')
|
||||
})
|
||||
|
||||
it('advertises the registered client-host and tunnel implementations independently', () => {
|
||||
expect(RUNTIME_CAPABILITIES).toContain(BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY)
|
||||
expect(RUNTIME_CAPABILITIES).toContain(BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY)
|
||||
expect(RUNTIME_CAPABILITIES).toContain(BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY)
|
||||
expect(RUNTIME_CAPABILITIES).toContain(BROWSER_SCREENCAST_RUNTIME_CAPABILITY)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user