[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
669
electron/bridges/terminalBridge.rebindOutput.test.cjs
Normal file
669
electron/bridges/terminalBridge.rebindOutput.test.cjs
Normal file
@@ -0,0 +1,669 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const Module = require("node:module");
|
||||
|
||||
function loadTerminalBridgeWithMocks() {
|
||||
const bridgePath = require.resolve("./terminalBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
|
||||
const opened = [];
|
||||
const fakeChannel = {
|
||||
openSession(sessionId, webContents) {
|
||||
opened.push({ sessionId, webContentsId: webContents.id });
|
||||
return true;
|
||||
},
|
||||
closeSession() {},
|
||||
};
|
||||
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function patchedRequire(request) {
|
||||
if (request === "./terminalOutputChannel.cjs" || request.endsWith("terminalOutputChannel.cjs")) {
|
||||
return {
|
||||
createTerminalOutputChannel: () => fakeChannel,
|
||||
TERMINAL_OUTPUT_PORT_CHANNEL: "netcatty:terminal-output-port",
|
||||
};
|
||||
}
|
||||
if (request === "./emitTerminalSessionData.cjs" || request.endsWith("emitTerminalSessionData.cjs")) {
|
||||
return { configureTerminalSessionDataEmitter: () => {} };
|
||||
}
|
||||
if (request === "electron") {
|
||||
return {};
|
||||
}
|
||||
return originalRequire.apply(this, arguments);
|
||||
};
|
||||
|
||||
try {
|
||||
const bridge = require("./terminalBridge.cjs");
|
||||
return { bridge, opened, fakeChannel };
|
||||
} finally {
|
||||
Module.prototype.require = originalRequire;
|
||||
}
|
||||
}
|
||||
|
||||
test("rebindTerminalSessionOutput moves output and updates webContentsId", () => {
|
||||
// Load bridge source helpers via init + direct IPC simulation is heavy;
|
||||
// assert the implementation is registered and the openSession rebind contract
|
||||
// used by the attach popup is present.
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /function rebindTerminalSessionOutput/);
|
||||
assert.match(source, /function restoreTerminalSessionOutput/);
|
||||
assert.match(source, /netcatty:terminal:rebindOutput/);
|
||||
assert.match(source, /netcatty:terminal:restoreOutput/);
|
||||
assert.match(source, /session\.webContentsId = sender\.id/);
|
||||
});
|
||||
|
||||
test("rebind and restore handlers register even when terminal worker is enabled", () => {
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
// Must be registered before the worker early-return, otherwise production
|
||||
// (worker-on) hits "No handler registered for rebindOutput" on first attach.
|
||||
const rebindIdx = source.indexOf('ipcMain.handle("netcatty:terminal:rebindOutput"');
|
||||
const restoreIdx = source.indexOf('ipcMain.handle("netcatty:terminal:restoreOutput"');
|
||||
const snapshotIdx = source.indexOf('ipcMain.handle("netcatty:terminal:requestSnapshot"');
|
||||
const workerReturnIdx = source.indexOf("].forEach((channel) => registerWorkerSend");
|
||||
assert.ok(rebindIdx > 0, "rebind handler present");
|
||||
assert.ok(restoreIdx > 0, "restore handler present");
|
||||
assert.ok(snapshotIdx > 0, "snapshot handler present");
|
||||
assert.ok(workerReturnIdx > 0, "worker send registration present");
|
||||
assert.ok(rebindIdx < workerReturnIdx, "rebind registered before worker-only early return");
|
||||
assert.ok(restoreIdx < workerReturnIdx, "restore registered before worker-only early return");
|
||||
assert.ok(snapshotIdx < workerReturnIdx, "snapshot registered before worker-only early return");
|
||||
assert.match(source, /terminalWorkerManager\.rebindOutputSession/);
|
||||
assert.match(source, /function requestTerminalSessionSnapshot/);
|
||||
});
|
||||
|
||||
test("main process arbitrates two renderer leases before forwarding to the worker", async () => {
|
||||
const { bridge } = loadTerminalBridgeWithMocks();
|
||||
const handles = new Map();
|
||||
const listeners = new Map();
|
||||
const sent = [];
|
||||
const requested = [];
|
||||
const ipcMain = {
|
||||
handle(channel, handler) { handles.set(channel, handler); },
|
||||
on(channel, handler) { listeners.set(channel, handler); },
|
||||
};
|
||||
const terminalWorkerManager = {
|
||||
request: async (channel, payload) => {
|
||||
requested.push({ channel, payload });
|
||||
return { success: true };
|
||||
},
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
bridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const sender = (id) => ({ id, once() {} });
|
||||
const acquire = handles.get("netcatty:terminal:acquireFlowPauseLease");
|
||||
const release = handles.get("netcatty:terminal:releaseFlowPauseLease");
|
||||
const flow = listeners.get("netcatty:flow");
|
||||
|
||||
const home = await acquire({ sender: sender(10) }, { sessionId: "session-lease" });
|
||||
const popup = await acquire({ sender: sender(20) }, { sessionId: "session-lease" });
|
||||
flow({ sender: sender(10) }, { sessionId: "session-lease", paused: false });
|
||||
await release(
|
||||
{ sender: sender(10) },
|
||||
{ sessionId: "session-lease", leaseId: home.leaseId },
|
||||
);
|
||||
await release(
|
||||
{ sender: sender(20) },
|
||||
{ sessionId: "session-lease", leaseId: popup.leaseId },
|
||||
);
|
||||
|
||||
assert.deepEqual(requested.map(({ payload }) => payload.paused), [true, true]);
|
||||
assert.deepEqual(sent.map(({ payload }) => payload.paused), [true, true, false]);
|
||||
assert.ok([...requested, ...sent].every(({ payload }) => payload._flowArbitrated === true));
|
||||
});
|
||||
|
||||
test("attach recovery respects a home renderer lease in worker mode", async () => {
|
||||
const { bridge } = loadTerminalBridgeWithMocks();
|
||||
const handles = new Map();
|
||||
const sent = [];
|
||||
const ipcMain = {
|
||||
handle(channel, handler) { handles.set(channel, handler); },
|
||||
on() {},
|
||||
};
|
||||
const terminalWorkerManager = {
|
||||
request: async () => ({ success: true }),
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
restoreAttachHome: async () => ({ success: true, restored: true }),
|
||||
};
|
||||
bridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const sender = { id: 10, once() {} };
|
||||
const acquire = handles.get("netcatty:terminal:acquireFlowPauseLease");
|
||||
const release = handles.get("netcatty:terminal:releaseFlowPauseLease");
|
||||
const home = await acquire({ sender }, { sessionId: "session-restore" });
|
||||
const attachRestore = require("./terminalAttachRestore.cjs");
|
||||
|
||||
const restored = await attachRestore.restoreAttachedSessionOutput("session-restore");
|
||||
|
||||
assert.equal(restored.success, true);
|
||||
assert.equal(sent.at(-1)?.payload?.paused, true);
|
||||
await release(
|
||||
{ sender },
|
||||
{ sessionId: "session-restore", leaseId: home.leaseId },
|
||||
);
|
||||
assert.equal(sent.at(-1)?.payload?.paused, false);
|
||||
});
|
||||
|
||||
test("duplicate popup crash restores share one in-flight operation", async () => {
|
||||
const { bridge } = loadTerminalBridgeWithMocks();
|
||||
const handles = new Map();
|
||||
let restoreCalls = 0;
|
||||
let finishRestore;
|
||||
const restoreResult = new Promise((resolve) => { finishRestore = resolve; });
|
||||
const ipcMain = {
|
||||
handle(channel, handler) { handles.set(channel, handler); },
|
||||
on() {},
|
||||
};
|
||||
const terminalWorkerManager = {
|
||||
request: async () => ({ success: true }),
|
||||
send() {},
|
||||
restoreAttachHome: async () => {
|
||||
restoreCalls += 1;
|
||||
return restoreResult;
|
||||
},
|
||||
};
|
||||
bridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const attachRestore = require("./terminalAttachRestore.cjs");
|
||||
|
||||
const renderGoneRestore = attachRestore.restoreAttachedSessionOutput("session-crash");
|
||||
const closedRestore = attachRestore.restoreAttachedSessionOutput("session-crash");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(restoreCalls, 1);
|
||||
|
||||
finishRestore({ success: true, restored: true });
|
||||
assert.deepEqual(await Promise.all([renderGoneRestore, closedRestore]), [
|
||||
{ success: true, restored: true },
|
||||
{ success: true, restored: true },
|
||||
]);
|
||||
assert.equal(restoreCalls, 1);
|
||||
});
|
||||
|
||||
test("failed worker lease acquisition compensates the backend pause", async () => {
|
||||
const { bridge } = loadTerminalBridgeWithMocks();
|
||||
const handles = new Map();
|
||||
const sent = [];
|
||||
const ipcMain = {
|
||||
handle(channel, handler) { handles.set(channel, handler); },
|
||||
on() {},
|
||||
};
|
||||
const terminalWorkerManager = {
|
||||
request: async () => { throw new Error("worker unavailable"); },
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
bridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const acquire = handles.get("netcatty:terminal:acquireFlowPauseLease");
|
||||
|
||||
await assert.rejects(
|
||||
acquire(
|
||||
{ sender: { id: 10, once() {} } },
|
||||
{ sessionId: "session-acquire-failure" },
|
||||
),
|
||||
/worker unavailable/,
|
||||
);
|
||||
|
||||
assert.equal(sent.at(-1)?.payload?.paused, false);
|
||||
assert.equal(sent.at(-1)?.payload?._flowArbitrated, true);
|
||||
});
|
||||
|
||||
test("worker close and natural exit clear main-process pause ownership", async () => {
|
||||
const { bridge } = loadTerminalBridgeWithMocks();
|
||||
const handles = new Map();
|
||||
const sends = new Map();
|
||||
const ipcMain = {
|
||||
handle(channel, handler) { handles.set(channel, handler); },
|
||||
on(channel, handler) { sends.set(channel, handler); },
|
||||
};
|
||||
let notifySessionClosed = null;
|
||||
const terminalWorkerManager = {
|
||||
request: async (_channel, payload) => (
|
||||
Number.isFinite(payload?.bootEpoch)
|
||||
? { skipped: true }
|
||||
: { success: true }
|
||||
),
|
||||
send() {},
|
||||
onSessionClosed(listener) {
|
||||
notifySessionClosed = listener;
|
||||
return { dispose() {} };
|
||||
},
|
||||
};
|
||||
bridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
const sender = { id: 10, isDestroyed() {}, send() {} };
|
||||
const acquire = handles.get("netcatty:terminal:acquireFlowPauseLease");
|
||||
const release = handles.get("netcatty:terminal:releaseFlowPauseLease");
|
||||
const closeAwait = handles.get("netcatty:close:await");
|
||||
const closeFireAndForget = sends.get("netcatty:close");
|
||||
|
||||
const closingLease = await acquire({ sender }, { sessionId: "session-close" });
|
||||
await closeAwait({ sender }, { sessionId: "session-close" });
|
||||
assert.equal((await release(
|
||||
{ sender },
|
||||
{ sessionId: "session-close", leaseId: closingLease.leaseId },
|
||||
)).success, false);
|
||||
|
||||
// Epoch-mismatch / skipped close must leave the replacement lease intact.
|
||||
const skippedLease = await acquire({ sender }, { sessionId: "session-skipped" });
|
||||
const skippedClose = await closeAwait(
|
||||
{ sender },
|
||||
{ sessionId: "session-skipped", bootEpoch: 1 },
|
||||
);
|
||||
assert.equal(skippedClose?.skipped, true);
|
||||
assert.equal((await release(
|
||||
{ sender },
|
||||
{ sessionId: "session-skipped", leaseId: skippedLease.leaseId },
|
||||
)).success, true);
|
||||
|
||||
// Fire-and-forget epoch-scoped close must also preserve leases (worker may
|
||||
// no-op); unscoped close still clears eagerly.
|
||||
const epochFireLease = await acquire({ sender }, { sessionId: "session-epoch-fire" });
|
||||
closeFireAndForget({ sender }, { sessionId: "session-epoch-fire", bootEpoch: 2 });
|
||||
assert.equal((await release(
|
||||
{ sender },
|
||||
{ sessionId: "session-epoch-fire", leaseId: epochFireLease.leaseId },
|
||||
)).success, true);
|
||||
|
||||
const unscopedFireLease = await acquire({ sender }, { sessionId: "session-unscoped-fire" });
|
||||
closeFireAndForget({ sender }, { sessionId: "session-unscoped-fire" });
|
||||
assert.equal((await release(
|
||||
{ sender },
|
||||
{ sessionId: "session-unscoped-fire", leaseId: unscopedFireLease.leaseId },
|
||||
)).success, false);
|
||||
|
||||
const exitLease = await acquire({ sender }, { sessionId: "session-exit" });
|
||||
notifySessionClosed({ sessionId: "session-exit", reason: "exited" });
|
||||
assert.equal((await release(
|
||||
{ sender },
|
||||
{ sessionId: "session-exit", leaseId: exitLease.leaseId },
|
||||
)).success, false);
|
||||
});
|
||||
|
||||
test("worker renderer-event forwarding prefers rebound webContentsId", () => {
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalWorkerManager.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /sessionWebContentsIds\.get\(sessionId\)/);
|
||||
assert.match(source, /displayWebContentsId/);
|
||||
assert.match(source, /attachHomeWebContentsIds/);
|
||||
assert.match(source, /restoreAttachHome/);
|
||||
// Exit cleanup must not run before we capture display/home targets.
|
||||
const rendererEventIdx = source.indexOf('if (message.kind === "renderer-event")');
|
||||
const captureIdx = source.indexOf("const displayWebContentsId =", rendererEventIdx);
|
||||
const closeIdx = source.indexOf('if (message.channel === "netcatty:exit" && sessionId)', captureIdx);
|
||||
assert.ok(captureIdx > 0 && closeIdx > captureIdx, "capture targets before closeOutputSession on exit");
|
||||
assert.match(
|
||||
source,
|
||||
/message\.channel === "netcatty:exit" && homeWebContentsId != null/,
|
||||
"only exit events fan out to the attach home",
|
||||
);
|
||||
});
|
||||
|
||||
test("in-process SSH and Telnet ZMODEM prompts follow the rebound display", () => {
|
||||
const sshSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "sshBridge/startSession.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const telnetSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge/telnetSession.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const sshSentry = sshSource.slice(
|
||||
sshSource.indexOf("const sshZmodemSentry"),
|
||||
sshSource.indexOf("session.zmodemSentry = sshZmodemSentry"),
|
||||
);
|
||||
const telnetSentry = telnetSource.slice(
|
||||
telnetSource.indexOf("const telnetZmodemSentry"),
|
||||
telnetSource.indexOf("const attachTelnetSentry"),
|
||||
);
|
||||
assert.match(sshSentry, /getCurrentSessionWebContents/);
|
||||
assert.match(sshSentry, /getCurrentSessionWebContentsId/);
|
||||
assert.doesNotMatch(sshSentry, /event\.sender/);
|
||||
assert.match(telnetSentry, /getCurrentTelnetWebContents/);
|
||||
assert.match(telnetSentry, /getCurrentTelnetWebContentsId/);
|
||||
assert.doesNotMatch(telnetSentry, /telnetWebContentsId/);
|
||||
});
|
||||
|
||||
test("popup window closed lifecycle restores attach output", () => {
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "windowManager/terminalPopupWindow.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /restoreAttachedSessionOutput\(attachSessionId\)/);
|
||||
assert.match(source, /terminalAttachRestore/);
|
||||
const crashStart = source.indexOf('"render-process-gone"');
|
||||
const crashEnd = source.indexOf('"console-message"', crashStart);
|
||||
assert.match(source.slice(crashStart, crashEnd), /win\.destroy\(\)/);
|
||||
});
|
||||
|
||||
test("attach close restores the output route before resuming the backend", () => {
|
||||
const bridgeSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const restoreStart = bridgeSource.indexOf("async function restoreAttachedSessionOutputOnce");
|
||||
const restoreEnd = bridgeSource.indexOf("function restoreTerminalSessionOutput", restoreStart);
|
||||
const restoreSource = bridgeSource.slice(restoreStart, restoreEnd);
|
||||
const routeIdx = restoreSource.indexOf("restoreAttachHome");
|
||||
const resumeIdx = restoreSource.lastIndexOf("setDirectPaused");
|
||||
assert.ok(routeIdx > 0 && resumeIdx > routeIdx, "restore route before resuming output");
|
||||
|
||||
const terminalSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "../../components/Terminal.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const cleanupStart = terminalSource.indexOf("Observe/attach popups must not kill");
|
||||
const cleanupEnd = terminalSource.indexOf("const cleanupPromise", cleanupStart);
|
||||
const cleanupSource = terminalSource.slice(cleanupStart, cleanupEnd);
|
||||
const pauseIdx = cleanupSource.indexOf("acquireSessionFlowPauseLease(closingSessionId)");
|
||||
const snapshotIdx = cleanupSource.indexOf("applySessionSnapshot");
|
||||
const rendererRestoreIdx = cleanupSource.indexOf("restoreSessionOutput");
|
||||
const disposeIdx = cleanupSource.indexOf("disposeSessionListeners()");
|
||||
const releaseIdx = cleanupSource.indexOf("releaseTerminalFlowBeforeHibernate");
|
||||
const leaseReleaseIdx = cleanupSource.indexOf("outputPauseLease.release()", releaseIdx);
|
||||
assert.ok(pauseIdx > 0, "pause before final snapshot");
|
||||
assert.ok(snapshotIdx > pauseIdx, "snapshot after pause");
|
||||
assert.ok(rendererRestoreIdx > snapshotIdx, "restore route after snapshot");
|
||||
assert.ok(disposeIdx > rendererRestoreIdx, "detach popup listener after route restore");
|
||||
assert.ok(releaseIdx > disposeIdx, "resume only after popup listener detaches");
|
||||
assert.ok(leaseReleaseIdx > releaseIdx, "release pause ownership after output handoff");
|
||||
const mainRestoreStart = bridgeSource.indexOf("async function restoreAttachedSessionOutputOnce");
|
||||
const mainRestoreEnd = bridgeSource.indexOf("function restoreTerminalSessionOutput", mainRestoreStart);
|
||||
const mainRestoreSource = bridgeSource.slice(mainRestoreStart, mainRestoreEnd);
|
||||
assert.match(
|
||||
mainRestoreSource,
|
||||
/if \(result\?\.success\) \{[\s\S]*setDirectPaused\([\s\S]*restorePauseOwner,[\s\S]*false/,
|
||||
);
|
||||
});
|
||||
|
||||
test("in-process explicit close sends an exit event before dropping the output route", () => {
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const start = source.indexOf("function closeSession(event, payload)");
|
||||
const end = source.indexOf("function setSessionEncoding", start);
|
||||
const closeSource = source.slice(start, end);
|
||||
assert.ok(
|
||||
closeSource.indexOf("fanoutSessionLifecycleEvent") < closeSource.indexOf("closeTerminalOutputSession"),
|
||||
"notify attached renderers before removing their output route",
|
||||
);
|
||||
assert.match(source, /if \(session\.closed\) return;/, "transport callbacks suppress duplicate explicit-close exits");
|
||||
const sshSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "sshBridge/startSession.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(sshSource, /if \(liveSession\?\.closed\)/, "SSH close callback suppresses duplicate explicit-close exits");
|
||||
});
|
||||
|
||||
test("late backend exits cannot tear down a replacement with the same session id", () => {
|
||||
const bridgeSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const sshSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "sshBridge/startSession.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const telnetSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge/telnetSession.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const moshSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge/moshSession.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const etSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge/etSession.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(sshSource, /if \(liveSession === session\)/);
|
||||
assert.match(bridgeSource, /if \(sessions\.get\(sessionId\) !== session\) return;/);
|
||||
assert.match(bridgeSource, /serialExitFinalized \|\| sessions\.get\(sessionId\) !== session/);
|
||||
assert.match(telnetSource, /sessions\.get\(sessionId\) !== activeSession/);
|
||||
assert.match(moshSource, /sessions\.get\(sessionId\) !== session/);
|
||||
assert.match(etSource, /sessions\.get\(sessionId\) !== session/);
|
||||
assert.match(sshSource, /establishedOwnerSession && current !== establishedOwnerSession/);
|
||||
assert.match(sshSource, /establishedOwnerSession \? \{ session: establishedOwnerSession \} : \{\}/);
|
||||
});
|
||||
|
||||
test("in-process explicit close notifies a rebound popup and its home renderer", async () => {
|
||||
const { bridge, fakeChannel } = loadTerminalBridgeWithMocks();
|
||||
const sent = [];
|
||||
const contents = new Map([7, 9].map((id) => [id, {
|
||||
id,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ id, channel, payload }),
|
||||
}]));
|
||||
const sessions = new Map([[
|
||||
"session-close",
|
||||
{ webContentsId: 7, proc: { kill() {} } },
|
||||
]]);
|
||||
bridge.init({
|
||||
sessions,
|
||||
electronModule: { webContents: { fromId: (id) => contents.get(id) } },
|
||||
terminalOutputChannel: fakeChannel,
|
||||
});
|
||||
const registry = require("./terminalAttachRestore.cjs");
|
||||
registry.registerAttachPopupAuthorization("close-grant", "session-close", 9);
|
||||
assert.equal(bridge.registerHandlers != null, true);
|
||||
const ipcMain = {
|
||||
handlers: new Map(),
|
||||
listeners: new Map(),
|
||||
handle(channel, handler) { this.handlers.set(channel, handler); },
|
||||
on(channel, handler) { this.listeners.set(channel, handler); },
|
||||
};
|
||||
bridge.registerHandlers(ipcMain);
|
||||
assert.equal(
|
||||
(await ipcMain.handlers.get("netcatty:terminal:rebindOutput")(
|
||||
{ sender: contents.get(9) },
|
||||
{ sessionId: "session-close", authorization: "close-grant" },
|
||||
)).success,
|
||||
true,
|
||||
);
|
||||
|
||||
bridge.closeSession({ sender: contents.get(7) }, { sessionId: "session-close" });
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
{ id: 9, channel: "netcatty:exit", payload: { sessionId: "session-close", exitCode: 0, reason: "closed" } },
|
||||
{ id: 7, channel: "netcatty:exit", payload: { sessionId: "session-close", exitCode: 0, reason: "closed" } },
|
||||
]);
|
||||
registry.releaseAttachPopupAuthorization("close-grant");
|
||||
});
|
||||
|
||||
test("snapshot apply acknowledgements are emitted only by the matching terminal", () => {
|
||||
const preloadSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "../preload/api.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const terminalSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "../../components/Terminal.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const bridgeSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const effectsSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "../../components/terminal/useTerminalEffects.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(preloadSource, /if \(handled !== true\) return/);
|
||||
assert.match(terminalSource, /typeof payload\.contextViewportSnapshot !== "string"/);
|
||||
assert.doesNotMatch(terminalSource, /if \(snap\) \{\s*const applied = await terminalBackend\.applySessionSnapshot/);
|
||||
assert.match(terminalSource, /const applied = await terminalBackend\.applySessionSnapshot/);
|
||||
assert.match(bridgeSource, /const hasSnapshot = typeof payload\?\.snapshot === "string"/);
|
||||
assert.match(bridgeSource, /const hasContextSnapshot = typeof payload\?\.contextSnapshot === "string"/);
|
||||
assert.match(preloadSource, /contextSnapshot: typeof context\?\.contextSnapshot === "string"/);
|
||||
assert.match(terminalSource, /finalContext = readTerminalHibernateContext\(snapshotTerm\)/);
|
||||
assert.match(bridgeSource, /kittyKeyboardModeState: normalizeKittyKeyboardModeState/);
|
||||
assert.match(preloadSource, /sanitizeKittyKeyboardModeState\(kittyKeyboardModeState\)/);
|
||||
assert.match(terminalSource, /getKittyKeyboardModeState\(\)/);
|
||||
assert.match(terminalSource, /restoreKittyKeyboardModeState/);
|
||||
assert.match(effectsSource, /runtime\.restoreKittyKeyboardModeState\(snap\.kittyKeyboardModeState\)/);
|
||||
assert.match(bridgeSource, /kittyKeyboardProtocolEnabled: typeof payload\?\.kittyKeyboardProtocolEnabled/);
|
||||
assert.match(preloadSource, /typeof kittyKeyboardProtocolEnabled === "boolean"/);
|
||||
assert.match(terminalSource, /getKittyKeyboardProtocolEnabled\(\)/);
|
||||
assert.match(terminalSource, /\?\? kittyKeyboardProtocolEnabledForSession/);
|
||||
assert.match(terminalSource, /setKittyKeyboardProtocolEnabled/);
|
||||
assert.match(effectsSource, /runtime\.setKittyKeyboardProtocolEnabled\(snap\.kittyKeyboardProtocolEnabled\)/);
|
||||
assert.match(terminalSource, /passwordPromptActive: passwordPromptActiveRef\.current/u);
|
||||
assert.match(preloadSource, /typeof passwordPromptActive === "boolean"/u);
|
||||
assert.match(bridgeSource, /passwordPromptActive: typeof payload\?\.passwordPromptActive/u);
|
||||
assert.match(effectsSource, /passwordPromptActiveRef\.current = snap\.passwordPromptActive/u);
|
||||
});
|
||||
|
||||
test("exit fanout preserves the original renderer before registry wiring", () => {
|
||||
const attachRestore = require("./terminalAttachRestore.cjs");
|
||||
attachRestore.setFanoutSessionExit(null);
|
||||
const sent = [];
|
||||
const contents = {
|
||||
id: 42,
|
||||
send(channel, payload) {
|
||||
sent.push({ channel, payload });
|
||||
},
|
||||
};
|
||||
const payload = { sessionId: "session-1", reason: "exited" };
|
||||
assert.equal(attachRestore.fanoutSessionExit("session-1", contents, payload), true);
|
||||
assert.deepEqual(sent, [{ channel: "netcatty:exit", payload }]);
|
||||
});
|
||||
|
||||
test("attach authorization is bound to one session and renderer", () => {
|
||||
const registry = require("./terminalAttachRestore.cjs");
|
||||
registry.registerAttachPopupAuthorization("grant-1", "session-1", 42);
|
||||
assert.equal(registry.validateAttachPopupAuthorization("grant-1", "session-1", 42), true);
|
||||
assert.equal(registry.validateAttachPopupAuthorization("grant-1", "session-2", 42), false);
|
||||
assert.equal(registry.validateAttachPopupAuthorization("grant-1", "session-1", 43), false);
|
||||
assert.equal(registry.markAttachPopupClosePrepared("grant-1", "session-1", 42), true);
|
||||
assert.equal(registry.isAttachPopupClosePrepared("grant-1"), true);
|
||||
registry.releaseAttachPopupAuthorization("grant-1");
|
||||
assert.equal(registry.validateAttachPopupAuthorization("grant-1", "session-1", 42), false);
|
||||
});
|
||||
|
||||
test("failed attach restores retry when a main renderer becomes ready", async () => {
|
||||
const registry = require("./terminalAttachRestore.cjs");
|
||||
let available = false;
|
||||
let calls = 0;
|
||||
registry.setRestoreAttachedSessionOutput(() => {
|
||||
calls += 1;
|
||||
return available
|
||||
? { success: true, restored: true }
|
||||
: { success: false, restored: false, error: "Home renderer unavailable" };
|
||||
});
|
||||
|
||||
assert.equal((await registry.restoreAttachedSessionOutput("session-retry")).success, false);
|
||||
available = true;
|
||||
await registry.retryPendingAttachedSessionOutputs();
|
||||
await registry.retryPendingAttachedSessionOutputs();
|
||||
|
||||
assert.equal(calls, 2, "successful retry clears the pending restore");
|
||||
});
|
||||
|
||||
test("closing a terminal clears failed restore and popup authorization state", async () => {
|
||||
const registry = require("./terminalAttachRestore.cjs");
|
||||
let calls = 0;
|
||||
registry.setRestoreAttachedSessionOutput(() => {
|
||||
calls += 1;
|
||||
return { success: false, restored: false };
|
||||
});
|
||||
registry.registerAttachPopupAuthorization("closed-grant", "closed-session", 42);
|
||||
await registry.restoreAttachedSessionOutput("closed-session");
|
||||
|
||||
registry.releaseAttachedSessionState("closed-session");
|
||||
const result = await registry.retryPendingAttachedSessionOutput("closed-session", 42);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(result, { success: true, restored: false });
|
||||
assert.equal(
|
||||
registry.validateAttachPopupAuthorization("closed-grant", "closed-session", 42),
|
||||
false,
|
||||
);
|
||||
registry.setRestoreAttachedSessionOutput(null);
|
||||
});
|
||||
|
||||
test("a ready replacement main renderer becomes the explicit restore target", async () => {
|
||||
const registry = require("./terminalAttachRestore.cjs");
|
||||
const targets = [];
|
||||
registry.setRestoreAttachedSessionOutput((_sessionId, preferredHomeWebContentsId) => {
|
||||
targets.push(preferredHomeWebContentsId);
|
||||
return targets.length === 1
|
||||
? { success: false, restored: false }
|
||||
: { success: true, restored: true };
|
||||
});
|
||||
|
||||
await registry.restoreAttachedSessionOutput("session-replacement");
|
||||
await registry.retryPendingAttachedSessionOutput("session-replacement", 99);
|
||||
|
||||
assert.deepEqual(targets, [null, 99]);
|
||||
});
|
||||
|
||||
test("attach IPC handlers validate popup authorization and flow pause has an awaitable barrier", () => {
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalBridge.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /function isAuthorizedAttachIpc/);
|
||||
for (const functionName of [
|
||||
"requestTerminalSessionSnapshot",
|
||||
"applyTerminalSessionSnapshot",
|
||||
"rebindTerminalSessionOutput",
|
||||
"restoreTerminalSessionOutput",
|
||||
]) {
|
||||
const start = source.indexOf(`function ${functionName}`);
|
||||
const end = source.indexOf("\nfunction ", start + 10);
|
||||
assert.match(source.slice(start, end), /isAuthorizedAttachIpc/);
|
||||
}
|
||||
assert.match(source, /netcatty:terminal:setFlowPausedAndWait/);
|
||||
assert.match(source, /terminalWorkerManager\.request\("netcatty:terminal:setFlowPausedAndWait"/);
|
||||
});
|
||||
|
||||
test("terminal worker manager exposes rebindOutputSession", () => {
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "terminalWorkerManager.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /function rebindOutputSession/);
|
||||
assert.match(source, /rebindOutputSession,/);
|
||||
assert.match(source, /getSessionWebContentsId\(sessionId\)/);
|
||||
});
|
||||
|
||||
test("attach popup payload field is consumed by terminal popup window", () => {
|
||||
const source = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "windowManager/terminalPopupWindow.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /attachSessionId/);
|
||||
assert.match(source, /attachSessionPopups/);
|
||||
assert.match(source, /reused: true/);
|
||||
});
|
||||
|
||||
test("attach snapshots preserve cwd and title updates including explicit clears", () => {
|
||||
const terminalSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "../../components/Terminal.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const effectsSource = require("node:fs").readFileSync(
|
||||
path.join(__dirname, "../../components/terminal/useTerminalEffects.ts"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/knownCwdRef\.current \?\? null,[\s\S]*?terminalTitleRef\.current \?\? null/,
|
||||
);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/snap\.cwd !== undefined[\s\S]*?setRendererCwd\(snap\.cwd,\s*"snapshot"\)[\s\S]*?snap\.title !== undefined/,
|
||||
);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/onTitleChange: \(title: string \| null\) => \{[\s\S]*?terminalTitleRef\.current = title \|\| undefined/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/payload\.cwd !== undefined[\s\S]*?setRendererCwd\(payload\.cwd,\s*"snapshot"\)[\s\S]*?payload\.title !== undefined/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user