Files
NetMesh/electron/bridges/scriptBridge.test.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

2404 lines
71 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const scriptBridge = require("./scriptBridge.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { getOrCreateBuffer } = require("../scripts/sessionOutputBuffer.cjs");
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitUntil(predicate, timeoutMs = 1000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const value = predicate();
if (value) return value;
await delay(10);
}
throw new Error("Timed out waiting for condition");
}
test("closing a terminal releases its script output buffer", () => {
let closeListener = null;
scriptBridge.init({
sessions: new Map(),
electronModule: {},
terminalBridge: {},
terminalWorkerManager: {
addOutputTap() { return () => {}; },
onSessionClosed(listener) {
closeListener = listener;
return { dispose() {} };
},
},
getMainWindow: () => null,
});
scriptBridge.appendSessionOutput("closed-session", "stale output");
assert.equal(getOrCreateBuffer("closed-session").getText(), "stale output");
assert.equal(typeof closeListener, "function");
closeListener({ sessionId: "closed-session", reason: "closed" });
assert.equal(getOrCreateBuffer("closed-session").getText(), "");
scriptBridge.removeSessionBuffer("closed-session");
});
test("script run writes through terminal worker manager when enabled", async () => {
const handlers = new Map();
const workerSends = [];
const terminalWrites = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession(_event, payload) {
terminalWrites.push(payload);
},
},
terminalWorkerManager: {
addOutputTap() {
return () => {};
},
send(channel, payload, options) {
workerSends.push({ channel, payload, options });
},
},
getMainWindow: () => null,
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "script-1",
scriptLabel: "Smoke",
sessionId: "session-1",
content: "await nct.screen.sendLine('echo hi');",
permissionMode: "auto",
});
assert.deepEqual(terminalWrites, []);
// sendLine emits body then CR as separate keyboard-like writes (#1960).
assert.equal(workerSends.length, 2);
assert.equal(workerSends[0].channel, "netcatty:write");
assert.deepEqual(workerSends[0].payload, {
sessionId: "session-1",
data: "echo hi",
automated: true,
});
assert.deepEqual(workerSends[1].payload, {
sessionId: "session-1",
data: "\r",
automated: true,
});
});
test("script run completion stores actual executed step count", async () => {
const handlers = new Map();
const sentRunUpdates = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "script-loop",
scriptLabel: "Loop",
sessionId: "session-1",
content: "for (let i = 0; i < 3; i += 1) { nct.log(`step ${i}`); }",
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "script-loop");
assert.equal(finalRun.status, "completed");
assert.equal(finalRun.stepIndex, 3);
assert.equal(finalRun.progressMode, "activity");
assert.equal(finalRun.totalSteps, undefined);
});
test("same session script runs are serialized through the session mutex", async () => {
const handlers = new Map();
const writeOrder = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession(_event, payload) {
const marker = String(payload.data || "").match(/slow-run|fast-run/);
if (marker) writeOrder.push(marker[0]);
},
},
terminalWorkerManager: null,
getMainWindow: () => null,
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
await Promise.all([
runHandler({}, {
scriptId: "slow",
scriptLabel: "Slow",
sessionId: "session-mutex",
content: `
await nct.sleep(40);
await nct.screen.sendLine('slow-run');
`,
permissionMode: "auto",
}),
runHandler({}, {
scriptId: "fast",
scriptLabel: "Fast",
sessionId: "session-mutex",
content: "await nct.screen.sendLine('fast-run');",
permissionMode: "auto",
}),
]);
assert.deepEqual(writeOrder, ["slow-run", "fast-run"]);
});
test("a caller can cancel its queued script by run id without stopping the active script", async () => {
const handlers = new Map();
const writes = [];
let activeDialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) {
writes.push(String(payload.data || ""));
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
activeDialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
const activeRun = runHandler({}, {
runId: "unrelated-active-run",
scriptId: "manual",
sessionId: "session-queued-cancel",
content: `
await nct.dialog.confirm('hold queue');
await nct.screen.sendLine('active-finished');
`,
permissionMode: "auto",
});
await waitUntil(() => activeDialogRequestId);
const queued = await runHandler({}, {
runId: "queued-connect-run",
returnWhenQueued: true,
scriptId: "connect",
sessionId: "session-queued-cancel",
content: "await nct.screen.sendLine('queued-ran');",
permissionMode: "auto",
});
assert.deepEqual(queued, {
runId: "queued-connect-run",
runIds: ["queued-connect-run"],
});
assert.deepEqual(
await handlers.get("netcatty:script:stop")({}, { runId: "queued-connect-run" }),
{ ok: true },
);
assert.deepEqual(
await handlers.get("netcatty:script:dialog-response")({}, {
requestId: activeDialogRequestId,
value: true,
}),
{ ok: true },
);
await activeRun;
await delay(30);
assert.ok(writes.some((data) => data.includes("active-finished")));
assert.ok(!writes.some((data) => data.includes("queued-ran")));
});
test("stopping a script releases the session queue so it can run again", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const writes = [];
let dialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession(_event, payload) {
writes.push(String(payload.data || ""));
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:dialog-request") {
dialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
const firstRunPromise = runHandler({}, {
scriptId: "stopped",
scriptLabel: "Stopped",
sessionId: "session-rerun",
content: `
const allowed = await nct.dialog.confirm('continue?');
if (allowed) {
await nct.screen.sendLine('old-run');
}
`,
permissionMode: "auto",
});
await waitUntil(() => dialogRequestId);
const firstRunId = await waitUntil(() => (
sentRunUpdates
.flat()
.find((run) => run.scriptId === "stopped" && run.status === "running")
?.runId
));
assert.deepEqual(await handlers.get("netcatty:script:stop")({}, { runId: firstRunId }), { ok: true });
await Promise.race([
runHandler({}, {
scriptId: "rerun",
scriptLabel: "Rerun",
sessionId: "session-rerun",
content: "await nct.screen.sendLine('second-run');",
permissionMode: "auto",
}),
delay(500).then(() => {
throw new Error("rerun stayed blocked behind stopped script");
}),
]);
await firstRunPromise;
assert.ok(writes.some((data) => data.includes("second-run")));
assert.ok(!writes.some((data) => data.includes("old-run")));
assert.deepEqual(await handlers.get("netcatty:script:dialog-response")({}, {
requestId: dialogRequestId,
value: true,
}), { ok: false });
await delay(100);
assert.ok(!writes.some((data) => data.includes("old-run")));
const stoppedRun = sentRunUpdates.flat().find((run) => (
run.runId === firstRunId && run.status === "failed"
));
assert.equal(stoppedRun.status, "failed");
assert.equal(stoppedRun.error, "Stopped by user");
});
test("reinitializing the script bridge drops an accepted old-run sendLine tail", async () => {
const handlers = new Map();
const oldWrites = [];
const replacementWrites = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
webContents: { fromId: () => null },
},
terminalBridge: {
writeToSession(_event, payload) { oldWrites.push(payload.data); },
},
terminalWorkerManager: null,
getMainWindow: () => null,
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const oldRun = handlers.get("netcatty:script:run")({}, {
scriptId: "old-sendline-tail",
scriptLabel: "Old sendLine tail",
sessionId: "session-old-sendline-tail",
content: "await nct.screen.sendLine('old-body');",
permissionMode: "auto",
});
await waitUntil(() => oldWrites.includes("old-body"));
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
webContents: { fromId: () => null },
},
terminalBridge: {
writeToSession(_event, payload) { replacementWrites.push(payload.data); },
},
terminalWorkerManager: null,
getMainWindow: () => null,
});
await oldRun;
await delay(60);
assert.deepEqual(oldWrites, ["old-body"]);
assert.deepEqual(replacementWrites, []);
});
test("reinitializing the script bridge invalidates accepted queued runs", async () => {
const handlers = new Map();
const oldWrites = [];
const replacementWrites = [];
let activeDialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { oldWrites.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
activeDialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const runHandler = handlers.get("netcatty:script:run");
const activeRun = runHandler({}, {
runId: "active-before-reinit",
scriptId: "active-before-reinit",
sessionId: "session-reinit-queue",
content: "await nct.dialog.confirm('hold queue');",
permissionMode: "auto",
});
await waitUntil(() => activeDialogRequestId);
await runHandler({}, {
runId: "queued-before-reinit",
returnWhenQueued: true,
scriptId: "queued-before-reinit",
sessionId: "session-reinit-queue",
content: "await nct.screen.sendLine('stale-queued-write');",
permissionMode: "auto",
});
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { replacementWrites.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
getMainWindow: () => null,
});
await activeRun;
await delay(60);
assert.deepEqual(oldWrites, []);
assert.deepEqual(replacementWrites, []);
});
test("an old queued run cannot unregister a reused run id after reinitialization", async () => {
const handlers = new Map();
const writes = [];
let oldDialogRequestId;
let replacementDialogRequestId;
const makeMainWindow = (setDialogRequestId) => () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
setDialogRequestId(payload.requestId);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
});
const baseDeps = {
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { writes.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
};
scriptBridge.init({
...baseDeps,
getMainWindow: makeMainWindow((id) => { oldDialogRequestId = id; }),
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const runHandler = handlers.get("netcatty:script:run");
const oldActive = runHandler({}, {
runId: "old-active-for-reuse",
sessionId: "session-reinit-reuse",
content: "await nct.dialog.confirm('hold old queue');",
permissionMode: "auto",
});
await waitUntil(() => oldDialogRequestId);
await runHandler({}, {
runId: "reused-queued-id",
returnWhenQueued: true,
sessionId: "session-reinit-reuse",
content: "await nct.screen.sendLine('stale-reused-write');",
permissionMode: "auto",
});
scriptBridge.init({
...baseDeps,
getMainWindow: makeMainWindow((id) => { replacementDialogRequestId = id; }),
});
const replacementActive = runHandler({}, {
runId: "replacement-active-for-reuse",
sessionId: "session-reinit-reuse",
content: "await nct.dialog.confirm('hold replacement queue');",
permissionMode: "auto",
});
await runHandler({}, {
runId: "reused-queued-id",
returnWhenQueued: true,
sessionId: "session-reinit-reuse",
content: "await nct.screen.sendLine('replacement-reused-write');",
permissionMode: "auto",
});
await oldActive;
await waitUntil(() => replacementDialogRequestId);
assert.deepEqual(
await handlers.get("netcatty:script:stop")({}, { runId: "reused-queued-id" }),
{ ok: true },
);
await handlers.get("netcatty:script:dialog-response")({}, {
requestId: replacementDialogRequestId,
value: true,
});
await replacementActive;
await delay(60);
assert.ok(!writes.some((data) => data.includes("stale-reused-write")));
assert.ok(!writes.some((data) => data.includes("replacement-reused-write")));
});
test("late startup snapshots from a stopped script do not seed the next run", async () => {
const handlers = new Map();
const writes = [];
const sentRunUpdates = [];
const snapshotRequestIds = [];
scriptBridge.removeSessionBuffer("session-late-snapshot");
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession(_event, payload) {
writes.push(String(payload.data || ""));
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:dialog-request") {
setImmediate(() => {
handlers.get("netcatty:script:dialog-response")({}, {
requestId: payload.requestId,
value: "abort",
});
});
}
if (channel === "netcatty:script:screen-snapshot-request") {
snapshotRequestIds.push(payload.requestId);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
const firstRunPromise = runHandler({}, {
scriptId: "stopped-before-snapshot",
scriptLabel: "Stopped before snapshot",
sessionId: "session-late-snapshot",
content: "await nct.screen.sendLine('old-run');",
permissionMode: "auto",
});
const firstSnapshotRequestId = await waitUntil(() => snapshotRequestIds[0]);
const firstRunId = await waitUntil(() => (
sentRunUpdates
.flat()
.find((run) => run.scriptId === "stopped-before-snapshot" && run.status === "running")
?.runId
));
assert.deepEqual(await handlers.get("netcatty:script:stop")({}, { runId: firstRunId }), { ok: true });
await firstRunPromise;
const secondRunPromise = runHandler({}, {
scriptId: "second-after-late-snapshot",
scriptLabel: "Second after late snapshot",
sessionId: "session-late-snapshot",
content: `
try {
await nct.screen.waitForText('OLD_READY', 200);
await nct.screen.sendLine('bad-write');
} catch {
await nct.screen.sendLine('good-write');
}
`,
permissionMode: "auto",
});
const secondSnapshotRequestId = await waitUntil(() => snapshotRequestIds[1]);
assert.deepEqual(await handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: secondSnapshotRequestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
}), { ok: true });
await delay(30);
assert.deepEqual(await handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: firstSnapshotRequestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["OLD_READY"],
},
}), { ok: false });
await secondRunPromise;
assert.ok(writes.some((data) => data.includes("good-write")));
assert.ok(!writes.some((data) => data.includes("bad-write")));
assert.ok(!writes.some((data) => data.includes("old-run")));
scriptBridge.removeSessionBuffer("session-late-snapshot");
});
test("completed scripts clear unawaited dialog requests without rejecting them as stopped", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const closedSessions = [];
let dialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
closeSession(_event, payload) {
closedSessions.push(payload.sessionId);
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:dialog-request") {
dialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "unawaited-dialog",
scriptLabel: "Unawaited dialog",
sessionId: "session-unawaited-dialog",
content: `
void nct.dialog.confirm('background prompt').then(() => nct.session.disconnect());
nct.log('done');
`,
permissionMode: "auto",
});
await delay(100);
assert.ok(dialogRequestId);
assert.deepEqual(closedSessions, []);
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "unawaited-dialog");
assert.equal(finalRun.status, "completed");
assert.deepEqual(await handlers.get("netcatty:script:dialog-response")({}, {
requestId: dialogRequestId,
value: true,
}), { ok: false });
});
test("stopping scripts clears unawaited dialog requests without unhandled rejections", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const unhandled = [];
let dialogRequestId;
const onUnhandledRejection = (reason) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:dialog-request") {
dialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runPromise = handlers.get("netcatty:script:run")({}, {
scriptId: "stop-unawaited-dialog",
scriptLabel: "Stop unawaited dialog",
sessionId: "session-stop-unawaited-dialog",
content: `
void nct.dialog.confirm('background prompt');
await nct.sleep(5000);
`,
permissionMode: "auto",
});
await waitUntil(() => dialogRequestId);
const runId = await waitUntil(() => (
sentRunUpdates
.flat()
.find((run) => run.scriptId === "stop-unawaited-dialog" && run.status === "running")
?.runId
));
assert.deepEqual(await handlers.get("netcatty:script:stop")({}, { runId }), { ok: true });
await runPromise;
await delay(100);
assert.equal(unhandled.length, 0);
assert.deepEqual(await handlers.get("netcatty:script:dialog-response")({}, {
requestId: dialogRequestId,
value: true,
}), { ok: false });
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
test("stopping scripts clears unawaited screen reads without unhandled rejections", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const snapshotRequestIds = [];
const unhandled = [];
const onUnhandledRejection = (reason) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
snapshotRequestIds.push(payload.requestId);
if (snapshotRequestIds.length === 1) {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
});
});
}
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runPromise = handlers.get("netcatty:script:run")({}, {
scriptId: "stop-unawaited-screen-read",
scriptLabel: "Stop unawaited screen read",
sessionId: "session-stop-unawaited-screen-read",
content: `
void nct.screen.getText();
await nct.sleep(5000);
`,
permissionMode: "auto",
});
await waitUntil(() => snapshotRequestIds[1]);
const runId = await waitUntil(() => (
sentRunUpdates
.flat()
.find((run) => run.scriptId === "stop-unawaited-screen-read" && run.status === "running")
?.runId
));
assert.deepEqual(await handlers.get("netcatty:script:stop")({}, { runId }), { ok: true });
await runPromise;
await delay(100);
assert.equal(unhandled.length, 0);
assert.deepEqual(await handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: snapshotRequestIds[1],
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["late"],
},
}), { ok: false });
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
test("stopping a script releases its session log so the next run can start one", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-stop-log-cleanup";
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-script-log-test-"));
try {
scriptBridge.init({
sessions: new Map([[sessionId, { hostname: "example.test" }]]),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => logDir,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
const firstLogPath = path.join(logDir, "first.log");
const firstRunPromise = runHandler({}, {
scriptId: "stopped-log-run",
scriptLabel: "Stopped log run",
sessionId,
content: `
await nct.session.startLog(${JSON.stringify(firstLogPath)});
try {
await nct.sleep(5000);
} finally {
await nct.session.stopLog();
}
`,
permissionMode: "auto",
});
await waitUntil(() => sessionLogStreamManager.hasStream(sessionId));
const firstRunId = await waitUntil(() => (
sentRunUpdates
.flat()
.find((run) => run.scriptId === "stopped-log-run" && run.status === "running")
?.runId
));
assert.deepEqual(await handlers.get("netcatty:script:stop")({}, { runId: firstRunId }), { ok: true });
await firstRunPromise;
await waitUntil(() => !sessionLogStreamManager.hasStream(sessionId));
const secondLogPath = path.join(logDir, "second.log");
await runHandler({}, {
scriptId: "next-log-run",
scriptLabel: "Next log run",
sessionId,
content: `
await nct.session.startLog(${JSON.stringify(secondLogPath)});
await nct.session.stopLog();
`,
permissionMode: "auto",
});
const secondRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "next-log-run");
assert.equal(secondRun.status, "completed");
assert.equal(sessionLogStreamManager.hasStream(sessionId), false);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(logDir, { recursive: true, force: true });
}
});
test("late stopLog from a stopped script does not close the next run log", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-stale-stop-log";
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-script-stale-log-test-"));
try {
scriptBridge.init({
sessions: new Map([[sessionId, { hostname: "example.test" }]]),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => logDir,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
const firstRunPromise = runHandler({}, {
scriptId: "stale-stop-log-first",
scriptLabel: "Stale stopLog first",
sessionId,
content: `
await nct.session.startLog(${JSON.stringify(path.join(logDir, "first.log"))});
try {
await nct.sleep(5000);
} finally {
await nct.session.stopLog();
}
`,
permissionMode: "auto",
});
await waitUntil(() => sessionLogStreamManager.hasStream(sessionId));
const firstRunId = await waitUntil(() => (
sentRunUpdates
.flat()
.find((run) => run.scriptId === "stale-stop-log-first" && run.status === "running")
?.runId
));
assert.deepEqual(await handlers.get("netcatty:script:stop")({}, { runId: firstRunId }), { ok: true });
await firstRunPromise;
await waitUntil(() => !sessionLogStreamManager.hasStream(sessionId));
const secondRunPromise = runHandler({}, {
scriptId: "stale-stop-log-second",
scriptLabel: "Stale stopLog second",
sessionId,
content: `
await nct.session.startLog(${JSON.stringify(path.join(logDir, "second.log"))});
await nct.sleep(250);
await nct.session.stopLog();
`,
permissionMode: "auto",
});
await waitUntil(() => sessionLogStreamManager.hasStream(sessionId));
await delay(120);
assert.equal(sessionLogStreamManager.hasStream(sessionId), true);
await secondRunPromise;
const secondRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "stale-stop-log-second");
assert.equal(secondRun.status, "completed");
assert.equal(sessionLogStreamManager.hasStream(sessionId), false);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(logDir, { recursive: true, force: true });
}
});
test("script run treats worker-managed sessions as connected", async () => {
const handlers = new Map();
const sentRunUpdates = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: {
hasOpenSession(sessionId) {
return sessionId === "session-worker";
},
addOutputTap() {
return () => {};
},
send() {},
},
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "connected-check",
scriptLabel: "Connected check",
sessionId: "session-worker",
sessionMeta: { connected: true, hostname: "worker-host", username: "root" },
content: `
if (!nct.session.connected) {
throw new Error("Session not connected");
}
nct.log("connected ok");
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "connected-check");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /connected ok/);
});
test("script run uses renderer sessionMeta when main-process session map is empty", async () => {
const handlers = new Map();
const sentRunUpdates = [];
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "renderer-meta",
scriptLabel: "Renderer meta",
sessionId: "session-renderer",
sessionMeta: { connected: true, name: "Production", hostname: "10.0.0.1", username: "root" },
content: `
if (!nct.session.connected) {
throw new Error("Session not connected");
}
nct.log(\`\${nct.session.name}:\${nct.session.hostname}@\${nct.session.username}\`);
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "renderer-meta");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /Production:10\.0\.0\.1@root/);
});
test("script run uses the main-process session label when renderer metadata is absent", async () => {
const handlers = new Map();
const sentRunUpdates = [];
scriptBridge.init({
sessions: new Map([["session-local", {
status: "connected",
label: "Local Terminal",
hostname: "localhost",
username: "local",
}]]),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "local-session-name",
scriptLabel: "Local session name",
sessionId: "session-local",
content: "nct.log(nct.session.name);",
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "local-session-name");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /Local Terminal/);
});
test("script run sends form dialog requests and resolves object responses", async () => {
const handlers = new Map();
const sentRunUpdates = [];
let dialogRequest;
const sessionId = "session-form-dialog";
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["user@host:~$ "],
},
});
});
}
if (channel === "netcatty:script:dialog-request") {
dialogRequest = payload;
setImmediate(() => {
handlers.get("netcatty:script:dialog-response")({}, {
requestId: payload.requestId,
value: { env: "prod", restart: true, mode: "safe" },
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "form-dialog",
scriptLabel: "Form dialog",
sessionId,
content: `
const values = await nct.dialog.form({
title: 'Deploy',
message: 'Choose options',
fields: [
{ type: 'select', name: 'env', label: 'Environment', options: ['dev', 'prod'], defaultValue: 'dev' },
{ type: 'checkbox', name: 'restart', label: 'Restart', defaultValue: false },
{ type: 'radio', name: 'mode', label: 'Mode', options: ['safe', 'fast'], defaultValue: 'fast' },
],
});
nct.log(values.env + ':' + values.restart + ':' + values.mode);
`,
permissionMode: "auto",
});
assert.equal(dialogRequest.type, "form");
assert.equal(dialogRequest.message, "Choose options");
assert.equal(dialogRequest.form.title, "Deploy");
assert.deepEqual(
JSON.parse(JSON.stringify(dialogRequest.form.fields.map((field) => [field.type, field.name, field.defaultValue]))),
[
["select", "env", "dev"],
["checkbox", "restart", false],
["radio", "mode", "fast"],
],
);
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "form-dialog");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /prod:true:safe/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script run treats cancelled form dialogs as script failures", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-form-cancel";
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["user@host:~$ "],
},
});
});
}
if (channel === "netcatty:script:dialog-request") {
setImmediate(() => {
handlers.get("netcatty:script:dialog-response")({}, {
requestId: payload.requestId,
cancelled: true,
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "form-cancel",
scriptLabel: "Form cancel",
sessionId,
content: `
await nct.dialog.form({
message: 'Choose options',
fields: [{ type: 'checkbox', name: 'restart', label: 'Restart' }],
});
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "form-cancel");
assert.equal(finalRun.status, "failed");
assert.equal(finalRun.error, "Dialog cancelled");
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitFor matches text already visible on the startup screen", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-visible-startup-output";
scriptBridge.removeSessionBuffer(sessionId);
// Prior scrollback that has left the viewport must not rematch.
scriptBridge.appendSessionOutput(sessionId, "scrolled-off READY marker\n");
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 1,
lines: [
"visible READY marker",
"user@host:~$ ",
],
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "visible-output",
scriptLabel: "Visible output",
sessionId,
content: `
const value = await nct.screen.waitFor('READY', 1000);
nct.log('matched ' + value);
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "visible-output");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /matched READY/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitFor ignores keywords that have scrolled off the visible screen", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-scrolled-off-startup-output";
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.appendSessionOutput(sessionId, "old deployment READY marker\nuser@host:~$ \n");
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["user@host:~$ "],
},
});
});
}
if (channel === "netcatty:script:dialog-request") {
setImmediate(() => {
handlers.get("netcatty:script:dialog-response")({}, {
requestId: payload.requestId,
value: "abort",
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "scrolled-off-output",
scriptLabel: "Scrolled off output",
sessionId,
content: `
await nct.screen.waitFor('READY', 200);
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "scrolled-off-output");
assert.equal(finalRun.status, "failed");
assert.match(String(finalRun.error || ""), /timed out|stopped/i);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitFor seeds buffer-fallback so connection banners stay waitable (#1960)", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-buffer-fallback-scrollback";
scriptBridge.removeSessionBuffer(sessionId);
// Buffer-fallback must seed the live buffer so bastion menus already
// received stay waitable. Scrolled-off rematch is covered by viewport tests.
scriptBridge.appendSessionOutput(sessionId, "Welcome to SSHD\nSSH资源(5) :\n> \n");
scriptBridge.init({
sessions: new Map([
[sessionId, { webContentsId: 999 }],
]),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "buffer-fallback",
scriptLabel: "Buffer fallback",
sessionId,
content: `
const value = await nct.screen.waitForRegex("SSH资源\\\\s*\\\\(\\\\d+\\\\)\\\\s*:", 1000);
nct.log('matched ' + value);
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "buffer-fallback");
assert.equal(finalRun.status, "completed", finalRun.error || "expected completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /matched SSH资源\(5\) :/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitFor keeps output that arrives during empty/fallback snapshot sync", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-output-during-empty-snapshot";
let snapshotRequestId;
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.appendSessionOutput(sessionId, "old scrollback without keyword\n");
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
snapshotRequestId = payload.requestId;
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runPromise = handlers.get("netcatty:script:run")({}, {
scriptId: "empty-snapshot-race",
scriptLabel: "Empty snapshot race",
sessionId,
content: `
const value = await nct.screen.waitFor('READY', 1000);
nct.log('matched ' + value);
`,
permissionMode: "auto",
});
await delay(20);
assert.ok(snapshotRequestId);
// Live output during the snapshot wait must stay matchable even when the
// snapshot is empty / falls through to the consumed-baseline path.
scriptBridge.appendSessionOutput(sessionId, "fresh READY\n");
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: snapshotRequestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: [],
},
});
await runPromise;
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "empty-snapshot-race");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /matched READY/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitForRegex matches bastion menu already on screen at script start", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-bastion-menu-startup";
// Keep the header near the top and pad past the 512-byte freshness slack so
// the whole seeded viewport must stay waitable (#1960 / Codex review).
const menuLines = [
"Welcome to SSHD",
"",
"user login success",
"",
"SSH资源(5) :",
" [1] [Empty]@192.168.233.11 (联通助理自研NLP01) ",
" [2] [Empty]@192.168.238.34 (192.168.238.34) ",
" [3] [Empty]@192.168.242.11 (192.168.242.11) ",
" [4] [Empty]@192.168.80.18",
" [5] [Empty]@192.168.80.19",
"",
"Telnet资源(0) :",
"",
"Rlogin资源(0) :",
"",
"操作命令:",
"[l] 显示SSH资源列表",
"[i] 显示Telnet资源列表",
"[r] 显示Rlogin资源列表",
"[s] 搜索资源根据IP/name/account/label",
"[k] 显示历史会话列表",
"[x] English",
"[n] encoding UTF8",
"[h] 显示帮助",
"[e] 退出",
...Array.from({ length: 20 }, (_, i) => ` filler line ${i} ${"x".repeat(24)}`),
];
assert.ok(menuLines.join("\n").length > 512);
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.appendSessionOutput(sessionId, `${menuLines.join("\n")}\n`);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 40,
cols: 80,
currentRow: menuLines.length - 1,
lines: menuLines,
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "bastion-menu",
scriptLabel: "Bastion menu",
sessionId,
content: `
const value = await nct.screen.waitForRegex("SSH资源\\\\s*\\\\(\\\\d+\\\\)\\\\s*:", 1000);
nct.log("matched " + value);
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "bastion-menu");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /matched SSH资源\(5\) :/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitForRegex keeps visible menu when BEL arrives during startup snapshot sync", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-bastion-menu-bel-during-sync";
const menuLines = [
"Welcome to SSHD",
"",
"user login success",
"",
"SSH资源(5) :",
" [1] [Empty]@192.168.233.11",
" [2] [Empty]@192.168.238.34",
" [3] [Empty]@192.168.242.11",
...Array.from({ length: 25 }, (_, i) => ` filler ${i} ${"y".repeat(20)}`),
];
assert.ok(menuLines.join("\n").length > 512);
let snapshotRequestId;
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.appendSessionOutput(sessionId, `${menuLines.join("\n")}\n`);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
snapshotRequestId = payload.requestId;
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runPromise = handlers.get("netcatty:script:run")({}, {
scriptId: "bastion-bel",
scriptLabel: "Bastion BEL during sync",
sessionId,
content: `
const value = await nct.screen.waitForRegex("SSH资源\\\\s*\\\\(\\\\d+\\\\)\\\\s*:", 1000);
nct.log("matched " + value);
`,
permissionMode: "auto",
});
await delay(20);
assert.ok(snapshotRequestId);
// Bastion keepalives / BEL can land while the snapshot IPC is in flight.
scriptBridge.appendSessionOutput(sessionId, "\x07");
scriptBridge.appendSessionOutput(sessionId, "\x07");
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: snapshotRequestId,
snapshot: {
rows: 40,
cols: 80,
currentRow: menuLines.length - 1,
lines: menuLines,
},
});
await runPromise;
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "bastion-bel");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /matched SSH资源\(5\) :/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitFor sees output that arrives while startup snapshot sync is pending", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-output-during-startup-sync";
let snapshotRequestId;
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
snapshotRequestId = payload.requestId;
}
if (channel === "netcatty:script:dialog-request") {
setImmediate(() => {
handlers.get("netcatty:script:dialog-response")({}, {
requestId: payload.requestId,
value: "abort",
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runPromise = handlers.get("netcatty:script:run")({}, {
scriptId: "output-during-sync",
scriptLabel: "Output during sync",
sessionId,
content: `
const value = await nct.screen.waitFor('READY', 1000);
nct.log('matched ' + value);
`,
permissionMode: "auto",
});
await delay(20);
assert.ok(snapshotRequestId);
scriptBridge.appendSessionOutput(sessionId, "fresh READY\n");
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: snapshotRequestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["user@host:~$ "],
},
});
await runPromise;
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "output-during-sync");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /matched READY/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script waitForPrompt can still use the current startup prompt", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-startup-prompt";
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession() {},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["root@host:~# "],
},
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "startup-prompt",
scriptLabel: "Startup prompt",
sessionId,
content: `
const index = await nct.screen.waitForPrompt(1000);
nct.log('prompt index ' + index);
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "startup-prompt");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /prompt index/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script sendLine invalidates startup seed before later waits", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-send-invalidates-seed";
const writes = [];
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession(_event, payload) {
writes.push(payload.data);
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["root@host:~# ", "old READY"],
},
});
});
}
if (channel === "netcatty:script:dialog-request") {
setImmediate(() => {
handlers.get("netcatty:script:dialog-response")({}, {
requestId: payload.requestId,
value: "abort",
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runPromise = handlers.get("netcatty:script:run")({}, {
scriptId: "send-invalidates-seed",
scriptLabel: "Send invalidates seed",
sessionId,
content: `
await nct.screen.sendLine('echo hi');
const prompt = await nct.screen.waitForPrompt(1000);
nct.log('prompt ' + prompt);
const value = await nct.screen.waitForText('READY', 1000);
nct.log('matched ' + value);
`,
permissionMode: "auto",
});
await waitUntil(() => writes.some((data) => String(data).includes("echo hi")));
assert.ok(writes.some((data) => String(data).includes("echo hi")));
// Startup seed must be gone: waits should not resolve on old READY / old prompt.
await delay(20);
const midRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "send-invalidates-seed");
assert.equal(midRun.status, "running");
// Prompt first, then post-command READY — matching the common send-then-wait flow.
scriptBridge.appendSessionOutput(sessionId, "\necho hi\nroot@host:~# \nfresh READY\n");
await runPromise;
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "send-invalidates-seed");
assert.equal(finalRun.status, "completed");
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /prompt 0/);
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /matched READY/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("script sendLine keeps prompts that arrive between body and CR waitable", async () => {
const handlers = new Map();
const sentRunUpdates = [];
const sessionId = "session-sendline-gap-prompt";
const writes = [];
scriptBridge.removeSessionBuffer(sessionId);
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: {
getVersion: () => "test",
getPath: () => process.cwd(),
},
webContents: {
fromId: () => null,
},
},
terminalBridge: {
writeToSession(_event, payload) {
writes.push(payload.data);
if (payload.data === "3") {
setTimeout(() => {
scriptBridge.appendSessionOutput(sessionId, "\n资源'[Empty]'账户:");
}, 5);
}
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
id: 1,
send(channel, payload) {
if (channel === "netcatty:script:runs-updated") {
sentRunUpdates.push(payload.runs);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: {
rows: 24,
cols: 80,
currentRow: 0,
lines: ["SSH资源(5) :", "> "],
},
});
});
}
if (channel === "netcatty:script:dialog-request") {
setImmediate(() => {
handlers.get("netcatty:script:dialog-response")({}, {
requestId: payload.requestId,
value: "abort",
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
await handlers.get("netcatty:script:run")({}, {
scriptId: "sendline-gap",
scriptLabel: "SendLine gap prompt",
sessionId,
content: `
await nct.screen.waitForRegex("SSH资源\\\\s*\\\\(\\\\d+\\\\)\\\\s*:", 1000);
await nct.screen.sendLine("3");
await nct.screen.waitForText("资源'[Empty]'账户:", 1000);
await nct.screen.sendLine("zxadmin");
nct.log("account sent");
`,
permissionMode: "auto",
});
const finalRun = sentRunUpdates.at(-1).find((run) => run.scriptId === "sendline-gap");
assert.equal(finalRun.status, "completed", finalRun.error || "expected completed");
assert.deepEqual(writes.filter((w) => w === "3" || w === "\r" || w === "zxadmin"), [
"3",
"\r",
"zxadmin",
"\r",
]);
assert.match(finalRun.logs.map((entry) => entry.message).join("\n"), /account sent/);
scriptBridge.removeSessionBuffer(sessionId);
});
test("resolveStartupSeedText prefers buffer when viewport paint lags bastion menu", () => {
const buffer =
"Welcome to SSHD\n\nuser login success\n\nSSH资源(5) :\n [1] host\n";
const laggingViewport = "Welcome to SSHD\n\nuser login success\n";
const seed = scriptBridge.resolveStartupSeedText(laggingViewport, buffer);
assert.match(seed, /SSH资源\(5\)/);
assert.equal(scriptBridge.resolveStartupSeedText("", buffer), buffer);
});
test("resolveStartupSeedText prefers viewport when buffer only adds scrolled-off prefix", () => {
const viewport = "SSH资源(5) :\n [1] host\n";
const buffer = `old deployment READY marker\nuser@host:~$ \n${viewport}`;
const seed = scriptBridge.resolveStartupSeedText(viewport, buffer);
assert.equal(seed, viewport);
assert.doesNotMatch(seed, /READY marker/);
});
test("script recording stops explicitly when the retained step limit is reached", async () => {
const handlers = new Map();
scriptBridge.init({
sessions: new Map(),
electronModule: {},
terminalBridge: {},
getMainWindow: () => null,
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const sessionId = "recording-step-budget";
await handlers.get("netcatty:script:recording:start")({}, { sessionId });
let result;
for (let index = 0; index < 10_000; index += 1) {
result = await handlers.get("netcatty:script:recording:append-step")({}, {
sessionId,
step: { type: "sleep", value: index },
});
}
assert.equal(result.stopped, true);
assert.match(result.error, /safety limit/i);
assert.equal(result.steps.length, 10_000);
assert.match(result.code, /sleep/);
assert.deepEqual(
await handlers.get("netcatty:script:recording:append-step")({}, {
sessionId,
step: { type: "sleep", value: 10_001 },
}),
{ ok: false, error: "Recording not started" },
);
});
test("script recording rejects one step beyond the total byte budget without retaining it", async () => {
const handlers = new Map();
scriptBridge.init({
sessions: new Map(),
electronModule: {},
terminalBridge: {},
getMainWindow: () => null,
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const sessionId = "recording-byte-budget";
await handlers.get("netcatty:script:recording:start")({}, { sessionId });
const result = await handlers.get("netcatty:script:recording:append-step")({}, {
sessionId,
step: { type: "send", value: "x".repeat(2 * 1024 * 1024 + 1) },
});
assert.equal(result.stopped, true);
assert.match(result.error, /safety limit/i);
assert.deepEqual(result.steps, []);
assert.equal(result.code, "");
});