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"));
}