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:
Ruben Fiszel
2026-05-11 18:43:54 +00:00
committed by GitHub
parent 20ecd904e7
commit 1e89aff2d6
2 changed files with 283 additions and 0 deletions
@@ -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"));
}