diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/abort_signal.rs b/crates/wasm-rquickjs/skeleton/src/builtin/abort_signal.rs new file mode 100644 index 000000000..f59fc0292 --- /dev/null +++ b/crates/wasm-rquickjs/skeleton/src/builtin/abort_signal.rs @@ -0,0 +1,48 @@ +use futures::future::{AbortHandle, Abortable}; +use rquickjs::function::This; +use rquickjs::{Ctx, Persistent, Value}; + +pub(crate) async fn with_abort_signal<'js, F, T>( + ctx: &Ctx<'js>, + signal: Option>, + future: F, +) -> rquickjs::Result +where + F: Future>, +{ + let signal = match signal { + Some(signal) if !signal.is_undefined() && !signal.is_null() => signal, + _ => return future.await, + }; + let signal = rquickjs::Object::from_value(signal)?; + if signal.get::<_, bool>("aborted")? { + return Err(ctx.throw(signal.get::<_, Value<'js>>("reason")?)); + } + + let add: rquickjs::Function<'js> = signal.get("addEventListener")?; + let remove: rquickjs::Function<'js> = signal.get("removeEventListener")?; + let (handle, registration) = AbortHandle::new_pair(); + let callback = rquickjs::Function::new(ctx.clone(), move || handle.abort())?; + let options = rquickjs::Object::new(ctx.clone())?; + options.set("once", true)?; + add.call::<_, ()>((This(signal.clone()), "abort", callback.clone(), options))?; + + if signal.get::<_, bool>("aborted")? { + let _ = remove.call::<_, ()>((This(signal.clone()), "abort", callback)); + return Err(ctx.throw(signal.get::<_, Value<'js>>("reason")?)); + } + + let signal = Persistent::save(ctx, signal); + let callback = Persistent::save(ctx, callback); + let remove = Persistent::save(ctx, remove); + let result = Abortable::new(future, registration).await; + let signal = signal.restore(ctx)?; + let callback = callback.restore(ctx)?; + let remove = remove.restore(ctx)?; + let _ = remove.call::<_, ()>((This(signal.clone()), "abort", callback)); + + match result { + Ok(result) => result, + Err(_) => Err(ctx.throw(signal.get::<_, Value<'js>>("reason")?)), + } +} diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/encoding.js b/crates/wasm-rquickjs/skeleton/src/builtin/encoding.js index 76aef6949..51755914e 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/encoding.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/encoding.js @@ -5,7 +5,6 @@ import { ERR_ENCODING_NOT_SUPPORTED, ERR_INVALID_ARG_TYPE, ERR_INVALID_THIS, - ERR_NO_ICU, } from '__wasm_rquickjs_builtin/internal/errors'; const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); @@ -132,6 +131,13 @@ function toDecodeBytes(input) { } function decodeNative(bytes, state, stream) { + if (state.nativeDecoder !== null) { + const [result, error] = state.nativeDecoder.decode(bytes, stream, state.fatal); + if (error !== undefined) { + throw new ERR_ENCODING_INVALID_ENCODED_DATA(state.encoding); + } + return result; + } const [result, error] = encodingNative.decode(bytes, state.encoding, stream, state.fatal, state.ignoreBOMForNextDecode); if (error !== undefined) { throw new ERR_ENCODING_INVALID_ENCODED_DATA(state.encoding); @@ -144,9 +150,6 @@ export class TextDecoder { validateOptions(options); const encoding = normalizeLabel(label); const fatal = !!options?.fatal; - if (fatal) { - throw new ERR_NO_ICU('fatal'); - } textDecoderState.set(this, { encoding, @@ -155,6 +158,9 @@ export class TextDecoder { ignoreBOMForNextDecode: !!options?.ignoreBOM, pending: new Uint8Array(0), streaming: false, + nativeDecoder: encodingNative.has_native_text_decoder() + ? new encodingNative.NativeTextDecoder(encoding, !!options?.ignoreBOM) + : null, }); } @@ -176,12 +182,16 @@ export class TextDecoder { let bytes = toDecodeBytes(buffer); const stream = !!options?.stream; - if (state.pending.length !== 0) { + if (state.nativeDecoder !== null) { + state.streaming = stream; + return decodeNative(bytes, state, stream); + } + if (state.nativeDecoder === null && state.pending.length !== 0) { bytes = concatBytes(state.pending, bytes); state.pending = new Uint8Array(0); } - if (stream) { + if (stream && state.nativeDecoder === null) { const pendingLength = trailingIncompleteLength(bytes, state.encoding); if (pendingLength !== 0) { state.pending = bytes.slice(bytes.length - pendingLength); @@ -248,9 +258,6 @@ export class TextDecoderStream extends streams.TransformStream { validateOptions(options); const encoding = normalizeLabel(label); const fatal = !!options?.fatal; - if (fatal) { - throw new ERR_NO_ICU('fatal'); - } let decoder; super({ diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/encoding.rs b/crates/wasm-rquickjs/skeleton/src/builtin/encoding.rs index 14cb601e2..f2290b23f 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/encoding.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/encoding.rs @@ -1,16 +1,24 @@ #[cfg(feature = "encoding")] -use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE}; +use encoding_rs::{CoderResult, Decoder, DecoderResult, Encoding, UTF_8, UTF_16BE, UTF_16LE}; use rquickjs::JsLifetime; use rquickjs::class::Trace; +#[cfg(feature = "encoding")] +use std::cell::RefCell; use std::ptr; use std::ptr::NonNull; #[rquickjs::module(rename = "camelCase")] pub mod native_module { + pub use super::NativeTextDecoder; use rquickjs::convert::Coerced; use rquickjs::prelude::*; use rquickjs::{Ctx, TypedArray}; + #[rquickjs::function] + pub fn has_native_text_decoder() -> bool { + cfg!(feature = "encoding") + } + #[rquickjs::function] pub fn supports_encoding(encoding: Coerced) -> bool { let encoding = encoding.0; @@ -76,6 +84,97 @@ pub mod native_module { } } +#[cfg(feature = "encoding")] +struct NativeDecoderState { + encoding: &'static Encoding, + ignore_bom: bool, + decoder: Decoder, +} + +#[cfg(feature = "encoding")] +impl NativeDecoderState { + fn new(encoding: &'static Encoding, ignore_bom: bool) -> Self { + let decoder = if ignore_bom { + encoding.new_decoder_without_bom_handling() + } else { + encoding.new_decoder_with_bom_removal() + }; + Self { + encoding, + ignore_bom, + decoder, + } + } + + fn reset(&mut self) { + *self = Self::new(self.encoding, self.ignore_bom); + } +} + +#[derive(Trace, JsLifetime)] +#[rquickjs::class] +pub struct NativeTextDecoder { + #[cfg(feature = "encoding")] + #[qjs(skip_trace)] + inner: RefCell, +} + +#[cfg(feature = "encoding")] +#[rquickjs::methods] +impl NativeTextDecoder { + #[qjs(constructor)] + pub fn new( + ctx: rquickjs::Ctx<'_>, + encoding: rquickjs::convert::Coerced, + ignore_bom: bool, + ) -> rquickjs::Result { + let encoding = Encoding::for_label(encoding.0.as_bytes()) + .ok_or_else(|| rquickjs::Exception::throw_message(&ctx, "Unsupported text encoding"))?; + Ok(Self { + inner: RefCell::new(NativeDecoderState::new(encoding, ignore_bom)), + }) + } + + pub fn decode( + &self, + bytes: rquickjs::TypedArray<'_, u8>, + stream: bool, + fatal: bool, + ) -> rquickjs::prelude::List<(Option, Option)> { + let Some(bytes) = bytes.as_bytes() else { + return rquickjs::prelude::List((Some(String::new()), None)); + }; + let mut state = self.inner.borrow_mut(); + let capacity = if fatal { + state + .decoder + .max_utf8_buffer_length_without_replacement(bytes.len()) + } else { + state.decoder.max_utf8_buffer_length(bytes.len()) + } + .unwrap_or(0); + let mut output = String::with_capacity(capacity); + let malformed = if fatal { + let (result, _) = + state + .decoder + .decode_to_string_without_replacement(bytes, &mut output, !stream); + matches!(result, DecoderResult::Malformed(_, _)) + } else { + let (result, _, _) = state.decoder.decode_to_string(bytes, &mut output, !stream); + matches!(result, CoderResult::OutputFull) + }; + if !stream || malformed { + state.reset(); + } + if malformed { + rquickjs::prelude::List((None, Some("Malformed input".to_string()))) + } else { + rquickjs::prelude::List((Some(output), None)) + } + } +} + #[rquickjs::class] #[derive(Trace, JsLifetime)] pub struct EncodeIntoResult { diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/http.js b/crates/wasm-rquickjs/skeleton/src/builtin/http.js index c5f580700..a1b595fa8 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/http.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/http.js @@ -15,6 +15,17 @@ function normalizeFetchMethod(method) { return normalizedFetchMethods.has(upper) ? upper : value; } +function viewToBytes(view) { + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); +} + +function snapshotBufferSource(value) { + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : viewToBytes(value); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy; +} + // Defined as a plain (non-async) function so its prototype is // `Function.prototype` (not `AsyncFunction.prototype`) — which Node's vendored // `parallel/test-fetch.mjs` asserts. We deliberately do NOT use a @@ -115,7 +126,7 @@ export function fetch(resource, options = {}) { fetchPromise = streamingRequest( url, method, rawHeaders, version, mode, referer, referrerPolicy, credentials, redirect, - bodyCreator + bodyCreator, signal ); } else { // Simple request @@ -135,10 +146,8 @@ export function fetch(resource, options = {}) { // no body } else if (body instanceof ArrayBuffer) { request.arrayBufferBody(body); - } else if (body instanceof DataView) { - request.uint8ArrayBody(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)); - } else if (body instanceof Uint8Array) { - request.uint8ArrayBody(body); + } else if (ArrayBuffer.isView(body)) { + request.uint8ArrayBody(viewToBytes(body)); } else if (body instanceof URLSearchParams) { request.addHeader('Content-Type', 'application/x-www-form-urlencoded'); request.stringBody(body.toString()); @@ -149,45 +158,35 @@ export function fetch(resource, options = {}) { } fetchPromise = (async () => { - const nativeResponse = await request.simpleSend(); - return new Response(nativeResponse, request.url, credentials); + const nativeResponse = await request.simpleSend(signal); + return new Response(nativeResponse, request.url, credentials, false, signal); })(); } - // If signal is provided, wrap the promise to support abort - if (signal) { - fetchPromise = abortableFetch(fetchPromise, signal); - } - return fetchPromise; })(); } -function abortableFetch(fetchPromise, signal) { - // Create a race between the fetch and the abort signal - return Promise.race([ - fetchPromise, - new Promise((_, reject) => { - // If signal is already aborted, this won't execute - if (signal.aborted) { - reject(signal.reason || new DOMException('The operation was aborted.', 'AbortError')); - } else { - // Listen for abort event - signal.addEventListener('abort', () => { - reject(signal.reason || new DOMException('The operation was aborted.', 'AbortError')); - }); - } - }) - ]); -} - // Marker tag for body source (ReadableStream/Blob/FormData) errors so the // streaming request loop can distinguish them from transport errors that may // arise when the server closes the upload (e.g. on an early redirect). const BODY_SOURCE_ERROR = Symbol('bodySourceError'); +function stopUpload(abortRef) { + abortRef.aborted = true; + if (abortRef.reader) { + try { abortRef.reader.cancel().catch(() => {}); } catch (_) { /* ignore */ } + } + if (abortRef.bodyWriter) { + const bodyWriter = abortRef.bodyWriter; + abortRef.bodyWriter = null; + try { bodyWriter.abortBody(); } catch (_) { /* ignore */ } + } +} + async function sendBody(bodyWriter, body, abortRef, onFirstChunk) { const reader = body.getReader(); + abortRef.reader = reader; try { while (true) { if (abortRef.aborted) { @@ -222,11 +221,13 @@ async function sendBody(bodyWriter, body, abortRef, onFirstChunk) { } } } finally { + abortRef.reader = null; try { reader.releaseLock(); } catch (_) { /* ignore */ } } if (abortRef.aborted) return; try { bodyWriter.finishBody(); + abortRef.bodyWriter = null; } catch (err) { if (abortRef.aborted) return; throw err; @@ -235,7 +236,7 @@ async function sendBody(bodyWriter, body, abortRef, onFirstChunk) { async function streamingRequest( url, method, headers, version, mode, referer, referrerPolicy, credentials, redirect, - bodyCreator + bodyCreator, signal ) { let currentUrl = url; let currentMethod = method; @@ -245,7 +246,22 @@ async function streamingRequest( const maxRedirects = 20; let currentRedirects = 0; + let activeAbortRef = null; + const onSignalAbort = () => { + if (activeAbortRef) stopUpload(activeAbortRef); + }; + if (signal) { + if (signal.aborted) { + throw signal.reason || new DOMException('The operation was aborted.', 'AbortError'); + } + signal.addEventListener('abort', onSignalAbort); + } + + try { while (true) { + if (signal && signal.aborted) { + throw signal.reason || new DOMException('The operation was aborted.', 'AbortError'); + } const request = new httpNative.HttpRequest( currentUrl, currentMethod, @@ -265,7 +281,8 @@ async function streamingRequest( // Track body upload state synchronously so we can inspect it from the // redirect path without having to await the upload promise (which may // never finish for slow/infinite streaming bodies). - const abortRef = {aborted: false}; + const abortRef = {aborted: false, reader: null, bodyWriter: bodyWriter}; + activeAbortRef = abortRef; const bodyState = {settled: false, ok: true, error: undefined}; let firstChunkWritten = false; let notifyFirstChunk; @@ -294,11 +311,19 @@ async function streamingRequest( ); } else { bodyWriter.finishBody(); + abortRef.bodyWriter = null; bodyState.settled = true; bodyPromise = Promise.resolve(); } - const nativeResponse = await request.receiveResponse(); + let nativeResponse; + try { + nativeResponse = await request.receiveResponse(signal); + } catch (e) { + stopUpload(abortRef); + bodyPromise.catch(() => {}); + throw e; + } const status = nativeResponse.status; const isRedirectStatus = status >= 300 && status < 400 && // is redirect @@ -324,13 +349,16 @@ async function streamingRequest( // and slow/infinite streaming bodies must not delay redirect // handling. Signal the upload to abort and ignore further errors // (transport errors after this point are expected). - abortRef.aborted = true; + stopUpload(abortRef); // Suppress unhandled-rejection noise on the detached promise. bodyPromise.catch(() => {}); } else { // Non-redirect: wait for the body upload to complete and propagate // any error (whether source-side or transport-side). await bodyPromise; + if (signal && signal.aborted) { + throw signal.reason || new DOMException('The operation was aborted.', 'AbortError'); + } if (!bodyState.ok) { throw bodyState.error; } @@ -393,6 +421,7 @@ async function streamingRequest( currentUrl = newUrl; currentMethod = newMethod; currentRedirects++; + nativeResponse.discardBody(); continue; } } @@ -400,7 +429,7 @@ async function streamingRequest( throw new Error("Unexpected redirect"); } - const response = new Response(nativeResponse, currentUrl, credentials); + const response = new Response(nativeResponse, currentUrl, credentials, false, signal); if (currentRedirects > 0) { response.nativeResponse.redirected = true; } @@ -419,10 +448,17 @@ async function streamingRequest( return response; } + } finally { + if (signal) signal.removeEventListener('abort', onSignalAbort); + } +} + +function responseAbortError() { + return new DOMException('The operation was aborted.', 'AbortError'); } export class Response { - constructor(bodyOrNative, initOrUrl, credentials, isError = false) { + constructor(bodyOrNative, initOrUrl, credentials, isError = false, signal = undefined) { if (bodyOrNative instanceof httpNative.HttpResponse) { // Internal path: constructed from native HttpResponse this.nativeResponse = bodyOrNative; @@ -431,6 +467,7 @@ export class Response { this._credentials = credentials || 'same-origin'; this._isError = isError; this._isNative = true; + this._signal = signal; } else { // Standard Web API path: new Response(body, init) const body = bodyOrNative; @@ -443,7 +480,10 @@ export class Response { this._credentials = 'same-origin'; this._isError = false; this._isNative = false; - this._body = body !== undefined && body !== null ? body : null; + this._signal = undefined; + this._body = body instanceof ArrayBuffer || ArrayBuffer.isView(body) + ? snapshotBufferSource(body) + : body !== undefined && body !== null ? body : null; } } @@ -474,12 +514,20 @@ export class Response { return "bytes"; }, async pull(controller) { + if (response._signal?.aborted) throw responseAbortError(); if (nativeStreamSourceSlot.nativeStreamSource === undefined) { nativeStreamSourceSlot.nativeStreamSource = response.nativeResponse.stream(); response.bodyUsed = true; } - const [next, err] = await nativeStreamSourceSlot.nativeStreamSource.pull(); + let next; + let err; + try { + [next, err] = await nativeStreamSourceSlot.nativeStreamSource.pull(); + } catch (error) { + if (response._signal?.aborted) throw responseAbortError(); + throw error; + } if (err !== undefined) { console.error("Error reading response body stream:", err); controller.error(err); @@ -506,8 +554,8 @@ export class Response { bytes = new TextEncoder().encode(body); } else if (body instanceof ArrayBuffer) { bytes = new Uint8Array(body); - } else if (body instanceof Uint8Array) { - bytes = body; + } else if (ArrayBuffer.isView(body)) { + bytes = viewToBytes(body); } else if (body instanceof Blob) { return body.stream(); } else { @@ -615,9 +663,21 @@ export class Response { } if (this._isNative) { - return new Response(this.nativeResponse.clone(), this.url, this._credentials, this._isError); + return new Response( + this.nativeResponse.clone(), + this.url, + this._credentials, + this._isError, + this._signal, + ); + } + let clonedBody = this._body; + if (this._body instanceof ReadableStream) { + const [originalBranch, clonedBranch] = this._body.tee(); + this._body = originalBranch; + clonedBody = clonedBranch; } - const cloned = new Response(this._body, { + const cloned = new Response(clonedBody, { status: this._status, statusText: this._statusText, headers: this._headers, @@ -646,7 +706,14 @@ export class Response { async arrayBuffer() { if (this._isNative) { - let result = await this.nativeResponse.arrayBuffer(); + if (this._signal?.aborted) throw responseAbortError(); + let result; + try { + result = await this.nativeResponse.arrayBuffer(); + } catch (error) { + if (this._signal?.aborted) throw responseAbortError(); + throw error; + } this.bodyUsed = true; return result; } @@ -657,8 +724,9 @@ export class Response { if (this._body instanceof ArrayBuffer) { return this._body; } - if (this._body instanceof Uint8Array) { - return this._body.buffer.slice(this._body.byteOffset, this._body.byteOffset + this._body.byteLength); + if (ArrayBuffer.isView(this._body)) { + const bytes = viewToBytes(this._body); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); } if (this._body instanceof Blob) { return this._body.arrayBuffer(); @@ -676,7 +744,8 @@ export class Response { const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { - result.set(new Uint8Array(chunk.buffer || chunk), offset); + const bytes = ArrayBuffer.isView(chunk) ? viewToBytes(chunk) : new Uint8Array(chunk); + result.set(bytes, offset); offset += chunk.byteLength; } return result.buffer; @@ -701,7 +770,14 @@ export class Response { async text() { if (this._isNative) { - let result = await this.nativeResponse.text(); + if (this._signal?.aborted) throw responseAbortError(); + let result; + try { + result = await this.nativeResponse.text(); + } catch (error) { + if (this._signal?.aborted) throw responseAbortError(); + throw error; + } this.bodyUsed = true; return result; } @@ -845,15 +921,17 @@ export class Headers { export class Request { constructor(input, options = {}) { if (input instanceof Request) { + if (input._bodyUsed && input._body != null) { + throw new TypeError('Request body is already consumed'); + } this._url = input._url; this._headers = new Headers(input._headers); this._bodyUsed = false; this._options = { ...input._options }; - // Clone the request body. Buffered bodies (string / typed arrays / URLSearchParams / - // Blob / FormData) are replayable, so sharing the reference is safe and leaves the - // original request undisturbed. A ReadableStream body has a single reader, so it is - // tee'd per the Fetch standard: the original keeps one branch and the clone gets the - // other. + // Clone the request body. Buffered bodies have already been extracted from any mutable + // caller-owned BufferSource, so the internal value is replayable. A ReadableStream body + // has a single reader, so it is tee'd per the Fetch standard: the original keeps one + // branch and the clone gets the other. if (input._body instanceof ReadableStream) { const [originalBranch, clonedBranch] = input._body.tee(); input._body = originalBranch; @@ -868,7 +946,9 @@ export class Request { this._options = { ...options, }; - this._body = options.body; + this._body = options.body instanceof ArrayBuffer || ArrayBuffer.isView(options.body) + ? snapshotBufferSource(options.body) + : options.body; } } @@ -887,11 +967,8 @@ export class Request { } else if (this._body instanceof ArrayBuffer) { const blob = new Blob([this._body]); return blob.stream(); - } else if (this._body instanceof DataView) { - const blob = new Blob([this._body.buffer.slice(this._body.byteOffset, this._body.byteOffset + this._body.byteLength)]); - return blob.stream(); - } else if (this._body instanceof Uint8Array) { - const blob = new Blob([this._body]); + } else if (ArrayBuffer.isView(this._body)) { + const blob = new Blob([viewToBytes(this._body)]); return blob.stream(); } else if (typeof this._body === 'string' || this._body instanceof String) { const blob = new Blob([this._body]); @@ -979,10 +1056,9 @@ export class Request { return new TextEncoder().encode(this._body.toString()).buffer; } else if (this._body instanceof ArrayBuffer) { return this._body; - } else if (this._body instanceof DataView) { - return this._body.buffer.slice(this._body.byteOffset, this._body.byteOffset + this._body.byteLength); - } else if (this._body instanceof Uint8Array) { - return this._body.buffer; + } else if (ArrayBuffer.isView(this._body)) { + const bytes = viewToBytes(this._body); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); } else if (typeof this._body === 'string' || this._body instanceof String) { return new TextEncoder().encode(this._body).buffer; } else { @@ -1004,10 +1080,8 @@ export class Request { return new Blob([this._body.toString()]); } else if (this._body instanceof ArrayBuffer) { return new Blob([this._body]); - } else if (this._body instanceof DataView) { - return new Blob([this._body.buffer.slice(this._body.byteOffset, this._body.byteOffset + this._body.byteLength)]); - } else if (this._body instanceof Uint8Array) { - return new Blob([this._body]); + } else if (ArrayBuffer.isView(this._body)) { + return new Blob([viewToBytes(this._body)]); } else if (typeof this._body === 'string' || this._body instanceof String) { return new Blob([this._body]); } else { @@ -1022,17 +1096,15 @@ export class Request { return new Uint8Array(await streamToArrayBuffer(this._body)); } else if (this._body instanceof FormData) { const blob = formDataToBlob(this._body); - return blob.bytes(); + return new Uint8Array(await blob.arrayBuffer()); } else if (this._body instanceof Blob) { - return this._body.bytes(); + return new Uint8Array(await this._body.arrayBuffer()); } else if (this._body instanceof URLSearchParams) { return new TextEncoder().encode(this._body.toString()); } else if (this._body instanceof ArrayBuffer) { return new Uint8Array(this._body); - } else if (this._body instanceof DataView) { - return new Uint8Array(this._body.buffer, this._body.byteOffset, this._body.byteLength); - } else if (this._body instanceof Uint8Array) { - return this._body; + } else if (ArrayBuffer.isView(this._body)) { + return viewToBytes(this._body).slice(); } else if (typeof this._body === 'string' || this._body instanceof String) { return new TextEncoder().encode(this._body); } else { @@ -1326,8 +1398,8 @@ export class XMLHttpRequest { fetchOptions.body = this._requestBody; } else if (this._requestBody instanceof ArrayBuffer) { fetchOptions.body = this._requestBody; - } else if (this._requestBody instanceof Uint8Array) { - fetchOptions.body = this._requestBody; + } else if (ArrayBuffer.isView(this._requestBody)) { + fetchOptions.body = viewToBytes(this._requestBody); } else if (this._requestBody instanceof URLSearchParams) { fetchOptions.body = this._requestBody; } else { diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/http.rs b/crates/wasm-rquickjs/skeleton/src/builtin/http.rs index 6d1d9b0ef..cce0197f7 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/http.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/http.rs @@ -22,6 +22,8 @@ use std::collections::HashMap; use std::rc::{Rc, Weak}; use wstd::runtime::AsyncPollable; +use super::abort_signal::with_abort_signal; + /// Request mode - defines the cross-origin behavior #[derive(Debug, Clone, Copy, PartialEq, Eq, rquickjs::class::Trace, rquickjs::JsLifetime)] pub enum RequestMode { @@ -458,23 +460,37 @@ impl HttpRequest { } } - pub async fn receive_response<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result { - if let Some(execution) = self.execution.take() { - let response = execution - .receive_response() - .await - .map_err(|_| Exception::throw_message(&ctx, "Failed to receive HTTP response"))?; - - Ok(HttpResponse::from_response(response)) - } else { - Err(Exception::throw_message( + pub async fn receive_response<'js>( + &mut self, + ctx: Ctx<'js>, + signal: Option>, + ) -> rquickjs::Result { + let Some(execution) = self.execution.take() else { + return Err(Exception::throw_message( &ctx, "HTTP request has not been initialized for sending", - )) - } + )); + }; + let inner_ctx = ctx.clone(); + with_abort_signal(&ctx, signal, async move { + let response = execution.receive_response().await.map_err(|_| { + Exception::throw_message(&inner_ctx, "Failed to receive HTTP response") + })?; + Ok(HttpResponse::from_response(response)) + }) + .await + } + + pub async fn simple_send<'js>( + &mut self, + ctx: Ctx<'js>, + signal: Option>, + ) -> rquickjs::Result { + let send = self.simple_send_inner(ctx.clone()); + with_abort_signal(&ctx, signal, send).await } - pub async fn simple_send<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result { + async fn simple_send_inner<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result { // Validate mode constraints if self.mode == RequestMode::NoCors { let method_str = self.method.to_string().to_uppercase(); @@ -667,6 +683,10 @@ impl WrappedRequestBodyWriter { )) } } + + pub fn abort_body(&mut self) { + self.writer = None; + } } #[derive(Trace, JsLifetime)] @@ -732,6 +752,10 @@ impl HttpResponse { self.status = golem_wasi_http::StatusCode::OK; // Will report as 0 when is_opaque is true } + pub fn discard_body(&mut self) { + self.body_source = ResponseBodySource::Consumed; + } + #[qjs(get)] pub fn redirected(&self) -> bool { self.redirected diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/http_p3.rs b/crates/wasm-rquickjs/skeleton/src/builtin/http_p3.rs index 0c30db516..0ee083fe4 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/http_p3.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/http_p3.rs @@ -31,6 +31,8 @@ use http::{HeaderName, HeaderValue, StatusCode, Version}; use rquickjs::convert::Coerced; use rquickjs::prelude::List; use rquickjs::{ArrayBuffer, Ctx, Exception, FromJs, IntoJs, JsLifetime, TypedArray, Value}; + +use super::abort_signal::with_abort_signal; use std::collections::HashMap; use std::future::Future; use std::pin::Pin; @@ -475,7 +477,19 @@ impl HttpRequest { /// reading so a large / slow / never-ending discarded body cannot stall the redirect loop. Only /// the final visible response body is read; a body that failed mid-transfer is recorded and /// surfaced when JS actually consumes it. - pub async fn receive_response<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result { + pub async fn receive_response<'js>( + &mut self, + ctx: Ctx<'js>, + signal: Option>, + ) -> rquickjs::Result { + let receive = self.receive_response_inner(ctx.clone()); + with_abort_signal(&ctx, signal, receive).await + } + + async fn receive_response_inner<'js>( + &mut self, + ctx: Ctx<'js>, + ) -> rquickjs::Result { let Some(send_future) = self.send_future.take() else { return Err(Exception::throw_message( &ctx, @@ -545,7 +559,16 @@ impl HttpRequest { /// Buffered send with redirect handling. Only the final visible response body is read; the /// bodies of followed redirects, rejected redirects, and opaque responses are discarded without /// reading, so a large or never-ending discarded body cannot stall the fetch. - pub async fn simple_send<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result { + pub async fn simple_send<'js>( + &mut self, + ctx: Ctx<'js>, + signal: Option>, + ) -> rquickjs::Result { + let send = self.simple_send_inner(ctx.clone()); + with_abort_signal(&ctx, signal, send).await + } + + async fn simple_send_inner<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result { // Validate mode constraints (mirrors the Preview 2 path). The streaming request-body path // performs the same validation in `init_send`. self.validate_request_mode(&ctx)?; @@ -874,6 +897,11 @@ impl WrappedRequestBodyWriter { self.trailers_tx = None; Ok(()) } + + pub fn abort_body(&mut self) { + self.body_tx = None; + self.trailers_tx = None; + } } // --------------------------------------------------------------------------- @@ -960,6 +988,11 @@ impl HttpResponse { self.body_error = None; } + pub fn discard_body(&mut self) { + self.body = ResponseBody::Consumed; + self.body_error = None; + } + /// Turns this response into a `redirect: "manual"` opaque-redirect filtered response. Like /// [`make_opaque`], it hides status/headers/body, but it additionally reports a `type` of /// `opaqueredirect` (via [`is_opaque_redirect`]) so the public `Response.type` getter can tell diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs index 5f865a6a0..1e15469eb 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs @@ -1,6 +1,7 @@ use std::fmt::Write; mod abort_controller; +mod abort_signal; mod assert; mod async_hooks; mod base64; diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/websocket.js b/crates/wasm-rquickjs/skeleton/src/builtin/websocket.js index 2aa27cc9a..50e2200e7 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/websocket.js +++ b/crates/wasm-rquickjs/skeleton/src/builtin/websocket.js @@ -158,6 +158,10 @@ class WebSocket { this._extensions = ''; this._protocol = ''; this._connection = null; + this._sendQueue = []; + this._sendQueueIndex = 0; + this._sendPending = false; + this._pendingClose = null; // Event handlers this._onopen = null; @@ -278,50 +282,71 @@ class WebSocket { return; } + const entry = this._normalizeSendData(data); + this._bufferedAmount += entry.byteLength; + this._sendQueue.push(entry); + if (!this._sendPending) this._drainSendQueue(); + } + + _normalizeSendData(data) { + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return {kind: 'blob', data, byteLength: data.size}; + } + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + const source = data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const snapshot = new Uint8Array(source.byteLength); + snapshot.set(source); + return {kind: 'binary', data: snapshot, byteLength: snapshot.byteLength}; + } + const text = typeof data === 'string' ? data : String(data); + return {kind: 'text', data: text, byteLength: utf8ByteLength(text)}; + } + + _sendEntry(entry) { + if (entry.kind === 'text') { + this._connection.send_text(entry.data); + } else { + this._connection.send_binary(entry.data); + } + this._bufferedAmount -= entry.byteLength; + } + + async _drainSendQueue() { + this._sendPending = true; try { - if (typeof data === 'string') { - this._bufferedAmount += utf8ByteLength(data); - this._connection.send_text(data); - this._bufferedAmount = 0; - } else if (data instanceof ArrayBuffer) { - this._bufferedAmount += data.byteLength; - this._connection.send_binary(new Uint8Array(data)); - this._bufferedAmount = 0; - } else if (ArrayBuffer.isView(data)) { - this._bufferedAmount += data.byteLength; - this._connection.send_binary(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); - this._bufferedAmount = 0; - } else if (typeof Blob !== 'undefined' && data instanceof Blob) { - // Blob support: read as ArrayBuffer and send as binary - const reader = new FileReader(); - reader.onload = () => { - if (this._readyState === OPEN && this._connection) { - try { - const buf = new Uint8Array(reader.result); - this._bufferedAmount += buf.byteLength; - this._connection.send_binary(buf); - this._bufferedAmount = 0; - } catch (e2) { - this._bufferedAmount = 0; - this._readyState = CLOSED; - this._dispatch('error', new ErrorEvent(e2.message || String(e2))); - this._dispatch('close', new CloseEvent(1006, '', false)); - } - } - }; - reader.readAsArrayBuffer(data); - } else { - // Fallback: coerce to string per spec - const str = String(data); - this._bufferedAmount += utf8ByteLength(str); - this._connection.send_text(str); - this._bufferedAmount = 0; + while (this._sendQueueIndex < this._sendQueue.length) { + let entry = this._sendQueue[this._sendQueueIndex++]; + if (entry.kind === 'blob') { + entry = { + kind: 'binary', + data: new Uint8Array(await entry.data.arrayBuffer()), + byteLength: entry.byteLength, + }; + } + if (!this._connection || this._readyState === CLOSED) { + throw new Error('WebSocket connection is closed'); + } + this._sendEntry(entry); } + this._sendQueue.length = 0; + this._sendQueueIndex = 0; + if (this._pendingClose) this._finishClose(); } catch (e) { this._bufferedAmount = 0; + this._sendQueue.length = 0; + this._sendQueueIndex = 0; + this._pendingClose = null; + const wasClosed = this._readyState === CLOSED; this._readyState = CLOSED; - this._dispatch('error', new ErrorEvent(e.message || String(e))); - this._dispatch('close', new CloseEvent(1006, '', false)); + this._connection = null; + if (!wasClosed) { + this._dispatch('error', new ErrorEvent(e.message || String(e))); + this._dispatch('close', new CloseEvent(1006, '', false)); + } + } finally { + this._sendPending = false; } } @@ -351,7 +376,13 @@ class WebSocket { } this._readyState = CLOSING; + this._pendingClose = {code, reason}; + if (!this._sendPending) this._drainSendQueue(); + } + _finishClose() { + const {code, reason} = this._pendingClose; + this._pendingClose = null; try { if (this._connection) { this._connection.close( @@ -481,7 +512,7 @@ class WebSocketStream { }); const writable = new WritableStream({ - write(chunk) { + async write(chunk) { if (!conn) { throw new Error('WebSocketStream is closed'); } @@ -491,6 +522,8 @@ class WebSocketStream { conn.send_binary(new Uint8Array(chunk)); } else if (ArrayBuffer.isView(chunk)) { conn.send_binary(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + } else if (typeof Blob !== 'undefined' && chunk instanceof Blob) { + conn.send_binary(new Uint8Array(await chunk.arrayBuffer())); } else { conn.send_text(String(chunk)); } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/websocket.rs b/crates/wasm-rquickjs/skeleton/src/builtin/websocket.rs index 348ec2d1f..222304d20 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/websocket.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/websocket.rs @@ -1,6 +1,6 @@ use golem_websocket::{Error as WsError, Message, WebsocketConnection}; use rquickjs::class::Trace; -use rquickjs::{Ctx, Exception, JsLifetime}; +use rquickjs::{Ctx, Exception, JsLifetime, TypedArray}; use std::cell::RefCell; /// Upper bound (in milliseconds) that a Preview 2 `receive_with_timeout` host call may block the @@ -113,7 +113,11 @@ impl WsConnection { .map_err(|e| Exception::throw_message(&ctx, &format!("WebSocket send failed: {e:?}"))) } - pub fn send_binary(&self, ctx: Ctx<'_>, data: Vec) -> rquickjs::Result<()> { + pub fn send_binary(&self, ctx: Ctx<'_>, data: TypedArray<'_, u8>) -> rquickjs::Result<()> { + let data = data + .as_bytes() + .ok_or_else(|| Exception::throw_message(&ctx, "WebSocket data buffer is detached"))? + .to_vec(); let inner = self.inner.borrow(); let conn = inner .as_ref() diff --git a/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs b/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs index f8ec6e251..1df8b1c31 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs @@ -29,6 +29,8 @@ use std::fmt::Write; #[path = "builtin/abort_controller.rs"] mod abort_controller; +#[path = "builtin/abort_signal.rs"] +mod abort_signal; #[path = "builtin/assert.rs"] mod assert; #[path = "builtin/async_hooks.rs"] diff --git a/examples/runtime/encoding/src/encoding.js b/examples/runtime/encoding/src/encoding.js index 9c0727953..c41d9fc17 100644 --- a/examples/runtime/encoding/src/encoding.js +++ b/examples/runtime/encoding/src/encoding.js @@ -117,3 +117,152 @@ export const test2 = () => { return true; }; + +export const test3 = async () => { + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + + try { + const decoder = new TextDecoder('utf-8', {fatal: true}); + check(decoder.fatal === true, 'fatal getter should be true'); + check( + decoder.decode(new Uint8Array([0xe2, 0x82, 0xac])) === '€', + 'valid UTF-8 should decode', + ); + + const invalids = { + 'lone 0xff': [0xff], + 'truncated multibyte': [0xe2, 0x82], + 'overlong NUL': [0xc0, 0x80], + 'lone surrogate': [0xed, 0xa0, 0x80], + 'above U+10FFFF': [0xf4, 0x90, 0x80, 0x80], + }; + for (const [name, bytes] of Object.entries(invalids)) { + let error; + try { + new TextDecoder('utf-8', {fatal: true}).decode(new Uint8Array(bytes)); + } catch (caught) { + error = caught; + } + check(error !== undefined, `fatal decode should throw for ${name}`); + check( + error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA', + `wrong error code for ${name}: ${error.code}`, + ); + new TextDecoder('utf-8').decode(new Uint8Array(bytes)); + } + + const bomAndA = new Uint8Array([0xef, 0xbb, 0xbf, 0x61]); + check( + new TextDecoder('utf-8', {fatal: true, ignoreBOM: false}).decode(bomAndA) === 'a', + 'ignoreBOM:false should strip the BOM', + ); + check( + new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(bomAndA) === '\ufeffa', + 'ignoreBOM:true should preserve the BOM', + ); + + const streaming = new TextDecoder('utf-8', {fatal: true}); + const first = streaming.decode(new Uint8Array([0xe2, 0x82]), {stream: true}); + const second = streaming.decode(new Uint8Array([0xac]), {stream: true}); + check(first + second === '€', 'split streaming sequence should decode'); + + const truncated = new TextDecoder('utf-8', {fatal: true}); + truncated.decode(new Uint8Array([0xe2, 0x82]), {stream: true}); + let flushError; + try { + truncated.decode(); + } catch (caught) { + flushError = caught; + } + check(flushError !== undefined, 'truncated sequence should fail on final decode'); + check( + flushError.code === 'ERR_ENCODING_INVALID_ENCODED_DATA', + `wrong final decode error: ${flushError.code}`, + ); + + const stream = new TextDecoderStream('utf-8', {fatal: true}); + check(stream.fatal === true, 'TextDecoderStream fatal getter should be true'); + + let streamError; + try { + const invalidStream = new TextDecoderStream('utf-8', {fatal: true}); + const writer = invalidStream.writable.getWriter(); + const reader = invalidStream.readable.getReader(); + const drain = (async () => { + while (!(await reader.read()).done) {} + })(); + await writer.write(new Uint8Array([0xff])); + await writer.close(); + await drain; + } catch (caught) { + streamError = caught; + } + check(streamError !== undefined, 'fatal TextDecoderStream should reject invalid input'); + + return true; + } catch (error) { + console.log('test3 failure:', error?.message); + return false; + } +}; + +export const test4 = async () => { + const check = (condition, message) => { + if (!condition) throw new Error(message); + }; + try { + const vectors = [ + ['shift_jis', [0x82, 0xa0], 'あ'], + ['gbk', [0xc4, 0xe3], '你'], + ['big5', [0xa7, 0x41], '你'], + ['euc-jp', [0xa4, 0xa2], 'あ'], + ]; + for (const [encoding, bytes, expected] of vectors) { + const decoder = new TextDecoder(encoding, {fatal: true}); + check(decoder.decode(new Uint8Array(bytes.slice(0, 1)), {stream: true}) === '', + `${encoding} should buffer its lead byte`); + check(decoder.decode(new Uint8Array(bytes.slice(1)), {stream: true}) === expected, + `${encoding} should complete a split character`); + check(decoder.decode() === '', `${encoding} final flush should be empty`); + } + + const fatal = new TextDecoder('shift_jis', {fatal: true}); + fatal.decode(new Uint8Array([0x82]), {stream: true}); + let fatalError; + try { + fatal.decode(); + } catch (error) { + fatalError = error; + } + check(fatalError?.code === 'ERR_ENCODING_INVALID_ENCODED_DATA', + 'fatal truncated Shift_JIS should fail on flush'); + + const replacement = new TextDecoder('shift_jis'); + check(replacement.decode(new Uint8Array([0x82]), {stream: true}) === '', + 'nonfatal Shift_JIS should buffer its lead byte'); + check(replacement.decode() === '\ufffd', + 'nonfatal truncated Shift_JIS should replace on flush'); + + const decoded = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0x82])); + controller.enqueue(new Uint8Array([0xa0])); + controller.close(); + }, + }).pipeThrough(new TextDecoderStream('shift_jis', {fatal: true})); + const reader = decoded.getReader(); + let streamed = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) break; + streamed += value; + } + check(streamed === 'あ', 'TextDecoderStream should complete split Shift_JIS'); + return true; + } catch (error) { + console.log('test4 failure:', error?.message); + return false; + } +}; diff --git a/examples/runtime/encoding/wit/encoding.wit b/examples/runtime/encoding/wit/encoding.wit index e35414a40..40f9b54a3 100644 --- a/examples/runtime/encoding/wit/encoding.wit +++ b/examples/runtime/encoding/wit/encoding.wit @@ -3,4 +3,6 @@ package quickjs:encoding; world encoding { export test1: func(); export test2: func() -> bool; + export test3: func() -> bool; + export test4: func() -> bool; } diff --git a/examples/runtime/fetch/src/fetch.js b/examples/runtime/fetch/src/fetch.js index e218bc521..60a7bdf7c 100644 --- a/examples/runtime/fetch/src/fetch.js +++ b/examples/runtime/fetch/src/fetch.js @@ -1048,3 +1048,93 @@ export async function fetchFunctionShape() { console.log("fetch function shape: FAILED"); } } + +export async function abortReleasesRequest(port) { + const controller = new AbortController(); + const reason = 'cancelled by test'; + const request = fetch(`http://localhost:${port}/slow-response`, { + signal: controller.signal, + }); + + await fetch(`http://localhost:${port}/abort-ready`); + controller.abort(reason); + try { + await request; + return false; + } catch (error) { + return error === reason; + } +} + +export async function abortReleasesUpload(port) { + const controller = new AbortController(); + const reason = 'cancelled upload'; + let cancelled = false; + const body = new ReadableStream({ + pull() { + return new Promise(() => {}); + }, + cancel() { + cancelled = true; + }, + }); + const request = fetch(`http://localhost:${port}/slow-response`, { + method: 'POST', + body, + signal: controller.signal, + }); + await fetch(`http://localhost:${port}/abort-ready`); + controller.abort(reason); + try { + await request; + return false; + } catch (error) { + return error === reason && cancelled; + } +} + +export async function abortAfterRedirect(port) { + const controller = new AbortController(); + const reason = 'cancelled redirected request'; + const request = fetch(`http://localhost:${port}/redirect-to-slow`, { + signal: controller.signal, + }); + await fetch(`http://localhost:${port}/abort-ready`); + controller.abort(reason); + try { + await request; + return false; + } catch (error) { + return error === reason; + } +} + +export async function abortResponseBody(port) { + const textController = new AbortController(); + const textResponse = await fetch(`http://localhost:${port}/todos/0`, { + signal: textController.signal, + }); + textController.abort('body reason must not escape'); + let textError; + try { + await textResponse.text(); + } catch (error) { + textError = error; + } + if (!(textError instanceof DOMException) || textError.name !== 'AbortError') { + return false; + } + + const streamController = new AbortController(); + const streamResponse = await fetch(`http://localhost:${port}/todos/0`, { + signal: streamController.signal, + }); + streamController.abort('stream reason must not escape'); + let streamError; + try { + await streamResponse.body.getReader().read(); + } catch (error) { + streamError = error; + } + return streamError instanceof DOMException && streamError.name === 'AbortError'; +} diff --git a/examples/runtime/fetch/wit/fetch.wit b/examples/runtime/fetch/wit/fetch.wit index 4eb282654..e7bfffc50 100644 --- a/examples/runtime/fetch/wit/fetch.wit +++ b/examples/runtime/fetch/wit/fetch.wit @@ -38,4 +38,8 @@ world fetch { export redirect-with-failing-stream-body: func(port: u16); export redirect-with-infinite-stream-body: func(port: u16); export fetch-function-shape: func(); + export abort-releases-request: func(port: u16) -> bool; + export abort-releases-upload: func(port: u16) -> bool; + export abort-after-redirect: func(port: u16) -> bool; + export abort-response-body: func(port: u16) -> bool; } diff --git a/examples/runtime/response-constructor/src/response-constructor.js b/examples/runtime/response-constructor/src/response-constructor.js index a588f44a7..3df3f5b81 100644 --- a/examples/runtime/response-constructor/src/response-constructor.js +++ b/examples/runtime/response-constructor/src/response-constructor.js @@ -188,6 +188,148 @@ const responseConstructorExports = { return ok(name); } catch (e) { return fail(name, e); } }, + + async testRequestClone() { + const name = 'Request.clone() preserves bodies'; + try { + const r = new Request('https://example.com/x', { + method: 'POST', headers: { 'X-Test': 'yes' }, body: 'hello body', + }); + const c = r.clone(); + if (await r.text() !== 'hello body' || await c.text() !== 'hello body') { + return fail(name, 'buffered body was not independently readable'); + } + if (c.url !== r.url || c.method !== 'POST' || c.headers.get('x-test') !== 'yes') { + return fail(name, 'request metadata was not preserved'); + } + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('stream body')); + controller.close(); + }, + }); + const streamed = new Request('https://example.com/y', { method: 'POST', body: stream }); + const streamedClone = streamed.clone(); + if (await streamed.text() !== 'stream body' || await streamedClone.text() !== 'stream body') { + return fail(name, 'stream body was not independently readable'); + } + return ok(name); + } catch (e) { return fail(name, e); } + }, + + async testRequestCloneAfterConsume() { + const name = 'Request.clone() rejects consumed bodies'; + try { + for (const wrap of [false, true]) { + const r = new Request('https://example.com/x', { method: 'POST', body: 'abc' }); + await r.text(); + let threw = false; + try { + if (wrap) new Request(r); + else r.clone(); + } catch (e) { + threw = e instanceof TypeError; + } + if (!threw) return fail(name, wrap ? 'new Request(consumed)' : 'clone after consume'); + } + const bodyless = new Request('https://example.com/x'); + await bodyless.text(); + bodyless.clone(); + return ok(name); + } catch (e) { return fail(name, e); } + }, + + async testRequestBytesBlob() { + const name = 'Request.bytes() with Blob body'; + try { + const bytes = await new Request('https://example.com/x', { + method: 'POST', body: new Blob(['abc']), + }).bytes(); + if (!(bytes instanceof Uint8Array) || new TextDecoder().decode(bytes) !== 'abc') { + return fail(name, `unexpected bytes: ${bytes}`); + } + return ok(name); + } catch (e) { return fail(name, e); } + }, + + async testResponseCloneStream() { + const name = 'Response.clone() tees stream body'; + try { + const backing = new Uint8Array([0, 115, 116, 114, 101, 97, 109, 0]); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(backing.subarray(1, 4)); + controller.enqueue(backing.subarray(4, 7)); + controller.close(); + }, + }); + const r = new Response(stream); + const c = r.clone(); + if (await r.text() !== 'stream' || await c.text() !== 'stream') { + return fail(name, 'original and clone did not receive the exact stream bytes'); + } + return ok(name); + } catch (e) { return fail(name, e); } + }, + + async testTypedArrayBodies() { + const name = 'all ArrayBuffer views are exact body sources'; + const eq = (actual, expected, what) => { + const values = Array.from(actual); + if (values.length !== expected.length || values.some((v, i) => v !== expected[i])) { + throw new Error(`${what}: expected [${expected}], got [${values}]`); + } + }; + try { + eq(new Uint8Array(await new Response(new Int8Array([1, 2, 3])).arrayBuffer()), + [1, 2, 3], 'Int8Array'); + const u16 = new Uint16Array(new Uint8Array([1, 2, 3, 4]).buffer); + eq(new Uint8Array(await new Response(u16).arrayBuffer()), [1, 2, 3, 4], 'Uint16Array'); + const dvBuf = new Uint8Array([9, 8, 7, 6]).buffer; + eq(new Uint8Array(await new Response(new DataView(dvBuf, 1, 2)).arrayBuffer()), + [8, 7], 'DataView'); + const sub = new Uint8Array([10, 20, 30, 40, 50]).subarray(1, 4); + eq(new Uint8Array(await new Request('https://example.com/x', { + method: 'POST', body: sub, + }).arrayBuffer()), [20, 30, 40], 'Request.arrayBuffer subview'); + eq(await new Request('https://example.com/x', { method: 'POST', body: sub }).bytes(), + [20, 30, 40], 'Request.bytes subview'); + const blob = await new Response(new Int8Array([5, 6])).blob(); + eq(new Uint8Array(await blob.arrayBuffer()), [5, 6], 'Blob'); + return ok(name); + } catch (e) { return fail(name, e); } + }, + + async testBufferSourceSnapshot() { + const name = 'Request and Response snapshot BufferSource bodies'; + const eq = (actual, expected, what) => { + const values = Array.from(actual); + if (values.length !== expected.length || values.some((v, i) => v !== expected[i])) { + throw new Error(`${what}: expected [${expected}], got [${values}]`); + } + }; + try { + const responseBacking = new Uint8Array([0, 1, 2, 3, 0]); + const response = new Response(responseBacking.subarray(1, 4)); + const responseClone = response.clone(); + responseBacking.fill(9); + eq(new Uint8Array(await response.arrayBuffer()), [1, 2, 3], 'Response'); + eq(new Uint8Array(await responseClone.arrayBuffer()), [1, 2, 3], 'Response clone'); + + const requestBacking = new Uint8Array([0, 4, 5, 6, 0]); + const request = new Request('https://example.com/x', { + method: 'POST', + body: new DataView(requestBacking.buffer, 1, 3), + }); + const requestClone = request.clone(); + requestBacking.fill(8); + const returnedBytes = await request.bytes(); + eq(returnedBytes, [4, 5, 6], 'Request'); + returnedBytes.fill(7); + eq(new Uint8Array(await requestClone.arrayBuffer()), [4, 5, 6], 'Request clone'); + return ok(name); + } catch (e) { return fail(name, e); } + }, }; export { responseConstructorExports }; diff --git a/examples/runtime/response-constructor/wit/response-constructor.wit b/examples/runtime/response-constructor/wit/response-constructor.wit index fd6ca1b07..52834c5c9 100644 --- a/examples/runtime/response-constructor/wit/response-constructor.wit +++ b/examples/runtime/response-constructor/wit/response-constructor.wit @@ -19,6 +19,12 @@ interface response-constructor-exports { test-default-values: func() -> test-result; test-mock-fetch-pattern: func() -> test-result; test-headers-iteration: func() -> test-result; + test-request-clone: func() -> test-result; + test-request-clone-after-consume: func() -> test-result; + test-request-bytes-blob: func() -> test-result; + test-response-clone-stream: func() -> test-result; + test-typed-array-bodies: func() -> test-result; + test-buffer-source-snapshot: func() -> test-result; } world response-constructor { diff --git a/examples/runtime/websocket/src/websocket.js b/examples/runtime/websocket/src/websocket.js new file mode 100644 index 000000000..2dd0f6e62 --- /dev/null +++ b/examples/runtime/websocket/src/websocket.js @@ -0,0 +1,52 @@ +export const testBinarySend = async () => { + const ws = new WebSocket('ws://localhost:9999/echo'); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = (event) => reject(new Error(event && event.message || 'WebSocket error')); + }); + + ws.send(new Uint8Array([1, 2, 3]).buffer); + ws.send(new Uint8Array([0, 4, 5, 6, 0]).subarray(1, 4)); + ws.send(new Blob([new Uint8Array([7, 8, 9])])); + ws.send('hello'); + + while (ws.bufferedAmount !== 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + return true; +}; + +export const testWebsocketStreamSend = async () => { + const stream = new WebSocketStream('ws://localhost:9999/echo'); + const { writable } = await stream.opened; + const writer = writable.getWriter(); + + await writer.write('hello'); + await writer.write(new Uint8Array([1, 2, 3]).buffer); + await writer.write(new Uint8Array([0, 4, 5, 6, 0]).subarray(1, 4)); + await writer.write(new Blob([new Uint8Array([7, 8, 9])])); + return true; +}; + +export const testSendSnapshotAndCloseOrder = async () => { + const ws = new WebSocket('ws://localhost:9999/echo'); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = (event) => reject(new Error(event && event.message || 'WebSocket error')); + }); + + ws.send(new Blob([new Uint8Array([1])])); + const arrayBufferBytes = new Uint8Array([2, 3]); + ws.send(arrayBufferBytes.buffer); + arrayBufferBytes.fill(9); + const viewBacking = new Uint8Array([0, 4, 5, 0]); + ws.send(viewBacking.subarray(1, 3)); + viewBacking.fill(8); + ws.send('tail'); + + await new Promise((resolve) => { + ws.onclose = resolve; + ws.close(3000, 'done'); + }); + return ws.bufferedAmount === 0; +}; diff --git a/examples/runtime/websocket/wit/websocket.wit b/examples/runtime/websocket/wit/websocket.wit new file mode 100644 index 000000000..35388cf16 --- /dev/null +++ b/examples/runtime/websocket/wit/websocket.wit @@ -0,0 +1,7 @@ +package quickjs:websocket; + +world websocket { + export test-binary-send: func() -> bool; + export test-websocket-stream-send: func() -> bool; + export test-send-snapshot-and-close-order: func() -> bool; +} diff --git a/tests/common/js_subtest_parser.rs b/tests/common/js_subtest_parser.rs index 165847c8c..5df75e993 100644 --- a/tests/common/js_subtest_parser.rs +++ b/tests/common/js_subtest_parser.rs @@ -187,10 +187,8 @@ fn extract_callback_body<'a>(call: &'a CallExpression<'a>) -> Option<&'a Functio return Some(body); } } - Argument::ArrowFunctionExpression(arrow) => { - if !arrow.expression { - return Some(&arrow.body); - } + Argument::ArrowFunctionExpression(arrow) if !arrow.expression => { + return Some(&arrow.body); } _ => {} } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index fb0926e93..2341c0939 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -22,7 +22,8 @@ use wasm_rquickjs::{ EmbeddingMode, GenerationTarget, JsModuleSpec, generate_wrapper_crate_with_target, }; use wasmtime::component::{ - Component, Func, Instance, Linker, ResourceAny, ResourceTable, ResourceType, Val, + Component, Func, HasSelf, Instance, Linker, Resource, ResourceAny, ResourceTable, ResourceType, + Val, }; use wasmtime::{Engine, Store, StoreContextMut, UpdateDeadline}; use wasmtime_wasi::cli::OutputFile; @@ -31,6 +32,197 @@ use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxView, WasiView}; use wasmtime_wasi_http::WasiHttpCtx; use wasmtime_wasi_http::p2::{WasiHttpCtxView, WasiHttpView, default_hooks}; +pub mod ws_mock_p2 { + wasmtime::component::bindgen!({ + world: "golem-websocket", + path: "crates/golem-websocket/wit", + imports: { default: async | trappable }, + with: { + "golem:websocket/client.websocket-connection": super::WsMockConnection, + }, + }); +} + +pub mod ws_mock_p3 { + wasmtime::component::bindgen!({ + world: "golem-websocket", + path: "crates/golem-websocket/wit-p3", + imports: { default: async | trappable }, + with: { + "golem:websocket/client.websocket-connection": super::WsMockConnection, + }, + }); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WsSentMessage { + Text(String), + Binary(Vec), + Close(Option, Option), +} + +pub struct WsMockConnection; + +impl ws_mock_p2::golem::websocket::client::Host for Host {} + +impl ws_mock_p2::golem::websocket::client::HostWebsocketConnection for Host { + async fn connect( + &mut self, + _url: String, + _headers: Option>, + ) -> wasmtime::Result< + Result, ws_mock_p2::golem::websocket::client::Error>, + > { + Ok(Ok(self.table.lock().unwrap().push(WsMockConnection)?)) + } + + async fn send( + &mut self, + _self_: Resource, + message: ws_mock_p2::golem::websocket::client::Message, + ) -> wasmtime::Result> { + let message = match message { + ws_mock_p2::golem::websocket::client::Message::Text(value) => { + WsSentMessage::Text(value) + } + ws_mock_p2::golem::websocket::client::Message::Binary(value) => { + WsSentMessage::Binary(value) + } + }; + self.ws_sent.lock().unwrap().push(message); + Ok(Ok(())) + } + + async fn receive( + &mut self, + _self_: Resource, + ) -> wasmtime::Result< + Result< + ws_mock_p2::golem::websocket::client::Message, + ws_mock_p2::golem::websocket::client::Error, + >, + > { + Ok(Err(ws_mock_p2::golem::websocket::client::Error::Closed( + None, + ))) + } + + async fn receive_with_timeout( + &mut self, + _self_: Resource, + _timeout_ms: u64, + ) -> wasmtime::Result< + Result< + Option, + ws_mock_p2::golem::websocket::client::Error, + >, + > { + Ok(Err(ws_mock_p2::golem::websocket::client::Error::Closed( + None, + ))) + } + + async fn close( + &mut self, + _self_: Resource, + code: Option, + reason: Option, + ) -> wasmtime::Result> { + self.ws_sent + .lock() + .unwrap() + .push(WsSentMessage::Close(code, reason)); + Ok(Ok(())) + } + + async fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + self.table.lock().unwrap().delete(rep)?; + Ok(()) + } +} + +impl ws_mock_p3::golem::websocket::client::Host for Host {} + +impl ws_mock_p3::golem::websocket::client::HostWebsocketConnection for Host { + async fn connect( + &mut self, + _url: String, + _headers: Option>, + ) -> wasmtime::Result< + Result, ws_mock_p3::golem::websocket::client::Error>, + > { + Ok(Ok(self.table.lock().unwrap().push(WsMockConnection)?)) + } + + async fn send( + &mut self, + _self_: Resource, + message: ws_mock_p3::golem::websocket::client::Message, + ) -> wasmtime::Result> { + let message = match message { + ws_mock_p3::golem::websocket::client::Message::Text(value) => { + WsSentMessage::Text(value) + } + ws_mock_p3::golem::websocket::client::Message::Binary(value) => { + WsSentMessage::Binary(value) + } + }; + self.ws_sent.lock().unwrap().push(message); + Ok(Ok(())) + } + + async fn close( + &mut self, + _self_: Resource, + code: Option, + reason: Option, + ) -> wasmtime::Result> { + self.ws_sent + .lock() + .unwrap() + .push(WsSentMessage::Close(code, reason)); + Ok(Ok(())) + } + + async fn drop(&mut self, rep: Resource) -> wasmtime::Result<()> { + self.table.lock().unwrap().delete(rep)?; + Ok(()) + } +} + +impl ws_mock_p3::golem::websocket::client::HostWebsocketConnectionWithStore + for HasSelf +{ + async fn receive( + _store: &wasmtime::component::Accessor, + _self_: Resource, + ) -> wasmtime::Result< + Result< + ws_mock_p3::golem::websocket::client::Message, + ws_mock_p3::golem::websocket::client::Error, + >, + > { + Ok(Err(ws_mock_p3::golem::websocket::client::Error::Closed( + None, + ))) + } + + async fn receive_with_timeout( + _store: &wasmtime::component::Accessor, + _self_: Resource, + _timeout_ms: u64, + ) -> wasmtime::Result< + Result< + Option, + ws_mock_p3::golem::websocket::client::Error, + >, + > { + Ok(Err(ws_mock_p3::golem::websocket::client::Error::Closed( + None, + ))) + } +} + /// Default timeout for node_compat tests (in seconds). pub const DEFAULT_NODE_COMPAT_TEST_TIMEOUT_SECS: u64 = 120; @@ -430,7 +622,7 @@ mod tests { assert!(output_fresh_for_inputs( &output, &stamp, - &[input.clone()], + std::slice::from_ref(&input), signature, )); @@ -602,67 +794,10 @@ fn test_linker_with_common_hosts(engine: &Engine) -> anyhow::Result )?; } - { - struct WsConn; - let mut ws = linker.instance("golem:websocket/client@1.5.0")?; - ws.resource("websocket-connection", ResourceType::host::(), { - move |_ctx: StoreContextMut<'_, Host>, _rep: u32| Ok(()) - })?; - - ws.func_new( - "[static]websocket-connection.connect", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket connect not available in tests", - )) - }, - )?; - - ws.func_new( - "[method]websocket-connection.send", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket send not available in tests", - )) - }, - )?; - - ws.func_new( - "[method]websocket-connection.receive", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket receive not available in tests", - )) - }, - )?; - - ws.func_new( - "[method]websocket-connection.receive-with-timeout", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket receive-with-timeout not available in tests", - )) - }, - )?; - - ws.func_new( - "[method]websocket-connection.close", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket close not available in tests", - )) - }, - )?; - - ws.func_new( - "[method]websocket-connection.subscribe", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket subscribe not available in tests", - )) - }, - )?; - } + ws_mock_p2::golem::websocket::client::add_to_linker::>( + &mut linker, + |host| host, + )?; Ok(linker) } @@ -1655,6 +1790,7 @@ impl TestInstance { started_at: Instant::now(), timeout: Duration::from_secs(120), log_messages: Arc::new(Mutex::new(Vec::new())), + ws_sent: Arc::new(Mutex::new(Vec::new())), #[cfg(feature = "use-golem-wasmtime")] io_ctx: Arc::new(Mutex::new(io_ctx)), golem_spans: golem_spans.clone(), @@ -1778,6 +1914,10 @@ impl TestInstance { self.store.data().log_messages.lock().unwrap().clone() } + pub fn read_ws_sent(&self) -> Vec { + self.store.data().ws_sent.lock().unwrap().clone() + } + async fn invoke_and_capture_output_inner( &mut self, interface_name: Option<&str>, @@ -2323,6 +2463,7 @@ pub struct Host { pub started_at: Instant, pub timeout: Duration, pub log_messages: Arc>>, + pub ws_sent: Arc>>, #[cfg(feature = "use-golem-wasmtime")] pub io_ctx: Arc>, pub golem_spans: Option>>>, @@ -2540,96 +2681,24 @@ fn add_golem_context_mock(linker: &mut Linker) -> anyhow::Result<()> { Ok(()) } -/// Mock `golem:websocket/client@1.5.0`: registers the `websocket-connection` resource and stubs -/// every method to fail. This satisfies the host import required when the `websocket` module is -/// compiled in; tests only exercise the JS-side API surface (globals, brand checks), not live -/// connections. +/// Add the target-specific functional `golem:websocket/client@1.5.0` mock. +/// +/// Connections close cleanly on receive and sent frames remain instance-local for exact assertions. fn add_websocket_client_mock(linker: &mut Linker, target: TestTarget) -> anyhow::Result<()> { - struct WsConn; - let mut ws = linker.instance("golem:websocket/client@1.5.0")?; - ws.resource("websocket-connection", ResourceType::host::(), { - move |_ctx: StoreContextMut<'_, Host>, _rep: u32| Ok(()) - })?; - - ws.func_new( - "[static]websocket-connection.connect", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket connect not available in tests", - )) - }, - )?; - - ws.func_new( - "[method]websocket-connection.send", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket send not available in tests", - )) - }, - )?; - match target { TestTarget::P2 => { - ws.func_new( - "[method]websocket-connection.receive", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket receive not available in tests", - )) - }, - )?; - ws.func_new( - "[method]websocket-connection.receive-with-timeout", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket receive-with-timeout not available in tests", - )) - }, + ws_mock_p2::golem::websocket::client::add_to_linker::>( + linker, + |host| host, )?; } TestTarget::P3 => { - ws.func_new_concurrent( - "[method]websocket-connection.receive", - |_accessor, _ty, _params, _results| { - Box::pin(async { - Err(wasmtime::Error::msg( - "WebSocket receive not available in tests", - )) - }) - }, - )?; - ws.func_new_concurrent( - "[method]websocket-connection.receive-with-timeout", - |_accessor, _ty, _params, _results| { - Box::pin(async { - Err(wasmtime::Error::msg( - "WebSocket receive-with-timeout not available in tests", - )) - }) - }, + ws_mock_p3::golem::websocket::client::add_to_linker::>( + linker, + |host| host, )?; } } - - ws.func_new( - "[method]websocket-connection.close", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket close not available in tests", - )) - }, - )?; - - ws.func_new( - "[method]websocket-connection.subscribe", - |_store, _ty, _params, _results| { - Err(wasmtime::Error::msg( - "WebSocket subscribe not available in tests", - )) - }, - )?; - Ok(()) } diff --git a/tests/common/test_server.rs b/tests/common/test_server.rs index d3c029fb1..ade981e77 100644 --- a/tests/common/test_server.rs +++ b/tests/common/test_server.rs @@ -10,7 +10,7 @@ use indoc::formatdoc; use serde::{Deserialize, Serialize}; use std::io::Cursor; use std::sync::Arc; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, mpsc, watch}; use tokio::task::JoinHandle; use tokio_util::io::ReaderStream; @@ -234,6 +234,41 @@ pub async fn start_test_server() -> (u16, TestServerHandle) { (host_http_port, TestServerHandle::new(handle)) } +pub async fn start_abort_test_server() -> (u16, TestServerHandle, mpsc::UnboundedReceiver<()>) { + let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (arrived_tx, arrived_rx) = mpsc::unbounded_channel(); + let (ready_tx, ready_rx) = watch::channel(false); + + let handle = tokio::spawn(async move { + let slow_ready = ready_tx.clone(); + let slow = axum::routing::any(async move || { + let _ = arrived_tx.send(()); + let _ = slow_ready.send(true); + std::future::pending::<&'static str>().await + }); + let ready = axum::routing::get(async move || { + let mut ready_rx = ready_rx; + while !*ready_rx.borrow() { + if ready_rx.changed().await.is_err() { + break; + } + } + "ready" + }); + let router = Router::new() + .route("/slow-response", slow) + .route( + "/redirect-to-slow", + axum::routing::any(async || (StatusCode::FOUND, [("Location", "/slow-response")])), + ) + .route("/abort-ready", ready); + axum::serve(listener, router).await.unwrap(); + }); + + (port, TestServerHandle::new(handle), arrived_rx) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct Todo { diff --git a/tests/goldenfiles/generated_types_encoding_exports.d.ts b/tests/goldenfiles/generated_types_encoding_exports.d.ts index 9128b82a0..97937fdfa 100644 --- a/tests/goldenfiles/generated_types_encoding_exports.d.ts +++ b/tests/goldenfiles/generated_types_encoding_exports.d.ts @@ -1,4 +1,6 @@ declare module 'encoding' { export function test1(): Promise; export function test2(): Promise; + export function test3(): Promise; + export function test4(): Promise; } diff --git a/tests/goldenfiles/generated_types_fetch_exports.d.ts b/tests/goldenfiles/generated_types_fetch_exports.d.ts index fe131d18c..22dc6e484 100644 --- a/tests/goldenfiles/generated_types_fetch_exports.d.ts +++ b/tests/goldenfiles/generated_types_fetch_exports.d.ts @@ -36,4 +36,8 @@ declare module 'fetch' { export function redirectWithFailingStreamBody(port: number): Promise; export function redirectWithInfiniteStreamBody(port: number): Promise; export function fetchFunctionShape(): Promise; + export function abortReleasesRequest(port: number): Promise; + export function abortReleasesUpload(port: number): Promise; + export function abortAfterRedirect(port: number): Promise; + export function abortResponseBody(port: number): Promise; } diff --git a/tests/goldenfiles/generated_types_response-constructor_exports.d.ts b/tests/goldenfiles/generated_types_response-constructor_exports.d.ts index a06353f10..3eea56ff9 100644 --- a/tests/goldenfiles/generated_types_response-constructor_exports.d.ts +++ b/tests/goldenfiles/generated_types_response-constructor_exports.d.ts @@ -12,6 +12,12 @@ declare module 'response-constructor' { export function testDefaultValues(): Promise; export function testMockFetchPattern(): Promise; export function testHeadersIteration(): Promise; + export function testRequestClone(): Promise; + export function testRequestCloneAfterConsume(): Promise; + export function testRequestBytesBlob(): Promise; + export function testResponseCloneStream(): Promise; + export function testTypedArrayBodies(): Promise; + export function testBufferSourceSnapshot(): Promise; export type TestResult = { name: string; passed: boolean; diff --git a/tests/goldenfiles/generated_types_websocket_exports.d.ts b/tests/goldenfiles/generated_types_websocket_exports.d.ts new file mode 100644 index 000000000..851ff7191 --- /dev/null +++ b/tests/goldenfiles/generated_types_websocket_exports.d.ts @@ -0,0 +1,5 @@ +declare module 'websocket' { + export function testBinarySend(): Promise; + export function testWebsocketStreamSend(): Promise; + export function testSendSnapshotAndCloseOrder(): Promise; +} diff --git a/tests/libraries/libraries.md b/tests/libraries/libraries.md index 935949008..424c8ac31 100644 --- a/tests/libraries/libraries.md +++ b/tests/libraries/libraries.md @@ -92,6 +92,7 @@ This document tracks compatibility testing of popular npm packages with the wasm | 40 | Anthropic SDK | `@anthropic-ai/sdk` | ✅ | 2026-03-17 | 5/5 wasm tests pass; constructor, URL builder, request builder, mock API call with `messages.create()`, error classes, and `toFile` all work; live API calls require credentials | | 41 | Vercel AI SDK | `ai` | ✅ | 2026-03-09 | All 5 bundled offline utility/message-processing tests pass in Node.js and wasm-rquickjs | | 42 | MCP SDK | `@modelcontextprotocol/sdk` | ✅ | 2026-03-09 | All 5 bundled offline tests pass in Node.js and wasm-rquickjs (in-memory client/server flows, tools, resources/prompts, stdio utilities, URI/error helpers) | +| 201 | OpenRouter SDK | `@openrouter/sdk` | ✅💰 | 2026-07-31 | Version 0.12.35 passes bundled client construction and a real HTTP mock chat request in Node.js and wasm-rquickjs; Rollup's generated-helper warnings are non-fatal; live calls require `OPENROUTER_API_KEY` | ## Authentication & Security diff --git a/tests/libraries/openrouter-sdk/.gitignore b/tests/libraries/openrouter-sdk/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/tests/libraries/openrouter-sdk/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/tests/libraries/openrouter-sdk/mock-server.mjs b/tests/libraries/openrouter-sdk/mock-server.mjs new file mode 100644 index 000000000..9559eea43 --- /dev/null +++ b/tests/libraries/openrouter-sdk/mock-server.mjs @@ -0,0 +1,69 @@ +import http from 'node:http'; + +const PORT = 18083; + +const sendJson = (res, status, payload) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(payload)); +}; + +const readJsonBody = (req) => + new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(error); + } + }); + req.on('error', reject); + }); + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const key = `${req.method} ${url.pathname}`; + + if (key === 'GET /health') { + sendJson(res, 200, { status: 'ok' }); + return; + } + + if (key === 'POST /api/v1/chat/completions') { + const body = await readJsonBody(req); + if ( + req.headers.authorization !== 'Bearer sk-test' + || body.model !== 'openai/gpt-test' + || body.messages?.[0]?.content !== 'Hello' + ) { + sendJson(res, 400, { error: 'unexpected request' }); + return; + } + + sendJson(res, 200, { + choices: [{ + finish_reason: 'stop', + index: 0, + message: { + content: 'offline reply', + role: 'assistant', + }, + }], + created: 1, + id: 'generation-test', + model: 'openai/gpt-test', + object: 'chat.completion', + system_fingerprint: null, + }); + return; + } + + sendJson(res, 404, { error: 'not found', method: req.method, path: url.pathname }); +}); + +server.listen(PORT, () => { + console.log(`Mock server listening on http://localhost:${PORT}`); +}); diff --git a/tests/libraries/openrouter-sdk/package-lock.json b/tests/libraries/openrouter-sdk/package-lock.json new file mode 100644 index 000000000..1ebbd64ce --- /dev/null +++ b/tests/libraries/openrouter-sdk/package-lock.json @@ -0,0 +1,737 @@ +{ + "name": "openrouter-sdk", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@openrouter/sdk": "0.12.35" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^28.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@openrouter/sdk": { + "version": "0.12.35", + "resolved": "https://registry.npmjs.org/@openrouter/sdk/-/sdk-0.12.35.tgz", + "integrity": "sha512-s4QVLLnG1AmfW3TjnnHUqGfsCkzwVK+kboGcZmKbde09m1DPqgzl4RUFt/HJ5v97MX8aEaN0UG3mKv2S+qj2Gw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/tests/libraries/openrouter-sdk/package.json b/tests/libraries/openrouter-sdk/package.json new file mode 100644 index 000000000..574505ba5 --- /dev/null +++ b/tests/libraries/openrouter-sdk/package.json @@ -0,0 +1,13 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "@openrouter/sdk": "0.12.35" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^28.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.0.0" + } +} diff --git a/tests/libraries/openrouter-sdk/results.md b/tests/libraries/openrouter-sdk/results.md new file mode 100644 index 000000000..07def9d8d --- /dev/null +++ b/tests/libraries/openrouter-sdk/results.md @@ -0,0 +1,45 @@ +# OpenRouter SDK Compatibility Test Results + +**Package:** `@openrouter/sdk` +**Version:** `0.12.35` +**Tested on:** 2026-07-31 + +## Test Results + +### test-01-basic.js — client construction and core API surface +- **Bundled Node.js:** ✅ PASS +- **wasm-rquickjs:** ✅ PASS + +## Integration Tests (HTTP Mock) + +**Mock server:** `mock-server.mjs` on port `18083` + +### test-integration-01-chat-send.js — chat request and response parsing +- **Bundled Node.js:** ✅ PASS +- **wasm-rquickjs:** ✅ PASS +- **Coverage:** Executes the SDK's real `Request.clone()` and `fetch` path, verifies + authorization and JSON request payload at the server, and parses a deterministic + chat-completion response. + +## Bundling + +Rollup reports that top-level `this` was rewritten to `undefined` in generated +TypeScript helper expressions in `esm/lib/sdks.js` and `esm/types/async.js`. +It also reports Zod's internal circular dependency. Both bundles complete and +execute successfully in Node.js and wasm-rquickjs, so these warnings are +non-blocking for version 0.12.35. + +## Live Service Tests + +Live OpenRouter requests were not run because no `OPENROUTER_API_KEY` is +configured. The deterministic HTTP mock covers request construction, transport, +and response decoding without credentials or external network access. + +## Summary + +- Offline tests passed: 1/1 in wasm-rquickjs (1/1 bundled Node.js) +- HTTP mock integration tests passed: 1/1 in wasm-rquickjs (1/1 bundled Node.js) +- Live service tests passed: N/A — no OpenRouter token configured +- Missing APIs: none observed +- Behavioral differences: none observed +- Blockers: none; live API calls require an `OPENROUTER_API_KEY` diff --git a/tests/libraries/openrouter-sdk/rollup.config.mjs b/tests/libraries/openrouter-sdk/rollup.config.mjs new file mode 100644 index 000000000..2b806cbf2 --- /dev/null +++ b/tests/libraries/openrouter-sdk/rollup.config.mjs @@ -0,0 +1,40 @@ +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import fs from 'node:fs'; + +const testFiles = fs.readdirSync('.').filter((file) => + file.startsWith('test-') && file.endsWith('.js')); + +const nodeBuiltins = [ + 'assert', 'buffer', 'child_process', 'crypto', 'dgram', 'dns', 'events', + 'fs', 'http', 'http2', 'https', 'module', 'net', 'os', 'path', + 'perf_hooks', 'process', 'querystring', 'readline', 'stream', 'stream/web', + 'string_decoder', 'tls', 'tty', 'url', 'util', 'v8', 'vm', 'worker_threads', 'zlib', +]; + +const externalPackages = (id) => { + const bare = id.replace(/^node:/, ''); + return nodeBuiltins.includes(bare); +}; + +export default testFiles.map((input) => ({ + input, + output: { + file: `dist/${input.replace('.js', '.bundle.js')}`, + format: 'esm', + inlineDynamicImports: true, + sourcemap: false, + }, + external: externalPackages, + plugins: [ + nodeResolve({ + extensions: ['.mjs', '.js', '.json', '.node'], + preferBuiltins: true, + }), + commonjs({ + include: ['node_modules/**'], + }), + json(), + ], +})); diff --git a/tests/libraries/openrouter-sdk/run-node.mjs b/tests/libraries/openrouter-sdk/run-node.mjs new file mode 100644 index 000000000..4ce2dbadc --- /dev/null +++ b/tests/libraries/openrouter-sdk/run-node.mjs @@ -0,0 +1,18 @@ +const testFile = process.argv[2]; +if (!testFile) { + console.error('Usage: node run-node.mjs ./dist/test-01-basic.bundle.js'); + process.exit(1); +} + +const mod = await import(testFile); + +try { + const result = await mod.run(); + console.log(result); + if (!result.startsWith('PASS')) { + process.exit(1); + } +} catch (error) { + console.error('FAIL:', error?.message || error); + process.exit(1); +} diff --git a/tests/libraries/openrouter-sdk/test-01-basic.js b/tests/libraries/openrouter-sdk/test-01-basic.js new file mode 100644 index 000000000..0278cb37d --- /dev/null +++ b/tests/libraries/openrouter-sdk/test-01-basic.js @@ -0,0 +1,16 @@ +import assert from 'node:assert'; +import { HTTPClient, OpenRouter } from '@openrouter/sdk'; + +export const run = () => { + const httpClient = new HTTPClient(); + const client = new OpenRouter({ + apiKey: 'sk-test', + httpClient, + }); + + assert.ok(client.chat); + assert.strictEqual(typeof client.chat.send, 'function'); + assert.strictEqual(typeof httpClient.request, 'function'); + + return 'PASS: OpenRouter client exposes the chat and HTTP client APIs'; +}; diff --git a/tests/libraries/openrouter-sdk/test-integration-01-chat-send.js b/tests/libraries/openrouter-sdk/test-integration-01-chat-send.js new file mode 100644 index 000000000..5405a6234 --- /dev/null +++ b/tests/libraries/openrouter-sdk/test-integration-01-chat-send.js @@ -0,0 +1,22 @@ +import assert from 'node:assert'; +import { OpenRouter } from '@openrouter/sdk'; + +const SERVER_URL = 'http://localhost:18083/api/v1'; + +export const run = async () => { + const client = new OpenRouter({ apiKey: 'sk-test' }); + const result = await client.chat.send({ + chatRequest: { + messages: [{ role: 'user', content: 'Hello' }], + model: 'openai/gpt-test', + }, + }, { + serverURL: SERVER_URL, + retries: { strategy: 'none' }, + }); + + assert.strictEqual(result.choices[0].message.content, 'offline reply'); + assert.strictEqual(result.choices[0].finishReason, 'stop'); + + return 'PASS: OpenRouter sends and parses a chat request through the HTTP stack'; +}; diff --git a/tests/libraries/openrouter-sdk/wit/openrouter-sdk.wit b/tests/libraries/openrouter-sdk/wit/openrouter-sdk.wit new file mode 100644 index 000000000..15ff4c6a8 --- /dev/null +++ b/tests/libraries/openrouter-sdk/wit/openrouter-sdk.wit @@ -0,0 +1,5 @@ +package test:lib-openrouter-sdk; + +world lib-openrouter-sdk { + export run: func() -> string; +} diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 8f2089635..1c571820a 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -6312,7 +6312,7 @@ "parallel/test-cwd-enoent-repl.js": { "category": "wasi-impossible", "reason": "requires spawning an interactive Node REPL subprocess (--interactive) and driving it via stdin" }, "parallel/test-cwd-enoent.js": { "category": "known-gap", "reason": "child_process spawn() stdio stream compatibility (e.g. pipe) is incomplete in execPath emulation" }, "parallel/test-data-url.js": { "category": "node-internals", "reason": "uses --expose-internals and require('internal/data_url')" }, - "parallel/test-datetime-change-notify.js": { "category": "known-gap", "reason": "requires Intl/timezone data support that is not available in the current runtime" }, + "parallel/test-datetime-change-notify.js": { "category": "known-gap", "reason": "Date does not react to process.env.TZ changes with full regional timezone-name data" }, "parallel/test-debug-process.js": { "category": "wasi-impossible", "reason": "Windows-specific process._debugProcess behavior is not available in WASI" }, "parallel/test-debug-v8-fast-api.js": { "category": "node-internals", "reason": "uses --expose-internals with internal/test/binding and V8 native syntax" }, "parallel/test-debugger-backtrace.js": { "category": "wasi-impossible", "reason": "inspector/debugger is not available in WASM" }, @@ -6761,14 +6761,14 @@ "parallel/test-icu-stringwidth.js": { "category": "node-internals", "reason": "requires --expose-internals and internal/util/inspect" }, "parallel/test-icu-transcode.js": { "category": "known-gap", - "reason": "Intl is not available in current runtime", + "reason": "buffer.transcode and ICU transcoding are not implemented", "split": true, "subtests": { - "block_00_block_00": { "reason": "inherited: Intl is not available in current runtime" }, - "block_01_block_01": { "reason": "inherited: Intl is not available in current runtime" }, - "block_02_test_that_uint8array_arguments_are_okay": { "reason": "inherited: Intl is not available in current runtime" }, - "block_03_block_03": { "reason": "inherited: Intl is not available in current runtime" }, - "block_04_test_that_it_doesn_t_crash": { "reason": "inherited: Intl is not available in current runtime" } + "block_00_block_00": { "reason": "inherited: buffer.transcode and ICU transcoding are not implemented" }, + "block_01_block_01": { "reason": "inherited: buffer.transcode and ICU transcoding are not implemented" }, + "block_02_test_that_uint8array_arguments_are_okay": { "reason": "inherited: buffer.transcode and ICU transcoding are not implemented" }, + "block_03_block_03": { "reason": "inherited: buffer.transcode and ICU transcoding are not implemented" }, + "block_04_test_that_it_doesn_t_crash": { "reason": "inherited: buffer.transcode and ICU transcoding are not implemented" } } }, "parallel/test-inspect-address-in-use.js": { "category": "wasi-impossible", "reason": "inspector/debugger is not available in WASM" }, @@ -6945,8 +6945,8 @@ }, "parallel/test-internal-validators-validateport.js": { "category": "node-internals", "reason": "requires --expose-internals and internal/validators" }, "parallel/test-internal-webidl-converttoint.js": { "category": "node-internals", "reason": "requires --expose-internals and internal/webidl" }, - "parallel/test-intl-v8BreakIterator.js": { "category": "known-gap", "reason": "Intl is not available in current runtime" }, - "parallel/test-intl.js": { "category": "known-gap", "reason": "Intl is not available in current runtime" }, + "parallel/test-intl-v8BreakIterator.js": { "category": "known-gap", "reason": "Intl is not installed in vm contexts and Intl.v8BreakIterator is not implemented" }, + "parallel/test-intl.js": { "category": "known-gap", "reason": "process.config reports ICU disabled and full Node Intl metadata and fidelity are not implemented" }, "parallel/test-js-stream-call-properties.js": { "category": "node-internals", "reason": "requires internalBinding('js_stream').JSStream" }, "parallel/test-kill-segfault-freebsd.js": {}, "parallel/test-listen-fd-cluster.js": { "category": "wasi-impossible", "reason": "cluster requires process forking and fd passing between processes" }, @@ -9085,7 +9085,7 @@ "parallel/test-tty-stdin-pipe.js": { "category": "known-gap", "reason": "node:readline module is not yet supported in WebAssembly environment" }, "parallel/test-ttywrap-invalid-fd.js": { "category": "node-internals", "reason": "uses --expose-internals and internalBinding('uv')" }, "parallel/test-ttywrap-stack.js": { "category": "known-gap", "reason": "deep async recursion intended to exercise V8 stack recovery can trap the QuickJS/WASM runtime before JavaScript can catch and log the RangeError" }, - "parallel/test-tz-version.js": { "category": "known-gap", "reason": "Intl (including process.versions.tz expectations) is not available in current runtime" }, + "parallel/test-tz-version.js": { "category": "known-gap", "reason": "process.config ICU path and process.versions.tz metadata are not available" }, "parallel/test-unhandled-exception-rethrow-error.js": { "category": "known-gap", "reason": "uncaughtException rethrow exit-code semantics are incomplete" }, "parallel/test-unhandled-exception-with-worker-inuse.js": { "category": "wasi-impossible", "reason": "requires worker_threads" }, "parallel/test-unicode-node-options.js": { "category": "node-internals", "reason": "uses --expose-internals and internal/options" }, @@ -9540,11 +9540,11 @@ }, "parallel/test-whatwg-encoding-custom-fatal-streaming.js": { "category": "known-gap", - "reason": "Intl is not available in current runtime", + "reason": "gated by common.hasIntl, which cannot be enabled until broader Intl/ICU and IDNA behavior is compatible", "split": true, "subtests": { - "block_00_block_00": { "reason": "inherited: Intl is not available in current runtime" }, - "block_01_block_01": { "reason": "inherited: Intl is not available in current runtime" } + "block_00_block_00": { "reason": "inherited: gated by common.hasIntl, which cannot be enabled until broader Intl/ICU and IDNA behavior is compatible" }, + "block_01_block_01": { "reason": "inherited: gated by common.hasIntl, which cannot be enabled until broader Intl/ICU and IDNA behavior is compatible" } } }, "parallel/test-whatwg-encoding-custom-internals.js": { "category": "node-internals", "reason": "requires --expose-internals and internal/encoding" }, @@ -9560,27 +9560,27 @@ } }, "parallel/test-whatwg-encoding-custom-textdecoder-api-invalid-label.js": { "category": "runnable" }, - "parallel/test-whatwg-encoding-custom-textdecoder-fatal.js": { "category": "known-gap", "reason": "Intl is not available in current runtime" }, + "parallel/test-whatwg-encoding-custom-textdecoder-fatal.js": { "category": "known-gap", "reason": "gated by common.hasIntl, which cannot be enabled until broader Intl/ICU and IDNA behavior is compatible" }, "parallel/test-whatwg-encoding-custom-textdecoder-invalid-arg.js": { "category": "runnable" }, "parallel/test-whatwg-encoding-custom-textdecoder-streaming.js": { "category": "runnable" }, - "parallel/test-whatwg-encoding-custom-textdecoder-utf16-surrogates.js": { "category": "known-gap", "reason": "Intl is not available in current runtime" }, + "parallel/test-whatwg-encoding-custom-textdecoder-utf16-surrogates.js": { "category": "known-gap", "reason": "gated by common.hasIntl, which cannot be enabled until broader Intl/ICU and IDNA behavior is compatible" }, "parallel/test-whatwg-encoding-custom-textdecoder.js": { - "category": "unevaluated", - "reason": "newly discovered, not yet evaluated", + "category": "known-gap", + "reason": "the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility", "split": true, "subtests": { - "block_00_test_textdecoder_utf_8_fatal_false_ignorebom_false": { "category": "runnable" }, - "block_01_test_textdecoder_utf_8_fatal_false_ignorebom_true": { "category": "runnable" }, - "block_02_invalid_encoders": { "category": "runnable" }, - "block_03_test_textdecoder_label_undefined_options_null": { "category": "runnable" }, - "block_04_test_textdecoder_utf_16le": { "category": "runnable" }, + "block_00_test_textdecoder_utf_8_fatal_false_ignorebom_false": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_01_test_textdecoder_utf_8_fatal_false_ignorebom_true": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_02_invalid_encoders": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_03_test_textdecoder_label_undefined_options_null": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_04_test_textdecoder_utf_16le": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, "block_05_test_textdecoder_inspect_with_hidden_fields": { "category": "known-gap", "reason": "hidden TextDecoder inspect output exposes Node/V8 internal decoder handles that are not represented in this runtime" }, - "block_06_test_textdecoder_inspect_without_hidden_fields": { "category": "runnable" }, - "block_07_test_textdecoder_inspect_with_negative_depth": { "category": "runnable" }, - "block_08_block_08": { "category": "runnable" }, - "block_09_block_09": { "category": "runnable" }, - "block_10_test_textdecoder_for_incomplete_utf_8_byte_sequence": { "category": "runnable" }, - "block_11_block_11": { "category": "runnable" } + "block_06_test_textdecoder_inspect_without_hidden_fields": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_07_test_textdecoder_inspect_with_negative_depth": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_08_block_08": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_09_block_09": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_10_test_textdecoder_for_incomplete_utf_8_byte_sequence": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" }, + "block_11_block_11": { "reason": "inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility" } } }, "parallel/test-whatwg-events-add-event-listener-options-passive.js": { @@ -9743,8 +9743,8 @@ "parallel/test-whatwg-url-canparse.js": { "category": "runnable" }, "parallel/test-whatwg-url-custom-global.js": { "category": "runnable" }, "parallel/test-whatwg-url-custom-href-side-effect.js": { "category": "runnable" }, - "parallel/test-whatwg-url-custom-inspect.js": { "category": "known-gap", "reason": "Intl is not available in current runtime" }, - "parallel/test-whatwg-url-custom-parsing.js": { "category": "known-gap", "reason": "Intl is not available in current runtime" }, + "parallel/test-whatwg-url-custom-inspect.js": { "category": "known-gap", "reason": "URL inspect output uses the URL string instead of Node's structured URL representation" }, + "parallel/test-whatwg-url-custom-parsing.js": { "category": "known-gap", "reason": "invalid URL parsing errors lack Node's TypeError and ERR_INVALID_URL shape" }, "parallel/test-whatwg-url-custom-properties.js": { "category": "known-gap", "reason": "native rquickjs URL class property enumeration order does not match Web IDL order and descriptors are not fully configurable from JS" }, "parallel/test-whatwg-url-custom-searchparams-append.js": { "category": "runnable" }, "parallel/test-whatwg-url-custom-searchparams-constructor.js": { @@ -9782,8 +9782,8 @@ "parallel/test-whatwg-url-custom-setters.js": { "split": true, "subtests": { - "block_00_block_00": { "category": "known-gap", "reason": "Intl is not available in current runtime" }, - "block_01_block_01": { "category": "known-gap", "reason": "Intl is not available in current runtime" } + "block_00_block_00": { "category": "known-gap", "reason": "node_compat common shim is missing ../common/wpt harness" }, + "block_01_block_01": { "category": "known-gap", "reason": "node_compat common shim is missing ../common/wpt harness" } } }, "parallel/test-whatwg-url-custom-tostringtag.js": { "category": "runnable" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index c5a4dc103..96b466289 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3239/4425 (73.2%) +**Primary compatibility (CI-enforced):** 3228/4425 (72.9%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3239 | 73.2% | 56.3% | 47.1% | -| 🧩 known gap | 1186 | 26.8% | 20.6% | 17.3% | +| ✅ passing (runnable) | 3228 | 72.9% | 56.1% | 47.0% | +| 🧩 known gap | 1197 | 27.1% | 20.8% | 17.4% | | 🚫 WASI-impossible (excluded) | 1157 | — | 20.1% | 16.8% | | ⚙️ engine difference (excluded) | 168 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1123 | — | — | 16.3% | | **Total** | **6873** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3239/5750 (56.3%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3228/5750 (56.1%)**. ## Inventory by Module @@ -84,7 +84,7 @@ Secondary full-public compatibility, including public tests that are currently e | vm | 128 | 73 | 39 | 3 | 13 | 0 | 0 | 65.2% | 57.0% | | webcrypto | 107 | 43 | 21 | 1 | 0 | 0 | 42 | 67.2% | 66.2% | | webstreams | 68 | 67 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | -| whatwg | 261 | 54 | 21 | 0 | 0 | 0 | 186 | 72.0% | 72.0% | +| whatwg | 261 | 43 | 32 | 0 | 0 | 0 | 186 | 57.3% | 57.3% | | worker_threads | 189 | 4 | 51 | 126 | 0 | 0 | 8 | 7.3% | 2.2% | | zlib | 61 | 52 | 5 | 0 | 0 | 0 | 4 | 91.2% | 91.2% | @@ -616,7 +616,7 @@ Secondary full-public compatibility, including public tests that are currently e | `test-webstreams-pipeline.js` | 17 | 17 | 0 | 0 | 0 | 0 | 0 | | `test-whatwg-encoding-custom-fatal-streaming.js` | 2 | 0 | 2 | 0 | 0 | 0 | 0 | | `test-whatwg-encoding-custom-interop.js` | 4 | 0 | 0 | 0 | 0 | 0 | 4 | -| `test-whatwg-encoding-custom-textdecoder.js` | 12 | 11 | 1 | 0 | 0 | 0 | 0 | +| `test-whatwg-encoding-custom-textdecoder.js` | 12 | 0 | 12 | 0 | 0 | 0 | 0 | | `test-whatwg-events-add-event-listener-options-passive.js` | 2 | 1 | 1 | 0 | 0 | 0 | 0 | | `test-whatwg-events-add-event-listener-options-signal.js` | 10 | 10 | 0 | 0 | 0 | 0 | 0 | | `test-whatwg-events-customevent.js` | 3 | 3 | 0 | 0 | 0 | 0 | 0 | @@ -684,7 +684,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1186) +### known gap (1197) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -697,6 +697,7 @@ Secondary full-public compatibility, including public tests that are currently e | inherited: dns.getServers()/setServers default-server behavior and validation are not Node-compatible | 12 | `parallel/test-dns.js#block_00_verify_that_setservers_handles_arrays_with_holes_and_other_o`, `parallel/test-dns.js#block_01_block_01`, `parallel/test-dns.js#block_02_block_02`, ... (+9) | | node:readline module is not yet supported in WebAssembly environment | 12 | `parallel/test-readline-keys.js`, `parallel/test-readline-position.js`, `parallel/test-readline-reopen.js`, ... (+9) | | inherited: process.permission and --permission CLI semantics are incomplete in execPath emulation | 11 | `parallel/test-permission-allow-child-process-cli.js#block_00_guarantee_the_initial_state`, `parallel/test-permission-allow-child-process-cli.js#block_01_to_spawn_unless_allow_child_process_is_sent`, `parallel/test-permission-allow-wasi-cli.js#block_00_guarantee_the_initial_state`, ... (+8) | +| inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility | 11 | `parallel/test-whatwg-encoding-custom-textdecoder.js#block_00_test_textdecoder_utf_8_fatal_false_ignorebom_false`, `parallel/test-whatwg-encoding-custom-textdecoder.js#block_01_test_textdecoder_utf_8_fatal_false_ignorebom_true`, `parallel/test-whatwg-encoding-custom-textdecoder.js#block_02_invalid_encoders`, ... (+8) | | net.js TCP implementation incomplete - needs event handling and API fixes | 11 | `parallel/test-net-connect-nodelay.js`, `parallel/test-net-connect-paused-connection.js`, `parallel/test-net-during-close.js`, ... (+8) | | remaining failures run through spawnSync(process.execPath, ...) and assert exact child-process status/stderr cycle diagnostics; direct node modules app same-process module graph coverage lives in tests/node_modules_apps | 11 | `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_00_a_mjs_b_cjs_c_mjs_a_mjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_01_b_cjs_c_mjs_a_mjs_b_cjs`, `es-module/test-require-module-cycle-esm-cjs-esm-esm.js#block_02_c_mjs_a_mjs_b_cjs_c_mjs`, ... (+8) | | wasi:sockets UDP implementation hangs in wasmtime | 11 | `parallel/test-dgram-implicit-bind.js`, `parallel/test-dgram-multicast-set-interface.js#block_00_block_00`, `parallel/test-dgram-multicast-set-interface.js#block_02_block_02`, ... (+8) | @@ -705,12 +706,10 @@ Secondary full-public compatibility, including public tests that are currently e | spawn() AbortSignal handling is incomplete (exit code/signal/error semantics differ from Node) | 9 | `parallel/test-child-process-spawn-controller.js#block_00_block_00`, `parallel/test-child-process-spawn-controller.js#block_01_block_01`, `parallel/test-child-process-spawn-controller.js#block_02_block_02`, ... (+6) | | spawnSync() returns ENOSYS for non-execPath commands; Node expects ENOENT after option validation | 9 | `parallel/test-child-process-spawnsync-validation-errors.js#block_00_block_00`, `parallel/test-child-process-spawnsync-validation-errors.js#block_01_block_01`, `parallel/test-child-process-spawnsync-validation-errors.js#block_02_block_02`, ... (+6) | | stripTypeScriptTypes requires Amaro support, which is not implemented | 9 | `parallel/test-module-strip-types.js#test_00_striptypescripttypes`, `parallel/test-module-strip-types.js#test_01_striptypescripttypes_explicit`, `parallel/test-module-strip-types.js#test_02_striptypescripttypes_code_is_not_a_string`, ... (+6) | -| Intl is not available in current runtime | 8 | `parallel/test-intl-v8BreakIterator.js`, `parallel/test-intl.js`, `parallel/test-whatwg-encoding-custom-textdecoder-fatal.js`, ... (+5) | | process unhandledRejection/rejectionHandled/warning mode behavior is incomplete | 8 | `parallel/test-promise-unhandled-silent-no-hook.js`, `parallel/test-promise-unhandled-silent.js`, `parallel/test-promise-unhandled-warn-no-hook.js`, ... (+5) | | vm.constants.DONT_CONTEXTIFY and vanilla-context behavior are not implemented | 8 | `parallel/test-vm-context-dont-contextify.js#block_00_block_00`, `parallel/test-vm-context-dont-contextify.js#block_01_block_01`, `parallel/test-vm-context-dont-contextify.js#block_02_block_02`, ... (+5) | | WebAssembly module loading for .wasm files is not implemented; binary input is currently treated as JS source | 7 | `es-module/test-esm-extensionless-esm-and-wasm.mjs#test_04_should_be_importable`, `es-module/test-esm-extensionless-esm-and-wasm.mjs#test_05_should_be_importable_from_a_module_scope_under_node_modules`, `es-module/test-esm-extensionless-esm-and-wasm.mjs#test_09_should_run_on_import`, ... (+4) | | common-shim spawnPromisified child emulation does not support --experimental-webstorage/--localstorage-file flags | 7 | `parallel/test-webstorage.js#test_01_emits_a_warning_when_used`, `parallel/test-webstorage.js#test_02_storage_instances_cannot_be_created_in_userland`, `parallel/test-webstorage.js#test_03_sessionstorage_is_not_persisted`, ... (+4) | -| inherited: Intl is not available in current runtime | 7 | `parallel/test-icu-transcode.js#block_00_block_00`, `parallel/test-icu-transcode.js#block_01_block_01`, `parallel/test-icu-transcode.js#block_02_test_that_uint8array_arguments_are_okay`, ... (+4) | | requires spawned process.execPath entry-point execution with --experimental-default-type=module | 7 | `es-module/test-esm-type-flag-loose-files.mjs#test_00_should_run_as_esm_a_js_file_that_is_outside_of_any_package_s`, `es-module/test-esm-type-flag-loose-files.mjs#test_01_should_run_as_esm_an_extensionless_javascript_file_that_is_o`, `es-module/test-esm-type-flag-package-scopes.mjs#test_00_should_run_as_esm_an_extensionless_javascript_file_within_a_`, ... (+4) | | WebAssembly global is missing in current runtime | 6 | `es-module/test-wasm-memory-out-of-bound.js`, `es-module/test-wasm-simple.js`, `es-module/test-wasm-web-api.js`, ... (+3) | | fork() AbortSignal handling is incomplete (exit code/signal/error semantics differ from Node) | 6 | `parallel/test-child-process-fork-abort-signal.js#block_00_block_00`, `parallel/test-child-process-fork-abort-signal.js#block_01_block_01`, `parallel/test-child-process-fork-abort-signal.js#block_02_block_02`, ... (+3) | @@ -719,8 +718,10 @@ Secondary full-public compatibility, including public tests that are currently e | inherited: performance.timerify function entries are not implemented | 6 | `parallel/test-performance-function.js#block_00_block_00`, `parallel/test-performance-function.js#block_01_block_01`, `parallel/test-performance-function.js#block_02_block_02`, ... (+3) | | IPv6 sockets are not available in this runtime (common.hasIPv6=false) | 5 | `parallel/test-dgram-ipv6only.js`, `parallel/test-dgram-udp6-link-local-address.js`, `parallel/test-dgram-udp6-send-default-host.js`, ... (+2) | | http.request({ createConnection }) generic duplex stream semantics are incomplete (request dispatch, keep-alive, and clientError paths) | 5 | `parallel/test-http-generic-streams.js#block_00_test_1_simple_http_test_no_keep_alive`, `parallel/test-http-generic-streams.js#block_01_test_2_keep_alive_for_2_requests`, `parallel/test-http-generic-streams.js#block_02_test_3_connection_close_request_response_with_chunked`, ... (+2) | +| inherited: buffer.transcode and ICU transcoding are not implemented | 5 | `parallel/test-icu-transcode.js#block_00_block_00`, `parallel/test-icu-transcode.js#block_01_block_01`, `parallel/test-icu-transcode.js#block_02_test_that_uint8array_arguments_are_okay`, ... (+2) | | inherited: perf_hooks PerformanceResourceTiming/markResourceTiming behavior is incomplete | 5 | `parallel/test-perf-hooks-resourcetiming.js#block_00_performanceresourcetiming_should_not_be_initialized_external`, `parallel/test-perf-hooks-resourcetiming.js#block_01_using_performance_getentries`, `parallel/test-perf-hooks-resourcetiming.js#block_02_default_values`, ... (+2) | | node:readline createInterface/async iterator API is not implemented | 5 | `parallel/test-readline-async-iterators-backpressure.js`, `parallel/test-readline-async-iterators-destroy.js`, `parallel/test-readline-async-iterators.js`, ... (+2) | +| node_compat common shim is missing ../common/wpt harness | 5 | `parallel/test-whatwg-events-event-constructors.js`, `parallel/test-whatwg-events-eventtarget-this-of-listener.js`, `parallel/test-whatwg-url-custom-searchparams-sort.js`, ... (+2) | | process.getActiveResourcesInfo() is not implemented | 5 | `parallel/test-process-getactiveresources-track-active-handles.js`, `parallel/test-process-getactiveresources-track-active-requests.js`, `parallel/test-process-getactiveresources-track-interval-lifetime.js`, ... (+2) | | requires Node TypeScript stripping/Amaro support, which is out of scope for this module PR | 5 | `es-module/test-typescript-commonjs.mjs`, `es-module/test-typescript-eval.mjs`, `es-module/test-typescript-module.mjs`, ... (+2) | | util.format output formatting differences | 5 | `parallel/test-util-format.js#block_00_block_00`, `parallel/test-util-format.js#block_01_string_format_specifier_including_tostring_properties_on_the`, `parallel/test-util-format.js#block_02_symbol_toprimitive_handling_for_string_format_specifier`, ... (+2) | @@ -760,7 +761,6 @@ Secondary full-public compatibility, including public tests that are currently e | net edge case not yet handled | 3 | `parallel/test-net-autoselectfamily.js#block_01_test_that_only_the_last_successful_connection_is_established`, `parallel/test-net-connect-reset.js`, `parallel/test-net-pingpong.js` | | node:readline Interface constructor/options are not implemented | 3 | `parallel/test-readline-interface-escapecodetimeout.js`, `parallel/test-readline-interface-no-trailing-newline.js`, `parallel/test-readline-interface-recursive-writes.js` | | node:test concurrency scheduling/completion semantics are incomplete | 3 | `parallel/test-runner-concurrency.js#test_00_concurrency_option_boolean_true`, `parallel/test-runner-concurrency.js#test_01_concurrency_option_boolean_false`, `parallel/test-runner-concurrency.js#test_02_concurrency_true_implies_infinity` | -| node_compat common shim is missing ../common/wpt harness | 3 | `parallel/test-whatwg-events-event-constructors.js`, `parallel/test-whatwg-events-eventtarget-this-of-listener.js`, `parallel/test-whatwg-url-custom-searchparams-sort.js` | | perf_hooks incomplete | 3 | `parallel/test-performance-gc.js#block_00_adding_an_observer_should_force_at_least_one_gc_to_appear`, `parallel/test-performance-measure-detail.js`, `parallel/test-performance-measure.js` | | perf_hooks.monitorEventLoopDelay is not implemented | 3 | `sequential/test-performance-eventloopdelay.js#block_00_block_00`, `sequential/test-performance-eventloopdelay.js#block_01_block_01`, `sequential/test-performance-eventloopdelay.js#block_02_block_02` | | setUncaughtExceptionCaptureCallback does not fully intercept thrown uncaught exceptions | 3 | `parallel/test-process-exception-capture-should-abort-on-uncaught-setflagsfromstring.js`, `parallel/test-process-exception-capture-should-abort-on-uncaught.js`, `parallel/test-process-exception-capture.js` | @@ -785,9 +785,11 @@ Secondary full-public compatibility, including public tests that are currently e | execPath child emulation does not yet support trace-events CLI arg parsing used by -e runs | 2 | `parallel/test-trace-events-fs-async.js`, `parallel/test-trace-events-fs-sync.js` | | fork() timeout/killSignal behavior is not Node-compatible in WASM emulation | 2 | `parallel/test-child-process-fork-timeout-kill-signal.js#block_00_block_00`, `parallel/test-child-process-fork-timeout-kill-signal.js#block_01_block_01` | | fork()/spawn() IPC send() boolean/backpressure semantics are not implemented | 2 | `parallel/test-child-process-send-returns-boolean.js#block_00_block_00`, `parallel/test-child-process-send-returns-boolean.js#block_01_block_01` | +| gated by common.hasIntl, which cannot be enabled until broader Intl/ICU and IDNA behavior is compatible | 2 | `parallel/test-whatwg-encoding-custom-textdecoder-fatal.js`, `parallel/test-whatwg-encoding-custom-textdecoder-utf16-surrogates.js` | | http edge case not yet handled | 2 | `parallel/test-http-agent-close.js`, `parallel/test-http-insecure-parser.js` | | inherited: dgram multicast loopback API is not implemented (ENOSYS) | 2 | `parallel/test-dgram-multicast-loopback.js#block_00_block_00`, `parallel/test-dgram-multicast-loopback.js#block_01_block_01` | | inherited: dgram setBroadcast API is not implemented (ENOSYS) | 2 | `parallel/test-dgram-setBroadcast.js#block_00_block_00`, `parallel/test-dgram-setBroadcast.js#block_01_block_01` | +| inherited: gated by common.hasIntl, which cannot be enabled until broader Intl/ICU and IDNA behavior is compatible | 2 | `parallel/test-whatwg-encoding-custom-fatal-streaming.js#block_00_block_00`, `parallel/test-whatwg-encoding-custom-fatal-streaming.js#block_01_block_01` | | inherited: listen(options) argument validation/error semantics are not fully Node-compatible | 2 | `parallel/test-net-server-listen-options.js#block_01_block_01`, `parallel/test-net-server-listen-options.js#block_02_block_02` | | inherited: process.getActiveResourcesInfo() is not implemented | 2 | `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_00_block_00`, `parallel/test-process-getactiveresources-track-timer-lifetime.js#block_01_block_01` | | inherited: queueMicrotask argument validation/error codes are incomplete | 2 | `parallel/test-queue-microtask.js#block_00_block_00`, `parallel/test-queue-microtask.js#block_01_block_01` | @@ -857,6 +859,7 @@ Secondary full-public compatibility, including public tests that are currently e | ClientRequest.setTimeout callback path does not reliably destroy/close the request | 1 | `parallel/test-http-client-timeout.js` | | ClientRequest.shouldKeepAlive handling for HTTP/1.0 and Connection headers is not fully Node-compatible | 1 | `parallel/test-http-should-keep-alive.js` | | Custom lookup error path is incomplete (request error events are not emitted correctly) | 1 | `parallel/test-http-client-req-error-dont-double-fire.js` | +| Date does not react to process.env.TZ changes with full regional timezone-name data | 1 | `parallel/test-datetime-change-notify.js` | | Date timezone changes via process.env.TZ are not implemented | 1 | `parallel/test-process-env-tz.js` | | ECDH key import/deriveBits compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-derivebits-ecdh.js` | | ECDH key import/deriveKey compatibility for test vectors is incomplete | 1 | `parallel/test-webcrypto-derivekey-ecdh.js` | @@ -901,7 +904,7 @@ Secondary full-public compatibility, including public tests that are currently e | IncomingBody lifecycle on extra response data can trap in wasi:http integration | 1 | `parallel/test-http-extra-response.js` | | IncomingMessage.destroy(err) close/errored state transitions are not Node-compatible | 1 | `parallel/test-http-client-incomingmessage-destroy.js` | | IncomingMessage.setTimeout() does not schedule/emit response timeout events | 1 | `parallel/test-http-client-response-timeout.js` | -| Intl (including process.versions.tz expectations) is not available in current runtime | 1 | `parallel/test-tz-version.js` | +| Intl is not installed in vm contexts and Intl.v8BreakIterator is not implemented | 1 | `parallel/test-intl-v8BreakIterator.js` | | Keep-alive request queue/release-before-finish semantics are incomplete | 1 | `parallel/test-http-client-keep-alive-release-before-finish.js` | | MessageEvent.target/ports fields are incomplete | 1 | `parallel/test-worker-message-port.js#block_02_block_02` | | MessagePort EventTarget API integration is incomplete | 1 | `parallel/test-worker-message-port.js#block_01_block_01` | @@ -956,6 +959,7 @@ Secondary full-public compatibility, including public tests that are currently e | SourceTextModule evaluation timeout does not interrupt an infinite loop | 1 | `parallel/test-vm-module-basic.js#block_02_statement_02` | | SourceTextModule identifiers are not incremented per VM context like Node | 1 | `parallel/test-vm-module-basic.js#block_03_check_the_generated_identifier_for_each_module` | | Timeout listener bookkeeping on keep-alive sockets is not Node-compatible | 1 | `parallel/test-http-client-timeout-option-listeners.js` | +| URL inspect output uses the URL string instead of Node's structured URL representation | 1 | `parallel/test-whatwg-url-custom-inspect.js` | | WASI UDP ping-pong over loopback does not reliably deliver datagrams in the local runtime despite Node-compatible hostname resolution | 1 | `sequential/test-dgram-pingpong.js` | | WASM child emulation does not support --experimental-test-module-mocks CLI flag | 1 | `parallel/test-runner-module-mocking.js#test_11_node_modules_can_be_used_by_both_module_systems` | | WASM child emulation does not support --experimental-test-module-mocks/--experimental-default-type flags | 1 | `parallel/test-runner-module-mocking.js#test_16_wrong_import_syntax_should_throw_error_after_module_mocking` | @@ -1133,6 +1137,7 @@ Secondary full-public compatibility, including public tests that are currently e | importing scrypt-encrypted PKCS#8 keys traps in the WASM crypto backend | 1 | `parallel/test-crypto-key-objects.js#block_05_block_05` | | inherited: Resolver#setLocalAddress validation/error behavior is not implemented | 1 | `parallel/test-dns-setlocaladdress.js#block_01_verify_that_setlocaladdress_throws_if_called_with_an_invalid` | | invalid EC private keys do not raise Node-compatible DataError | 1 | `parallel/test-webcrypto-export-import-ec.js#block_01_bad_private_keys` | +| invalid URL parsing errors lack Node's TypeError and ERR_INVALID_URL shape | 1 | `parallel/test-whatwg-url-custom-parsing.js` | | invalid repeated Transfer-Encoding handling differs from Node | 1 | `parallel/test-http-transfer-encoding-repeated-chunked.js` | | keep-alive free-socket lifecycle (free event + req.destroyed transitions) is not Node-compatible | 1 | `parallel/test-http-keepalive-free.js` | | keep-alive request sequencing with unread request bodies has non-Node lifecycle behavior | 1 | `parallel/test-http-no-read-no-dump.js` | @@ -1184,6 +1189,8 @@ Secondary full-public compatibility, including public tests that are currently e | process uncaughtException handling inside http client callbacks is incomplete | 1 | `parallel/test-http-catch-uncaughtexception.js` | | process unhandledRejection/warning semantics are incomplete | 1 | `parallel/test-promise-handled-rejection-no-warning.js` | | process.assert() is not implemented | 1 | `parallel/test-process-assert.js` | +| process.config ICU path and process.versions.tz metadata are not available | 1 | `parallel/test-tz-version.js` | +| process.config reports ICU disabled and full Node Intl metadata and fidelity are not implemented | 1 | `parallel/test-intl.js` | | process.env defaults are incomplete (PATH is missing in VM context) | 1 | `parallel/test-vm-access-process-env.js` | | process.exitCode validation and coercion semantics are incomplete | 1 | `parallel/test-process-exit-code-validation.js` | | process.loadEnvFile() behavior is incomplete | 1 | `parallel/test-process-load-env-file.js` | @@ -1210,7 +1217,6 @@ Secondary full-public compatibility, including public tests that are currently e | request/response pause-resume flow control does not complete with Node-compatible behavior | 1 | `parallel/test-http-pause.js` | | requires ERR_INVALID_ARG_TYPE validation on resolve methods (not yet implemented) | 1 | `parallel/test-dns-resolvens-typeerror.js` | | requires HTTP server functionality, we only support clients | 1 | `parallel/test-diagnostic-channel-http-response-created.js` | -| requires Intl/timezone data support that is not available in the current runtime | 1 | `parallel/test-datetime-change-notify.js` | | requires V8-style GC/finalization behavior for rapidly churned HTTP client requests; current QuickJS/WASM runtime does not collect all watched request objects reliably | 1 | `parallel/test-gc-http-client-connaborted.js` | | requires V8-style GC/finalization behavior for rapidly churned net sockets with timeouts; current QuickJS/WASM runtime does not collect all watched socket objects reliably | 1 | `parallel/test-gc-net-timeout.js` | | requires actual TCP socket reuse with remotePort identity tracking via server; wasi:http creates new connections per request | 1 | `parallel/test-http-agent-scheduling.js` | diff --git a/tests/runtime/diagnostics_channel_golem.rs b/tests/runtime/diagnostics_channel_golem.rs index c5ce055f5..bc3860949 100644 --- a/tests/runtime/diagnostics_channel_golem.rs +++ b/tests/runtime/diagnostics_channel_golem.rs @@ -64,43 +64,44 @@ async fn golem_context_tracing( let golem_spans = instance .golem_spans() .expect("Golem-prepared test instance should record spans"); - let spans = golem_spans.lock().unwrap(); - assert!( - spans.len() >= 3, - "Expected at least 3 spans (2 http.client + 1 custom), got {}. Output: {}", - spans.len(), - output - ); + { + let spans = golem_spans.lock().unwrap(); + assert!( + spans.len() >= 3, + "Expected at least 3 spans (2 http.client + 1 custom), got {}. Output: {}", + spans.len(), + output + ); - // Check first span (successful GET) - let first_span = &spans[0]; - assert!(first_span.finished, "First span should be finished"); - assert!( - first_span.attributes.iter().any(|(k, _)| k == "method"), - "First span should have 'method' attribute: {:?}", - first_span.attributes - ); - assert!( - first_span - .attributes - .iter() - .any(|(k, v)| k == "method" && v == "GET"), - "First span method should be GET: {:?}", - first_span.attributes - ); + // Check first span (successful GET) + let first_span = &spans[0]; + assert!(first_span.finished, "First span should be finished"); + assert!( + first_span.attributes.iter().any(|(k, _)| k == "method"), + "First span should have 'method' attribute: {:?}", + first_span.attributes + ); + assert!( + first_span + .attributes + .iter() + .any(|(k, v)| k == "method" && v == "GET"), + "First span method should be GET: {:?}", + first_span.attributes + ); - // Check second span (failed POST with error) - let second_span = &spans[1]; - assert!(second_span.finished, "Second span should be finished"); - assert!( - second_span - .attributes - .iter() - .any(|(k, v)| k == "error" && v == "true"), - "Second span should have error=true: {:?}", - second_span.attributes - ); - drop(spans); + // Check second span (failed POST with error) + let second_span = &spans[1]; + assert!(second_span.finished, "Second span should be finished"); + assert!( + second_span + .attributes + .iter() + .any(|(k, v)| k == "error" && v == "true"), + "Second span should have error=true: {:?}", + second_span.attributes + ); + } let second_instance = TestInstance::from_golem_prepared(&prepared).await?; let second_golem_spans = second_instance diff --git a/tests/runtime/encoding.rs b/tests/runtime/encoding.rs index 1894c3e24..e71bfc8e4 100644 --- a/tests/runtime/encoding.rs +++ b/tests/runtime/encoding.rs @@ -65,3 +65,29 @@ async fn encoding_coercion(#[tagged_as("encoding")] compiled: &CompiledTest) -> assert_eq!(r, Some(wasmtime::component::Val::Bool(true))); Ok(()) } + +#[test] +async fn encoding_fatal(#[tagged_as("encoding")] compiled: &CompiledTest) -> anyhow::Result<()> { + let (result, output) = + invoke_and_capture_output(compiled.wasm_path(), None, "test3", &[]).await; + let result = result?; + + println!("Output:\n{output}"); + + assert_eq!(result, Some(wasmtime::component::Val::Bool(true))); + Ok(()) +} + +#[test] +async fn encoding_legacy_streaming( + #[tagged_as("encoding")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + let (result, output) = + invoke_and_capture_output(compiled.wasm_path(), None, "test4", &[]).await; + let result = result?; + + println!("Output:\n{output}"); + + assert_eq!(result, Some(wasmtime::component::Val::Bool(true))); + Ok(()) +} diff --git a/tests/runtime/fetch.rs b/tests/runtime/fetch.rs index 77afd30a7..30825a51c 100644 --- a/tests/runtime/fetch.rs +++ b/tests/runtime/fetch.rs @@ -1,10 +1,12 @@ -use crate::common::test_server::start_test_server; +use crate::common::test_server::{start_abort_test_server, start_test_server}; use crate::common::{CompiledTest, TestTarget, invoke_and_capture_output, test_target}; use camino::Utf8Path; use test_r::{test, test_dep}; use wasmtime::component::Val; +const ABORT_REQUEST_ARRIVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180); + #[test_dep(tagged_as = "fetch", scope = Cloneable)] async fn compiled_fetch() -> CompiledTest { let path = Utf8Path::new("examples/runtime/fetch"); @@ -930,3 +932,89 @@ async fn fetch_function_shape(#[tagged_as("fetch")] compiled: &CompiledTest) -> Ok(()) } + +#[test] +async fn fetch_abort_releases_request( + #[tagged_as("fetch")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + let (port, _server, mut arrivals) = start_abort_test_server().await; + let wasm_path = compiled.wasm_path().to_path_buf(); + let invocation = tokio::spawn(async move { + invoke_and_capture_output( + &wasm_path, + None, + "abort-releases-request", + &[Val::U16(port)], + ) + .await + }); + + tokio::time::timeout(ABORT_REQUEST_ARRIVAL_TIMEOUT, arrivals.recv()) + .await + .expect("timed out waiting for /slow-response") + .expect("abort test server stopped before the request arrived"); + let (result, output) = tokio::time::timeout(std::time::Duration::from_secs(5), invocation) + .await + .expect("aborted fetch kept the component invocation alive")?; + assert_eq!( + result?, + Some(Val::Bool(true)), + "fetch must reject with the signal's exact reason. Output:\n{output}" + ); + Ok(()) +} + +async fn run_abort_case(compiled: &CompiledTest, function: &'static str) -> anyhow::Result<()> { + let (port, _server, mut arrivals) = start_abort_test_server().await; + let wasm_path = compiled.wasm_path().to_path_buf(); + let invocation = tokio::spawn(async move { + invoke_and_capture_output(&wasm_path, None, function, &[Val::U16(port)]).await + }); + tokio::time::timeout(ABORT_REQUEST_ARRIVAL_TIMEOUT, arrivals.recv()) + .await + .expect("timed out waiting for /slow-response") + .expect("abort test server stopped before the request arrived"); + let (result, output) = tokio::time::timeout(std::time::Duration::from_secs(5), invocation) + .await + .unwrap_or_else(|_| panic!("{function} kept the component invocation alive"))?; + assert_eq!( + result?, + Some(Val::Bool(true)), + "{function} must cancel native work and preserve the exact reason. Output:\n{output}" + ); + Ok(()) +} + +#[test] +async fn fetch_abort_releases_upload( + #[tagged_as("fetch")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_abort_case(compiled, "abort-releases-upload").await +} + +#[test] +async fn fetch_abort_after_redirect( + #[tagged_as("fetch")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_abort_case(compiled, "abort-after-redirect").await +} + +#[test] +async fn fetch_abort_response_body( + #[tagged_as("fetch")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + let (port, _server) = start_test_server().await; + let (result, output) = invoke_and_capture_output( + compiled.wasm_path(), + None, + "abort-response-body", + &[Val::U16(port)], + ) + .await; + assert_eq!( + result?, + Some(Val::Bool(true)), + "response body consumption after abort must reject with AbortError. Output:\n{output}" + ); + Ok(()) +} diff --git a/tests/runtime/main.rs b/tests/runtime/main.rs index 934c9b72f..ce269e2d6 100644 --- a/tests/runtime/main.rs +++ b/tests/runtime/main.rs @@ -48,6 +48,7 @@ mod toplevel_timer; mod url; mod v8_stack_trace; mod variant_list_roundtrip; +mod websocket; mod xhr; // Tag suites into runtime groups for parallel CI matrix execution. @@ -63,6 +64,7 @@ tag_suite!(assert, group4); tag_suite!(dns, group4); tag_suite!(console, group4); tag_suite!(encoding, group4); +tag_suite!(websocket, group4); tag_suite!(response_constructor, group5); tag_suite!(streams, group5); diff --git a/tests/runtime/node_modules_apps.rs b/tests/runtime/node_modules_apps.rs index fc768cde7..9abf4a616 100644 --- a/tests/runtime/node_modules_apps.rs +++ b/tests/runtime/node_modules_apps.rs @@ -190,9 +190,11 @@ fn ensure_node_supports_require_esm() -> anyhow::Result { anyhow::ensure!( major == NODE_MODULES_APP_BASELINE_MAJOR - && (minor > NODE_MODULES_APP_BASELINE_MINOR - || (minor == NODE_MODULES_APP_BASELINE_MINOR - && patch >= NODE_MODULES_APP_BASELINE_PATCH)), + && (minor, patch) + >= ( + NODE_MODULES_APP_BASELINE_MINOR, + NODE_MODULES_APP_BASELINE_PATCH, + ), "node_modules app tests require Node.js major {NODE_MODULES_APP_BASELINE_MAJOR} at or after {baseline}; found {version}", ); @@ -238,7 +240,6 @@ async fn run_node_modules_app_test( verify_with_node(&baseline_app, test_file)?; let mut instance = TestInstance::new(compiled_test.wasm_path()).await?; - instance.set_epoch_deadline(timeout_secs); let guest_app = prepare_node_modules_app(app_name)?; let mounted_app_dir = instance.temp_dir_path().join("app"); @@ -248,6 +249,9 @@ async fn run_node_modules_app_test( mounted_app_dir.as_std_path(), )?; + // Fixture preparation is host-side setup and can be expensive under concurrent + // group9 load. The deadline should bound guest execution, not app copying. + instance.set_epoch_deadline(timeout_secs); let guest_test_path = format!("/app/{test_file}"); let (result, stdout, stderr) = instance .invoke_and_capture_output_with_stderr(None, "run-test", &[Val::String(guest_test_path)]) diff --git a/tests/runtime/response_constructor.rs b/tests/runtime/response_constructor.rs index 4802805e7..0143dc674 100644 --- a/tests/runtime/response_constructor.rs +++ b/tests/runtime/response_constructor.rs @@ -133,3 +133,45 @@ async fn response_constructor_headers_iteration( ) -> anyhow::Result<()> { run_test(compiled, "test-headers-iteration").await } + +#[test] +async fn response_constructor_request_clone( + #[tagged_as("response_constructor")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_test(compiled, "test-request-clone").await +} + +#[test] +async fn response_constructor_request_clone_after_consume( + #[tagged_as("response_constructor")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_test(compiled, "test-request-clone-after-consume").await +} + +#[test] +async fn response_constructor_request_bytes_blob( + #[tagged_as("response_constructor")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_test(compiled, "test-request-bytes-blob").await +} + +#[test] +async fn response_constructor_response_clone_stream( + #[tagged_as("response_constructor")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_test(compiled, "test-response-clone-stream").await +} + +#[test] +async fn response_constructor_typed_array_bodies( + #[tagged_as("response_constructor")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_test(compiled, "test-typed-array-bodies").await +} + +#[test] +async fn response_constructor_buffer_source_snapshot( + #[tagged_as("response_constructor")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_test(compiled, "test-buffer-source-snapshot").await +} diff --git a/tests/runtime/websocket.rs b/tests/runtime/websocket.rs new file mode 100644 index 000000000..46d178de0 --- /dev/null +++ b/tests/runtime/websocket.rs @@ -0,0 +1,88 @@ +use crate::common::{ + CompiledTest, FeatureCombination, GolemPreparedComponent, TestInstance, WsSentMessage, +}; +use camino::Utf8Path; +use test_r::{test, test_dep}; +use wasmtime::component::Val; + +#[test_dep(tagged_as = "websocket", scope = Cloneable)] +async fn compiled_websocket() -> CompiledTest { + CompiledTest::new_with_features( + Utf8Path::new("examples/runtime/websocket"), + true, + FeatureCombination::Golem, + ) + .await + .expect("Failed to compile websocket") +} + +async fn run_and_assert_frames( + compiled: &CompiledTest, + function: &str, + expected: Vec, +) -> anyhow::Result<()> { + let prepared = GolemPreparedComponent::new(compiled.wasm_path())?; + let mut instance = TestInstance::from_golem_prepared(&prepared).await?; + let (result, output) = instance + .invoke_and_capture_output(None, function, &[]) + .await; + assert_eq!( + result?, + Some(Val::Bool(true)), + "{function} should return true. Output:\n{output}" + ); + assert_eq!(instance.read_ws_sent(), expected); + Ok(()) +} + +#[test] +async fn websocket_binary_send( + #[tagged_as("websocket")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_and_assert_frames( + compiled, + "test-binary-send", + vec![ + WsSentMessage::Binary(vec![1, 2, 3]), + WsSentMessage::Binary(vec![4, 5, 6]), + WsSentMessage::Binary(vec![7, 8, 9]), + WsSentMessage::Text("hello".to_string()), + ], + ) + .await +} + +#[test] +async fn websocket_stream_send( + #[tagged_as("websocket")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_and_assert_frames( + compiled, + "test-websocket-stream-send", + vec![ + WsSentMessage::Text("hello".to_string()), + WsSentMessage::Binary(vec![1, 2, 3]), + WsSentMessage::Binary(vec![4, 5, 6]), + WsSentMessage::Binary(vec![7, 8, 9]), + ], + ) + .await +} + +#[test] +async fn websocket_send_snapshot_and_close_order( + #[tagged_as("websocket")] compiled: &CompiledTest, +) -> anyhow::Result<()> { + run_and_assert_frames( + compiled, + "test-send-snapshot-and-close-order", + vec![ + WsSentMessage::Binary(vec![1]), + WsSentMessage::Binary(vec![2, 3]), + WsSentMessage::Binary(vec![4, 5]), + WsSentMessage::Text("tail".to_string()), + WsSentMessage::Close(Some(3000), Some("done".to_string())), + ], + ) + .await +} diff --git a/tools/ai-dev-tools/src/commands/validate-classifications.ts b/tools/ai-dev-tools/src/commands/validate-classifications.ts index dc78d6679..a849e78be 100644 --- a/tools/ai-dev-tools/src/commands/validate-classifications.ts +++ b/tools/ai-dev-tools/src/commands/validate-classifications.ts @@ -115,7 +115,7 @@ function loadValidationRecords(resultsPath: string): ValidationRecord[] { } records.push(record); } catch (err) { - throw new Error(`Invalid JSONL record at ${resultsPath}:${idx + 1}: ${err}`); + throw new Error(`Invalid JSONL record at ${resultsPath}:${idx + 1}: ${err}`, { cause: err }); } } return records;