use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new(label: &str) -> Self {
let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"noxid-wo20-task-runtime-{label}-{}-{ordinal}",
std::process::id()
));
fs::create_dir_all(root.join("server/tasks")).expect("create task fixture");
fs::write(
root.join("Noxid.toml"),
"[app]\ntitle = \"Task runtime\"\nbase = \"/console\"\n\n[deploy]\nadapter = \"node\"\n",
)
.expect("write task project config");
fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
.expect("write module marker");
Self { root }
}
fn write(&self, relative: &str, contents: &str) {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create fixture parent");
}
fs::write(path, contents).expect("write fixture file");
}
fn noxid(&self, arguments: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(arguments)
.current_dir(&self.root)
.output()
.expect("run noxid command")
}
fn build(&self) -> Output {
self.noxid(&["build", ".", "--out-dir", "dist"])
}
fn run_node(&self, source: &str) -> Output {
let path = self.root.join("dist/task-runtime-test.mjs");
fs::write(&path, source).expect("write Node assertion");
Command::new("node")
.arg(path.file_name().expect("test script filename"))
.current_dir(self.root.join("dist"))
.output()
.expect("run Node assertion")
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn assert_success(output: &Output, context: &str) {
assert!(
output.status.success(),
"{context}:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn write_tasks(fixture: &Fixture) {
fixture.write(
"server/tasks/empty.nox",
"task Empty { schedule: \"0 * * * *\" handler { } }\n",
);
fixture.write(
"server/tasks/host.nox",
"task HostWork { schedule: \"1 0 * * *\" }\n",
);
fixture.write(
"server/host.js",
r#"export async function authorize({ capability, environment }) {
environment.authorized = (environment.authorized ?? 0) + 1;
return capability === "tasks.run" && (environment.allow === true || environment.NOXID_TASK_ALLOW === "yes");
}
export const tasks = Object.freeze({
"task:HostWork": async (_arguments, { environment, semanticId }) => {
(environment.calls ??= []).push(semanticId);
if (environment.taskGate) await environment.taskGate;
return "host-ran";
},
});
"#,
);
}
#[test]
fn task_trigger_is_post_only_authorized_and_executes_empty_compiler_and_host_handlers() {
let fixture = Fixture::new("trigger");
write_tasks(&fixture);
assert_success(&fixture.build(), "build task-only project");
let manifest = fs::read_to_string(fixture.root.join("dist/server/tasks.manifest.json"))
.expect("read task security manifest");
assert!(manifest.contains("\"name\":\"Empty\",\"schedule\":\"0 * * * *\",\"hostKey\":null"));
assert!(manifest.contains(
"\"name\":\"HostWork\",\"schedule\":\"1 0 * * *\",\"hostKey\":\"task:HostWork\""
));
let node = fixture.run_node(
r#"import { fetch as handle } from "./server/handler.js";
const environment = { allow: false, calls: [] };
let response = await handle(new Request("http://noxid.test/console/_noxid/tasks/HostWork"), environment);
let body = await response.json();
if (response.status !== 405 || body.error?.code !== "TASK_METHOD") throw new Error(JSON.stringify(body));
response = await handle(new Request("http://noxid.test/console/_noxid/tasks/HostWork", { method: "POST" }), environment);
body = await response.json();
if (response.status !== 403 || body.error?.code !== "TASK_CAPABILITY_DENIED" || environment.calls.length !== 0) throw new Error(JSON.stringify(body));
environment.allow = true;
response = await handle(new Request("http://noxid.test/console/_noxid/tasks/Empty", { method: "POST" }), environment);
body = await response.json();
if (response.status !== 200 || body.value !== null) throw new Error(JSON.stringify(body));
response = await handle(new Request("http://noxid.test/console/_noxid/tasks/HostWork", { method: "POST" }), environment);
body = await response.json();
if (response.status !== 200 || body.value !== "host-ran" || environment.calls.join() !== "task:HostWork") throw new Error(JSON.stringify(body));
if (environment.authorized !== 3) throw new Error(`authorization count changed: ${environment.authorized}`);
"#,
);
assert_success(&node, "exercise generated task trigger");
}
#[test]
fn node_scheduler_uses_utc_cron_and_routes_due_work_through_authorization() {
let fixture = Fixture::new("scheduler");
write_tasks(&fixture);
assert_success(&fixture.build(), "build scheduled task project");
let node = fixture.run_node(
r#"import { startTaskScheduler } from "./server/handler.js";
const environment = { allow: true, calls: [] };
const timers = [];
const scheduler = startTaskScheduler(environment, Object.create(null), {
now: () => new Date("2026-01-05T00:00:30.000Z"),
setTimeout: (callback, delay) => { timers.push({ callback, delay }); return timers.length; },
clearTimeout: () => {},
onError: (_task, error) => { throw error; },
});
if (timers.length !== 1 || timers[0].delay !== 30_000) throw new Error(JSON.stringify(timers));
timers.shift().callback();
await new Promise((resolve) => setTimeout(resolve, 0));
if (environment.calls.join() !== "task:HostWork" || environment.authorized !== 1) throw new Error(JSON.stringify(environment));
scheduler.stop();
"#,
);
assert_success(&node, "exercise deterministic Node task scheduler");
}
#[test]
fn stopping_scheduler_joins_a_task_that_is_already_running() {
let fixture = Fixture::new("scheduler-stop-join");
write_tasks(&fixture);
assert_success(&fixture.build(), "build scheduler stop fixture");
let node = fixture.run_node(
r#"import { startTaskScheduler } from "./server/handler.js";
let releaseTask;
const environment = {
allow: true,
calls: [],
taskGate: new Promise((resolve) => { releaseTask = resolve; }),
};
const timers = [];
const scheduler = startTaskScheduler(environment, Object.create(null), {
now: () => new Date("2026-01-05T00:00:30.000Z"),
setTimeout: (callback) => { timers.push(callback); return timers.length; },
clearTimeout: () => {},
onError: (_task, error) => { throw error; },
});
timers.shift()();
await new Promise((resolve) => setImmediate(resolve));
if (environment.calls.join() !== "task:HostWork") throw new Error("scheduled task did not start");
let stopped = false;
const stopping = scheduler.stop().then(() => { stopped = true; });
await new Promise((resolve) => setImmediate(resolve));
if (stopped) throw new Error("scheduler stop returned before its active task finished");
releaseTask();
await stopping;
if (!stopped) throw new Error("scheduler stop did not join the active task");
"#,
);
assert_success(&node, "join active task while stopping scheduler");
}
#[test]
fn manual_task_command_runs_the_same_capability_guarded_trigger() {
let fixture = Fixture::new("manual");
write_tasks(&fixture);
let allowed = Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["task", "run", "HostWork"])
.env("NOXID_TASK_TEST_UNUSED", "1")
.current_dir(&fixture.root)
.output()
.expect("run manual task command");
assert!(
!allowed.status.success(),
"manual task unexpectedly bypassed host authorization"
);
let stderr = String::from_utf8_lossy(&allowed.stderr);
assert!(stderr.contains("TASK_CAPABILITY_DENIED"), "{stderr}");
let allowed = Command::new(env!("CARGO_BIN_EXE_noxid"))
.args(["task", "run", "HostWork"])
.env("NOXID_TASK_ALLOW", "yes")
.current_dir(&fixture.root)
.output()
.expect("run authorized manual task command");
assert_success(&allowed, "run authorized manual task command");
assert!(
String::from_utf8_lossy(&allowed.stdout).contains("\"value\":\"host-ran\""),
"{}",
String::from_utf8_lossy(&allowed.stdout)
);
}
#[test]
fn compiler_owned_task_namespace_cannot_be_shadowed_and_failed_rebuild_is_atomic() {
let fixture = Fixture::new("reserved");
write_tasks(&fixture);
assert_success(&fixture.build(), "build valid task project");
let before = fs::read(fixture.root.join("dist/server/handler.js")).expect("read handler");
fixture.write(
"server/routes/_noxid/tasks/[name].post.nox",
r#"endpoint ShadowTask {
params { name: String }
result: String
handler { return name }
}
"#,
);
let rejected = fixture.build();
assert!(
!rejected.status.success(),
"reserved task route unexpectedly built"
);
let stderr = String::from_utf8_lossy(&rejected.stderr);
assert!(
stderr.contains("error[ENDPOINT_SYSTEM_ROUTE_RESERVED]")
&& stderr.contains("/_noxid/tasks/<name>"),
"{stderr}"
);
let after =
fs::read(fixture.root.join("dist/server/handler.js")).expect("read preserved handler");
assert_eq!(
before, after,
"failed reserved-route rebuild mutated last-good output"
);
}