diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 0000000..7d5ad0a --- /dev/null +++ b/.github/workflows/compat.yml @@ -0,0 +1,15 @@ +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + - uses: ./.github/actions/prepare + - run: npm run test:compat + +name: XState Compatibility + +on: + pull_request: ~ + push: + branches: + - main diff --git a/eslint.config.js b/eslint.config.js index a49e422..7a74f1c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ +// @ts-check + import eslint from '@eslint/js'; import stylistic from '@stylistic/eslint-plugin'; import n from 'eslint-plugin-n'; @@ -27,7 +27,7 @@ export default tseslint.config( perfectionist.configs['recommended-natural'], ...tseslint.config({ extends: tseslint.configs.recommendedTypeChecked, - files: ['**/*.js', '**/*.ts'], + files: ['**/*.ts', '**/*.mts', '**/*.cts'], languageOptions: { parserOptions: { projectService: { @@ -119,7 +119,6 @@ export default tseslint.config( // seems to be incompatible with tshy 'n/no-extraneous-import': 'off', - 'n/no-unpublished-import': 'off', 'no-empty': [ diff --git a/package-lock.json b/package-lock.json index 922601e..55a276d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@stylistic/eslint-plugin": "2.7.2", "@types/eslint__js": "8.42.3", "@types/node": "20.16.3", + "@types/semver": "7.5.8", "c8": "10.1.2", "cspell": "8.14.2", "eslint": "9.9.1", @@ -32,6 +33,7 @@ "prettier-plugin-jsdoc": "1.3.0", "prettier-plugin-organize-imports": "4.0.0", "prettier-plugin-pkg": "0.18.1", + "semver": "7.6.3", "serve": "14.2.3", "tshy": "3.0.2", "tsx": "4.19.0", @@ -40,13 +42,14 @@ "typedoc-plugin-mdn-links": "3.2.11", "typescript": "5.5.4", "typescript-eslint": "8.3.0", - "xstate": "5.18.0" + "xstate": "5.18.0", + "zx": "8.1.5" }, "engines": { "node": ">=20.2.0" }, "peerDependencies": { - "xstate": ">=5.0.0" + "xstate": ">=5.14.0" } }, "node_modules/@babel/code-frame": { @@ -1952,6 +1955,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1976,6 +1991,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2003,6 +2029,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", @@ -8671,6 +8704,23 @@ "peerDependencies": { "zod": "^3.18.0" } + }, + "node_modules/zx": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.1.5.tgz", + "integrity": "sha512-gvmiYPvDDEz2Gcc37x7pJkipTKcFIE18q9QlSI1p5qoPDtoSn3jmGuWD0eEb7HuxEH5aDD7N/RVgH8BqSxbKzA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + }, + "optionalDependencies": { + "@types/fs-extra": ">=11", + "@types/node": ">=20" + } } } } diff --git a/package.json b/package.json index 35f23db..dffa26b 100644 --- a/package.json +++ b/package.json @@ -73,12 +73,13 @@ "lint:spelling": "cspell \"**\" \".github/**/*\"", "lint:staged": "lint-staged", "prepare": "husky", - "test": "node --import tsx --test \"./test/*.spec.ts\"", + "test": "node --test --import tsx \"./test/*.spec.ts\"", + "test:compat": "tsx ./test/xstate-compat.ts", "test:coverage": "c8 npm test", "test:types": "tsc -p tsconfig.tsc.json" }, "peerDependencies": { - "xstate": ">=5.0.0" + "xstate": ">=5.14.0" }, "devDependencies": { "@commitlint/cli": "19.4.1", @@ -87,6 +88,7 @@ "@stylistic/eslint-plugin": "2.7.2", "@types/eslint__js": "8.42.3", "@types/node": "20.16.3", + "@types/semver": "7.5.8", "c8": "10.1.2", "cspell": "8.14.2", "eslint": "9.9.1", @@ -104,6 +106,7 @@ "prettier-plugin-jsdoc": "1.3.0", "prettier-plugin-organize-imports": "4.0.0", "prettier-plugin-pkg": "0.18.1", + "semver": "7.6.3", "serve": "14.2.3", "tshy": "3.0.2", "tsx": "4.19.0", @@ -112,7 +115,8 @@ "typedoc-plugin-mdn-links": "3.2.11", "typescript": "5.5.4", "typescript-eslint": "8.3.0", - "xstate": "5.18.0" + "xstate": "5.18.0", + "zx": "8.1.5" }, "commitlint": { "extends": [ diff --git a/test/xstate-compat.ts b/test/xstate-compat.ts new file mode 100755 index 0000000..d69d14b --- /dev/null +++ b/test/xstate-compat.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env tsx + +import {setMaxListeners} from 'node:events'; +import {parse} from 'semver'; +import {$} from 'zx'; + +/** + * This script runs the test suite against all versions of `xstate` greater than + * or equal to the {@link KNOWN_MINIMUM known minimum version}. + * + * This version should be the base for the `peerDependencies.xstate` range in + * `package.json`. + * + * Any version newer than the known minimum which fails the test suite will be + * reported, and this script will fail with a non-zero exit code. + * + * Upon completion, the minimum version that passed the test will be logged to + * `STDOUT`. + * + * @packageDocumentation + */ +// zx doesn't dispose its signal listeners, apparently +setMaxListeners(30); + +/** + * The known minimum version of `xstate` that works with `xstate-audition`. + */ +const KNOWN_MINIMUM = '5.14.0'; + +/** + * All versions of `xstate` available on npm + */ +const allVersions = await $`npm show xstate versions --json`.json(); + +/** + * All versions newer than the known minimum (inclusive) + * + * Versions must be `SemVer` versions and not naughty things instead + */ +const versionsUnderTest = allVersions + .slice(allVersions.indexOf(KNOWN_MINIMUM)) + .filter((version) => parse(version) !== null); + +/** + * The minimum version that passed the test suite + */ +let foundMinimum: string = ''; + +// We abort this signal upon SIGINT +const ac = new AbortController(); + +const {signal} = ac; + +process + // if we got CTRL-C, then we need to restore the xstate version + .once('SIGINT', (signal) => { + ac.abort(); + process.stderr.write('\nAborted; restoring xstate version …'); + void $.sync({quiet: true})`npm install --force`; + process.stderr.write(' done\n'); + process.emit('SIGINT', signal); + }) + // and we need to restore it before exit as well + .once('beforeExit', () => { + process.stderr.write('\nRestoring xstate version …'); + void $.sync({quiet: true})`npm install --force`; + process.stderr.write(' done\n'); + }); + +const unexpectedFailures: string[] = []; + +// unfortunately this must run in serial +for (const version of versionsUnderTest) { + if (signal.aborted) { + continue; + } + try { + await $({signal})`npm i xstate@${version} --no-save`; + } catch (err) { + if (signal.aborted) { + continue; + } + console.error(`xstate@${version} - uninstallable`, err); + continue; + } + try { + process.stderr.write(`xstate@${version} …`); + await $({signal})`npm test`; + foundMinimum ||= version; + process.stderr.write(` OK\n`); + } catch (err) { + if (signal.aborted) { + continue; + } + process.stderr.write(` NOT OK\n`); + console.error(err); + if (foundMinimum) { + unexpectedFailures.push(version); + } + } +} + +if (!signal.aborted) { + if (unexpectedFailures.length) { + console.error('Unexpected failures:', unexpectedFailures.join(', ')); + process.exitCode = 1; + } + if (foundMinimum) { + console.log(foundMinimum); + } else { + console.error('No passing versions found!'); + process.exitCode = 1; + } +}