noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
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"
    );
}