Files
windmill/backend/windmill-runtime-nativets/src/smoke_tests.rs
T
Ruben FiszelandClaude Opus 5 785277e0bb feat(nativets): bound fetch on a peer that never answers (#11026)
* feat(nativets): bound fetch on a peer that never answers

deno_fetch applies no deadline of any kind. A peer that completes the TCP
handshake, accepts the request and then goes silent leaves `await fetch(...)`
pending indefinitely, holding its worker slot until the *job* timeout -- which
on self-hosted defaults to DEFAULT_SELFHOSTED_TIMEOUT, i.e. 7 days.

Nothing else catches this. Zombie-job detection keys off a stale
v2_job_runtime.ping, and a worker blocked inside a pending fetch keeps pinging
normally throughout: the worker is alive and healthy, only the work is dead.

What this bounds is the wait for a response to begin, and it stops there:

  - a peer that never answers            -> rejected after N seconds
  - a peer slow to answer, but under N   -> unaffected
  - a body that then streams for an hour,
    or is read slowly by the caller      -> unaffected, always

That last line rules out the obvious implementation: AbortSignal.timeout(N)
around every fetch would bound the hang and break every streaming response and
long download. This is a hang detector, not a latency budget.

Default 300s via WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS (0 disables), with a
per-script `//fetch_response_timeout <seconds>` annotation alongside the
existing //useragent and //proxy. Both nativets paths inherit it, since
eval_fetch_timeout and the dedicated-worker path in bun_executor both funnel
through create_nativets_runtime.

The ms value is clamped to i32::MAX: deno_web's setTimeout runs its delay
through webidl.converters.long, a 32-bit conversion that *wraps*, so a setting
past ~24.8 days would come out negative and fire immediately -- turning an
over-generous timeout into an instant one on every fetch.

The window covers connect, TLS and request upload as well as server think
time, so a very slow large upload is bounded by it too; the error message says
so rather than claiming the connection went silent.

Not covered: a body that stalls midway. Reaching that needs the response's
InnerBody, which deno_fetch keeps module-private, and every way to wrap it
from outside changes observable Response semantics (locking, bodyUsed,
double-consume errors). Left for a follow-up in deno_fetch itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* test(nativets): cover the instance-wide response-timeout env var

A typo in WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS would compile, pass every
other test, and silently hand every operator the 300s default -- the same
class of silent-default failure the timeout itself exists to prevent. Its
own test binary, since a LazyLock resolves the value once per process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): inherit the caller's RequestInit, and clear the long-poll ceiling

Two problems with the first cut, both found in review.

`{ ...init, signal }` copied only own enumerable properties, but RequestInit is
a WebIDL dictionary whose members deno_fetch reads with plain property gets
that walk the prototype chain. Anything inherited or non-enumerable was
dropped: `fetch(url, Object.create({method: "POST"}))` silently became a GET.
Worse, a non-object init went from a loud TypeError to a silent GET, because
spreading "POST" yields {0:"P",1:"O",...} -- a valid dictionary with ignored
keys. Now the init is inherited from rather than copied, and a non-dictionary
is handed straight back to deno_fetch for its own TypeError.

The 300s default also sat at half of TIMEOUT_WAIT_RESULT (600s), which
run_wait_result long-polls against with no response headers. A script running
another job synchronously for 300-600s would have timed out client-side while
the server was still legitimately holding the request open -- the long-poll
risk class, instantiated inside the product and reachable without writing a
raw fetch. Default raised to 900s, with the constraint recorded where someone
would break it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): hand the caller's RequestInit to Request untouched

Carrying a WebIDL dictionary across by hand has no safe form, and both
previous attempts were wrong in opposite directions. Spreading a copy drops
inherited and non-enumerable members, and turns a non-object init from a loud
TypeError into a silent GET. Inheriting from it via Object.create fixes those
but makes the child the receiver, so an accessor on the original runs against
an object that lacks its private-field brand:

    Cannot read private member #body from an object whose class did not
    declare it

So don't carry it at all. fetch()'s own first act is `new Request(input,
init)`; doing that here hands the init to the same constructor, read exactly
as it would be without this wrapper, and our signal travels in an init we own.
`req.signal` is then deno's own resolution of init.signal over an input
Request's signal, which removes the hand-rolled version of that rule too.

The Request is built twice as a result, once here and once inside fetch. That
is cheap: cloneInnerRequest carries method, headers, redirect mode, clientRid
and blob entry, and a body is proxied rather than buffered -- a static body is
a shallow {body, consumed} copy sharing its bytes, a stream gets a one-chunk
pass-through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): keep an aborted fetch settling in the same tick

deno_fetch keeps its outer fetch non-async on purpose: "WPT has a test that
aborted fetch is settled in the same tick. This means we cannot wrap the
promise if it is already settled" (26_fetch.js). An `async` wrapper adopts
that promise through another one, so a rejection that used to land before any
microtask queued after the call now lands after it.

Made the wrapper non-async, with an early return that hands deno's settled
rejection straight back for an already-aborted signal, and no timer armed
there since there is no response to wait for. Construction still has to reject
rather than throw, so it is caught and returned as a rejection, which is what
the `async` was buying.

The comment claiming this matched deno_fetch's own `async function fetch` was
wrong on two counts -- that function is not async, and the wrapper was not
matching it. Replaced with the constraint that actually holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): keep fetch's observable shape and reach intrinsics safely

Three ways the wrapper was distinguishable from the fetch it replaces, all
observable from a script sharing the isolate.

`.then` was an ordinary property lookup, so `Promise.prototype.then =
undefined` broke fetch after the request had already gone out. deno's own
modules reach intrinsics through primordials, and this file already captured
setTimeout, clearTimeout and Promise.reject for exactly that reason, so the
lookup was the odd one out. Now captured alongside them.

Declaring `init` without a default made `fetch.length` 2 where the standard
says 1. And the empty-call branch forwarded two explicit `undefined`s, so
deno's required-argument check saw two arguments and raised "Invalid URL:
'undefined'" instead of "1 argument required". Forwarding through
ReflectApply preserves the count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* docs: clarify fetch timeout restart requirements

* fix: capture native fetch abort helpers

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:46:12 +02:00

842 lines
36 KiB
Rust

//! 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::default();
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_missing_optional_arg_uses_default() {
// A missing optional arg must arrive as `undefined`, not `null`, so the
// parameter default applies. With `null`, slice(0, null) -> [] -> length 0.
let ts = r#"
export async function main(limit = 50): Promise<number> {
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].slice(0, limit).length;
}
"#;
let r = run_ts(ts, &["limit"], serde_json::json!({})).await;
assert_eq!(unwrap_value(&r), serde_json::json!(10));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_explicit_null_arg_is_preserved() {
// An explicitly-provided JSON null must stay null (distinct from a missing
// arg), so the default does NOT apply.
let ts = r#"
export async function main(x: number | null = 7): Promise<string> {
return x === null ? "null" : String(x);
}
"#;
let r = run_ts(ts, &["x"], serde_json::json!({ "x": null })).await;
assert_eq!(unwrap_value(&r), serde_json::json!("null"));
}
#[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` and the rest of
// the wired web globals are covered by `smoke_web_globals_are_wired`.)
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::default();
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);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_text_encoder_decoder() {
// TextEncoder/TextDecoder are wired from deno_web's 08_text_encoding.
// The "€" (U+20AC) is a 3-byte UTF-8 sequence, so this asserts the
// multi-byte encode → decode round-trip (not just ASCII) survives the
// deno_core op boundary.
let ts = r#"
export async function main(): Promise<{ bytes: number[]; round_trip: string }> {
const enc = new TextEncoder();
const dec = new TextDecoder();
const bytes = enc.encode("a€b");
return { bytes: Array.from(bytes), round_trip: dec.decode(bytes) };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({
// "a" = 0x61, "€" = 0xE2 0x82 0xAC, "b" = 0x62
"bytes": [0x61, 0xE2, 0x82, 0xAC, 0x62],
"round_trip": "a€b",
}),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_globals_are_wired() {
// Guard against a wrong export name silently leaving a global unwired:
// assert each web-platform global we assign in runtime.js is actually
// defined. If a deno_web/deno_url export is renamed on a future bump,
// the corresponding `globalThis.X = mod.X` becomes `undefined` and this
// test flips it to false.
let ts = r#"
export async function main(): Promise<Record<string, boolean>> {
const names = [
"DOMException",
"TextEncoder", "TextDecoder", "TextEncoderStream", "TextDecoderStream",
"File",
"Event", "EventTarget", "CustomEvent",
"MessageEvent", "CloseEvent", "ErrorEvent", "reportError",
"ReadableStream", "WritableStream", "TransformStream",
"ReadableStreamDefaultReader", "ReadableStreamBYOBReader",
"ReadableStreamDefaultController", "ReadableByteStreamController",
"ReadableStreamBYOBRequest", "WritableStreamDefaultWriter",
"WritableStreamDefaultController", "TransformStreamDefaultController",
"ByteLengthQueuingStrategy", "CountQueuingStrategy",
"Performance", "PerformanceEntry", "PerformanceMark", "PerformanceMeasure",
"URLPattern",
"CompressionStream", "DecompressionStream",
"MessageChannel", "MessagePort",
"structuredClone", "performance",
];
const out: Record<string, boolean> = {};
for (const n of names) out[n] = typeof (globalThis as any)[n] !== "undefined";
return out;
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
let obj = v.as_object().expect("expected an object result");
let unwired: Vec<&String> = obj
.iter()
.filter(|(_, defined)| defined.as_bool() != Some(true))
.map(|(name, _)| name)
.collect();
assert!(
unwired.is_empty(),
"these globals were not wired: {unwired:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_structured_clone_and_performance() {
// structuredClone is the spec function (from 13_message_port.js): it
// deep-clones and accepts an options bag. performance.now() must be finite,
// and performance.timeOrigin must be seeded per isolate (via the Rust-side
// __wmInitPerIsolate call) so that `timeOrigin + now()` tracks wall-clock
// time rather than being NaN.
let ts = r#"
export async function main(): Promise<{ deep_equal: boolean; source_unchanged: boolean; now_ok: boolean; origin_ok: boolean }> {
const src = { a: 1, nested: { b: [2, 3] } };
const copy = structuredClone(src);
copy.nested.b.push(4);
const deep_equal = JSON.stringify(copy) === JSON.stringify({ a: 1, nested: { b: [2, 3, 4] } });
// Mutating the clone must not touch the source (proves a real deep clone).
const source_unchanged = src.nested.b.length === 2;
const now_ok = Number.isFinite(performance.now());
// timeOrigin must be a finite epoch-ms value, and timeOrigin + now() must
// land within a few seconds of Date.now() (guards the per-isolate seeding).
const origin = performance.timeOrigin;
const origin_ok = Number.isFinite(origin) && Math.abs(origin + performance.now() - Date.now()) < 5000;
return { deep_equal, source_unchanged, now_ok, origin_ok };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({ "deep_equal": true, "source_unchanged": true, "now_ok": true, "origin_ok": true }),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_globals_construct() {
// `smoke_web_globals_are_wired` only checks the constructors are defined.
// Several are backed by deno_web ops (compression, message-port, URL
// pattern parsing) that a future bump could move out from under the
// still-defined constructor — it would pass the presence check but throw
// at `new`. Actually construct those here so that regression surfaces.
let ts = r#"
export async function main(): Promise<{ pathname: boolean; channel: boolean; gzip_ok: boolean }> {
const pat = new URLPattern({ pathname: "/books/:id" });
const pathname = pat.test("https://example.com/books/42");
const chan = new MessageChannel();
const channel = chan.port1 instanceof MessagePort && chan.port2 instanceof MessagePort;
// Round-trip "hi" through gzip compression then decompression.
const compressed = new Blob(["hi"]).stream().pipeThrough(new CompressionStream("gzip"));
const restored = compressed.pipeThrough(new DecompressionStream("gzip"));
const bytes = new Uint8Array(await new Response(restored).arrayBuffer());
const gzip_ok = new TextDecoder().decode(bytes) === "hi";
return { pathname, channel, gzip_ok };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({ "pathname": true, "channel": true, "gzip_ok": true }),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_readable_stream_roundtrip() {
// ReadableStream + TextEncoderStream/TextDecoderStream: pipe an encode
// stream through and read chunks back. Exercises the streams surface
// (06_streams.js) that `res.body instanceof ReadableStream` relies on.
let ts = r#"
export async function main(): Promise<{ is_readable: boolean; text: string }> {
const rs = new ReadableStream<string>({
start(controller) {
controller.enqueue("hello ");
controller.enqueue("stream");
controller.close();
},
});
const is_readable = rs instanceof ReadableStream;
const decoded = rs
.pipeThrough(new TextEncoderStream())
.pipeThrough(new TextDecoderStream());
let text = "";
for await (const chunk of decoded) text += chunk;
return { is_readable, text };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({ "is_readable": true, "text": "hello stream" }),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_crypto() {
// deno_crypto surface: the Web Crypto globals (`crypto`, `crypto.subtle`)
// that bring nativets to parity with the bun runner. Covers all three
// op families: getRandomValues (sync fill), randomUUID (RNG + formatting),
// and subtle.digest (async op returning an ArrayBuffer). The SHA-256 of
// "abc" is a fixed NIST vector, so a broken digest op fails the assert
// rather than silently returning garbage.
let ts = r#"
export async function main(): Promise<{ uuid: string; nonzero: boolean; sha256: string }> {
const uuid = crypto.randomUUID();
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
// A 16-byte CSPRNG fill returning all zeros is astronomically unlikely;
// a no-op/stub getRandomValues would leave the array zeroed.
const nonzero = buf.some((b) => b !== 0);
// "abc" as raw bytes — this test targets deno_crypto, so it avoids
// depending on deno_web's text encoding.
const data = new Uint8Array([0x61, 0x62, 0x63]);
const digest = await crypto.subtle.digest("SHA-256", data);
const sha256 = Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return { uuid, nonzero, sha256 };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
// UUIDv4 shape: 8-4-4-4-12 hex, version nibble 4, variant nibble 8/9/a/b.
let uuid = v.get("uuid").and_then(|x| x.as_str()).unwrap_or("");
let re =
regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
.unwrap();
assert!(
re.is_match(uuid),
"randomUUID did not match UUIDv4 shape: {uuid:?}"
);
assert_eq!(
v.get("nonzero"),
Some(&serde_json::json!(true)),
"getRandomValues left the buffer all zeros",
);
// Known SHA-256("abc") vector.
assert_eq!(
v.get("sha256").and_then(|x| x.as_str()),
Some("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_globals_edge_cases() {
// Broad functional sweep of every wired global — not just "defined" but
// "actually works". A constructor can be present yet throw at `new`/on call
// (an op moved by a deno bump, or a global dependency like DOMException not
// wired), which a presence check misses. Each check returns a bool; anything
// that isn't "ok" is reported in the failure message.
let ts = r#"
export async function main(): Promise<Record<string, string>> {
const out: Record<string, string> = {};
async function check(name: string, fn: () => any) {
try { out[name] = (await fn()) ? "ok" : "FAIL"; }
catch (e: any) { out[name] = "ERR: " + (e && e.message ? e.message : String(e)); }
}
// --- DOMException + AbortController/AbortSignal (needs the DOMException global) ---
await check("DOMException.construct", () => {
const e = new DOMException("nope", "AbortError");
return e.name === "AbortError" && e.message === "nope" && e instanceof DOMException;
});
await check("AbortController.abort_default_reason", () => {
const ac = new AbortController();
let fired = false;
ac.signal.addEventListener("abort", () => { fired = true; });
ac.abort(); // constructs a default DOMException("...", "AbortError")
return fired && ac.signal.aborted === true && ac.signal.reason?.name === "AbortError";
});
await check("AbortSignal.timeout", async () => {
const sig = AbortSignal.timeout(5);
await new Promise((r) => setTimeout(r, 30));
return sig.aborted === true && sig.reason?.name === "TimeoutError";
});
// --- TextEncoder / TextDecoder ---
await check("TextEncoder.encodeInto", () => {
const buf = new Uint8Array(3);
const { read, written } = new TextEncoder().encodeInto("abc", buf);
return read === 3 && written === 3 && buf[0] === 0x61;
});
await check("TextDecoder.utf16le", () =>
new TextDecoder("utf-16le").decode(new Uint8Array([0x41, 0x00])) === "A");
await check("TextDecoder.fatal_throws", () => {
try { new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array([0xff])); return false; }
catch { return true; }
});
// --- File ---
await check("File.props_and_read", async () => {
const f = new File(["hi"], "a.txt", { type: "text/plain", lastModified: 123 });
return f.name === "a.txt" && f.type === "text/plain" && f.lastModified === 123
&& f.size === 2 && (await f.text()) === "hi" && f instanceof Blob;
});
// --- Events ---
await check("EventTarget.dispatch_fires", () => {
const et = new EventTarget();
let got = "";
et.addEventListener("ping", (e: any) => { got = e.detail; });
et.dispatchEvent(new CustomEvent("ping", { detail: "pong" }));
return got === "pong";
});
// (A throwing EventTarget listener and reportError both surface the error
// asynchronously as an unhandled exception — matching bun, which exits
// non-zero — so they fail the script rather than a `check`; the dedicated
// smoke_eventtarget_throwing_listener_reports_original test covers that the
// ORIGINAL error is surfaced, not a masking one.)
await check("MessageEvent.data", () => new MessageEvent("m", { data: 42 }).data === 42);
await check("CloseEvent.code_reason", () => {
const e = new CloseEvent("close", { code: 1000, reason: "bye" });
return e.code === 1000 && e.reason === "bye";
});
await check("ErrorEvent.message", () => new ErrorEvent("error", { message: "boom" }).message === "boom");
// --- Streams ---
await check("ReadableStream.tee", async () => {
const rs = new ReadableStream<number>({ start(c) { c.enqueue(1); c.enqueue(2); c.close(); } });
const [a, b] = rs.tee();
const ra: number[] = []; for await (const x of a) ra.push(x);
const rb: number[] = []; for await (const x of b) rb.push(x);
return JSON.stringify(ra) === "[1,2]" && JSON.stringify(rb) === "[1,2]";
});
await check("ReadableStream.reader_cancel", async () => {
const rs = new ReadableStream<string>({ start(c) { c.enqueue("x"); } });
const rd = rs.getReader();
const { value } = await rd.read();
await rd.cancel();
return value === "x";
});
await check("WritableStream.write_close", async () => {
const chunks: string[] = [];
const ws = new WritableStream<string>({ write(c) { chunks.push(c); } });
const w = ws.getWriter();
await w.write("a"); await w.write("b"); await w.close();
return JSON.stringify(chunks) === '["a","b"]';
});
await check("TransformStream.identity", async () => {
const t = new TransformStream<string, string>();
const w = t.writable.getWriter(); w.write("hello"); w.close();
let o = ""; for await (const c of t.readable) o += c;
return o === "hello";
});
await check("Stream reader/controller instanceof globals", async () => {
// The reader/writer/controller constructors are exposed as globals for
// instanceof checks (bun parity). Obtain real instances and verify.
let ctrlOk = false;
const rs = new ReadableStream({
start(c: any) { ctrlOk = c instanceof ReadableStreamDefaultController; c.close(); },
});
const reader = rs.getReader();
const readerOk = reader instanceof ReadableStreamDefaultReader;
const ws = new WritableStream();
const writerOk = ws.getWriter() instanceof WritableStreamDefaultWriter;
return ctrlOk && readerOk && writerOk;
});
await check("ByteLengthQueuingStrategy", () => {
const s = new ByteLengthQueuingStrategy({ highWaterMark: 16 });
return s.highWaterMark === 16 && typeof s.size === "function";
});
await check("CountQueuingStrategy.in_stream", async () => {
const s = new CountQueuingStrategy({ highWaterMark: 1 });
const rs = new ReadableStream<number>({ start(c) { c.enqueue(7); c.close(); } }, s);
const rd = rs.getReader();
return (await rd.read()).value === 7 && s.highWaterMark === 1;
});
// --- URLPattern ---
await check("URLPattern.exec_groups", () => {
const p = new URLPattern({ pathname: "/users/:id" });
const m = p.exec("https://x.com/users/42");
return p.test("https://x.com/users/42") && m?.pathname.groups.id === "42";
});
// --- Compression (all three formats) ---
async function roundtrip(fmt: string): Promise<string> {
const comp = new Blob(["hello compression"]).stream().pipeThrough(new CompressionStream(fmt as any));
const decomp = comp.pipeThrough(new DecompressionStream(fmt as any));
return new TextDecoder().decode(new Uint8Array(await new Response(decomp).arrayBuffer()));
}
await check("Compression.gzip", async () => (await roundtrip("gzip")) === "hello compression");
await check("Compression.deflate", async () => (await roundtrip("deflate")) === "hello compression");
await check("Compression.deflate_raw", async () => (await roundtrip("deflate-raw")) === "hello compression");
// --- structuredClone edge cases ---
await check("structuredClone.Map", () => {
const c = structuredClone(new Map([["k", 1]]));
return c instanceof Map && c.get("k") === 1;
});
await check("structuredClone.Set", () => {
const c = structuredClone(new Set([1, 2]));
return c instanceof Set && c.has(2);
});
await check("structuredClone.Date", () => {
const c = structuredClone(new Date(0));
return c instanceof Date && c.getTime() === 0;
});
await check("structuredClone.TypedArray", () => {
const c = structuredClone(new Uint8Array([1, 2, 3]));
return c instanceof Uint8Array && c[1] === 2;
});
await check("structuredClone.circular", () => {
const o: any = {}; o.self = o;
const c = structuredClone(o);
return c.self === c;
});
await check("structuredClone.rejects_function", () => {
try { structuredClone(() => {}); return false; } catch { return true; }
});
await check("structuredClone.options_bag", () => {
const c = structuredClone({ a: 1 }, { transfer: [] });
return c.a === 1;
});
// --- performance ---
await check("performance.now_monotonic", () => {
const a = performance.now(); const b = performance.now();
return Number.isFinite(a) && b >= a;
});
await check("performance.mark_measure", () => {
performance.mark("m1");
performance.measure("meas", "m1");
return performance.getEntriesByType("measure").some((e: any) => e.name === "meas");
});
await check("performance.constructor_globals", () => {
// The Performance/PerformanceEntry/Mark/Measure constructors are exposed
// as globals (bun parity) for instanceof checks against real entries.
const mark = performance.mark("m2");
const meas = performance.measure("meas2", "m2");
return performance instanceof Performance
&& mark instanceof PerformanceMark && mark instanceof PerformanceEntry
&& meas instanceof PerformanceMeasure && meas instanceof PerformanceEntry;
});
await check("performance.toJSON_origin", () => {
const j: any = performance.toJSON();
return Number.isFinite(j.timeOrigin);
});
// --- MessageChannel / MessagePort (async delivery, guarded by timeout) ---
await check("MessagePort.postMessage", async () => {
const mc = new MessageChannel();
const got = await Promise.race([
new Promise<string>((res) => {
mc.port2.onmessage = (e: any) => res(JSON.stringify(e.data));
mc.port1.postMessage({ hello: "world" });
}),
new Promise<string>((res) => setTimeout(() => res("TIMEOUT"), 3000)),
]);
return got === '{"hello":"world"}';
});
// --- Web Crypto works alongside the other wired globals ---
await check("crypto.getRandomValues", () => {
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
return buf.some((b) => b !== 0);
});
await check("crypto.subtle.digest", async () => {
const d = await crypto.subtle.digest("SHA-256", new Uint8Array([0x61, 0x62, 0x63]));
return new Uint8Array(d)[0] === 0xba; // SHA-256("abc") starts with 0xba
});
return out;
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
let obj = v.as_object().expect("expected an object result");
let failures: Vec<String> = obj
.iter()
.filter(|(_, val)| val.as_str() != Some("ok"))
.map(|(name, val)| format!("{name} => {}", val.as_str().unwrap_or("?")))
.collect();
assert!(
failures.is_empty(),
"edge-case checks that did not pass ({} of {}):\n{}",
failures.len(),
obj.len(),
failures.join("\n"),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_eventtarget_throwing_listener_reports_original() {
// Invariant: globalThis is a functional EventTarget, so deno_web's
// reportException has a valid saved global dispatch target. An uncaught
// EventTarget-listener error is therefore surfaced with its original message
// as an async unhandled exception (matching bun, which exits non-zero), not
// replaced by a masking "Illegal invocation"/undefined-reference error.
let ts = r#"
export async function main(): Promise<void> {
const et = new EventTarget();
et.addEventListener("boom", () => { throw new Error("wm_listener_marker"); });
et.dispatchEvent(new Event("boom"));
// Give the async unhandled-exception report a tick to fire.
await new Promise((r) => setTimeout(r, 20));
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let err = r
.result
.expect_err("a throwing listener should surface an error");
assert!(
err.contains("wm_listener_marker"),
"the original listener error was lost: {err}",
);
assert!(
!err.contains("Illegal invocation") && !err.to_lowercase().contains("undefined"),
"dispatchEvent surfaced a masking error instead of the original: {err}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_report_error_both_call_forms() {
// reportError must work both unqualified and as a property of globalThis.
// The property form goes through a receiver check (`this === globalThis_`),
// which only passes because globalThis is the saved EventTarget reference;
// otherwise it throws "Illegal invocation". Both forms report the error as an
// async unhandled exception (matching bun), so the script errors with the
// original message rather than a masking one.
for (label, call) in [
("bare", "reportError(new Error(\"wm_report_marker\"));"),
(
"property",
"globalThis.reportError(new Error(\"wm_report_marker\"));",
),
] {
let ts = format!(
r#"
export async function main(): Promise<void> {{
{call}
await new Promise((r) => setTimeout(r, 20));
}}
"#
);
let r = run_ts(&ts, &[], serde_json::json!({})).await;
let err = r
.result
.expect_err(&format!("{label} reportError should surface an error"));
assert!(
err.contains("wm_report_marker"),
"{label} reportError lost the original error: {err}",
);
assert!(
!err.contains("Illegal invocation"),
"{label} reportError threw Illegal invocation: {err}",
);
}
}
// -----------------------------------------------------------------------------
// 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"));
}