diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fb11b81..d5e12276 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 @@ -36,9 +34,21 @@ 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 + - 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..eb0e19d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,12 +2,10 @@ name: Release on: pull_request: - branches: - - main 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 @@ -16,11 +14,8 @@ permissions: contents: read jobs: - publish: + validate: runs-on: ubuntu-latest - permissions: - contents: read - id-token: write steps: - name: Checkout repository @@ -30,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 @@ -48,39 +42,123 @@ 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 + - 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 - - name: get-npm-version - id: package-version - uses: martinbeentjes/npm-get-version-action@v1.3.1 + - name: Validate alpha package metadata + run: npm run check:release - - name: Get short commit hash - id: vars - run: echo "COMMIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + - name: Check unpublished release version + if: github.event_name == 'workflow_dispatch' + run: node scripts/check-release.mjs --check-unpublished - - 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 + - name: Dry-run publish the validated artifact + run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --dry-run --ignore-scripts - - 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: Upload validated release 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 + 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"; + 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" })); + 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: 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 --ignore-scripts + + - 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/.gitignore b/.gitignore index 24b4284b..abe3638e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,12 @@ .bsb.lock .astro dist/ +.release/ 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..29571fb5 100644 --- a/docs/content/docs/contributing/testing.mdx +++ b/docs/content/docs/contributing/testing.mdx @@ -4,24 +4,27 @@ 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`, +`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 - - HTMLCanvasElement_test.res + - FetchAPI + - Headers\_\_test.res @@ -33,12 +36,46 @@ 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. +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. + +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 +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/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 4350f592..e796f0a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,27 @@ { "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", + "@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", - "sharp": "^0.35.3" + "rescript": "13.0.0-alpha.5", + "sharp": "^0.35.3", + "ws": "^8.22.1" }, "peerDependencies": { - "rescript": ">=13" + "rescript": ">=13.0.0-alpha.5 <13.0.0" } }, "node_modules/@astrojs/compiler-binding": { @@ -1809,6 +1811,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 +4133,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 +4999,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 +5084,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": { @@ -6040,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", @@ -6991,6 +7061,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 +8154,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 +8491,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 +9001,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 +9033,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", @@ -9539,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 91292394..79731283 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,26 +36,36 @@ "provenance": true }, "scripts": { - "test": "node tests/index.js", + "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 ./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", + "check:consumer": "node scripts/check-consumer.mjs", + "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" }, "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", - "sharp": "^0.35.3" + "rescript": "13.0.0-alpha.5", + "sharp": "^0.35.3", + "ws": "^8.22.1" }, "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..a8dccc78 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,23 @@ +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", + workers: 1, + reporter: "list", + use: { + browserName: "chromium", + baseURL: testOrigin, + }, + webServer: { + command: "node tests/browser/server.mjs", + url: `${testOrigin}/tests/browser/index.html`, + reuseExistingServer: false, + }, +}); diff --git a/scripts/check-consumer.mjs b/scripts/check-consumer.mjs new file mode 100644 index 00000000..2300d4d5 --- /dev/null +++ b/scripts/check-consumer.mjs @@ -0,0 +1,254 @@ +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"; +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")); +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)); + const artifactErrors = validateReleaseArtifact( + { name: packageJson.name, version: packageJson.version }, + packedMetadata, + ); + if (artifactErrors.length > 0) { + throw new Error(`Packed artifact is not publishable:\n${artifactErrors.join("\n")}`); + } + 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: "Crypto", + fixture: "scripts/consumer-crypto.res", + features: ["WebAPI.Crypto"], + }, + { + 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..497439bb --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,66 @@ +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*$/; +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}.`); +} +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 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."); +} +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}`; +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 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/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/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/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/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/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 new file mode 100644 index 00000000..9eaffcbb --- /dev/null +++ b/scripts/negative-fixtures.json @@ -0,0 +1,140 @@ +[ + { + "name": "expected compiler diagnostic matching", + "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/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..4a3e6b7b --- /dev/null +++ b/scripts/validate-release-artifact.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +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", + publishConfig: { access: "public", provenance: true }, +}; + +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\)/); +}); 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/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/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/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/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/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/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/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/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..f1f519a4 --- /dev/null +++ b/tests/browser/native-api.spec.mjs @@ -0,0 +1,60 @@ +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({ message: error.message, stack: error.stack })); + 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 = []; + 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("/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"); +}); diff --git a/tests/browser/server.mjs b/tests/browser/server.mjs new file mode 100644 index 00000000..7a593a13 --- /dev/null +++ b/tests/browser/server.mjs @@ -0,0 +1,35 @@ +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"], +]); + +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}`)) { + 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(); + } +}); + +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 new file mode 100644 index 00000000..f066adb1 --- /dev/null +++ b/tests/fixtures.json @@ -0,0 +1,51 @@ +{ + "compileOnly": [ + "BindingAbsence__test.res", + "BindingByteTypes__test.res", + "browser/WorkerFetchBinding.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", + "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", + "WebAudioAPI/AudioDestinationNode__.test.res", + "WebAudioAPI/OfflineAudioContext__test.res", + "browser/WebSocketBindings.res", + "WebSocketsAPI/WebSocket__test.res", + "WebStorageAPI/Storage__test.res", + "WebWorkersAPI/SharedWorkerScope__test.res", + "WebWorkersAPI/SharedWorker__test.res" + ], + "documentationExamples": ["Fetch__test.res", "VisualViewport__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/BindingContracts__test.res", "browser/NativeAPI__test.res"] +} diff --git a/tests/index.js b/tests/index.js index 1547bb4f..4803a803 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,33 @@ 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.documentationExamples, + ...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 +46,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 +78,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);