noxid-cli 0.2.0

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use std::fs;
use std::path::{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-plugins-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(&root).expect("create WO-20 plugin fixture");
        fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
            .expect("write ESM package marker");
        let fixture = Self { root };
        fixture.write("Noxid.toml", "[app]\ntitle = \"WO-20 plugins\"\n");
        fixture.write(
            "src/routes/+page.nox",
            r#"component PluginPage {
    route { title: "Plugins" }
    view { <main>Plugins</main> }
}
"#,
        );
        fixture
    }

    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 WO-20 plugin fixture parent");
        }
        fs::write(path, contents).expect("write WO-20 plugin fixture file");
    }

    fn build(&self) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(["build", self.root.to_str().expect("UTF-8 fixture")])
            .arg("--out-dir")
            .arg(self.root.join("dist"))
            .output()
            .expect("run WO-20 plugin build")
    }

    fn run_node(&self, name: &str, source: &str) -> Output {
        let script = self.root.join("dist").join(name);
        fs::write(&script, source).expect("write WO-20 plugin Node script");
        Command::new("node")
            .arg(script.file_name().expect("Node script filename"))
            .current_dir(self.root.join("dist"))
            .output()
            .expect("run WO-20 plugin Node script")
    }
}

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 assert_failure(output: &Output, code: &str) {
    assert!(
        !output.status.success(),
        "expected {code}, stdout:\n{}",
        String::from_utf8_lossy(&output.stdout)
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains(code), "missing {code}:\n{stderr}");
}

fn assert_output_absent(path: &Path) {
    assert!(
        !path.exists(),
        "failed server planning published {}",
        path.display()
    );
}

#[test]
fn plugins_start_once_in_filename_order_with_frozen_context_and_typescript_support() {
    let fixture = Fixture::new("ordered");
    fixture.write(
        "server/host.js",
        "export const hostMarker = \"host-ready\";\nexport const actions = Object.freeze({});\n",
    );
    fixture.write(
        "server/plugins/01.first.ts",
        r#"export default async function first(context: { environment: { token?: string }, storage: Function, host: { hostMarker?: string } }): Promise<void> {
  if (!Object.isFrozen(context)) throw new Error("plugin context was mutable");
  if (context.environment.token !== "environment-ready") throw new Error("environment missing");
  if (context.host.hostMarker !== "host-ready") throw new Error("host module missing");
  globalThis.__pluginOrder = ["first"];
  await new Promise((resolve) => setTimeout(resolve, 15));
  await context.storage("wo20-plugin").set("started", { source: "first" });
}
"#,
    );
    fixture.write(
        "server/plugins/02.second.js",
        r#"export default async function second({ storage }) {
  if (JSON.stringify(globalThis.__pluginOrder) !== JSON.stringify(["first"])) throw new Error("plugins ran out of order");
  const started = await storage("wo20-plugin").get("started");
  if (started?.source !== "first") throw new Error("plugin storage context was not shared");
  globalThis.__pluginOrder.push("second");
  globalThis.__pluginStarts = (globalThis.__pluginStarts ?? 0) + 1;
}
"#,
    );

    let build = fixture.build();
    assert_success(&build, "build ordered plugins");
    for emitted in [
        "dist/server/handler.js",
        "dist/server/plugins.js",
        "dist/server/plugins/01.first.js",
        "dist/server/plugins/02.second.js",
        "dist/server/noxid-server.js",
    ] {
        assert!(fixture.root.join(emitted).is_file(), "missing {emitted}");
    }
    let typescript = fs::read_to_string(fixture.root.join("dist/server/plugins/01.first.js"))
        .expect("read emitted TypeScript plugin");
    assert!(!typescript.contains("Promise<void>"));
    let output = fixture.run_node(
        "ordered.mjs",
        r#"import { fetch } from "./server/handler.js";
const environment = Object.freeze({ token: "environment-ready" });
const requests = Array.from({ length: 8 }, () => fetch(new Request("https://example.test/missing"), environment));
await Promise.all(requests);
await fetch(new Request("https://example.test/still-missing"), environment);
if (JSON.stringify(globalThis.__pluginOrder) !== JSON.stringify(["first", "second"])) throw new Error(`wrong order: ${JSON.stringify(globalThis.__pluginOrder)}`);
if (globalThis.__pluginStarts !== 1) throw new Error(`plugins restarted: ${globalThis.__pluginStarts}`);
"#,
    );
    assert_success(&output, "execute ordered plugins");
}

#[test]
fn throwing_plugin_aborts_startup_stays_failed_and_stops_later_plugins() {
    let fixture = Fixture::new("failure");
    fixture.write(
        "server/plugins/01.fail.js",
        r#"export default async function fail() {
  globalThis.__failingPluginStarts = (globalThis.__failingPluginStarts ?? 0) + 1;
  throw new Error("deliberate plugin failure");
}
"#,
    );
    fixture.write(
        "server/plugins/02.must-not-run.js",
        "export default () => { globalThis.__laterPluginRan = true; };\n",
    );
    let build = fixture.build();
    assert_success(&build, "build failing plugin");
    let output = fixture.run_node(
        "failure.mjs",
        r#"import { fetch } from "./server/handler.js";
for (let attempt = 0; attempt < 2; attempt += 1) {
  let rejected = false;
  try { await fetch(new Request("https://example.test/missing")); }
  catch (error) { rejected = error?.message === "deliberate plugin failure"; }
  if (!rejected) throw new Error("throwing plugin did not abort startup");
}
if (globalThis.__failingPluginStarts !== 1) throw new Error("failed startup was retried");
if (globalThis.__laterPluginRan === true) throw new Error("startup continued after failure");
"#,
    );
    assert_success(&output, "execute failing plugin");
}

#[test]
fn plugin_only_project_emits_a_server_handler() {
    let fixture = Fixture::new("plugin-only");
    fixture.write(
        "server/plugins/start.js",
        "export default ({ environment }) => { globalThis.__pluginOnly = environment.ready; };\n",
    );
    let build = fixture.build();
    assert_success(&build, "build plugin-only project");
    let manifest =
        fs::read_to_string(fixture.root.join("dist/app.manifest.json")).expect("read app manifest");
    assert!(manifest.contains("\"fetchHandler\": \"server/handler.js\""));
    let output = fixture.run_node(
        "plugin-only.mjs",
        r#"import { fetch } from "./server/handler.js";
await fetch(new Request("https://example.test/missing"), { ready: "yes" });
if (globalThis.__pluginOnly !== "yes") throw new Error("plugin-only startup was omitted");
"#,
    );
    assert_success(&output, "execute plugin-only project");
}

#[test]
fn duplicate_ts_and_js_plugin_stems_fail_before_output_is_published() {
    let fixture = Fixture::new("duplicate");
    fixture.write("server/plugins/start.js", "export default () => {};\n");
    fixture.write("server/plugins/start.ts", "export default () => {};\n");
    let build = fixture.build();
    assert_failure(&build, "SERVER_PLUGIN_DUPLICATE");
    assert_output_absent(&fixture.root.join("dist"));
}

#[test]
fn nested_plugin_directories_fail_before_output_is_published() {
    let fixture = Fixture::new("nested");
    fixture.write(
        "server/plugins/nested/start.js",
        "export default () => {};\n",
    );
    let build = fixture.build();
    assert_failure(&build, "SERVER_PLUGIN_NESTED");
    assert_output_absent(&fixture.root.join("dist"));
}