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..e547814b 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,124 @@ 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 explicit alpha version and unpublished state + run: npm run check:release + + - name: Package release artifact + id: package + run: | + 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: Verify packed consumers and tarball contents + run: npm run check:consumer -- "${{ steps.package.outputs.artifact }}" - - name: Get short commit hash - id: vars - run: echo "COMMIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + - name: Dry-run publish the validated artifact + run: npm publish "${{ steps.package.outputs.artifact }}" --access public --tag alpha --dry-run - - name: Update package version to - - env: - PACKAGE_VERSION: ${{ steps.package-version.outputs.current-version }}-alpha-${{ env.COMMIT_HASH }} + - name: Reject real publication from non-main branches + if: github.event_name == 'workflow_dispatch' && !inputs.dry_run && github.ref != 'refs/heads/main' 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`); + 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 + uses: actions/upload-artifact@v4 + with: + name: alpha-release-artifact + path: ${{ steps.package.outputs.artifact }} + if-no-files-found: error + retention-days: 1 + + publish: + needs: validate + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.dry_run + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: "26" + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Setup npm + run: npm install --global npm@11.5.1 + + - name: Download validated release artifact + uses: actions/download-artifact@v4 + with: + name: alpha-release-artifact + path: .release + + - name: Verify downloaded artifact metadata + run: | + artifact=".release/rescript-webapi-$(node -p 'require("./package.json").version').tgz" + test -f "$artifact" + node --input-type=module <<'NODE' + import { execFileSync } from "node:child_process"; + import { readFileSync } from "node:fs"; + const { name, version, publishConfig } = JSON.parse(readFileSync("package.json", "utf8")); + const artifact = `.release/rescript-webapi-${version}.tgz`; + const packed = JSON.parse(execFileSync("tar", ["-xOf", artifact, "package/package.json"], { encoding: "utf8" })); + if (packed.name !== name || packed.version !== version || !/^1\.0\.0-alpha\.[1-9]\d*$/.test(version)) { + throw new Error("Downloaded artifact does not match the numbered alpha package metadata."); + } + if (publishConfig.access !== "public" || publishConfig.provenance !== true) { + throw new Error("Downloaded artifact is missing public/provenance publishing metadata."); + } + const publishLifecycleScripts = ["prepare", "prepack", "prepublishOnly", "publish", "postpublish"]; + if (publishLifecycleScripts.some((script) => packed.scripts?.[script] !== undefined)) { + throw new Error("Downloaded artifact contains a publish lifecycle script."); + } + console.log(`Verified ${name}@${version} artifact before publishing with the alpha tag.`); NODE - - name: Package npm package - run: npm pack + - name: Verify publication guard + run: npm run check:release -- --publish - - 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: Publish alpha artifact + run: npm publish ".release/rescript-webapi-$(node -p 'require("./package.json").version').tgz" --access public --tag alpha --provenance - - name: Publish npm package - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && !inputs.dry_run - run: npm publish ./rescript-webapi-*.tgz --access public --tag alpha + - 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..18c63947 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,35 @@ 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. + +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 d06e3beb..d5fdbc4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,26 @@ { "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.75.0", "prettier": "^3.8.3", "prettier-plugin-astro": "^1.0.0", - "rescript": "^13.0.0-alpha.4", + "rescript": "13.0.0-alpha.5", "sharp": "^0.35.3" }, "peerDependencies": { - "rescript": ">=13" + "rescript": ">=13.0.0-alpha.5 <13.0.0" } }, "node_modules/@astrojs/compiler-binding": { @@ -1809,6 +1810,22 @@ "win32" ] }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@prettier/parse-srcset": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@prettier/parse-srcset/-/parse-srcset-3.1.0.tgz", @@ -4115,10 +4132,11 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "dev": true, + "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", @@ -4980,6 +4998,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", @@ -5036,9 +5083,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6991,6 +7038,15 @@ "dev": true, "optional": true }, + "@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "requires": { + "playwright": "1.64.1" + } + }, "@prettier/parse-srcset": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@prettier/parse-srcset/-/parse-srcset-3.1.0.tgz", @@ -8075,7 +8131,7 @@ "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", + "mdast-util-to-hast": "^13.2.1", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", @@ -8412,9 +8468,9 @@ } }, "mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "dev": true, "requires": { "@types/hast": "^3.0.0", @@ -8922,6 +8978,21 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true }, + "playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "requires": { + "playwright-core": "1.64.1" + } + }, + "playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true + }, "postcss": { "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", @@ -8939,13 +9010,13 @@ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", "dev": true, "requires": { - "postcss-selector-parser": "^6.1.1" + "postcss-selector-parser": "^6.1.5" } }, "postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "dev": true, "requires": { "cssesc": "^3.0.0", diff --git a/package.json b/package.json index 76f4fa8d..e900c0fa 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,35 @@ "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 scripts/check-release.mjs", + "format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write .github/workflows/*.yml ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/native-api.spec.mjs ./tests/browser/describe-error.mjs ./tests/browser/server.mjs ./scripts/negative-fixtures.json ./playwright.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages", "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.75.0", "prettier": "^3.8.3", "prettier-plugin-astro": "^1.0.0", - "rescript": "^13.0.0-alpha.4", + "rescript": "13.0.0-alpha.5", "sharp": "^0.35.3" }, "peerDependencies": { - "rescript": ">=13" + "rescript": ">=13.0.0-alpha.5 <13.0.0" + }, + "overrides": { + "mdast-util-to-hast": "^13.2.1", + "postcss-selector-parser": "^6.1.5" } } diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 00000000..66e6430e --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/browser", + testMatch: "**/*.spec.mjs", + workers: 1, + reporter: "list", + use: { + browserName: "chromium", + }, + webServer: { + command: "node tests/browser/server.mjs", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/scripts/check-consumer.mjs b/scripts/check-consumer.mjs new file mode 100644 index 00000000..ce2212fa --- /dev/null +++ b/scripts/check-consumer.mjs @@ -0,0 +1,265 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); +const compilerVersion = "13.0.0-alpha.5"; +if ( + packageJson.devDependencies.rescript !== compilerVersion || + packageJson.peerDependencies.rescript !== ">=13.0.0-alpha.5 <13.0.0" +) { + throw new Error("The tested compiler and package peer range are inconsistent."); +} +const requiredFiles = ["LICENSE", "README.md", "package.json", "rescript.json"]; +const forbiddenPaths = /^(?:tests|scripts|docs|\.github|node_modules|lib|dist)\//; + +const runCommand = (command, args, cwd) => { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (result.error || result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed.\n${result.error?.message ?? output}`); + } + return output; +}; + +const validateTarballFiles = (files) => { + const paths = files.map(({ path: filePath }) => filePath); + const missingFiles = requiredFiles.filter((filePath) => !paths.includes(filePath)); + const invalidFiles = paths.filter( + (filePath) => + forbiddenPaths.test(filePath) || + (!requiredFiles.includes(filePath) && + !(filePath.startsWith("src/") && /\.(?:res|resi)$/.test(filePath)) && + filePath !== "LICENSE" && + filePath !== "README.md"), + ); + if (missingFiles.length > 0 || invalidFiles.length > 0) { + throw new Error( + `Unexpected package contents. Missing: ${missingFiles.join(", ") || "none"}. Unexpected: ${invalidFiles.join(", ") || "none"}.`, + ); + } + if (!paths.some((filePath) => filePath.startsWith("src/") && filePath.endsWith(".res"))) { + throw new Error("The package tarball contains no ReScript source files."); + } + if (!paths.some((filePath) => filePath.startsWith("src/") && filePath.endsWith(".resi"))) { + throw new Error("The package tarball contains no ReScript interface files."); + } +}; + +const writeProject = (directory, sourceFile, features) => { + const sourceDirectory = path.join(directory, "src"); + const fixturePath = path.resolve(repoRoot, sourceFile); + if (!fixturePath.startsWith(`${path.join(repoRoot, "scripts")}${path.sep}`)) { + throw new Error(`Consumer fixture must be under scripts/: ${sourceFile}`); + } + mkdirSync(sourceDirectory, { recursive: true }); + writeFileSync(path.join(directory, "package.json"), '{"type":"module"}\n'); + writeFileSync( + path.join(directory, "rescript.json"), + `${JSON.stringify( + { + name: "packed-webapi-consumer", + sources: [{ dir: "src" }], + dependencies: + features === null ? ["@rescript/webapi"] : [{ name: "@rescript/webapi", features }], + "experimental-features": { LetUnwrap: true }, + warnings: { error: "+8" }, + "package-specs": { module: "esmodule", "in-source": true }, + }, + null, + 2, + )}\n`, + ); + writeFileSync(path.join(sourceDirectory, "Consumer.res"), readFileSync(fixturePath)); +}; + +const compilerCli = (directory) => + path.join(directory, "node_modules", "rescript", "cli", "rescript.js"); + +const compile = (directory, expectedFailure = null) => { + const cli = compilerCli(directory); + const clean = spawnSync(process.execPath, [cli, "clean"], { cwd: directory }); + if (clean.status !== 0) { + throw new Error("Installed compiler failed to clean the consumer project."); + } + const result = spawnSync(process.execPath, [cli, "build", "--prod"], { + cwd: directory, + encoding: "utf8", + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}` + .replace(/\u001b\[[0-9;]*m/g, "") + .replace(/\s+/g, " "); + if (expectedFailure === null && result.status !== 0) { + throw new Error(`Consumer failed to compile:\n${output}`); + } + const diagnosticMatches = + expectedFailure instanceof RegExp + ? expectedFailure.test(output) + : typeof expectedFailure === "string" && output.includes(expectedFailure); + if (expectedFailure !== null && (result.status === 0 || !diagnosticMatches)) { + throw new Error( + `Consumer did not fail with the expected diagnostic ${expectedFailure}; exit status ${result.status}:\n${output}`, + ); + } +}; + +const verifyUnsupportedCompiler = (directory, tarballPath) => { + mkdirSync(directory); + writeFileSync(path.join(directory, "package.json"), '{"type":"module"}\n'); + const result = spawnSync( + "npm", + ["install", "--no-audit", "--no-fund", "--no-save", tarballPath, "rescript@12.3.1"], + { cwd: directory, encoding: "utf8" }, + ); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if ( + result.status === 0 || + !output.includes("@rescript/webapi") || + !output.includes(">=13.0.0-alpha.5 <13.0.0") + ) { + throw new Error( + `Unsupported compiler installation did not explain the peer constraint:\n${output}`, + ); + } +}; + +const checkConsumers = async () => { + const tempDirectory = mkdtempSync(path.join(os.tmpdir(), "webapi-packed-consumer-")); + try { + const negativeOnly = process.argv.includes("--negative-only"); + const suppliedArtifact = process.argv.slice(2).find((argument) => !argument.startsWith("--")); + let tarballPath; + let packagedFiles; + if (suppliedArtifact === undefined) { + const packOutput = runCommand( + "npm", + ["pack", "--json", "--pack-destination", tempDirectory], + repoRoot, + ); + const [pack] = JSON.parse(packOutput); + tarballPath = path.join(tempDirectory, pack.filename); + packagedFiles = pack.files; + } else { + tarballPath = path.resolve(repoRoot, suppliedArtifact); + if (!existsSync(tarballPath)) { + throw new Error(`Packed artifact does not exist: ${tarballPath}`); + } + packagedFiles = runCommand("tar", ["-tzf", tarballPath], repoRoot) + .split(/\r?\n/) + .filter((filePath) => filePath.startsWith("package/")) + .map((filePath) => ({ path: filePath.slice("package/".length) })); + } + validateTarballFiles(packagedFiles); + const packedMetadata = + suppliedArtifact === undefined + ? packageJson + : JSON.parse(runCommand("tar", ["-xOf", tarballPath, "package/package.json"], repoRoot)); + if ( + packedMetadata.name !== packageJson.name || + packedMetadata.version !== packageJson.version || + !/^1\.0\.0-alpha\.[1-9]\d*$/.test(packedMetadata.version) + ) { + throw new Error( + "Packed artifact name/version does not match the explicit alpha package metadata.", + ); + } + const publishLifecycleScripts = [ + "prepare", + "prepack", + "prepublishOnly", + "publish", + "postpublish", + ]; + const configuredPublishScripts = publishLifecycleScripts.filter( + (scriptName) => packedMetadata.scripts?.[scriptName] !== undefined, + ); + if (configuredPublishScripts.length > 0) { + throw new Error( + `Packed artifact has lifecycle scripts that would execute during publish: ${configuredPublishScripts.join(", ")}.`, + ); + } + const projectDirectory = path.join(tempDirectory, "project"); + mkdirSync(projectDirectory); + writeFileSync(path.join(projectDirectory, "package.json"), '{"type":"module"}\n'); + runCommand( + "npm", + [ + "install", + "--no-audit", + "--no-fund", + "--no-save", + tarballPath, + `rescript@${compilerVersion}`, + ], + projectDirectory, + ); + console.log(`[ok] installed tarball with supported ReScript ${compilerVersion}`); + + verifyUnsupportedCompiler(path.join(tempDirectory, "unsupported"), tarballPath); + console.log("[ok] unsupported ReScript version rejected with peer-range diagnostic"); + + if (!negativeOnly) { + const scenarios = [ + { name: "default", fixture: "scripts/consumer-default.res", features: null }, + { + name: "Fetch-only", + fixture: "scripts/consumer-fetch.res", + features: ["WebAPI.Fetch"], + }, + { + name: "SafeFetch", + fixture: "scripts/consumer-safe-fetch.res", + features: ["WebAPI.SafeFetch"], + }, + { + name: "Fetch-only SafeFetch rejection", + fixture: "scripts/consumer-safe-fetch-disabled.res", + features: ["WebAPI.Fetch"], + expectedFailure: /The module or file (?:WebAPI\.)?SafeFetch can't be found\./, + }, + ]; + + for (const scenario of scenarios) { + writeProject(projectDirectory, scenario.fixture, scenario.features); + compile(projectDirectory, scenario.expectedFailure ?? null); + console.log(`[ok] packed ${scenario.name} consumer`); + } + + writeProject(projectDirectory, "scripts/consumer-runtime.res", ["WebAPI.Fetch"]); + compile(projectDirectory); + runCommand( + process.execPath, + [ + "--input-type=module", + "--eval", + `await import(${JSON.stringify(pathToFileURL(path.join(projectDirectory, "src/Consumer.js")).href)})`, + ], + projectDirectory, + ); + console.log(`[ok] packed consumer runtime using ReScript ${compilerVersion}`); + } + + const negativeCases = JSON.parse( + readFileSync(path.join(repoRoot, "scripts/negative-fixtures.json"), "utf8"), + ); + if (negativeOnly && negativeCases.length === 0) { + throw new Error("No negative consumer fixtures are registered."); + } + for (const negativeCase of negativeCases) { + writeProject(projectDirectory, negativeCase.fixture, negativeCase.features); + compile(projectDirectory, negativeCase.expectedDiagnostic); + console.log(`[ok] expected rejection: ${negativeCase.name}`); + } + console.log(`[ok] tarball contents (${packagedFiles.length} files)`); + } finally { + rmSync(tempDirectory, { recursive: true, force: true }); + } +}; + +checkConsumers().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 00000000..283d9950 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,50 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); +const lockFile = JSON.parse(readFileSync(path.join(repoRoot, "package-lock.json"), "utf8")); +const versionPattern = /^1\.0\.0-alpha\.[1-9]\d*$/; + +if (!versionPattern.test(packageJson.version)) { + throw new Error(`Expected an explicit 1.0.0-alpha.N version; received ${packageJson.version}.`); +} +if ( + lockFile.version !== packageJson.version || + lockFile.packages?.[""]?.version !== packageJson.version +) { + throw new Error("package.json and package-lock.json versions must match."); +} +if ( + packageJson.publishConfig?.access !== "public" || + packageJson.publishConfig?.provenance !== true +) { + throw new Error("Alpha packages must retain public access and provenance configuration."); +} + +const packageReference = `${packageJson.name}@${packageJson.version}`; +const published = spawnSync("npm", ["view", packageReference, "version"], { + cwd: repoRoot, + encoding: "utf8", +}); +const registryOutput = `${published.stdout ?? ""}\n${published.stderr ?? ""}`; +if (published.status === 0) { + throw new Error(`${packageReference} is already published; increment the alpha version.`); +} +if (!/\bE404\b|404 Not Found/.test(registryOutput)) { + throw new Error(`Unable to verify that ${packageReference} is unpublished:\n${registryOutput}`); +} + +if (process.argv.includes("--publish")) { + if (process.env.GITHUB_REF !== "refs/heads/main") { + throw new Error("Real alpha publication is allowed only from refs/heads/main."); + } + if (process.env.GITHUB_EVENT_NAME !== "workflow_dispatch") { + throw new Error("Real alpha publication requires an explicit workflow_dispatch."); + } + console.log(`Verified unpublished alpha version ${packageReference} for the main branch.`); +} else { + console.log(`Verified unpublished alpha version ${packageReference}; expected dist-tag: alpha.`); +} diff --git a/scripts/consumer-default.res b/scripts/consumer-default.res new file mode 100644 index 00000000..0c13c244 --- /dev/null +++ b/scripts/consumer-default.res @@ -0,0 +1,3 @@ +let location = WebAPI.DOM.window->WebAPI.Window.location +let href = location.href +location->WebAPI.Location.reload diff --git a/scripts/consumer-runtime.res b/scripts/consumer-runtime.res new file mode 100644 index 00000000..8b2d0d8a --- /dev/null +++ b/scripts/consumer-runtime.res @@ -0,0 +1,6 @@ +let headers = WebAPI.Headers.make() +headers->WebAPI.Headers.set(~name="X-Artifact", ~value="installed") +assert(headers->WebAPI.Headers.get("x-artifact")->Null.getOr("") == "installed") + +let response = WebAPI.Response.fromString("packed consumer") +assert(await response->WebAPI.Response.text == "packed consumer") diff --git a/scripts/fixtures/negative/DiagnosticMatching.res b/scripts/fixtures/negative/DiagnosticMatching.res new file mode 100644 index 00000000..2bc68831 --- /dev/null +++ b/scripts/fixtures/negative/DiagnosticMatching.res @@ -0,0 +1 @@ +let invalidValue: int = "not an integer" diff --git a/scripts/negative-fixtures.json b/scripts/negative-fixtures.json new file mode 100644 index 00000000..3dec583c --- /dev/null +++ b/scripts/negative-fixtures.json @@ -0,0 +1,8 @@ +[ + { + "name": "expected compiler diagnostic matching", + "fixture": "scripts/fixtures/negative/DiagnosticMatching.res", + "features": null, + "expectedDiagnostic": "This has type: string" + } +] diff --git a/tests/FetchAPI/Headers__test.res b/tests/FetchAPI/Headers__test.res index cb6ba06a..53f815f0 100644 --- a/tests/FetchAPI/Headers__test.res +++ b/tests/FetchAPI/Headers__test.res @@ -1,9 +1,11 @@ let h = Headers.make() h->Headers.set(~name="X-Test", ~value="1") +assert(h->Headers.get("x-test")->Null.getOr("") == "1") let h2 = Headers.fromDict(dict{"X-Vegetable": "Carrot"}) +assert(h2->Headers.get("x-vegetable")->Null.getOr("") == "Carrot") let h3 = Headers.fromKeyValueArray([("X-Fruit", "Apple"), ("X-Vegetable", "Carrot")]) - -Console.log(h3->Headers.get("X-Fruit")) +assert(h3->Headers.get("x-fruit")->Null.getOr("") == "Apple") +assert(h3->Headers.has("x-vegetable")) diff --git a/tests/FetchAPI/Request__test.res b/tests/FetchAPI/Request__test.res index 23b1ee32..d790df70 100644 --- a/tests/FetchAPI/Request__test.res +++ b/tests/FetchAPI/Request__test.res @@ -1,5 +1,3 @@ -let req = Request.fromURL("https://example.com") - let blob = Blob.make(~blobParts=[]) let file = File.make(~fileBits=[], ~fileName="hello.txt") let params = URLSearchParams.fromString("greeting=hello") @@ -28,4 +26,4 @@ let formDataReq = Request.fromURL( ) let _formData: FormData.t = await formDataReq->Request.formData -Console.log(await req2->Request.text) +assert((await req2->Request.text) == "hello") diff --git a/tests/FetchAPI/Response__test.res b/tests/FetchAPI/Response__test.res index 04707902..0eb615d7 100644 --- a/tests/FetchAPI/Response__test.res +++ b/tests/FetchAPI/Response__test.res @@ -8,6 +8,7 @@ let stream = ReadableStream.make() let response = Response.fromNull(~init={status: 204, headers}) let response1 = Response.fromString("pong", ~init={status: 200, headers}) +assert((await response1->Response.clone->Response.text) == "pong") let response2 = Response.fromBlob(blob) let response3 = Response.fromFile(file) diff --git a/tests/FetchAPI/URLSearchParams__test.res b/tests/FetchAPI/URLSearchParams__test.res index c4532c4d..e03c59a8 100644 --- a/tests/FetchAPI/URLSearchParams__test.res +++ b/tests/FetchAPI/URLSearchParams__test.res @@ -5,9 +5,7 @@ let params2 = URLSearchParams.fromKeyValueArray([("foo", "1"), ("bar", "b")]) params2->URLSearchParams.values->Array.fromIterable->Array.forEach(Console.log) let params3 = URLSearchParams.fromDict(dict{"foo": "1", "bar": "b"}) -params3 -->URLSearchParams.entries -->Array.fromIterable -->Array.forEach(((key, value)) => Console.log2(key, value)) - +assert(params3->URLSearchParams.get("foo")->Null.getOr("") == "1") +assert(params3->URLSearchParams.has(~name="bar")) let paramStr = params3->URLSearchParams.toString +assert(paramStr == "foo=1&bar=b" || paramStr == "bar=b&foo=1") diff --git a/tests/URLAPI/URL__test.res b/tests/URLAPI/URL__test.res index e9448152..48634645 100644 --- a/tests/URLAPI/URL__test.res +++ b/tests/URLAPI/URL__test.res @@ -1 +1,4 @@ let url = URL.make(~url="/foo", ~base="https://bar.com") +assert(url->URL.toJSON == "https://bar.com/foo") +assert(URL.canParse(~url="/foo", ~base="https://bar.com")) +assert(!URL.canParse(~url="not a URL")) diff --git a/tests/browser/NativeAPI__test.res b/tests/browser/NativeAPI__test.res new file mode 100644 index 00000000..a38aeaa1 --- /dev/null +++ b/tests/browser/NativeAPI__test.res @@ -0,0 +1,2 @@ +let url = URL.make(~url="/native", ~base="https://example.com") +assert(url->URL.toJSON == "https://example.com/native") diff --git a/tests/browser/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..0d3910c5 --- /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("http://127.0.0.1:4173/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("http://127.0.0.1:4173/tests/browser/index.html"); + const failures = await page.evaluate(async () => { + const { describeError } = await import("/tests/browser/describe-error.mjs"); + const bigIntFailure = describeError({ + RE_EXN_ID: "Review_bigint", + _1: 1n, + Error: new Error("original BigInt failure"), + }); + const cyclicPayload = { + RE_EXN_ID: "Review_cyclic", + Error: new Error("original cyclic failure"), + }; + cyclicPayload._1 = cyclicPayload; + return { + bigIntFailure, + cyclicFailure: describeError(cyclicPayload), + }; + }); + + expect(failures.bigIntFailure.name).toBe("Review_bigint"); + expect(failures.bigIntFailure.message).toBe("original BigInt failure"); + expect(failures.bigIntFailure.stack).toContain("original BigInt failure"); + expect(failures.bigIntFailure.detail).toContain("1n"); + expect(failures.cyclicFailure.name).toBe("Review_cyclic"); + expect(failures.cyclicFailure.message).toBe("original cyclic failure"); + expect(failures.cyclicFailure.stack).toContain("original cyclic failure"); + expect(failures.cyclicFailure.detail).toContain("Exception payload omitted"); +}); diff --git a/tests/browser/server.mjs b/tests/browser/server.mjs new file mode 100644 index 00000000..8f7c14b9 --- /dev/null +++ b/tests/browser/server.mjs @@ -0,0 +1,29 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const contentTypes = new Map([ + [".html", "text/html"], + [".js", "text/javascript"], + [".mjs", "text/javascript"], +]); + +createServer(async (request, response) => { + const pathname = new URL(request.url ?? "/", "http://localhost").pathname; + const file = path.resolve(root, `.${decodeURIComponent(pathname)}`); + if (!file.startsWith(`${root}${path.sep}`)) { + response.writeHead(403).end(); + return; + } + try { + const content = await readFile(file); + response.writeHead(200, { + "content-type": contentTypes.get(path.extname(file)) ?? "application/octet-stream", + }); + response.end(content); + } catch { + response.writeHead(404).end(); + } +}).listen(4173, "127.0.0.1"); diff --git a/tests/fixtures.json b/tests/fixtures.json new file mode 100644 index 00000000..a9d90487 --- /dev/null +++ b/tests/fixtures.json @@ -0,0 +1,48 @@ +{ + "compileOnly": [ + "BindingAbsence__test.res", + "CanvasAPI/Path2D__test.res", + "CSSFontLoadingAPI/FontFace__test.res", + "DOMAPI/AddEventListener__test.res", + "DOMAPI/Document__test.res", + "DOMAPI/DOMMatrixReadOnly__test.res", + "DOMAPI/DOMMatrix__test.res", + "DOMAPI/Element__test.res", + "DOMAPI/EventTarget__test.res", + "DOMAPI/Event__test.res", + "DOMAPI/EventType__test.res", + "DOMAPI/HTMLCanvasElement__test.res", + "DOMAPI/HTMLElementHidden__test.res", + "DOMAPI/HTMLElement__test.res", + "DOMAPI/HTMLInputElement__test.res", + "DOMAPI/HTMLSlotElement__test.res", + "DOMAPI/ImageData__test.res", + "DOMAPI/Location__test.res", + "DOMAPI/SVGElement__test.res", + "DOMAPI/VideoFrame__test.res", + "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", + "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/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);