812 lines
26 KiB
JavaScript
812 lines
26 KiB
JavaScript
|
|
"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);
|
||
|
|
});
|