From a0060e3c9352a69abb8ee8c2d2bda1743a235312 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 15:32:50 -0500 Subject: [PATCH 01/13] test: establish runtime and Chromium fixture harness --- .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 8 +- .gitignore | 3 + docs/content/docs/contributing/testing.mdx | 28 ++++-- package-lock.json | 103 +++++++++++++++++---- package.json | 14 ++- playwright.config.mjs | 16 ++++ tests/FetchAPI/Headers__test.res | 6 +- tests/FetchAPI/Request__test.res | 4 +- tests/FetchAPI/Response__test.res | 1 + tests/FetchAPI/URLSearchParams__test.res | 8 +- tests/URLAPI/URL__test.res | 3 + tests/browser/NativeAPI__test.res | 2 + tests/browser/index.html | 14 +++ tests/browser/native-api.spec.mjs | 18 ++++ tests/browser/server.mjs | 29 ++++++ tests/fixtures.json | 49 ++++++++++ tests/index.js | 55 ++++++----- 18 files changed, 299 insertions(+), 70 deletions(-) create mode 100644 playwright.config.mjs create mode 100644 tests/browser/NativeAPI__test.res create mode 100644 tests/browser/index.html create mode 100644 tests/browser/native-api.spec.mjs create mode 100644 tests/browser/server.mjs create mode 100644 tests/fixtures.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fb11b81..7b832494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main permissions: contents: read @@ -39,6 +37,12 @@ jobs: - name: Run tests run: npm test + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run browser tests + run: npm run test:browser + - name: Build documentation run: npm run build:docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95c14944..ab386f25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,6 @@ name: Release on: pull_request: - branches: - - main workflow_dispatch: inputs: dry_run: @@ -51,6 +49,12 @@ jobs: - name: Run tests run: npm test + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run browser tests + run: npm run test:browser + - name: Build documentation run: npm run build:docs diff --git a/.gitignore b/.gitignore index 24b4284b..c3ff32e6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ dist/ tmp/ docs/public/llm.txt +.playwright/ +/test-results/ +/playwright-report/ .npm-cache/ *.tgz *~ diff --git a/docs/content/docs/contributing/testing.mdx b/docs/content/docs/contributing/testing.mdx index a5e8eaf6..89721d83 100644 --- a/docs/content/docs/contributing/testing.mdx +++ b/docs/content/docs/contributing/testing.mdx @@ -4,14 +4,18 @@ description: Learn more about testing the bindings for @rescript/webapi. slug: "06-testing" --- -import { Aside, FileTree } from "@astrojs/starlight/components"; +import { FileTree } from "@astrojs/starlight/components"; Adding some unit tests is a great way to ensure that the bindings work as expected. It illustrates how the bindings are supposed to be used and can help to catch regressions. ## Testing the bindings -Create a new help in the `test` folder with the same name as the module you want to test, followed by `_test.res`. +Add a ReScript fixture under `tests/`, following the source module's directory structure. Register +each fixture in exactly one category in `tests/fixtures.json`: `compileOnly`, `nodeRuntime`, or +`browserRuntime`. `npm test` fails if a fixture is missing from the manifest or appears in multiple +categories, compiles all fixtures, and executes only the Node runtime modules. Compile-only +examples are never executed. @@ -21,7 +25,9 @@ Create a new help in the `test` folder with the same name as the module you want - HTMLCanvasElement.res - tests - DOMAPI - - HTMLCanvasElement_test.res + - Classify\_\_test.res + - FetchAPI + - Headers\_\_test.res @@ -33,12 +39,18 @@ git add tests npm test ``` -If any of the existing tests have different ouput JavaScript, you will see a diff in the output and the test will fail. +Run the focused checks that match your fixture: - +```shell +npm test # compile fixtures and execute Node-compatible runtime tests +npm run test:browser # execute native-browser regressions in Chromium +npm run test:negative # verify expected compiler failures and diagnostics +``` + +Use the browser suite for APIs that require a real browser. Node's built-in Fetch support is useful +for Node runtime coverage, but is not a substitute for Chromium validation; DOM emulators are not +browser evidence either. Keep examples that need special setup, such as a GET request with a body, +compile-only unless they are deliberately configured as a runtime fixture. ## Why add tests? diff --git a/package-lock.json b/package-lock.json index 4350f592..c740e447 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,17 @@ "license": "MIT", "devDependencies": { "@astrojs/starlight": "0.42.8", + "@playwright/test": "^1.64.1", "astro": "^7.2.9", "micromark": "^4.0.1", "oxfmt": "^0.76.0", "prettier": "^3.8.3", "prettier-plugin-astro": "^1.0.0", - "rescript": "^13.0.0-alpha.4", + "rescript": "13.0.0-alpha.5", "sharp": "^0.35.3" }, "peerDependencies": { - "rescript": ">=13" + "rescript": ">=13.0.0-alpha.5 <13.0.0" } }, "node_modules/@astrojs/compiler-binding": { @@ -1809,6 +1810,22 @@ "win32" ] }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@prettier/parse-srcset": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@prettier/parse-srcset/-/parse-srcset-3.1.0.tgz", @@ -4115,10 +4132,11 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "dev": true, + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -4980,6 +4998,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", @@ -5036,9 +5083,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6991,6 +7038,15 @@ "dev": true, "optional": true }, + "@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "requires": { + "playwright": "1.64.1" + } + }, "@prettier/parse-srcset": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@prettier/parse-srcset/-/parse-srcset-3.1.0.tgz", @@ -8075,7 +8131,7 @@ "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", + "mdast-util-to-hast": "^13.2.1", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", @@ -8412,9 +8468,9 @@ } }, "mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "dev": true, "requires": { "@types/hast": "^3.0.0", @@ -8922,6 +8978,21 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true }, + "playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "requires": { + "playwright-core": "1.64.1" + } + }, + "playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true + }, "postcss": { "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", @@ -8939,13 +9010,13 @@ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, "requires": { - "postcss-selector-parser": "^6.1.1" + "postcss-selector-parser": "^6.1.5" } }, "postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "dev": true, "requires": { "cssesc": "^3.0.0", diff --git a/package.json b/package.json index 91292394..04158936 100644 --- a/package.json +++ b/package.json @@ -37,25 +37,31 @@ }, "scripts": { "test": "node tests/index.js", + "test:browser": "playwright test", "build": "rescript", "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check ./docs/pages", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./playwright.config.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./playwright.config.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" }, "devDependencies": { "@astrojs/starlight": "0.42.8", + "@playwright/test": "^1.64.1", "astro": "^7.2.9", "micromark": "^4.0.1", "oxfmt": "^0.76.0", "prettier": "^3.8.3", "prettier-plugin-astro": "^1.0.0", - "rescript": "^13.0.0-alpha.4", + "rescript": "13.0.0-alpha.5", "sharp": "^0.35.3" }, "peerDependencies": { - "rescript": ">=13" + "rescript": ">=13.0.0-alpha.5 <13.0.0" + }, + "overrides": { + "mdast-util-to-hast": "^13.2.1", + "postcss-selector-parser": "^6.1.5" } } diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 00000000..66e6430e --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/browser", + testMatch: "**/*.spec.mjs", + workers: 1, + reporter: "list", + use: { + browserName: "chromium", + }, + webServer: { + command: "node tests/browser/server.mjs", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/FetchAPI/Headers__test.res b/tests/FetchAPI/Headers__test.res index cb6ba06a..53f815f0 100644 --- a/tests/FetchAPI/Headers__test.res +++ b/tests/FetchAPI/Headers__test.res @@ -1,9 +1,11 @@ let h = Headers.make() h->Headers.set(~name="X-Test", ~value="1") +assert(h->Headers.get("x-test")->Null.getOr("") == "1") let h2 = Headers.fromDict(dict{"X-Vegetable": "Carrot"}) +assert(h2->Headers.get("x-vegetable")->Null.getOr("") == "Carrot") let h3 = Headers.fromKeyValueArray([("X-Fruit", "Apple"), ("X-Vegetable", "Carrot")]) - -Console.log(h3->Headers.get("X-Fruit")) +assert(h3->Headers.get("x-fruit")->Null.getOr("") == "Apple") +assert(h3->Headers.has("x-vegetable")) diff --git a/tests/FetchAPI/Request__test.res b/tests/FetchAPI/Request__test.res index 23b1ee32..d790df70 100644 --- a/tests/FetchAPI/Request__test.res +++ b/tests/FetchAPI/Request__test.res @@ -1,5 +1,3 @@ -let req = Request.fromURL("https://example.com") - let blob = Blob.make(~blobParts=[]) let file = File.make(~fileBits=[], ~fileName="hello.txt") let params = URLSearchParams.fromString("greeting=hello") @@ -28,4 +26,4 @@ let formDataReq = Request.fromURL( ) let _formData: FormData.t = await formDataReq->Request.formData -Console.log(await req2->Request.text) +assert((await req2->Request.text) == "hello") diff --git a/tests/FetchAPI/Response__test.res b/tests/FetchAPI/Response__test.res index 04707902..0eb615d7 100644 --- a/tests/FetchAPI/Response__test.res +++ b/tests/FetchAPI/Response__test.res @@ -8,6 +8,7 @@ let stream = ReadableStream.make() let response = Response.fromNull(~init={status: 204, headers}) let response1 = Response.fromString("pong", ~init={status: 200, headers}) +assert((await response1->Response.clone->Response.text) == "pong") let response2 = Response.fromBlob(blob) let response3 = Response.fromFile(file) diff --git a/tests/FetchAPI/URLSearchParams__test.res b/tests/FetchAPI/URLSearchParams__test.res index c4532c4d..e03c59a8 100644 --- a/tests/FetchAPI/URLSearchParams__test.res +++ b/tests/FetchAPI/URLSearchParams__test.res @@ -5,9 +5,7 @@ let params2 = URLSearchParams.fromKeyValueArray([("foo", "1"), ("bar", "b")]) params2->URLSearchParams.values->Array.fromIterable->Array.forEach(Console.log) let params3 = URLSearchParams.fromDict(dict{"foo": "1", "bar": "b"}) -params3 -->URLSearchParams.entries -->Array.fromIterable -->Array.forEach(((key, value)) => Console.log2(key, value)) - +assert(params3->URLSearchParams.get("foo")->Null.getOr("") == "1") +assert(params3->URLSearchParams.has(~name="bar")) let paramStr = params3->URLSearchParams.toString +assert(paramStr == "foo=1&bar=b" || paramStr == "bar=b&foo=1") diff --git a/tests/URLAPI/URL__test.res b/tests/URLAPI/URL__test.res index e9448152..48634645 100644 --- a/tests/URLAPI/URL__test.res +++ b/tests/URLAPI/URL__test.res @@ -1 +1,4 @@ let url = URL.make(~url="/foo", ~base="https://bar.com") +assert(url->URL.toJSON == "https://bar.com/foo") +assert(URL.canParse(~url="/foo", ~base="https://bar.com")) +assert(!URL.canParse(~url="not a URL")) diff --git a/tests/browser/NativeAPI__test.res b/tests/browser/NativeAPI__test.res new file mode 100644 index 00000000..a38aeaa1 --- /dev/null +++ b/tests/browser/NativeAPI__test.res @@ -0,0 +1,2 @@ +let url = URL.make(~url="/native", ~base="https://example.com") +assert(url->URL.toJSON == "https://example.com/native") diff --git a/tests/browser/index.html b/tests/browser/index.html new file mode 100644 index 00000000..28150aec --- /dev/null +++ b/tests/browser/index.html @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/tests/browser/native-api.spec.mjs b/tests/browser/native-api.spec.mjs new file mode 100644 index 00000000..88d48b4a --- /dev/null +++ b/tests/browser/native-api.spec.mjs @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import path from "node:path"; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const fixtures = JSON.parse(readFileSync(path.join(currentDirectory, "../fixtures.json"), "utf8")); + +test("browser runtime fixtures execute in Chromium", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("http://127.0.0.1:4173/tests/browser/index.html"); + await page.evaluate(async (paths) => { + await Promise.all(paths.map((fixture) => import(`/tests/${fixture.replace(/\.res$/, ".js")}`))); + }, fixtures.browserRuntime); + expect(fixtures.browserRuntime.length).toBeGreaterThan(0); + expect(errors).toEqual([]); +}); diff --git a/tests/browser/server.mjs b/tests/browser/server.mjs new file mode 100644 index 00000000..8f7c14b9 --- /dev/null +++ b/tests/browser/server.mjs @@ -0,0 +1,29 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const contentTypes = new Map([ + [".html", "text/html"], + [".js", "text/javascript"], + [".mjs", "text/javascript"], +]); + +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}`)) { + response.writeHead(403).end(); + return; + } + try { + const content = await readFile(file); + response.writeHead(200, { + "content-type": contentTypes.get(path.extname(file)) ?? "application/octet-stream", + }); + response.end(content); + } catch { + response.writeHead(404).end(); + } +}).listen(4173, "127.0.0.1"); diff --git a/tests/fixtures.json b/tests/fixtures.json new file mode 100644 index 00000000..a5d9761e --- /dev/null +++ b/tests/fixtures.json @@ -0,0 +1,49 @@ +{ + "compileOnly": [ + "BindingAbsence__test.res", + "CanvasAPI/Path2D__test.res", + "CSSFontLoadingAPI/FontFace__test.res", + "DOMAPI/AddEventListener__test.res", + "DOMAPI/Document__test.res", + "DOMAPI/DOMMatrixReadOnly__test.res", + "DOMAPI/DOMMatrix__test.res", + "DOMAPI/Element__test.res", + "DOMAPI/EventTarget__test.res", + "DOMAPI/Event__test.res", + "DOMAPI/EventType__test.res", + "DOMAPI/HTMLCanvasElement__test.res", + "DOMAPI/HTMLElementHidden__test.res", + "DOMAPI/HTMLElement__test.res", + "DOMAPI/HTMLInputElement__test.res", + "DOMAPI/HTMLSlotElement__test.res", + "DOMAPI/ImageData__test.res", + "DOMAPI/Location__test.res", + "DOMAPI/SVGElement__test.res", + "DOMAPI/VideoFrame__test.res", + "Fetch__test.res", + "FetchAPI/FormData__test.res", + "FileAPI/ReadableStream__test.res", + "IntersectionObserverAPI/IntersectionObserver__test.res", + "MediaCaptureAndStreamsAPI/MediaStream__test.res", + "MutationObserverAPI/MutationObserver__test.res", + "NotificationsAPI/Notification__test.res", + "ServiceWorkerAPI/ServiceWorker__test.res", + "VisualViewport__test.res", + "WebAudioAPI/AudioDestinationNode__.test.res", + "WebAudioAPI/OfflineAudioContext__test.res", + "WebSocketsAPI/WebSocket__test.res", + "WebStorageAPI/Storage__test.res", + "WebWorkersAPI/SharedWorkerScope__test.res", + "WebWorkersAPI/SharedWorker__test.res" + ], + "nodeRuntime": [ + "DOMAPI/Classify__test.res", + "FetchAPI/Headers__test.res", + "FetchAPI/Request__test.res", + "FetchAPI/Response__test.res", + "FetchAPI/SafeFetch__test.res", + "FetchAPI/URLSearchParams__test.res", + "URLAPI/URL__test.res" + ], + "browserRuntime": ["browser/NativeAPI__test.res"] +} diff --git a/tests/index.js b/tests/index.js index 1547bb4f..66e399e8 100644 --- a/tests/index.js +++ b/tests/index.js @@ -1,4 +1,4 @@ -import { execFileSync, execSync, spawnSync } from "child_process"; +import { execSync, spawnSync } from "child_process"; import * as fs from "fs"; import { fileURLToPath, pathToFileURL } from "url"; import * as path from "path"; @@ -10,16 +10,32 @@ const repoRoot = path.resolve(currentDir, ".."); const testsDir = path.join(repoRoot, "tests"); const rescriptConfig = JSON.parse(fs.readFileSync(path.join(repoRoot, "rescript.json"), "utf8")); const compiledSuffix = rescriptConfig.suffix ?? ".js"; +const fixtureCategories = JSON.parse(fs.readFileSync(path.join(testsDir, "fixtures.json"), "utf8")); -const runtimeTests = [ - "DOMAPI/Classify__test.res", - "FetchAPI/Headers__test.res", - "FetchAPI/Request__test.res", - "FetchAPI/Response__test.res", - "FetchAPI/SafeFetch__test.res", - "FetchAPI/URLSearchParams__test.res", - "URLAPI/URL__test.res", -]; +const discoverFixtures = (directory, relativeDirectory = "") => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const relativePath = path.join(relativeDirectory, entry.name); + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return discoverFixtures(absolutePath, relativePath); + } + return entry.isFile() && entry.name.endsWith(".res") ? [relativePath] : []; + }); + +const discoveredFixtures = discoverFixtures(testsDir) + .map((fixture) => fixture.split(path.sep).join("/")) + .sort(); +const categorizedFixtures = [ + ...fixtureCategories.compileOnly, + ...fixtureCategories.nodeRuntime, + ...fixtureCategories.browserRuntime, +].sort(); +if (JSON.stringify(discoveredFixtures) !== JSON.stringify(categorizedFixtures)) { + throw new Error( + `Fixture category mismatch.\nDiscovered: ${discoveredFixtures.join(", ")}\nCategorized: ${categorizedFixtures.join(", ")}`, + ); +} +const runtimeTests = fixtureCategories.nodeRuntime; const compiledRuntimeTests = runtimeTests.map((testFile) => testFile.replace(/\.res$/, compiledSuffix), @@ -29,7 +45,6 @@ const compiledRuntimeTests = runtimeTests.map((testFile) => execSync("npm run build", { cwd: repoRoot, stdio: "inherit" }); const successGreen = "\x1b[32m"; -const warningYellow = "\x1b[33m"; const errorRed = "\x1b[31m"; const resetColor = "\x1b[0m"; @@ -62,20 +77,4 @@ for (const testFile of compiledRuntimeTests) { console.log(`${successGreen}✓ ${testFile}${resetColor}`); } -// Assert generated test output stayed in sync. -const gitDiff = execFileSync("git", ["ls-files", "--modified", "--", ...compiledRuntimeTests], { - cwd: testsDir, -}).toString(); -if (!gitDiff) { - console.log(`${successGreen}✅ No unstaged generated test difference!${resetColor}`); - exit(0); -} else { - console.log( - `${warningYellow}⚠️ There are unstaged differences in generated tests! Did you break a test?\n${gitDiff}${resetColor}`, - ); - execFileSync("git", ["--no-pager", "diff", "--", ...compiledRuntimeTests], { - stdio: "inherit", - cwd: testsDir, - }); - exit(1); -} +exit(0); From 376edfd707eab2e245c4c3ffa3db65eabf981e91 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 15:48:31 -0500 Subject: [PATCH 02/13] feat: validate packed alpha releases and consumers --- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 46 ++-- .gitignore | 1 + docs/content/docs/contributing/testing.mdx | 24 +- docs/llm.js | 6 +- package-lock.json | 4 +- package.json | 13 +- scripts/check-consumer.mjs | 250 ++++++++++++++++++ scripts/check-release.mjs | 50 ++++ scripts/consumer-default.res | 3 + scripts/consumer-runtime.res | 6 + .../fixtures/negative/DiagnosticMatching.res | 1 + scripts/negative-fixtures.json | 8 + tests/browser/native-api.spec.mjs | 29 +- tests/fixtures.json | 3 +- tests/index.js | 1 + 16 files changed, 404 insertions(+), 47 deletions(-) create mode 100644 scripts/check-consumer.mjs create mode 100644 scripts/check-release.mjs create mode 100644 scripts/consumer-default.res create mode 100644 scripts/consumer-runtime.res create mode 100644 scripts/fixtures/negative/DiagnosticMatching.res create mode 100644 scripts/negative-fixtures.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b832494..d5e12276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,12 @@ jobs: - name: Check public feature builds run: npm run check:features + - name: Verify packed consumers + run: npm run check:consumer + + - name: Run negative compiler consumers + run: npm run test:negative + - name: Run tests run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab386f25..05a3a3d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,33 +58,37 @@ jobs: - name: Build documentation run: npm run build:docs - - name: get-npm-version - id: package-version - uses: martinbeentjes/npm-get-version-action@v1.3.1 + - name: Run negative compiler consumers + run: npm run test:negative - - name: Get short commit hash - id: vars - run: echo "COMMIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + - name: Validate explicit alpha version and unpublished state + run: npm run check:release - - name: Update package version to - - env: - PACKAGE_VERSION: ${{ steps.package-version.outputs.current-version }}-alpha-${{ env.COMMIT_HASH }} + - name: Package release artifact + id: package run: | - node <<'NODE' - const fs = require("node:fs"); - const packageJsonPath = "package.json"; - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - packageJson.version = process.env.PACKAGE_VERSION; - fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); - NODE + mkdir -p .release + npm pack --json --pack-destination .release > .release/metadata.json + artifact="$(node -e 'process.stdout.write(JSON.parse(require("node:fs").readFileSync(".release/metadata.json", "utf8"))[0].filename)')" + echo "artifact=.release/$artifact" >> "$GITHUB_OUTPUT" - - name: Package npm package - run: npm pack + - name: Verify packed consumers and tarball contents + run: npm run check:consumer -- "${{ steps.package.outputs.artifact }}" - name: Dry-run npm publish if: github.event_name == 'pull_request' || inputs.dry_run - run: npm publish ./rescript-webapi-*.tgz --access public --tag alpha --dry-run + run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --dry-run - - name: Publish npm package + - name: Reject real publication from non-main branches + if: github.event_name == 'workflow_dispatch' && !inputs.dry_run && github.ref != 'refs/heads/main' + run: | + echo "Real alpha publication is permitted only from main." + exit 1 + + - name: Verify publication guard + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.dry_run + run: npm run check:release -- --publish + + - name: Publish alpha artifact if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.dry_run - run: npm publish ./rescript-webapi-*.tgz --access public --tag alpha + run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --provenance diff --git a/.gitignore b/.gitignore index c3ff32e6..abe3638e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .bsb.lock .astro dist/ +.release/ tmp/ docs/public/llm.txt .playwright/ diff --git a/docs/content/docs/contributing/testing.mdx b/docs/content/docs/contributing/testing.mdx index 89721d83..a7913656 100644 --- a/docs/content/docs/contributing/testing.mdx +++ b/docs/content/docs/contributing/testing.mdx @@ -12,20 +12,17 @@ It illustrates how the bindings are supposed to be used and can help to catch re ## Testing the bindings Add a ReScript fixture under `tests/`, following the source module's directory structure. Register -each fixture in exactly one category in `tests/fixtures.json`: `compileOnly`, `nodeRuntime`, or -`browserRuntime`. `npm test` fails if a fixture is missing from the manifest or appears in multiple -categories, compiles all fixtures, and executes only the Node runtime modules. Compile-only -examples are never executed. +each fixture in exactly one category in `tests/fixtures.json`: `compileOnly`, +`documentationExamples`, `nodeRuntime`, or `browserRuntime`. `npm test` fails if a fixture is missing +from the manifest or appears in multiple categories, compiles all fixtures, and executes only the +Node runtime modules. Compile-only fixtures and documentation examples are never executed. - src - - DOMAPI.res - - DOMAPI - - HTMLCanvasElement.res + - fetch + - Headers.res - tests - - DOMAPI - - Classify\_\_test.res - FetchAPI - Headers\_\_test.res @@ -51,6 +48,15 @@ Use the browser suite for APIs that require a real browser. Node's built-in Fetc for Node runtime coverage, but is not a substitute for Chromium validation; DOM emulators are not browser evidence either. Keep examples that need special setup, such as a GET request with a body, compile-only unless they are deliberately configured as a runtime fixture. +Browser fixtures with asynchronous behavior must await all assertions and callback completion before +the ReScript module finishes evaluating; the browser runner awaits module evaluation and treats +rejections and uncaught page errors as failures. + +## Installed package consumers + +Run `npm run check:consumer` to pack the current package, install that artifact with the supported +ReScript compiler in a fresh temporary project, and compile representative feature consumers. +This verifies the installed tarball rather than copied source files or local compiler links. ## Why add tests? diff --git a/docs/llm.js b/docs/llm.js index 4e957660..ff2c622a 100644 --- a/docs/llm.js +++ b/docs/llm.js @@ -145,11 +145,9 @@ files.sort(); const pages = await Promise.all(files.map(processFile)); const packageJson = await fs.readFile(path.join(import.meta.dirname, "../package.json"), "utf-8"); const version = JSON.parse(packageJson).version; -const sha = await execAsync("git rev-parse --short HEAD").then(({ stdout }) => stdout.trim()); -const fullVersion = `${version}-alpha-${sha}`; -const header = `Experimental ReScript WebAPI Documentation ${fullVersion} +const header = `Experimental ReScript WebAPI Documentation ${version} -This is the API documentation for the experimental WebAPI module version ${fullVersion}. +This is the API documentation for the experimental WebAPI module version ${version}. More information can be found on https://rescript-lang.github.io/experimental-rescript-webapi/ `; diff --git a/package-lock.json b/package-lock.json index c740e447..097cecef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rescript/webapi", - "version": "0.1.0", + "version": "1.0.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@rescript/webapi", - "version": "0.1.0", + "version": "1.0.0-alpha.1", "license": "MIT", "devDependencies": { "@astrojs/starlight": "0.42.8", diff --git a/package.json b/package.json index 04158936..2d7ceddd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rescript/webapi", - "version": "0.1.0", + "version": "1.0.0-alpha.1", "description": "Experimental successor to [rescript-webapi](https://github.com/TheSpyder/rescript-webapi)", "keywords": [ "dom", @@ -36,12 +36,15 @@ "provenance": true }, "scripts": { - "test": "node tests/index.js", - "test:browser": "playwright test", + "test": "rescript clean && node tests/index.js", + "test:browser": "rescript clean && npm run build && playwright test", + "test:negative": "node scripts/check-consumer.mjs --negative-only", "build": "rescript", "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./playwright.config.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./playwright.config.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check ./docs/pages", + "check:consumer": "node scripts/check-consumer.mjs", + "check:release": "node scripts/check-release.mjs", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" diff --git a/scripts/check-consumer.mjs b/scripts/check-consumer.mjs new file mode 100644 index 00000000..3e93feb4 --- /dev/null +++ b/scripts/check-consumer.mjs @@ -0,0 +1,250 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); +const compilerVersion = "13.0.0-alpha.5"; +if ( + packageJson.devDependencies.rescript !== compilerVersion || + packageJson.peerDependencies.rescript !== ">=13.0.0-alpha.5 <13.0.0" +) { + throw new Error("The tested compiler and package peer range are inconsistent."); +} +const requiredFiles = ["LICENSE", "README.md", "package.json", "rescript.json"]; +const forbiddenPaths = /^(?:tests|scripts|docs|\.github|node_modules|lib|dist)\//; + +const runCommand = (command, args, cwd) => { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (result.error || result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed.\n${result.error?.message ?? output}`); + } + return output; +}; + +const validateTarballFiles = (files) => { + const paths = files.map(({ path: filePath }) => filePath); + const missingFiles = requiredFiles.filter((filePath) => !paths.includes(filePath)); + const invalidFiles = paths.filter( + (filePath) => + forbiddenPaths.test(filePath) || + (!requiredFiles.includes(filePath) && + !(filePath.startsWith("src/") && /\.(?:res|resi)$/.test(filePath)) && + filePath !== "LICENSE" && + filePath !== "README.md"), + ); + if (missingFiles.length > 0 || invalidFiles.length > 0) { + throw new Error( + `Unexpected package contents. Missing: ${missingFiles.join(", ") || "none"}. Unexpected: ${invalidFiles.join(", ") || "none"}.`, + ); + } + if (!paths.some((filePath) => filePath.startsWith("src/") && filePath.endsWith(".res"))) { + throw new Error("The package tarball contains no ReScript source files."); + } + if (!paths.some((filePath) => filePath.startsWith("src/") && filePath.endsWith(".resi"))) { + throw new Error("The package tarball contains no ReScript interface files."); + } +}; + +const writeProject = (directory, sourceFile, features) => { + const sourceDirectory = path.join(directory, "src"); + const fixturePath = path.resolve(repoRoot, sourceFile); + if (!fixturePath.startsWith(`${path.join(repoRoot, "scripts")}${path.sep}`)) { + throw new Error(`Consumer fixture must be under scripts/: ${sourceFile}`); + } + mkdirSync(sourceDirectory, { recursive: true }); + writeFileSync(path.join(directory, "package.json"), '{"type":"module"}\n'); + writeFileSync( + path.join(directory, "rescript.json"), + `${JSON.stringify( + { + name: "packed-webapi-consumer", + sources: [{ dir: "src" }], + dependencies: + features === null ? ["@rescript/webapi"] : [{ name: "@rescript/webapi", features }], + "experimental-features": { LetUnwrap: true }, + warnings: { error: "+8" }, + "package-specs": { module: "esmodule", "in-source": true }, + }, + null, + 2, + )}\n`, + ); + writeFileSync(path.join(sourceDirectory, "Consumer.res"), readFileSync(fixturePath)); +}; + +const compilerCli = (directory) => + path.join(directory, "node_modules", "rescript", "cli", "rescript.js"); + +const compile = (directory, expectedFailure = null) => { + const cli = compilerCli(directory); + const clean = spawnSync(process.execPath, [cli, "clean"], { cwd: directory }); + if (clean.status !== 0) { + throw new Error("Installed compiler failed to clean the consumer project."); + } + const result = spawnSync(process.execPath, [cli, "build", "--prod"], { + cwd: directory, + encoding: "utf8", + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}` + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\s+/g, " "); + if (expectedFailure === null && result.status !== 0) { + throw new Error(`Consumer failed to compile:\n${output}`); + } + const diagnosticMatches = + expectedFailure instanceof RegExp + ? expectedFailure.test(output) + : typeof expectedFailure === "string" && output.includes(expectedFailure); + if (expectedFailure !== null && (result.status === 0 || !diagnosticMatches)) { + throw new Error( + `Consumer did not fail with the expected diagnostic ${expectedFailure}; exit status ${result.status}:\n${output}`, + ); + } +}; + +const verifyUnsupportedCompiler = (directory, tarballPath) => { + mkdirSync(directory); + writeFileSync(path.join(directory, "package.json"), '{"type":"module"}\n'); + const result = spawnSync( + "npm", + ["install", "--no-audit", "--no-fund", "--no-save", tarballPath, "rescript@12.3.1"], + { cwd: directory, encoding: "utf8" }, + ); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if ( + result.status === 0 || + !output.includes("@rescript/webapi") || + !output.includes(">=13.0.0-alpha.5 <13.0.0") + ) { + throw new Error( + `Unsupported compiler installation did not explain the peer constraint:\n${output}`, + ); + } +}; + +const checkConsumers = async () => { + const tempDirectory = mkdtempSync(path.join(os.tmpdir(), "webapi-packed-consumer-")); + try { + const negativeOnly = process.argv.includes("--negative-only"); + const suppliedArtifact = process.argv.slice(2).find((argument) => !argument.startsWith("--")); + let tarballPath; + let packagedFiles; + if (suppliedArtifact === undefined) { + const packOutput = runCommand( + "npm", + ["pack", "--json", "--pack-destination", tempDirectory], + repoRoot, + ); + const [pack] = JSON.parse(packOutput); + tarballPath = path.join(tempDirectory, pack.filename); + packagedFiles = pack.files; + } else { + tarballPath = path.resolve(repoRoot, suppliedArtifact); + if (!existsSync(tarballPath)) { + throw new Error(`Packed artifact does not exist: ${tarballPath}`); + } + packagedFiles = runCommand("tar", ["-tzf", tarballPath], repoRoot) + .split(/\r?\n/) + .filter((filePath) => filePath.startsWith("package/")) + .map((filePath) => ({ path: filePath.slice("package/".length) })); + } + validateTarballFiles(packagedFiles); + const packedMetadata = + suppliedArtifact === undefined + ? packageJson + : JSON.parse(runCommand("tar", ["-xOf", tarballPath, "package/package.json"], repoRoot)); + if ( + packedMetadata.name !== packageJson.name || + packedMetadata.version !== packageJson.version || + !/^1\.0\.0-alpha\.[1-9]\d*$/.test(packedMetadata.version) + ) { + throw new Error( + "Packed artifact name/version does not match the explicit alpha package metadata.", + ); + } + const projectDirectory = path.join(tempDirectory, "project"); + mkdirSync(projectDirectory); + writeFileSync(path.join(projectDirectory, "package.json"), '{"type":"module"}\n'); + runCommand( + "npm", + [ + "install", + "--no-audit", + "--no-fund", + "--no-save", + tarballPath, + `rescript@${compilerVersion}`, + ], + projectDirectory, + ); + console.log(`[ok] installed tarball with supported ReScript ${compilerVersion}`); + + verifyUnsupportedCompiler(path.join(tempDirectory, "unsupported"), tarballPath); + console.log("[ok] unsupported ReScript version rejected with peer-range diagnostic"); + + if (!negativeOnly) { + const scenarios = [ + { name: "default", fixture: "scripts/consumer-default.res", features: null }, + { + name: "Fetch-only", + fixture: "scripts/consumer-fetch.res", + features: ["WebAPI.Fetch"], + }, + { + name: "SafeFetch", + fixture: "scripts/consumer-safe-fetch.res", + features: ["WebAPI.SafeFetch"], + }, + { + name: "Fetch-only SafeFetch rejection", + fixture: "scripts/consumer-safe-fetch-disabled.res", + features: ["WebAPI.Fetch"], + expectedFailure: /The module or file (?:WebAPI\.)?SafeFetch can't be found\./, + }, + ]; + + for (const scenario of scenarios) { + writeProject(projectDirectory, scenario.fixture, scenario.features); + compile(projectDirectory, scenario.expectedFailure ?? null); + console.log(`[ok] packed ${scenario.name} consumer`); + } + + writeProject(projectDirectory, "scripts/consumer-runtime.res", ["WebAPI.Fetch"]); + compile(projectDirectory); + runCommand( + process.execPath, + [ + "--input-type=module", + "--eval", + `await import(${JSON.stringify(pathToFileURL(path.join(projectDirectory, "src/Consumer.js")).href)})`, + ], + projectDirectory, + ); + console.log(`[ok] packed consumer runtime using ReScript ${compilerVersion}`); + } + + const negativeCases = JSON.parse( + readFileSync(path.join(repoRoot, "scripts/negative-fixtures.json"), "utf8"), + ); + if (negativeOnly && negativeCases.length === 0) { + throw new Error("No negative consumer fixtures are registered."); + } + for (const negativeCase of negativeCases) { + writeProject(projectDirectory, negativeCase.fixture, negativeCase.features); + compile(projectDirectory, negativeCase.expectedDiagnostic); + console.log(`[ok] expected rejection: ${negativeCase.name}`); + } + console.log(`[ok] tarball contents (${packagedFiles.length} files)`); + } finally { + rmSync(tempDirectory, { recursive: true, force: true }); + } +}; + +checkConsumers().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 00000000..283d9950 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); +const lockFile = JSON.parse(readFileSync(path.join(repoRoot, "package-lock.json"), "utf8")); +const versionPattern = /^1\.0\.0-alpha\.[1-9]\d*$/; + +if (!versionPattern.test(packageJson.version)) { + throw new Error(`Expected an explicit 1.0.0-alpha.N version; received ${packageJson.version}.`); +} +if ( + lockFile.version !== packageJson.version || + lockFile.packages?.[""]?.version !== packageJson.version +) { + throw new Error("package.json and package-lock.json versions must match."); +} +if ( + packageJson.publishConfig?.access !== "public" || + packageJson.publishConfig?.provenance !== true +) { + throw new Error("Alpha packages must retain public access and provenance configuration."); +} + +const packageReference = `${packageJson.name}@${packageJson.version}`; +const published = spawnSync("npm", ["view", packageReference, "version"], { + cwd: repoRoot, + encoding: "utf8", +}); +const registryOutput = `${published.stdout ?? ""}\n${published.stderr ?? ""}`; +if (published.status === 0) { + throw new Error(`${packageReference} is already published; increment the alpha version.`); +} +if (!/\bE404\b|404 Not Found/.test(registryOutput)) { + throw new Error(`Unable to verify that ${packageReference} is unpublished:\n${registryOutput}`); +} + +if (process.argv.includes("--publish")) { + if (process.env.GITHUB_REF !== "refs/heads/main") { + throw new Error("Real alpha publication is allowed only from refs/heads/main."); + } + if (process.env.GITHUB_EVENT_NAME !== "workflow_dispatch") { + throw new Error("Real alpha publication requires an explicit workflow_dispatch."); + } + console.log(`Verified unpublished alpha version ${packageReference} for the main branch.`); +} else { + console.log(`Verified unpublished alpha version ${packageReference}; expected dist-tag: alpha.`); +} diff --git a/scripts/consumer-default.res b/scripts/consumer-default.res new file mode 100644 index 00000000..0c13c244 --- /dev/null +++ b/scripts/consumer-default.res @@ -0,0 +1,3 @@ +let location = WebAPI.DOM.window->WebAPI.Window.location +let href = location.href +location->WebAPI.Location.reload diff --git a/scripts/consumer-runtime.res b/scripts/consumer-runtime.res new file mode 100644 index 00000000..8b2d0d8a --- /dev/null +++ b/scripts/consumer-runtime.res @@ -0,0 +1,6 @@ +let headers = WebAPI.Headers.make() +headers->WebAPI.Headers.set(~name="X-Artifact", ~value="installed") +assert(headers->WebAPI.Headers.get("x-artifact")->Null.getOr("") == "installed") + +let response = WebAPI.Response.fromString("packed consumer") +assert(await response->WebAPI.Response.text == "packed consumer") diff --git a/scripts/fixtures/negative/DiagnosticMatching.res b/scripts/fixtures/negative/DiagnosticMatching.res new file mode 100644 index 00000000..2bc68831 --- /dev/null +++ b/scripts/fixtures/negative/DiagnosticMatching.res @@ -0,0 +1 @@ +let invalidValue: int = "not an integer" diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json new file mode 100644 index 00000000..3dec583c --- /dev/null +++ b/scripts/negative-fixtures.json @@ -0,0 +1,8 @@ +[ + { + "name": "expected compiler diagnostic matching", + "fixture": "scripts/fixtures/negative/DiagnosticMatching.res", + "features": null, + "expectedDiagnostic": "This has type: string" + } +] diff --git a/tests/browser/native-api.spec.mjs b/tests/browser/native-api.spec.mjs index 88d48b4a..f598a496 100644 --- a/tests/browser/native-api.spec.mjs +++ b/tests/browser/native-api.spec.mjs @@ -8,11 +8,32 @@ 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(error.message)); + 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.evaluate(async (paths) => { - await Promise.all(paths.map((fixture) => import(`/tests/${fixture.replace(/\.res$/, ".js")}`))); - }, fixtures.browserRuntime); + const results = await page.evaluate( + async (paths) => + Promise.all( + paths.map(async (fixture) => { + try { + await import(`/tests/${fixture.replace(/\.res$/, ".js")}`); + return { fixture, _tag: "Success" }; + } catch (error) { + const detail = + error instanceof Error + ? { name: error.name, message: error.message, stack: error.stack } + : (JSON.stringify(error, (_key, value) => + value instanceof Error + ? { name: value.name, message: value.message, stack: value.stack } + : value, + ) ?? String(error)); + return { fixture, _tag: "Failure", detail }; + } + }), + ), + fixtures.browserRuntime, + ); expect(fixtures.browserRuntime.length).toBeGreaterThan(0); + expect(results).toHaveLength(fixtures.browserRuntime.length); + expect(results.filter((result) => result._tag === "Failure")).toEqual([]); expect(errors).toEqual([]); }); diff --git a/tests/fixtures.json b/tests/fixtures.json index a5d9761e..a9d90487 100644 --- a/tests/fixtures.json +++ b/tests/fixtures.json @@ -20,7 +20,6 @@ "DOMAPI/Location__test.res", "DOMAPI/SVGElement__test.res", "DOMAPI/VideoFrame__test.res", - "Fetch__test.res", "FetchAPI/FormData__test.res", "FileAPI/ReadableStream__test.res", "IntersectionObserverAPI/IntersectionObserver__test.res", @@ -28,7 +27,6 @@ "MutationObserverAPI/MutationObserver__test.res", "NotificationsAPI/Notification__test.res", "ServiceWorkerAPI/ServiceWorker__test.res", - "VisualViewport__test.res", "WebAudioAPI/AudioDestinationNode__.test.res", "WebAudioAPI/OfflineAudioContext__test.res", "WebSocketsAPI/WebSocket__test.res", @@ -36,6 +34,7 @@ "WebWorkersAPI/SharedWorkerScope__test.res", "WebWorkersAPI/SharedWorker__test.res" ], + "documentationExamples": ["Fetch__test.res", "VisualViewport__test.res"], "nodeRuntime": [ "DOMAPI/Classify__test.res", "FetchAPI/Headers__test.res", diff --git a/tests/index.js b/tests/index.js index 66e399e8..4803a803 100644 --- a/tests/index.js +++ b/tests/index.js @@ -27,6 +27,7 @@ const discoveredFixtures = discoverFixtures(testsDir) .sort(); const categorizedFixtures = [ ...fixtureCategories.compileOnly, + ...fixtureCategories.documentationExamples, ...fixtureCategories.nodeRuntime, ...fixtureCategories.browserRuntime, ].sort(); From bdcff35211a80aa361576efc84ccc1009bbb137d Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 15:58:58 -0500 Subject: [PATCH 03/13] fix: isolate publish identity and preserve browser diagnostics --- .github/workflows/release.yml | 98 +++++++++++++++++++--- docs/content/docs/contributing/testing.mdx | 8 ++ package.json | 4 +- scripts/check-consumer.mjs | 15 ++++ tests/browser/describe-error.mjs | 42 ++++++++++ tests/browser/native-api.spec.mjs | 65 +++++++++----- 6 files changed, 195 insertions(+), 37 deletions(-) create mode 100644 tests/browser/describe-error.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05a3a3d4..fc29ae94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: dry_run: - description: Run npm publish without publishing the package + description: Validate and dry-run without publishing required: true default: true type: boolean @@ -14,11 +14,8 @@ permissions: contents: read jobs: - publish: + validate: runs-on: ubuntu-latest - permissions: - contents: read - id-token: write steps: - name: Checkout repository @@ -46,6 +43,12 @@ jobs: - name: Check public feature builds run: npm run check:features + - name: Verify packed consumers + run: npm run check:consumer + + - name: Run negative compiler consumers + run: npm run test:negative + - name: Run tests run: npm test @@ -58,9 +61,6 @@ jobs: - name: Build documentation run: npm run build:docs - - name: Run negative compiler consumers - run: npm run test:negative - - name: Validate explicit alpha version and unpublished state run: npm run check:release @@ -75,8 +75,7 @@ jobs: - name: Verify packed consumers and tarball contents run: npm run check:consumer -- "${{ steps.package.outputs.artifact }}" - - name: Dry-run npm publish - if: github.event_name == 'pull_request' || inputs.dry_run + - name: Dry-run publish the validated artifact run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --dry-run - name: Reject real publication from non-main branches @@ -85,10 +84,83 @@ jobs: echo "Real alpha publication is permitted only from main." exit 1 - - name: Verify publication guard + - name: Upload validated release artifact if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.dry_run + uses: actions/upload-artifact@v4 + with: + name: alpha-release-artifact + path: ${{ steps.package.outputs.artifact }} + if-no-files-found: error + retention-days: 1 + + publish: + needs: validate + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.dry_run + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: "26" + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Setup npm + run: npm install --global npm@11.5.1 + + - name: Download validated release artifact + uses: actions/download-artifact@v4 + with: + name: alpha-release-artifact + path: .release + + - name: Verify downloaded artifact metadata + run: | + artifact=".release/rescript-webapi-$(node -p 'require("./package.json").version').tgz" + test -f "$artifact" + node --input-type=module <<'NODE' + import { execFileSync } from "node:child_process"; + import { readFileSync } from "node:fs"; + const { name, version, publishConfig } = JSON.parse(readFileSync("package.json", "utf8")); + const artifact = `.release/rescript-webapi-${version}.tgz`; + const packed = JSON.parse(execFileSync("tar", ["-xOf", artifact, "package/package.json"], { encoding: "utf8" })); + if (packed.name !== name || packed.version !== version || !/^1\.0\.0-alpha\.[1-9]\d*$/.test(version)) { + throw new Error("Downloaded artifact does not match the numbered alpha package metadata."); + } + if (publishConfig.access !== "public" || publishConfig.provenance !== true) { + throw new Error("Downloaded artifact is missing public/provenance publishing metadata."); + } + const publishLifecycleScripts = ["prepare", "prepack", "prepublishOnly", "publish", "postpublish"]; + if (publishLifecycleScripts.some((script) => packed.scripts?.[script] !== undefined)) { + throw new Error("Downloaded artifact contains a publish lifecycle script."); + } + console.log(`Verified ${name}@${version} artifact before publishing with the alpha tag.`); + NODE + + - name: Verify publication guard run: npm run check:release -- --publish - name: Publish alpha artifact - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.dry_run - run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --provenance + run: npm publish ".release/rescript-webapi-$(node -p 'require("./package.json").version').tgz" --access public --tag alpha --provenance + + - name: Verify alpha dist-tag + run: | + node <<'NODE' + const { execFileSync } = require("node:child_process"); + const { name, version } = require("./package.json"); + const tags = JSON.parse(execFileSync("npm", ["view", name, "dist-tags", "--json"], { encoding: "utf8" })); + if (tags.alpha !== version) { + throw new Error(`Expected alpha dist-tag ${version}, received ${tags.alpha ?? "missing"}.`); + } + if (tags.latest === version) { + throw new Error(`Alpha publication unexpectedly advanced latest to ${version}.`); + } + console.log(`Verified ${name}@${version} is tagged alpha and not latest.`); + NODE diff --git a/docs/content/docs/contributing/testing.mdx b/docs/content/docs/contributing/testing.mdx index a7913656..18c63947 100644 --- a/docs/content/docs/contributing/testing.mdx +++ b/docs/content/docs/contributing/testing.mdx @@ -52,11 +52,19 @@ 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. +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 +and that expected diagnostic; an unrelated compiler failure does not pass the check. + ## Installed package consumers Run `npm run check:consumer` to pack the current package, install that artifact with the supported ReScript compiler in a fresh temporary project, and compile representative feature consumers. This verifies the installed tarball rather than copied source files or local compiler links. +The development compiler and the exact version exercised by this check are ReScript +`13.0.0-alpha.5`. The peer range `>=13.0.0-alpha.5 <13.0.0` admits later `13.0.0` prereleases; +it does not claim support for earlier compilers or stable `13.0.0`. ## Why add tests? diff --git a/package.json b/package.json index 2d7ceddd..896c07f8 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", "check:consumer": "node scripts/check-consumer.mjs", "check:release": "node scripts/check-release.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check ./docs/pages", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" diff --git a/scripts/check-consumer.mjs b/scripts/check-consumer.mjs index 3e93feb4..ce2212fa 100644 --- a/scripts/check-consumer.mjs +++ b/scripts/check-consumer.mjs @@ -166,6 +166,21 @@ const checkConsumers = async () => { "Packed artifact name/version does not match the explicit alpha package metadata.", ); } + const publishLifecycleScripts = [ + "prepare", + "prepack", + "prepublishOnly", + "publish", + "postpublish", + ]; + const configuredPublishScripts = publishLifecycleScripts.filter( + (scriptName) => packedMetadata.scripts?.[scriptName] !== undefined, + ); + if (configuredPublishScripts.length > 0) { + throw new Error( + `Packed artifact has lifecycle scripts that would execute during publish: ${configuredPublishScripts.join(", ")}.`, + ); + } const projectDirectory = path.join(tempDirectory, "project"); mkdirSync(projectDirectory); writeFileSync(path.join(projectDirectory, "package.json"), '{"type":"module"}\n'); diff --git a/tests/browser/describe-error.mjs b/tests/browser/describe-error.mjs new file mode 100644 index 00000000..df3305c4 --- /dev/null +++ b/tests/browser/describe-error.mjs @@ -0,0 +1,42 @@ +const readProperty = (value, key) => { + try { + return typeof value === "object" && value !== null ? value[key] : undefined; + } catch { + return undefined; + } +}; + +const stringifySafely = (value) => { + try { + return String(value); + } catch { + return ""; + } +}; + +export const describeError = (error) => { + const cause = readProperty(error, "Error"); + const identity = + readProperty(error, "RE_EXN_ID") ?? readProperty(error, "name") ?? stringifySafely(error); + const message = readProperty(error, "message") ?? readProperty(cause, "message"); + const stack = readProperty(error, "stack") ?? readProperty(cause, "stack"); + + let detail; + try { + detail = JSON.stringify(error, (_key, value) => { + if (value instanceof Error) { + return { name: value.name, message: value.message, stack: value.stack }; + } + return typeof value === "bigint" ? `${value}n` : value; + }); + } catch (serializationError) { + detail = `Exception payload omitted: ${stringifySafely(serializationError)}`; + } + + return { + name: stringifySafely(identity), + message: message === undefined ? "" : stringifySafely(message), + stack: stack === undefined ? "" : stringifySafely(stack), + detail: detail ?? stringifySafely(error), + }; +}; diff --git a/tests/browser/native-api.spec.mjs b/tests/browser/native-api.spec.mjs index f598a496..0d3910c5 100644 --- a/tests/browser/native-api.spec.mjs +++ b/tests/browser/native-api.spec.mjs @@ -10,30 +10,51 @@ 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"); - const results = await page.evaluate( - async (paths) => - Promise.all( - paths.map(async (fixture) => { - try { - await import(`/tests/${fixture.replace(/\.res$/, ".js")}`); - return { fixture, _tag: "Success" }; - } catch (error) { - const detail = - error instanceof Error - ? { name: error.name, message: error.message, stack: error.stack } - : (JSON.stringify(error, (_key, value) => - value instanceof Error - ? { name: value.name, message: value.message, stack: value.stack } - : value, - ) ?? String(error)); - return { fixture, _tag: "Failure", detail }; - } - }), - ), - fixtures.browserRuntime, - ); + const results = await page.evaluate(async (paths) => { + const { describeError } = await import("/tests/browser/describe-error.mjs"); + const outcomes = []; + for (const fixture of paths) { + try { + await import(`/tests/${fixture.replace(/\.res$/, ".js")}`); + outcomes.push({ fixture, _tag: "Success" }); + } catch (error) { + outcomes.push({ fixture, _tag: "Failure", error: describeError(error) }); + } + } + return outcomes; + }, fixtures.browserRuntime); expect(fixtures.browserRuntime.length).toBeGreaterThan(0); expect(results).toHaveLength(fixtures.browserRuntime.length); expect(results.filter((result) => result._tag === "Failure")).toEqual([]); expect(errors).toEqual([]); }); + +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"); + const failures = await page.evaluate(async () => { + const { describeError } = await import("/tests/browser/describe-error.mjs"); + const bigIntFailure = describeError({ + RE_EXN_ID: "Review_bigint", + _1: 1n, + Error: new Error("original BigInt failure"), + }); + const cyclicPayload = { + RE_EXN_ID: "Review_cyclic", + Error: new Error("original cyclic failure"), + }; + cyclicPayload._1 = cyclicPayload; + return { + bigIntFailure, + cyclicFailure: describeError(cyclicPayload), + }; + }); + + expect(failures.bigIntFailure.name).toBe("Review_bigint"); + expect(failures.bigIntFailure.message).toBe("original BigInt failure"); + expect(failures.bigIntFailure.stack).toContain("original BigInt failure"); + expect(failures.bigIntFailure.detail).toContain("1n"); + expect(failures.cyclicFailure.name).toBe("Review_cyclic"); + expect(failures.cyclicFailure.message).toBe("original cyclic failure"); + expect(failures.cyclicFailure.stack).toContain("original cyclic failure"); + expect(failures.cyclicFailure.detail).toContain("Exception payload omitted"); +}); From e15a985015511428bb09a2bd504b522d479ef483 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 16:01:04 -0500 Subject: [PATCH 04/13] build: keep registry credentials out of validation job --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc29ae94..e547814b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,6 @@ jobs: uses: actions/setup-node@v7 with: node-version: "26" - registry-url: https://registry.npmjs.org package-manager-cache: false - name: Setup npm From 19cf49054ef2bfb48f9dd50ea3bd76f23af601f5 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 6 Nov 2026 20:15:21 -0500 Subject: [PATCH 05/13] fix: validate release artifact before OIDC publish --- .github/workflows/release.yml | 21 +++--- package.json | 6 +- scripts/check-consumer.mjs | 28 ++------ scripts/check-release.mjs | 15 ++-- scripts/validate-release-artifact.mjs | 54 ++++++++++++++ scripts/validate-release-artifact.test.mjs | 84 ++++++++++++++++++++++ 6 files changed, 163 insertions(+), 45 deletions(-) create mode 100644 scripts/validate-release-artifact.mjs create mode 100644 scripts/validate-release-artifact.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e547814b..bf62a5d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,7 +75,7 @@ jobs: run: npm run check:consumer -- "${{ steps.package.outputs.artifact }}" - name: Dry-run publish the validated artifact - run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --dry-run + run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --dry-run --ignore-scripts - name: Reject real publication from non-main branches if: github.event_name == 'workflow_dispatch' && !inputs.dry_run && github.ref != 'refs/heads/main' @@ -127,27 +127,22 @@ jobs: node --input-type=module <<'NODE' import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; - const { name, version, publishConfig } = JSON.parse(readFileSync("package.json", "utf8")); + import { validateReleaseArtifact } from "./scripts/validate-release-artifact.mjs"; + const { name, version } = JSON.parse(readFileSync("package.json", "utf8")); const artifact = `.release/rescript-webapi-${version}.tgz`; const packed = JSON.parse(execFileSync("tar", ["-xOf", artifact, "package/package.json"], { encoding: "utf8" })); - if (packed.name !== name || packed.version !== version || !/^1\.0\.0-alpha\.[1-9]\d*$/.test(version)) { - throw new Error("Downloaded artifact does not match the numbered alpha package metadata."); - } - if (publishConfig.access !== "public" || publishConfig.provenance !== true) { - throw new Error("Downloaded artifact is missing public/provenance publishing metadata."); - } - const publishLifecycleScripts = ["prepare", "prepack", "prepublishOnly", "publish", "postpublish"]; - if (publishLifecycleScripts.some((script) => packed.scripts?.[script] !== undefined)) { - throw new Error("Downloaded artifact contains a publish lifecycle script."); + const errors = validateReleaseArtifact({ name, version }, packed); + if (errors.length > 0) { + throw new Error(`Downloaded artifact is not publishable:\n${errors.join("\n")}`); } console.log(`Verified ${name}@${version} artifact before publishing with the alpha tag.`); NODE - name: Verify publication guard - run: npm run check:release -- --publish + run: node scripts/check-release.mjs --publish - name: Publish alpha artifact - run: npm publish ".release/rescript-webapi-$(node -p 'require("./package.json").version').tgz" --access public --tag alpha --provenance + run: npm publish ".release/rescript-webapi-$(node -p 'require("./package.json").version').tgz" --access public --tag alpha --provenance --ignore-scripts - name: Verify alpha dist-tag run: | diff --git a/package.json b/package.json index 896c07f8..b6f475f2 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,9 @@ "build": "rescript", "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", "check:consumer": "node scripts/check-consumer.mjs", - "check:release": "node scripts/check-release.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", + "check:release": "node --test scripts/validate-release-artifact.test.mjs && node scripts/check-release.mjs", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" diff --git a/scripts/check-consumer.mjs b/scripts/check-consumer.mjs index ce2212fa..e75c37e6 100644 --- a/scripts/check-consumer.mjs +++ b/scripts/check-consumer.mjs @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { validateReleaseArtifact } from "./validate-release-artifact.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); @@ -157,29 +158,12 @@ const checkConsumers = async () => { suppliedArtifact === undefined ? packageJson : JSON.parse(runCommand("tar", ["-xOf", tarballPath, "package/package.json"], repoRoot)); - if ( - packedMetadata.name !== packageJson.name || - packedMetadata.version !== packageJson.version || - !/^1\.0\.0-alpha\.[1-9]\d*$/.test(packedMetadata.version) - ) { - throw new Error( - "Packed artifact name/version does not match the explicit alpha package metadata.", - ); - } - const publishLifecycleScripts = [ - "prepare", - "prepack", - "prepublishOnly", - "publish", - "postpublish", - ]; - const configuredPublishScripts = publishLifecycleScripts.filter( - (scriptName) => packedMetadata.scripts?.[scriptName] !== undefined, + const artifactErrors = validateReleaseArtifact( + { name: packageJson.name, version: packageJson.version }, + packedMetadata, ); - if (configuredPublishScripts.length > 0) { - throw new Error( - `Packed artifact has lifecycle scripts that would execute during publish: ${configuredPublishScripts.join(", ")}.`, - ); + if (artifactErrors.length > 0) { + throw new Error(`Packed artifact is not publishable:\n${artifactErrors.join("\n")}`); } const projectDirectory = path.join(tempDirectory, "project"); mkdirSync(projectDirectory); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 283d9950..233d4760 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -23,6 +23,13 @@ if ( ) { throw new Error("Alpha packages must retain public access and provenance configuration."); } +const publishRequested = process.argv.includes("--publish"); +if (publishRequested && process.env.GITHUB_REF !== "refs/heads/main") { + throw new Error("Real alpha publication is allowed only from refs/heads/main."); +} +if (publishRequested && process.env.GITHUB_EVENT_NAME !== "workflow_dispatch") { + throw new Error("Real alpha publication requires an explicit workflow_dispatch."); +} const packageReference = `${packageJson.name}@${packageJson.version}`; const published = spawnSync("npm", ["view", packageReference, "version"], { @@ -37,13 +44,7 @@ if (!/\bE404\b|404 Not Found/.test(registryOutput)) { throw new Error(`Unable to verify that ${packageReference} is unpublished:\n${registryOutput}`); } -if (process.argv.includes("--publish")) { - if (process.env.GITHUB_REF !== "refs/heads/main") { - throw new Error("Real alpha publication is allowed only from refs/heads/main."); - } - if (process.env.GITHUB_EVENT_NAME !== "workflow_dispatch") { - throw new Error("Real alpha publication requires an explicit workflow_dispatch."); - } +if (publishRequested) { console.log(`Verified unpublished alpha version ${packageReference} for the main branch.`); } else { console.log(`Verified unpublished alpha version ${packageReference}; expected dist-tag: alpha.`); diff --git a/scripts/validate-release-artifact.mjs b/scripts/validate-release-artifact.mjs new file mode 100644 index 00000000..97841bac --- /dev/null +++ b/scripts/validate-release-artifact.mjs @@ -0,0 +1,54 @@ +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); + +const publishLifecycleScripts = [ + "prepublish", + "prepare", + "prepack", + "postpack", + "prepublishOnly", + "publish", + "postpublish", +]; + +export const validateReleaseArtifact = (expected, packed) => { + if (!isRecord(expected) || !isRecord(packed)) { + return ["Expected and packed metadata must be objects."]; + } + + const errors = [ + ...(packed.name === expected.name ? [] : ["Packed name does not match package metadata."]), + ...(packed.version === expected.version && /^1\.0\.0-alpha\.[1-9]\d*$/.test(packed.version) + ? [] + : ["Packed version does not match the numbered alpha package metadata."]), + ]; + const publishConfig = packed.publishConfig; + if (!isRecord(publishConfig)) { + errors.push("Packed artifact is missing publishConfig."); + } else { + if (publishConfig.access !== "public") { + errors.push("Packed artifact must set publishConfig.access to public."); + } + if (publishConfig.provenance !== true) { + errors.push("Packed artifact must enable publish provenance."); + } + const unexpectedConfig = Object.keys(publishConfig).filter( + (key) => key !== "access" && key !== "provenance", + ); + if (unexpectedConfig.length > 0) { + errors.push( + `Packed artifact has unexpected publishConfig overrides: ${unexpectedConfig.join(", ")}.`, + ); + } + } + + const scripts = isRecord(packed.scripts) ? packed.scripts : {}; + const configuredLifecycleScripts = publishLifecycleScripts.filter( + (scriptName) => scripts[scriptName] !== undefined, + ); + if (configuredLifecycleScripts.length > 0) { + errors.push( + `Packed artifact has publish lifecycle scripts: ${configuredLifecycleScripts.join(", ")}.`, + ); + } + return errors; +}; diff --git a/scripts/validate-release-artifact.test.mjs b/scripts/validate-release-artifact.test.mjs new file mode 100644 index 00000000..7376e9f6 --- /dev/null +++ b/scripts/validate-release-artifact.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { validateReleaseArtifact } from "./validate-release-artifact.mjs"; + +const workflow = readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"); +const publishJob = workflow.slice(workflow.indexOf("\n publish:")); + +const expected = { + name: "@rescript/webapi", + version: "1.0.0-alpha.1", +}; + +const validArtifact = { + name: expected.name, + version: expected.version, + publishConfig: { access: "public", provenance: true }, + scripts: {}, +}; + +test("accepts the expected alpha artifact metadata", () => { + assert.deepEqual(validateReleaseArtifact(expected, validArtifact), []); +}); + +test("rejects missing or false packed access and provenance", () => { + for (const publishConfig of [ + undefined, + { provenance: true }, + { access: "public" }, + { access: "restricted", provenance: true }, + { access: "public", provenance: false }, + ]) { + assert.notDeepEqual(validateReleaseArtifact(expected, { ...validArtifact, publishConfig }), []); + } +}); + +test("rejects packed registry and dist-tag overrides", () => { + for (const override of [ + { registry: "https://registry.example.invalid" }, + { tag: "latest" }, + { tag: "alpha" }, + ]) { + const errors = validateReleaseArtifact(expected, { + ...validArtifact, + publishConfig: { ...validArtifact.publishConfig, ...override }, + }); + assert.ok(errors.some((error) => error.includes("unexpected publishConfig overrides"))); + } +}); + +test("rejects package publish lifecycle scripts", () => { + const errors = validateReleaseArtifact(expected, { + ...validArtifact, + scripts: { prepublishOnly: "node scripts/untrusted.js" }, + }); + assert.ok(errors.some((error) => error.includes("prepublishOnly"))); +}); + +test("privileged workflow guard invokes Node directly without npm lifecycle hooks", () => { + assert.match(publishJob, /run: node scripts\/check-release\.mjs --publish/); + assert.doesNotMatch(publishJob, /npm run check:release/); + assert.match(publishJob, /npm publish .*--ignore-scripts/); + assert.match(publishJob, /validateReleaseArtifact\(\{ name, version \}, packed\)/); +}); + +test("direct release guard rejects non-main before a registry lookup", () => { + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("./check-release.mjs", import.meta.url)), "--publish"], + { + encoding: "utf8", + env: { + ...process.env, + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_REF: "refs/heads/release-branch", + }, + }, + ); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}\n${result.stderr}`, /only from refs\/heads\/main/); +}); From b973c1d7aae019e056808e2cff0706ec4bb171ed Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 6 Nov 2026 20:16:26 -0500 Subject: [PATCH 06/13] test: model tampered alpha metadata against valid config --- scripts/validate-release-artifact.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/validate-release-artifact.test.mjs b/scripts/validate-release-artifact.test.mjs index 7376e9f6..5d533aa6 100644 --- a/scripts/validate-release-artifact.test.mjs +++ b/scripts/validate-release-artifact.test.mjs @@ -11,6 +11,7 @@ const publishJob = workflow.slice(workflow.indexOf("\n publish:")); const expected = { name: "@rescript/webapi", version: "1.0.0-alpha.1", + publishConfig: { access: "public", provenance: true }, }; const validArtifact = { From 6a695d2ff87e317075a91128609a75cb1d2d9340 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Fri, 6 Nov 2026 20:22:47 -0500 Subject: [PATCH 07/13] build: include all browser harness scripts in format checks --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b6f475f2..7f170440 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", "check:consumer": "node scripts/check-consumer.mjs", "check:release": "node --test scripts/validate-release-artifact.test.mjs && node scripts/check-release.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" From bdc29107ee2f5fd6b891688aeb357cf7e209ff4f Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 10:58:10 -0500 Subject: [PATCH 08/13] docs: explain isolated local browser test 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 57083d192b0501beb94b97c7f75413a17086181d Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:02:17 -0500 Subject: [PATCH 09/13] docs: keep foundation browser guidance self-contained --- docs/content/docs/contributing/testing.mdx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/docs/content/docs/contributing/testing.mdx b/docs/content/docs/contributing/testing.mdx index 29571fb5..18c63947 100644 --- a/docs/content/docs/contributing/testing.mdx +++ b/docs/content/docs/contributing/testing.mdx @@ -52,17 +52,6 @@ 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 7770f8393ec735fbcbe2689d901905c159be1ecc Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:16:18 -0500 Subject: [PATCH 10/13] build: declare HTML entity decoder directly --- package-lock.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/package-lock.json b/package-lock.json index 097cecef..a7f317f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@astrojs/starlight": "0.42.8", "@playwright/test": "^1.64.1", "astro": "^7.2.9", + "entities": "4.5.0", "micromark": "^4.0.1", "oxfmt": "^0.76.0", "prettier": "^3.8.3", diff --git a/package.json b/package.json index 7f170440..be3ed46b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@astrojs/starlight": "0.42.8", "@playwright/test": "^1.64.1", "astro": "^7.2.9", + "entities": "4.5.0", "micromark": "^4.0.1", "oxfmt": "^0.76.0", "prettier": "^3.8.3", From 184db0360c4876ad2e0e6645fb12d3f8d0667ba8 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 7 Nov 2026 11:19:31 -0500 Subject: [PATCH 11/13] revert: keep docs checker dependency in docs stack --- package-lock.json | 1 - package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7f317f1..097cecef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@astrojs/starlight": "0.42.8", "@playwright/test": "^1.64.1", "astro": "^7.2.9", - "entities": "4.5.0", "micromark": "^4.0.1", "oxfmt": "^0.76.0", "prettier": "^3.8.3", diff --git a/package.json b/package.json index be3ed46b..7f170440 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "@astrojs/starlight": "0.42.8", "@playwright/test": "^1.64.1", "astro": "^7.2.9", - "entities": "4.5.0", "micromark": "^4.0.1", "oxfmt": "^0.76.0", "prettier": "^3.8.3", From dc5342b3838ebf3b876a7e8efbad8c767728e002 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 8 Nov 2026 12:05:52 -0500 Subject: [PATCH 12/13] fix(webapi): correct browser API types and calls [Codex] (#399) Fixes bindings that compiled but called the wrong browser method or used the wrong types. This covers WebSocket and worker calls, observer callbacks, and DOM, audio and crypto APIs. Byte readers are now typed as `Uint8Array` rather than plain arrays. HTTP body streams must yield `Uint8Array` chunks. Browser tests and compiler checks cover the corrected behavior. Builds on #397. Some public types change; #398 provides the migration notes and remaining documentation. Fixes #386. Fixes #389. Refs #383, #387, #388, #390, #391 and #392. --- docs/content/docs/contributing/testing.mdx | 11 + package-lock.json | 32 +- package.json | 3 +- playwright.config.mjs | 11 +- scripts/check-consumer.mjs | 5 + scripts/consumer-crypto.res | 78 ++++ .../negative/AnalyserRejectsOrdinaryArray.res | 7 + .../negative/BodyInitRejectsStringStream.res | 2 + .../negative/BroadElementHasNoClassName.res | 2 + .../fixtures/negative/BytesAreNotArray.res | 2 + .../CanvasRejectsNonNullableCallback.res | 2 + .../fixtures/negative/ClosestCannotNarrow.res | 2 + .../CustomElementRequiresConstructor.res | 9 + .../DOMMatrixRejectsOrdinaryArray.res | 1 + .../negative/GeneratePairRejectsAlgorithm.res | 14 + .../GenerateSymmetricRejectsAlgorithm.res | 11 + .../negative/IDBDeleteResultIsNotDatabase.res | 5 + .../negative/JwkIsNotBinaryKeyFormat.res | 15 + .../NotificationHasNoInstancePermission.res | 2 + .../negative/RawIsNotJwkKeyFormat.res | 15 + .../ResizeObserverRejectsCurriedCallback.res | 1 + .../negative/ResponseRejectsStringStream.res | 2 + .../negative/WebCryptoDigestReturnsBinary.res | 5 + ...bCryptoExportKeyFormatHasUnknownResult.res | 5 + .../WebCryptoGenerateKeyDoesNotReturnJSON.res | 9 + .../negative/WebCryptoSignReturnsBinary.res | 9 + .../WebCryptoVerifyReturnsBoolean.res | 7 + .../WebLocksRejectsNonNullableCallback.res | 9 + scripts/negative-fixtures.json | 132 +++++++ src/canvas/HTMLCanvasElement.res | 2 +- src/core/DOM.res | 1 + src/crypto/Crypto.res | 1 + src/crypto/SubtleCrypto.res | 189 ++++++---- src/crypto/WebCryptoTypes.res | 28 +- src/device/WebLocksTypes.res | 2 +- src/dom-nodes/DOMTree.res | 5 - src/dom-nodes/Element.res | 2 +- src/fetch/BodyInit.res | 2 +- src/fetch/FetchTypes.res | 6 +- src/fetch/Request.res | 2 +- src/fetch/Response.res | 5 +- 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 | 23 ++ tests/FetchAPI/SafeFetch__test.res | 16 +- tests/browser/BindingContracts__test.res | 357 ++++++++++++++++++ tests/browser/WebSocketBindings.res | 4 + tests/browser/WorkerFetchBinding.res | 7 + tests/browser/native-api.spec.mjs | 4 +- tests/browser/server.mjs | 10 +- tests/browser/websocket-bindings.spec.mjs | 62 +++ tests/browser/websocket-server.mjs | 18 + tests/browser/worker-fetch.mjs | 20 + tests/browser/worker-fetch.spec.mjs | 71 ++++ tests/browser/worker-http-server.mjs | 27 ++ tests/fixtures.json | 5 +- 70 files changed, 1223 insertions(+), 122 deletions(-) create mode 100644 scripts/consumer-crypto.res create mode 100644 scripts/fixtures/negative/AnalyserRejectsOrdinaryArray.res create mode 100644 scripts/fixtures/negative/BodyInitRejectsStringStream.res create mode 100644 scripts/fixtures/negative/BroadElementHasNoClassName.res create mode 100644 scripts/fixtures/negative/BytesAreNotArray.res create mode 100644 scripts/fixtures/negative/CanvasRejectsNonNullableCallback.res create mode 100644 scripts/fixtures/negative/ClosestCannotNarrow.res create mode 100644 scripts/fixtures/negative/CustomElementRequiresConstructor.res create mode 100644 scripts/fixtures/negative/DOMMatrixRejectsOrdinaryArray.res create mode 100644 scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res create mode 100644 scripts/fixtures/negative/GenerateSymmetricRejectsAlgorithm.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/ResizeObserverRejectsCurriedCallback.res create mode 100644 scripts/fixtures/negative/ResponseRejectsStringStream.res 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 create mode 100644 scripts/fixtures/negative/WebLocksRejectsNonNullableCallback.res create mode 100644 tests/BindingByteTypes__test.res create mode 100644 tests/browser/BindingContracts__test.res 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/websocket-server.mjs create mode 100644 tests/browser/worker-fetch.mjs create mode 100644 tests/browser/worker-fetch.spec.mjs create mode 100644 tests/browser/worker-http-server.mjs 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 diff --git a/package-lock.json b/package-lock.json index 097cecef..e796f0a5 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 7f170440..394052ad 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/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/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 new file mode 100644 index 00000000..79153a66 --- /dev/null +++ b/scripts/consumer-crypto.res @@ -0,0 +1,78 @@ +@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=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=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=WebAPI.SubtleCrypto.algorithmObject({name: "HMAC"}), + ~key=hmacKey, + ~data, +) +let verified: bool = await subtle->WebAPI.SubtleCrypto.verify( + ~algorithm=WebAPI.SubtleCrypto.algorithmObject({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=WebAPI.SubtleCrypto.algorithmObject({name: "ECDSA", hash: {name: "SHA-256"}}), + ~key=keyPair.privateKey, + ~data, +) + +let pairVerified: bool = await subtle->WebAPI.SubtleCrypto.verify( + ~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/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/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/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/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/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 new file mode 100644 index 00000000..01ac4703 --- /dev/null +++ b/scripts/fixtures/negative/ClosestCannotNarrow.res @@ -0,0 +1,2 @@ +let element: WebAPI.Element.t = %raw(`document.createElement("div")`) +let _input: Null.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/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/fixtures/negative/GeneratePairRejectsAlgorithm.res b/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res new file mode 100644 index 00000000..9ed7654a --- /dev/null +++ b/scripts/fixtures/negative/GeneratePairRejectsAlgorithm.res @@ -0,0 +1,14 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let _pair = WebAPI.SubtleCrypto.generateEcdsaKeyPair( + crypto.subtle, + ~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 new file mode 100644 index 00000000..cfc964a3 --- /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=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/IDBDeleteResultIsNotDatabase.res b/scripts/fixtures/negative/IDBDeleteResultIsNotDatabase.res new file mode 100644 index 00000000..33d703f1 --- /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 _close = request.result->WebAPI.IDBDatabase.close diff --git a/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res b/scripts/fixtures/negative/JwkIsNotBinaryKeyFormat.res new file mode 100644 index 00000000..82bcdd43 --- /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=WebAPI.SubtleCrypto.algorithmObject({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..0a55cea4 --- /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=WebAPI.SubtleCrypto.algorithmObject({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/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/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/fixtures/negative/WebCryptoDigestReturnsBinary.res b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res new file mode 100644 index 00000000..e1239bad --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoDigestReturnsBinary.res @@ -0,0 +1,5 @@ +let _digestReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~algorithm: WebAPI.WebCryptoTypes.algorithmIdentifier, + ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, +) => promise = WebAPI.SubtleCrypto.digest diff --git a/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res b/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res new file mode 100644 index 00000000..1dbfaeb6 --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoExportKeyFormatHasUnknownResult.res @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..a8f53577 --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoGenerateKeyDoesNotReturnJSON.res @@ -0,0 +1,9 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let _generateKeyReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~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 new file mode 100644 index 00000000..8f648778 --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoSignReturnsBinary.res @@ -0,0 +1,9 @@ +@scope("globalThis") +external crypto: WebAPI.Crypto.t = "crypto" + +let _signReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~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 new file mode 100644 index 00000000..2a8c980f --- /dev/null +++ b/scripts/fixtures/negative/WebCryptoVerifyReturnsBoolean.res @@ -0,0 +1,7 @@ +let _verifyReturnedJson: ( + WebAPI.WebCryptoTypes.subtleCrypto, + ~algorithm: WebAPI.WebCryptoTypes.algorithmIdentifier, + ~key: WebAPI.WebCryptoTypes.cryptoKey, + ~signature: WebAPI.ArrayBufferTypedArrayOrDataView.t, + ~data: WebAPI.ArrayBufferTypedArrayOrDataView.t, +) => promise = WebAPI.SubtleCrypto.verify 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 3dec583c..9eaffcbb 100644 --- a/scripts/negative-fixtures.json +++ b/scripts/negative-fixtures.json @@ -4,5 +4,137 @@ "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": "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", + "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", + "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": "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", + "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:" + }, + { + "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" + }, + { + "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/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..9d8f29ad 100644 --- a/src/crypto/SubtleCrypto.res +++ b/src/crypto/SubtleCrypto.res @@ -1,11 +1,30 @@ +type t = WebCryptoTypes.subtleCrypto +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 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: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithmIdentifier, + ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "encrypt" @@ -14,9 +33,9 @@ external encrypt: ( */ @send external decrypt: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithmIdentifier, + ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, ) => promise = "decrypt" @@ -25,77 +44,101 @@ external decrypt: ( */ @send external sign: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithmIdentifier, + ~key: cryptoKey, ~data: ArrayBufferTypedArrayOrDataView.t, -) => promise = "sign" +) => promise = "sign" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/verify) */ @send external verify: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, - ~key: WebCryptoTypes.cryptoKey, + t, + ~algorithm: algorithmIdentifier, + ~key: cryptoKey, ~signature: ArrayBufferTypedArrayOrDataView.t, ~data: ArrayBufferTypedArrayOrDataView.t, -) => promise = "verify" +) => promise = "verify" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) */ @send external digest: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + t, + ~algorithm: algorithmIdentifier, ~data: ArrayBufferTypedArrayOrDataView.t, -) => promise = "digest" +) => promise = "digest" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) */ @send external generateKey: ( - WebCryptoTypes.subtleCrypto, - ~algorithm: WebCryptoTypes.algorithm, + t, + ~algorithm: algorithmIdentifier, ~extractable: bool, - ~keyUsages: array, -) => promise = "generateKey" + ~keyUsages: array, +) => promise = "generateKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/generateKey) */ @send external generateKey2: ( - WebCryptoTypes.subtleCrypto, + t, ~algorithm: string, ~extractable: bool, - ~keyUsages: array, -) => promise = "generateKey" + ~keyUsages: array, +) => promise = "generateKey" + +let generateKeySymmetric = ( + subtle: t, + ~hash: algorithmIdentifier, + ~length: int, + ~extractable: bool, + ~keyUsages: array, +): promise => { + let _ = (subtle, hash, length, extractable, keyUsages) + %raw(`subtle.generateKey({name: "HMAC", hash, length}, extractable, keyUsages)`) +} + +/** +Generates an ECDSA public/private key pair. The fixed algorithm guarantees the native result shape. +*/ +let generateEcdsaKeyPair = ( + subtle: t, + ~namedCurve: string, + ~extractable: bool, + ~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.algorithmIdentifier, - ~baseKey: WebCryptoTypes.cryptoKey, - ~derivedKeyType: WebCryptoTypes.algorithmIdentifier, + t, + ~algorithm: algorithmIdentifier, + ~baseKey: cryptoKey, + ~derivedKeyType: algorithmIdentifier, ~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: algorithmIdentifier, + ~baseKey: cryptoKey, ~length: int=?, ) => promise = "deriveBits" @@ -104,9 +147,9 @@ external deriveBits: ( */ @send external deriveBits2: ( - WebCryptoTypes.subtleCrypto, + t, ~algorithm: string, - ~baseKey: WebCryptoTypes.cryptoKey, + ~baseKey: cryptoKey, ~length: int=?, ) => promise = "deriveBits" @@ -115,59 +158,79 @@ external deriveBits2: ( */ @send external importKey: ( - WebCryptoTypes.subtleCrypto, - ~format: unknown, + t, + ~format: keyFormat, ~keyData: ArrayBufferTypedArrayOrDataView.t, - ~algorithm: WebCryptoTypes.algorithmIdentifier, + ~algorithm: algorithmIdentifier, ~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, -) => promise = "wrapKey" + t, + ~format: keyFormat, + ~key: cryptoKey, + ~wrappingKey: cryptoKey, + ~wrapAlgorithm: algorithmIdentifier, +) => promise = "wrapKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/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" +) => promise = "wrapKey" /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) */ @send external unwrapKey: ( - WebCryptoTypes.subtleCrypto, - ~format: WebCryptoTypes.keyFormat, + t, + ~format: keyFormat, ~wrappedKey: ArrayBufferTypedArrayOrDataView.t, - ~unwrappingKey: WebCryptoTypes.cryptoKey, - ~unwrapAlgorithm: WebCryptoTypes.algorithmIdentifier, - ~unwrappedKeyAlgorithm: WebCryptoTypes.algorithmIdentifier, + ~unwrappingKey: cryptoKey, + ~unwrapAlgorithm: algorithmIdentifier, + ~unwrappedKeyAlgorithm: algorithmIdentifier, ~extractable: bool, - ~keyUsages: array, -) => promise = "unwrapKey" + ~keyUsages: array, +) => promise = "unwrapKey" + +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 +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: (t, ~format: binaryKeyFormat, ~key: cryptoKey) => promise = + "exportKey" + +@send +external exportKeyJwk: (t, ~format: jwkKeyFormat, ~key: cryptoKey) => promise = "exportKey" diff --git a/src/crypto/WebCryptoTypes.res b/src/crypto/WebCryptoTypes.res index 2d69697c..17565a5b 100644 --- a/src/crypto/WebCryptoTypes.res +++ b/src/crypto/WebCryptoTypes.res @@ -66,6 +66,32 @@ type cryptoKey = { usages: array, } +type cryptoKeyPair = { + privateKey: cryptoKey, + publicKey: cryptoKey, +} + type algorithmIdentifier = unknown -type algorithm = {mutable name: string} +type algorithm = { + mutable name: string, + 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/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/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/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..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) @@ -82,7 +83,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..bfc1eb1c --- /dev/null +++ b/tests/BindingByteTypes__test.res @@ -0,0 +1,23 @@ +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, + 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 = 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 bfc020fb..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.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 new file mode 100644 index 00000000..abc7d9b6 --- /dev/null +++ b/tests/browser/BindingContracts__test.res @@ -0,0 +1,357 @@ +@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" + +@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))`) + +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`) +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[1] === 66 && _result.value[2] === 67`) +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[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 wrongMiddleChunk: unknown = %raw(`({value: new Uint8Array([65, 0, 67])})`) +assert(!containsABCByteChunk(wrongMiddleChunk)) + +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 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 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 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") == + DomHTMLMediaElement.Empty) + +let permission = Notification.permission +assert(isKnownNotificationPermission(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 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, + ~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 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([-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 && 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([-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 && iirMagnitudes[0] !== -123 && !Number.isNaN(iirMagnitudes[0]) && iirPhases instanceof Float32Array && iirPhases[0] !== -456`)) + +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 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) +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 + } + resolveNextResizeSignal(resizeSignals) +}) +observer->ResizeObserver.observe(~target) +await initialResize +setSize(target, 20) +await resizedAgain +target->Element.remove + +let subtle = crypto.subtle +let digest = await subtle->SubtleCrypto.digest( + ~algorithm=SubtleCrypto.algorithmObject({name: "SHA-256"}), + ~data=Uint8Array.fromArray([65, 66, 67])->SubtleCrypto.binaryDataFromUint8Array, +) +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( + ~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=SubtleCrypto.algorithmObject({name: "HMAC"}), + ~key=hmacKey, + ~data, +) +let verified = await subtle->SubtleCrypto.verify( + ~algorithm=SubtleCrypto.algorithmObject({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.generateEcdsaKeyPair( + ~namedCurve="P-256", + ~extractable=true, + ~keyUsages=[SubtleCrypto.signUsage, SubtleCrypto.verifyUsage], +) +let _publicKeyPair: SubtleCrypto.cryptoKeyPair = keyPair +let pairSignature = await subtle->SubtleCrypto.sign( + ~algorithm=SubtleCrypto.algorithmObject({name: "ECDSA", hash: {name: "SHA-256"}}), + ~key=keyPair.privateKey, + ~data, +) +assert(await subtle->SubtleCrypto.verify( + ~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)) 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..c086af9c --- /dev/null +++ b/tests/browser/WorkerFetchBinding.res @@ -0,0 +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/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 new file mode 100644 index 00000000..5b771f45 --- /dev/null +++ b/tests/browser/websocket-bindings.spec.mjs @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; + +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}`)); + } + }); + }); + +test("WebSocket binding sends text, ArrayBuffer, and Blob payloads through a native local socket", async ({ + page, +}) => { + const server = await startWebSocketServer(); + try { + 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) => { + 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"))); + }); + }, server.port); + + 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.mjs b/tests/browser/worker-fetch.mjs new file mode 100644 index 00000000..381e7ba6 --- /dev/null +++ b/tests/browser/worker-fetch.mjs @@ -0,0 +1,20 @@ +import * as WorkerFetchBinding from "./WorkerFetchBinding.js"; + +self.addEventListener("message", async (event) => { + try { + 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({ + id: event.data.id, + response: await WorkerFetchBinding.text(response), + }); + } catch (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 new file mode 100644 index 00000000..8a02e14c --- /dev/null +++ b/tests/browser/worker-fetch.spec.mjs @@ -0,0 +1,71 @@ +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("/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" }); + const responses = {}; + worker.addEventListener("message", (event) => { + 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); + } + }); + worker.addEventListener( + "error", + (event) => { + worker.terminate(); + reject(new Error(event.message)); + }, + { once: true }, + ); + const url = `http://127.0.0.1:${port}/echo`; + worker.postMessage({ id: "initialized", url, withInit: true }); + worker.postMessage({ id: "default", url, withInit: false }); + }), + server.port, + ); + + expect(response).toStrictEqual({ + initialized: "POST|forwarded|binding-request-body", + default: "GET||", + }); + } 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`); + } +}); diff --git a/tests/fixtures.json b/tests/fixtures.json index a9d90487..f066adb1 100644 --- a/tests/fixtures.json +++ b/tests/fixtures.json @@ -1,6 +1,8 @@ { "compileOnly": [ "BindingAbsence__test.res", + "BindingByteTypes__test.res", + "browser/WorkerFetchBinding.res", "CanvasAPI/Path2D__test.res", "CSSFontLoadingAPI/FontFace__test.res", "DOMAPI/AddEventListener__test.res", @@ -29,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", @@ -44,5 +47,5 @@ "FetchAPI/URLSearchParams__test.res", "URLAPI/URL__test.res" ], - "browserRuntime": ["browser/NativeAPI__test.res"] + "browserRuntime": ["browser/BindingContracts__test.res", "browser/NativeAPI__test.res"] } From 0bae03a54c2986a53770bb888e14d7523449a6ff Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 8 Nov 2026 12:56:30 -0500 Subject: [PATCH 13/13] fix(release): allow published versions in pull request checks --- .github/workflows/release.yml | 6 +- package.json | 6 +- scripts/check-release.mjs | 39 ++-- scripts/check-release.test.mjs | 209 +++++++++++++++++++++ scripts/validate-release-artifact.test.mjs | 20 -- 5 files changed, 244 insertions(+), 36 deletions(-) create mode 100644 scripts/check-release.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf62a5d8..eb0e19d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,9 +60,13 @@ jobs: - name: Build documentation run: npm run build:docs - - name: Validate explicit alpha version and unpublished state + - name: Validate alpha package metadata run: npm run check:release + - name: Check unpublished release version + if: github.event_name == 'workflow_dispatch' + run: node scripts/check-release.mjs --check-unpublished + - name: Package release artifact id: package run: | diff --git a/package.json b/package.json index 394052ad..79731283 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,9 @@ "build": "rescript", "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", "check:consumer": "node scripts/check-consumer.mjs", - "check:release": "node --test scripts/validate-release-artifact.test.mjs && node scripts/check-release.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", + "check:release": "node --test scripts/validate-release-artifact.test.mjs scripts/check-release.test.mjs && node scripts/check-release.mjs", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-release.test.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-release.test.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index 233d4760..497439bb 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -7,6 +7,15 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); const lockFile = JSON.parse(readFileSync(path.join(repoRoot, "package-lock.json"), "utf8")); const versionPattern = /^1\.0\.0-alpha\.[1-9]\d*$/; +const args = process.argv.slice(2); +const mode = args[0]; + +if ( + args.length > 1 || + (mode !== undefined && mode !== "--check-unpublished" && mode !== "--publish") +) { + throw new Error("Usage: node scripts/check-release.mjs [--check-unpublished | --publish]"); +} if (!versionPattern.test(packageJson.version)) { throw new Error(`Expected an explicit 1.0.0-alpha.N version; received ${packageJson.version}.`); @@ -23,7 +32,7 @@ if ( ) { throw new Error("Alpha packages must retain public access and provenance configuration."); } -const publishRequested = process.argv.includes("--publish"); +const publishRequested = mode === "--publish"; if (publishRequested && process.env.GITHUB_REF !== "refs/heads/main") { throw new Error("Real alpha publication is allowed only from refs/heads/main."); } @@ -32,20 +41,26 @@ if (publishRequested && process.env.GITHUB_EVENT_NAME !== "workflow_dispatch") { } const packageReference = `${packageJson.name}@${packageJson.version}`; -const published = spawnSync("npm", ["view", packageReference, "version"], { - cwd: repoRoot, - encoding: "utf8", -}); -const registryOutput = `${published.stdout ?? ""}\n${published.stderr ?? ""}`; -if (published.status === 0) { - throw new Error(`${packageReference} is already published; increment the alpha version.`); -} -if (!/\bE404\b|404 Not Found/.test(registryOutput)) { - throw new Error(`Unable to verify that ${packageReference} is unpublished:\n${registryOutput}`); +if (mode === "--check-unpublished" || publishRequested) { + const published = spawnSync("npm", ["view", packageReference, "version"], { + cwd: repoRoot, + encoding: "utf8", + }); + const registryOutput = `${published.stdout ?? ""}\n${published.stderr ?? ""}`; + if (published.status === 0) { + throw new Error(`${packageReference} is already published; increment the alpha version.`); + } + if (!/\bE404\b|404 Not Found/.test(registryOutput)) { + throw new Error(`Unable to verify that ${packageReference} is unpublished:\n${registryOutput}`); + } } if (publishRequested) { console.log(`Verified unpublished alpha version ${packageReference} for the main branch.`); -} else { +} else if (mode === "--check-unpublished") { console.log(`Verified unpublished alpha version ${packageReference}; expected dist-tag: alpha.`); +} else { + console.log( + `Validated alpha metadata for ${packageReference}; registry availability not checked.`, + ); } diff --git a/scripts/check-release.test.mjs b/scripts/check-release.test.mjs new file mode 100644 index 00000000..068b1b7f --- /dev/null +++ b/scripts/check-release.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const metadata = { + name: "@rescript/webapi", + version: "1.0.0-alpha.1", + publishConfig: { access: "public", provenance: true }, +}; +const releaseEnvironment = { + GITHUB_REF: "refs/heads/main", + GITHUB_EVENT_NAME: "workflow_dispatch", +}; + +const createProject = (t, { manifest = metadata, lockVersion = metadata.version } = {}) => { + const directory = mkdtempSync(path.join(os.tmpdir(), "webapi-release-check-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + mkdirSync(path.join(directory, "scripts")); + copyFileSync( + new URL("./check-release.mjs", import.meta.url), + path.join(directory, "scripts/check-release.mjs"), + ); + writeFileSync(path.join(directory, "package.json"), JSON.stringify(manifest)); + writeFileSync( + path.join(directory, "package-lock.json"), + JSON.stringify({ + version: lockVersion, + packages: { "": { name: metadata.name, version: lockVersion } }, + }), + ); + writeFileSync(path.join(directory, ".npmrc"), ""); + writeFileSync(path.join(directory, "global.npmrc"), ""); + return directory; +}; + +const registryReplies = { + published: { + status: 200, + body: { + name: metadata.name, + "dist-tags": { latest: metadata.version }, + versions: { [metadata.version]: metadata }, + }, + }, + unpublished: { status: 404, body: { error: "Not found" } }, + unavailable: { status: 503, body: { error: "Registry unavailable" } }, +}; + +const startRegistry = async (t, reply = registryReplies.published) => { + let requests = 0; + const server = createServer((_request, response) => { + requests += 1; + response.writeHead(reply.status, { "content-type": "application/json" }); + response.end(JSON.stringify(reply.body)); + }); + t.after( + () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => (error ? reject(error) : resolve())); + }), + ); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return { + url: `http://127.0.0.1:${server.address().port}`, + requestCount: () => requests, + }; +}; + +const runCheck = (directory, args, registry, environment = {}) => + new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [path.join(directory, "scripts/check-release.mjs"), ...args], + { + cwd: directory, + stdio: ["ignore", "pipe", "pipe"], + timeout: 15_000, + env: { + ...process.env, + GITHUB_REF: "refs/pull/1/merge", + GITHUB_EVENT_NAME: "pull_request", + npm_config_registry: registry.url, + npm_config_cache: path.join(directory, "cache"), + npm_config_userconfig: path.join(directory, ".npmrc"), + npm_config_globalconfig: path.join(directory, "global.npmrc"), + npm_config_noproxy: "127.0.0.1", + npm_config_fetch_retries: "0", + npm_config_fetch_timeout: "5000", + npm_config_update_notifier: "false", + ...environment, + }, + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { + stdout += data; + }); + child.stderr.on("data", (data) => { + stderr += data; + }); + child.once("error", reject); + child.once("close", (status) => resolve({ status, output: `${stdout}\n${stderr}` })); + }); + +test("pull-request metadata validation accepts a published version without a registry lookup", async (t) => { + const directory = createProject(t); + const registry = await startRegistry(t); + const result = await runCheck(directory, [], registry); + + assert.equal(result.status, 0, result.output); + assert.match(result.output, /Validated alpha metadata/); + assert.equal(registry.requestCount(), 0); +}); + +for (const [name, fixture, diagnostic] of [ + [ + "non-alpha versions", + { manifest: { ...metadata, version: "1.0.0" } }, + /Expected an explicit 1\.0\.0-alpha\.N version/, + ], + ["mismatched lock versions", { lockVersion: "1.0.0-alpha.2" }, /versions must match/], + [ + "missing provenance", + { manifest: { ...metadata, publishConfig: { access: "public" } } }, + /public access and provenance/, + ], +]) { + test(`pull-request metadata validation still rejects ${name}`, async (t) => { + const directory = createProject(t, fixture); + const registry = await startRegistry(t); + const result = await runCheck(directory, [], registry); + + assert.notEqual(result.status, 0); + assert.match(result.output, diagnostic); + assert.equal(registry.requestCount(), 0); + }); +} + +for (const mode of ["--check-unpublished", "--publish"]) { + for (const [state, status, diagnostic] of [ + ["published", 1, /already published/], + ["unpublished", 0, /Verified unpublished alpha version/], + ["unavailable", 1, /Unable to verify/], + ]) { + test(`${mode} handles a ${state} registry result`, async (t) => { + const directory = createProject(t); + const registry = await startRegistry(t, registryReplies[state]); + const result = await runCheck(directory, [mode], registry, releaseEnvironment); + + assert.equal(result.status, status, result.output); + assert.match(result.output, diagnostic); + assert.ok(registry.requestCount() > 0); + }); + } +} + +for (const [environment, diagnostic] of [ + [{ ...releaseEnvironment, GITHUB_REF: "refs/heads/feature" }, /only from refs\/heads\/main/], + [{ ...releaseEnvironment, GITHUB_EVENT_NAME: "pull_request" }, /explicit workflow_dispatch/], +]) { + test(`publishing rejects ${JSON.stringify(environment)} before a registry lookup`, async (t) => { + const directory = createProject(t); + const registry = await startRegistry(t); + const result = await runCheck(directory, ["--publish"], registry, environment); + + assert.notEqual(result.status, 0); + assert.match(result.output, diagnostic); + assert.equal(registry.requestCount(), 0); + }); +} + +test("unknown release flags fail instead of silently skipping checks", async (t) => { + const directory = createProject(t); + const registry = await startRegistry(t); + const result = await runCheck(directory, ["--check-unpublshed"], registry); + + assert.notEqual(result.status, 0); + assert.match(result.output, /Usage:/); + assert.equal(registry.requestCount(), 0); +}); + +test("pull requests validate metadata while release dispatches also check the registry", () => { + const workflow = readFileSync( + new URL("../.github/workflows/release.yml", import.meta.url), + "utf8", + ); + const validateJob = workflow.slice( + workflow.indexOf("\n validate:"), + workflow.indexOf("\n publish:"), + ); + + assert.match( + validateJob, + /- name: Validate alpha package metadata\n\s+run: npm run check:release/, + ); + assert.match( + validateJob, + /- name: Check unpublished release version\n\s+if: github\.event_name == 'workflow_dispatch'\n\s+run: node scripts\/check-release\.mjs --check-unpublished/, + ); +}); diff --git a/scripts/validate-release-artifact.test.mjs b/scripts/validate-release-artifact.test.mjs index 5d533aa6..4a3e6b7b 100644 --- a/scripts/validate-release-artifact.test.mjs +++ b/scripts/validate-release-artifact.test.mjs @@ -1,8 +1,6 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { test } from "node:test"; -import { fileURLToPath } from "node:url"; import { validateReleaseArtifact } from "./validate-release-artifact.mjs"; const workflow = readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8"); @@ -65,21 +63,3 @@ test("privileged workflow guard invokes Node directly without npm lifecycle hook assert.match(publishJob, /npm publish .*--ignore-scripts/); assert.match(publishJob, /validateReleaseArtifact\(\{ name, version \}, packed\)/); }); - -test("direct release guard rejects non-main before a registry lookup", () => { - const result = spawnSync( - process.execPath, - [fileURLToPath(new URL("./check-release.mjs", import.meta.url)), "--publish"], - { - encoding: "utf8", - env: { - ...process.env, - GITHUB_EVENT_NAME: "workflow_dispatch", - GITHUB_REF: "refs/heads/release-branch", - }, - }, - ); - - assert.notEqual(result.status, 0); - assert.match(`${result.stdout}\n${result.stderr}`, /only from refs\/heads\/main/); -});