use crate::engine::ctx::Ctx as EngineCtx;
use crate::engine::mock_server::BridgedRequest;
use crate::engine::{ScenarioInfo, ScenarioResult, ScriptHost, TopLevel};
use rquickjs::loader::{ImportAttributes, Loader, Resolver};
use rquickjs::module::Declared;
use rquickjs::{
AsyncContext, AsyncRuntime, Ctx as JsCtx, Error as JsError, Function, Module, Persistent, Value,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
pub type EnvVars = Arc<Mutex<HashMap<String, String>>>;
#[derive(Default)]
pub struct Registry {
scenarios: Mutex<Vec<(ScenarioInfo, Persistent<Function<'static>>)>>,
setup: Mutex<Option<Persistent<Function<'static>>>>,
teardown: Mutex<Option<Persistent<Function<'static>>>>,
pub(super) bridged: Mutex<
Vec<(
mpsc::Receiver<BridgedRequest>,
Persistent<Function<'static>>,
)>,
>,
}
impl Registry {
pub fn add(&self, info: ScenarioInfo, body: Persistent<Function<'static>>) {
self.scenarios.lock().unwrap().push((info, body));
}
pub fn add_bridged(
&self,
rx: mpsc::Receiver<BridgedRequest>,
body: Persistent<Function<'static>>,
) {
self.bridged.lock().unwrap().push((rx, body));
}
pub fn set_setup(&self, body: Persistent<Function<'static>>) {
*self.setup.lock().unwrap() = Some(body);
}
pub fn set_teardown(&self, body: Persistent<Function<'static>>) {
*self.teardown.lock().unwrap() = Some(body);
}
fn setup_fn(&self) -> Option<Persistent<Function<'static>>> {
self.setup.lock().unwrap().clone()
}
fn teardown_fn(&self) -> Option<Persistent<Function<'static>>> {
self.teardown.lock().unwrap().clone()
}
fn infos(&self) -> Vec<ScenarioInfo> {
self.scenarios
.lock()
.unwrap()
.iter()
.map(|(i, _)| i.clone())
.collect()
}
}
pub struct JsHost {
rt: AsyncRuntime,
context: AsyncContext,
registry: Arc<Registry>,
source: String,
label: String,
entry_name: String,
}
impl JsHost {
pub fn new(
engine: Arc<EngineCtx>,
source: String,
label: String,
env: EnvVars,
overrides: HashMap<String, String>,
base_dir: PathBuf,
) -> anyhow::Result<Self> {
let rt = AsyncRuntime::new()?;
futures_executor::block_on(rt.set_loader(FsResolver, FsLoader));
let context = futures_executor::block_on(AsyncContext::full(&rt))?;
let entry_name = std::fs::canonicalize(&base_dir)
.unwrap_or_else(|_| base_dir.clone())
.join(
Path::new(&label)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("scenario.js")),
)
.to_string_lossy()
.into_owned();
#[allow(clippy::arc_with_non_send_sync)]
let registry = Arc::new(Registry::default());
futures_executor::block_on(context.with(|ctx| {
super::bindings::install(&ctx, &engine, ®istry, &env, &overrides, &base_dir)
}))?;
Ok(Self {
rt,
context,
registry,
source,
label,
entry_name,
})
}
pub fn check_syntax(&self) -> Result<(), String> {
let name = self.entry_name.clone();
let source = self.source.clone();
let label = self.label.clone();
futures_executor::block_on(
self.context.async_with(async move |ctx| {
match Module::declare(ctx.clone(), name, source) {
Ok(_) => Ok(()),
Err(e) => Err(super::dsl::core::format_exception(&ctx, e, &label)),
}
}),
)
}
}
impl ScriptHost for JsHost {
fn run_top_level(&mut self) -> TopLevel {
let label = self.label.clone();
let name = self.entry_name.clone();
let source = self.source.clone();
let top: Result<(), String> =
futures_executor::block_on(self.context.async_with(async move |ctx| {
match Module::evaluate(ctx.clone(), name, source) {
Ok(promise) => promise
.into_future::<()>()
.await
.map_err(|e| super::dsl::core::format_exception(&ctx, e, &label)),
Err(e) => Err(super::dsl::core::format_exception(&ctx, e, &label)),
}
}));
let scenarios = self.registry.infos();
if scenarios.is_empty() {
TopLevel::Single(top)
} else {
TopLevel::Suite {
scenarios,
top_error: top.err(),
}
}
}
fn run_scenario(&mut self, name: &str) -> ScenarioResult {
let label = self.label.clone();
let registry = self.registry.clone();
futures_executor::block_on(self.context.async_with(async |ctx| {
let entry = registry
.scenarios
.lock()
.unwrap()
.iter()
.find(|(i, _)| i.name == name)
.map(|(_, f)| f.clone());
let Some(persistent) = entry else {
return ScenarioResult::Failed(format!("scenario `{name}` not registered"));
};
let body: Function = match persistent.restore(&ctx) {
Ok(f) => f,
Err(e) => return ScenarioResult::Failed(format!("restore `{name}`: {e}")),
};
let mut sctx = Value::new_undefined(ctx.clone());
if let Some(setup) = registry.setup_fn() {
if let Ok(f) = setup.restore(&ctx) {
match await_value(&f, ()).await {
Ok(v) => sctx = v,
Err(e) => {
return super::dsl::scenario::classify_scenario_error(&ctx, e, &label);
}
}
}
}
let result = match await_body(&body, (sctx.clone(),)).await {
Ok(()) => ScenarioResult::Passed,
Err(e) => super::dsl::scenario::classify_scenario_error(&ctx, e, &label),
};
if let Some(teardown) = registry.teardown_fn() {
if let Ok(f) = teardown.restore(&ctx) {
let _ = await_body(&f, (sctx,)).await;
}
}
result
}))
}
}
async fn await_body<'js, A>(f: &Function<'js>, args: A) -> rquickjs::Result<()>
where
A: rquickjs::function::IntoArgs<'js>,
{
f.call::<_, rquickjs::promise::MaybePromise<'js>>(args)?
.into_future::<()>()
.await
}
async fn await_value<'js, A>(f: &Function<'js>, args: A) -> rquickjs::Result<Value<'js>>
where
A: rquickjs::function::IntoArgs<'js>,
{
f.call::<_, rquickjs::promise::MaybePromise<'js>>(args)?
.into_future::<Value<'js>>()
.await
}
struct FsResolver;
impl Resolver for FsResolver {
fn resolve<'js>(
&mut self,
_ctx: &JsCtx<'js>,
base: &str,
name: &str,
_attrs: Option<ImportAttributes<'js>>,
) -> rquickjs::Result<String> {
let target = if name.starts_with('.') {
Path::new(base)
.parent()
.unwrap_or_else(|| Path::new("."))
.join(name)
} else {
PathBuf::from(name)
};
let target = if target.extension().is_some() {
target
} else {
target.with_extension("js")
};
std::fs::canonicalize(&target)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|_| JsError::new_resolving(base.to_string(), name.to_string()))
}
}
struct FsLoader;
impl Loader for FsLoader {
fn load<'js>(
&mut self,
ctx: &JsCtx<'js>,
name: &str,
_attrs: Option<ImportAttributes<'js>>,
) -> rquickjs::Result<Module<'js, Declared>> {
let source = std::fs::read(name).map_err(|_| JsError::new_loading(name.to_string()))?;
Module::declare(ctx.clone(), name, source)
}
}
impl Drop for JsHost {
fn drop(&mut self) {
self.registry.scenarios.lock().unwrap().clear();
*self.registry.setup.lock().unwrap() = None;
*self.registry.teardown.lock().unwrap() = None;
self.registry.bridged.lock().unwrap().clear();
let _ = &self.rt;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::ctx::Ctx as EngineCtx;
use crate::engine::{ScriptHost, TopLevel};
use crate::runtime::report::{Human, Level, Reporter};
use std::time::Duration;
fn make_host(source: &str) -> JsHost {
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let reporter: Box<dyn Reporter + Send> = Box::new(Human::new(Level::Quiet));
let ctx = Arc::new(EngineCtx::new(
rt.handle().clone(),
reporter,
Duration::from_secs(5),
));
std::mem::forget(rt);
JsHost::new(
ctx,
source.to_string(),
"test.js".to_string(),
Arc::new(Mutex::new(HashMap::new())),
HashMap::new(),
PathBuf::from("."),
)
.unwrap()
}
#[test]
fn scenario_each_registers_one_scenario_per_row() {
let src = r#"
scenario.each([
{ name: "alpha", n: 1 },
{ name: "beta", n: 2 },
{ name: "gamma", n: 3 },
])("param: $name", { tags: ["param"] }, (ctx, p) => {
log("row " + p.name + " n=" + p.n);
});
"#;
let mut host = make_host(src);
let top = host.run_top_level();
let scenarios = match top {
TopLevel::Suite {
scenarios,
top_error,
} => {
assert!(top_error.is_none(), "top-level error: {:?}", top_error);
scenarios
}
other => panic!("expected Suite, got {:?}", other),
};
assert_eq!(scenarios.len(), 3, "should register 3 scenarios");
assert_eq!(scenarios[0].name, "param: alpha");
assert_eq!(scenarios[1].name, "param: beta");
assert_eq!(scenarios[2].name, "param: gamma");
assert_eq!(scenarios[0].tags, vec!["param".to_string()]);
}
#[test]
fn scenario_each_without_opts() {
let src = r#"
scenario.each([{ k: "x" }, { k: "y" }])("no-opts: $k", (ctx, p) => {
log(p.k);
});
"#;
let mut host = make_host(src);
let top = host.run_top_level();
let scenarios = match top {
TopLevel::Suite { scenarios, .. } => scenarios,
other => panic!("expected Suite, got {:?}", other),
};
assert_eq!(scenarios.len(), 2);
assert_eq!(scenarios[0].name, "no-opts: x");
assert_eq!(scenarios[1].name, "no-opts: y");
}
#[test]
fn scenario_each_passes_row_to_body() {
let src = r#"
var received = null;
scenario.each([{ val: 42 }])("row check", (ctx, p) => {
received = p.val;
});
"#;
let mut host = make_host(src);
let top = host.run_top_level();
assert!(matches!(top, TopLevel::Suite { .. }));
let result = host.run_scenario("row check");
assert!(
matches!(result, ScenarioResult::Passed),
"scenario should pass, got {:?}",
result
);
}
}