From f33c88b9d016e3bdcd0807999a949f6917a5a5ba Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 23 May 2026 22:21:10 +0200 Subject: [PATCH] test(executor-core): golden SDK contract suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/executor-core/tests/sdk_contract.rs | 400 +++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 crates/executor-core/tests/sdk_contract.rs diff --git a/crates/executor-core/tests/sdk_contract.rs b/crates/executor-core/tests/sdk_contract.rs new file mode 100644 index 0000000..af7fbb1 --- /dev/null +++ b/crates/executor-core/tests/sdk_contract.rs @@ -0,0 +1,400 @@ +//! 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(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::().is_ok()); + assert!(parts[1].parse::().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+ +}