Files
NetMesh/electron/scripts/scriptRuntime.test.cjs

812 lines
26 KiB
JavaScript
Raw Normal View History

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