Files
Netcatty/electron/scripts/scriptRuntime.test.cjs
T
HLD c79d51858a feat: add script dialog form controls (#1907)
* feat: add script dialog form controls

* feat: add script dialog text and number fields

* feat: add conditional script dialog fields

* fix: address script dialog review feedback

* fix: harden script dialog form validation

* fix: harden script dialog review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-07-04 15:04:08 +08:00

430 lines
13 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createScriptRuntime,
wrapScriptSource,
interruptibleSleep,
normalizeDialogFormSpec,
} = 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 executes simple log 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("nct.log('hello');");
assert.deepEqual(logs, ["hello"]);
});
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"]);
});