Files
Netcatty/electron/preloadTerminalOutputPorts.test.cjs
T
陈大猫 f99efa8ac1 fix(ui): open AI silent sessions in an attach terminal popup (#2380)
* fix(ui): open AI silent sessions in an attach terminal popup

Tray jumps for hidden MCP sessions attach the existing terminal popup
to the live PTY instead of activating a tab-less main-window surface.
Output is rebound to the popup and restored when the popup closes.

* fix(terminal): register rebindOutput handlers under worker mode

Worker-enabled builds returned early from terminalBridge.registerHandlers
without registering rebind/restore IPC, so the first AI silent-session
attach popup always failed with "No handler registered". Register those
handlers in both paths and rebind display routing via terminalWorkerManager.

* fix(terminal): harden AI attach popup for worker mode lifecycle

Address codex review P1s: resume flow control before restoring output,
route exit/events to the rebound renderer, and replay home scrollback
via a snapshot request before rebinding display output.

* fix(terminal): restore attach routing on popup close and dual-notify exit

Hardening for AI observe popups: restore output from BrowserWindow closed
lifecycle (not only React cleanup), keep attach home for exit events so
main/tray lifecycle updates, and resume paused backend flow on attach.

* fix(terminal): resume flow on attach restore and close snapshot gaps

Resume backend output when attach popups are force-closed, fan out
lifecycle exit events to home+display in non-worker mode, and pause
during snapshot→rebind so live bytes are not dropped from the popup.

* fix(terminal): harden attach restore and worker lifecycle edges

Prefer live display for ZMODEM dialogs after rebind, dual-notify worker
crash exits to attach-home, and avoid clearing attach-home when restore
targets a dead renderer.

* fix(terminal): sync observe-popup state home and fan out SSH exits

Push popup serializations home before restore, fall back to hibernate
snapshots, settle briefly after pause for worker handoff, and dual-notify
SSH exits to attach-home owners in non-worker mode.

* fix(terminal): fan out exits for mosh/telnet/et attach owners

Use shared fanout for in-process protocol exits so attach-home and
display renderers both receive netcatty:exit under non-worker mode.

* fix(terminal): restore attach only onto main app windows

Avoid routing silent-session output to settings/prewarm windows when the
original home renderer is gone; fall back only to registered main windows.

* fix(terminal): harden silent session popup handoff

* fix(terminal): close observed popup with session

* fix(terminal): keep observe popup from reconnecting

* fix(terminal): preserve cleared observe snapshots

* fix(terminal): route transfer prompts to observer

* fix(terminal): keep context after observe restore
2026-07-22 08:58:58 +08:00

262 lines
7.0 KiB
JavaScript

const assert = require("node:assert/strict");
const test = require("node:test");
const {
createTerminalOutputPortRegistry,
} = require("./preload/terminalOutputPorts.cjs");
const {
createTerminalUrgentInputPortRegistry,
} = require("./preload/terminalUrgentInputPorts.cjs");
function createFakeIpcRenderer() {
const handlers = new Map();
return {
on(channel, handler) {
handlers.set(channel, handler);
},
emitPort(sessionId, port) {
handlers.get("netcatty:terminal-output-port")?.({ ports: [port] }, { sessionId });
},
emitUrgentPort(port) {
handlers.get("netcatty:terminal-urgent-input-port")?.({ ports: [port] }, {});
},
};
}
function createFakePort() {
return {
messages: [],
closed: false,
started: false,
postMessage(message) {
this.messages.push(message);
},
start() {
this.started = true;
},
close() {
this.closed = true;
},
emit(data) {
this.onmessage?.({ data });
},
};
}
test("register attaches terminal output ports and delivers port messages", () => {
const delivered = [];
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
deliverToListeners(sessionId, data) {
delivered.push({ sessionId, data });
},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
port.emit({ sessionId: "session-1", data: "hello" });
assert.deepEqual(delivered, [
{ sessionId: "session-1", data: "hello" },
]);
});
test("terminal output ports forward terminal output metadata", () => {
const delivered = [];
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
deliverToListeners(sessionId, data, meta) {
delivered.push({ sessionId, data, meta });
},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
port.emit({
sessionId: "session-1",
data: "hello",
meta: { droppedOutputMayAffectTerminalState: true },
});
assert.deepEqual(delivered, [
{
sessionId: "session-1",
data: "hello",
meta: { droppedOutputMayAffectTerminalState: true },
},
]);
});
test("terminal output drain marker follows earlier data on the same port", () => {
const events = [];
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
deliverToListeners(_sessionId, data) {
events.push(`data:${data}`);
},
onDrain(_sessionId, requestId) {
events.push(`drain:${requestId}`);
},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
port.emit({ sessionId: "session-1", data: "tail" });
port.emit({ kind: "drain", sessionId: "session-1", requestId: "drain-1" });
assert.deepEqual(events, ["data:tail", "drain:drain-1"]);
});
test("terminal output ports filter data before delivery", () => {
const delivered = [];
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
filterData(_sessionId, data) {
return data.replace(/^.*__NCMCP_.*\n?/gm, "");
},
deliverToListeners(sessionId, data) {
delivered.push({ sessionId, data });
},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
port.emit({ sessionId: "session-1", data: "before\n__NCMCP_TEST_S\nvisible\n" });
assert.deepEqual(delivered, [
{ sessionId: "session-1", data: "before\nvisible\n" },
]);
});
test("terminal output ports accept filtered data with metadata", () => {
const delivered = [];
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
filterData(_sessionId, data) {
return {
data: data.toUpperCase(),
meta: { droppedOutputMayAffectTerminalState: true },
};
},
deliverToListeners(sessionId, data, meta) {
delivered.push({ sessionId, data, meta });
},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
port.emit({ sessionId: "session-1", data: "hello" });
assert.deepEqual(delivered, [
{
sessionId: "session-1",
data: "HELLO",
meta: { droppedOutputMayAffectTerminalState: true },
},
]);
});
test("terminal output ports do not deliver fully filtered chunks", () => {
const delivered = [];
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
filterData() {
return "";
},
deliverToListeners(sessionId, data) {
delivered.push({ sessionId, data });
},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
port.emit({ sessionId: "session-1", data: "__NCMCP_TEST_S\n" });
assert.deepEqual(delivered, []);
});
test("register closes stale replacement ports", () => {
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
deliverToListeners() {},
});
const stale = createFakePort();
const next = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", stale);
ipcRenderer.emitPort("session-1", next);
assert.equal(stale.closed, true);
assert.equal(next.closed, false);
});
test("closed sessions drop terminal output port messages", () => {
const delivered = [];
const closedTerminalDataSessions = new Set(["session-1"]);
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
closedTerminalDataSessions,
deliverToListeners(sessionId, data) {
delivered.push({ sessionId, data });
},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
port.emit({ sessionId: "session-1", data: "late" });
assert.deepEqual(delivered, []);
});
test("closeSession closes and removes a terminal output port", () => {
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalOutputPortRegistry({
ipcRenderer,
deliverToListeners() {},
});
const port = createFakePort();
registry.register();
ipcRenderer.emitPort("session-1", port);
registry.closeSession("session-1");
assert.equal(port.closed, true);
assert.equal(registry.hasSessionForTest("session-1"), false);
});
test("urgent input port posts interrupt messages over the transferred port", () => {
const ipcRenderer = createFakeIpcRenderer();
const registry = createTerminalUrgentInputPortRegistry({ ipcRenderer });
const port = createFakePort();
registry.register();
ipcRenderer.emitUrgentPort(port);
const sent = registry.postInterrupt("session-1", { traceId: "trace-1" });
assert.equal(sent, true);
assert.equal(port.started, true);
assert.deepEqual(port.messages, [
{
kind: "interrupt",
sessionId: "session-1",
trace: { traceId: "trace-1" },
},
]);
});