[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
41
electron/scripts/scriptCodegen.cjs
Normal file
41
electron/scripts/scriptCodegen.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
|
||||
const HEADER = "// Auto-generated by Netcatty Script Recorder\n";
|
||||
|
||||
function escapeJsString(value) {
|
||||
return JSON.stringify(String(value ?? ""));
|
||||
}
|
||||
|
||||
function stepsToJavaScript(steps, recordedAt) {
|
||||
const lines = [
|
||||
HEADER.trimEnd(),
|
||||
recordedAt ? `// Recorded on ${recordedAt}` : "",
|
||||
"",
|
||||
"async function main() {",
|
||||
].filter(Boolean);
|
||||
|
||||
for (let index = 0; index < steps.length; index += 1) {
|
||||
const step = steps[index];
|
||||
if (step.type === "send") {
|
||||
if (step.sensitive) {
|
||||
lines.push(` const sensitiveValue${index} = await nct.dialog.prompt("Enter sensitive value", "", { sensitive: true });`);
|
||||
lines.push(` await nct.screen.sendLine(sensitiveValue${index}, { sensitive: true });`);
|
||||
continue;
|
||||
}
|
||||
lines.push(` await nct.screen.sendLine(${escapeJsString(step.value)});`);
|
||||
} else if (step.type === "waitFor") {
|
||||
lines.push(` await nct.screen.waitForText(${escapeJsString(step.value)}, ${step.timeoutMs || 5000});`);
|
||||
} else if (step.type === "waitForPrompt") {
|
||||
lines.push(` await nct.screen.waitForPrompt(${step.timeoutMs || 30000});`);
|
||||
} else if (step.type === "sleep") {
|
||||
lines.push(` await nct.session.sleep(${Number(step.value) || 1000});`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("}", "", "await main();");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
stepsToJavaScript,
|
||||
};
|
||||
214
electron/scripts/scriptExecutionWorker.cjs
Normal file
214
electron/scripts/scriptExecutionWorker.cjs
Normal file
@@ -0,0 +1,214 @@
|
||||
"use strict";
|
||||
|
||||
const vm = require("node:vm");
|
||||
const { parentPort } = require("node:worker_threads");
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error("Script execution worker requires a parent port");
|
||||
}
|
||||
|
||||
const pendingRequests = new Map();
|
||||
let nextRequestId = 1;
|
||||
let finished = false;
|
||||
let started = false;
|
||||
let heartbeat = null;
|
||||
let heartbeatTimer = null;
|
||||
let runtimeSnapshot = null;
|
||||
let nct = null;
|
||||
let maxPendingHostRequests = 128;
|
||||
let maxLogNotifications = 512;
|
||||
let maxTotalNotifications = 20_000;
|
||||
let logNotificationCount = 0;
|
||||
let totalNotificationCount = 0;
|
||||
|
||||
function beat() {
|
||||
if (heartbeat) Atomics.store(heartbeat, 0, BigInt(Date.now()));
|
||||
}
|
||||
|
||||
function serializeError(error) {
|
||||
return {
|
||||
name: error?.name || "Error",
|
||||
message: error?.message || String(error),
|
||||
stack: typeof error?.stack === "string" ? error.stack : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function reviveError(value) {
|
||||
const error = new Error(value?.message || "Script API request failed");
|
||||
error.name = value?.name || "Error";
|
||||
if (typeof value?.stack === "string") error.stack = value.stack;
|
||||
return error;
|
||||
}
|
||||
|
||||
function updateSnapshot(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== "object") return;
|
||||
if (snapshot.session && typeof snapshot.session === "object") {
|
||||
Object.assign(runtimeSnapshot.session, snapshot.session);
|
||||
}
|
||||
if (snapshot.screen && typeof snapshot.screen === "object") {
|
||||
Object.assign(runtimeSnapshot.screen, snapshot.screen);
|
||||
}
|
||||
}
|
||||
|
||||
function callHost(method, args = []) {
|
||||
if (finished) return Promise.reject(new Error("Script execution finished"));
|
||||
if (pendingRequests.size >= maxPendingHostRequests) {
|
||||
throw new Error(
|
||||
`Script exceeded the ${maxPendingHostRequests} pending host request limit`,
|
||||
);
|
||||
}
|
||||
const requestId = nextRequestId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
parentPort.postMessage({ type: "rpc", requestId, method, args });
|
||||
});
|
||||
}
|
||||
|
||||
function notifyHost(method, args = []) {
|
||||
if (finished) return;
|
||||
totalNotificationCount += 1;
|
||||
if (totalNotificationCount > maxTotalNotifications) {
|
||||
throw new Error(`Script exceeded the ${maxTotalNotifications} notification limit`);
|
||||
}
|
||||
if (method === "log" || method === "console.log") {
|
||||
logNotificationCount += 1;
|
||||
if (logNotificationCount > maxLogNotifications) {
|
||||
throw new Error(`Script exceeded the ${maxLogNotifications} log notification limit`);
|
||||
}
|
||||
}
|
||||
parentPort.postMessage({ type: "notify", method, args });
|
||||
}
|
||||
|
||||
const sessionApi = {
|
||||
get connected() { return runtimeSnapshot.session.connected; },
|
||||
get name() { return runtimeSnapshot.session.name; },
|
||||
get hostname() { return runtimeSnapshot.session.hostname; },
|
||||
get username() { return runtimeSnapshot.session.username; },
|
||||
sleep: (ms) => callHost("session.sleep", [ms]),
|
||||
startLog: (path) => callHost("session.startLog", [path == null ? path : String(path)]),
|
||||
stopLog: () => callHost("session.stopLog"),
|
||||
disconnect: () => callHost("session.disconnect"),
|
||||
};
|
||||
|
||||
const screenApi = {
|
||||
send: (text, options) => callHost("screen.send", [
|
||||
String(text ?? ""),
|
||||
{ sensitive: options?.sensitive === true },
|
||||
]),
|
||||
sendLine: (text, options) => callHost("screen.sendLine", [
|
||||
String(text ?? ""),
|
||||
{ sensitive: options?.sensitive === true },
|
||||
]),
|
||||
waitFor: (pattern, timeoutMs) => callHost("screen.waitFor", [pattern, timeoutMs]),
|
||||
waitForText: (text, timeoutMs) => callHost("screen.waitForText", [text, timeoutMs]),
|
||||
waitForRegex: (pattern, timeoutMs) => callHost("screen.waitForRegex", [pattern, timeoutMs]),
|
||||
waitForPrompt: (timeoutMs) => callHost("screen.waitForPrompt", [timeoutMs]),
|
||||
waitForAny: (patterns, timeoutMs) => callHost("screen.waitForAny", [patterns, timeoutMs]),
|
||||
getText: (startRow, endRow) => callHost("screen.getText", [startRow, endRow]),
|
||||
get currentRow() { return runtimeSnapshot.screen.currentRow; },
|
||||
get rows() { return runtimeSnapshot.screen.rows; },
|
||||
get cols() { return runtimeSnapshot.screen.cols; },
|
||||
clear: () => callHost("screen.clear"),
|
||||
};
|
||||
|
||||
const dialogApi = {
|
||||
alert: (message) => callHost("dialog.alert", [String(message ?? "")]),
|
||||
confirm: (message) => callHost("dialog.confirm", [String(message ?? "")]),
|
||||
prompt: (message, defaultValue, options) => callHost("dialog.prompt", [
|
||||
String(message ?? ""),
|
||||
String(defaultValue ?? ""),
|
||||
{ sensitive: options?.sensitive === true },
|
||||
]),
|
||||
form: (spec) => callHost("dialog.form", [spec]),
|
||||
select: (message, options, defaultValue) => callHost("dialog.select", [message, options, defaultValue]),
|
||||
radio: (message, options, defaultValue) => callHost("dialog.radio", [message, options, defaultValue]),
|
||||
checkbox: (message, defaultChecked) => callHost("dialog.checkbox", [message, defaultChecked]),
|
||||
};
|
||||
|
||||
const progressApi = {
|
||||
start(label, total) { notifyHost("progress.start", [label, total]); },
|
||||
set(current, detail) { notifyHost("progress.set", [current, detail]); },
|
||||
step(detail) { notifyHost("progress.step", [detail]); },
|
||||
done() { notifyHost("progress.done"); },
|
||||
};
|
||||
|
||||
parentPort.on("message", (message) => {
|
||||
if (message?.type === "start") {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void run(message.config);
|
||||
return;
|
||||
}
|
||||
if (message?.type === "snapshot") {
|
||||
updateSnapshot(message.snapshot);
|
||||
return;
|
||||
}
|
||||
if (message?.type !== "rpc-result") return;
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (!pending) return;
|
||||
pendingRequests.delete(message.requestId);
|
||||
updateSnapshot(message.snapshot);
|
||||
if (message.ok) pending.resolve(message.value);
|
||||
else pending.reject(reviveError(message.error));
|
||||
});
|
||||
|
||||
async function run(config) {
|
||||
maxPendingHostRequests = Math.max(1, Number(config.maxPendingHostRequests) || 128);
|
||||
maxLogNotifications = Math.max(1, Number(config.maxLogNotifications) || 512);
|
||||
maxTotalNotifications = Math.max(
|
||||
maxLogNotifications,
|
||||
Number(config.maxTotalNotifications) || 20_000,
|
||||
);
|
||||
heartbeat = new BigInt64Array(config.heartbeatBuffer);
|
||||
const heartbeatIntervalMs = Math.max(2, Number(config.heartbeatIntervalMs) || 10);
|
||||
runtimeSnapshot = {
|
||||
session: {
|
||||
connected: Boolean(config.snapshot?.session?.connected),
|
||||
name: String(config.snapshot?.session?.name || ""),
|
||||
hostname: String(config.snapshot?.session?.hostname || ""),
|
||||
username: String(config.snapshot?.session?.username || ""),
|
||||
},
|
||||
screen: {
|
||||
currentRow: Number(config.snapshot?.screen?.currentRow) || 0,
|
||||
rows: Number(config.snapshot?.screen?.rows) || 24,
|
||||
cols: Number(config.snapshot?.screen?.cols) || 80,
|
||||
},
|
||||
};
|
||||
nct = {
|
||||
session: sessionApi,
|
||||
screen: screenApi,
|
||||
dialog: dialogApi,
|
||||
progress: progressApi,
|
||||
version: String(config.version || "0.0.0"),
|
||||
sleep: sessionApi.sleep,
|
||||
log(message) { notifyHost("log", [String(message ?? "")]); },
|
||||
};
|
||||
heartbeatTimer = setInterval(beat, heartbeatIntervalMs);
|
||||
beat();
|
||||
try {
|
||||
const sandbox = {
|
||||
nct,
|
||||
SharedArrayBuffer: undefined,
|
||||
console: {
|
||||
log: (...args) => notifyHost("console.log", [args.map((arg) => String(arg)).join(" ")]),
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox, {
|
||||
codeGeneration: { strings: false, wasm: false },
|
||||
});
|
||||
const script = new vm.Script(config.source, {
|
||||
filename: config.filename || "netcatty-script.js",
|
||||
});
|
||||
const result = script.runInContext(sandbox, { displayErrors: true });
|
||||
if (result && typeof result.then === "function") await result;
|
||||
finished = true;
|
||||
clearInterval(heartbeatTimer);
|
||||
parentPort.postMessage({ type: "completed" });
|
||||
} catch (error) {
|
||||
finished = true;
|
||||
clearInterval(heartbeatTimer);
|
||||
parentPort.postMessage({ type: "failed", error: serializeError(error) });
|
||||
}
|
||||
}
|
||||
|
||||
parentPort.postMessage({ type: "ready" });
|
||||
34
electron/scripts/scriptRunRetention.cjs
Normal file
34
electron/scripts/scriptRunRetention.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
|
||||
const MAX_RETAINED_COMPLETED_RUNS = 200;
|
||||
const MAX_RETAINED_LOGS_PER_RUN = 200;
|
||||
|
||||
function appendRetainedRunLog(run, entry, limit = MAX_RETAINED_LOGS_PER_RUN) {
|
||||
if (!run || !Array.isArray(run.logs)) return;
|
||||
run.logs.push(entry);
|
||||
const overflow = run.logs.length - limit;
|
||||
if (overflow > 0) run.logs.splice(0, overflow);
|
||||
}
|
||||
|
||||
function pruneCompletedRuns(runs, limit = MAX_RETAINED_COMPLETED_RUNS) {
|
||||
if (!(runs instanceof Map)) return 0;
|
||||
const completed = [...runs.values()]
|
||||
.filter((run) => Number.isFinite(run?.endedAt))
|
||||
.sort((left, right) => (
|
||||
left.endedAt - right.endedAt
|
||||
|| left.startedAt - right.startedAt
|
||||
|| String(left.runId).localeCompare(String(right.runId))
|
||||
));
|
||||
const overflow = completed.length - limit;
|
||||
for (let index = 0; index < overflow; index += 1) {
|
||||
runs.delete(completed[index].runId);
|
||||
}
|
||||
return Math.max(0, overflow);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_RETAINED_COMPLETED_RUNS,
|
||||
MAX_RETAINED_LOGS_PER_RUN,
|
||||
appendRetainedRunLog,
|
||||
pruneCompletedRuns,
|
||||
};
|
||||
39
electron/scripts/scriptRunRetention.test.cjs
Normal file
39
electron/scripts/scriptRunRetention.test.cjs
Normal file
@@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
MAX_RETAINED_COMPLETED_RUNS,
|
||||
MAX_RETAINED_LOGS_PER_RUN,
|
||||
appendRetainedRunLog,
|
||||
pruneCompletedRuns,
|
||||
} = require("./scriptRunRetention.cjs");
|
||||
|
||||
test("completed script history is bounded without removing active runs", () => {
|
||||
const runs = new Map();
|
||||
for (let index = 0; index < MAX_RETAINED_COMPLETED_RUNS + 25; index += 1) {
|
||||
runs.set(`completed-${index}`, {
|
||||
runId: `completed-${index}`,
|
||||
startedAt: index,
|
||||
endedAt: index + 1,
|
||||
});
|
||||
}
|
||||
runs.set("active", { runId: "active", startedAt: 0 });
|
||||
|
||||
assert.equal(pruneCompletedRuns(runs), 25);
|
||||
assert.equal(runs.size, MAX_RETAINED_COMPLETED_RUNS + 1);
|
||||
assert.equal(runs.has("active"), true);
|
||||
assert.equal(runs.has("completed-0"), false);
|
||||
assert.equal(runs.has(`completed-${MAX_RETAINED_COMPLETED_RUNS + 24}`), true);
|
||||
});
|
||||
|
||||
test("script logs keep only the newest bounded tail", () => {
|
||||
const run = { logs: [] };
|
||||
for (let index = 0; index < MAX_RETAINED_LOGS_PER_RUN + 30; index += 1) {
|
||||
appendRetainedRunLog(run, { message: `log-${index}` });
|
||||
}
|
||||
|
||||
assert.equal(run.logs.length, MAX_RETAINED_LOGS_PER_RUN);
|
||||
assert.equal(run.logs[0].message, "log-30");
|
||||
assert.equal(run.logs.at(-1).message, `log-${MAX_RETAINED_LOGS_PER_RUN + 29}`);
|
||||
});
|
||||
1324
electron/scripts/scriptRuntime.cjs
Normal file
1324
electron/scripts/scriptRuntime.cjs
Normal file
File diff suppressed because it is too large
Load Diff
811
electron/scripts/scriptRuntime.test.cjs
Normal file
811
electron/scripts/scriptRuntime.test.cjs
Normal file
@@ -0,0 +1,811 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
createScriptRuntime,
|
||||
SCRIPT_WORKER_MAX_PENDING_HOST_REQUESTS,
|
||||
SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS,
|
||||
SCRIPT_WORKER_MAX_TOTAL_NOTIFICATIONS,
|
||||
SCRIPT_WORKER_IMMEDIATE_PROGRESS_NOTIFICATIONS,
|
||||
wrapScriptSource,
|
||||
interruptibleSleep,
|
||||
normalizeDialogFormSpec,
|
||||
_getActiveScriptWorkerCountForTests,
|
||||
_getActiveScriptHostRequestCountForTests,
|
||||
} = require("./scriptRuntime.cjs");
|
||||
const { SessionOutputBuffer } = require("./sessionOutputBuffer.cjs");
|
||||
|
||||
test("wrapScriptSource wraps async main scripts in async IIFE", () => {
|
||||
const wrapped = wrapScriptSource(`
|
||||
// generated
|
||||
async function main() {
|
||||
await nct.log('hi');
|
||||
}
|
||||
|
||||
await main();
|
||||
`);
|
||||
assert.match(wrapped, /^\(async \(\) => \{/);
|
||||
assert.match(wrapped, /await main\(\);\n\}\)\(\);$/);
|
||||
});
|
||||
|
||||
test("wrapScriptSource wraps bare statements in async IIFE", () => {
|
||||
const wrapped = wrapScriptSource("await nct.log('hi');");
|
||||
assert.match(wrapped, /async \(\) =>/);
|
||||
});
|
||||
|
||||
test("interruptibleSleep rejects when aborted", async () => {
|
||||
let aborted = false;
|
||||
const pending = interruptibleSleep(5000, () => aborted);
|
||||
setTimeout(() => {
|
||||
aborted = true;
|
||||
}, 50);
|
||||
await assert.rejects(pending, /Script stopped/);
|
||||
});
|
||||
|
||||
test("createScriptRuntime executes async main script", async () => {
|
||||
const logs = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: (_id, message) => logs.push(message),
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({
|
||||
waitFor: async () => "ok",
|
||||
waitForAny: async () => 0,
|
||||
getText: () => "",
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
await runtime.execute(`
|
||||
async function main() {
|
||||
nct.log('from-main');
|
||||
}
|
||||
|
||||
await main();
|
||||
`);
|
||||
assert.deepEqual(logs, ["from-main"]);
|
||||
});
|
||||
|
||||
test("createScriptRuntime exposes the session name", async () => {
|
||||
const logs = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: (_id, message) => logs.push(message),
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({
|
||||
waitFor: async () => "ok",
|
||||
waitForAny: async () => 0,
|
||||
getText: () => "",
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, name: "Production", hostname: "host", username: "user" }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
await runtime.execute("nct.log(nct.session.name);");
|
||||
assert.deepEqual(logs, ["Production"]);
|
||||
});
|
||||
|
||||
test("createScriptRuntime releases its isolated worker after normal completion", async () => {
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-bounded-sync",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
await runtime.execute("void 0;");
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime stops a synchronous infinite loop", async () => {
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-infinite-loop",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
syncExecutionTimeoutMs: 20,
|
||||
});
|
||||
|
||||
await assert.rejects(runtime.execute("while (true) {}"), /timed out/i);
|
||||
});
|
||||
|
||||
test("createScriptRuntime stop forcibly terminates a blocked worker", async () => {
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-explicit-stop",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
syncExecutionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
const execution = runtime.execute(`
|
||||
await nct.sleep(1);
|
||||
/^(a+)+$/.test('a'.repeat(30) + '!');
|
||||
`);
|
||||
setTimeout(() => runtime.stop(new Error("Stopped by user")), 50);
|
||||
await assert.rejects(execution, /stopped by user/i);
|
||||
assert.ok(Date.now() - startedAt < 1_000, "explicit stop did not terminate the worker promptly");
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime enforces the worker deadline across promise continuations", async () => {
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-post-await-infinite-loop",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
syncExecutionTimeoutMs: 20,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
runtime.execute("await Promise.resolve(); while (true) {}"),
|
||||
/timed out/i,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.execute("await nct.sleep(1); while (true) {}"),
|
||||
/timed out/i,
|
||||
);
|
||||
await assert.rejects(
|
||||
runtime.execute(`
|
||||
await nct.sleep(1);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
|
||||
`),
|
||||
/blocking atomics|sharedarraybuffer/i,
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
await assert.rejects(
|
||||
runtime.execute(`
|
||||
await nct.sleep(1);
|
||||
/^(a+)+$/.test('a'.repeat(28) + '!');
|
||||
`),
|
||||
/timed out/i,
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 1_000, "catastrophic regexp was not terminated promptly");
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime drains normal host-backed promise continuations", async () => {
|
||||
const logs = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-post-await-normal",
|
||||
appendLog: (_id, message) => logs.push(message),
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
syncExecutionTimeoutMs: 20,
|
||||
});
|
||||
|
||||
await runtime.execute(`
|
||||
await nct.sleep(1);
|
||||
nct.log('first');
|
||||
await Promise.resolve();
|
||||
await nct.sleep(1);
|
||||
nct.log(nct.session.connected ? 'second' : 'disconnected');
|
||||
`);
|
||||
assert.deepEqual(logs, ["first", "second"]);
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime heartbeat remains healthy while a host request is paused", async () => {
|
||||
let paused = true;
|
||||
const writes = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-paused-host-request",
|
||||
appendLog: () => {},
|
||||
writeToSession: (_sessionId, data) => writes.push(data),
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => paused,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
syncExecutionTimeoutMs: 250,
|
||||
});
|
||||
|
||||
const execution = runtime.execute("await nct.screen.send('after-pause');");
|
||||
setTimeout(() => { paused = false; }, 400);
|
||||
await execution;
|
||||
assert.deepEqual(writes, ["after-pause"]);
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime terminates unawaited host requests at the worker boundary", async () => {
|
||||
let finishDialog;
|
||||
let disconnectCalls = 0;
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-unawaited-request",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: () => new Promise((resolve) => { finishDialog = resolve; }),
|
||||
disconnectSession: async () => { disconnectCalls += 1; },
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
await runtime.execute(`
|
||||
void nct.dialog.confirm('background').then(() => nct.session.disconnect());
|
||||
nct.log('done');
|
||||
`);
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
const cleanupStartedAt = Date.now();
|
||||
while (_getActiveScriptHostRequestCountForTests() > 0 && Date.now() - cleanupStartedAt < 500) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(_getActiveScriptHostRequestCountForTests(), 0);
|
||||
finishDialog(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(disconnectCalls, 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime terminates a 20k unawaited host-request fan-out at the pending limit", async () => {
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-host-request-fanout",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
syncExecutionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
await assert.rejects(
|
||||
runtime.execute("for (let i = 0; i < 20000; i += 1) void nct.sleep(60000);"),
|
||||
new RegExp(`${SCRIPT_WORKER_MAX_PENDING_HOST_REQUESTS} pending host request limit`, "i"),
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 1_000, "fan-out was not stopped promptly");
|
||||
assert.equal(_getActiveScriptHostRequestCountForTests(), 0);
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime still permits normal sequential host requests", async () => {
|
||||
const logs = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-sequential-host-requests",
|
||||
appendLog: (_id, message) => logs.push(message),
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
await runtime.execute(`
|
||||
for (let i = 0; i < 5; i += 1) await nct.sleep(0);
|
||||
nct.log('done');
|
||||
`);
|
||||
assert.deepEqual(logs, ["done"]);
|
||||
assert.equal(_getActiveScriptHostRequestCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime bounds a 200k log notification flood", async () => {
|
||||
let deliveredLogs = 0;
|
||||
let statusUpdates = 0;
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-log-flood",
|
||||
appendLog: () => { deliveredLogs += 1; },
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => { statusUpdates += 1; },
|
||||
syncExecutionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
await assert.rejects(
|
||||
runtime.execute("for (let i = 0; i < 200000; i += 1) nct.log('x');"),
|
||||
new RegExp(`${SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS} log notification limit`, "i"),
|
||||
);
|
||||
assert.ok(Date.now() - startedAt < 1_000);
|
||||
assert.equal(deliveredLogs, SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS);
|
||||
assert.equal(statusUpdates, SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS);
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("createScriptRuntime coalesces large progress floods before the total budget", async () => {
|
||||
let statusUpdates = 0;
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-progress-flood",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({ getText: () => "" }),
|
||||
getSessionMeta: () => ({ connected: true }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => { statusUpdates += 1; },
|
||||
syncExecutionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
runtime.execute(`
|
||||
nct.progress.start('many', 200000);
|
||||
for (let i = 0; i < 200000; i += 1) nct.progress.step('item ' + i);
|
||||
`),
|
||||
new RegExp(`${SCRIPT_WORKER_MAX_TOTAL_NOTIFICATIONS} notification limit`, "i"),
|
||||
);
|
||||
assert.ok(
|
||||
statusUpdates <= SCRIPT_WORKER_IMMEDIATE_PROGRESS_NOTIFICATIONS + 4,
|
||||
`progress broadcast count was not coalesced: ${statusUpdates}`,
|
||||
);
|
||||
assert.equal(_getActiveScriptWorkerCountForTests(), 0);
|
||||
});
|
||||
|
||||
test("sensitive script input is masked in UI and logs and remains host-bypassed", async () => {
|
||||
const logs = [];
|
||||
const writes = [];
|
||||
const dialogs = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r-sensitive",
|
||||
appendLog: (_id, message) => logs.push(message),
|
||||
writeToSession: (sessionId, data, options) => writes.push({ sessionId, data, options }),
|
||||
getOutputBuffer: () => ({
|
||||
getText: () => "",
|
||||
consumeThroughAbsolute() {},
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async (...args) => {
|
||||
dialogs.push(args);
|
||||
return "super-secret";
|
||||
},
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
await runtime.execute(`
|
||||
const value = await nct.dialog.prompt("Secret", "", { sensitive: true });
|
||||
await nct.screen.sendLine(value, { sensitive: true });
|
||||
`);
|
||||
|
||||
assert.deepEqual(dialogs[0], ["prompt", "Secret", "", { sensitive: true }]);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
sessionId: "s1",
|
||||
data: "super-secret",
|
||||
options: { automated: true, sensitive: true, invalidateStartupSeed: false },
|
||||
},
|
||||
{
|
||||
sessionId: "s1",
|
||||
data: "\r",
|
||||
options: { automated: true, sensitive: true, invalidateStartupSeed: false },
|
||||
},
|
||||
]);
|
||||
assert.equal(logs.some((entry) => entry.includes("super-secret")), false);
|
||||
assert.equal(logs.some((entry) => entry.includes("[sensitive]")), true);
|
||||
});
|
||||
|
||||
test("createScriptRuntime supports regex waits over multiline output", async () => {
|
||||
const logs = [];
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: (_id, message) => logs.push(message),
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => buffer,
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
const run = runtime.execute(`
|
||||
await nct.screen.waitForRegex(".*SSH资源.*登录方式.*", 1000);
|
||||
nct.log("matched");
|
||||
`);
|
||||
buffer.append("1. SSH资源\n请选择SSH资源\n'zxadmin'登录方式:");
|
||||
|
||||
await run;
|
||||
assert.deepEqual(logs, ["matched"]);
|
||||
});
|
||||
|
||||
test("createScriptRuntime reports activity labels for loops without X/Y totals", async () => {
|
||||
const statuses = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({
|
||||
waitFor: async () => "ok",
|
||||
waitForAny: async () => 0,
|
||||
getText: () => "",
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: (_id, patch) => statuses.push(patch),
|
||||
});
|
||||
|
||||
await runtime.execute("for (let i = 0; i < 3; i += 1) { nct.log(`step ${i}`); }");
|
||||
|
||||
const last = statuses.at(-1);
|
||||
assert.equal(last.stepIndex, 3);
|
||||
assert.equal(last.activityLabel, "log");
|
||||
assert.equal(last.progressMode, "activity");
|
||||
assert.equal(last.totalSteps, undefined);
|
||||
assert.equal(last.currentStep, "log");
|
||||
});
|
||||
|
||||
test("createScriptRuntime supports explicit determinate progress API", async () => {
|
||||
const statuses = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({
|
||||
waitFor: async () => "ok",
|
||||
waitForAny: async () => 0,
|
||||
getText: () => "",
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async () => true,
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: (_id, patch) => statuses.push(patch),
|
||||
});
|
||||
|
||||
await runtime.execute(`
|
||||
nct.progress.start('Sampling', 3);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
nct.progress.step('item ' + i);
|
||||
}
|
||||
nct.progress.done();
|
||||
nct.log('finished');
|
||||
`);
|
||||
|
||||
const during = statuses.find((patch) => patch.progressMode === "determinate" && patch.progressCurrent === 2);
|
||||
assert.ok(during);
|
||||
assert.equal(during.progressLabel, "Sampling");
|
||||
assert.equal(during.progressTotal, 3);
|
||||
assert.equal(during.activityLabel, "item 1");
|
||||
|
||||
const afterDone = statuses.filter((patch) => patch.progressMode === "activity").at(-1);
|
||||
assert.ok(afterDone);
|
||||
assert.equal(afterDone.progressCurrent, undefined);
|
||||
assert.equal(afterDone.progressTotal, undefined);
|
||||
});
|
||||
|
||||
test("normalizeDialogFormSpec normalizes fields and default choice values", () => {
|
||||
const form = normalizeDialogFormSpec({
|
||||
title: "Deploy",
|
||||
message: "Choose options",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "env",
|
||||
label: "Environment",
|
||||
options: [
|
||||
{ label: "Prod", value: "prod", disabled: true },
|
||||
"dev",
|
||||
],
|
||||
defaultValue: "prod",
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "restart",
|
||||
label: "Restart",
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
name: "mode",
|
||||
label: "Mode",
|
||||
options: [{ label: "Safe", value: "safe", description: "Recommended" }],
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
name: "notes",
|
||||
label: "Notes",
|
||||
defaultValue: 123,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "retries",
|
||||
label: "Retries",
|
||||
defaultValue: "3",
|
||||
min: "0",
|
||||
step: "1",
|
||||
visibleWhen: { field: "restart", equals: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(form.title, "Deploy");
|
||||
assert.equal(form.message, "Choose options");
|
||||
assert.equal(form.fields[0].defaultValue, "dev");
|
||||
assert.deepEqual(form.fields[0].options[1], {
|
||||
label: "dev",
|
||||
value: "dev",
|
||||
description: undefined,
|
||||
disabled: false,
|
||||
});
|
||||
assert.equal(form.fields[1].defaultValue, true);
|
||||
assert.equal(form.fields[1].required, false);
|
||||
assert.equal(form.fields[2].defaultValue, "safe");
|
||||
assert.equal(form.fields[3].defaultValue, "123");
|
||||
assert.equal(form.fields[3].required, false);
|
||||
assert.equal(form.fields[4].defaultValue, 3);
|
||||
assert.equal(form.fields[4].min, 0);
|
||||
assert.equal(form.fields[4].step, 1);
|
||||
assert.deepEqual(form.fields[4].visibleWhen, { field: "restart", equals: true });
|
||||
});
|
||||
|
||||
test("normalizeDialogFormSpec rejects invalid fields", () => {
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({ fields: [{ type: "checkbox", name: "", label: "Missing name" }] }),
|
||||
/field name is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [
|
||||
{ type: "checkbox", name: "same", label: "One" },
|
||||
{ type: "checkbox", name: "same", label: "Two" },
|
||||
],
|
||||
}),
|
||||
/Duplicate dialog form field name: same/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({ fields: [{ type: "checkbox", name: "__proto__", label: "Reserved" }] }),
|
||||
/field name is reserved: __proto__/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({ fields: [{ type: "select", name: "env", label: "Env", options: [] }] }),
|
||||
/requires at least one option/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({ fields: [{ type: "select", name: "env", label: "Env", options: [""] }] }),
|
||||
/option value is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({ fields: [{ type: "select", name: "env", label: "Env", options: ["dev", { label: "Dev again", value: "dev" }] }] }),
|
||||
/option values must be unique: dev/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [{
|
||||
type: "radio",
|
||||
name: "mode",
|
||||
label: "Mode",
|
||||
options: [{ label: "Safe", value: "safe", disabled: true }],
|
||||
}],
|
||||
}),
|
||||
/requires at least one enabled option/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [{ type: "number", name: "count", label: "Count", defaultValue: "many" }],
|
||||
}),
|
||||
/defaultValue must be a finite number/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [{ type: "number", name: "count", label: "Count", min: 10, max: 1 }],
|
||||
}),
|
||||
/min cannot be greater than max/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [
|
||||
{ type: "select", name: "target", label: "Target", options: ["local", "remote"] },
|
||||
{ type: "textarea", name: "host", label: "Host", visibleWhen: { field: "missing", equals: "remote" } },
|
||||
],
|
||||
}),
|
||||
/visibleWhen references unknown field: missing/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [
|
||||
{ type: "select", name: "target", label: "Target", options: ["local", "remote"] },
|
||||
{ type: "textarea", name: "host", label: "Host", visibleWhen: { field: "target", equals: "remote", truthy: true } },
|
||||
],
|
||||
}),
|
||||
/requires exactly one condition operator/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [
|
||||
{ type: "textarea", name: "host", label: "Host", visibleWhen: { field: "target", equals: "remote" } },
|
||||
{ type: "select", name: "target", label: "Target", options: ["local", "remote"] },
|
||||
],
|
||||
}),
|
||||
/visibleWhen must reference an earlier field: host/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [{ type: "checkbox", name: "self", label: "Self", visibleWhen: { field: "self", truthy: true } }],
|
||||
}),
|
||||
/visibleWhen must reference an earlier field: self/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [{ type: "number", name: "count", label: "Count", defaultValue: -1, min: 0 }],
|
||||
}),
|
||||
/defaultValue cannot be less than min/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeDialogFormSpec({
|
||||
fields: [{ type: "number", name: "count", label: "Count", defaultValue: 6, min: 1, step: 2 }],
|
||||
}),
|
||||
/defaultValue must match step from min/,
|
||||
);
|
||||
});
|
||||
|
||||
test("createScriptRuntime exposes form dialog API through showDialog", async () => {
|
||||
let dialogCall;
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({
|
||||
waitFor: async () => "ok",
|
||||
waitForAny: async () => 0,
|
||||
getText: () => "",
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async (type, message, defaultValue, extras) => {
|
||||
dialogCall = { type, message, defaultValue, extras };
|
||||
return { env: "prod", restart: true };
|
||||
},
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
await runtime.execute(`
|
||||
const values = await nct.dialog.form({
|
||||
message: 'Deploy?',
|
||||
fields: [
|
||||
{ type: 'select', name: 'env', label: 'Environment', options: ['dev', 'prod'], defaultValue: 'prod' },
|
||||
{ type: 'checkbox', name: 'restart', label: 'Restart', defaultValue: false },
|
||||
],
|
||||
});
|
||||
nct.log(values.env + ':' + values.restart);
|
||||
`);
|
||||
|
||||
assert.equal(dialogCall.type, "form");
|
||||
assert.equal(dialogCall.message, "Deploy?");
|
||||
assert.equal(dialogCall.defaultValue, undefined);
|
||||
assert.equal(dialogCall.extras.form.fields[0].defaultValue, "prod");
|
||||
});
|
||||
|
||||
test("createScriptRuntime convenience dialog controls return single values", async () => {
|
||||
const results = [
|
||||
{ value: "prod" },
|
||||
{ value: "safe" },
|
||||
{ value: true },
|
||||
];
|
||||
const calls = [];
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({
|
||||
waitFor: async () => "ok",
|
||||
waitForAny: async () => 0,
|
||||
getText: () => "",
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async (type, message, _defaultValue, extras) => {
|
||||
calls.push({ type, message, fieldType: extras.form.fields[0].type });
|
||||
return results.shift();
|
||||
},
|
||||
isPaused: () => false,
|
||||
isAborted: () => false,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
const values = [];
|
||||
runtime.nct.log = (message) => values.push(message);
|
||||
await runtime.execute(`
|
||||
nct.log(await nct.dialog.select('Environment', ['dev', 'prod'], 'dev'));
|
||||
nct.log(await nct.dialog.radio('Mode', ['safe', 'fast'], 'safe'));
|
||||
nct.log(String(await nct.dialog.checkbox('Restart', true)));
|
||||
`);
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.fieldType), ["select", "radio", "checkbox"]);
|
||||
assert.deepEqual(values, ["prod", "safe", "true"]);
|
||||
});
|
||||
|
||||
test("createScriptRuntime does not open dialogs after a script is stopped", async () => {
|
||||
let aborted = false;
|
||||
let dialogCalls = 0;
|
||||
const runtime = createScriptRuntime({
|
||||
sessionId: "s1",
|
||||
runId: "r1",
|
||||
appendLog: () => {},
|
||||
writeToSession: () => {},
|
||||
getOutputBuffer: () => ({
|
||||
waitFor: async () => "ok",
|
||||
waitForAny: async () => 0,
|
||||
getText: () => "",
|
||||
}),
|
||||
getSessionMeta: () => ({ connected: true, hostname: "host", username: "user" }),
|
||||
showDialog: async () => {
|
||||
dialogCalls += 1;
|
||||
return true;
|
||||
},
|
||||
isPaused: () => false,
|
||||
isAborted: () => aborted,
|
||||
onStatusChange: () => {},
|
||||
});
|
||||
|
||||
const run = runtime.execute(`
|
||||
try {
|
||||
await nct.sleep(5000);
|
||||
} catch {
|
||||
await nct.dialog.confirm('still there?');
|
||||
}
|
||||
`);
|
||||
setTimeout(() => {
|
||||
aborted = true;
|
||||
}, 30);
|
||||
|
||||
await assert.rejects(run, /Script stopped/);
|
||||
assert.equal(dialogCalls, 0);
|
||||
});
|
||||
935
electron/scripts/sessionOutputBuffer.cjs
Normal file
935
electron/scripts/sessionOutputBuffer.cjs
Normal file
@@ -0,0 +1,935 @@
|
||||
"use strict";
|
||||
|
||||
const { shellPromptPatterns } = require("./shellPromptPatterns.cjs");
|
||||
|
||||
const DEFAULT_BUFFER_SIZE = 1024 * 1024;
|
||||
/** Matches within this many bytes of buffer end count as live terminal output. */
|
||||
const FRESH_MATCH_TAIL_SLACK = 512;
|
||||
|
||||
function isFreshTailMatch(textLength, matchEndAbsolute) {
|
||||
return matchEndAbsolute >= textLength - FRESH_MATCH_TAIL_SLACK;
|
||||
}
|
||||
|
||||
function stripTrailingBlankLines(text) {
|
||||
return String(text || "").replace(/(?:[ \t]*\r?\n)*$/u, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop any prefix of trailingFresh that the viewport snapshot already shows.
|
||||
* Handles blank-padded full-viewport snapshots and partial overlaps where the
|
||||
* snapshot captured only the start of the sync-race bytes.
|
||||
*
|
||||
* `syncStartText` is the live buffer at snapshot-request time. When the
|
||||
* viewport still matches that pre-sync content, trailingFresh is genuinely new
|
||||
* even if it happens to equal the visible suffix (e.g. a second READY).
|
||||
*/
|
||||
function trimOverlappingTrailingFresh(viewportText, trailingFresh, syncStartText = "") {
|
||||
const trailing = String(trailingFresh || "");
|
||||
if (!trailing) return "";
|
||||
const viewport = String(viewportText || "");
|
||||
const viewportCore = stripTrailingBlankLines(viewport);
|
||||
const trailingCore = stripTrailingBlankLines(trailing);
|
||||
const syncCore = stripTrailingBlankLines(syncStartText);
|
||||
|
||||
// Stale snapshot: still showing pre-sync content → keep all trailingFresh.
|
||||
// Exact match covers a second identical marker while the snapshot IPC is in
|
||||
// flight. Proper-suffix match covers scrollback-backed buffers where the
|
||||
// visible viewport is only the tail of syncStartText (e.g. banner\nREADY vs
|
||||
// READY) so equality alone would miss the stale case and trim a real duplicate.
|
||||
if (
|
||||
syncCore
|
||||
&& viewportCore
|
||||
&& (
|
||||
viewportCore === syncCore
|
||||
|| (syncCore.length > viewportCore.length && syncCore.endsWith(viewportCore))
|
||||
)
|
||||
) {
|
||||
return trailing;
|
||||
}
|
||||
|
||||
if (
|
||||
viewport.endsWith(trailing)
|
||||
|| (trailingCore && viewportCore.endsWith(trailingCore))
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Partial overlap: prefer the unpadded core so blank-padded snapshots still
|
||||
// trim, then fall back to the full viewport for newline-accurate matches.
|
||||
const candidates = [viewportCore, viewport].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
const max = Math.min(candidate.length, trailing.length);
|
||||
for (let len = max; len > 0; len -= 1) {
|
||||
if (!candidate.endsWith(trailing.slice(0, len))) continue;
|
||||
let remainder = trailing.slice(len);
|
||||
// Avoid turning blank padding + overlapped newline into an extra blank.
|
||||
while (remainder.startsWith("\n") && viewport.endsWith("\n")) {
|
||||
remainder = remainder.slice(1);
|
||||
}
|
||||
return remainder;
|
||||
}
|
||||
}
|
||||
return trailing;
|
||||
}
|
||||
|
||||
function isRegExpLike(pattern) {
|
||||
return Boolean(
|
||||
pattern
|
||||
&& typeof pattern === "object"
|
||||
&& typeof pattern.exec === "function"
|
||||
&& typeof pattern.test === "function",
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function hasUnescapedCharAt(source, index, char) {
|
||||
if (source[index] !== char) return false;
|
||||
let backslashCount = 0;
|
||||
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
|
||||
backslashCount += 1;
|
||||
}
|
||||
return backslashCount % 2 === 0;
|
||||
}
|
||||
|
||||
function isRegexCompatibleWaitPattern(pattern) {
|
||||
return pattern instanceof RegExp || isRegExpLike(pattern);
|
||||
}
|
||||
|
||||
function edgeDotRepeatTokenLengthAt(source, index) {
|
||||
if (source[index] !== ".") return 0;
|
||||
const quantifier = source[index + 1];
|
||||
if (quantifier !== "*" && quantifier !== "+") return 0;
|
||||
if (!hasUnescapedCharAt(source, index, ".")) return 0;
|
||||
return source[index + 2] === "?" ? 3 : 2;
|
||||
}
|
||||
|
||||
function stripEdgeDotRepeats(source) {
|
||||
let start = 0;
|
||||
let end = source.length;
|
||||
if (hasUnescapedCharAt(source, start, "^")) {
|
||||
start += 1;
|
||||
}
|
||||
if (end > start && hasUnescapedCharAt(source, end - 1, "$")) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
while (start < end) {
|
||||
const tokenLength = edgeDotRepeatTokenLengthAt(source, start);
|
||||
if (tokenLength === 0) break;
|
||||
start += tokenLength;
|
||||
}
|
||||
while (end - start >= 2) {
|
||||
const lazyTokenLength = edgeDotRepeatTokenLengthAt(source, end - 3);
|
||||
if (lazyTokenLength === 3) {
|
||||
end -= 3;
|
||||
continue;
|
||||
}
|
||||
const greedyTokenLength = edgeDotRepeatTokenLengthAt(source, end - 2);
|
||||
if (greedyTokenLength !== 2) break;
|
||||
end -= 2;
|
||||
}
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function getRegExpFlags(regex, fallbackFlags = "") {
|
||||
if (typeof regex.flags === "string") return regex.flags;
|
||||
let flags = fallbackFlags;
|
||||
if (regex.global && !flags.includes("g")) flags += "g";
|
||||
if (regex.ignoreCase && !flags.includes("i")) flags += "i";
|
||||
if (regex.multiline && !flags.includes("m")) flags += "m";
|
||||
if (regex.dotAll && !flags.includes("s")) flags += "s";
|
||||
if (regex.unicode && !flags.includes("u")) flags += "u";
|
||||
if (regex.sticky && !flags.includes("y")) flags += "y";
|
||||
return flags;
|
||||
}
|
||||
|
||||
function compilePattern(pattern) {
|
||||
if (pattern instanceof RegExp || isRegExpLike(pattern)) return pattern;
|
||||
if (typeof pattern !== "string") {
|
||||
throw new TypeError("waitFor pattern must be a string or RegExp");
|
||||
}
|
||||
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
|
||||
if (slashMatch) {
|
||||
return new RegExp(slashMatch[1], slashMatch[2]);
|
||||
}
|
||||
return new RegExp(escapeRegExp(pattern));
|
||||
}
|
||||
|
||||
function compileTextPattern(text) {
|
||||
if (typeof text !== "string") {
|
||||
throw new TypeError("waitForText pattern must be a string");
|
||||
}
|
||||
return new RegExp(escapeRegExp(text));
|
||||
}
|
||||
|
||||
function compileRegexPattern(pattern) {
|
||||
if (pattern instanceof RegExp || isRegExpLike(pattern)) return pattern;
|
||||
if (typeof pattern !== "string") {
|
||||
throw new TypeError("waitForRegex pattern must be a string or RegExp");
|
||||
}
|
||||
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
|
||||
if (slashMatch) {
|
||||
const flags = slashMatch[2].includes("s") ? slashMatch[2] : `${slashMatch[2]}s`;
|
||||
return new RegExp(slashMatch[1], flags);
|
||||
}
|
||||
return new RegExp(pattern, "s");
|
||||
}
|
||||
|
||||
function compileRegexFreshnessPattern(pattern) {
|
||||
const regex = compileRegexPattern(pattern);
|
||||
const source = typeof regex.source === "string" ? regex.source : String(pattern);
|
||||
const strippedSource = stripEdgeDotRepeats(source);
|
||||
if (!strippedSource || strippedSource === source) return null;
|
||||
const flags = getRegExpFlags(regex).replace(/y/g, "");
|
||||
const globalFlags = flags.includes("g") ? flags : `${flags}g`;
|
||||
try {
|
||||
return new RegExp(strippedSource, globalFlags);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tryMatch(text, pattern) {
|
||||
const regex = compilePattern(pattern);
|
||||
const match = regex.exec(text);
|
||||
if (!match) return null;
|
||||
return match[0];
|
||||
}
|
||||
|
||||
function tryMatchWithEnd(text, pattern, compiler = compilePattern) {
|
||||
const regex = compiler(pattern);
|
||||
if (typeof regex.lastIndex === "number") regex.lastIndex = 0;
|
||||
const match = regex.exec(text);
|
||||
if (!match || match.index === undefined) return null;
|
||||
return {
|
||||
value: match[0],
|
||||
endOffset: match.index + match[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
function tryRegexMatchWithEnd(text, pattern) {
|
||||
const regex = compileRegexPattern(pattern);
|
||||
if (typeof regex.lastIndex === "number") regex.lastIndex = 0;
|
||||
const match = regex.exec(text);
|
||||
if (!match || match.index === undefined) return null;
|
||||
|
||||
const value = match[0];
|
||||
let freshStartOffset = match.index;
|
||||
let freshEndOffset = match.index + value.length;
|
||||
const freshnessRegex = compileRegexFreshnessPattern(pattern);
|
||||
if (freshnessRegex) {
|
||||
if (typeof freshnessRegex.lastIndex === "number") freshnessRegex.lastIndex = 0;
|
||||
let freshMatch = freshnessRegex.exec(value);
|
||||
let latestFreshStartOffset = null;
|
||||
let latestFreshEndOffset = null;
|
||||
while (freshMatch && freshMatch.index !== undefined) {
|
||||
const startOffset = freshMatch.index;
|
||||
const endOffset = freshMatch.index + freshMatch[0].length;
|
||||
if (latestFreshEndOffset === null || endOffset >= latestFreshEndOffset) {
|
||||
latestFreshStartOffset = startOffset;
|
||||
}
|
||||
latestFreshEndOffset = Math.max(latestFreshEndOffset ?? 0, endOffset);
|
||||
if (freshMatch[0].length === 0) {
|
||||
freshnessRegex.lastIndex += 1;
|
||||
}
|
||||
freshMatch = freshnessRegex.exec(value);
|
||||
}
|
||||
if (latestFreshEndOffset !== null) {
|
||||
freshStartOffset = match.index + latestFreshStartOffset;
|
||||
freshEndOffset = match.index + latestFreshEndOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
startOffset: match.index,
|
||||
endOffset: match.index + value.length,
|
||||
freshStartOffset,
|
||||
freshEndOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function staleAdvanceEndOffset(matched) {
|
||||
return Math.max(Number(matched?.endOffset) || 0, 1);
|
||||
}
|
||||
|
||||
function staleRegexAdvanceEndOffset(matched) {
|
||||
return Math.max((Number(matched?.startOffset) || 0) + 1, 1);
|
||||
}
|
||||
|
||||
function findFreshTailMatchAny(text, patterns) {
|
||||
for (let index = 0; index < patterns.length; index += 1) {
|
||||
const pattern = patterns[index];
|
||||
let offset = 0;
|
||||
while (offset <= text.length) {
|
||||
const matched = tryMatchWithEnd(text.slice(offset), pattern);
|
||||
if (matched === null) break;
|
||||
const absoluteEnd = offset + matched.endOffset;
|
||||
if (isFreshTailMatch(text.length, absoluteEnd)) {
|
||||
return {
|
||||
index,
|
||||
matched: {
|
||||
value: matched.value,
|
||||
endOffset: absoluteEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
offset = Math.max(absoluteEnd, offset + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingPreservedTailMatch(text, patterns, preserved) {
|
||||
for (let index = 0; index < patterns.length; index += 1) {
|
||||
const pattern = patterns[index];
|
||||
let offset = 0;
|
||||
while (offset <= text.length) {
|
||||
const matched = tryMatchWithEnd(text.slice(offset), pattern);
|
||||
if (matched === null) break;
|
||||
const absoluteEnd = offset + matched.endOffset;
|
||||
if (absoluteEnd === preserved.endOffset && matched.value === preserved.value) {
|
||||
return {
|
||||
index,
|
||||
matched: {
|
||||
value: matched.value,
|
||||
endOffset: absoluteEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
offset = Math.max(absoluteEnd, offset + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class SessionOutputBuffer {
|
||||
constructor(sessionId, maxSize = DEFAULT_BUFFER_SIZE) {
|
||||
this.sessionId = sessionId;
|
||||
this.maxSize = maxSize;
|
||||
this.chunks = [];
|
||||
this.totalLength = 0;
|
||||
this.scanOffset = 0;
|
||||
this.waiters = [];
|
||||
this.preservedTailMatch = null;
|
||||
/** @type {number | null} Absolute end of a seeded visible viewport that stays fully waitable. */
|
||||
this.seededLength = null;
|
||||
}
|
||||
|
||||
append(data) {
|
||||
if (!data) return;
|
||||
this.preservedTailMatch = null;
|
||||
this.chunks.push(String(data));
|
||||
this.totalLength += this.chunks[this.chunks.length - 1].length;
|
||||
while (this.totalLength > this.maxSize && this.chunks.length > 1) {
|
||||
const removed = this.chunks.shift();
|
||||
const removedLength = removed.length;
|
||||
this.totalLength -= removedLength;
|
||||
this.scanOffset = Math.max(0, this.scanOffset - removedLength);
|
||||
if (typeof this.seededLength === "number") {
|
||||
this.seededLength = Math.max(0, this.seededLength - removedLength);
|
||||
}
|
||||
for (const waiter of this.waiters) {
|
||||
if (typeof waiter.freshBoundary === "number") {
|
||||
waiter.freshBoundary = Math.max(0, waiter.freshBoundary - removedLength);
|
||||
}
|
||||
if (waiter.custom && typeof waiter.custom.freshBoundary === "number") {
|
||||
waiter.custom.freshBoundary = Math.max(0, waiter.custom.freshBoundary - removedLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.flushWaiters();
|
||||
}
|
||||
|
||||
getText() {
|
||||
return this.chunks.join("");
|
||||
}
|
||||
|
||||
getPendingText() {
|
||||
return this.getText().slice(this.scanOffset);
|
||||
}
|
||||
|
||||
tryMatchPending(pattern) {
|
||||
return tryMatchWithEnd(this.getPendingText(), pattern);
|
||||
}
|
||||
|
||||
tryMatchPendingText(text) {
|
||||
return tryMatchWithEnd(this.getPendingText(), text, compileTextPattern);
|
||||
}
|
||||
|
||||
tryMatchPendingRegex(pattern) {
|
||||
return tryRegexMatchWithEnd(this.getPendingText(), pattern);
|
||||
}
|
||||
|
||||
currentFreshBoundary() {
|
||||
const textLength = this.getText().length;
|
||||
const normalBoundary = Math.max(this.scanOffset, textLength - FRESH_MATCH_TAIL_SLACK);
|
||||
if (typeof this.seededLength === "number" && this.scanOffset < this.seededLength) {
|
||||
// Startup viewport rows must all be waitable for waitFor / waitForText /
|
||||
// waitForRegex, even when the visible screen is longer than
|
||||
// FRESH_MATCH_TAIL_SLACK (bastion menus, etc.).
|
||||
return this.scanOffset;
|
||||
}
|
||||
if (typeof this.seededLength === "number" && this.scanOffset >= this.seededLength) {
|
||||
this.seededLength = null;
|
||||
}
|
||||
return normalBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freshness for waitForPrompt (allowPreservedTailMatch): live tail only, and
|
||||
* never rematch prompts that only exist inside the still-unconsumed seeded
|
||||
* viewport. After live output clears preservedTailMatch, a short seed like
|
||||
* `root# ` must not satisfy waitForPrompt via the normal 512-byte window.
|
||||
* Generic waitForAny uses currentFreshBoundary instead.
|
||||
*/
|
||||
currentTailFreshBoundary() {
|
||||
const textLength = this.getText().length;
|
||||
let boundary = Math.max(this.scanOffset, textLength - FRESH_MATCH_TAIL_SLACK);
|
||||
if (typeof this.seededLength === "number") {
|
||||
if (this.scanOffset >= this.seededLength) {
|
||||
this.seededLength = null;
|
||||
} else {
|
||||
boundary = Math.max(boundary, this.seededLength);
|
||||
}
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace buffer contents with the current visible terminal screen.
|
||||
* The entire seeded viewport is treated as fresh for waitFor / waitForText /
|
||||
* waitForRegex / generic waitForAny. waitForPrompt still uses the live tail
|
||||
* window. `trailingFresh` (bytes that arrived during snapshot sync) stays
|
||||
* outside seededLength so consuming a startup prompt does not discard it.
|
||||
*/
|
||||
replaceWithVisibleScreen(screenText, trailingFresh = "", syncStartText = "") {
|
||||
const normalized = String(screenText || "").endsWith("\n")
|
||||
? String(screenText || "")
|
||||
: `${String(screenText || "")}\n`;
|
||||
// Snapshot and live taps can both observe the same suffix. Full-viewport
|
||||
// snapshots often pad with blank rows, and the snapshot may only include a
|
||||
// prefix of trailingFresh — trim any overlapping prefix before appending.
|
||||
const trailing = trimOverlappingTrailingFresh(normalized, trailingFresh, syncStartText);
|
||||
this.clear();
|
||||
this.append(normalized);
|
||||
// Seed only the visible viewport — not sync-race trailing bytes.
|
||||
this.seededLength = this.getText().length;
|
||||
this.scanOffset = 0;
|
||||
// Re-open pending waiters onto the seeded viewport (#1960).
|
||||
for (const waiter of this.waiters) {
|
||||
if (typeof waiter.freshBoundary === "number") {
|
||||
waiter.freshBoundary = 0;
|
||||
}
|
||||
if (waiter.custom && typeof waiter.custom.freshBoundary === "number") {
|
||||
waiter.custom.freshBoundary = 0;
|
||||
}
|
||||
}
|
||||
// Preserve a live-tail shell prompt from the viewport for waitForPrompt,
|
||||
// matching the old markOutputConsumedThrough(preserveTailPatterns) behavior.
|
||||
this.preservedTailMatch = null;
|
||||
const viewportText = this.getText();
|
||||
const fresh = findFreshTailMatchAny(viewportText, shellPromptPatterns());
|
||||
if (trailing) {
|
||||
this.append(trailing);
|
||||
}
|
||||
if (fresh !== null) {
|
||||
this.preservedTailMatch = {
|
||||
textLength: this.getText().length,
|
||||
value: fresh.matched.value,
|
||||
endOffset: fresh.matched.endOffset,
|
||||
};
|
||||
}
|
||||
this.flushWaiters();
|
||||
}
|
||||
|
||||
/**
|
||||
* After the script sends automated input, startup snapshot content must not
|
||||
* satisfy later waits (sendLine then waitForPrompt / waitForText). Consume
|
||||
* everything currently buffered — including sync-race trailingFresh that
|
||||
* arrived before the input — so waits require post-command output.
|
||||
*/
|
||||
invalidateStartupSeed() {
|
||||
this.seededLength = null;
|
||||
this.preservedTailMatch = null;
|
||||
this.scanOffset = this.getText().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark output through `absoluteLength` as already seen, without consuming
|
||||
* anything that arrived after that point. Used by sendLine so peer prompts
|
||||
* that land between body and CR stay waitable (#1960).
|
||||
*/
|
||||
consumeThroughAbsolute(absoluteLength) {
|
||||
this.seededLength = null;
|
||||
this.preservedTailMatch = null;
|
||||
const capped = Math.max(0, Math.min(this.getText().length, Number(absoluteLength) || 0));
|
||||
this.scanOffset = Math.max(this.scanOffset, capped);
|
||||
}
|
||||
|
||||
consumeFreshPendingMatch(pattern, freshBoundary = this.currentFreshBoundary()) {
|
||||
while (true) {
|
||||
const matched = this.tryMatchPending(pattern);
|
||||
if (matched === null) return null;
|
||||
const absoluteEnd = this.scanOffset + matched.endOffset;
|
||||
if (absoluteEnd >= freshBoundary) {
|
||||
return matched;
|
||||
}
|
||||
this.advanceScanOffset(staleAdvanceEndOffset(matched));
|
||||
}
|
||||
}
|
||||
|
||||
consumeFreshPendingText(text, freshBoundary = this.currentFreshBoundary()) {
|
||||
while (true) {
|
||||
const matched = this.tryMatchPendingText(text);
|
||||
if (matched === null) return null;
|
||||
const absoluteEnd = this.scanOffset + matched.endOffset;
|
||||
if (absoluteEnd >= freshBoundary) {
|
||||
return matched;
|
||||
}
|
||||
this.advanceScanOffset(staleAdvanceEndOffset(matched));
|
||||
}
|
||||
}
|
||||
|
||||
consumeFreshPendingRegex(pattern, options = {}) {
|
||||
const fallbackBoundary = this.currentFreshBoundary();
|
||||
const text = this.getText();
|
||||
const baseOffset = this.scanOffset;
|
||||
const pendingText = text.slice(baseOffset);
|
||||
let relativeOffset = 0;
|
||||
while (relativeOffset <= pendingText.length) {
|
||||
const matched = tryRegexMatchWithEnd(pendingText.slice(relativeOffset), pattern);
|
||||
if (matched === null) {
|
||||
if (relativeOffset > 0) {
|
||||
this.scanOffset = Math.min(baseOffset + relativeOffset, text.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const minFreshStartAbsolute = Number.isFinite(options.minFreshStartAbsolute)
|
||||
? options.minFreshStartAbsolute
|
||||
: null;
|
||||
const absoluteStart = baseOffset + relativeOffset + matched.freshStartOffset;
|
||||
const matchStartAbsolute = baseOffset + relativeOffset + matched.startOffset;
|
||||
const freshBoundary = minFreshStartAbsolute === null ? fallbackBoundary : minFreshStartAbsolute;
|
||||
if (absoluteStart >= freshBoundary) {
|
||||
let value = matched.value;
|
||||
if (minFreshStartAbsolute !== null && matchStartAbsolute < minFreshStartAbsolute) {
|
||||
const valueFreshStart = matched.freshStartOffset - matched.startOffset;
|
||||
const lineStart = matched.value.lastIndexOf("\n", Math.max(0, valueFreshStart - 1));
|
||||
const valueStart = lineStart >= 0 ? lineStart : Math.max(0, valueFreshStart);
|
||||
value = matched.value.slice(valueStart);
|
||||
}
|
||||
return {
|
||||
...matched,
|
||||
value,
|
||||
endOffset: relativeOffset + matched.endOffset,
|
||||
};
|
||||
}
|
||||
relativeOffset += staleRegexAdvanceEndOffset(matched);
|
||||
}
|
||||
this.scanOffset = Math.min(baseOffset + relativeOffset, text.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
consumeFreshPendingMatchAny(patterns, freshBoundary = this.currentFreshBoundary()) {
|
||||
for (let index = 0; index < patterns.length; index += 1) {
|
||||
const pattern = patterns[index];
|
||||
while (true) {
|
||||
const matched = this.tryMatchPending(pattern);
|
||||
if (matched === null) break;
|
||||
const absoluteEnd = this.scanOffset + matched.endOffset;
|
||||
if (absoluteEnd >= freshBoundary) {
|
||||
return { index, matched };
|
||||
}
|
||||
this.advanceScanOffset(staleAdvanceEndOffset(matched));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
advanceScanOffset(endOffset) {
|
||||
const absoluteEnd = this.scanOffset + endOffset;
|
||||
this.scanOffset = Math.min(absoluteEnd, this.getText().length);
|
||||
// Normal waits that consume past a preserved startup prompt invalidate it
|
||||
// (e.g. waitForText matched trailingFresh after the prompt). Baseline via
|
||||
// markOutputConsumedThrough sets scanOffset directly and keeps the prompt.
|
||||
if (this.preservedTailMatch && this.scanOffset > this.preservedTailMatch.endOffset) {
|
||||
this.preservedTailMatch = null;
|
||||
}
|
||||
if (typeof this.seededLength === "number" && this.scanOffset >= this.seededLength) {
|
||||
this.seededLength = null;
|
||||
}
|
||||
}
|
||||
|
||||
markCurrentOutputConsumed(options = {}) {
|
||||
this.markOutputConsumedThrough(this.getText().length, options);
|
||||
}
|
||||
|
||||
markOutputConsumedThrough(length, options = {}) {
|
||||
const text = this.getText();
|
||||
const consumedLength = Math.max(0, Math.min(Number(length) || 0, text.length));
|
||||
const consumedText = text.slice(0, consumedLength);
|
||||
this.scanOffset = consumedLength;
|
||||
this.preservedTailMatch = null;
|
||||
if (typeof this.seededLength === "number" && this.scanOffset >= this.seededLength) {
|
||||
this.seededLength = null;
|
||||
}
|
||||
|
||||
const preserveTailPatterns = Array.isArray(options.preserveTailPatterns)
|
||||
? options.preserveTailPatterns
|
||||
: [];
|
||||
if (preserveTailPatterns.length === 0 || consumedText.length === 0) return;
|
||||
|
||||
const fresh = findFreshTailMatchAny(consumedText, preserveTailPatterns);
|
||||
if (fresh === null) return;
|
||||
this.preservedTailMatch = {
|
||||
textLength: consumedText.length,
|
||||
value: fresh.matched.value,
|
||||
endOffset: fresh.matched.endOffset,
|
||||
};
|
||||
}
|
||||
|
||||
consumePreservedTailMatchAny(patterns) {
|
||||
const preserved = this.preservedTailMatch;
|
||||
if (!preserved) return null;
|
||||
const text = this.getText();
|
||||
if (text.length !== preserved.textLength) {
|
||||
this.preservedTailMatch = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const fresh = findMatchingPreservedTailMatch(text, patterns, preserved);
|
||||
if (fresh === null) return null;
|
||||
|
||||
this.preservedTailMatch = null;
|
||||
// Consuming the startup prompt must also consume the whole seeded viewport
|
||||
// from that snapshot. Advancing only to the prompt end leaves later bytes
|
||||
// from the same screen (e.g. `root@host:~# \nold READY`) waitable for the
|
||||
// next waitForText/waitForRegex.
|
||||
const consumeThrough = typeof this.seededLength === "number"
|
||||
? Math.max(fresh.matched.endOffset, this.seededLength)
|
||||
: Math.max(fresh.matched.endOffset, preserved.textLength);
|
||||
this.scanOffset = Math.max(this.scanOffset, consumeThrough);
|
||||
// Sync-race trailingFresh sits past the original seededLength. Keep it fully
|
||||
// waitable even when longer than FRESH_MATCH_TAIL_SLACK; otherwise a marker
|
||||
// near the start of a large burst (READY + long menu) times out after prompt.
|
||||
const textLength = this.getText().length;
|
||||
if (textLength > this.scanOffset) {
|
||||
this.seededLength = textLength;
|
||||
} else {
|
||||
this.seededLength = null;
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.chunks = [];
|
||||
this.totalLength = 0;
|
||||
this.scanOffset = 0;
|
||||
this.preservedTailMatch = null;
|
||||
this.seededLength = null;
|
||||
}
|
||||
|
||||
flushWaiters() {
|
||||
if (this.waiters.length === 0) return;
|
||||
const remaining = [];
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.custom) {
|
||||
if (!waiter.custom.check()) {
|
||||
remaining.push(waiter);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const matched = this.consumeFreshPendingMatch(
|
||||
waiter.pattern,
|
||||
waiter.freshBoundary ?? this.currentFreshBoundary(),
|
||||
);
|
||||
if (matched !== null) {
|
||||
this.advanceScanOffset(matched.endOffset);
|
||||
clearTimeout(waiter.timer);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
waiter.resolve(matched.value);
|
||||
} else {
|
||||
remaining.push(waiter);
|
||||
}
|
||||
}
|
||||
this.waiters = remaining;
|
||||
}
|
||||
|
||||
waitForWithMatcher({ pattern, timeoutMs, shouldAbort, consumeFreshMatch, timeoutLabel, freshBoundary }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = {
|
||||
pattern,
|
||||
freshBoundary,
|
||||
resolve,
|
||||
reject,
|
||||
shouldAbort,
|
||||
timer: null,
|
||||
check: () => {
|
||||
const matched = consumeFreshMatch(waiter.freshBoundary);
|
||||
if (matched === null) return false;
|
||||
this.advanceScanOffset(matched.endOffset);
|
||||
clearTimeout(waiter.timer);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||||
resolve(matched.value);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
waiter.timer = setTimeout(() => {
|
||||
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
reject(new Error(`${timeoutLabel} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
if (typeof shouldAbort === "function") {
|
||||
waiter.abortInterval = setInterval(() => {
|
||||
if (!shouldAbort()) return;
|
||||
clearTimeout(waiter.timer);
|
||||
clearInterval(waiter.abortInterval);
|
||||
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||||
reject(new Error("Script stopped"));
|
||||
}, 100);
|
||||
}
|
||||
this.waiters.push({
|
||||
pattern,
|
||||
resolve: () => {},
|
||||
reject,
|
||||
timer: waiter.timer,
|
||||
custom: waiter,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
waitFor(pattern, timeoutMs = 30000, shouldAbort) {
|
||||
if (isRegexCompatibleWaitPattern(pattern)) {
|
||||
const minFreshStartAbsolute = this.currentFreshBoundary();
|
||||
const immediate = this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute });
|
||||
if (immediate !== null) {
|
||||
this.advanceScanOffset(immediate.endOffset);
|
||||
return Promise.resolve(immediate.value);
|
||||
}
|
||||
|
||||
return this.waitForWithMatcher({
|
||||
pattern,
|
||||
timeoutMs,
|
||||
shouldAbort,
|
||||
freshBoundary: minFreshStartAbsolute,
|
||||
consumeFreshMatch: (boundary) => this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute: boundary }),
|
||||
timeoutLabel: "waitFor",
|
||||
});
|
||||
}
|
||||
|
||||
const freshBoundary = this.currentFreshBoundary();
|
||||
const immediate = this.consumeFreshPendingMatch(pattern, freshBoundary);
|
||||
if (immediate !== null) {
|
||||
this.advanceScanOffset(immediate.endOffset);
|
||||
return Promise.resolve(immediate.value);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = {
|
||||
pattern,
|
||||
freshBoundary,
|
||||
resolve,
|
||||
reject,
|
||||
shouldAbort,
|
||||
timer: setTimeout(() => {
|
||||
this.waiters = this.waiters.filter((entry) => entry !== waiter);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
reject(new Error(`waitFor timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs),
|
||||
};
|
||||
if (typeof shouldAbort === "function") {
|
||||
waiter.abortInterval = setInterval(() => {
|
||||
if (!shouldAbort()) return;
|
||||
clearTimeout(waiter.timer);
|
||||
clearInterval(waiter.abortInterval);
|
||||
this.waiters = this.waiters.filter((entry) => entry !== waiter);
|
||||
reject(new Error("Script stopped"));
|
||||
}, 100);
|
||||
}
|
||||
this.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
waitForText(text, timeoutMs = 30000, shouldAbort) {
|
||||
const freshBoundary = this.currentFreshBoundary();
|
||||
const immediate = this.consumeFreshPendingText(text, freshBoundary);
|
||||
if (immediate !== null) {
|
||||
this.advanceScanOffset(immediate.endOffset);
|
||||
return Promise.resolve(immediate.value);
|
||||
}
|
||||
|
||||
return this.waitForWithMatcher({
|
||||
pattern: text,
|
||||
timeoutMs,
|
||||
shouldAbort,
|
||||
freshBoundary,
|
||||
consumeFreshMatch: (boundary) => this.consumeFreshPendingText(text, boundary),
|
||||
timeoutLabel: "waitForText",
|
||||
});
|
||||
}
|
||||
|
||||
waitForRegex(pattern, timeoutMs = 30000, shouldAbort) {
|
||||
const minFreshStartAbsolute = this.currentFreshBoundary();
|
||||
const immediate = this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute });
|
||||
if (immediate !== null) {
|
||||
this.advanceScanOffset(immediate.endOffset);
|
||||
return Promise.resolve(immediate.value);
|
||||
}
|
||||
|
||||
return this.waitForWithMatcher({
|
||||
pattern,
|
||||
timeoutMs,
|
||||
shouldAbort,
|
||||
freshBoundary: minFreshStartAbsolute,
|
||||
consumeFreshMatch: (boundary) => this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute: boundary }),
|
||||
timeoutLabel: "waitForRegex",
|
||||
});
|
||||
}
|
||||
|
||||
abortWaiters(reason = "Script stopped") {
|
||||
for (const waiter of this.waiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
if (waiter.custom?.abortInterval) clearInterval(waiter.custom.abortInterval);
|
||||
if (waiter.custom?.interval) clearInterval(waiter.custom.interval);
|
||||
waiter.reject?.(new Error(reason));
|
||||
}
|
||||
this.waiters = [];
|
||||
}
|
||||
|
||||
async waitForAny(patterns, timeoutMs = 30000, shouldAbort, options = {}) {
|
||||
if (!Array.isArray(patterns) || patterns.length === 0) {
|
||||
throw new TypeError("waitForAny requires a non-empty patterns array");
|
||||
}
|
||||
if (options.allowPreservedTailMatch === true) {
|
||||
const preserved = this.consumePreservedTailMatchAny(patterns);
|
||||
if (preserved !== null) {
|
||||
return preserved.index;
|
||||
}
|
||||
}
|
||||
// Generic waitForAny must see the full seeded viewport (menu labels near
|
||||
// the top). waitForPrompt passes allowPreservedTailMatch and stays on the
|
||||
// live-tail window so older visible prompts are not readiness signals.
|
||||
const freshBoundary = options.allowPreservedTailMatch === true
|
||||
? this.currentTailFreshBoundary()
|
||||
: this.currentFreshBoundary();
|
||||
const fresh = this.consumeFreshPendingMatchAny(patterns, freshBoundary);
|
||||
if (fresh !== null) {
|
||||
this.advanceScanOffset(fresh.matched.endOffset);
|
||||
return fresh.index;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = {
|
||||
patterns,
|
||||
freshBoundary,
|
||||
resolve,
|
||||
reject,
|
||||
shouldAbort,
|
||||
timer: null,
|
||||
interval: null,
|
||||
check: () => {
|
||||
if (options.allowPreservedTailMatch === true) {
|
||||
const preserved = this.consumePreservedTailMatchAny(patterns);
|
||||
if (preserved !== null) {
|
||||
clearTimeout(waiter.timer);
|
||||
if (waiter.interval) clearInterval(waiter.interval);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||||
resolve(preserved.index);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const fresh = this.consumeFreshPendingMatchAny(patterns, waiter.freshBoundary);
|
||||
if (fresh !== null) {
|
||||
this.advanceScanOffset(fresh.matched.endOffset);
|
||||
clearTimeout(waiter.timer);
|
||||
if (waiter.interval) clearInterval(waiter.interval);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||||
resolve(fresh.index);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
waiter.timer = setTimeout(() => {
|
||||
if (waiter.interval) clearInterval(waiter.interval);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||||
reject(new Error(`waitForAny timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
waiter.interval = setInterval(() => {
|
||||
waiter.check();
|
||||
}, 50);
|
||||
if (typeof shouldAbort === "function") {
|
||||
waiter.abortInterval = setInterval(() => {
|
||||
if (!shouldAbort()) return;
|
||||
clearTimeout(waiter.timer);
|
||||
clearInterval(waiter.interval);
|
||||
clearInterval(waiter.abortInterval);
|
||||
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
|
||||
reject(new Error("Script stopped"));
|
||||
}, 100);
|
||||
}
|
||||
this.waiters.push({
|
||||
pattern: patterns[0],
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
timer: waiter.timer,
|
||||
custom: waiter,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const waiter of this.waiters) {
|
||||
clearTimeout(waiter.timer);
|
||||
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
|
||||
if (waiter.custom?.abortInterval) clearInterval(waiter.custom.abortInterval);
|
||||
if (waiter.custom?.interval) clearInterval(waiter.custom.interval);
|
||||
waiter.reject?.(new Error("Session output buffer disposed"));
|
||||
}
|
||||
this.waiters = [];
|
||||
this.chunks = [];
|
||||
this.totalLength = 0;
|
||||
this.scanOffset = 0;
|
||||
this.preservedTailMatch = null;
|
||||
this.seededLength = null;
|
||||
}
|
||||
}
|
||||
|
||||
const buffers = new Map();
|
||||
|
||||
function getOrCreateBuffer(sessionId) {
|
||||
if (!buffers.has(sessionId)) {
|
||||
buffers.set(sessionId, new SessionOutputBuffer(sessionId));
|
||||
}
|
||||
return buffers.get(sessionId);
|
||||
}
|
||||
|
||||
function appendSessionOutput(sessionId, data) {
|
||||
getOrCreateBuffer(sessionId).append(data);
|
||||
}
|
||||
|
||||
function removeSessionBuffer(sessionId) {
|
||||
const buffer = buffers.get(sessionId);
|
||||
if (buffer) {
|
||||
buffer.dispose();
|
||||
buffers.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SessionOutputBuffer,
|
||||
appendSessionOutput,
|
||||
getOrCreateBuffer,
|
||||
removeSessionBuffer,
|
||||
tryMatch,
|
||||
compilePattern,
|
||||
};
|
||||
822
electron/scripts/sessionOutputBuffer.test.cjs
Normal file
822
electron/scripts/sessionOutputBuffer.test.cjs
Normal file
@@ -0,0 +1,822 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { SessionOutputBuffer, tryMatch } = require("./sessionOutputBuffer.cjs");
|
||||
const { SHELL_PROMPT_END_REGEX, shellPromptPatterns } = require("./shellPromptPatterns.cjs");
|
||||
const { stepsToJavaScript } = require("./scriptCodegen.cjs");
|
||||
|
||||
test("tryMatch finds substring patterns", () => {
|
||||
assert.equal(tryMatch("hello world", "world"), "world");
|
||||
});
|
||||
|
||||
test("tryMatch supports slash-delimited regex patterns", () => {
|
||||
assert.equal(tryMatch("sudo password:", "/password/i"), "password");
|
||||
});
|
||||
|
||||
test("tryMatch keeps wildcard-looking strings literal", () => {
|
||||
assert.equal(
|
||||
tryMatch("literal .* [broken", ".* [broken"),
|
||||
".* [broken",
|
||||
);
|
||||
});
|
||||
|
||||
test("tryMatch accepts RegExp objects from an isolated vm context", () => {
|
||||
const vm = require("node:vm");
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
const pattern = vm.runInContext("/SAMPLE_4_DONE/", sandbox);
|
||||
assert.equal(tryMatch("tag_SAMPLE_4_DONE ok", pattern), "SAMPLE_4_DONE");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor resolves on appended data", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitFor("$ ", 1000);
|
||||
buffer.append("user@host:$ ");
|
||||
assert.equal(await pending, "$ ");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor resolves root shell prompt", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitFor("# ", 1000);
|
||||
buffer.append("Welcome to Ubuntu\nroot@VM-4-16-ubuntu:~# ");
|
||||
assert.equal(await pending, "# ");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex resolves regex source strings across line breaks", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForRegex(".*SSH资源.*登录方式.*", 1000);
|
||||
buffer.append("1. SSH资源\n请选择SSH资源\n'zxadmin'登录方式:");
|
||||
assert.equal(await pending, "1. SSH资源\n请选择SSH资源\n'zxadmin'登录方式:");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor treats wildcard-looking strings as literal text", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitFor(".*SSH资源.*登录方式.*", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
|
||||
buffer.append("1. SSH资源\n请选择SSH资源\n'zxadmin'登录方式:");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\n.*SSH资源.*登录方式.*");
|
||||
assert.equal(await pending, ".*SSH资源.*登录方式.*");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor preserves slash regex dot behavior", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitFor("/BEGIN.*END/", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
|
||||
buffer.append("BEGIN\nEND");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nBEGIN END");
|
||||
assert.equal(await pending, "BEGIN END");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor handles invalid-regex-looking strings literally", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitFor("literal .* [broken", 1000);
|
||||
buffer.append("literal .* [broken");
|
||||
assert.equal(await pending, "literal .* [broken");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex resolves simple source strings immediately", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append("READY");
|
||||
assert.equal(await buffer.waitForRegex("READY", 1000), "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex resolves simple source strings after append", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForRegex("READY", 1000);
|
||||
buffer.append("READY");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForText keeps strings literal", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForText("资源'[Empty]'账户:", 1000);
|
||||
buffer.append("资源'[Empty]'账户:");
|
||||
assert.equal(await pending, "资源'[Empty]'账户:");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForText treats wildcard-looking strings as literal text", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForText("literal .* prompt", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
buffer.append("literal abc prompt");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nliteral .* prompt");
|
||||
assert.equal(await pending, "literal .* prompt");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex does not treat stale edge-wildcard matches as fresh", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`SSH资源${"x".repeat(600)}`);
|
||||
|
||||
const pending = buffer.waitForRegex(".*SSH资源.*", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nSSH资源");
|
||||
assert.equal(await pending, "\nSSH资源");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex preserves the full regex match value", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForRegex("Version: .*", 1000);
|
||||
buffer.append("Version: 1.2.3 ready");
|
||||
assert.equal(await pending, "Version: 1.2.3 ready");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex applies stale protection to RegExp objects", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`SSH资源${"x".repeat(600)}`);
|
||||
|
||||
const pending = buffer.waitForRegex(/.*SSH资源.*/s, 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nSSH资源");
|
||||
assert.equal(await pending, "\nSSH资源");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex handles lazy edge wildcards", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForRegex(/.*?READY.*?/s, 1000);
|
||||
buffer.append("READY");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex applies stale protection to anchored edge wildcards", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`READY${"x".repeat(600)}`);
|
||||
|
||||
const pending = buffer.waitForRegex(/^.*READY.*$/s, 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nREADY");
|
||||
assert.equal(await pending, "\nREADY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex applies stale protection to edge plus wildcards", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`xREADYy${"x".repeat(600)}`);
|
||||
|
||||
const pending = buffer.waitForRegex(/.+READY.+/s, 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nxREADYy");
|
||||
assert.equal(await pending, "\nxREADYy");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex accepts fresh core matches later in a full regex result", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForRegex(".*READY.*", 1000);
|
||||
buffer.append(`READY${"x".repeat(600)}READY`);
|
||||
assert.equal(await pending, `READY${"x".repeat(600)}READY`);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex does not combine stale prefix with fresh suffix", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`SSH资源${"x".repeat(600)}`);
|
||||
|
||||
const pending = buffer.waitForRegex(".*SSH资源.*登录方式.*", 1000);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
|
||||
buffer.append("\n登录方式:");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nSSH资源\n登录方式:");
|
||||
assert.equal(await pending, "\nSSH资源\n登录方式:");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex finds fresh match after rejected stale overlap", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`SSH资源${"x".repeat(600)}`);
|
||||
|
||||
const pending = buffer.waitForRegex(".*SSH资源.*登录方式.*", 1000);
|
||||
buffer.append("\n登录方式:\nSSH资源\n登录方式:");
|
||||
assert.equal(await pending, "\nSSH资源\n登录方式:");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex rejects preexisting stale prefix with tail suffix", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`SSH资源${"x".repeat(600)}登录方式:`);
|
||||
|
||||
const pending = buffer.waitForRegex(".*SSH资源.*登录方式.*", 1000);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nSSH资源\n登录方式:");
|
||||
assert.equal(await pending, "\nSSH资源\n登录方式:");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex does not loop forever on stale zero-length matches", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`READY${"x".repeat(600)}`);
|
||||
|
||||
await assert.rejects(
|
||||
buffer.waitForRegex(/(?=READY)/, 50),
|
||||
/waitForRegex timed out/,
|
||||
);
|
||||
});
|
||||
|
||||
test("shell prompt regex matches root and user prompts", () => {
|
||||
assert.match("root@VM-4-16-ubuntu:~# ", SHELL_PROMPT_END_REGEX);
|
||||
assert.match("user@host:~$ ", SHELL_PROMPT_END_REGEX);
|
||||
assert.doesNotMatch("Welcome to Ubuntu 22.04", SHELL_PROMPT_END_REGEX);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForAny matches shell prompt patterns", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForAny(["# ", "$ ", SHELL_PROMPT_END_REGEX], 1000);
|
||||
buffer.append("root@VM-4-16-ubuntu:~# ");
|
||||
assert.equal(await pending, 0);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor ignores stale scrollback not near buffer tail", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`${"x".repeat(600)}Do you want to reset password? ${"x".repeat(600)}`);
|
||||
|
||||
const pending = buffer.waitFor(/Do you want to reset password/, 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("Do you want to reset password? ");
|
||||
assert.equal(await pending, "Do you want to reset password");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor resolves prompt already at buffer tail", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append("root@host:~# ");
|
||||
assert.equal(await buffer.waitFor("# ", 1000), "# ");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor ignores stale prompt before cursor", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append("user@host:~$ ");
|
||||
const first = buffer.waitFor("$ ", 1000);
|
||||
assert.equal(await first, "$ ");
|
||||
|
||||
const second = buffer.waitFor("$ ", 1000);
|
||||
let resolvedEarly = false;
|
||||
void second.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("ls output\nuser@host:~$ ");
|
||||
assert.equal(await second, "$ ");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer markCurrentOutputConsumed prevents startup text from matching", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append("previous deploy READY\nuser@host:~$ ");
|
||||
buffer.markCurrentOutputConsumed({ preserveTailPatterns: shellPromptPatterns() });
|
||||
|
||||
const pending = buffer.waitFor("READY", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("fresh READY\n");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer markCurrentOutputConsumed preserves the startup prompt once", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append("root@host:~# ");
|
||||
buffer.markCurrentOutputConsumed({ preserveTailPatterns: shellPromptPatterns() });
|
||||
|
||||
assert.equal(
|
||||
await buffer.waitForAny(
|
||||
shellPromptPatterns(),
|
||||
1000,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
const second = buffer.waitForAny(shellPromptPatterns(), 200);
|
||||
let resolvedEarly = false;
|
||||
void second.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("root@host:~# ");
|
||||
assert.equal(await second, 0);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer normal waitForAny does not consume preserved startup prompts", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append("previous deploy READY\nuser@host:~$ ");
|
||||
buffer.markCurrentOutputConsumed({ preserveTailPatterns: shellPromptPatterns() });
|
||||
|
||||
const pending = buffer.waitForAny(["READY", ...shellPromptPatterns()], 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("READY\n");
|
||||
assert.equal(await pending, 0);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer preserved prompt can be consumed explicitly for waitForPrompt", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append("previous deploy READY\nuser@host:~$ ");
|
||||
buffer.markCurrentOutputConsumed({ preserveTailPatterns: shellPromptPatterns() });
|
||||
|
||||
assert.equal(
|
||||
await buffer.waitForAny(
|
||||
["READY", ...shellPromptPatterns()],
|
||||
1000,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor slash regex matches output followed by long multi-line burst", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitFor("/SSH资源\\(/", 1000);
|
||||
const menu = Array.from({ length: 15 }, (_, i) => ` [${i}] menu entry line`).join("\n");
|
||||
buffer.append(`user login success\n\nSSH资源(5) :\n${menu}\n\n> `);
|
||||
assert.equal(await pending, "SSH资源(");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForText matches literal text followed by long multi-line burst", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForText("SSH资源(", 1000);
|
||||
buffer.append(`SSH资源(5) :\n${"x".repeat(600)}`);
|
||||
assert.equal(await pending, "SSH资源(");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex matches core followed by long multi-line burst", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForRegex(".*SSH资源\\(.*", 1000);
|
||||
const burst = `SSH资源(5) :\n${"x".repeat(600)}`;
|
||||
buffer.append(burst);
|
||||
assert.equal(await pending, burst);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForAny matches pattern followed by long multi-line burst", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const pending = buffer.waitForAny(["账户:", "密码:"], 1000);
|
||||
buffer.append(`资源'[Empty]'账户:\n${"x".repeat(600)}`);
|
||||
assert.equal(await pending, 0);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForText survives buffer trimming while waiting", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1", 1024);
|
||||
buffer.append("x".repeat(1000));
|
||||
const pending = buffer.waitForText("TARGET", 1000);
|
||||
buffer.append(`${"y".repeat(100)}TARGET${"z".repeat(500)}`);
|
||||
assert.equal(await pending, "TARGET");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForRegex survives buffer trimming while waiting", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1", 1024);
|
||||
buffer.append("x".repeat(1000));
|
||||
const pending = buffer.waitForRegex("TARGET", 1000);
|
||||
buffer.append(`${"y".repeat(100)}TARGET${"z".repeat(500)}`);
|
||||
assert.equal(await pending, "TARGET");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForAny survives buffer trimming while waiting", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1", 1024);
|
||||
buffer.append("x".repeat(1000));
|
||||
const pending = buffer.waitForAny(["TARGET"], 1000);
|
||||
buffer.append(`${"y".repeat(100)}TARGET${"z".repeat(500)}`);
|
||||
assert.equal(await pending, 0);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitFor still rejects pre-registration scrollback beyond tail slack", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.append(`TARGET${"x".repeat(600)}`);
|
||||
const pending = buffer.waitFor("TARGET", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
buffer.append("\nTARGET");
|
||||
assert.equal(await pending, "TARGET");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer replaceWithVisibleScreen makes the whole viewport waitable", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const header = "Welcome\n\nSSH资源(5) :\n";
|
||||
const body = Array.from({ length: 40 }, (_, i) => ` [${i}] host-${i} ${"x".repeat(20)}`).join("\n");
|
||||
const menu = `${header}${body}\n`;
|
||||
assert.ok(menu.length > 512);
|
||||
|
||||
buffer.replaceWithVisibleScreen(menu);
|
||||
assert.equal(await buffer.waitForRegex("SSH资源\\s*\\(\\d+\\)\\s*:", 200), "SSH资源(5) :");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer replaceWithVisibleScreen keeps trailing fresh output from sync race", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const menu = `SSH资源(5) :\n${"x".repeat(600)}\n`;
|
||||
buffer.replaceWithVisibleScreen(menu, "\x07\x07");
|
||||
assert.equal(await buffer.waitForRegex("SSH资源\\s*\\(\\d+\\)\\s*:", 200), "SSH资源(5) :");
|
||||
assert.match(buffer.getText(), /\x07\x07$/);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer seeded viewport does not make mid-screen prompts waitable via waitForPrompt", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const screen = [
|
||||
"user@host:~$ ",
|
||||
"running long job...",
|
||||
...Array.from({ length: 30 }, (_, i) => `output line ${i} ${"x".repeat(20)}`),
|
||||
].join("\n");
|
||||
assert.ok(screen.length > 512);
|
||||
|
||||
buffer.replaceWithVisibleScreen(screen);
|
||||
|
||||
// waitForPrompt uses allowPreservedTailMatch — mid-screen prompts must not win.
|
||||
const pending = buffer.waitForAny(shellPromptPatterns(), 200, undefined, {
|
||||
allowPreservedTailMatch: true,
|
||||
});
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nuser@host:~$ ");
|
||||
assert.equal(await pending, 1);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer seeded viewport keeps generic waitForAny matches outside the live tail", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
const header = "Option A\nOption B\n";
|
||||
const body = Array.from({ length: 40 }, (_, i) => `filler ${i} ${"x".repeat(20)}`).join("\n");
|
||||
const screen = `${header}${body}\n`;
|
||||
assert.ok(screen.length > 512);
|
||||
|
||||
buffer.replaceWithVisibleScreen(screen);
|
||||
assert.equal(await buffer.waitForAny(["Option A", "Option B"], 200), 0);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer seeded viewport still preserves a live-tail prompt for waitForPrompt", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen(`menu header\n${"x".repeat(600)}\nroot@host:~# `);
|
||||
|
||||
assert.equal(
|
||||
await buffer.waitForAny(
|
||||
shellPromptPatterns(),
|
||||
1000,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer consuming startup prompt also consumes seeded viewport above it", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen(`old READY marker\n${"x".repeat(80)}\nuser@host:~$ `);
|
||||
|
||||
assert.equal(
|
||||
await buffer.waitForAny(
|
||||
shellPromptPatterns(),
|
||||
1000,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
),
|
||||
1,
|
||||
);
|
||||
|
||||
const pending = buffer.waitForText("READY", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nfresh READY\n");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer consuming startup prompt also consumes seeded text after the prompt", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
// Short snapshot where the live-tail prompt is not the final visible text.
|
||||
buffer.replaceWithVisibleScreen("root@host:~# \nold READY\n");
|
||||
|
||||
assert.equal(
|
||||
await buffer.waitForAny(
|
||||
shellPromptPatterns(),
|
||||
1000,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
const pending = buffer.waitForText("READY", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("fresh READY\n");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForPrompt does not consume sync-race trailingFresh", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen("root@host:~# ", "\nfresh READY\n");
|
||||
|
||||
assert.equal(
|
||||
await buffer.waitForAny(
|
||||
shellPromptPatterns(),
|
||||
1000,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer does not duplicate trailingFresh already in blank-padded viewport", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
// Renderer snapshots include the full viewport, often with blank rows after
|
||||
// the live content. Sync-race trailingFresh that is already painted must not
|
||||
// be appended again or waitForText can match a phantom second copy.
|
||||
buffer.replaceWithVisibleScreen("READY\n\n\n", "READY\n");
|
||||
assert.equal(buffer.getText(), "READY\n\n\n");
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
|
||||
const pending = buffer.waitForText("READY", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nfresh READY\n");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer trims partial overlap from sync-race trailingFresh", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
// Snapshot captured READY\\n while the live buffer already has READY\\nNEXT\\n.
|
||||
buffer.replaceWithVisibleScreen("READY\n", "READY\nNEXT\n");
|
||||
assert.equal(buffer.getText(), "READY\nNEXT\n");
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
assert.equal(await buffer.waitForText("NEXT", 200), "NEXT");
|
||||
|
||||
const pending = buffer.waitForText("READY", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nfresh READY\n");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer trims blank-padded partial overlap from trailingFresh", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
// Full-viewport snapshot often pads with blank rows after live content.
|
||||
buffer.replaceWithVisibleScreen("READY\n\n", "READY\nNEXT\n");
|
||||
assert.equal(buffer.getText(), "READY\n\nNEXT\n");
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
assert.equal(await buffer.waitForText("NEXT", 200), "NEXT");
|
||||
|
||||
const pending = buffer.waitForText("READY", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\nfresh READY\n");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer keeps duplicate trailingFresh when snapshot is still pre-sync", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
// Viewport still matches syncStart while an identical READY arrived during
|
||||
// the snapshot IPC — that second marker must stay matchable.
|
||||
buffer.replaceWithVisibleScreen("READY\n", "READY\n", "READY\n");
|
||||
assert.equal(buffer.getText(), "READY\nREADY\n");
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer keeps duplicate trailingFresh when stale viewport is a syncStart suffix", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
// Pre-sync buffer had scrollback; stale snapshot still shows only the old
|
||||
// visible suffix while a second READY arrived during the IPC round-trip.
|
||||
buffer.replaceWithVisibleScreen("READY\n", "READY\nNEXT\n", "banner\nREADY\n");
|
||||
assert.equal(buffer.getText(), "READY\nREADY\nNEXT\n");
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
assert.equal(await buffer.waitForText("NEXT", 200), "NEXT");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer invalidateStartupSeed blocks seeded waits after input", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen("root@host:~# \nold READY\n");
|
||||
|
||||
buffer.invalidateStartupSeed();
|
||||
|
||||
const pendingPrompt = buffer.waitForAny(
|
||||
shellPromptPatterns(),
|
||||
200,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
);
|
||||
const pendingText = buffer.waitForText("READY", 200);
|
||||
let promptEarly = false;
|
||||
let textEarly = false;
|
||||
void pendingPrompt.then(() => {
|
||||
promptEarly = true;
|
||||
});
|
||||
void pendingText.then(() => {
|
||||
textEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(promptEarly, false);
|
||||
assert.equal(textEarly, false);
|
||||
|
||||
buffer.append("command output\nroot@host:~# ");
|
||||
assert.equal(await pendingPrompt, 0);
|
||||
|
||||
buffer.append("\nfresh READY\n");
|
||||
assert.equal(await pendingText, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer invalidateStartupSeed also consumes pre-input trailingFresh", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen("root@host:~# ", "\nfresh READY\n");
|
||||
|
||||
buffer.invalidateStartupSeed();
|
||||
|
||||
const pending = buffer.waitForText("READY", 200);
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("\npost-command READY\n");
|
||||
assert.equal(await pending, "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForPrompt keeps long sync-race trailingFresh matchable", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen("root@host:~# ", `\nREADY\n${"x".repeat(600)}`);
|
||||
|
||||
assert.equal(
|
||||
await buffer.waitForAny(
|
||||
shellPromptPatterns(),
|
||||
1000,
|
||||
undefined,
|
||||
{ allowPreservedTailMatch: true },
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForPrompt does not rematch short seeded prompt after live output", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen("root@host:~# ");
|
||||
|
||||
// Live output clears preservedTailMatch; the old prompt must not win via the
|
||||
// normal 512-byte window on a short seeded screen.
|
||||
buffer.append("echo hi\nhi\n");
|
||||
|
||||
const pending = buffer.waitForAny(shellPromptPatterns(), 200, undefined, {
|
||||
allowPreservedTailMatch: true,
|
||||
});
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("root@host:~# ");
|
||||
assert.equal(await pending, 0);
|
||||
});
|
||||
|
||||
test("SessionOutputBuffer waitForPrompt ignores preserved prompt after newer output is consumed", async () => {
|
||||
const buffer = new SessionOutputBuffer("s1");
|
||||
buffer.replaceWithVisibleScreen("root@host:~# ", "\nfresh READY\n");
|
||||
|
||||
assert.equal(await buffer.waitForText("READY", 200), "READY");
|
||||
assert.ok(buffer.scanOffset > 0);
|
||||
|
||||
const pending = buffer.waitForAny(shellPromptPatterns(), 200, undefined, {
|
||||
allowPreservedTailMatch: true,
|
||||
});
|
||||
let resolvedEarly = false;
|
||||
void pending.then(() => {
|
||||
resolvedEarly = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(resolvedEarly, false);
|
||||
|
||||
buffer.append("root@host:~# ");
|
||||
assert.equal(await pending, 0);
|
||||
});
|
||||
|
||||
test("stepsToJavaScript sends sensitive prompt result", () => {
|
||||
const code = stepsToJavaScript([
|
||||
{ type: "send", value: "secret", sensitive: true },
|
||||
{ type: "waitForPrompt", timeoutMs: 30000 },
|
||||
], "2026-06-27");
|
||||
assert.match(code, /const sensitiveValue0 = await nct\.dialog\.prompt\("Enter sensitive value", "", \{ sensitive: true \}\);/);
|
||||
assert.match(code, /await nct\.screen\.sendLine\(sensitiveValue0, \{ sensitive: true \}\);/);
|
||||
});
|
||||
|
||||
test("stepsToJavaScript generates sendLine and waitForPrompt steps", () => {
|
||||
const code = stepsToJavaScript([
|
||||
{ type: "waitForPrompt", timeoutMs: 30000 },
|
||||
{ type: "send", value: "ls -la" },
|
||||
{ type: "waitFor", value: "DONE", timeoutMs: 5000 },
|
||||
{ type: "waitForPrompt", timeoutMs: 30000 },
|
||||
], "2026-06-27");
|
||||
assert.match(code, /sendLine\("ls -la"\)/);
|
||||
assert.match(code, /waitForText\("DONE", 5000\)/);
|
||||
assert.match(code, /waitForPrompt\(30000\)/);
|
||||
assert.doesNotMatch(code, /waitFor\("DONE"/);
|
||||
});
|
||||
17
electron/scripts/shellPromptPatterns.cjs
Normal file
17
electron/scripts/shellPromptPatterns.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
/** @see domain/snippetScript.ts DEFAULT_SHELL_PROMPT_PATTERNS */
|
||||
const DEFAULT_SHELL_PROMPT_PATTERNS = ["# ", "$ ", "~# ", "~$ ", "% "];
|
||||
|
||||
/** @see domain/snippetScript.ts SHELL_PROMPT_END_REGEX */
|
||||
const SHELL_PROMPT_END_REGEX = /(?:~[#$]\s*|[@][^\n]{0,120}[:][^\n]{0,120}[#$%]\s*)$/m;
|
||||
|
||||
function shellPromptPatterns() {
|
||||
return [...DEFAULT_SHELL_PROMPT_PATTERNS, SHELL_PROMPT_END_REGEX];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SHELL_PROMPT_PATTERNS,
|
||||
SHELL_PROMPT_END_REGEX,
|
||||
shellPromptPatterns,
|
||||
};
|
||||
Reference in New Issue
Block a user