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
2404 lines
71 KiB
JavaScript
2404 lines
71 KiB
JavaScript
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, "");
|
||
});
|