diff --git a/crates/executor-core/tests/stdlib.rs b/crates/executor-core/tests/stdlib.rs new file mode 100644 index 0000000..1f119c7 --- /dev/null +++ b/crates/executor-core/tests/stdlib.rs @@ -0,0 +1,382 @@ +//! Integration tests for the v1.1.0 stdlib utility modules. +//! +//! These exist alongside `sdk_contract.rs` rather than inside it +//! because the stateless utilities aren't part of the same versioned +//! SDK contract surface — `sdk_contract.rs` covers things that bump +//! `SDK_VERSION` when they change; stdlib additions don't. + +use std::collections::BTreeMap; + +use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits}; +use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services}; +use serde_json::{json, Value}; + +// ---------------------------------------------------------------------------- +// Test harness — duplicated from sdk_contract.rs (each integration test +// crate has its own; there is no tests/common/). +// ---------------------------------------------------------------------------- + +fn engine() -> Engine { + Engine::new(Limits::default(), Services::new()) +} + +fn baseline_request() -> ExecRequest { + let execution_id = ExecutionId::new(); + ExecRequest { + execution_id, + request_id: RequestId::new(), + script_id: ScriptId::new(), + script_name: "stdlib".into(), + invocation_type: InvocationType::Http, + path: "/stdlib-test".into(), + headers: BTreeMap::new(), + body: Value::Null, + params: BTreeMap::new(), + query: BTreeMap::new(), + rest: String::new(), + sandbox_overrides: ScriptSandbox::default(), + app_id: AppId::new(), + principal: None, + trigger_depth: 0, + root_execution_id: execution_id, + } +} + +fn run(source: &str) -> Value { + engine() + .execute(source, baseline_request()) + .expect("stdlib test should execute cleanly") + .body +} + +fn run_err(source: &str) -> ExecError { + engine() + .execute(source, baseline_request()) + .expect_err("stdlib test expected to throw") +} + +fn assert_runtime_err(err: ExecError, needle: &str) { + match err { + ExecError::Runtime(msg) => assert!( + msg.contains(needle), + "runtime error did not contain `{needle}`: {msg}" + ), + other => panic!("expected Runtime error containing `{needle}`, got {other:?}"), + } +} + +// ============================================================================ +// regex +// ============================================================================ + +#[test] +fn regex_is_match_true_and_false() { + assert_eq!(run(r#"regex::is_match("^h", "hello")"#), json!(true)); + assert_eq!(run(r#"regex::is_match("^x", "hello")"#), json!(false)); +} + +#[test] +fn regex_find_returns_first_match() { + assert_eq!(run(r#"regex::find("\\d+", "abc 42 def 99")"#), json!("42")); +} + +#[test] +fn regex_find_returns_unit_when_no_match() { + // () serializes to JSON null via dynamic_to_json. + assert_eq!(run(r#"regex::find("\\d+", "abc")"#), Value::Null); +} + +#[test] +fn regex_find_all_returns_array() { + assert_eq!( + run(r#"regex::find_all("\\d+", "a1 b22 c333")"#), + json!(["1", "22", "333"]) + ); +} + +#[test] +fn regex_replace_first_only() { + assert_eq!( + run(r#"regex::replace("a", "banana", "X")"#), + json!("bXnana") + ); +} + +#[test] +fn regex_replace_all() { + assert_eq!( + run(r#"regex::replace_all("a", "banana", "X")"#), + json!("bXnXnX") + ); +} + +#[test] +fn regex_split() { + assert_eq!( + run(r#"regex::split(",\\s*", "a, b,c, d")"#), + json!(["a", "b", "c", "d"]) + ); +} + +#[test] +fn regex_captures_extracts_groups() { + assert_eq!( + run(r#"regex::captures("(\\d+)-(\\w+)", "42-abc")"#), + json!(["42-abc", "42", "abc"]) + ); +} + +#[test] +fn regex_captures_returns_unit_when_no_match() { + assert_eq!(run(r#"regex::captures("(\\d+)", "abc")"#), Value::Null); +} + +#[test] +fn regex_invalid_pattern_throws() { + assert_runtime_err(run_err(r#"regex::is_match("(", "x")"#), "invalid regex"); +} + +// ============================================================================ +// random +// ============================================================================ + +#[test] +fn random_int_within_range() { + // Run a few times to exercise the bounds — each call is independent. + let body = run(r" + let n = random::int(10, 20); + n >= 10 && n <= 20 + "); + assert_eq!(body, json!(true)); +} + +#[test] +fn random_int_throws_when_min_greater_than_max() { + assert_runtime_err(run_err("random::int(20, 10)"), "min"); +} + +#[test] +fn random_float_in_unit_interval() { + let body = run(r" + let f = random::float(); + f >= 0.0 && f < 1.0 + "); + assert_eq!(body, json!(true)); +} + +#[test] +fn random_bytes_returns_blob_of_correct_length() { + assert_eq!(run("random::bytes(16).len()"), json!(16)); +} + +#[test] +fn random_bytes_rejects_negative() { + assert_runtime_err(run_err("random::bytes(-1)"), "random::bytes"); +} + +#[test] +fn random_bytes_rejects_oversize() { + assert_runtime_err(run_err("random::bytes(70000)"), "random::bytes"); +} + +#[test] +fn random_string_produces_alphanumeric_of_correct_length() { + let body = run(r#" + let s = random::string(32); + s.len == 32 && regex::is_match("^[A-Za-z0-9]+$", s) + "#); + assert_eq!(body, json!(true)); +} + +#[test] +fn random_uuid_has_canonical_format() { + let body = run( + r#"regex::is_match("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", random::uuid())"#, + ); + assert_eq!(body, json!(true)); +} + +// ============================================================================ +// time +// ============================================================================ + +#[test] +fn time_now_ms_is_positive() { + let body = run("time::now_ms() > 0"); + assert_eq!(body, json!(true)); +} + +#[test] +fn time_now_string_looks_like_iso() { + let body = run(r#"regex::is_match("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", time::now())"#); + assert_eq!(body, json!(true)); +} + +#[test] +fn time_parse_format_round_trip() { + let body = run(r" + let ms = 1700000000000; + time::parse(time::format(ms)) == ms + "); + assert_eq!(body, json!(true)); +} + +#[test] +fn time_add_seconds() { + assert_eq!(run("time::add_seconds(0, 60)"), json!(60_000)); + assert_eq!(run("time::add_seconds(1000, -1)"), json!(0)); +} + +#[test] +fn time_diff_seconds_truncates() { + assert_eq!(run("time::diff_seconds(0, 65_500)"), json!(65)); +} + +#[test] +fn time_parse_rejects_garbage() { + assert_runtime_err(run_err(r#"time::parse("nonsense")"#), "time::parse"); +} + +// ============================================================================ +// json +// ============================================================================ + +#[test] +fn json_parse_then_stringify_round_trip() { + let body = run(r#" + let src = `{"a":1,"b":"x"}`; + json::stringify(json::parse(src)) == src + "#); + assert_eq!(body, json!(true)); +} + +#[test] +fn json_stringify_compact() { + assert_eq!(run(r"json::stringify(#{ a: 1 })"), json!(r#"{"a":1}"#)); +} + +#[test] +fn json_stringify_pretty_has_newlines() { + let body = run(r#"json::stringify_pretty(#{ a: 1 }).contains("\n")"#); + assert_eq!(body, json!(true)); +} + +#[test] +fn json_parse_invalid_throws() { + assert_runtime_err(run_err(r#"json::parse("not json")"#), "json::parse"); +} + +// ============================================================================ +// base64 +// ============================================================================ + +#[test] +fn base64_encode_string() { + assert_eq!(run(r#"base64::encode("hi")"#), json!("aGk=")); +} + +#[test] +fn base64_decode_then_re_encode_round_trip() { + assert_eq!( + run(r#"base64::encode(base64::decode("aGVsbG8="))"#), + json!("aGVsbG8=") + ); +} + +#[test] +fn base64_encode_url_has_no_padding() { + let body = run(r#" + let s = base64::encode_url("hello world!?"); + !s.contains("=") && !s.contains("+") && !s.contains("/") + "#); + assert_eq!(body, json!(true)); +} + +#[test] +fn base64_decode_url_round_trip() { + assert_eq!( + run(r#"base64::encode_url(base64::decode_url("aGVsbG8"))"#), + json!("aGVsbG8") + ); +} + +#[test] +fn base64_decode_invalid_throws() { + assert_runtime_err(run_err(r#"base64::decode("!!!")"#), "base64::decode"); +} + +// ============================================================================ +// hex +// ============================================================================ + +#[test] +fn hex_encode_produces_lowercase() { + assert_eq!(run(r#"hex::encode("Z")"#), json!("5a")); +} + +#[test] +fn hex_decode_then_re_encode_round_trip() { + // mixed-case input → lowercase output proves both case-insensitive + // decode and lowercase encode. + assert_eq!( + run(r#"hex::encode(hex::decode("DeAdBeEf"))"#), + json!("deadbeef") + ); +} + +#[test] +fn hex_decode_returns_correct_length() { + assert_eq!(run(r#"hex::decode("deadbeef").len()"#), json!(4)); +} + +#[test] +fn hex_decode_invalid_throws() { + assert_runtime_err(run_err(r#"hex::decode("xyz")"#), "hex::decode"); +} + +// ============================================================================ +// url +// ============================================================================ + +#[test] +fn url_encode_basic() { + assert_eq!(run(r#"url::encode("hello world")"#), json!("hello%20world")); +} + +#[test] +fn url_encode_preserves_unreserved() { + assert_eq!( + run(r#"url::encode("abcXYZ123-_.~")"#), + json!("abcXYZ123-_.~") + ); +} + +#[test] +fn url_decode_round_trip() { + assert_eq!( + run(r#"url::decode(url::encode("hello world!?"))"#), + json!("hello world!?") + ); +} + +#[test] +fn url_encode_query_basic() { + // Map keys come out alphabetically (Rhai's Map is a BTreeMap). + assert_eq!( + run(r#"url::encode_query(#{ a: "1", b: "x y" })"#), + json!("a=1&b=x%20y") + ); +} + +#[test] +fn url_encode_query_coerces_non_strings() { + // Numbers and bools shouldn't throw; they coerce via to_string(). + let body = run(r"url::encode_query(#{ n: 42, b: true })"); + // Order is alphabetical: b before n. + assert_eq!(body, json!("b=true&n=42")); +} + +#[test] +fn url_decode_rejects_invalid_utf8() { + assert_runtime_err(run_err(r#"url::decode("%FF%FE%80")"#), "url::decode"); +}