feat(wfe-yaml): add HTTP ops, module loader, and npm support via esm.sh
Phase 4 — Permission-gated HTTP fetch op: - op_fetch with net permission check on every request - globalThis.fetch() wrapper with .json()/.text() methods - Supports GET/POST/PUT/DELETE with headers and body Phase 5 — Module loader: - WfeModuleLoader resolving npm: → esm.sh, https://, file://, relative paths - All resolution paths permission-checked - Bare path resolution (/) for esm.sh sub-module redirects - Dynamic import rejection unless permissions.dynamic_import: true - esm.sh auto-added to net allowlist when modules declared Mandatory npm integration test (is-number via esm.sh). 25 new tests. 133 total deno tests, 326 total workspace tests.
This commit is contained in:
@@ -6,7 +6,7 @@ description = "YAML workflow definitions for WFE"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
deno = ["deno_core", "url"]
|
||||
deno = ["deno_core", "deno_error", "url", "reqwest"]
|
||||
|
||||
[dependencies]
|
||||
wfe-core = { workspace = true }
|
||||
@@ -19,7 +19,9 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
deno_core = { workspace = true, optional = true }
|
||||
deno_error = { workspace = true, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
@@ -27,3 +29,5 @@ tokio = { workspace = true, features = ["test-util", "process"] }
|
||||
tokio-util = "0.7"
|
||||
wfe-core = { workspace = true, features = ["test-support"] }
|
||||
wfe = { path = "../wfe" }
|
||||
wiremock = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
11
wfe-yaml/src/executors/deno/js/bootstrap.js
vendored
11
wfe-yaml/src/executors/deno/js/bootstrap.js
vendored
@@ -1,3 +1,14 @@
|
||||
globalThis.inputs = () => Deno.core.ops.op_inputs();
|
||||
globalThis.output = (key, value) => Deno.core.ops.op_output(key, value);
|
||||
globalThis.log = (msg) => Deno.core.ops.op_log(msg);
|
||||
|
||||
globalThis.fetch = async (url, options) => {
|
||||
const resp = await Deno.core.ops.op_fetch(url, options || null);
|
||||
return {
|
||||
status: resp.status,
|
||||
ok: resp.ok,
|
||||
headers: resp.headers,
|
||||
text: () => Promise.resolve(resp.body),
|
||||
json: () => Promise.resolve(JSON.parse(resp.body)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod module_loader;
|
||||
pub mod ops;
|
||||
pub mod permissions;
|
||||
pub mod runtime;
|
||||
|
||||
391
wfe-yaml/src/executors/deno/module_loader.rs
Normal file
391
wfe-yaml/src/executors/deno/module_loader.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use deno_core::{
|
||||
ModuleLoadOptions, ModuleLoadReferrer, ModuleLoadResponse, ModuleLoader, ModuleSource,
|
||||
ModuleSourceCode, ModuleSpecifier, ModuleType, ResolutionKind,
|
||||
};
|
||||
use deno_error::JsErrorBox;
|
||||
|
||||
use super::permissions::PermissionChecker;
|
||||
|
||||
/// Custom module loader that enforces permissions and rewrites npm: specifiers.
|
||||
pub struct WfeModuleLoader {
|
||||
permissions: Rc<RefCell<PermissionChecker>>,
|
||||
}
|
||||
|
||||
impl WfeModuleLoader {
|
||||
pub fn new(permissions: Rc<RefCell<PermissionChecker>>) -> Self {
|
||||
Self { permissions }
|
||||
}
|
||||
|
||||
/// Rewrite `npm:package@version` to `https://esm.sh/package@version`.
|
||||
pub fn resolve_npm_specifier(specifier: &str) -> Option<String> {
|
||||
specifier
|
||||
.strip_prefix("npm:")
|
||||
.map(|rest| format!("https://esm.sh/{rest}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl ModuleLoader for WfeModuleLoader {
|
||||
fn resolve(
|
||||
&self,
|
||||
specifier: &str,
|
||||
referrer: &str,
|
||||
kind: ResolutionKind,
|
||||
) -> Result<ModuleSpecifier, JsErrorBox> {
|
||||
// Block dynamic imports if not allowed.
|
||||
if matches!(kind, ResolutionKind::DynamicImport) {
|
||||
let checker = self.permissions.borrow();
|
||||
if !checker.allow_dynamic_import {
|
||||
return Err(JsErrorBox::generic(
|
||||
"Dynamic import is not allowed by permissions",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ext: URLs are handled by deno_core.
|
||||
if specifier.starts_with("ext:") {
|
||||
return ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid ext URL: {e}")));
|
||||
}
|
||||
|
||||
// npm: specifier -> rewrite to esm.sh URL.
|
||||
if let Some(esm_url) = Self::resolve_npm_specifier(specifier) {
|
||||
let parsed = ModuleSpecifier::parse(&esm_url)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid esm.sh URL: {e}")))?;
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
// wfe: scheme for inline modules (used by load_main_es_module_from_code).
|
||||
if specifier.starts_with("wfe:") {
|
||||
return ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid wfe URL: {e}")));
|
||||
}
|
||||
|
||||
// Absolute file:// URL.
|
||||
if specifier.starts_with("file://") {
|
||||
let parsed = ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid file URL: {e}")))?;
|
||||
let path = parsed
|
||||
.to_file_path()
|
||||
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_read(
|
||||
path.to_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?,
|
||||
)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
// Absolute https:// URL.
|
||||
if specifier.starts_with("https://") || specifier.starts_with("http://") {
|
||||
let parsed = ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid URL: {e}")))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_net(host)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
// Relative or bare path — resolve against referrer.
|
||||
// This handles ./foo, ../foo, and /foo (absolute path on same origin, e.g. esm.sh redirects)
|
||||
if specifier.starts_with("./")
|
||||
|| specifier.starts_with("../")
|
||||
|| specifier.starts_with('/')
|
||||
{
|
||||
let base = ModuleSpecifier::parse(referrer)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid referrer '{referrer}': {e}")))?;
|
||||
let resolved = base
|
||||
.join(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Failed to resolve '{specifier}': {e}")))?;
|
||||
|
||||
// Check permissions based on scheme.
|
||||
match resolved.scheme() {
|
||||
"file" => {
|
||||
let path = resolved
|
||||
.to_file_path()
|
||||
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_read(
|
||||
path.to_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?,
|
||||
)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
}
|
||||
"https" | "http" => {
|
||||
let host = resolved
|
||||
.host_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_net(host)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
Err(JsErrorBox::generic(format!(
|
||||
"Unsupported module specifier: '{specifier}'"
|
||||
)))
|
||||
}
|
||||
|
||||
fn load(
|
||||
&self,
|
||||
module_specifier: &ModuleSpecifier,
|
||||
_maybe_referrer: Option<&ModuleLoadReferrer>,
|
||||
_options: ModuleLoadOptions,
|
||||
) -> ModuleLoadResponse {
|
||||
let specifier = module_specifier.clone();
|
||||
let permissions = self.permissions.clone();
|
||||
|
||||
match specifier.scheme() {
|
||||
"ext" => {
|
||||
// Handled by deno_core; should not reach here.
|
||||
ModuleLoadResponse::Sync(Err(JsErrorBox::generic(
|
||||
"ext: modules are handled by deno_core",
|
||||
)))
|
||||
}
|
||||
"https" | "http" => {
|
||||
// Fetch over network.
|
||||
let url = specifier.to_string();
|
||||
ModuleLoadResponse::Async(Box::pin(async move {
|
||||
// Permission check on the host.
|
||||
let host = specifier
|
||||
.host_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?
|
||||
.to_string();
|
||||
{
|
||||
let checker = permissions.borrow();
|
||||
checker
|
||||
.check_net(&host)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
}
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
JsErrorBox::generic(format!("Failed to fetch module '{url}': {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(JsErrorBox::generic(format!(
|
||||
"Failed to fetch module '{url}': HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let code: String = response.text().await.map_err(|e| {
|
||||
JsErrorBox::generic(format!("Failed to read module body '{url}': {e}"))
|
||||
})?;
|
||||
|
||||
Ok(ModuleSource::new(
|
||||
ModuleType::JavaScript,
|
||||
ModuleSourceCode::String(code.into()),
|
||||
&specifier,
|
||||
None,
|
||||
))
|
||||
}))
|
||||
}
|
||||
"file" => {
|
||||
// Read from disk.
|
||||
let path_result = specifier
|
||||
.to_file_path()
|
||||
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"));
|
||||
match path_result {
|
||||
Ok(path) => {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
||||
// Permission check.
|
||||
{
|
||||
let checker = permissions.borrow();
|
||||
if let Err(e) = checker.check_read(&path_str) {
|
||||
return ModuleLoadResponse::Sync(Err(JsErrorBox::new(
|
||||
"PermissionError",
|
||||
e.to_string(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(code) => ModuleLoadResponse::Sync(Ok(ModuleSource::new(
|
||||
ModuleType::JavaScript,
|
||||
ModuleSourceCode::String(code.into()),
|
||||
&specifier,
|
||||
None,
|
||||
))),
|
||||
Err(e) => ModuleLoadResponse::Sync(Err(JsErrorBox::generic(
|
||||
format!("Failed to read module '{}': {e}", path.display()),
|
||||
))),
|
||||
}
|
||||
}
|
||||
Err(e) => ModuleLoadResponse::Sync(Err(e)),
|
||||
}
|
||||
}
|
||||
scheme => ModuleLoadResponse::Sync(Err(JsErrorBox::generic(format!(
|
||||
"Unsupported module scheme: '{scheme}'"
|
||||
)))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::executors::deno::config::DenoPermissions;
|
||||
|
||||
fn make_loader(perms: DenoPermissions) -> WfeModuleLoader {
|
||||
let checker = PermissionChecker::from_config(&perms);
|
||||
WfeModuleLoader::new(Rc::new(RefCell::new(checker)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_npm_to_esm_sh() {
|
||||
let result = WfeModuleLoader::resolve_npm_specifier("npm:lodash@4.17.21");
|
||||
assert_eq!(result, Some("https://esm.sh/lodash@4.17.21".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_npm_scoped_package() {
|
||||
let result = WfeModuleLoader::resolve_npm_specifier("npm:@org/pkg@1.0.0");
|
||||
assert_eq!(result, Some("https://esm.sh/@org/pkg@1.0.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_non_npm_returns_none() {
|
||||
assert!(WfeModuleLoader::resolve_npm_specifier("https://example.com/mod.js").is_none());
|
||||
assert!(WfeModuleLoader::resolve_npm_specifier("./local.js").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_npm_specifier_via_loader() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["esm.sh".to_string()],
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader
|
||||
.resolve("npm:lodash@4", "ext:wfe/bootstrap.js", ResolutionKind::Import)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "https://esm.sh/lodash@4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_https_with_permission() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["cdn.example.com".to_string()],
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader
|
||||
.resolve(
|
||||
"https://cdn.example.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "https://cdn.example.com/mod.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_https_without_permission_fails() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader.resolve(
|
||||
"https://evil.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Permission denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dynamic_import_denied() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["cdn.example.com".to_string()],
|
||||
dynamic_import: false,
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader.resolve(
|
||||
"https://cdn.example.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::DynamicImport,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Dynamic import is not allowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dynamic_import_allowed() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["cdn.example.com".to_string()],
|
||||
dynamic_import: true,
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader.resolve(
|
||||
"https://cdn.example.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::DynamicImport,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ext_url() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader
|
||||
.resolve(
|
||||
"ext:wfe/bootstrap.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "ext:wfe/bootstrap.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_relative_from_file_referrer() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
read: vec!["/tmp".to_string()],
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader
|
||||
.resolve(
|
||||
"./helper.js",
|
||||
"file:///tmp/main.js",
|
||||
ResolutionKind::Import,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "file:///tmp/helper.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_relative_without_read_permission_fails() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader.resolve("./helper.js", "file:///tmp/main.js", ResolutionKind::Import);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_unsupported_specifier() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader.resolve(
|
||||
"ftp://bad.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Unsupported"));
|
||||
}
|
||||
}
|
||||
108
wfe-yaml/src/executors/deno/ops/http.rs
Normal file
108
wfe-yaml/src/executors/deno/ops/http.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use deno_core::op2;
|
||||
use deno_core::OpState;
|
||||
use deno_error::JsErrorBox;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::executors::deno::permissions::PermissionChecker;
|
||||
|
||||
/// Options for the fetch call (method, headers, body).
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct FetchOptions {
|
||||
pub method: Option<String>,
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// Response returned to JavaScript from fetch.
|
||||
#[derive(Serialize)]
|
||||
pub struct FetchResponse {
|
||||
pub status: u16,
|
||||
pub ok: bool,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub async fn op_fetch(
|
||||
state: Rc<RefCell<OpState>>,
|
||||
#[string] url: String,
|
||||
#[serde] options: Option<FetchOptions>,
|
||||
) -> Result<FetchResponse, JsErrorBox> {
|
||||
// 1. Parse URL to extract host for permission check.
|
||||
let parsed = url::Url::parse(&url)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid URL '{url}': {e}")))?;
|
||||
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| JsErrorBox::generic(format!("URL '{url}' has no host")))?
|
||||
.to_string();
|
||||
|
||||
// 2. Check net permission.
|
||||
{
|
||||
let state = state.borrow();
|
||||
let checker = state.borrow::<PermissionChecker>();
|
||||
checker
|
||||
.check_net(&host)
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 3. Build the request.
|
||||
let opts = options.unwrap_or_default();
|
||||
let method = opts.method.as_deref().unwrap_or("GET");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut builder = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
"PUT" => client.put(&url),
|
||||
"DELETE" => client.delete(&url),
|
||||
"PATCH" => client.patch(&url),
|
||||
"HEAD" => client.head(&url),
|
||||
other => {
|
||||
return Err(JsErrorBox::generic(format!(
|
||||
"Unsupported HTTP method: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref headers) = opts.headers {
|
||||
for (key, value) in headers {
|
||||
builder = builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref body) = opts.body {
|
||||
builder = builder.body(body.clone());
|
||||
}
|
||||
|
||||
// 4. Execute request.
|
||||
let response = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| JsErrorBox::generic(format!("Fetch failed for '{url}': {e}")))?;
|
||||
|
||||
// 5. Build response.
|
||||
let status = response.status().as_u16();
|
||||
let ok = response.status().is_success();
|
||||
let headers: HashMap<String, String> = response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| JsErrorBox::generic(format!("Failed to read response body: {e}")))?;
|
||||
|
||||
Ok(FetchResponse {
|
||||
status,
|
||||
ok,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod http;
|
||||
pub mod workflow;
|
||||
|
||||
pub use workflow::{StepMeta, StepOutputs, WorkflowInputs};
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn op_log(state: &mut OpState, #[string] msg: String) {
|
||||
|
||||
deno_core::extension!(
|
||||
wfe_ops,
|
||||
ops = [op_inputs, op_output, op_log],
|
||||
ops = [op_inputs, op_output, op_log, super::http::op_fetch],
|
||||
esm_entry_point = "ext:wfe/bootstrap.js",
|
||||
esm = ["ext:wfe/bootstrap.js" = "src/executors/deno/js/bootstrap.js"],
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use deno_core::JsRuntime;
|
||||
use deno_core::RuntimeOptions;
|
||||
use wfe_core::WfeError;
|
||||
|
||||
use super::config::DenoConfig;
|
||||
use super::module_loader::WfeModuleLoader;
|
||||
use super::ops::workflow::{wfe_ops, StepMeta, StepOutputs, WorkflowInputs};
|
||||
use super::permissions::PermissionChecker;
|
||||
|
||||
@@ -16,8 +19,18 @@ pub fn create_runtime(
|
||||
) -> Result<JsRuntime, WfeError> {
|
||||
let ext = wfe_ops::init();
|
||||
|
||||
// Build permissions, auto-adding esm.sh if modules are declared.
|
||||
let mut permissions = config.permissions.clone();
|
||||
if !config.modules.is_empty() && !permissions.net.iter().any(|h| h == "esm.sh") {
|
||||
permissions.net.push("esm.sh".to_string());
|
||||
}
|
||||
|
||||
let checker = Rc::new(RefCell::new(PermissionChecker::from_config(&permissions)));
|
||||
let module_loader = WfeModuleLoader::new(checker.clone());
|
||||
|
||||
let runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![ext],
|
||||
module_loader: Some(Rc::new(module_loader)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -34,12 +47,18 @@ pub fn create_runtime(
|
||||
state.put(StepMeta {
|
||||
name: step_name.to_string(),
|
||||
});
|
||||
state.put(PermissionChecker::from_config(&config.permissions));
|
||||
state.put(PermissionChecker::from_config(&permissions));
|
||||
}
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// Returns whether esm.sh would be auto-added for the given config.
|
||||
/// Exposed for testing.
|
||||
pub fn would_auto_add_esm_sh(config: &DenoConfig) -> bool {
|
||||
!config.modules.is_empty() && !config.permissions.net.iter().any(|h| h == "esm.sh")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -78,4 +97,46 @@ mod tests {
|
||||
let meta = state.borrow::<StepMeta>();
|
||||
assert_eq!(meta.name, "my-step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_add_esm_sh_when_modules_declared() {
|
||||
let config = DenoConfig {
|
||||
script: Some("1".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec!["npm:lodash@4".to_string()],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
assert!(would_auto_add_esm_sh(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_auto_add_esm_sh_when_no_modules() {
|
||||
let config = DenoConfig {
|
||||
script: Some("1".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
assert!(!would_auto_add_esm_sh(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_auto_add_esm_sh_when_already_present() {
|
||||
let config = DenoConfig {
|
||||
script: Some("1".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions {
|
||||
net: vec!["esm.sh".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
modules: vec!["npm:lodash@4".to_string()],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
assert!(!would_auto_add_esm_sh(&config));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ impl StepBody for DenoStep {
|
||||
|
||||
let config = self.config.clone();
|
||||
let timeout_ms = self.config.timeout_ms;
|
||||
let use_module = needs_module_evaluation(&source);
|
||||
let file_path = self.config.file.clone();
|
||||
|
||||
// JsRuntime is !Send, so we run it on a dedicated thread with its own
|
||||
// single-threaded tokio runtime.
|
||||
@@ -63,8 +65,19 @@ impl StepBody for DenoStep {
|
||||
})?;
|
||||
|
||||
rt.block_on(async move {
|
||||
run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms)
|
||||
if use_module {
|
||||
run_module_inner(
|
||||
&config,
|
||||
workflow_data,
|
||||
&step_name,
|
||||
&source,
|
||||
file_path.as_deref(),
|
||||
timeout_ms,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
run_script_inner(&config, workflow_data, &step_name, &source, timeout_ms).await
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -79,6 +92,13 @@ impl StepBody for DenoStep {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the source code uses ES module syntax or top-level await.
|
||||
fn needs_module_evaluation(source: &str) -> bool {
|
||||
// Top-level await requires module evaluation. ES import/export also require it.
|
||||
source.contains("import ") || source.contains("import(") || source.contains("export ")
|
||||
|| source.contains("await ")
|
||||
}
|
||||
|
||||
async fn run_script_inner(
|
||||
config: &DenoConfig,
|
||||
workflow_data: serde_json::Value,
|
||||
@@ -130,7 +150,100 @@ async fn run_script_inner(
|
||||
}
|
||||
})?;
|
||||
|
||||
// Extract outputs from OpState.
|
||||
extract_outputs(&mut runtime)
|
||||
}
|
||||
|
||||
async fn run_module_inner(
|
||||
config: &DenoConfig,
|
||||
workflow_data: serde_json::Value,
|
||||
step_name: &str,
|
||||
source: &str,
|
||||
file_path: Option<&str>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> wfe_core::Result<ExecutionResult> {
|
||||
let mut runtime = create_runtime(config, workflow_data, step_name)?;
|
||||
|
||||
let _timeout_guard = timeout_ms.map(|ms| {
|
||||
let isolate_handle = runtime.v8_isolate().thread_safe_handle();
|
||||
let duration = std::time::Duration::from_millis(ms);
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(duration);
|
||||
isolate_handle.terminate_execution();
|
||||
})
|
||||
});
|
||||
|
||||
// Determine the module URL. Use the file path if available, otherwise a synthetic URL.
|
||||
let module_url = if let Some(path) = file_path {
|
||||
let abs = std::path::Path::new(path);
|
||||
let abs = if abs.is_absolute() {
|
||||
abs.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| WfeError::StepExecution(format!("Cannot get cwd: {e}")))?
|
||||
.join(abs)
|
||||
};
|
||||
url::Url::from_file_path(&abs)
|
||||
.map_err(|_| {
|
||||
WfeError::StepExecution(format!("Cannot convert path to URL: {}", abs.display()))
|
||||
})?
|
||||
.to_string()
|
||||
} else {
|
||||
"wfe:///inline-module.js".to_string()
|
||||
};
|
||||
|
||||
let specifier = deno_core::ModuleSpecifier::parse(&module_url).map_err(|e| {
|
||||
WfeError::StepExecution(format!("Invalid module URL '{module_url}': {e}"))
|
||||
})?;
|
||||
|
||||
let module_id = runtime
|
||||
.load_main_es_module_from_code(&specifier, source.to_string())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("terminated") {
|
||||
WfeError::StepExecution(format!(
|
||||
"Deno script timed out after {}ms",
|
||||
timeout_ms.unwrap_or(0)
|
||||
))
|
||||
} else {
|
||||
WfeError::StepExecution(format!("Deno module load error: {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let eval_future = runtime.mod_evaluate(module_id);
|
||||
|
||||
// Drive the event loop to resolve imports and execute the module.
|
||||
runtime
|
||||
.run_event_loop(Default::default())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("terminated") {
|
||||
WfeError::StepExecution(format!(
|
||||
"Deno script timed out after {}ms",
|
||||
timeout_ms.unwrap_or(0)
|
||||
))
|
||||
} else {
|
||||
WfeError::StepExecution(format!("Deno event loop error: {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
eval_future.await.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("terminated") {
|
||||
WfeError::StepExecution(format!(
|
||||
"Deno script timed out after {}ms",
|
||||
timeout_ms.unwrap_or(0)
|
||||
))
|
||||
} else {
|
||||
WfeError::StepExecution(format!("Deno module error: {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
extract_outputs(&mut runtime)
|
||||
}
|
||||
|
||||
fn extract_outputs(runtime: &mut deno_core::JsRuntime) -> wfe_core::Result<ExecutionResult> {
|
||||
let outputs = {
|
||||
let state = runtime.op_state();
|
||||
let mut state = state.borrow_mut();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#![cfg(feature = "deno")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use wfe_yaml::executors::deno::config::{DenoConfig, DenoPermissions};
|
||||
use wfe_yaml::executors::deno::module_loader::WfeModuleLoader;
|
||||
use wfe_yaml::executors::deno::permissions::PermissionChecker;
|
||||
use wfe_yaml::executors::deno::runtime::create_runtime;
|
||||
use wfe_yaml::executors::deno::runtime::{create_runtime, would_auto_add_esm_sh};
|
||||
use wfe_yaml::executors::deno::step::DenoStep;
|
||||
|
||||
use wfe_core::models::execution_pointer::ExecutionPointer;
|
||||
@@ -267,3 +269,329 @@ workflow:
|
||||
let (key, _factory) = &compiled.step_factories[0];
|
||||
assert!(key.contains("deno"), "factory key should contain 'deno', got: {key}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4: HTTP op integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn config_with_net(script: &str, net_hosts: &[&str]) -> DenoConfig {
|
||||
DenoConfig {
|
||||
script: Some(script.to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions {
|
||||
net: net_hosts.iter().map(|s| s.to_string()).collect(),
|
||||
..Default::default()
|
||||
},
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: Some(10000),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_fetch_allowed_host() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
const resp = await fetch("{}");
|
||||
output("status", resp.status);
|
||||
output("body", await resp.text());
|
||||
"#,
|
||||
server.uri()
|
||||
);
|
||||
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["status"], serde_json::json!(200));
|
||||
assert_eq!(data["body"], serde_json::json!("ok"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_fetch_denied_host() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Do NOT add 127.0.0.1 to net permissions.
|
||||
let script = format!(
|
||||
r#"
|
||||
try {{
|
||||
await fetch("{}");
|
||||
output("result", "should_not_reach");
|
||||
}} catch (e) {{
|
||||
output("error", e.message || String(e));
|
||||
}}
|
||||
"#,
|
||||
server.uri()
|
||||
);
|
||||
let mut step = DenoStep::new(config_with_net(&script, &[]));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert!(
|
||||
data.get("error").is_some(),
|
||||
"expected permission error, got: {data:?}"
|
||||
);
|
||||
let err_msg = data["error"].as_str().unwrap();
|
||||
assert!(
|
||||
err_msg.contains("Permission denied"),
|
||||
"got: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_fetch_returns_json() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({"name": "alice", "age": 30})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
const resp = await fetch("{}");
|
||||
const data = await resp.json();
|
||||
output("name", data.name);
|
||||
output("age", data.age);
|
||||
"#,
|
||||
server.uri()
|
||||
);
|
||||
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["name"], serde_json::json!("alice"));
|
||||
assert_eq!(data["age"], serde_json::json!(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_fetch_post_with_body() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::header("content-type", "application/json"))
|
||||
.and(wiremock::matchers::body_json(serde_json::json!({"key": "val"})))
|
||||
.respond_with(wiremock::ResponseTemplate::new(201).set_body_string("created"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
const resp = await fetch("{}", {{
|
||||
method: "POST",
|
||||
headers: {{ "content-type": "application/json" }},
|
||||
body: JSON.stringify({{ key: "val" }})
|
||||
}});
|
||||
output("status", resp.status);
|
||||
output("body", await resp.text());
|
||||
"#,
|
||||
server.uri()
|
||||
);
|
||||
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["status"], serde_json::json!(201));
|
||||
assert_eq!(data["body"], serde_json::json!("created"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_fetch_no_permissions_denies_all() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::any())
|
||||
.respond_with(wiremock::ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Empty net allowlist should deny everything.
|
||||
let script = format!(
|
||||
r#"
|
||||
try {{
|
||||
await fetch("{}");
|
||||
output("result", "should_not_reach");
|
||||
}} catch (e) {{
|
||||
output("denied", true);
|
||||
}}
|
||||
"#,
|
||||
server.uri()
|
||||
);
|
||||
let mut step = DenoStep::new(config_with_net(&script, &[]));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["denied"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5: Module loader integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn deno_module_loader_resolves_npm_to_esm_sh() {
|
||||
let result = WfeModuleLoader::resolve_npm_specifier("npm:lodash@4.17.21");
|
||||
assert_eq!(result, Some("https://esm.sh/lodash@4.17.21".to_string()));
|
||||
|
||||
let scoped = WfeModuleLoader::resolve_npm_specifier("npm:@scope/pkg@1.0.0");
|
||||
assert_eq!(scoped, Some("https://esm.sh/@scope/pkg@1.0.0".to_string()));
|
||||
|
||||
// Non-npm specifiers return None.
|
||||
assert!(WfeModuleLoader::resolve_npm_specifier("https://cdn.com/mod.js").is_none());
|
||||
assert!(WfeModuleLoader::resolve_npm_specifier("./local.js").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deno_esm_sh_auto_added_to_net_allowlist() {
|
||||
// When modules are declared, esm.sh should be auto-added.
|
||||
let config = DenoConfig {
|
||||
script: Some("1".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec!["npm:lodash@4".to_string()],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
assert!(would_auto_add_esm_sh(&config));
|
||||
|
||||
// When no modules, esm.sh is not added.
|
||||
let config_no_modules = DenoConfig {
|
||||
script: Some("1".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions::default(),
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
assert!(!would_auto_add_esm_sh(&config_no_modules));
|
||||
|
||||
// When esm.sh already present, not duplicated.
|
||||
let config_already = DenoConfig {
|
||||
script: Some("1".to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions {
|
||||
net: vec!["esm.sh".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
modules: vec!["npm:lodash@4".to_string()],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
assert!(!would_auto_add_esm_sh(&config_already));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_import_local_file() {
|
||||
// Create a temp JS file to import.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let helper_path = dir.path().join("helper.js");
|
||||
let mut f = std::fs::File::create(&helper_path).unwrap();
|
||||
writeln!(f, "export function greet(name) {{ return `hello ${{name}}`; }}").unwrap();
|
||||
drop(f);
|
||||
|
||||
let main_path = dir.path().join("main.js");
|
||||
let main_code = format!(
|
||||
r#"import {{ greet }} from "file://{}";
|
||||
output("greeting", greet("world"));"#,
|
||||
helper_path.to_str().unwrap()
|
||||
);
|
||||
std::fs::write(&main_path, &main_code).unwrap();
|
||||
|
||||
let config = DenoConfig {
|
||||
script: None,
|
||||
file: Some(main_path.to_str().unwrap().to_string()),
|
||||
permissions: DenoPermissions {
|
||||
read: vec![dir.path().to_str().unwrap().to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
modules: vec![],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: Some(5000),
|
||||
};
|
||||
|
||||
let mut step = DenoStep::new(config);
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["greeting"], serde_json::json!("hello world"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_dynamic_import_denied() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::any())
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200)
|
||||
.set_body_string("export const x = 1;"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// dynamic_import defaults to false.
|
||||
let script = format!(
|
||||
r#"
|
||||
try {{
|
||||
await import("{}/mod.js");
|
||||
output("result", "should_not_reach");
|
||||
}} catch (e) {{
|
||||
output("error", e.message || String(e));
|
||||
}}
|
||||
"#,
|
||||
server.uri()
|
||||
);
|
||||
let mut step = DenoStep::new(config_with_net(&script, &["127.0.0.1"]));
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert!(
|
||||
data.get("error").is_some(),
|
||||
"expected dynamic import to be denied, got: {data:?}"
|
||||
);
|
||||
let err_msg = data["error"].as_str().unwrap();
|
||||
assert!(
|
||||
err_msg.contains("Dynamic import") || err_msg.contains("not allowed"),
|
||||
"expected dynamic import denied error, got: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deno_import_npm_module() {
|
||||
// Use a tiny npm package via esm.sh.
|
||||
let script = r#"
|
||||
import isNumber from "npm:is-number@7.0.0";
|
||||
output("result", isNumber(42));
|
||||
"#;
|
||||
let config = DenoConfig {
|
||||
script: Some(script.to_string()),
|
||||
file: None,
|
||||
permissions: DenoPermissions {
|
||||
net: vec!["esm.sh".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
modules: vec!["npm:is-number@7.0.0".to_string()],
|
||||
env: HashMap::new(),
|
||||
timeout_ms: Some(30000),
|
||||
};
|
||||
|
||||
let mut step = DenoStep::new(config);
|
||||
let (ws, wf, ptr) = make_test_fixtures(serde_json::json!({}));
|
||||
let ctx = make_context(&ws, &wf, &ptr);
|
||||
let result = step.run(&ctx).await.unwrap();
|
||||
let data = result.output_data.unwrap();
|
||||
assert_eq!(data["result"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user