From ac7ab70f3e164f5395e59827e1a4c25df0584710 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 15:49:02 -0500 Subject: [PATCH 01/15] fix(bindings): correct native API contracts --- src/canvas/HTMLCanvasElement.res | 2 +- src/core/DOM.res | 1 + src/crypto/Crypto.res | 1 + src/crypto/SubtleCrypto.res | 101 ++++++++++++++++++----- src/crypto/WebCryptoTypes.res | 14 +++- src/device/WebLocksTypes.res | 2 +- src/dom-nodes/DOMTree.res | 5 -- src/dom-nodes/Element.res | 2 +- src/fetch/FetchTypes.res | 6 +- src/fetch/Request.res | 2 +- src/fetch/Response.res | 2 +- src/file/Blob.res | 4 +- src/geometry/DOMMatrix.res | 6 +- src/geometry/DOMMatrixReadOnly.res | 4 +- src/media/DomHTMLMediaElement.res | 1 + src/messaging/NotificationTypes.res | 4 - src/messaging/WebSocket.res | 6 +- src/observers/ResizeObserverTypes.res | 2 +- src/safe-fetch/SafeFetch.resi | 4 +- src/storage/IDBFactory.res | 2 +- src/svg/SVGElement.res | 6 ++ src/web-audio/AnalyserNode.res | 8 +- src/web-audio/AudioBuffer.res | 6 +- src/web-audio/BiquadFilterNode.res | 6 +- src/web-audio/IIRFilterNode.res | 6 +- src/window/CustomElementRegistry.res | 2 +- src/workers/Worker.res | 1 + tests/BindingByteTypes__test.res | 16 ++++ tests/FetchAPI/SafeFetch__test.res | 2 +- tests/browser/BindingContracts__test.res | 88 ++++++++++++++++++++ tests/fixtures.json | 3 +- 31 files changed, 247 insertions(+), 68 deletions(-) create mode 100644 tests/BindingByteTypes__test.res create mode 100644 tests/browser/BindingContracts__test.res diff --git a/src/canvas/HTMLCanvasElement.res b/src/canvas/HTMLCanvasElement.res index 885e3f40..a60d303d 100644 --- a/src/canvas/HTMLCanvasElement.res +++ b/src/canvas/HTMLCanvasElement.res @@ -116,7 +116,7 @@ external toDataURL: (t, ~type_: string=?, ~quality: JSON.t=?) => string = "toDat @throws(JsExn) @send external toBlob: ( t, - ~callback: FileTypes.blob => unit, + ~callback: Null.t => unit, ~type_: string=?, ~quality: JSON.t=?, ) => unit = "toBlob" diff --git a/src/core/DOM.res b/src/core/DOM.res index 32c6ef5f..77140bac 100644 --- a/src/core/DOM.res +++ b/src/core/DOM.res @@ -80,6 +80,7 @@ type remotePlaybackState = | @as("disconnected") Disconnected type referrerPolicy = + | @as("") Empty | @as("no-referrer") NoReferrer | @as("no-referrer-when-downgrade") NoReferrerWhenDowngrade | @as("origin") Origin diff --git a/src/crypto/Crypto.res b/src/crypto/Crypto.res index 93d641c0..485e9bd6 100644 --- a/src/crypto/Crypto.res +++ b/src/crypto/Crypto.res @@ -9,3 +9,4 @@ external getRandomValues: (WebCryptoTypes.crypto, 't) => 't = "getRandomValues" */ @send external randomUUID: WebCryptoTypes.crypto => string = "randomUUID" +type t = WebCryptoTypes.crypto diff --git a/src/crypto/SubtleCrypto.res b/src/crypto/SubtleCrypto.res index 8f8dc483..bc89d6c4 100644 --- a/src/crypto/SubtleCrypto.res +++ b/src/crypto/SubtleCrypto.res @@ -4,7 +4,7 @@ @send external encrypt: ( WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: WebCryptoTypes.algorithm, ~key: WebCryptoTypes.cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "encrypt" @@ -15,7 +15,7 @@ external encrypt: ( @send external decrypt: ( WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: WebCryptoTypes.algorithm, ~key: WebCryptoTypes.cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "decrypt" @@ -26,10 +26,10 @@ external decrypt: ( @send external sign: ( WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: WebCryptoTypes.algorithm, ~key: WebCryptoTypes.cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, -) => promise = "sign" +) => promise = "sign" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) @@ -37,11 +37,11 @@ external sign: ( @send external verify: ( WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: WebCryptoTypes.algorithm, ~key: WebCryptoTypes.cryptoKey, ~signature: ArrayBufferTypedArrayOrDataView.t, ~data: ArrayBufferTypedArrayOrDataView.t, -) => promise = "verify" +) => promise = "verify" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) @@ -49,9 +49,9 @@ external verify: ( @send external digest: ( WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: WebCryptoTypes.algorithm, ~data: ArrayBufferTypedArrayOrDataView.t, -) => promise = "digest" +) => promise = "digest" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) @@ -62,7 +62,7 @@ external generateKey: ( ~algorithm: WebCryptoTypes.algorithm, ~extractable: bool, ~keyUsages: array, -) => promise = "generateKey" +) => promise = "generateKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) @@ -73,7 +73,26 @@ external generateKey2: ( ~algorithm: string, ~extractable: bool, ~keyUsages: array, -) => promise = "generateKey" +) => promise = "generateKey" + +@send +external generateKeySymmetric: ( + WebCryptoTypes.subtleCrypto, + ~algorithm: WebCryptoTypes.algorithm, + ~extractable: bool, + ~keyUsages: array, +) => promise = "generateKey" + +/** +Generates a public/private key pair. Use for algorithms whose native result is a CryptoKeyPair. +*/ +@send +external generateKeyPair: ( + WebCryptoTypes.subtleCrypto, + ~algorithm: WebCryptoTypes.algorithm, + ~extractable: bool, + ~keyUsages: array, +) => promise = "generateKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) @@ -81,12 +100,12 @@ external generateKey2: ( @send external deriveKey: ( WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: WebCryptoTypes.algorithm, ~baseKey: WebCryptoTypes.cryptoKey, - ~derivedKeyType: WebCryptoTypes.algorithmIdentifier, + ~derivedKeyType: WebCryptoTypes.algorithm, ~extractable: bool, ~keyUsages: array, -) => promise = "deriveKey" +) => promise = "deriveKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) @@ -116,9 +135,9 @@ external deriveBits2: ( @send external importKey: ( WebCryptoTypes.subtleCrypto, - ~format: unknown, + ~format: WebCryptoTypes.keyFormat, ~keyData: ArrayBufferTypedArrayOrDataView.t, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: WebCryptoTypes.algorithm, ~extractable: bool, ~keyUsages: array, ) => promise = "importKey" @@ -131,7 +150,7 @@ external exportKey: ( WebCryptoTypes.subtleCrypto, ~format: WebCryptoTypes.keyFormat, ~key: WebCryptoTypes.cryptoKey, -) => promise = "exportKey" +) => promise = "exportKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) @@ -143,7 +162,7 @@ external wrapKey: ( ~key: WebCryptoTypes.cryptoKey, ~wrappingKey: WebCryptoTypes.cryptoKey, ~wrapAlgorithm: WebCryptoTypes.algorithm, -) => promise = "wrapKey" +) => promise = "wrapKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) @@ -155,7 +174,7 @@ external wrapKey2: ( ~key: WebCryptoTypes.cryptoKey, ~wrappingKey: WebCryptoTypes.cryptoKey, ~wrapAlgorithm: string, -) => promise = "wrapKey" +) => promise = "wrapKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) @@ -166,8 +185,50 @@ external unwrapKey: ( ~format: WebCryptoTypes.keyFormat, ~wrappedKey: ArrayBufferTypedArrayOrDataView.t, ~unwrappingKey: WebCryptoTypes.cryptoKey, - ~unwrapAlgorithm: WebCryptoTypes.algorithmIdentifier, - ~unwrappedKeyAlgorithm: WebCryptoTypes.algorithmIdentifier, + ~unwrapAlgorithm: WebCryptoTypes.algorithm, + ~unwrappedKeyAlgorithm: WebCryptoTypes.algorithm, ~extractable: bool, ~keyUsages: array, ) => promise = "unwrapKey" +type t = WebCryptoTypes.subtleCrypto +type binaryData = ArrayBufferTypedArrayOrDataView.t +type keyType = WebCryptoTypes.keyType +type keyUsage = WebCryptoTypes.keyUsage +type keyFormat = WebCryptoTypes.keyFormat +type binaryKeyFormat = + | @as("raw") Raw + | @as("pkcs8") Pkcs8 + | @as("spki") Spki +type jwkKeyFormat = | @as("jwk") Jwk +type keyAlgorithm = WebCryptoTypes.keyAlgorithm +type algorithmIdentifier = WebCryptoTypes.algorithmIdentifier +type algorithm = WebCryptoTypes.algorithm +type cryptoKey = WebCryptoTypes.cryptoKey +type cryptoKeyPair = WebCryptoTypes.cryptoKeyPair + +let decryptUsage = WebCryptoTypes.Decrypt +let deriveBitsUsage = WebCryptoTypes.DeriveBits +let deriveKeyUsage = WebCryptoTypes.DeriveKey +let encryptUsage = WebCryptoTypes.Encrypt +let signUsage = WebCryptoTypes.Sign +let unwrapKeyUsage = WebCryptoTypes.UnwrapKey +let verifyUsage = WebCryptoTypes.Verify +let wrapKeyUsage = WebCryptoTypes.WrapKey + +external binaryDataFromUint8Array: Uint8Array.t => binaryData = "%identity" +external binaryDataFromArrayBuffer: ArrayBuffer.t => binaryData = "%identity" +external binaryDataFromDataView: DataView.t => binaryData = "%identity" + +@send +external exportKeyBinary: ( + WebCryptoTypes.subtleCrypto, + ~format: binaryKeyFormat, + ~key: WebCryptoTypes.cryptoKey, +) => promise = "exportKey" + +@send +external exportKeyJwk: ( + WebCryptoTypes.subtleCrypto, + ~format: jwkKeyFormat, + ~key: WebCryptoTypes.cryptoKey, +) => promise = "exportKey" diff --git a/src/crypto/WebCryptoTypes.res b/src/crypto/WebCryptoTypes.res index 2d69697c..92d778a2 100644 --- a/src/crypto/WebCryptoTypes.res +++ b/src/crypto/WebCryptoTypes.res @@ -66,6 +66,16 @@ type cryptoKey = { usages: array, } -type algorithmIdentifier = unknown +type cryptoKeyPair = { + privateKey: cryptoKey, + publicKey: cryptoKey, +} + +type algorithmIdentifier = {name: string} -type algorithm = {mutable name: string} +type algorithm = { + mutable name: string, + mutable hash?: algorithmIdentifier, + mutable length?: int, + mutable namedCurve?: string, +} diff --git a/src/device/WebLocksTypes.res b/src/device/WebLocksTypes.res index 2815c01f..69cea70c 100644 --- a/src/device/WebLocksTypes.res +++ b/src/device/WebLocksTypes.res @@ -42,4 +42,4 @@ type lockOptions = { mutable signal?: DOM.abortSignal, } -type lockGrantedCallback = lock => promise +type lockGrantedCallback = Null.t => promise diff --git a/src/dom-nodes/DOMTree.res b/src/dom-nodes/DOMTree.res index d0c70248..9611fd51 100644 --- a/src/dom-nodes/DOMTree.res +++ b/src/dom-nodes/DOMTree.res @@ -190,11 +190,6 @@ TODO: mark as private once mutating fields of private records is allowed [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Element/id) */ mutable id: string, - /** - Returns the value of element's class content attribute. Can be set to change it. - [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Element/className) - */ - mutable className: string, /** Allows for manipulation of element's class content attribute as a set of whitespace-separated tokens through a DOMTokenList object. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Element/classList) diff --git a/src/dom-nodes/Element.res b/src/dom-nodes/Element.res index 768e384a..7ae7d68e 100644 --- a/src/dom-nodes/Element.res +++ b/src/dom-nodes/Element.res @@ -100,7 +100,7 @@ Returns the first (starting at element) inclusive ancestor that matches selector [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Element/closest) */ @throws(JsExn) @send - external closest: (T.t, string) => Null.t<'e> = "closest" + external closest: (T.t, string) => Null.t = "closest" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Element/computedStyleMap) diff --git a/src/fetch/FetchTypes.res b/src/fetch/FetchTypes.res index 3394e12c..7a4648fd 100644 --- a/src/fetch/FetchTypes.res +++ b/src/fetch/FetchTypes.res @@ -9,6 +9,7 @@ type responseType = | @as("opaqueredirect") Opaqueredirect type requestDestination = + | @as("") Empty | @as("audio") Audio | @as("audioworklet") Audioworklet | @as("document") Document @@ -30,6 +31,7 @@ type requestDestination = | @as("xslt") Xslt type referrerPolicy = + | @as("") Empty | @as("no-referrer") NoReferrer | @as("no-referrer-when-downgrade") NoReferrerWhenDowngrade | @as("origin") Origin @@ -149,7 +151,7 @@ type request = private { /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/body) */ - body: Null.t>>, + body: Null.t>, /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ @@ -194,7 +196,7 @@ type response = private { /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/body) */ - body: Null.t>>, + body: Null.t>, /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ diff --git a/src/fetch/Request.res b/src/fetch/Request.res index b9575e64..f51ad3fd 100644 --- a/src/fetch/Request.res +++ b/src/fetch/Request.res @@ -34,7 +34,7 @@ external blob: t => promise = "blob" [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/bytes) */ @send -external bytes: t => promise> = "bytes" +external bytes: t => promise = "bytes" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/formData) diff --git a/src/fetch/Response.res b/src/fetch/Response.res index a0428602..006ad748 100644 --- a/src/fetch/Response.res +++ b/src/fetch/Response.res @@ -82,7 +82,7 @@ external blob: t => promise = "blob" [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/bytes) */ @send -external bytes: t => promise> = "bytes" +external bytes: t => promise = "bytes" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/formData) diff --git a/src/file/Blob.res b/src/file/Blob.res index ba46dc15..a4142b94 100644 --- a/src/file/Blob.res +++ b/src/file/Blob.res @@ -24,7 +24,7 @@ module Impl = ( [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Blob/stream) */ @send - external stream: T.t => ReadableStream.t> = "stream" + external stream: T.t => ReadableStream.t = "stream" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Blob/text) @@ -42,7 +42,7 @@ module Impl = ( [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Blob/bytes) */ @send - external bytes: T.t => promise> = "bytes" + external bytes: T.t => promise = "bytes" } include Impl({type t = t}) diff --git a/src/geometry/DOMMatrix.res b/src/geometry/DOMMatrix.res index 6466cf7a..183fe864 100644 --- a/src/geometry/DOMMatrix.res +++ b/src/geometry/DOMMatrix.res @@ -45,7 +45,7 @@ external asDOMMatrixReadOnly: DOM.domMatrix => DOM.domMatrixReadOnly = "%identit external fromMatrix: (~other: DOM.domMatrixInit=?) => DOM.domMatrixReadOnly = "fromMatrix" @throws(JsExn) @scope("DOMMatrix") -external fromFloat32Array: array => DOM.domMatrixReadOnly = "fromFloat32Array" +external fromFloat32Array: Float32Array.t => DOM.domMatrixReadOnly = "fromFloat32Array" @throws(JsExn) @scope("DOMMatrix") external fromFloat64Array: Float64Array.t => DOM.domMatrixReadOnly = "fromFloat64Array" @@ -123,7 +123,7 @@ external transformPoint: (DOM.domMatrix, ~point: DOM.domPointInit=?) => DOM.domP "transformPoint" @send -external toFloat32Array: DOM.domMatrix => array = "toFloat32Array" +external toFloat32Array: DOM.domMatrix => Float32Array.t = "toFloat32Array" @send external toFloat64Array: DOM.domMatrix => Float64Array.t = "toFloat64Array" @@ -135,7 +135,7 @@ external toJSON: DOM.domMatrix => Dict.t = "toJSON" external fromMatrixD: (~other: DOM.domMatrixInit=?) => DOM.domMatrix = "fromMatrix" @throws(JsExn) @scope("DOMMatrix") -external fromFloat32ArrayD: array => DOM.domMatrix = "fromFloat32Array" +external fromFloat32ArrayD: Float32Array.t => DOM.domMatrix = "fromFloat32Array" @throws(JsExn) @scope("DOMMatrix") external fromFloat64ArrayD: Float64Array.t => DOM.domMatrix = "fromFloat64Array" diff --git a/src/geometry/DOMMatrixReadOnly.res b/src/geometry/DOMMatrixReadOnly.res index 8f099453..39bd42f2 100644 --- a/src/geometry/DOMMatrixReadOnly.res +++ b/src/geometry/DOMMatrixReadOnly.res @@ -44,7 +44,7 @@ external fromArray: array => DOM.domMatrixReadOnly = "DOMMatrixReadOnly" external fromMatrix: (~other: DOM.domMatrixInit=?) => DOM.domMatrixReadOnly = "fromMatrix" @throws(JsExn) @scope("DOMMatrixReadOnly") -external fromFloat32Array: array => DOM.domMatrixReadOnly = "fromFloat32Array" +external fromFloat32Array: Float32Array.t => DOM.domMatrixReadOnly = "fromFloat32Array" @throws(JsExn) @scope("DOMMatrixReadOnly") external fromFloat64Array: Float64Array.t => DOM.domMatrixReadOnly = "fromFloat64Array" @@ -131,7 +131,7 @@ external transformPoint: (DOM.domMatrixReadOnly, ~point: DOM.domPointInit=?) => "transformPoint" @send -external toFloat32Array: DOM.domMatrixReadOnly => array = "toFloat32Array" +external toFloat32Array: DOM.domMatrixReadOnly => Float32Array.t = "toFloat32Array" @send external toFloat64Array: DOM.domMatrixReadOnly => Float64Array.t = "toFloat64Array" diff --git a/src/media/DomHTMLMediaElement.res b/src/media/DomHTMLMediaElement.res index 2751c58e..757430d8 100644 --- a/src/media/DomHTMLMediaElement.res +++ b/src/media/DomHTMLMediaElement.res @@ -1,6 +1,7 @@ type mediaProvider = unknown type canPlayTypeResult = + | @as("") Empty | @as("maybe") Maybe | @as("probably") Probably diff --git a/src/messaging/NotificationTypes.res b/src/messaging/NotificationTypes.res index fc0f02ab..f3336f70 100644 --- a/src/messaging/NotificationTypes.res +++ b/src/messaging/NotificationTypes.res @@ -17,10 +17,6 @@ This Notifications API interface is used to configure and display desktop notifi @editor.completeFrom(Notification) type notification = private { ...DOM.eventTarget, - /** - [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Notification/permission_static) - */ - permission: notificationPermission, /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Notification/title) */ diff --git a/src/messaging/WebSocket.res b/src/messaging/WebSocket.res index 7c9ee7b8..daf80866 100644 --- a/src/messaging/WebSocket.res +++ b/src/messaging/WebSocket.res @@ -56,19 +56,19 @@ external send: (t, DataView.t) => unit = "send" Transmits data using the WebSocket connection. data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/WebSocket/send) */ -@throws(JsExn) +@throws(JsExn) @send external sendArrayBuffer: (t, ArrayBuffer.t) => unit = "send" /** Transmits data using the WebSocket connection. data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/WebSocket/send) */ -@throws(JsExn) +@throws(JsExn) @send external sendBlob: (t, Blob.t) => unit = "send" /** Transmits data using the WebSocket connection. data can be a string, a Blob, an ArrayBuffer, or an ArrayBufferView. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/WebSocket/send) */ -@throws(JsExn) +@throws(JsExn) @send external sendString: (t, string) => unit = "send" diff --git a/src/observers/ResizeObserverTypes.res b/src/observers/ResizeObserverTypes.res index 4916a850..fdce78e7 100644 --- a/src/observers/ResizeObserverTypes.res +++ b/src/observers/ResizeObserverTypes.res @@ -53,4 +53,4 @@ type resizeObserver = private {} type resizeObserverOptions = {mutable box?: resizeObserverBoxOptions} -type resizeObserverCallback = array => resizeObserver => unit +type resizeObserverCallback = (array, resizeObserver) => unit diff --git a/src/safe-fetch/SafeFetch.resi b/src/safe-fetch/SafeFetch.resi index b3c76c52..1ac61f44 100644 --- a/src/safe-fetch/SafeFetch.resi +++ b/src/safe-fetch/SafeFetch.resi @@ -95,11 +95,11 @@ Returns the body with the original response, or a `bodyError` when the native re let blob: Response.t => promise, [> bodyError]>> /** -Reads the response body as an array of bytes. +Reads the response body as a native `Uint8Array.t`. Returns the body with the original response, or a `bodyError` when the native reader rejects. */ -let bytes: Response.t => promise>, [> bodyError]>> +let bytes: Response.t => promise, [> bodyError]>> /** Reads the response body as `FormData.t`. diff --git a/src/storage/IDBFactory.res b/src/storage/IDBFactory.res index c5085273..712e695a 100644 --- a/src/storage/IDBFactory.res +++ b/src/storage/IDBFactory.res @@ -14,7 +14,7 @@ Attempts to delete the named database. If the database already exists and there [Read more on MDN](https://developer.mozilla.org/docs/Web/API/IDBFactory/deleteDatabase) */ @send -external deleteDatabase: (IndexedDbTypes.idbFactory, string) => IndexedDbTypes.idbOpenDBRequest = +external deleteDatabase: (IndexedDbTypes.idbFactory, string) => IndexedDbTypes.idbRequest = "deleteDatabase" /** diff --git a/src/svg/SVGElement.res b/src/svg/SVGElement.res index 2de4853b..252b1a26 100644 --- a/src/svg/SVGElement.res +++ b/src/svg/SVGElement.res @@ -4,6 +4,11 @@ Used for attributes of type SVGPreserveAspectRatio which can be animated. */ type svgAnimatedPreserveAspectRatio = {} +type svgAnimatedString = { + mutable baseVal: string, + animVal: string, +} + /** Correspond to the basic data type. [See SVGLength on MDN](https://developer.mozilla.org/docs/Web/API/SVGLength) @@ -32,6 +37,7 @@ All of the SVG WebApiDOM interfaces that correspond directly to elements in the */ type t = { ...DOMTree.element, + className: svgAnimatedString, /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/HTMLElement/dataset) */ diff --git a/src/web-audio/AnalyserNode.res b/src/web-audio/AnalyserNode.res index 80b54ff1..6610b376 100644 --- a/src/web-audio/AnalyserNode.res +++ b/src/web-audio/AnalyserNode.res @@ -13,26 +13,26 @@ external make: ( [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AnalyserNode/getFloatFrequencyData) */ @send -external getFloatFrequencyData: (WebAudioTypes.analyserNode, array) => unit = +external getFloatFrequencyData: (WebAudioTypes.analyserNode, Float32Array.t) => unit = "getFloatFrequencyData" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AnalyserNode/getByteFrequencyData) */ @send -external getByteFrequencyData: (WebAudioTypes.analyserNode, array) => unit = +external getByteFrequencyData: (WebAudioTypes.analyserNode, Uint8Array.t) => unit = "getByteFrequencyData" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AnalyserNode/getFloatTimeDomainData) */ @send -external getFloatTimeDomainData: (WebAudioTypes.analyserNode, array) => unit = +external getFloatTimeDomainData: (WebAudioTypes.analyserNode, Float32Array.t) => unit = "getFloatTimeDomainData" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AnalyserNode/getByteTimeDomainData) */ @send -external getByteTimeDomainData: (WebAudioTypes.analyserNode, array) => unit = +external getByteTimeDomainData: (WebAudioTypes.analyserNode, Uint8Array.t) => unit = "getByteTimeDomainData" diff --git a/src/web-audio/AudioBuffer.res b/src/web-audio/AudioBuffer.res index b1e6f496..01878002 100644 --- a/src/web-audio/AudioBuffer.res +++ b/src/web-audio/AudioBuffer.res @@ -8,7 +8,7 @@ external make: WebAudioTypes.audioBufferOptions => WebAudioTypes.audioBuffer = " [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioBuffer/getChannelData) */ @throws(JsExn) @send -external getChannelData: (WebAudioTypes.audioBuffer, int) => array = "getChannelData" +external getChannelData: (WebAudioTypes.audioBuffer, int) => Float32Array.t = "getChannelData" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/AudioBuffer/copyFromChannel) @@ -16,7 +16,7 @@ external getChannelData: (WebAudioTypes.audioBuffer, int) => array = "get @throws(JsExn) @send external copyFromChannel: ( WebAudioTypes.audioBuffer, - ~destination: array, + ~destination: Float32Array.t, ~channelNumber: int, ~bufferOffset: int=?, ) => unit = "copyFromChannel" @@ -27,7 +27,7 @@ external copyFromChannel: ( @throws(JsExn) @send external copyToChannel: ( WebAudioTypes.audioBuffer, - ~source: array, + ~source: Float32Array.t, ~channelNumber: int, ~bufferOffset: int=?, ) => unit = "copyToChannel" diff --git a/src/web-audio/BiquadFilterNode.res b/src/web-audio/BiquadFilterNode.res index 745fab30..4c227c64 100644 --- a/src/web-audio/BiquadFilterNode.res +++ b/src/web-audio/BiquadFilterNode.res @@ -15,7 +15,7 @@ external make: ( @throws(JsExn) @send external getFrequencyResponse: ( WebAudioTypes.biquadFilterNode, - ~frequencyHz: array, - ~magResponse: array, - ~phaseResponse: array, + ~frequencyHz: Float32Array.t, + ~magResponse: Float32Array.t, + ~phaseResponse: Float32Array.t, ) => unit = "getFrequencyResponse" diff --git a/src/web-audio/IIRFilterNode.res b/src/web-audio/IIRFilterNode.res index aa10af56..c10f1cca 100644 --- a/src/web-audio/IIRFilterNode.res +++ b/src/web-audio/IIRFilterNode.res @@ -15,7 +15,7 @@ external make: ( @throws(JsExn) @send external getFrequencyResponse: ( WebAudioTypes.iirFilterNode, - ~frequencyHz: array, - ~magResponse: array, - ~phaseResponse: array, + ~frequencyHz: Float32Array.t, + ~magResponse: Float32Array.t, + ~phaseResponse: Float32Array.t, ) => unit = "getFrequencyResponse" diff --git a/src/window/CustomElementRegistry.res b/src/window/CustomElementRegistry.res index a21f1d64..badd6ce4 100644 --- a/src/window/CustomElementRegistry.res +++ b/src/window/CustomElementRegistry.res @@ -5,7 +5,7 @@ external define: ( DOM.customElementRegistry, ~name: string, - ~constructor: DOMTree.htmlElement, + ~constructor: DOM.customElementConstructor, ~options: DOM.elementDefinitionOptions=?, ) => unit = "define" diff --git a/src/workers/Worker.res b/src/workers/Worker.res index 32bc9941..be152a42 100644 --- a/src/workers/Worker.res +++ b/src/workers/Worker.res @@ -41,6 +41,7 @@ let response = await self->Worker.fetch(myRequest) [Read more on MDN](https://developer.mozilla.org/en-US/docs/Web/API/WorkerGlobalScope/fetch) */ + @send external fetchWithRequest: (T.t, Request.t, ~init: Request.requestInit=?) => promise = "fetch" } diff --git a/tests/BindingByteTypes__test.res b/tests/BindingByteTypes__test.res new file mode 100644 index 00000000..6b3a533d --- /dev/null +++ b/tests/BindingByteTypes__test.res @@ -0,0 +1,16 @@ +let _requestBytes: promise = Request.fromURL("/")->Request.bytes +let _responseBytes: promise = Response.fromString("")->Response.bytes +let _blobBytes: promise = Blob.make()->Blob.bytes +let _blobStream: ReadableStream.t = Blob.make()->Blob.stream + +let _analyserFloat: ( + WebAudioTypes.analyserNode, + Float32Array.t, +) => unit = AnalyserNode.getFloatTimeDomainData +let _analyserByte: ( + WebAudioTypes.analyserNode, + Uint8Array.t, +) => unit = AnalyserNode.getByteTimeDomainData +let _audioBuffer: (WebAudioTypes.audioBuffer, int) => Float32Array.t = AudioBuffer.getChannelData + +let _sign: SubtleCrypto.keyUsage = WebCryptoTypes.Sign diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res index bfc020fb..803a885a 100644 --- a/tests/FetchAPI/SafeFetch__test.res +++ b/tests/FetchAPI/SafeFetch__test.res @@ -197,7 +197,7 @@ let blobResult: result< assert(blobResult->Result.isOk) let bytesResult: result< - SafeFetch.response>, + SafeFetch.response, [> SafeFetch.bodyError], > = await Response.fromString("bytes")->SafeFetch.bytes assert(bytesResult->Result.isOk) diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res new file mode 100644 index 00000000..14e4094f --- /dev/null +++ b/tests/browser/BindingContracts__test.res @@ -0,0 +1,88 @@ +@scope("globalThis") +external crypto: Crypto.t = "crypto" + +let withResolver = (_run: (unit => unit) => unit): promise => + %raw(`new Promise(resolve => _run(resolve))`) + +let hasResizeCallbackArgs = (_entries, _observer) => + %raw(`_entries.length > 0 && _observer instanceof ResizeObserver`) + +let appendToDocument = _target => %raw(`document.body.appendChild(_target)`) + +let isUint8ArrayWithABC = _bytes => + %raw(`_bytes instanceof Uint8Array && _bytes.length === 3 && _bytes[0] === 65 && _bytes[2] === 67`) + +let isFloat32ArrayWithSixteenValues = _values => + %raw(`_values instanceof Float32Array && _values.length === 16 && _values[0] === 1 && _values[15] === 1`) + +let bytes = await Response.fromString("ABC")->Response.bytes +assert(isUint8ArrayWithABC(bytes)) + +let requestBytes = await Request.fromURL( + "https://example.invalid/", + ~init={method: "POST", body: Nullable.make(BodyInit.fromString("ABC"))}, +)->Request.bytes +assert(isUint8ArrayWithABC(requestBytes)) + +let blobBytes = await Blob.make(~blobParts=[String("ABC")])->Blob.bytes +assert(isUint8ArrayWithABC(blobBytes)) + +let matrix = DOMMatrix.fromFloat32Array(Float32Array.fromArray([1., 0., 0., 1., 0., 0.])) +assert(isFloat32ArrayWithSixteenValues(matrix->DOMMatrixReadOnly.toFloat32Array)) + +let target: DOMTree.element = %raw(`document.createElement("div")`) +appendToDocument(target) +await withResolver(resolve => { + let observer = ResizeObserver.make((entries, observer) => { + observer->ResizeObserver.disconnect + assert(hasResizeCallbackArgs(entries, observer)) + resolve() + }) + observer->ResizeObserver.observe(~target) +}) +target->Element.remove + +let subtle = crypto.subtle +let digest = await subtle->SubtleCrypto.digest( + ~algorithm={name: "SHA-256"}, + ~data=Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array, +) +assert(%raw(`digest.byteLength === 32`)) + +let data = Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array +let hmacKey = await subtle->SubtleCrypto.generateKeySymmetric( + ~algorithm={name: "HMAC", hash: {name: "SHA-256"}, length: 256}, + ~extractable=true, + ~keyUsages=[SubtleCrypto.signUsage, SubtleCrypto.verifyUsage], +) +let _publicHmacKey: SubtleCrypto.cryptoKey = hmacKey +let signature = await subtle->SubtleCrypto.sign(~algorithm={name: "HMAC"}, ~key=hmacKey, ~data) +let verified = await subtle->SubtleCrypto.verify( + ~algorithm={name: "HMAC"}, + ~key=hmacKey, + ~signature=SubtleCrypto.binaryDataFromArrayBuffer(signature), + ~data, +) +assert(verified) + +let rawKey = await subtle->SubtleCrypto.exportKeyBinary(~format=SubtleCrypto.Raw, ~key=hmacKey) +let jwk = await subtle->SubtleCrypto.exportKeyJwk(~format=SubtleCrypto.Jwk, ~key=hmacKey) +assert(%raw(`rawKey.byteLength === 32 && jwk.kty === "oct"`)) + +let keyPair = await subtle->SubtleCrypto.generateKeyPair( + ~algorithm={name: "ECDSA", namedCurve: "P-256"}, + ~extractable=true, + ~keyUsages=[SubtleCrypto.signUsage, SubtleCrypto.verifyUsage], +) +let _publicKeyPair: SubtleCrypto.cryptoKeyPair = keyPair +let pairSignature = await subtle->SubtleCrypto.sign( + ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~key=keyPair.privateKey, + ~data, +) +assert(await subtle->SubtleCrypto.verify( + ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~key=keyPair.publicKey, + ~signature=SubtleCrypto.binaryDataFromArrayBuffer(pairSignature), + ~data, +)) diff --git a/tests/fixtures.json b/tests/fixtures.json index a5d9761e..f06bb7b6 100644 --- a/tests/fixtures.json +++ b/tests/fixtures.json @@ -1,6 +1,7 @@ { "compileOnly": [ "BindingAbsence__test.res", + "BindingByteTypes__test.res", "CanvasAPI/Path2D__test.res", "CSSFontLoadingAPI/FontFace__test.res", "DOMAPI/AddEventListener__test.res", @@ -45,5 +46,5 @@ "FetchAPI/URLSearchParams__test.res", "URLAPI/URL__test.res" ], - "browserRuntime": ["browser/NativeAPI__test.res"] + "browserRuntime": ["browser/BindingContracts__test.res", "browser/NativeAPI__test.res"] } From 0c397d95ef94923ce463f841f782b0f9e69a98b1 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 18:44:05 -0500 Subject: [PATCH 02/15] test(bindings): cover native contracts and type rejection --- .../fixtures/negative/BytesAreNotArray.res | 2 + .../fixtures/negative/ClosestCannotNarrow.res | 2 + .../CustomElementRequiresConstructor.res | 9 + .../negative/GeneratePairRejectsAlgorithm.res | 10 + .../GenerateSymmetricRejectsAlgorithm.res | 11 ++ scripts/negative-fixtures.json | 30 +++ src/crypto/SubtleCrypto.res | 183 +++++++++--------- tests/browser/BindingContracts__test.res | 109 ++++++++++- 8 files changed, 259 insertions(+), 97 deletions(-) create mode 100644 scripts/fixtures/negative/BytesAreNotArray.res create mode 100644 scripts/fixtures/negative/ClosestCannotNarrow.res create mode 100644 scripts/fixtures/negative/CustomElementRequiresConstructor.res create mode 100644 scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res create mode 100644 scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res diff --git a/scripts/fixtures/negative/BytesAreNotArray.res b/scripts/fixtures/negative/BytesAreNotArray.res new file mode 100644 index 00000000..5f19a35b --- /dev/null +++ b/scripts/fixtures/negative/BytesAreNotArray.res @@ -0,0 +1,2 @@ +let _bytes: array = + await WebAPI.Request.fromURL("https://example.invalid/")->WebAPI.Request.bytes diff --git a/scripts/fixtures/negative/ClosestCannotNarrow.res b/scripts/fixtures/negative/ClosestCannotNarrow.res new file mode 100644 index 00000000..11ef59c6 --- /dev/null +++ b/scripts/fixtures/negative/ClosestCannotNarrow.res @@ -0,0 +1,2 @@ +let element: WebAPI.Element.t = %raw(`document.createElement("div")`) +let _input: WebAPI.HTMLInputElement.t = element->WebAPI.Element.closest("input") diff --git a/scripts/fixtures/negative/CustomElementRequiresConstructor.res b/scripts/fixtures/negative/CustomElementRequiresConstructor.res new file mode 100644 index 00000000..984a80ac --- /dev/null +++ b/scripts/fixtures/negative/CustomElementRequiresConstructor.res @@ -0,0 +1,9 @@ +@scope("globalThis") +external customElements: WebAPI.DOM.customElementRegistry = "customElements" + +let element: WebAPI.HTMLElement.t = %raw(`document.createElement("div")`) +WebAPI.CustomElementRegistry.define( + customElements, + ~name="x-invalid-binding-element", + ~constructor=element, +) diff --git a/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res b/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res new file mode 100644 index 00000000..bdba253f --- /dev/null +++ b/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res @@ -0,0 +1,10 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let _pair = WebAPI.SubtleCrypto.generateEcdsaKeyPair( + crypto.subtle, + ~algorithm={name: "HMAC", hash: {name: "SHA-256"}, length: 256}, + ~namedCurve="P-256", + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage], +) diff --git a/scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res b/scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res new file mode 100644 index 00000000..a6046b49 --- /dev/null +++ b/scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res @@ -0,0 +1,11 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let _key = WebAPI.SubtleCrypto.generateKeySymmetric( + crypto.subtle, + ~algorithm={name: "ECDSA", namedCurve: "P-256"}, + ~hash={name: "SHA-256"}, + ~length=256, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage], +) diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json index 3dec583c..c2a29409 100644 --- a/scripts/negative-fixtures.json +++ b/scripts/negative-fixtures.json @@ -4,5 +4,35 @@ "fixture": "scripts/fixtures/negative/DiagnosticMatching.res", "features": null, "expectedDiagnostic": "This has type: string" + }, + { + "name": "native request bytes are not an ordinary array", + "fixture": "scripts/fixtures/negative/BytesAreNotArray.res", + "features": ["WebAPI.Fetch"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "closest cannot narrow to an unrelated element type", + "fixture": "scripts/fixtures/negative/ClosestCannotNarrow.res", + "features": ["WebAPI.HTML"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "custom element definition rejects element instances", + "fixture": "scripts/fixtures/negative/CustomElementRequiresConstructor.res", + "features": ["WebAPI.Window"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "symmetric key generation fixes the algorithm", + "fixture": "scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "This function call is incorrect. The function has type:" + }, + { + "name": "key-pair generation fixes the algorithm", + "fixture": "scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "This function call is incorrect. The function has type:" } ] diff --git a/src/crypto/SubtleCrypto.res b/src/crypto/SubtleCrypto.res index bc89d6c4..95ed5931 100644 --- a/src/crypto/SubtleCrypto.res +++ b/src/crypto/SubtleCrypto.res @@ -1,11 +1,27 @@ +type t = WebCryptoTypes.subtleCrypto +type binaryData = ArrayBufferTypedArrayOrDataView.t +type keyType = WebCryptoTypes.keyType +type keyUsage = WebCryptoTypes.keyUsage +type keyFormat = WebCryptoTypes.keyFormat +type binaryKeyFormat = + | @as("raw") Raw + | @as("pkcs8") Pkcs8 + | @as("spki") Spki +type jwkKeyFormat = | @as("jwk") Jwk +type keyAlgorithm = WebCryptoTypes.keyAlgorithm +type algorithmIdentifier = WebCryptoTypes.algorithmIdentifier +type algorithm = WebCryptoTypes.algorithm +type cryptoKey = WebCryptoTypes.cryptoKey +type cryptoKeyPair = WebCryptoTypes.cryptoKeyPair + /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) */ @send external encrypt: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithm, + ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "encrypt" @@ -14,9 +30,9 @@ external encrypt: ( */ @send external decrypt: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithm, + ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "decrypt" @@ -25,9 +41,9 @@ external decrypt: ( */ @send external sign: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithm, + ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "sign" @@ -36,9 +52,9 @@ external sign: ( */ @send external verify: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithm, + ~key: cryptoKey, ~signature: ArrayBufferTypedArrayOrDataView.t, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "verify" @@ -48,8 +64,8 @@ external verify: ( */ @send external digest: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, + t, + ~algorithm: algorithm, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "digest" @@ -58,10 +74,10 @@ external digest: ( */ @send external generateKey: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, + t, + ~algorithm: algorithm, ~extractable: bool, - ~keyUsages: array, + ~keyUsages: array, ) => promise = "generateKey" /** @@ -69,52 +85,57 @@ external generateKey: ( */ @send external generateKey2: ( - WebCryptoTypes.subtleCrypto, + t, ~algorithm: string, ~extractable: bool, - ~keyUsages: array, + ~keyUsages: array, ) => promise = "generateKey" -@send -external generateKeySymmetric: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, +let generateKeySymmetric = ( + subtle: t, + ~hash: algorithmIdentifier, + ~length: int, ~extractable: bool, - ~keyUsages: array, -) => promise = "generateKey" + ~keyUsages: array, +): promise => { + let _ = (subtle, hash, length, extractable, keyUsages) + %raw(`subtle.generateKey({name: "HMAC", hash, length}, extractable, keyUsages)`) +} /** -Generates a public/private key pair. Use for algorithms whose native result is a CryptoKeyPair. +Generates an ECDSA public/private key pair. The fixed algorithm guarantees the native result shape. */ -@send -external generateKeyPair: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, +let generateEcdsaKeyPair = ( + subtle: t, + ~namedCurve: string, ~extractable: bool, - ~keyUsages: array, -) => promise = "generateKey" + ~keyUsages: array, +): promise => { + let _ = (subtle, namedCurve, extractable, keyUsages) + %raw(`subtle.generateKey({name: "ECDSA", namedCurve}, extractable, keyUsages)`) +} /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveKey) */ @send external deriveKey: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, - ~baseKey: WebCryptoTypes.cryptoKey, - ~derivedKeyType: WebCryptoTypes.algorithm, + t, + ~algorithm: algorithm, + ~baseKey: cryptoKey, + ~derivedKeyType: algorithm, ~extractable: bool, - ~keyUsages: array, -) => promise = "deriveKey" + ~keyUsages: array, +) => promise = "deriveKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) */ @send external deriveBits: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, - ~baseKey: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithm, + ~baseKey: cryptoKey, ~length: int=?, ) => promise = "deriveBits" @@ -123,9 +144,9 @@ external deriveBits: ( */ @send external deriveBits2: ( - WebCryptoTypes.subtleCrypto, + t, ~algorithm: string, - ~baseKey: WebCryptoTypes.cryptoKey, + ~baseKey: cryptoKey, ~length: int=?, ) => promise = "deriveBits" @@ -134,34 +155,30 @@ external deriveBits2: ( */ @send external importKey: ( - WebCryptoTypes.subtleCrypto, - ~format: WebCryptoTypes.keyFormat, + t, + ~format: keyFormat, ~keyData: ArrayBufferTypedArrayOrDataView.t, - ~algorithm: WebCryptoTypes.algorithm, + ~algorithm: algorithm, ~extractable: bool, - ~keyUsages: array, -) => promise = "importKey" + ~keyUsages: array, +) => promise = "importKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/exportKey) */ @send -external exportKey: ( - WebCryptoTypes.subtleCrypto, - ~format: WebCryptoTypes.keyFormat, - ~key: WebCryptoTypes.cryptoKey, -) => promise = "exportKey" +external exportKey: (t, ~format: keyFormat, ~key: cryptoKey) => promise = "exportKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) */ @send external wrapKey: ( - WebCryptoTypes.subtleCrypto, - ~format: WebCryptoTypes.keyFormat, - ~key: WebCryptoTypes.cryptoKey, - ~wrappingKey: WebCryptoTypes.cryptoKey, - ~wrapAlgorithm: WebCryptoTypes.algorithm, + t, + ~format: keyFormat, + ~key: cryptoKey, + ~wrappingKey: cryptoKey, + ~wrapAlgorithm: algorithm, ) => promise = "wrapKey" /** @@ -169,10 +186,10 @@ external wrapKey: ( */ @send external wrapKey2: ( - WebCryptoTypes.subtleCrypto, - ~format: WebCryptoTypes.keyFormat, - ~key: WebCryptoTypes.cryptoKey, - ~wrappingKey: WebCryptoTypes.cryptoKey, + t, + ~format: keyFormat, + ~key: cryptoKey, + ~wrappingKey: cryptoKey, ~wrapAlgorithm: string, ) => promise = "wrapKey" @@ -181,30 +198,15 @@ external wrapKey2: ( */ @send external unwrapKey: ( - WebCryptoTypes.subtleCrypto, - ~format: WebCryptoTypes.keyFormat, + t, + ~format: keyFormat, ~wrappedKey: ArrayBufferTypedArrayOrDataView.t, - ~unwrappingKey: WebCryptoTypes.cryptoKey, - ~unwrapAlgorithm: WebCryptoTypes.algorithm, - ~unwrappedKeyAlgorithm: WebCryptoTypes.algorithm, + ~unwrappingKey: cryptoKey, + ~unwrapAlgorithm: algorithm, + ~unwrappedKeyAlgorithm: algorithm, ~extractable: bool, - ~keyUsages: array, -) => promise = "unwrapKey" -type t = WebCryptoTypes.subtleCrypto -type binaryData = ArrayBufferTypedArrayOrDataView.t -type keyType = WebCryptoTypes.keyType -type keyUsage = WebCryptoTypes.keyUsage -type keyFormat = WebCryptoTypes.keyFormat -type binaryKeyFormat = - | @as("raw") Raw - | @as("pkcs8") Pkcs8 - | @as("spki") Spki -type jwkKeyFormat = | @as("jwk") Jwk -type keyAlgorithm = WebCryptoTypes.keyAlgorithm -type algorithmIdentifier = WebCryptoTypes.algorithmIdentifier -type algorithm = WebCryptoTypes.algorithm -type cryptoKey = WebCryptoTypes.cryptoKey -type cryptoKeyPair = WebCryptoTypes.cryptoKeyPair + ~keyUsages: array, +) => promise = "unwrapKey" let decryptUsage = WebCryptoTypes.Decrypt let deriveBitsUsage = WebCryptoTypes.DeriveBits @@ -220,15 +222,8 @@ external binaryDataFromArrayBuffer: ArrayBuffer.t => binaryData = "%identity" external binaryDataFromDataView: DataView.t => binaryData = "%identity" @send -external exportKeyBinary: ( - WebCryptoTypes.subtleCrypto, - ~format: binaryKeyFormat, - ~key: WebCryptoTypes.cryptoKey, -) => promise = "exportKey" +external exportKeyBinary: (t, ~format: binaryKeyFormat, ~key: cryptoKey) => promise = + "exportKey" @send -external exportKeyJwk: ( - WebCryptoTypes.subtleCrypto, - ~format: jwkKeyFormat, - ~key: WebCryptoTypes.cryptoKey, -) => promise = "exportKey" +external exportKeyJwk: (t, ~format: jwkKeyFormat, ~key: cryptoKey) => promise = "exportKey" diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 14e4094f..90510fa7 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -1,13 +1,29 @@ @scope("globalThis") external crypto: Crypto.t = "crypto" +@scope("globalThis") +external document: DOM.document = "document" + +@scope("globalThis") +external indexedDB: IndexedDbTypes.idbFactory = "indexedDB" + +@scope("globalThis") +external navigator: Navigator.t = "navigator" + let withResolver = (_run: (unit => unit) => unit): promise => %raw(`new Promise(resolve => _run(resolve))`) let hasResizeCallbackArgs = (_entries, _observer) => %raw(`_entries.length > 0 && _observer instanceof ResizeObserver`) +let isNull = _value => %raw(`_value === null`) +let isUndefined = _value => %raw(`_value === undefined`) +let appendChild = (_parent, _child) => %raw(`_parent.appendChild(_child)`) + let appendToDocument = _target => %raw(`document.body.appendChild(_target)`) +let onSuccess = (_request, _callback) => + %raw(`_request.addEventListener("success", _callback, {once: true})`) +let releaseLock = () => %raw(`globalThis.__releaseBindingLock()`) let isUint8ArrayWithABC = _bytes => %raw(`_bytes instanceof Uint8Array && _bytes.length === 3 && _bytes[0] === 65 && _bytes[2] === 67`) @@ -24,9 +40,95 @@ let requestBytes = await Request.fromURL( )->Request.bytes assert(isUint8ArrayWithABC(requestBytes)) +let emptyStateRequest = Request.fromURL("https://example.invalid/") +assert(%raw(`emptyStateRequest.destination === "" && emptyStateRequest.referrerPolicy === ""`)) + let blobBytes = await Blob.make(~blobParts=[String("ABC")])->Blob.bytes assert(isUint8ArrayWithABC(blobBytes)) +let audio: HTMLAudioElement.t = %raw(`document.createElement("audio")`) +assert(audio->HTMLAudioElement.canPlayType("application/x-webapi-unsupported") == + DomHTMLMediaElement.Empty) + +let _notificationPermission = Notification.permission + +switch Document.createElement(document, "canvas")->HTMLCanvasElement.classify { +| Some(canvas) => { + canvas.width = 0 + canvas.height = 0 + await withResolver(resolve => + canvas->HTMLCanvasElement.toBlob(~callback=_blob => { + assert(isNull(_blob)) + resolve() + }) + ) + } +| None => assert(false) +} + +%raw(`globalThis.BindingContractsElement = class extends HTMLElement {}`) + +@scope("globalThis") +external customElements: DOM.customElementRegistry = "customElements" + +@scope("globalThis") +external bindingContractsConstructor: DOM.customElementConstructor = "BindingContractsElement" + +let customElementName = "webapi-binding-contracts" +CustomElementRegistry.define( + customElements, + ~name=customElementName, + ~constructor=bindingContractsConstructor, +) +let customElement = Document.createElement(document, customElementName) +assert(%raw(`customElement instanceof globalThis.BindingContractsElement`)) + +let inputAncestor: DOMTree.element = Document.createElement(document, "input") +let spanDescendant: DOMTree.element = Document.createElement(document, "span") +appendToDocument(inputAncestor) +appendChild(inputAncestor, spanDescendant) +let closestInput = spanDescendant->Element.closest("input") +assert(%raw(`closestInput !== null && closestInput.tagName === "INPUT"`)) +spanDescendant->Element.remove +inputAncestor->Element.remove + +let svg: SVGElement.t = %raw(`document.createElementNS("http://www.w3.org/2000/svg", "svg")`) +svg.className.baseVal = "binding-contracts-svg" +assert(svg.className.baseVal == "binding-contracts-svg") +let circle: SVGElement.t = %raw(`document.createElementNS("http://www.w3.org/2000/svg", "circle")`) +appendChild(svg, circle) +let broadSvgResult = svg->SVGElement.querySelector("circle") +assert(%raw(`broadSvgResult !== null && typeof broadSvgResult.className === "object" && broadSvgResult.className.baseVal === ""`)) + +let deleteRequest = IDBFactory.deleteDatabase(indexedDB, "binding-contracts-regression") +await withResolver(resolve => + onSuccess(deleteRequest, () => { + assert(isUndefined(deleteRequest.result)) + resolve() + }) +) + +let lockManager = navigator->Navigator.locks +await withResolver(resolve => { + let _heldRequest = lockManager->LockManager.request( + ~name="binding-contracts-contended-lock", + ~callback=lock => { + assert(!isNull(lock)) + resolve() + %raw(`new Promise(done => { globalThis.__releaseBindingLock = done })`) + }, + ) +}) +let _contendedRequest = await lockManager->LockManager.request2( + ~name="binding-contracts-contended-lock", + ~options={ifAvailable: true}, + ~callback=lock => { + assert(isNull(lock)) + %raw(`Promise.resolve(null)`) + }, +) +releaseLock() + let matrix = DOMMatrix.fromFloat32Array(Float32Array.fromArray([1., 0., 0., 1., 0., 0.])) assert(isFloat32ArrayWithSixteenValues(matrix->DOMMatrixReadOnly.toFloat32Array)) @@ -51,7 +153,8 @@ assert(%raw(`digest.byteLength === 32`)) let data = Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array let hmacKey = await subtle->SubtleCrypto.generateKeySymmetric( - ~algorithm={name: "HMAC", hash: {name: "SHA-256"}, length: 256}, + ~hash={name: "SHA-256"}, + ~length=256, ~extractable=true, ~keyUsages=[SubtleCrypto.signUsage, SubtleCrypto.verifyUsage], ) @@ -69,8 +172,8 @@ let rawKey = await subtle->SubtleCrypto.exportKeyBinary(~format=SubtleCrypto.Raw let jwk = await subtle->SubtleCrypto.exportKeyJwk(~format=SubtleCrypto.Jwk, ~key=hmacKey) assert(%raw(`rawKey.byteLength === 32 && jwk.kty === "oct"`)) -let keyPair = await subtle->SubtleCrypto.generateKeyPair( - ~algorithm={name: "ECDSA", namedCurve: "P-256"}, +let keyPair = await subtle->SubtleCrypto.generateEcdsaKeyPair( + ~namedCurve="P-256", ~extractable=true, ~keyUsages=[SubtleCrypto.signUsage, SubtleCrypto.verifyUsage], ) From 62f2165857c4c2f65b6564cf775bb503424987ec Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 18:47:17 -0500 Subject: [PATCH 03/15] test(bindings): verify remaining native behaviors --- .../ResizeObserverRejectsCurriedCallback.res | 1 + scripts/negative-fixtures.json | 6 ++++ tests/BindingByteTypes__test.res | 8 ++++- tests/FetchAPI/SafeFetch__test.res | 14 ++++++-- tests/browser/BindingContracts__test.res | 36 +++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 scripts/fixtures/negative/ResizeObserverRejectsCurriedCallback.res diff --git a/scripts/fixtures/negative/ResizeObserverRejectsCurriedCallback.res b/scripts/fixtures/negative/ResizeObserverRejectsCurriedCallback.res new file mode 100644 index 00000000..535c4644 --- /dev/null +++ b/scripts/fixtures/negative/ResizeObserverRejectsCurriedCallback.res @@ -0,0 +1 @@ +let _observer = WebAPI.ResizeObserver.make(_entries => _observer => ()) diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json index c2a29409..310c9b1c 100644 --- a/scripts/negative-fixtures.json +++ b/scripts/negative-fixtures.json @@ -23,6 +23,12 @@ "features": ["WebAPI.Window"], "expectedDiagnostic": "This has type:" }, + { + "name": "resize observer rejects nested callbacks", + "fixture": "scripts/fixtures/negative/ResizeObserverRejectsCurriedCallback.res", + "features": ["WebAPI.Observers"], + "expectedDiagnostic": "This function is expected" + }, { "name": "symmetric key generation fixes the algorithm", "fixture": "scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res", diff --git a/tests/BindingByteTypes__test.res b/tests/BindingByteTypes__test.res index 6b3a533d..ff4da9af 100644 --- a/tests/BindingByteTypes__test.res +++ b/tests/BindingByteTypes__test.res @@ -13,4 +13,10 @@ let _analyserByte: ( ) => unit = AnalyserNode.getByteTimeDomainData let _audioBuffer: (WebAudioTypes.audioBuffer, int) => Float32Array.t = AudioBuffer.getChannelData -let _sign: SubtleCrypto.keyUsage = WebCryptoTypes.Sign +let _sign: SubtleCrypto.keyUsage = SubtleCrypto.signUsage +let _sendArrayBuffer = WebSocket.sendArrayBuffer +let _sendBlob = WebSocket.sendBlob +let _sendString = WebSocket.sendString +let _workerRequestFetch = Worker.fetchWithRequest +let _sharedWorkerRequestFetch = SharedWorkerScope.fetchWithRequest +let _serviceWorkerRequestFetch = ServiceWorkerScope.fetchWithRequest diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res index 803a885a..f954f76e 100644 --- a/tests/FetchAPI/SafeFetch__test.res +++ b/tests/FetchAPI/SafeFetch__test.res @@ -1,6 +1,9 @@ @scope("Object") external isSame: ('value, 'value) => bool = "is" +let isBytesABC = _bytes => + %raw(`_bytes instanceof Uint8Array && _bytes.length === 3 && _bytes[0] === 65 && _bytes[2] === 67`) + let okResponse = Response.fromString("ok", ~init={status: 200}) switch okResponse->SafeFetch.checkOk { | Ok(response) => assert(isSame(response, okResponse)) @@ -196,11 +199,18 @@ let blobResult: result< > = await Response.fromString("blob")->SafeFetch.blob assert(blobResult->Result.isOk) +let bytesResponse = Response.fromString("ABC") let bytesResult: result< SafeFetch.response, [> SafeFetch.bodyError], -> = await Response.fromString("bytes")->SafeFetch.bytes -assert(bytesResult->Result.isOk) +> = await bytesResponse->SafeFetch.bytes +switch bytesResult { +| Ok({response, body}) => { + assert(isSame(response, bytesResponse)) + assert(isBytesABC(body)) + } +| Error(_) => assert(false) +} let formDataResponse = Response.fromString( "fruit=peach", diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 90510fa7..b97aa0a8 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -100,6 +100,42 @@ appendChild(svg, circle) let broadSvgResult = svg->SVGElement.querySelector("circle") assert(%raw(`broadSvgResult !== null && typeof broadSvgResult.className === "object" && broadSvgResult.className.baseVal === ""`)) +let audioContext = OfflineAudioContext.fromChannelCountLengthAndSampleRate( + ~numberOfChannels=1, + ~length=128, + ~sampleRate=44100., +) +let audioBuffer = + audioContext->OfflineAudioContext.createBuffer( + ~numberOfChannels=1, + ~length=128, + ~sampleRate=44100., + ) +let sourceChannel: Float32Array.t = %raw(`new Float32Array([0.5, -0.25])`) +audioBuffer->AudioBuffer.copyToChannel(~source=sourceChannel, ~channelNumber=0) +let channelData = audioBuffer->AudioBuffer.getChannelData(0) +assert(%raw(`channelData instanceof Float32Array && channelData[0] === 0.5 && channelData[1] === -0.25`)) + +let destination: Float32Array.t = %raw(`new Float32Array(2)`) +audioBuffer->AudioBuffer.copyFromChannel(~destination, ~channelNumber=0) +assert(%raw(`destination instanceof Float32Array && destination[0] === 0.5 && destination[1] === -0.25`)) + +let analyser = audioContext->OfflineAudioContext.createAnalyser +let analyserBytes: Uint8Array.t = %raw(`new Uint8Array(32)`) +analyser->AnalyserNode.getByteTimeDomainData(analyserBytes) +assert(%raw(`analyserBytes instanceof Uint8Array && analyserBytes.length === 32 && analyserBytes[0] === 128`)) + +let filter = audioContext->OfflineAudioContext.createBiquadFilter +let frequencies: Float32Array.t = %raw(`new Float32Array([440, 880])`) +let magnitudes: Float32Array.t = %raw(`new Float32Array(2)`) +let phases: Float32Array.t = %raw(`new Float32Array(2)`) +filter->BiquadFilterNode.getFrequencyResponse( + ~frequencyHz=frequencies, + ~magResponse=magnitudes, + ~phaseResponse=phases, +) +assert(%raw(`magnitudes instanceof Float32Array && phases instanceof Float32Array && magnitudes.length === 2 && phases.length === 2`)) + let deleteRequest = IDBFactory.deleteDatabase(indexedDB, "binding-contracts-regression") await withResolver(resolve => onSuccess(deleteRequest, () => { From c6af120805daa5cf9b47e262cbfd82e18c0ae39a Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 6 Nov 2026 20:15:51 -0500 Subject: [PATCH 04/15] test(bindings): exercise worker and websocket dispatch --- tests/browser/BindingContracts__test.res | 16 +++++++++++ tests/browser/WebSocketBindings.res | 4 +++ tests/browser/WorkerFetchBinding.res | 4 +++ tests/browser/websocket-bindings.spec.mjs | 34 +++++++++++++++++++++++ tests/browser/worker-fetch-body.txt | 1 + tests/browser/worker-fetch.mjs | 11 ++++++++ tests/fixtures.json | 2 ++ 7 files changed, 72 insertions(+) create mode 100644 tests/browser/WebSocketBindings.res create mode 100644 tests/browser/WorkerFetchBinding.res create mode 100644 tests/browser/websocket-bindings.spec.mjs create mode 100644 tests/browser/worker-fetch-body.txt create mode 100644 tests/browser/worker-fetch.mjs diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index b97aa0a8..a3716bb9 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -23,6 +23,15 @@ let appendChild = (_parent, _child) => %raw(`_parent.appendChild(_child)`) let appendToDocument = _target => %raw(`document.body.appendChild(_target)`) let onSuccess = (_request, _callback) => %raw(`_request.addEventListener("success", _callback, {once: true})`) +let createWorker = () => + %raw(`new Worker(new URL("/tests/browser/worker-fetch.mjs", location.href), {type: "module"})`) +let awaitWorkerMessage = _worker => + %raw(`new Promise((resolve, reject) => { + _worker.addEventListener("message", event => resolve(event.data), {once: true}); + _worker.addEventListener("error", reject, {once: true}); + })`) +let postWorkerMessage = _worker => %raw(`_worker.postMessage(null)`) +let stopWorker = _worker => %raw(`_worker.terminate()`) let releaseLock = () => %raw(`globalThis.__releaseBindingLock()`) let isUint8ArrayWithABC = _bytes => @@ -52,6 +61,13 @@ assert(audio->HTMLAudioElement.canPlayType("application/x-webapi-unsupported") = let _notificationPermission = Notification.permission +let worker = createWorker() +let workerResponse = awaitWorkerMessage(worker) +postWorkerMessage(worker) +let workerBody = await workerResponse +stopWorker(worker) +assert(%raw(`workerBody === "worker-fetch-body\n"`)) + switch Document.createElement(document, "canvas")->HTMLCanvasElement.classify { | Some(canvas) => { canvas.width = 0 diff --git a/tests/browser/WebSocketBindings.res b/tests/browser/WebSocketBindings.res new file mode 100644 index 00000000..0ddf0f61 --- /dev/null +++ b/tests/browser/WebSocketBindings.res @@ -0,0 +1,4 @@ +let connect = url => WebSocket.fromURL(~url) +let sendArrayBuffer = (socket, payload: ArrayBuffer.t) => WebSocket.sendArrayBuffer(socket, payload) +let sendBlob = (socket, payload: Blob.t) => WebSocket.sendBlob(socket, payload) +let sendString = (socket, payload: string) => WebSocket.sendString(socket, payload) diff --git a/tests/browser/WorkerFetchBinding.res b/tests/browser/WorkerFetchBinding.res new file mode 100644 index 00000000..aa76099e --- /dev/null +++ b/tests/browser/WorkerFetchBinding.res @@ -0,0 +1,4 @@ +let fetchWithRequest = request => + Worker.fetchWithRequest(Worker.current, request, ~init={method: "POST"}) + +let text = response => Response.text(response) diff --git a/tests/browser/websocket-bindings.spec.mjs b/tests/browser/websocket-bindings.spec.mjs new file mode 100644 index 00000000..41d54b5b --- /dev/null +++ b/tests/browser/websocket-bindings.spec.mjs @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test("WebSocket binding sends text, ArrayBuffer, and Blob payloads through the socket receiver", async ({ + page, +}) => { + await page.routeWebSocket("ws://127.0.0.1:4173/binding-echo", (echoSocket) => { + echoSocket.onMessage((message) => echoSocket.send(message)); + }); + + await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + const received = await page.evaluate(async () => { + const bindings = await import("/tests/browser/WebSocketBindings.js"); + return new Promise((resolve, reject) => { + const socket = bindings.connect("ws://127.0.0.1:4173/binding-echo"); + const payloads = []; + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => { + bindings.sendString(socket, "text"); + bindings.sendArrayBuffer(socket, new Uint8Array([65, 66]).buffer); + bindings.sendBlob(socket, new Blob(["CD"])); + }); + socket.addEventListener("message", ({ data }) => { + payloads.push(typeof data === "string" ? data : Array.from(new Uint8Array(data)).join(",")); + if (payloads.length === 3) { + socket.close(); + resolve(payloads); + } + }); + socket.addEventListener("error", () => reject(new Error("WebSocket echo failed"))); + }); + }); + + expect(received).toEqual(["text", "65,66", "67,68"]); +}); diff --git a/tests/browser/worker-fetch-body.txt b/tests/browser/worker-fetch-body.txt new file mode 100644 index 00000000..f10e3a8b --- /dev/null +++ b/tests/browser/worker-fetch-body.txt @@ -0,0 +1 @@ +worker-fetch-body diff --git a/tests/browser/worker-fetch.mjs b/tests/browser/worker-fetch.mjs new file mode 100644 index 00000000..6b53fd8d --- /dev/null +++ b/tests/browser/worker-fetch.mjs @@ -0,0 +1,11 @@ +import * as WorkerFetchBinding from "./WorkerFetchBinding.js"; + +self.addEventListener("message", async () => { + try { + const request = new Request(new URL("./worker-fetch-body.txt", self.location.href)); + const response = await WorkerFetchBinding.fetchWithRequest(request); + self.postMessage(await WorkerFetchBinding.text(response)); + } catch (error) { + self.postMessage({ error: String(error) }); + } +}); diff --git a/tests/fixtures.json b/tests/fixtures.json index 4b2bfc50..f066adb1 100644 --- a/tests/fixtures.json +++ b/tests/fixtures.json @@ -2,6 +2,7 @@ "compileOnly": [ "BindingAbsence__test.res", "BindingByteTypes__test.res", + "browser/WorkerFetchBinding.res", "CanvasAPI/Path2D__test.res", "CSSFontLoadingAPI/FontFace__test.res", "DOMAPI/AddEventListener__test.res", @@ -30,6 +31,7 @@ "ServiceWorkerAPI/ServiceWorker__test.res", "WebAudioAPI/AudioDestinationNode__.test.res", "WebAudioAPI/OfflineAudioContext__test.res", + "browser/WebSocketBindings.res", "WebSocketsAPI/WebSocket__test.res", "WebStorageAPI/Storage__test.res", "WebWorkersAPI/SharedWorkerScope__test.res", From 1728dc95c955ed431c27bd061870441d0773560b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 6 Nov 2026 20:17:42 -0500 Subject: [PATCH 05/15] test(bindings): cover WebAudio and native edge cases --- .../negative/AnalyserRejectsOrdinaryArray.res | 7 +++++++ .../negative/DOMMatrixRejectsOrdinaryArray.res | 1 + scripts/negative-fixtures.json | 12 ++++++++++++ tests/browser/BindingContracts__test.res | 2 +- 4 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 scripts/fixtures/negative/AnalyserRejectsOrdinaryArray.res create mode 100644 scripts/fixtures/negative/DOMMatrixRejectsOrdinaryArray.res diff --git a/scripts/fixtures/negative/AnalyserRejectsOrdinaryArray.res b/scripts/fixtures/negative/AnalyserRejectsOrdinaryArray.res new file mode 100644 index 00000000..01713928 --- /dev/null +++ b/scripts/fixtures/negative/AnalyserRejectsOrdinaryArray.res @@ -0,0 +1,7 @@ +let context = WebAPI.OfflineAudioContext.fromChannelCountLengthAndSampleRate( + ~numberOfChannels=1, + ~length=128, + ~sampleRate=44100., +) +let analyser = context->WebAPI.OfflineAudioContext.createAnalyser +analyser->WebAPI.AnalyserNode.getFloatTimeDomainData([0., 0.]) diff --git a/scripts/fixtures/negative/DOMMatrixRejectsOrdinaryArray.res b/scripts/fixtures/negative/DOMMatrixRejectsOrdinaryArray.res new file mode 100644 index 00000000..10d391ff --- /dev/null +++ b/scripts/fixtures/negative/DOMMatrixRejectsOrdinaryArray.res @@ -0,0 +1 @@ +let _matrix = WebAPI.DOMMatrix.fromFloat32Array([1., 0., 0., 1., 0., 0.]) diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json index 310c9b1c..41c3e936 100644 --- a/scripts/negative-fixtures.json +++ b/scripts/negative-fixtures.json @@ -29,6 +29,18 @@ "features": ["WebAPI.Observers"], "expectedDiagnostic": "This function is expected" }, + { + "name": "DOMMatrix requires native Float32Array input", + "fixture": "scripts/fixtures/negative/DOMMatrixRejectsOrdinaryArray.res", + "features": ["WebAPI.Geometry"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "Analyser requires native Float32Array output buffer", + "fixture": "scripts/fixtures/negative/AnalyserRejectsOrdinaryArray.res", + "features": ["WebAPI.WebAudio"], + "expectedDiagnostic": "This has type:" + }, { "name": "symmetric key generation fixes the algorithm", "fixture": "scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res", diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index a3716bb9..75cb0986 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -150,7 +150,7 @@ filter->BiquadFilterNode.getFrequencyResponse( ~magResponse=magnitudes, ~phaseResponse=phases, ) -assert(%raw(`magnitudes instanceof Float32Array && phases instanceof Float32Array && magnitudes.length === 2 && phases.length === 2`)) +assert(%raw(`magnitudes instanceof Float32Array && phases instanceof Float32Array && magnitudes.length === 2 && phases.length === 2 && !Number.isNaN(magnitudes[0])`)) let deleteRequest = IDBFactory.deleteDatabase(indexedDB, "binding-contracts-regression") await withResolver(resolve => From f5df816e882e816b5c29529b205be27e4c60b000 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 6 Nov 2026 20:23:37 -0500 Subject: [PATCH 06/15] test(bindings): verify stream and native api edge cases --- scripts/consumer-crypto.res | 51 ++++++++++++++++++++++++ tests/browser/BindingContracts__test.res | 33 ++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 scripts/consumer-crypto.res diff --git a/scripts/consumer-crypto.res b/scripts/consumer-crypto.res new file mode 100644 index 00000000..5a8a6263 --- /dev/null +++ b/scripts/consumer-crypto.res @@ -0,0 +1,51 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let subtle = crypto.subtle +let data = + WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) + +let digest: ArrayBuffer.t = await subtle->WebAPI.SubtleCrypto.digest(~algorithm={name: "SHA-256"}, ~data) + +let hmacKey: WebAPI.SubtleCrypto.cryptoKey = await subtle->WebAPI.SubtleCrypto.generateKeySymmetric( + ~hash={name: "SHA-256"}, + ~length=256, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage, WebAPI.SubtleCrypto.verifyUsage], +) + +let signature = await subtle->WebAPI.SubtleCrypto.sign(~algorithm={name: "HMAC"}, ~key=hmacKey, ~data) +let verified: bool = await subtle->WebAPI.SubtleCrypto.verify( + ~algorithm={name: "HMAC"}, + ~key=hmacKey, + ~signature=WebAPI.SubtleCrypto.binaryDataFromArrayBuffer(signature), + ~data, +) + +let rawKey: ArrayBuffer.t = await subtle->WebAPI.SubtleCrypto.exportKeyBinary( + ~format=WebAPI.SubtleCrypto.Raw, + ~key=hmacKey, +) +let jwk: JSON.t = await subtle->WebAPI.SubtleCrypto.exportKeyJwk( + ~format=WebAPI.SubtleCrypto.Jwk, + ~key=hmacKey, +) + +let keyPair: WebAPI.SubtleCrypto.cryptoKeyPair = await subtle->WebAPI.SubtleCrypto.generateEcdsaKeyPair( + ~namedCurve="P-256", + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage, WebAPI.SubtleCrypto.verifyUsage], +) + +let pairSignature = await subtle->WebAPI.SubtleCrypto.sign( + ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~key=keyPair.privateKey, + ~data, +) + +let pairVerified: bool = await subtle->WebAPI.SubtleCrypto.verify( + ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~key=keyPair.publicKey, + ~signature=WebAPI.SubtleCrypto.binaryDataFromArrayBuffer(pairSignature), + ~data, +) diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 75cb0986..7369ca14 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -10,6 +10,14 @@ external indexedDB: IndexedDbTypes.idbFactory = "indexedDB" @scope("globalThis") external navigator: Navigator.t = "navigator" +@send +external readByteStreamChunk: FileTypes.readableStreamReader => promise = + "read" + +@send +external cancelByteStreamReader: FileTypes.readableStreamReader => promise = + "cancel" + let withResolver = (_run: (unit => unit) => unit): promise => %raw(`new Promise(resolve => _run(resolve))`) @@ -18,6 +26,8 @@ let hasResizeCallbackArgs = (_entries, _observer) => let isNull = _value => %raw(`_value === null`) let isUndefined = _value => %raw(`_value === undefined`) +let containsABCByteChunk = _result => + %raw(`_result.value instanceof Uint8Array && _result.value.length === 3 && _result.value[0] === 65 && _result.value[2] === 67`) let appendChild = (_parent, _child) => %raw(`_parent.appendChild(_child)`) let appendToDocument = _target => %raw(`document.body.appendChild(_target)`) @@ -55,11 +65,19 @@ assert(%raw(`emptyStateRequest.destination === "" && emptyStateRequest.referrerP let blobBytes = await Blob.make(~blobParts=[String("ABC")])->Blob.bytes assert(isUint8ArrayWithABC(blobBytes)) +let byteStream = Blob.make(~blobParts=[String("ABC")])->Blob.stream +let byteStreamReader = byteStream->ReadableStream.getReader +let firstChunk = await byteStreamReader->readByteStreamChunk +assert(containsABCByteChunk(firstChunk)) +await byteStreamReader->cancelByteStreamReader + let audio: HTMLAudioElement.t = %raw(`document.createElement("audio")`) assert(audio->HTMLAudioElement.canPlayType("application/x-webapi-unsupported") == DomHTMLMediaElement.Empty) -let _notificationPermission = Notification.permission +switch Notification.permission { +| NotificationTypes.Default | NotificationTypes.Denied | NotificationTypes.Granted => () +} let worker = createWorker() let workerResponse = awaitWorkerMessage(worker) @@ -152,6 +170,19 @@ filter->BiquadFilterNode.getFrequencyResponse( ) assert(%raw(`magnitudes instanceof Float32Array && phases instanceof Float32Array && magnitudes.length === 2 && phases.length === 2 && !Number.isNaN(magnitudes[0])`)) +let iirFilter = IIRFilterNode.make( + ~context=audioContext->OfflineAudioContext.asBaseAudioContext, + ~options={feedforward: [1.], feedback: [1.]}, +) +let iirMagnitudes: Float32Array.t = %raw(`new Float32Array(2)`) +let iirPhases: Float32Array.t = %raw(`new Float32Array(2)`) +iirFilter->IIRFilterNode.getFrequencyResponse( + ~frequencyHz=frequencies, + ~magResponse=iirMagnitudes, + ~phaseResponse=iirPhases, +) +assert(%raw(`iirMagnitudes instanceof Float32Array && !Number.isNaN(iirMagnitudes[0]) && iirPhases instanceof Float32Array`)) + let deleteRequest = IDBFactory.deleteDatabase(indexedDB, "binding-contracts-regression") await withResolver(resolve => onSuccess(deleteRequest, () => { From 5be6b714c4861dd32ec2fd305973d6435fd51a0e Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 6 Nov 2026 20:39:32 -0500 Subject: [PATCH 07/15] test(bindings): harden native regression evidence --- package-lock.json | 32 ++++++- package.json | 3 +- scripts/check-consumer.mjs | 5 ++ scripts/consumer-crypto.res | 2 + .../negative/BroadElementHasNoClassName.res | 2 + .../CanvasRejectsNonNullableCallback.res | 2 + .../fixtures/negative/ClosestCannotNarrow.res | 2 +- .../negative/IDBDeleteResultIsNotDatabase.res | 5 ++ .../negative/JwkIsNotBinaryKeyFormat.res | 15 ++++ .../NotificationHasNoInstancePermission.res | 2 + .../negative/RawIsNotJwkKeyFormat.res | 15 ++++ .../WebLocksRejectsNonNullableCallback.res | 9 ++ scripts/negative-fixtures.json | 42 ++++++++++ tests/browser/BindingContracts__test.res | 83 +++++++++++-------- tests/browser/WorkerFetchBinding.res | 4 +- tests/browser/websocket-bindings.spec.mjs | 82 ++++++++++++------ tests/browser/websocket-server.mjs | 18 ++++ tests/browser/worker-fetch-body.txt | 1 - tests/browser/worker-fetch.mjs | 10 ++- tests/browser/worker-fetch.spec.mjs | 61 ++++++++++++++ tests/browser/worker-http-server.mjs | 27 ++++++ 21 files changed, 353 insertions(+), 69 deletions(-) create mode 100644 scripts/fixtures/negative/BroadElementHasNoClassName.res create mode 100644 scripts/fixtures/negative/CanvasRejectsNonNullableCallback.res create mode 100644 scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res create mode 100644 scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res create mode 100644 scripts/fixtures/negative/NotificationHasNoInstancePermission.res create mode 100644 scripts/fixtures/negative/RawIsNotJwkKeyFormat.res create mode 100644 scripts/fixtures/negative/WebLocksRejectsNonNullableCallback.res create mode 100644 tests/browser/websocket-server.mjs delete mode 100644 tests/browser/worker-fetch-body.txt create mode 100644 tests/browser/worker-fetch.spec.mjs create mode 100644 tests/browser/worker-http-server.mjs diff --git a/package-lock.json b/package-lock.json index d5fdbc4e..713cfa3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "prettier": "^3.8.3", "prettier-plugin-astro": "^1.0.0", "rescript": "13.0.0-alpha.5", - "sharp": "^0.35.3" + "sharp": "^0.35.3", + "ws": "^8.22.1" }, "peerDependencies": { "rescript": ">=13.0.0-alpha.5 <13.0.0" @@ -6087,6 +6088,28 @@ } } }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", @@ -9610,6 +9633,13 @@ "dev": true, "requires": {} }, + "ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "requires": {} + }, "yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/package.json b/package.json index 0597fd98..d2d0f65d 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "prettier": "^3.8.3", "prettier-plugin-astro": "^1.0.0", "rescript": "13.0.0-alpha.5", - "sharp": "^0.35.3" + "sharp": "^0.35.3", + "ws": "^8.22.1" }, "peerDependencies": { "rescript": ">=13.0.0-alpha.5 <13.0.0" diff --git a/scripts/check-consumer.mjs b/scripts/check-consumer.mjs index e75c37e6..2300d4d5 100644 --- a/scripts/check-consumer.mjs +++ b/scripts/check-consumer.mjs @@ -198,6 +198,11 @@ const checkConsumers = async () => { fixture: "scripts/consumer-safe-fetch.res", features: ["WebAPI.SafeFetch"], }, + { + name: "Crypto", + fixture: "scripts/consumer-crypto.res", + features: ["WebAPI.Crypto"], + }, { name: "Fetch-only SafeFetch rejection", fixture: "scripts/consumer-safe-fetch-disabled.res", diff --git a/scripts/consumer-crypto.res b/scripts/consumer-crypto.res index 5a8a6263..2aeffa63 100644 --- a/scripts/consumer-crypto.res +++ b/scripts/consumer-crypto.res @@ -6,6 +6,8 @@ let data = WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) let digest: ArrayBuffer.t = await subtle->WebAPI.SubtleCrypto.digest(~algorithm={name: "SHA-256"}, ~data) +let digestBytes: Uint8Array.t = %raw(`new Uint8Array(digest)`) +assert(%raw(`digestBytes instanceof Uint8Array && digestBytes.buffer === digest`)) let hmacKey: WebAPI.SubtleCrypto.cryptoKey = await subtle->WebAPI.SubtleCrypto.generateKeySymmetric( ~hash={name: "SHA-256"}, diff --git a/scripts/fixtures/negative/BroadElementHasNoClassName.res b/scripts/fixtures/negative/BroadElementHasNoClassName.res new file mode 100644 index 00000000..822dcb94 --- /dev/null +++ b/scripts/fixtures/negative/BroadElementHasNoClassName.res @@ -0,0 +1,2 @@ +let broadElement: WebAPI.Element.t = %raw(`document.createElement("div")`) +let _className: string = broadElement.className diff --git a/scripts/fixtures/negative/CanvasRejectsNonNullableCallback.res b/scripts/fixtures/negative/CanvasRejectsNonNullableCallback.res new file mode 100644 index 00000000..c06a371f --- /dev/null +++ b/scripts/fixtures/negative/CanvasRejectsNonNullableCallback.res @@ -0,0 +1,2 @@ +let canvas: WebAPI.HTMLCanvasElement.t = %raw(`document.createElement("canvas")`) +WebAPI.HTMLCanvasElement.toBlob(canvas, ~callback=(_blob: WebAPI.Blob.t) => ()) diff --git a/scripts/fixtures/negative/ClosestCannotNarrow.res b/scripts/fixtures/negative/ClosestCannotNarrow.res index 11ef59c6..01ac4703 100644 --- a/scripts/fixtures/negative/ClosestCannotNarrow.res +++ b/scripts/fixtures/negative/ClosestCannotNarrow.res @@ -1,2 +1,2 @@ let element: WebAPI.Element.t = %raw(`document.createElement("div")`) -let _input: WebAPI.HTMLInputElement.t = element->WebAPI.Element.closest("input") +let _input: Null.t = element->WebAPI.Element.closest("input") diff --git a/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res b/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res new file mode 100644 index 00000000..05870380 --- /dev/null +++ b/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res @@ -0,0 +1,5 @@ +@scope("globalThis") +external indexedDB: WebAPI.IndexedDbTypes.idbFactory = "indexedDB" + +let request = WebAPI.IDBFactory.deleteDatabase(indexedDB, "compile-only") +let _database: string = request.result diff --git a/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res b/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res new file mode 100644 index 00000000..bd667d71 --- /dev/null +++ b/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res @@ -0,0 +1,15 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let key = await WebAPI.SubtleCrypto.generateKeySymmetric( + crypto.subtle, + ~hash={name: "SHA-256"}, + ~length=256, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage], +) +let _export = WebAPI.SubtleCrypto.exportKeyBinary( + crypto.subtle, + ~format=WebAPI.SubtleCrypto.Jwk, + ~key, +) diff --git a/scripts/fixtures/negative/NotificationHasNoInstancePermission.res b/scripts/fixtures/negative/NotificationHasNoInstancePermission.res new file mode 100644 index 00000000..62ba23c2 --- /dev/null +++ b/scripts/fixtures/negative/NotificationHasNoInstancePermission.res @@ -0,0 +1,2 @@ +let notification = WebAPI.Notification.make(~title="compile-only") +let _permission = notification.permission diff --git a/scripts/fixtures/negative/RawIsNotJwkKeyFormat.res b/scripts/fixtures/negative/RawIsNotJwkKeyFormat.res new file mode 100644 index 00000000..ec0aea6b --- /dev/null +++ b/scripts/fixtures/negative/RawIsNotJwkKeyFormat.res @@ -0,0 +1,15 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let key = await WebAPI.SubtleCrypto.generateKeySymmetric( + crypto.subtle, + ~hash={name: "SHA-256"}, + ~length=256, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage], +) +let _export = WebAPI.SubtleCrypto.exportKeyJwk( + crypto.subtle, + ~format=WebAPI.SubtleCrypto.Raw, + ~key, +) diff --git a/scripts/fixtures/negative/WebLocksRejectsNonNullableCallback.res b/scripts/fixtures/negative/WebLocksRejectsNonNullableCallback.res new file mode 100644 index 00000000..b1d35061 --- /dev/null +++ b/scripts/fixtures/negative/WebLocksRejectsNonNullableCallback.res @@ -0,0 +1,9 @@ +@scope("globalThis") +external navigator: WebAPI.Navigator.t = "navigator" + +let callback: WebAPI.WebLocksTypes.lock => promise = _lock => %raw(`Promise.resolve(null)`) +let _request = navigator->WebAPI.Navigator.locks->WebAPI.LockManager.request2( + ~name="compile-only", + ~options={ifAvailable: true}, + ~callback, +) diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json index 41c3e936..571aebea 100644 --- a/scripts/negative-fixtures.json +++ b/scripts/negative-fixtures.json @@ -17,6 +17,36 @@ "features": ["WebAPI.HTML"], "expectedDiagnostic": "This has type:" }, + { + "name": "broad Element has no platform-dependent className field", + "fixture": "scripts/fixtures/negative/BroadElementHasNoClassName.res", + "features": ["WebAPI.DOMNodes"], + "expectedDiagnostic": "The field className does not belong to type" + }, + { + "name": "Canvas callback requires nullable Blob", + "fixture": "scripts/fixtures/negative/CanvasRejectsNonNullableCallback.res", + "features": ["WebAPI.Canvas"], + "expectedDiagnostic": "but a pattern was expected which matches values of type Null.t" + }, + { + "name": "Notification permission is not an instance field", + "fixture": "scripts/fixtures/negative/NotificationHasNoInstancePermission.res", + "features": ["WebAPI.Messaging"], + "expectedDiagnostic": "The field permission does not belong to type" + }, + { + "name": "IndexedDB delete result is not an opened database", + "fixture": "scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res", + "features": ["WebAPI.Storage"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "Web Locks callback accepts nullable Lock", + "fixture": "scripts/fixtures/negative/WebLocksRejectsNonNullableCallback.res", + "features": ["WebAPI.Navigator"], + "expectedDiagnostic": "Null.t" + }, { "name": "custom element definition rejects element instances", "fixture": "scripts/fixtures/negative/CustomElementRequiresConstructor.res", @@ -52,5 +82,17 @@ "fixture": "scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res", "features": ["WebAPI.Crypto"], "expectedDiagnostic": "This function call is incorrect. The function has type:" + }, + { + "name": "JWK format cannot be used for binary export", + "fixture": "scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "belongs to the variant type" + }, + { + "name": "raw format cannot be used for JWK export", + "fixture": "scripts/fixtures/negative/RawIsNotJwkKeyFormat.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "belongs to the variant type" } ] diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 7369ca14..4a5495d4 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -23,6 +23,13 @@ let withResolver = (_run: (unit => unit) => unit): promise => let hasResizeCallbackArgs = (_entries, _observer) => %raw(`_entries.length > 0 && _observer instanceof ResizeObserver`) +let createResizeSignals = () => %raw(`({count: 0, resolvers: []})`) +let waitForResizeSignal = (_signals: unknown): promise => + %raw(`new Promise(resolve => _signals.resolvers.push(resolve))`) +let resolveNextResizeSignal = (_signals: unknown): unit => %raw(`_signals.resolvers.shift()()`) +let incrementResizeCount = (_signals: unknown): int => %raw(`++_signals.count`) +let setSize = (_target: DOMTree.element, _pixels: int): unit => + %raw(`(_target.style.width = _pixels + "px", _target.style.height = _pixels + "px")`) let isNull = _value => %raw(`_value === null`) let isUndefined = _value => %raw(`_value === undefined`) @@ -33,22 +40,13 @@ let appendChild = (_parent, _child) => %raw(`_parent.appendChild(_child)`) let appendToDocument = _target => %raw(`document.body.appendChild(_target)`) let onSuccess = (_request, _callback) => %raw(`_request.addEventListener("success", _callback, {once: true})`) -let createWorker = () => - %raw(`new Worker(new URL("/tests/browser/worker-fetch.mjs", location.href), {type: "module"})`) -let awaitWorkerMessage = _worker => - %raw(`new Promise((resolve, reject) => { - _worker.addEventListener("message", event => resolve(event.data), {once: true}); - _worker.addEventListener("error", reject, {once: true}); - })`) -let postWorkerMessage = _worker => %raw(`_worker.postMessage(null)`) -let stopWorker = _worker => %raw(`_worker.terminate()`) let releaseLock = () => %raw(`globalThis.__releaseBindingLock()`) let isUint8ArrayWithABC = _bytes => %raw(`_bytes instanceof Uint8Array && _bytes.length === 3 && _bytes[0] === 65 && _bytes[2] === 67`) -let isFloat32ArrayWithSixteenValues = _values => - %raw(`_values instanceof Float32Array && _values.length === 16 && _values[0] === 1 && _values[15] === 1`) +let isNonIdentityFloat32Matrix = _values => + %raw(`_values instanceof Float32Array && _values.length === 16 && _values[0] === 2 && _values[5] === 3 && _values[12] === 4 && _values[13] === 5 && _values[15] === 1`) let bytes = await Response.fromString("ABC")->Response.bytes assert(isUint8ArrayWithABC(bytes)) @@ -79,13 +77,6 @@ switch Notification.permission { | NotificationTypes.Default | NotificationTypes.Denied | NotificationTypes.Granted => () } -let worker = createWorker() -let workerResponse = awaitWorkerMessage(worker) -postWorkerMessage(worker) -let workerBody = await workerResponse -stopWorker(worker) -assert(%raw(`workerBody === "worker-fetch-body\n"`)) - switch Document.createElement(document, "canvas")->HTMLCanvasElement.classify { | Some(canvas) => { canvas.width = 0 @@ -134,6 +125,18 @@ appendChild(svg, circle) let broadSvgResult = svg->SVGElement.querySelector("circle") assert(%raw(`broadSvgResult !== null && typeof broadSvgResult.className === "object" && broadSvgResult.className.baseVal === ""`)) +let htmlParent: HTMLDivElement.t = %raw(`document.createElement("div")`) +htmlParent->HTMLDivElement.setAttribute(~qualifiedName="class", ~value="binding-contracts-html") +assert(htmlParent.className == "binding-contracts-html") +let htmlChild: HTMLSpanElement.t = %raw(`document.createElement("span")`) +htmlChild->HTMLSpanElement.setAttribute( + ~qualifiedName="class", + ~value="binding-contracts-html-child", +) +appendChild(htmlParent, htmlChild) +let broadHtmlResult = htmlParent->HTMLDivElement.querySelector(".binding-contracts-html-child") +assert(%raw(`broadHtmlResult !== null && typeof broadHtmlResult.className === "string"`)) + let audioContext = OfflineAudioContext.fromChannelCountLengthAndSampleRate( ~numberOfChannels=1, ~length=128, @@ -158,30 +161,33 @@ let analyser = audioContext->OfflineAudioContext.createAnalyser let analyserBytes: Uint8Array.t = %raw(`new Uint8Array(32)`) analyser->AnalyserNode.getByteTimeDomainData(analyserBytes) assert(%raw(`analyserBytes instanceof Uint8Array && analyserBytes.length === 32 && analyserBytes[0] === 128`)) +let analyserFloats: Float32Array.t = %raw(`new Float32Array([0.5, -0.5])`) +analyser->AnalyserNode.getFloatTimeDomainData(analyserFloats) +assert(%raw(`analyserFloats instanceof Float32Array && analyserFloats[0] === 0 && analyserFloats[1] === 0`)) let filter = audioContext->OfflineAudioContext.createBiquadFilter let frequencies: Float32Array.t = %raw(`new Float32Array([440, 880])`) -let magnitudes: Float32Array.t = %raw(`new Float32Array(2)`) -let phases: Float32Array.t = %raw(`new Float32Array(2)`) +let magnitudes: Float32Array.t = %raw(`new Float32Array([-123, -123])`) +let phases: Float32Array.t = %raw(`new Float32Array([-456, -456])`) filter->BiquadFilterNode.getFrequencyResponse( ~frequencyHz=frequencies, ~magResponse=magnitudes, ~phaseResponse=phases, ) -assert(%raw(`magnitudes instanceof Float32Array && phases instanceof Float32Array && magnitudes.length === 2 && phases.length === 2 && !Number.isNaN(magnitudes[0])`)) +assert(%raw(`magnitudes instanceof Float32Array && phases instanceof Float32Array && magnitudes.length === 2 && phases.length === 2 && magnitudes[0] !== -123 && phases[0] !== -456 && !Number.isNaN(magnitudes[0])`)) let iirFilter = IIRFilterNode.make( ~context=audioContext->OfflineAudioContext.asBaseAudioContext, ~options={feedforward: [1.], feedback: [1.]}, ) -let iirMagnitudes: Float32Array.t = %raw(`new Float32Array(2)`) -let iirPhases: Float32Array.t = %raw(`new Float32Array(2)`) +let iirMagnitudes: Float32Array.t = %raw(`new Float32Array([-123, -123])`) +let iirPhases: Float32Array.t = %raw(`new Float32Array([-456, -456])`) iirFilter->IIRFilterNode.getFrequencyResponse( ~frequencyHz=frequencies, ~magResponse=iirMagnitudes, ~phaseResponse=iirPhases, ) -assert(%raw(`iirMagnitudes instanceof Float32Array && !Number.isNaN(iirMagnitudes[0]) && iirPhases instanceof Float32Array`)) +assert(%raw(`iirMagnitudes instanceof Float32Array && iirMagnitudes[0] !== -123 && !Number.isNaN(iirMagnitudes[0]) && iirPhases instanceof Float32Array && iirPhases[0] !== -456`)) let deleteRequest = IDBFactory.deleteDatabase(indexedDB, "binding-contracts-regression") await withResolver(resolve => @@ -212,19 +218,29 @@ let _contendedRequest = await lockManager->LockManager.request2( ) releaseLock() -let matrix = DOMMatrix.fromFloat32Array(Float32Array.fromArray([1., 0., 0., 1., 0., 0.])) -assert(isFloat32ArrayWithSixteenValues(matrix->DOMMatrixReadOnly.toFloat32Array)) +let matrixValues = Float32Array.fromArray([2., 0., 0., 3., 4., 5.]) +let matrix = DOMMatrix.fromFloat32Array(matrixValues) +assert(isNonIdentityFloat32Matrix(matrix->DOMMatrixReadOnly.toFloat32Array)) +let readOnlyMatrix = DOMMatrixReadOnly.fromFloat32Array(matrixValues) +assert(isNonIdentityFloat32Matrix(readOnlyMatrix->DOMMatrixReadOnly.toFloat32Array)) let target: DOMTree.element = %raw(`document.createElement("div")`) appendToDocument(target) -await withResolver(resolve => { - let observer = ResizeObserver.make((entries, observer) => { +let resizeSignals = createResizeSignals() +let initialResize = waitForResizeSignal(resizeSignals) +let resizedAgain = waitForResizeSignal(resizeSignals) +setSize(target, 10) +let observer = ResizeObserver.make((entries, observer) => { + assert(hasResizeCallbackArgs(entries, observer)) + if incrementResizeCount(resizeSignals) == 2 { observer->ResizeObserver.disconnect - assert(hasResizeCallbackArgs(entries, observer)) - resolve() - }) - observer->ResizeObserver.observe(~target) + } + resolveNextResizeSignal(resizeSignals) }) +observer->ResizeObserver.observe(~target) +await initialResize +setSize(target, 20) +await resizedAgain target->Element.remove let subtle = crypto.subtle @@ -232,7 +248,8 @@ let digest = await subtle->SubtleCrypto.digest( ~algorithm={name: "SHA-256"}, ~data=Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array, ) -assert(%raw(`digest.byteLength === 32`)) +let digestBytes: Uint8Array.t = %raw(`new Uint8Array(digest)`) +assert(%raw(`digest.byteLength === 32 && digestBytes instanceof Uint8Array && digestBytes.length === 32 && digestBytes.buffer === digest`)) let data = Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array let hmacKey = await subtle->SubtleCrypto.generateKeySymmetric( diff --git a/tests/browser/WorkerFetchBinding.res b/tests/browser/WorkerFetchBinding.res index aa76099e..deff5654 100644 --- a/tests/browser/WorkerFetchBinding.res +++ b/tests/browser/WorkerFetchBinding.res @@ -1,4 +1,4 @@ -let fetchWithRequest = request => - Worker.fetchWithRequest(Worker.current, request, ~init={method: "POST"}) +let fetchWithRequest = (request: Request.t, init: Request.requestInit) => + Worker.fetchWithRequest(Worker.current, request, ~init) let text = response => Response.text(response) diff --git a/tests/browser/websocket-bindings.spec.mjs b/tests/browser/websocket-bindings.spec.mjs index 41d54b5b..2dbf6105 100644 --- a/tests/browser/websocket-bindings.spec.mjs +++ b/tests/browser/websocket-bindings.spec.mjs @@ -1,34 +1,62 @@ import { expect, test } from "@playwright/test"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; -test("WebSocket binding sends text, ArrayBuffer, and Blob payloads through the socket receiver", async ({ - page, -}) => { - await page.routeWebSocket("ws://127.0.0.1:4173/binding-echo", (echoSocket) => { - echoSocket.onMessage((message) => echoSocket.send(message)); +const startWebSocketServer = () => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["tests/browser/websocket-server.mjs"], { + stdio: ["ignore", "pipe", "inherit"], + }); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + const port = Number(output.trim()); + if (Number.isInteger(port) && port > 0) { + resolve({ child, port }); + } + }); + child.once("error", reject); + child.once("exit", (code) => { + if (output.length === 0) { + reject(new Error(`WebSocket server exited before startup with code ${code}`)); + } + }); }); - await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); - const received = await page.evaluate(async () => { - const bindings = await import("/tests/browser/WebSocketBindings.js"); - return new Promise((resolve, reject) => { - const socket = bindings.connect("ws://127.0.0.1:4173/binding-echo"); - const payloads = []; - socket.binaryType = "arraybuffer"; - socket.addEventListener("open", () => { - bindings.sendString(socket, "text"); - bindings.sendArrayBuffer(socket, new Uint8Array([65, 66]).buffer); - bindings.sendBlob(socket, new Blob(["CD"])); - }); - socket.addEventListener("message", ({ data }) => { - payloads.push(typeof data === "string" ? data : Array.from(new Uint8Array(data)).join(",")); - if (payloads.length === 3) { - socket.close(); - resolve(payloads); - } +test("WebSocket binding sends text, ArrayBuffer, and Blob payloads through a native local socket", async ({ + page, +}) => { + const server = await startWebSocketServer(); + try { + await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + const received = await page.evaluate(async (port) => { + const bindings = await import("/tests/browser/WebSocketBindings.js"); + return new Promise((resolve, reject) => { + const socket = bindings.connect(`ws://127.0.0.1:${port}/binding-echo`); + const payloads = []; + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => { + bindings.sendString(socket, "text"); + bindings.sendArrayBuffer(socket, new Uint8Array([65, 66]).buffer); + bindings.sendBlob(socket, new Blob(["CD"])); + }); + socket.addEventListener("message", ({ data }) => { + payloads.push( + typeof data === "string" ? data : Array.from(new Uint8Array(data)).join(","), + ); + if (payloads.length === 3) { + socket.close(); + resolve(payloads); + } + }); + socket.addEventListener("error", () => reject(new Error("WebSocket echo failed"))); }); - socket.addEventListener("error", () => reject(new Error("WebSocket echo failed"))); - }); - }); + }, server.port); - expect(received).toEqual(["text", "65,66", "67,68"]); + expect(received).toEqual(["text", "65,66", "67,68"]); + } finally { + server.child.kill(); + await once(server.child, "exit"); + } }); diff --git a/tests/browser/websocket-server.mjs b/tests/browser/websocket-server.mjs new file mode 100644 index 00000000..0ec32460 --- /dev/null +++ b/tests/browser/websocket-server.mjs @@ -0,0 +1,18 @@ +import { createServer } from "node:http"; +import { WebSocketServer } from "ws"; + +const server = createServer(); +const webSocketServer = new WebSocketServer({ server }); + +webSocketServer.on("connection", (socket) => { + socket.on("message", (message, isBinary) => { + socket.send(message, { binary: isBinary }); + }); +}); + +server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address !== null && typeof address !== "string") { + process.stdout.write(`${address.port}\n`); + } +}); diff --git a/tests/browser/worker-fetch-body.txt b/tests/browser/worker-fetch-body.txt deleted file mode 100644 index f10e3a8b..00000000 --- a/tests/browser/worker-fetch-body.txt +++ /dev/null @@ -1 +0,0 @@ -worker-fetch-body diff --git a/tests/browser/worker-fetch.mjs b/tests/browser/worker-fetch.mjs index 6b53fd8d..0f8765ab 100644 --- a/tests/browser/worker-fetch.mjs +++ b/tests/browser/worker-fetch.mjs @@ -1,9 +1,13 @@ import * as WorkerFetchBinding from "./WorkerFetchBinding.js"; -self.addEventListener("message", async () => { +self.addEventListener("message", async (event) => { try { - const request = new Request(new URL("./worker-fetch-body.txt", self.location.href)); - const response = await WorkerFetchBinding.fetchWithRequest(request); + const request = new Request(event.data); + const response = await WorkerFetchBinding.fetchWithRequest(request, { + method: "POST", + headers: { "X-Binding-Contracts": "forwarded" }, + body: "binding-request-body", + }); self.postMessage(await WorkerFetchBinding.text(response)); } catch (error) { self.postMessage({ error: String(error) }); diff --git a/tests/browser/worker-fetch.spec.mjs b/tests/browser/worker-fetch.spec.mjs new file mode 100644 index 00000000..cbd37996 --- /dev/null +++ b/tests/browser/worker-fetch.spec.mjs @@ -0,0 +1,61 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { expect, test } from "@playwright/test"; + +const startWorkerHttpServer = () => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["tests/browser/worker-http-server.mjs"], { + stdio: ["ignore", "pipe", "inherit"], + }); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + const port = Number(output.trim()); + if (Number.isInteger(port) && port > 0) { + resolve({ child, port }); + } + }); + child.once("error", reject); + child.once("exit", (code) => { + if (output.length === 0) { + reject(new Error(`Worker HTTP server exited before startup with code ${code}`)); + } + }); + }); + +test("Worker Request fetch forwards its receiver, request, and initializer", async ({ page }) => { + const server = await startWorkerHttpServer(); + try { + await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + const response = await page.evaluate( + (port) => + new Promise((resolve, reject) => { + const worker = new Worker("/tests/browser/worker-fetch.mjs", { type: "module" }); + worker.addEventListener( + "message", + (event) => { + worker.terminate(); + resolve(event.data); + }, + { once: true }, + ); + worker.addEventListener( + "error", + (event) => { + worker.terminate(); + reject(new Error(event.message)); + }, + { once: true }, + ); + worker.postMessage(`http://127.0.0.1:${port}/echo`); + }), + server.port, + ); + + expect(response).toBe("POST|forwarded|binding-request-body"); + } finally { + server.child.kill(); + await once(server.child, "exit"); + } +}); diff --git a/tests/browser/worker-http-server.mjs b/tests/browser/worker-http-server.mjs new file mode 100644 index 00000000..d0a52069 --- /dev/null +++ b/tests/browser/worker-http-server.mjs @@ -0,0 +1,27 @@ +import { createServer } from "node:http"; + +const server = createServer(async (request, response) => { + response.setHeader("access-control-allow-origin", "*"); + response.setHeader("access-control-allow-headers", "content-type, x-binding-contracts"); + response.setHeader("access-control-allow-methods", "POST, OPTIONS"); + if (request.method === "OPTIONS") { + response.writeHead(204).end(); + return; + } + + const body = []; + for await (const chunk of request) { + body.push(chunk); + } + response.writeHead(200, { "content-type": "text/plain" }); + response.end( + `${request.method}|${request.headers["x-binding-contracts"] ?? ""}|${Buffer.concat(body).toString()}`, + ); +}); + +server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address !== null && typeof address !== "string") { + process.stdout.write(`${address.port}\n`); + } +}); From d938c719b016e439aeeec3da2d185dcd0b4e714f Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 10:51:34 -0500 Subject: [PATCH 08/15] test(bindings): isolate local browser servers --- playwright.config.mjs | 11 +++++++++-- tests/browser/native-api.spec.mjs | 4 ++-- tests/browser/server.mjs | 10 ++++++++-- tests/browser/websocket-bindings.spec.mjs | 2 +- tests/browser/worker-fetch.spec.mjs | 2 +- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/playwright.config.mjs b/playwright.config.mjs index 66e6430e..a8dccc78 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -1,5 +1,11 @@ import { defineConfig } from "@playwright/test"; +const testPort = process.env.WEBAPI_TEST_PORT ?? "4173"; +if (!/^\d+$/.test(testPort) || Number(testPort) < 1 || Number(testPort) > 65535) { + throw new Error("WEBAPI_TEST_PORT must be an integer from 1 to 65535."); +} +const testOrigin = `http://127.0.0.1:${testPort}`; + export default defineConfig({ testDir: "./tests/browser", testMatch: "**/*.spec.mjs", @@ -7,10 +13,11 @@ export default defineConfig({ reporter: "list", use: { browserName: "chromium", + baseURL: testOrigin, }, webServer: { command: "node tests/browser/server.mjs", - url: "http://127.0.0.1:4173", - reuseExistingServer: !process.env.CI, + url: `${testOrigin}/tests/browser/index.html`, + reuseExistingServer: false, }, }); diff --git a/tests/browser/native-api.spec.mjs b/tests/browser/native-api.spec.mjs index 0d3910c5..f1f519a4 100644 --- a/tests/browser/native-api.spec.mjs +++ b/tests/browser/native-api.spec.mjs @@ -9,7 +9,7 @@ const fixtures = JSON.parse(readFileSync(path.join(currentDirectory, "../fixture test("browser runtime fixtures execute in Chromium", async ({ page }) => { const errors = []; page.on("pageerror", (error) => errors.push({ message: error.message, stack: error.stack })); - await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + await page.goto("/tests/browser/index.html"); const results = await page.evaluate(async (paths) => { const { describeError } = await import("/tests/browser/describe-error.mjs"); const outcomes = []; @@ -30,7 +30,7 @@ test("browser runtime fixtures execute in Chromium", async ({ page }) => { }); test("browser error details preserve BigInt and cyclic ReScript failures", async ({ page }) => { - await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + await page.goto("/tests/browser/index.html"); const failures = await page.evaluate(async () => { const { describeError } = await import("/tests/browser/describe-error.mjs"); const bigIntFailure = describeError({ diff --git a/tests/browser/server.mjs b/tests/browser/server.mjs index 8f7c14b9..7a593a13 100644 --- a/tests/browser/server.mjs +++ b/tests/browser/server.mjs @@ -10,7 +10,7 @@ const contentTypes = new Map([ [".mjs", "text/javascript"], ]); -createServer(async (request, response) => { +const server = createServer(async (request, response) => { const pathname = new URL(request.url ?? "/", "http://localhost").pathname; const file = path.resolve(root, `.${decodeURIComponent(pathname)}`); if (!file.startsWith(`${root}${path.sep}`)) { @@ -26,4 +26,10 @@ createServer(async (request, response) => { } catch { response.writeHead(404).end(); } -}).listen(4173, "127.0.0.1"); +}); + +const port = Number(process.env.WEBAPI_TEST_PORT ?? "4173"); +if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("WEBAPI_TEST_PORT must be an integer from 1 to 65535."); +} +server.listen(port, "127.0.0.1"); diff --git a/tests/browser/websocket-bindings.spec.mjs b/tests/browser/websocket-bindings.spec.mjs index 2dbf6105..5b771f45 100644 --- a/tests/browser/websocket-bindings.spec.mjs +++ b/tests/browser/websocket-bindings.spec.mjs @@ -29,7 +29,7 @@ test("WebSocket binding sends text, ArrayBuffer, and Blob payloads through a nat }) => { const server = await startWebSocketServer(); try { - await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + await page.goto("/tests/browser/index.html"); const received = await page.evaluate(async (port) => { const bindings = await import("/tests/browser/WebSocketBindings.js"); return new Promise((resolve, reject) => { diff --git a/tests/browser/worker-fetch.spec.mjs b/tests/browser/worker-fetch.spec.mjs index cbd37996..bc77d27e 100644 --- a/tests/browser/worker-fetch.spec.mjs +++ b/tests/browser/worker-fetch.spec.mjs @@ -27,7 +27,7 @@ const startWorkerHttpServer = () => test("Worker Request fetch forwards its receiver, request, and initializer", async ({ page }) => { const server = await startWorkerHttpServer(); try { - await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + await page.goto("/tests/browser/index.html"); const response = await page.evaluate( (port) => new Promise((resolve, reject) => { From 40d9e73105db97abb74387e5627e8305ddd60b9d Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:03:52 -0500 Subject: [PATCH 09/15] test(bindings): isolate operation-specific WebCrypto results --- .../negative/IDBDeleteResultIsNotDatabase.res | 2 +- .../negative/WebCryptoDigestReturnsBinary.res | 6 ++++ ...bCryptoExportKeyFormatHasUnknownResult.res | 12 ++++++++ .../WebCryptoGenerateKeyDoesNotReturnJSON.res | 8 +++++ .../negative/WebCryptoSignReturnsBinary.res | 14 +++++++++ .../WebCryptoVerifyReturnsBoolean.res | 20 +++++++++++++ scripts/negative-fixtures.json | 30 +++++++++++++++++++ tests/browser/WorkerFetchBinding.res | 3 ++ tests/browser/worker-fetch.mjs | 14 +++++---- tests/browser/worker-fetch.spec.mjs | 20 +++++++------ 10 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res create mode 100644 scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res create mode 100644 scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res create mode 100644 scripts/fixtures/negative/WebCryptoSignReturnsBinary.res create mode 100644 scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res diff --git a/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res b/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res index 05870380..33d703f1 100644 --- a/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res +++ b/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res @@ -2,4 +2,4 @@ external indexedDB: WebAPI.IndexedDbTypes.idbFactory = "indexedDB" let request = WebAPI.IDBFactory.deleteDatabase(indexedDB, "compile-only") -let _database: string = request.result +let _close = request.result->WebAPI.IDBDatabase.close diff --git a/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res new file mode 100644 index 00000000..7149be79 --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res @@ -0,0 +1,6 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let data = + WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) +let _digest: JSON.t = await crypto.subtle->WebAPI.SubtleCrypto.digest(~algorithm={name: "SHA-256"}, ~data) diff --git a/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res b/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res new file mode 100644 index 00000000..a2e582d6 --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res @@ -0,0 +1,12 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let key = await WebAPI.SubtleCrypto.generateKeySymmetric( + crypto.subtle, + ~hash={name: "SHA-256"}, + ~length=256, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage], +) +let format: WebAPI.SubtleCrypto.keyFormat = %raw(`"raw"`) +let _export: JSON.t = await crypto.subtle->WebAPI.SubtleCrypto.exportKey(~format, ~key) diff --git a/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res b/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res new file mode 100644 index 00000000..3d7e0e18 --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res @@ -0,0 +1,8 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let _key: JSON.t = await crypto.subtle->WebAPI.SubtleCrypto.generateKey( + ~algorithm={name: "HMAC", hash: {name: "SHA-256"}, length: 256}, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage], +) diff --git a/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res b/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res new file mode 100644 index 00000000..aa68ce18 --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res @@ -0,0 +1,14 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let subtle = crypto.subtle +let data = + WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) +let key = await WebAPI.SubtleCrypto.generateKeySymmetric( + subtle, + ~hash={name: "SHA-256"}, + ~length=256, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.signUsage], +) +let _signature: JSON.t = await subtle->WebAPI.SubtleCrypto.sign(~algorithm={name: "HMAC"}, ~key, ~data) diff --git a/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res b/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res new file mode 100644 index 00000000..115acf6a --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res @@ -0,0 +1,20 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let subtle = crypto.subtle +let data = + WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) +let key = await WebAPI.SubtleCrypto.generateKeySymmetric( + subtle, + ~hash={name: "SHA-256"}, + ~length=256, + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.verifyUsage], +) +let signature: ArrayBuffer.t = %raw(`new ArrayBuffer(32)`) +let _verified: JSON.t = await subtle->WebAPI.SubtleCrypto.verify( + ~algorithm={name: "HMAC"}, + ~key, + ~signature=WebAPI.SubtleCrypto.binaryDataFromArrayBuffer(signature), + ~data, +) diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json index 571aebea..36957331 100644 --- a/scripts/negative-fixtures.json +++ b/scripts/negative-fixtures.json @@ -94,5 +94,35 @@ "fixture": "scripts/fixtures/negative/RawIsNotJwkKeyFormat.res", "features": ["WebAPI.Crypto"], "expectedDiagnostic": "belongs to the variant type" + }, + { + "name": "sign returns binary data, not JSON", + "fixture": "scripts/fixtures/negative/WebCryptoSignReturnsBinary.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "digest returns binary data, not JSON", + "fixture": "scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "verify returns boolean, not JSON", + "fixture": "scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "generic generateKey result is algorithm-dependent", + "fixture": "scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "generic exportKey result is format-dependent", + "fixture": "scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res", + "features": ["WebAPI.Crypto"], + "expectedDiagnostic": "This has type:" } ] diff --git a/tests/browser/WorkerFetchBinding.res b/tests/browser/WorkerFetchBinding.res index deff5654..c086af9c 100644 --- a/tests/browser/WorkerFetchBinding.res +++ b/tests/browser/WorkerFetchBinding.res @@ -1,4 +1,7 @@ let fetchWithRequest = (request: Request.t, init: Request.requestInit) => Worker.fetchWithRequest(Worker.current, request, ~init) +let fetchWithRequestDefault = (request: Request.t) => + Worker.fetchWithRequest(Worker.current, request) + let text = response => Response.text(response) diff --git a/tests/browser/worker-fetch.mjs b/tests/browser/worker-fetch.mjs index 0f8765ab..30410325 100644 --- a/tests/browser/worker-fetch.mjs +++ b/tests/browser/worker-fetch.mjs @@ -2,12 +2,14 @@ import * as WorkerFetchBinding from "./WorkerFetchBinding.js"; self.addEventListener("message", async (event) => { try { - const request = new Request(event.data); - const response = await WorkerFetchBinding.fetchWithRequest(request, { - method: "POST", - headers: { "X-Binding-Contracts": "forwarded" }, - body: "binding-request-body", - }); + const request = new Request(event.data.url); + const response = await (event.data.withInit + ? WorkerFetchBinding.fetchWithRequest(request, { + method: "POST", + headers: { "X-Binding-Contracts": "forwarded" }, + body: "binding-request-body", + }) + : WorkerFetchBinding.fetchWithRequestDefault(request)); self.postMessage(await WorkerFetchBinding.text(response)); } catch (error) { self.postMessage({ error: String(error) }); diff --git a/tests/browser/worker-fetch.spec.mjs b/tests/browser/worker-fetch.spec.mjs index bc77d27e..02da6a9c 100644 --- a/tests/browser/worker-fetch.spec.mjs +++ b/tests/browser/worker-fetch.spec.mjs @@ -32,14 +32,14 @@ test("Worker Request fetch forwards its receiver, request, and initializer", asy (port) => new Promise((resolve, reject) => { const worker = new Worker("/tests/browser/worker-fetch.mjs", { type: "module" }); - worker.addEventListener( - "message", - (event) => { + const responses = []; + worker.addEventListener("message", (event) => { + responses.push(event.data); + if (responses.length === 2) { worker.terminate(); - resolve(event.data); - }, - { once: true }, - ); + resolve(responses); + } + }); worker.addEventListener( "error", (event) => { @@ -48,12 +48,14 @@ test("Worker Request fetch forwards its receiver, request, and initializer", asy }, { once: true }, ); - worker.postMessage(`http://127.0.0.1:${port}/echo`); + const url = `http://127.0.0.1:${port}/echo`; + worker.postMessage({ url, withInit: true }); + worker.postMessage({ url, withInit: false }); }), server.port, ); - expect(response).toBe("POST|forwarded|binding-request-body"); + expect(response).toEqual(["POST|forwarded|binding-request-body", "GET||"]); } finally { server.child.kill(); await once(server.child, "exit"); From 5a5d2d65dd6fcf36ac59b7f076aff948befcbfc0 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:16:33 -0500 Subject: [PATCH 10/15] fix(crypto): accept native algorithm parameters --- scripts/consumer-crypto.res | 37 ++++++++-- .../negative/GeneratePairRejectsAlgorithm.res | 6 +- .../GenerateSymmetricRejectsAlgorithm.res | 4 +- .../negative/JwkIsNotBinaryKeyFormat.res | 2 +- .../negative/RawIsNotJwkKeyFormat.res | 2 +- .../negative/WebCryptoDigestReturnsBinary.res | 11 ++- ...bCryptoExportKeyFormatHasUnknownResult.res | 17 ++--- .../WebCryptoGenerateKeyDoesNotReturnJSON.res | 11 +-- .../negative/WebCryptoSignReturnsBinary.res | 17 ++--- .../WebCryptoVerifyReturnsBoolean.res | 27 ++------ src/crypto/SubtleCrypto.res | 37 +++++----- src/crypto/WebCryptoTypes.res | 20 +++++- tests/browser/BindingContracts__test.res | 67 +++++++++++++++++-- 13 files changed, 170 insertions(+), 88 deletions(-) diff --git a/scripts/consumer-crypto.res b/scripts/consumer-crypto.res index 2aeffa63..79153a66 100644 --- a/scripts/consumer-crypto.res +++ b/scripts/consumer-crypto.res @@ -5,20 +5,27 @@ let subtle = crypto.subtle let data = WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) -let digest: ArrayBuffer.t = await subtle->WebAPI.SubtleCrypto.digest(~algorithm={name: "SHA-256"}, ~data) +let digest: ArrayBuffer.t = await subtle->WebAPI.SubtleCrypto.digest( + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "SHA-256"}), + ~data, +) let digestBytes: Uint8Array.t = %raw(`new Uint8Array(digest)`) assert(%raw(`digestBytes instanceof Uint8Array && digestBytes.buffer === digest`)) let hmacKey: WebAPI.SubtleCrypto.cryptoKey = await subtle->WebAPI.SubtleCrypto.generateKeySymmetric( - ~hash={name: "SHA-256"}, + ~hash=WebAPI.SubtleCrypto.algorithmObject({name: "SHA-256"}), ~length=256, ~extractable=true, ~keyUsages=[WebAPI.SubtleCrypto.signUsage, WebAPI.SubtleCrypto.verifyUsage], ) -let signature = await subtle->WebAPI.SubtleCrypto.sign(~algorithm={name: "HMAC"}, ~key=hmacKey, ~data) +let signature = await subtle->WebAPI.SubtleCrypto.sign( + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "HMAC"}), + ~key=hmacKey, + ~data, +) let verified: bool = await subtle->WebAPI.SubtleCrypto.verify( - ~algorithm={name: "HMAC"}, + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "HMAC"}), ~key=hmacKey, ~signature=WebAPI.SubtleCrypto.binaryDataFromArrayBuffer(signature), ~data, @@ -40,14 +47,32 @@ let keyPair: WebAPI.SubtleCrypto.cryptoKeyPair = await subtle->WebAPI.SubtleCryp ) let pairSignature = await subtle->WebAPI.SubtleCrypto.sign( - ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "ECDSA", hash: {name: "SHA-256"}}), ~key=keyPair.privateKey, ~data, ) let pairVerified: bool = await subtle->WebAPI.SubtleCrypto.verify( - ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "ECDSA", hash: {name: "SHA-256"}}), ~key=keyPair.publicKey, ~signature=WebAPI.SubtleCrypto.binaryDataFromArrayBuffer(pairSignature), ~data, ) + +let aesKeyData = Uint8Array.fromArray([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, +])->WebAPI.SubtleCrypto.binaryDataFromUint8Array +let aesKey: WebAPI.SubtleCrypto.cryptoKey = await subtle->WebAPI.SubtleCrypto.importKey( + ~format=WebAPI.SubtleCrypto.rawKeyFormat, + ~keyData=aesKeyData, + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "AES-GCM", length: 128}), + ~extractable=true, + ~keyUsages=[WebAPI.SubtleCrypto.encryptUsage, WebAPI.SubtleCrypto.decryptUsage], +) +let aesIv = Uint8Array.fromArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + ->WebAPI.SubtleCrypto.binaryDataFromUint8Array +let aesAlgorithm = WebAPI.SubtleCrypto.algorithmObject({name: "AES-GCM", iv: aesIv, tagLength: 128}) +let ciphertext: ArrayBuffer.t = + await subtle->WebAPI.SubtleCrypto.encrypt(~algorithm=aesAlgorithm, ~key=aesKey, ~data) +let plaintext: ArrayBuffer.t = + await subtle->WebAPI.SubtleCrypto.decrypt(~algorithm=aesAlgorithm, ~key=aesKey, ~data=WebAPI.SubtleCrypto.binaryDataFromArrayBuffer(ciphertext)) diff --git a/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res b/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res index bdba253f..9ed7654a 100644 --- a/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res +++ b/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res @@ -3,7 +3,11 @@ external crypto: WebAPI.Crypto.t = "crypto" let _pair = WebAPI.SubtleCrypto.generateEcdsaKeyPair( crypto.subtle, - ~algorithm={name: "HMAC", hash: {name: "SHA-256"}, length: 256}, + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({ + name: "HMAC", + hash: {name: "SHA-256"}, + length: 256, + }), ~namedCurve="P-256", ~extractable=true, ~keyUsages=[WebAPI.SubtleCrypto.signUsage], diff --git a/scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res b/scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res index a6046b49..cfc964a3 100644 --- a/scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res +++ b/scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.res @@ -3,8 +3,8 @@ external crypto: WebAPI.Crypto.t = "crypto" let _key = WebAPI.SubtleCrypto.generateKeySymmetric( crypto.subtle, - ~algorithm={name: "ECDSA", namedCurve: "P-256"}, - ~hash={name: "SHA-256"}, + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "ECDSA", namedCurve: "P-256"}), + ~hash=WebAPI.SubtleCrypto.algorithmObject({name: "SHA-256"}), ~length=256, ~extractable=true, ~keyUsages=[WebAPI.SubtleCrypto.signUsage], diff --git a/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res b/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res index bd667d71..82bcdd43 100644 --- a/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res +++ b/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res @@ -3,7 +3,7 @@ external crypto: WebAPI.Crypto.t = "crypto" let key = await WebAPI.SubtleCrypto.generateKeySymmetric( crypto.subtle, - ~hash={name: "SHA-256"}, + ~hash=WebAPI.SubtleCrypto.algorithmObject({name: "SHA-256"}), ~length=256, ~extractable=true, ~keyUsages=[WebAPI.SubtleCrypto.signUsage], diff --git a/scripts/fixtures/negative/RawIsNotJwkKeyFormat.res b/scripts/fixtures/negative/RawIsNotJwkKeyFormat.res index ec0aea6b..0a55cea4 100644 --- a/scripts/fixtures/negative/RawIsNotJwkKeyFormat.res +++ b/scripts/fixtures/negative/RawIsNotJwkKeyFormat.res @@ -3,7 +3,7 @@ external crypto: WebAPI.Crypto.t = "crypto" let key = await WebAPI.SubtleCrypto.generateKeySymmetric( crypto.subtle, - ~hash={name: "SHA-256"}, + ~hash=WebAPI.SubtleCrypto.algorithmObject({name: "SHA-256"}), ~length=256, ~extractable=true, ~keyUsages=[WebAPI.SubtleCrypto.signUsage], diff --git a/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res index 7149be79..8a33cd42 100644 --- a/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res +++ b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res @@ -1,6 +1,5 @@ -@scope("globalThis") -external crypto: WebAPI.Crypto.t = "crypto" - -let data = - WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) -let _digest: JSON.t = await crypto.subtle->WebAPI.SubtleCrypto.digest(~algorithm={name: "SHA-256"}, ~data) +let _digestReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, +) => promise = WebAPI.SubtleCrypto.digest diff --git a/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res b/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res index a2e582d6..1dbfaeb6 100644 --- a/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res +++ b/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res @@ -1,12 +1,5 @@ -@scope("globalThis") -external crypto: WebAPI.Crypto.t = "crypto" - -let key = await WebAPI.SubtleCrypto.generateKeySymmetric( - crypto.subtle, - ~hash={name: "SHA-256"}, - ~length=256, - ~extractable=true, - ~keyUsages=[WebAPI.SubtleCrypto.signUsage], -) -let format: WebAPI.SubtleCrypto.keyFormat = %raw(`"raw"`) -let _export: JSON.t = await crypto.subtle->WebAPI.SubtleCrypto.exportKey(~format, ~key) +let _exportReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~format: WebAPI.WebCryptoTypes.keyFormat, + ~key: WebAPI.WebCryptoTypes.cryptoKey, +) => promise = WebAPI.SubtleCrypto.exportKey diff --git a/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res b/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res index 3d7e0e18..b05c1fc7 100644 --- a/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res +++ b/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res @@ -1,8 +1,9 @@ @scope("globalThis") external crypto: WebAPI.Crypto.t = "crypto" -let _key: JSON.t = await crypto.subtle->WebAPI.SubtleCrypto.generateKey( - ~algorithm={name: "HMAC", hash: {name: "SHA-256"}, length: 256}, - ~extractable=true, - ~keyUsages=[WebAPI.SubtleCrypto.signUsage], -) +let _generateKeyReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~extractable: bool, + ~keyUsages: array, +) => promise = WebAPI.SubtleCrypto.generateKey diff --git a/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res b/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res index aa68ce18..ab8c2684 100644 --- a/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res +++ b/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res @@ -1,14 +1,9 @@ @scope("globalThis") external crypto: WebAPI.Crypto.t = "crypto" -let subtle = crypto.subtle -let data = - WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) -let key = await WebAPI.SubtleCrypto.generateKeySymmetric( - subtle, - ~hash={name: "SHA-256"}, - ~length=256, - ~extractable=true, - ~keyUsages=[WebAPI.SubtleCrypto.signUsage], -) -let _signature: JSON.t = await subtle->WebAPI.SubtleCrypto.sign(~algorithm={name: "HMAC"}, ~key, ~data) +let _signReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~key: WebAPI.WebCryptoTypes.cryptoKey, + ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, +) => promise = WebAPI.SubtleCrypto.sign diff --git a/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res b/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res index 115acf6a..bf12bf50 100644 --- a/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res +++ b/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res @@ -1,20 +1,7 @@ -@scope("globalThis") -external crypto: WebAPI.Crypto.t = "crypto" - -let subtle = crypto.subtle -let data = - WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67])) -let key = await WebAPI.SubtleCrypto.generateKeySymmetric( - subtle, - ~hash={name: "SHA-256"}, - ~length=256, - ~extractable=true, - ~keyUsages=[WebAPI.SubtleCrypto.verifyUsage], -) -let signature: ArrayBuffer.t = %raw(`new ArrayBuffer(32)`) -let _verified: JSON.t = await subtle->WebAPI.SubtleCrypto.verify( - ~algorithm={name: "HMAC"}, - ~key, - ~signature=WebAPI.SubtleCrypto.binaryDataFromArrayBuffer(signature), - ~data, -) +let _verifyReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~key: WebAPI.WebCryptoTypes.cryptoKey, + ~signature: WebAPI.ArrayBufferTypedArrayOrDataView.t, + ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, +) => promise = WebAPI.SubtleCrypto.verify diff --git a/src/crypto/SubtleCrypto.res b/src/crypto/SubtleCrypto.res index 95ed5931..9d8f29ad 100644 --- a/src/crypto/SubtleCrypto.res +++ b/src/crypto/SubtleCrypto.res @@ -3,24 +3,27 @@ type binaryData = ArrayBufferTypedArrayOrDataView.t type keyType = WebCryptoTypes.keyType type keyUsage = WebCryptoTypes.keyUsage type keyFormat = WebCryptoTypes.keyFormat +type algorithmIdentifier = WebCryptoTypes.algorithmIdentifier +type algorithm = WebCryptoTypes.algorithm type binaryKeyFormat = | @as("raw") Raw | @as("pkcs8") Pkcs8 | @as("spki") Spki type jwkKeyFormat = | @as("jwk") Jwk type keyAlgorithm = WebCryptoTypes.keyAlgorithm -type algorithmIdentifier = WebCryptoTypes.algorithmIdentifier -type algorithm = WebCryptoTypes.algorithm type cryptoKey = WebCryptoTypes.cryptoKey type cryptoKeyPair = WebCryptoTypes.cryptoKeyPair +external algorithmString: string => algorithmIdentifier = "%identity" +external algorithmObject: algorithm => algorithmIdentifier = "%identity" + /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/encrypt) */ @send external encrypt: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "encrypt" @@ -31,7 +34,7 @@ external encrypt: ( @send external decrypt: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "decrypt" @@ -42,7 +45,7 @@ external decrypt: ( @send external sign: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "sign" @@ -53,7 +56,7 @@ external sign: ( @send external verify: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~key: cryptoKey, ~signature: ArrayBufferTypedArrayOrDataView.t, ~data: ArrayBufferTypedArrayOrDataView.t, @@ -65,7 +68,7 @@ external verify: ( @send external digest: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "digest" @@ -75,7 +78,7 @@ external digest: ( @send external generateKey: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~extractable: bool, ~keyUsages: array, ) => promise = "generateKey" @@ -121,9 +124,9 @@ let generateEcdsaKeyPair = ( @send external deriveKey: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~baseKey: cryptoKey, - ~derivedKeyType: algorithm, + ~derivedKeyType: algorithmIdentifier, ~extractable: bool, ~keyUsages: array, ) => promise = "deriveKey" @@ -134,7 +137,7 @@ external deriveKey: ( @send external deriveBits: ( t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~baseKey: cryptoKey, ~length: int=?, ) => promise = "deriveBits" @@ -158,7 +161,7 @@ external importKey: ( t, ~format: keyFormat, ~keyData: ArrayBufferTypedArrayOrDataView.t, - ~algorithm: algorithm, + ~algorithm: algorithmIdentifier, ~extractable: bool, ~keyUsages: array, ) => promise = "importKey" @@ -178,7 +181,7 @@ external wrapKey: ( ~format: keyFormat, ~key: cryptoKey, ~wrappingKey: cryptoKey, - ~wrapAlgorithm: algorithm, + ~wrapAlgorithm: algorithmIdentifier, ) => promise = "wrapKey" /** @@ -202,8 +205,8 @@ external unwrapKey: ( ~format: keyFormat, ~wrappedKey: ArrayBufferTypedArrayOrDataView.t, ~unwrappingKey: cryptoKey, - ~unwrapAlgorithm: algorithm, - ~unwrappedKeyAlgorithm: algorithm, + ~unwrapAlgorithm: algorithmIdentifier, + ~unwrappedKeyAlgorithm: algorithmIdentifier, ~extractable: bool, ~keyUsages: array, ) => promise = "unwrapKey" @@ -212,6 +215,10 @@ let decryptUsage = WebCryptoTypes.Decrypt let deriveBitsUsage = WebCryptoTypes.DeriveBits let deriveKeyUsage = WebCryptoTypes.DeriveKey let encryptUsage = WebCryptoTypes.Encrypt +let rawKeyFormat = WebCryptoTypes.Raw +let pkcs8KeyFormat = WebCryptoTypes.Pkcs8 +let spkiKeyFormat = WebCryptoTypes.Spki +let jwkFormat = WebCryptoTypes.Jwk let signUsage = WebCryptoTypes.Sign let unwrapKeyUsage = WebCryptoTypes.UnwrapKey let verifyUsage = WebCryptoTypes.Verify diff --git a/src/crypto/WebCryptoTypes.res b/src/crypto/WebCryptoTypes.res index 92d778a2..17565a5b 100644 --- a/src/crypto/WebCryptoTypes.res +++ b/src/crypto/WebCryptoTypes.res @@ -71,11 +71,27 @@ type cryptoKeyPair = { publicKey: cryptoKey, } -type algorithmIdentifier = {name: string} +type algorithmIdentifier = unknown type algorithm = { mutable name: string, - mutable hash?: algorithmIdentifier, + mutable hash?: keyAlgorithm, mutable length?: int, mutable namedCurve?: string, + mutable iv?: ArrayBufferTypedArrayOrDataView.t, + mutable counter?: ArrayBufferTypedArrayOrDataView.t, + mutable additionalData?: ArrayBufferTypedArrayOrDataView.t, + mutable tagLength?: int, + mutable salt?: ArrayBufferTypedArrayOrDataView.t, + mutable iterations?: int, + mutable info?: ArrayBufferTypedArrayOrDataView.t, + @as("public") + mutable publicKey?: cryptoKey, + mutable saltLength?: int, + mutable modulusLength?: int, + mutable publicExponent?: Uint8Array.t, + mutable primeLength?: int, + mutable subprimeLength?: int, + mutable generator?: ArrayBufferTypedArrayOrDataView.t, + mutable label?: ArrayBufferTypedArrayOrDataView.t, } diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 4a5495d4..a6dd0c42 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -245,7 +245,7 @@ target->Element.remove let subtle = crypto.subtle let digest = await subtle->SubtleCrypto.digest( - ~algorithm={name: "SHA-256"}, + ~algorithm=SubtleCrypto.algorithmObject({name: "SHA-256"}), ~data=Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array, ) let digestBytes: Uint8Array.t = %raw(`new Uint8Array(digest)`) @@ -253,15 +253,19 @@ assert(%raw(`digest.byteLength === 32 && digestBytes instanceof Uint8Array && di let data = Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array let hmacKey = await subtle->SubtleCrypto.generateKeySymmetric( - ~hash={name: "SHA-256"}, + ~hash=SubtleCrypto.algorithmObject({name: "SHA-256"}), ~length=256, ~extractable=true, ~keyUsages=[SubtleCrypto.signUsage, SubtleCrypto.verifyUsage], ) let _publicHmacKey: SubtleCrypto.cryptoKey = hmacKey -let signature = await subtle->SubtleCrypto.sign(~algorithm={name: "HMAC"}, ~key=hmacKey, ~data) +let signature = await subtle->SubtleCrypto.sign( + ~algorithm=SubtleCrypto.algorithmObject({name: "HMAC"}), + ~key=hmacKey, + ~data, +) let verified = await subtle->SubtleCrypto.verify( - ~algorithm={name: "HMAC"}, + ~algorithm=SubtleCrypto.algorithmObject({name: "HMAC"}), ~key=hmacKey, ~signature=SubtleCrypto.binaryDataFromArrayBuffer(signature), ~data, @@ -279,13 +283,64 @@ let keyPair = await subtle->SubtleCrypto.generateEcdsaKeyPair( ) let _publicKeyPair: SubtleCrypto.cryptoKeyPair = keyPair let pairSignature = await subtle->SubtleCrypto.sign( - ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~algorithm=SubtleCrypto.algorithmObject({name: "ECDSA", hash: {name: "SHA-256"}}), ~key=keyPair.privateKey, ~data, ) assert(await subtle->SubtleCrypto.verify( - ~algorithm={name: "ECDSA", hash: {name: "SHA-256"}}, + ~algorithm=SubtleCrypto.algorithmObject({name: "ECDSA", hash: {name: "SHA-256"}}), ~key=keyPair.publicKey, ~signature=SubtleCrypto.binaryDataFromArrayBuffer(pairSignature), ~data, )) + +let aesKeyData = + Uint8Array.fromArray([ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + ])->SubtleCrypto.binaryDataFromUint8Array +let aesKey = await subtle->SubtleCrypto.importKey( + ~format=SubtleCrypto.rawKeyFormat, + ~keyData=aesKeyData, + ~algorithm=SubtleCrypto.algorithmObject({name: "AES-GCM", length: 128}), + ~extractable=true, + ~keyUsages=[SubtleCrypto.encryptUsage, SubtleCrypto.decryptUsage], +) +let aesIv = + Uint8Array.fromArray([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + ])->SubtleCrypto.binaryDataFromUint8Array +let aesAlgorithm = SubtleCrypto.algorithmObject({name: "AES-GCM", iv: aesIv, tagLength: 128}) +let ciphertext = await subtle->SubtleCrypto.encrypt(~algorithm=aesAlgorithm, ~key=aesKey, ~data) +let plaintext = await subtle->SubtleCrypto.decrypt( + ~algorithm=aesAlgorithm, + ~key=aesKey, + ~data=SubtleCrypto.binaryDataFromArrayBuffer(ciphertext), +) +let plaintextBytes: Uint8Array.t = %raw(`new Uint8Array(plaintext)`) +assert(isUint8ArrayWithABC(plaintextBytes)) From f5f7d338794ffd74dcc32f69d238e0059d611cbc Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:17:20 -0500 Subject: [PATCH 11/15] docs(testing): explain isolated browser ports --- docs/content/docs/contributing/testing.mdx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/content/docs/contributing/testing.mdx b/docs/content/docs/contributing/testing.mdx index 18c63947..29571fb5 100644 --- a/docs/content/docs/contributing/testing.mdx +++ b/docs/content/docs/contributing/testing.mdx @@ -52,6 +52,17 @@ Browser fixtures with asynchronous behavior must await all assertions and callba the ReScript module finishes evaluating; the browser runner awaits module evaluation and treats rejections and uncaught page errors as failures. +The local browser runner starts its own server on port `4173` by default and never reuses an existing +server. If another worktree or local service already uses that port, choose a free port with +`WEBAPI_TEST_PORT`; the same value configures both the test server and browser base URL: + +```shell +WEBAPI_TEST_PORT=4192 npm run test:browser +``` + +Invalid port values fail before the runner starts, and an occupied port fails instead of attaching +to potentially stale generated files from another checkout. + Put expected-failure consumer sources under `scripts/fixtures/negative/`, outside the recursively compiled `tests/` tree. Register each in `scripts/negative-fixtures.json` with its feature set and a distinctive compiler diagnostic substring. The negative command must observe both compilation failure From dd9f1c4005b50af4a6cd39ad419de52fc47946e6 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:23:45 -0500 Subject: [PATCH 12/15] test(bindings): make final regression proofs discriminating --- .../negative/WebCryptoDigestReturnsBinary.res | 2 +- .../WebCryptoGenerateKeyDoesNotReturnJSON.res | 2 +- .../negative/WebCryptoSignReturnsBinary.res | 2 +- .../WebCryptoVerifyReturnsBoolean.res | 2 +- tests/browser/BindingContracts__test.res | 7 ++++--- tests/browser/worker-fetch.mjs | 7 +++++-- tests/browser/worker-fetch.spec.mjs | 20 +++++++++++++------ 7 files changed, 27 insertions(+), 15 deletions(-) diff --git a/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res index 8a33cd42..e1239bad 100644 --- a/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res +++ b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res @@ -1,5 +1,5 @@ let _digestReturnedJson: ( WebAPI.WebCryptoTypes.subtleCrypto, - ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~algorithm: WebAPI.WebCryptoTypes.algorithmIdentifier, ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, ) => promise = WebAPI.SubtleCrypto.digest diff --git a/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res b/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res index b05c1fc7..a8f53577 100644 --- a/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res +++ b/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res @@ -3,7 +3,7 @@ external crypto: WebAPI.Crypto.t = "crypto" let _generateKeyReturnedJson: ( WebAPI.WebCryptoTypes.subtleCrypto, - ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~algorithm: WebAPI.WebCryptoTypes.algorithmIdentifier, ~extractable: bool, ~keyUsages: array, ) => promise = WebAPI.SubtleCrypto.generateKey diff --git a/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res b/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res index ab8c2684..8f648778 100644 --- a/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res +++ b/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res @@ -3,7 +3,7 @@ external crypto: WebAPI.Crypto.t = "crypto" let _signReturnedJson: ( WebAPI.WebCryptoTypes.subtleCrypto, - ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~algorithm: WebAPI.WebCryptoTypes.algorithmIdentifier, ~key: WebAPI.WebCryptoTypes.cryptoKey, ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, ) => promise = WebAPI.SubtleCrypto.sign diff --git a/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res b/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res index bf12bf50..2a8c980f 100644 --- a/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res +++ b/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res @@ -1,6 +1,6 @@ let _verifyReturnedJson: ( WebAPI.WebCryptoTypes.subtleCrypto, - ~algorithm: WebAPI.WebCryptoTypes.algorithm, + ~algorithm: WebAPI.WebCryptoTypes.algorithmIdentifier, ~key: WebAPI.WebCryptoTypes.cryptoKey, ~signature: WebAPI.ArrayBufferTypedArrayOrDataView.t, ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index a6dd0c42..9a9716e5 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -33,6 +33,8 @@ let setSize = (_target: DOMTree.element, _pixels: int): unit => let isNull = _value => %raw(`_value === null`) let isUndefined = _value => %raw(`_value === undefined`) +let isKnownNotificationPermission = _permission => + %raw(`_permission === "default" || _permission === "denied" || _permission === "granted"`) let containsABCByteChunk = _result => %raw(`_result.value instanceof Uint8Array && _result.value.length === 3 && _result.value[0] === 65 && _result.value[2] === 67`) let appendChild = (_parent, _child) => %raw(`_parent.appendChild(_child)`) @@ -73,9 +75,8 @@ let audio: HTMLAudioElement.t = %raw(`document.createElement("audio")`) assert(audio->HTMLAudioElement.canPlayType("application/x-webapi-unsupported") == DomHTMLMediaElement.Empty) -switch Notification.permission { -| NotificationTypes.Default | NotificationTypes.Denied | NotificationTypes.Granted => () -} +let permission = Notification.permission +assert(isKnownNotificationPermission(permission)) switch Document.createElement(document, "canvas")->HTMLCanvasElement.classify { | Some(canvas) => { diff --git a/tests/browser/worker-fetch.mjs b/tests/browser/worker-fetch.mjs index 30410325..381e7ba6 100644 --- a/tests/browser/worker-fetch.mjs +++ b/tests/browser/worker-fetch.mjs @@ -10,8 +10,11 @@ self.addEventListener("message", async (event) => { body: "binding-request-body", }) : WorkerFetchBinding.fetchWithRequestDefault(request)); - self.postMessage(await WorkerFetchBinding.text(response)); + self.postMessage({ + id: event.data.id, + response: await WorkerFetchBinding.text(response), + }); } catch (error) { - self.postMessage({ error: String(error) }); + self.postMessage({ id: event.data.id, error: String(error) }); } }); diff --git a/tests/browser/worker-fetch.spec.mjs b/tests/browser/worker-fetch.spec.mjs index 02da6a9c..8a02e14c 100644 --- a/tests/browser/worker-fetch.spec.mjs +++ b/tests/browser/worker-fetch.spec.mjs @@ -32,10 +32,15 @@ test("Worker Request fetch forwards its receiver, request, and initializer", asy (port) => new Promise((resolve, reject) => { const worker = new Worker("/tests/browser/worker-fetch.mjs", { type: "module" }); - const responses = []; + const responses = {}; worker.addEventListener("message", (event) => { - responses.push(event.data); - if (responses.length === 2) { + if (event.data.error !== undefined) { + worker.terminate(); + reject(new Error(event.data.error)); + return; + } + responses[event.data.id] = event.data.response; + if (Object.keys(responses).length === 2) { worker.terminate(); resolve(responses); } @@ -49,13 +54,16 @@ test("Worker Request fetch forwards its receiver, request, and initializer", asy { once: true }, ); const url = `http://127.0.0.1:${port}/echo`; - worker.postMessage({ url, withInit: true }); - worker.postMessage({ url, withInit: false }); + worker.postMessage({ id: "initialized", url, withInit: true }); + worker.postMessage({ id: "default", url, withInit: false }); }), server.port, ); - expect(response).toEqual(["POST|forwarded|binding-request-body", "GET||"]); + expect(response).toStrictEqual({ + initialized: "POST|forwarded|binding-request-body", + default: "GET||", + }); } finally { server.child.kill(); await once(server.child, "exit"); From 14f5873fc453e8d409244fd79a2afd71e9f25571 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:39:31 -0500 Subject: [PATCH 13/15] fix(fetch): require byte streams for body adapters --- .../negative/BodyInitRejectsStringStream.res | 2 ++ .../negative/ResponseRejectsStringStream.res | 2 ++ scripts/negative-fixtures.json | 12 ++++++++++++ src/fetch/BodyInit.res | 2 +- src/fetch/Response.res | 3 ++- tests/BindingByteTypes__test.res | 1 + tests/browser/BindingContracts__test.res | 5 +++++ 7 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 scripts/fixtures/negative/BodyInitRejectsStringStream.res create mode 100644 scripts/fixtures/negative/ResponseRejectsStringStream.res diff --git a/scripts/fixtures/negative/BodyInitRejectsStringStream.res b/scripts/fixtures/negative/BodyInitRejectsStringStream.res new file mode 100644 index 00000000..c05e140a --- /dev/null +++ b/scripts/fixtures/negative/BodyInitRejectsStringStream.res @@ -0,0 +1,2 @@ +let stream: WebAPI.ReadableStream.t = WebAPI.ReadableStream.make() +let _body = WebAPI.BodyInit.fromReadableStream(stream) diff --git a/scripts/fixtures/negative/ResponseRejectsStringStream.res b/scripts/fixtures/negative/ResponseRejectsStringStream.res new file mode 100644 index 00000000..e47c1fa3 --- /dev/null +++ b/scripts/fixtures/negative/ResponseRejectsStringStream.res @@ -0,0 +1,2 @@ +let stream: WebAPI.ReadableStream.t = WebAPI.ReadableStream.make() +let _response = WebAPI.Response.fromReadableStream(stream) diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json index 36957331..9eaffcbb 100644 --- a/scripts/negative-fixtures.json +++ b/scripts/negative-fixtures.json @@ -11,6 +11,18 @@ "features": ["WebAPI.Fetch"], "expectedDiagnostic": "This has type:" }, + { + "name": "BodyInit rejects non-byte stream chunks", + "fixture": "scripts/fixtures/negative/BodyInitRejectsStringStream.res", + "features": ["WebAPI.Fetch"], + "expectedDiagnostic": "This has type:" + }, + { + "name": "Response rejects non-byte stream chunks", + "fixture": "scripts/fixtures/negative/ResponseRejectsStringStream.res", + "features": ["WebAPI.Fetch"], + "expectedDiagnostic": "This has type:" + }, { "name": "closest cannot narrow to an unrelated element type", "fixture": "scripts/fixtures/negative/ClosestCannotNarrow.res", diff --git a/src/fetch/BodyInit.res b/src/fetch/BodyInit.res index 059213cb..2660eddd 100644 --- a/src/fetch/BodyInit.res +++ b/src/fetch/BodyInit.res @@ -43,4 +43,4 @@ external fromFormData: FormData.t => t = "%identity" /** [Read more on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#setting_a_body) */ -external fromReadableStream: ReadableStream.t<'t> => t = "%identity" +external fromReadableStream: ReadableStream.t => t = "%identity" diff --git a/src/fetch/Response.res b/src/fetch/Response.res index 006ad748..aa76cfcb 100644 --- a/src/fetch/Response.res +++ b/src/fetch/Response.res @@ -64,7 +64,8 @@ external fromFormData: (FormData.t, ~init: responseInit=?) => t = "Response" [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Response) */ @new -external fromReadableStream: (ReadableStream.t<'t>, ~init: responseInit=?) => t = "Response" +external fromReadableStream: (ReadableStream.t, ~init: responseInit=?) => t = + "Response" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) diff --git a/tests/BindingByteTypes__test.res b/tests/BindingByteTypes__test.res index ff4da9af..bfc1eb1c 100644 --- a/tests/BindingByteTypes__test.res +++ b/tests/BindingByteTypes__test.res @@ -2,6 +2,7 @@ let _requestBytes: promise = Request.fromURL("/")->Request.bytes let _responseBytes: promise = Response.fromString("")->Response.bytes let _blobBytes: promise = Blob.make()->Blob.bytes let _blobStream: ReadableStream.t = Blob.make()->Blob.stream +let _bodyInitFromByteStream: BodyInit.t = BodyInit.fromReadableStream(_blobStream) let _analyserFloat: ( WebAudioTypes.analyserNode, diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 9a9716e5..1b76ffca 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -70,6 +70,11 @@ let byteStreamReader = byteStream->ReadableStream.getReader let firstChunk = await byteStreamReader->readByteStreamChunk assert(containsABCByteChunk(firstChunk)) await byteStreamReader->cancelByteStreamReader +let responseFromByteStream = Response.fromReadableStream( + Blob.make(~blobParts=[String("ABC")])->Blob.stream, +) +let responseStreamBytes = await responseFromByteStream->Response.bytes +assert(isUint8ArrayWithABC(responseStreamBytes)) let audio: HTMLAudioElement.t = %raw(`document.createElement("audio")`) assert(audio->HTMLAudioElement.canPlayType("application/x-webapi-unsupported") == From 01aead35e04738093db0dd8fb9508ac4c1405b78 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:49:27 -0500 Subject: [PATCH 14/15] test(fetch): assert every Uint8Array stream byte --- tests/browser/BindingContracts__test.res | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 1b76ffca..450d0b1a 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -45,11 +45,14 @@ let onSuccess = (_request, _callback) => let releaseLock = () => %raw(`globalThis.__releaseBindingLock()`) let isUint8ArrayWithABC = _bytes => - %raw(`_bytes instanceof Uint8Array && _bytes.length === 3 && _bytes[0] === 65 && _bytes[2] === 67`) + %raw(`_bytes instanceof Uint8Array && _bytes.length === 3 && _bytes[0] === 65 && _bytes[1] === 66 && _bytes[2] === 67`) let isNonIdentityFloat32Matrix = _values => %raw(`_values instanceof Float32Array && _values.length === 16 && _values[0] === 2 && _values[5] === 3 && _values[12] === 4 && _values[13] === 5 && _values[15] === 1`) +let wrongMiddleBytes: Uint8Array.t = %raw(`new Uint8Array([65, 0, 67])`) +assert(!isUint8ArrayWithABC(wrongMiddleBytes)) + let bytes = await Response.fromString("ABC")->Response.bytes assert(isUint8ArrayWithABC(bytes)) From 0b4642539c9a41d9e2aa53c3278a9e6850d7a744 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:50:49 -0500 Subject: [PATCH 15/15] test(fetch): cover middle-byte stream mismatches --- tests/browser/BindingContracts__test.res | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/browser/BindingContracts__test.res b/tests/browser/BindingContracts__test.res index 450d0b1a..abc7d9b6 100644 --- a/tests/browser/BindingContracts__test.res +++ b/tests/browser/BindingContracts__test.res @@ -36,7 +36,7 @@ let isUndefined = _value => %raw(`_value === undefined`) let isKnownNotificationPermission = _permission => %raw(`_permission === "default" || _permission === "denied" || _permission === "granted"`) let containsABCByteChunk = _result => - %raw(`_result.value instanceof Uint8Array && _result.value.length === 3 && _result.value[0] === 65 && _result.value[2] === 67`) + %raw(`_result.value instanceof Uint8Array && _result.value.length === 3 && _result.value[0] === 65 && _result.value[1] === 66 && _result.value[2] === 67`) let appendChild = (_parent, _child) => %raw(`_parent.appendChild(_child)`) let appendToDocument = _target => %raw(`document.body.appendChild(_target)`) @@ -52,6 +52,8 @@ let isNonIdentityFloat32Matrix = _values => let wrongMiddleBytes: Uint8Array.t = %raw(`new Uint8Array([65, 0, 67])`) assert(!isUint8ArrayWithABC(wrongMiddleBytes)) +let wrongMiddleChunk: unknown = %raw(`({value: new Uint8Array([65, 0, 67])})`) +assert(!containsABCByteChunk(wrongMiddleChunk)) let bytes = await Response.fromString("ABC")->Response.bytes assert(isUint8ArrayWithABC(bytes))