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, ""); });