mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
test(nativets): add #[ignore]'d smoke suite for deno_core / deno_ast / swc bumps (#9108)
Adds ten `#[ignore]`'d integration tests in
backend/windmill-runtime-nativets/src/smoke_tests.rs exercising the JS
API surface that the existing nativets tests in tests/worker.rs don't
reach. Run them when bumping the deno_core / deno_ast / deno_runtime /
swc_* pins in backend/Cargo.toml, not on every CI:
cargo test -p windmill-runtime-nativets smoke -- --ignored
# skip network-dependent ones with `--skip smoke_net_`
Why here and not in tests/worker.rs:
windmill-runtime-nativets is the only consumer of the deno_core crate
in the workspace — ScriptLang::Deno spawns the external `deno` binary
via deno_executor.rs, while ScriptLang::Nativets is the only path that
loads the in-process V8 runtime. So a deno_core / deno_ast bump can
only break things downstream of this crate. Co-locating the smoke
tests with the runtime they exercise means they hit the right surface
directly, skip the entire job-queue / worker / API-server stack, and
run in <1s end-to-end (vs. ~30-60s per test for the worker-level
nativets tests).
The tests use the existing `PrewarmedIsolate::spawn` API (already
public for the dedicated-worker path), which gives a clean
"compile-TS → load module → execute main(args) → return JSON" entry
point with no DB or queue plumbing required.
Coverage:
- smoke_basic_value_passing — args binding + return marshaling
- smoke_transpile_enum_and_union — TS-specific syntax (enums,
discriminated unions, casts) through swc_ecma_parser / swc_ecma_ast
- smoke_set_timeout_and_promise_all — deno_web timer ops + V8
microtask drain order
- smoke_url_and_searchparams — deno_url surface
- smoke_web_blob_btoa_atob — deno_web Blob + base64 ops
- smoke_large_payload_roundtrip — 512 KB string in/out through the
op-table boundary
- smoke_error_propagation_with_message — thrown Error must surface
in PrewarmedResult::Err with original message
- smoke_concurrent_isolates — 8 isolates spawned in parallel from
the same tokio runtime; catches V8 isolate-setup races
- smoke_net_fetch_example_com — deno_fetch end-to-end against
example.com
- smoke_net_fetch_json_and_headers — deno_fetch with custom request
headers + Response.json() against httpbin.org/anything
`structuredClone` is not currently wired into the nativets global —
documented in the smoke_web_blob_btoa_atob test in case that ever
changes.
All ten tests pass locally against the current pinned versions
(deno_core 0.336.0 / deno_ast =0.44.0 / swc_common =0.37.5).
This commit is contained in:
@@ -15,6 +15,9 @@
|
||||
mod dedicated;
|
||||
pub use dedicated::{ExecutingIsolate, PrewarmedIsolate, PrewarmedResult};
|
||||
|
||||
#[cfg(test)]
|
||||
mod smoke_tests;
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
cell::RefCell,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Opt-in smoke tests for the nativets V8 runtime.
|
||||
//!
|
||||
//! Exercise the deno_core / deno_ast / swc surface (TypeScript transpile,
|
||||
//! fetch, timers, URL, structuredClone, error propagation, concurrent
|
||||
//! isolates, large payload roundtrip) that the standard worker-level
|
||||
//! nativets tests in `backend/tests/worker.rs` don't reach — those tests
|
||||
//! validate value passing through the job queue, but not the JS API
|
||||
//! surface a deno_core bump would actually move.
|
||||
//!
|
||||
//! These tests are `#[ignore]`'d so the regular `cargo test` flow doesn't
|
||||
//! pay their cost (each spawns a V8 isolate; some hit the network). Run
|
||||
//! when changing the `deno_core` / `deno_ast` / `deno_runtime` / `swc_*`
|
||||
//! pins in `backend/Cargo.toml`:
|
||||
//!
|
||||
//! cargo test -p windmill-runtime-nativets smoke -- --ignored
|
||||
//!
|
||||
//! Tests prefixed `smoke_net_` hit the public internet (httpbin.org,
|
||||
//! example.com) and will fail if the runner has no egress. Skip them
|
||||
//! locally with `cargo test -p windmill-runtime-nativets smoke -- --ignored --skip smoke_net_`.
|
||||
|
||||
use crate::{transpile_ts, NativeAnnotation, PrewarmedIsolate, PrewarmedResult};
|
||||
|
||||
/// Compile a TS snippet, run it through a fresh isolate with the given
|
||||
/// positional args, and return the isolate's result + captured logs.
|
||||
async fn run_ts(ts: &str, arg_names: &[&str], args: serde_json::Value) -> PrewarmedResult {
|
||||
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
|
||||
let ann = NativeAnnotation { useragent: None, proxy: None };
|
||||
let arg_names: Vec<String> = arg_names.iter().map(|s| s.to_string()).collect();
|
||||
let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, arg_names, None);
|
||||
iso.wait_ready().await.expect("isolate failed to pre-warm");
|
||||
iso.start_execution(args.to_string())
|
||||
.wait()
|
||||
.await
|
||||
.expect("isolate execution panicked")
|
||||
}
|
||||
|
||||
fn unwrap_value(r: &PrewarmedResult) -> serde_json::Value {
|
||||
let raw = r.result.as_ref().expect("script returned an error");
|
||||
serde_json::from_str(raw.get()).expect("result not valid JSON")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Local (no network) — these still need V8 / deno_core ops to be wired.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_basic_value_passing() {
|
||||
let ts = r#"
|
||||
export async function main(x: number): Promise<number> {
|
||||
return x + 1;
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &["x"], serde_json::json!({"x": 41})).await;
|
||||
assert_eq!(unwrap_value(&r), serde_json::json!(42));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_transpile_enum_and_union() {
|
||||
// Enums + discriminated union + as-cast exercise the swc_ecma_ast +
|
||||
// swc_ecma_parser TS-syntax paths the bare value tests don't.
|
||||
let ts = r#"
|
||||
enum Direction { Up = "U", Down = "D" }
|
||||
type Msg = { kind: "move"; dir: Direction } | { kind: "stop" };
|
||||
export async function main(): Promise<string> {
|
||||
const msgs: Msg[] = [
|
||||
{ kind: "move", dir: Direction.Up },
|
||||
{ kind: "stop" },
|
||||
{ kind: "move", dir: Direction.Down },
|
||||
];
|
||||
return msgs.map(m => m.kind === "move" ? m.dir : "_").join(",");
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
assert_eq!(unwrap_value(&r), serde_json::json!("U,_,D"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_set_timeout_and_promise_all() {
|
||||
// setTimeout lives in deno_web; Promise.all hits the V8 microtask
|
||||
// queue. A bump that breaks timer-op registration or microtask drain
|
||||
// would surface here (script would hang or return wrong order).
|
||||
let ts = r#"
|
||||
export async function main(): Promise<number[]> {
|
||||
const delays = [40, 10, 20, 30];
|
||||
return await Promise.all(delays.map(d =>
|
||||
new Promise<number>(resolve => setTimeout(() => resolve(d), d))
|
||||
));
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
// Promise.all preserves input order regardless of resolution order.
|
||||
assert_eq!(unwrap_value(&r), serde_json::json!([40, 10, 20, 30]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_url_and_searchparams() {
|
||||
// deno_url surface: URL ctor, URLSearchParams parsing + iteration.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ host: string; pairs: [string, string][] }> {
|
||||
const u = new URL("https://example.com:8443/path?b=2&a=1&a=3");
|
||||
const pairs: [string, string][] = [];
|
||||
for (const [k, v] of u.searchParams) pairs.push([k, v]);
|
||||
return { host: u.host, pairs };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
assert_eq!(
|
||||
unwrap_value(&r),
|
||||
serde_json::json!({
|
||||
"host": "example.com:8443",
|
||||
"pairs": [["b", "2"], ["a", "1"], ["a", "3"]],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_web_blob_btoa_atob() {
|
||||
// deno_web surface: Blob, atob/btoa. `structuredClone` is *not* wired
|
||||
// into the nativets global (the deno_web binding doesn't expose it
|
||||
// here) — if that's ever changed, extend this test to cover it.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ b64: string; round_trip: string; size: number }> {
|
||||
const blob = new Blob(["hello"], { type: "text/plain" });
|
||||
const b64 = btoa("hello");
|
||||
const round_trip = atob(b64);
|
||||
return { b64, round_trip, size: blob.size };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
assert_eq!(
|
||||
unwrap_value(&r),
|
||||
serde_json::json!({
|
||||
"b64": "aGVsbG8=",
|
||||
"round_trip": "hello",
|
||||
"size": 5,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_large_payload_roundtrip() {
|
||||
// ~512 KB string in and out — exercises arg encoding + result
|
||||
// serialization through the deno_core <-> host op boundary at sizes
|
||||
// an op-table change could break.
|
||||
let big_in: String = "a".repeat(512 * 1024);
|
||||
let ts = r#"
|
||||
export async function main(s: string): Promise<{ in_len: number; out: string }> {
|
||||
if (typeof s !== "string") throw new Error(`expected string, got ${typeof s}`);
|
||||
return { in_len: s.length, out: "b".repeat(512 * 1024) };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &["s"], serde_json::json!({"s": big_in})).await;
|
||||
let v = unwrap_value(&r);
|
||||
assert_eq!(v.get("in_len").and_then(|x| x.as_u64()), Some(512 * 1024));
|
||||
let out_len = v
|
||||
.get("out")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
assert_eq!(out_len, 512 * 1024);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_error_propagation_with_message() {
|
||||
// Throwing a typed Error must surface as PrewarmedResult::Err with
|
||||
// the original message. A deno_core bump that changes the host-side
|
||||
// error wrapping would lose this contract.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<void> {
|
||||
throw new Error("nativets_smoke_marker_xyz");
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
let err = r.result.expect_err("expected script to fail");
|
||||
assert!(
|
||||
err.contains("nativets_smoke_marker_xyz"),
|
||||
"thrown error message did not reach result: {err}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_concurrent_isolates() {
|
||||
// Spawn N isolates in parallel from the same tokio runtime. Each
|
||||
// PrewarmedIsolate uses spawn_blocking + a fresh V8 isolate.
|
||||
// Catches isolate-setup races (V8_ISOLATE_CREATE_LOCK ordering) and
|
||||
// any per-isolate state that a deno_core bump could break under
|
||||
// concurrency.
|
||||
let ts = r#"
|
||||
export async function main(i: number): Promise<number> {
|
||||
return i * 10;
|
||||
}
|
||||
"#;
|
||||
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
|
||||
|
||||
const N: i64 = 8;
|
||||
let mut handles = Vec::with_capacity(N as usize);
|
||||
for i in 0..N {
|
||||
let js = js.clone();
|
||||
let h = tokio::spawn(async move {
|
||||
let ann = NativeAnnotation { useragent: None, proxy: None };
|
||||
let mut iso =
|
||||
PrewarmedIsolate::spawn(String::new(), js, ann, vec!["i".to_string()], None);
|
||||
iso.wait_ready().await.expect("pre-warm failed");
|
||||
let res = iso
|
||||
.start_execution(serde_json::json!({"i": i}).to_string())
|
||||
.wait()
|
||||
.await
|
||||
.expect("isolate panicked");
|
||||
res.result.expect("script errored")
|
||||
});
|
||||
handles.push(h);
|
||||
}
|
||||
|
||||
let mut got: Vec<i64> = Vec::with_capacity(N as usize);
|
||||
for h in handles {
|
||||
let raw = h.await.expect("join failed");
|
||||
let v: serde_json::Value = serde_json::from_str(raw.get()).expect("not JSON");
|
||||
got.push(v.as_i64().unwrap_or(-1));
|
||||
}
|
||||
got.sort();
|
||||
let expected: Vec<i64> = (0..N).map(|i| i * 10).collect();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Network — actually exercise deno_fetch end-to-end. Skip in air-gapped CI
|
||||
// with `--skip smoke_net_`.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke (network); run with --ignored"]
|
||||
async fn smoke_net_fetch_example_com() {
|
||||
// example.com is one of the most stable hosts on the internet and
|
||||
// returns a tiny known-text body, so we can both assert "fetch works"
|
||||
// and "the response body parses correctly through deno_fetch".
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ status: number; has_marker: boolean }> {
|
||||
const r = await fetch("https://example.com/");
|
||||
const body = await r.text();
|
||||
return { status: r.status, has_marker: body.includes("Example Domain") };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
let v = unwrap_value(&r);
|
||||
assert_eq!(v.get("status").and_then(|x| x.as_u64()), Some(200));
|
||||
assert_eq!(v.get("has_marker"), Some(&serde_json::json!(true)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke (network); run with --ignored"]
|
||||
async fn smoke_net_fetch_json_and_headers() {
|
||||
// httpbin.org/anything echoes request metadata back as JSON, so we
|
||||
// can verify: deno_fetch sends custom headers, parses JSON response,
|
||||
// and propagates query params end-to-end.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ ua: string; arg: string }> {
|
||||
const r = await fetch("https://httpbin.org/anything?nativets=ok", {
|
||||
headers: { "x-windmill-smoke": "1" },
|
||||
});
|
||||
if (!r.ok) throw new Error(`status ${r.status}`);
|
||||
const j: any = await r.json();
|
||||
return {
|
||||
ua: j.headers["X-Windmill-Smoke"] ?? "",
|
||||
arg: j.args.nativets ?? "",
|
||||
};
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
let v = unwrap_value(&r);
|
||||
assert_eq!(v.get("ua").and_then(|x| x.as_str()), Some("1"));
|
||||
assert_eq!(v.get("arg").and_then(|x| x.as_str()), Some("ok"));
|
||||
}
|
||||
Reference in New Issue
Block a user