Pins the user-visible Rhai SDK behaviors to a concrete test file so
SemVer enforcement isn't aspirational. **Editing this file is an SDK
version bump event** — the file header documents the rule.
* 30 tests covering every documented SDK 1.0 + 1.1 surface:
ctx.sdk_version (format + feature-detection)
ctx.execution_id / request_id / script_id (UUID shape)
ctx.script_name (round-trip)
ctx.invocation_type (http / function / scheduled)
ctx.request.path / headers / body / params / query / rest
log::trace / info / warn / error (with and without data)
response convention: bare value → 200, structured map →
statusCode pass-through, missing statusCode → wrapped 200,
non-integer statusCode → InvalidResponse error
sandbox restrictions: imports blocked, print disabled,
log::debug rejected (Rhai keyword — use log::trace)
JSON type fidelity (string/int/float/bool/null/array/object/
nested round-trip)
* Separate from tests/engine.rs (which tests internal Engine
behaviors) — same crate, different audience: engine.rs is
"does the engine work right", sdk_contract.rs is "does the
public contract hold". Some overlap is intentional so the
contract is readable in one place.
* Plain cargo test --workspace runs all 30 (no infrastructure
needed); these are pure unit tests.
Wires up enforcement item (3) from docs/versioning.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
401 lines
13 KiB
Rust
401 lines
13 KiB
Rust
//! Golden SDK contract tests.
|
|
//!
|
|
//! **Editing this file is an SDK version bump event.** Every test here
|
|
//! pins one documented user-visible behavior of the Rhai SDK exposed
|
|
//! through `ctx`, `log::*`, and the response convention. Adding a
|
|
//! function or field means adding a test here in the same change.
|
|
//! Changing the meaning of an existing function means bumping
|
|
//! `SDK_VERSION`'s major.
|
|
//!
|
|
//! Per `docs/versioning.md`, the rules are:
|
|
//!
|
|
//! * **Minor bump** (`1.0 → 1.1`) — additions only. Every test here
|
|
//! for the previous minor must still pass.
|
|
//! * **Major bump** (`1 → 2`) — removals, renames, retypes,
|
|
//! restrictions. Tests in this file get updated; the SDK breaks.
|
|
//!
|
|
//! These tests live in `executor-core` because that crate owns the
|
|
//! Rhai engine. They're separate from `engine.rs` because the audience
|
|
//! differs: `engine.rs` tests internal Engine behaviors, this file
|
|
//! tests the public contract scripts can rely on. Some overlap is
|
|
//! intentional — the contract should be readable in one place.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel};
|
|
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
|
use serde_json::{json, Value};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Test harness
|
|
// ----------------------------------------------------------------------------
|
|
|
|
fn engine() -> Engine {
|
|
Engine::new(Limits::default())
|
|
}
|
|
|
|
fn baseline_request() -> ExecRequest {
|
|
ExecRequest {
|
|
execution_id: ExecutionId::new(),
|
|
request_id: RequestId::new(),
|
|
script_id: ScriptId::new(),
|
|
script_name: "contract".into(),
|
|
invocation_type: InvocationType::Http,
|
|
path: "/contract-test".into(),
|
|
headers: BTreeMap::new(),
|
|
body: Value::Null,
|
|
params: BTreeMap::new(),
|
|
query: BTreeMap::new(),
|
|
rest: String::new(),
|
|
sandbox_overrides: ScriptSandbox::default(),
|
|
}
|
|
}
|
|
|
|
fn run(source: &str) -> Value {
|
|
engine()
|
|
.execute(source, baseline_request())
|
|
.expect("contract test should execute cleanly")
|
|
.body
|
|
}
|
|
|
|
fn run_with<F: FnOnce(&mut ExecRequest)>(source: &str, mutate: F) -> Value {
|
|
let mut req = baseline_request();
|
|
mutate(&mut req);
|
|
engine()
|
|
.execute(source, req)
|
|
.expect("contract test should execute cleanly")
|
|
.body
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK 1.0 — execution context
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn ctx_sdk_version_is_major_minor() {
|
|
// The value MUST parse as integer.integer so scripts can do
|
|
// `if ctx.sdk_version >= "1.2"` for feature detection.
|
|
let body = run("ctx.sdk_version");
|
|
let v = body.as_str().expect("ctx.sdk_version is a string");
|
|
let parts: Vec<&str> = v.split('.').collect();
|
|
assert_eq!(parts.len(), 2, "expected MAJOR.MINOR, got {v:?}");
|
|
assert!(parts[0].parse::<u32>().is_ok());
|
|
assert!(parts[1].parse::<u32>().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_execution_id_is_uuid_string() {
|
|
let body = run("ctx.execution_id");
|
|
let s = body.as_str().expect("execution_id is a string");
|
|
assert_eq!(s.len(), 36, "expected UUID-shaped string, got {s:?}");
|
|
assert_eq!(s.matches('-').count(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_id_is_uuid_string() {
|
|
let body = run("ctx.request_id");
|
|
let s = body.as_str().expect("request_id is a string");
|
|
assert_eq!(s.len(), 36);
|
|
assert_eq!(s.matches('-').count(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_script_id_is_uuid_string() {
|
|
let body = run("ctx.script_id");
|
|
let s = body.as_str().expect("script_id is a string");
|
|
assert_eq!(s.len(), 36);
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_script_name_round_trips() {
|
|
let body = run_with("ctx.script_name", |r| r.script_name = "payments-v2".into());
|
|
assert_eq!(body, json!("payments-v2"));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_invocation_type_http() {
|
|
let body = run("ctx.invocation_type");
|
|
assert_eq!(body, json!("http"));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_invocation_type_function() {
|
|
let body = run_with("ctx.invocation_type", |r| {
|
|
r.invocation_type = InvocationType::Function;
|
|
});
|
|
assert_eq!(body, json!("function"));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_invocation_type_scheduled() {
|
|
let body = run_with("ctx.invocation_type", |r| {
|
|
r.invocation_type = InvocationType::Scheduled;
|
|
});
|
|
assert_eq!(body, json!("scheduled"));
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK 1.0 — request data
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn ctx_request_path() {
|
|
let body = run_with("ctx.request.path", |r| r.path = "/payments/webhook".into());
|
|
assert_eq!(body, json!("/payments/webhook"));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_headers_map() {
|
|
let body = run_with("ctx.request.headers[\"x-api-key\"]", |r| {
|
|
r.headers.insert("x-api-key".into(), "secret".into());
|
|
});
|
|
assert_eq!(body, json!("secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_body_round_trip() {
|
|
let body = run_with("ctx.request.body", |r| {
|
|
r.body = json!({ "amount": 1234, "items": [1, 2, 3] });
|
|
});
|
|
assert_eq!(body, json!({ "amount": 1234, "items": [1, 2, 3] }));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_body_null() {
|
|
let body = run("ctx.request.body");
|
|
assert!(body.is_null());
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK 1.0 — log::* functions
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn log_info_single_arg() {
|
|
let resp = engine()
|
|
.execute(r#"log::info("hello"); 1"#, baseline_request())
|
|
.unwrap();
|
|
assert_eq!(resp.logs.len(), 1);
|
|
assert_eq!(resp.logs[0].level, LogLevel::Info);
|
|
assert_eq!(resp.logs[0].message, "hello");
|
|
assert!(resp.logs[0].data.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn log_info_with_data() {
|
|
let resp = engine()
|
|
.execute(
|
|
r#"log::info("paid", #{ amount: 100, user: "alice" }); 1"#,
|
|
baseline_request(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(resp.logs.len(), 1);
|
|
assert_eq!(
|
|
resp.logs[0].data,
|
|
Some(json!({ "amount": 100, "user": "alice" }))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn log_all_four_levels() {
|
|
let src = r#"
|
|
log::trace("t");
|
|
log::info("i");
|
|
log::warn("w");
|
|
log::error("e");
|
|
1
|
|
"#;
|
|
let resp = engine().execute(src, baseline_request()).unwrap();
|
|
let levels: Vec<_> = resp.logs.iter().map(|l| l.level).collect();
|
|
assert_eq!(
|
|
levels,
|
|
vec![
|
|
LogLevel::Trace,
|
|
LogLevel::Info,
|
|
LogLevel::Warn,
|
|
LogLevel::Error
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn log_debug_is_rejected_use_trace() {
|
|
// `debug` is a Rhai reserved keyword — scripts MUST use `log::trace`
|
|
// for sub-info diagnostics. This is documented in the SDK doc; the
|
|
// assertion pins the rejection.
|
|
let err = engine()
|
|
.execute(r#"log::debug("nope"); 1"#, baseline_request())
|
|
.expect_err("log::debug must be rejected");
|
|
assert!(matches!(err, picloud_executor_core::ExecError::Parse(_)));
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK 1.0 — response convention
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn bare_value_becomes_200_with_body() {
|
|
let resp = engine().execute("42", baseline_request()).unwrap();
|
|
assert_eq!(resp.status_code, 200);
|
|
assert_eq!(resp.body, json!(42));
|
|
assert!(resp.headers.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn structured_response_passes_through() {
|
|
let src = r#"
|
|
#{ statusCode: 201,
|
|
headers: #{ "x-tag": "on" },
|
|
body: #{ ok: true } }
|
|
"#;
|
|
let resp = engine().execute(src, baseline_request()).unwrap();
|
|
assert_eq!(resp.status_code, 201);
|
|
assert_eq!(resp.headers.get("x-tag").map(String::as_str), Some("on"));
|
|
assert_eq!(resp.body, json!({ "ok": true }));
|
|
}
|
|
|
|
#[test]
|
|
fn missing_status_code_defaults_to_200() {
|
|
// A Map without `statusCode` is just the body wrapped — falls back
|
|
// to the bare-value rule, NOT to the structured shape.
|
|
let resp = engine()
|
|
.execute(r"#{ ok: true }", baseline_request())
|
|
.unwrap();
|
|
assert_eq!(resp.status_code, 200);
|
|
assert_eq!(resp.body, json!({ "ok": true }));
|
|
}
|
|
|
|
#[test]
|
|
fn structured_response_status_code_must_be_integer() {
|
|
let err = engine()
|
|
.execute(
|
|
r#"#{ statusCode: "not-a-number", body: 1 }"#,
|
|
baseline_request(),
|
|
)
|
|
.expect_err("non-integer statusCode should fail");
|
|
assert!(matches!(
|
|
err,
|
|
picloud_executor_core::ExecError::InvalidResponse(_)
|
|
));
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK 1.0 — sandbox restrictions (what scripts CANNOT do)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn imports_are_blocked() {
|
|
let err = engine()
|
|
.execute(r#"import "external" as e; 1"#, baseline_request())
|
|
.expect_err("imports must be blocked");
|
|
assert!(matches!(
|
|
err,
|
|
picloud_executor_core::ExecError::Runtime(_) | picloud_executor_core::ExecError::Parse(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn print_is_disabled() {
|
|
// The `print` Rhai built-in is disabled; scripts route output
|
|
// through `log::*` only.
|
|
let err = engine()
|
|
.execute(r#"print("nope"); 1"#, baseline_request())
|
|
.expect_err("print should be disabled");
|
|
assert!(matches!(err, picloud_executor_core::ExecError::Parse(_)));
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK 1.1 — route-extracted request data
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn ctx_request_params_for_param_route() {
|
|
let body = run_with("ctx.request.params", |r| {
|
|
r.params.insert("name".into(), "alice".into());
|
|
r.params.insert("post".into(), "42".into());
|
|
});
|
|
assert_eq!(body, json!({ "name": "alice", "post": "42" }));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_params_empty_when_no_route_captures() {
|
|
let body = run("ctx.request.params");
|
|
assert_eq!(body, json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_query_parses() {
|
|
let body = run_with("ctx.request.query", |r| {
|
|
r.query.insert("tab".into(), "details".into());
|
|
r.query.insert("page".into(), "2".into());
|
|
});
|
|
assert_eq!(body, json!({ "tab": "details", "page": "2" }));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_query_empty_for_no_query_string() {
|
|
let body = run("ctx.request.query");
|
|
assert_eq!(body, json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_rest_for_prefix_route() {
|
|
let body = run_with("ctx.request.rest", |r| r.rest = "alice/morning".into());
|
|
assert_eq!(body, json!("alice/morning"));
|
|
}
|
|
|
|
#[test]
|
|
fn ctx_request_rest_empty_for_non_prefix_routes() {
|
|
let body = run("ctx.request.rest");
|
|
assert_eq!(body, json!(""));
|
|
}
|
|
|
|
// ============================================================================
|
|
// SDK 1.0 — JSON type fidelity (what survives the bridge in both directions)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn json_round_trip_preserves_nested_shapes() {
|
|
let body = run_with("ctx.request.body", |r| {
|
|
r.body = json!({
|
|
"string": "value",
|
|
"int": 42,
|
|
"float": 2.5,
|
|
"bool_t": true,
|
|
"bool_f": false,
|
|
"null": null,
|
|
"list": [1, "two", 3.0, null, true],
|
|
"nested": { "a": { "b": { "c": "deep" } } }
|
|
});
|
|
});
|
|
assert_eq!(body["string"], json!("value"));
|
|
assert_eq!(body["int"], json!(42));
|
|
assert_eq!(body["bool_t"], json!(true));
|
|
assert_eq!(body["bool_f"], json!(false));
|
|
assert!(body["null"].is_null());
|
|
assert_eq!(body["list"][1], json!("two"));
|
|
assert_eq!(body["nested"]["a"]["b"]["c"], json!("deep"));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Feature-detection scenarios (the reason ctx.sdk_version exists)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn scripts_can_feature_detect_minor_versions() {
|
|
// A script written today for 1.1 should still parse and run on 1.x
|
|
// for any x >= 1, with a fallback for older minors. This pattern is
|
|
// the entire point of exposing ctx.sdk_version as major.minor.
|
|
let src = r#"
|
|
let v = ctx.sdk_version;
|
|
if v >= "1.1" {
|
|
#{ statusCode: 200, body: "modern" }
|
|
} else {
|
|
#{ statusCode: 200, body: "legacy" }
|
|
}
|
|
"#;
|
|
let resp = engine().execute(src, baseline_request()).unwrap();
|
|
assert_eq!(resp.status_code, 200);
|
|
assert_eq!(resp.body, json!("modern")); // we're at 1.1+
|
|
}
|