diff --git a/.changeset/support-node-20.md b/.changeset/support-node-20.md new file mode 100644 index 000000000..71589b899 --- /dev/null +++ b/.changeset/support-node-20.md @@ -0,0 +1,13 @@ +--- +"@qawolf/cli": minor +--- + +Support Node 20. The `engines.node` floor is lowered to `>=20.19.0`, and on Node +versions without native TypeScript support (Node 20, and Node 22.15–22.17) flows +are now loaded through the `@oxc-node/core` ESM loader, which transpiles and +resolves TypeScript at runtime. Bun and Node 22.18+ are unaffected. A CI matrix +smoke-tests the published bundle on Node 20, 22, 24, and Bun. + +Note: the `@qawolf/*` platform packages currently declare `engines.node >=22.22.0`, +so installing on Node 20 prints `EBADENGINE` warnings. They are verified to run on +Node 20.19, and the warnings are non-fatal unless `engine-strict` is enabled. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f3f1a1ba..bf2dfe32e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,61 @@ jobs: env: BASE_REF: ${{ github.base_ref }} run: bash scripts/check-co-authored.sh "$BASE_REF" + + # Proves the published npm bundle runs on every supported runtime: the bundle + # boots and a TypeScript flow transpiles + loads. Node 20 has no native TS, so + # this is the guard that the oxc-node loader keeps working there. + # + # Node 20 reached end-of-life on 2026-04-30 but is deliberately kept: an + # internal AI Task Docker image still runs Node 20 (see WIZ-10992). Do not + # remove the node-20 lane while that requirement stands. It pins the exact + # engines.node floor (20.19.0) so a regression at the minimum is caught. + runtime-smoke: + name: Runtime smoke (${{ matrix.name }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: node-20 + node-version: "20.19.0" + - name: node-22 + node-version: "22" + - name: node-24 + node-version: "24" + - name: bun + node-version: "" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: package.json + - if: matrix.node-version != '' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + - run: bun install --frozen-lockfile + - run: bun run build + # Bundle the real registerFlowLoader (oxc-node external) so the smoke drives + # the shipped strategy selection, not a copy. Bundling resolves the ~/ alias + # that Node/oxc cannot. + - name: Bundle flow loader for smoke + run: > + bun build src/shell/resolver/registerFlowLoader.ts + --target=node --format=esm --external @oxc-node/core + --outfile test/node20/loader.generated.mjs + - name: Smoke — bundle boots + TypeScript flow loads + env: + RUNTIME: ${{ matrix.name }} + run: | + if [ "$RUNTIME" = "bun" ]; then + bun dist/cli.js --version + bun test/node20/smoke.mjs + else + node dist/cli.js --version + node test/node20/smoke.mjs + fi diff --git a/.gitignore b/.gitignore index 76abe83d2..febc3712b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ dist/ # Generated source files src/generated/ +# Generated smoke-test loader bundle (built in the runtime-smoke CI job) +test/node20/loader.generated.mjs + # Environment .env .env.* diff --git a/.oxlintrc.json b/.oxlintrc.json index d9083ebc0..5af766622 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -102,7 +102,12 @@ "options": { "typeAware": true }, - "ignorePatterns": ["dist/", "node_modules/", "knip.config.ts"], + "ignorePatterns": [ + "dist/", + "node_modules/", + "knip.config.ts", + "test/node20/" + ], "overrides": [ { "files": ["src/core/**/*.ts"], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a91940f0..527b34bff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ QA Wolf currently maintains `@qawolf/cli` internally and isn't accepting externa ## Prerequisites - [Bun](https://bun.sh) — version is pinned in the `packageManager` field of `package.json` -- [Node.js](https://nodejs.org) 22.12.0 or later +- [Node.js](https://nodejs.org) 20.19 or later ## Setup diff --git a/README.md b/README.md index fd86d1198..c590d2716 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Try it without installing: npx @qawolf/cli --help ``` -Supported Node versions: active LTS and newer (currently Node 22+). See [nodejs.org](https://nodejs.org). +Supported Node versions: Node 20.19+. Node 20 reached [end-of-life](https://endoflife.date/nodejs) on 2026-04-30 and no longer receives security updates; it remains supported here only for environments still pinned to Node 20. Prefer Node 22+ where possible. ### Standalone binaries diff --git a/bun.lock b/bun.lock index 3fda7b12d..962ab70fe 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@clack/prompts": "1.5.1", "@napi-rs/keyring": "1.3.0", + "@oxc-node/core": "0.1.0", "@playwright/test": "1.60.0", "@qawolf/api-contracts": "0.1.0", "@qawolf/emails": "1.1.1", @@ -108,11 +109,11 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -236,6 +237,42 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@oxc-node/core": ["@oxc-node/core@0.1.0", "", { "dependencies": { "pirates": "^4.0.7" }, "optionalDependencies": { "@oxc-node/core-android-arm-eabi": "0.1.0", "@oxc-node/core-android-arm64": "0.1.0", "@oxc-node/core-darwin-arm64": "0.1.0", "@oxc-node/core-darwin-x64": "0.1.0", "@oxc-node/core-freebsd-x64": "0.1.0", "@oxc-node/core-linux-arm-gnueabihf": "0.1.0", "@oxc-node/core-linux-arm64-gnu": "0.1.0", "@oxc-node/core-linux-arm64-musl": "0.1.0", "@oxc-node/core-linux-ppc64-gnu": "0.1.0", "@oxc-node/core-linux-s390x-gnu": "0.1.0", "@oxc-node/core-linux-x64-gnu": "0.1.0", "@oxc-node/core-linux-x64-musl": "0.1.0", "@oxc-node/core-openharmony-arm64": "0.1.0", "@oxc-node/core-wasm32-wasi": "0.1.0", "@oxc-node/core-win32-arm64-msvc": "0.1.0", "@oxc-node/core-win32-ia32-msvc": "0.1.0", "@oxc-node/core-win32-x64-msvc": "0.1.0" } }, "sha512-Spk/ey3zg1CpBU1eUHBPbAbfFddntutZPPsweh+kNh9M9Ksc8j9OCujralW9HrVyi6nNWek1PnMfSZ7NPLLCKA=="], + + "@oxc-node/core-android-arm-eabi": ["@oxc-node/core-android-arm-eabi@0.1.0", "", { "os": "android", "cpu": "arm" }, "sha512-+ycNqMBKBz3EWpQKm7HgUMRLGKfFZsZ/JxN9ctx12CwGy0PTtjX3TB+1WEbiJrgWiZM0axBjuwe4MEqS6j1kgQ=="], + + "@oxc-node/core-android-arm64": ["@oxc-node/core-android-arm64@0.1.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sJZGDgQwlawrGnLPu1ueAoM/0sUKtCUZr0y4IljarPCVbVBHimcxKcyNlzucIjyQwwiptZourCbUNHONvm4i1g=="], + + "@oxc-node/core-darwin-arm64": ["@oxc-node/core-darwin-arm64@0.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6hsKxbYCzAg390Y/URpCCDPDM4HSHl+Cxodsh2lw6GjX68FDFgLbEwOU2ivXfnXEmJMEMLSjv/0tPTBzDPIJJg=="], + + "@oxc-node/core-darwin-x64": ["@oxc-node/core-darwin-x64@0.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-VCygvTqrquI3u25B0D6LjO1GnAVMyuAXae4PxRwmEwNuWgWaErG3zHaaU6+hBlXysiyZWKhSXAoAAJmrwe17tA=="], + + "@oxc-node/core-freebsd-x64": ["@oxc-node/core-freebsd-x64@0.1.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UQ0hCpwTOUpg1Oh/H/I0BRQmt8XCjCQArk3Cp0P1qc3/xcZ2IcTihxwIZAKLr/lkl+7Ya5rBn9zuY1fe28HaqA=="], + + "@oxc-node/core-linux-arm-gnueabihf": ["@oxc-node/core-linux-arm-gnueabihf@0.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Z+Il9u39MIhusioOQyRfZFMRY+e6QtePxRH44bUHXj7r+eTcHXTJyaDqqCr1HRvtPc9K/re1zH2hV9yEZtHUsg=="], + + "@oxc-node/core-linux-arm64-gnu": ["@oxc-node/core-linux-arm64-gnu@0.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-nt2IT6MCZApyWsaEjky2znYZIII/BphuqD5mtnbGrFeF3dBpO6U2JiXHCQK/FZ/yrVLlCmcPgcOEL6yMYu8Tiw=="], + + "@oxc-node/core-linux-arm64-musl": ["@oxc-node/core-linux-arm64-musl@0.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0CYLp49qV4KIZmgckqiNcHiLROcb9J1sQSejIKJwbgcaoBLRw5olRCUE9cOi424jRxzXq50zsEv4ihheSA71pQ=="], + + "@oxc-node/core-linux-ppc64-gnu": ["@oxc-node/core-linux-ppc64-gnu@0.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eRarcfNvV0NorkUx5oywdUlC+E35dQCwZzI9BRe6ePywyeCBGJw6D5NnMdPfhCI1olPTkpPdCHXjY/gcl8XH3Q=="], + + "@oxc-node/core-linux-s390x-gnu": ["@oxc-node/core-linux-s390x-gnu@0.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-PZx57NdmM9jq5jWXV1uk/PfHuDbodQ71KIwkNtLqLTdgJZS69m9Xi9j0vo6yyMBGJMD+aYK79eO0KxPU/5TgSA=="], + + "@oxc-node/core-linux-x64-gnu": ["@oxc-node/core-linux-x64-gnu@0.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-N9lTK2chPpsXx0Ur6nUTW7OFE0d0wK4qpbb7WJAyJ48mU9bC22xuCi2yGDB748n16Yly3+mCJ+LiU7r3aBLZhA=="], + + "@oxc-node/core-linux-x64-musl": ["@oxc-node/core-linux-x64-musl@0.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-xs/ObZhHfN6AcAcjYQQkaeXBR1khm7ZlF6uOpmEhdAiG80P1aVw9ndEYz0LQHp2iVhPaRL9UfYuVKLqKah1TAA=="], + + "@oxc-node/core-openharmony-arm64": ["@oxc-node/core-openharmony-arm64@0.1.0", "", { "os": "none", "cpu": "arm64" }, "sha512-B7ixIkc5G7pIqZmWM8oqi/qDdcRdo//mTNM4ChFNU1oou2Hy/FlidAKppMLFVRc4ddRrp6uEvYrT1LgZnjzn6g=="], + + "@oxc-node/core-wasm32-wasi": ["@oxc-node/core-wasm32-wasi@0.1.0", "", { "dependencies": { "@emnapi/core": "1.9.1", "@emnapi/runtime": "1.9.1", "@napi-rs/wasm-runtime": "^1.1.2" }, "cpu": "none" }, "sha512-W4jM3S4XRgtpCKDGNGitCCfinIlyRAKoiQHi1nM+Nul3+Xf2RZqFOgBzdHxT+CahrT8kDi+p7gDGQN3uziwM1A=="], + + "@oxc-node/core-win32-arm64-msvc": ["@oxc-node/core-win32-arm64-msvc@0.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-3Qi2tdV+JSqK5Cfcpg3FX54zR78dBEFk+/usFXcF+bhMiBy8YmXGd+yDudl+wwEYf9Xz+Hqx74u8YCXrWDAUTg=="], + + "@oxc-node/core-win32-ia32-msvc": ["@oxc-node/core-win32-ia32-msvc@0.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-j66qK1qu5FcmfmIYdv1bp7gIuOi1LCDhzFOu7UdWZyFrinZHh7gHnLHngpSibM/M2Zp/mVLLWD2Ojlsy+8VtNA=="], + + "@oxc-node/core-win32-x64-msvc": ["@oxc-node/core-win32-x64-msvc@0.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-S3qWlUQ7ZrOSLG1IwNiqQEovJU8MZ11Vc9k+NUzNTO20zFPEt9Jq1wFElusxJZBqBsd7AUXIq08n9dXC/ialbg=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.133.0", "", { "os": "android", "cpu": "arm" }, "sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.133.0", "", { "os": "android", "cpu": "arm64" }, "sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg=="], @@ -1352,6 +1389,8 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], "pkg-dir": ["pkg-dir@5.0.0", "", { "dependencies": { "find-up": "^5.0.0" } }, "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA=="], @@ -1762,6 +1801,8 @@ "@img/sharp-linux-ppc64/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1780,6 +1821,14 @@ "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@playwright/test/playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], "@qawolf/flow-targets/zod": ["zod@4.4.2", "", {}, "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw=="], @@ -2190,6 +2239,10 @@ "@jest/types/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@playwright/test/playwright/playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], "@so-ric/colorspace/color/color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], @@ -3320,12 +3373,26 @@ "webdriver/@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@appium/base-driver/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@appium/base-plugin/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@appium/docutils/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@appium/support/@appium/types/@types/express/@types/express-serve-static-core/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="], "@appium/support/@appium/types/@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@wdio/config/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "appium-adb/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "appium-android-driver/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "appium-idb/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "appium-ios-device/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "appium-ios-remotexpc/@appium/support/glob/path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], "appium-ios-remotexpc/@appium/support/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], @@ -3334,6 +3401,10 @@ "appium-ios-remotexpc/@appium/support/read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "appium-ios-remotexpc/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "appium-ios-simulator/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "appium-ios-tuntap/@appium/support/glob/path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], "appium-ios-tuntap/@appium/support/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], @@ -3342,10 +3413,20 @@ "appium-ios-tuntap/@appium/support/read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "appium-ios-tuntap/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "appium-remote-debugger/@appium/support/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "appium-remote-debugger/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "appium-remote-debugger/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "appium-webdriveragent/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "appium-xcode/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "appium-xcuitest-driver/@appium/support/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "mv/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], diff --git a/knip.config.ts b/knip.config.ts index 559d1b9f6..22b2a3ffb 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -9,6 +9,11 @@ const config: KnipConfig = { "src/**/*.testUtils.ts", ], project: ["src/**/*.ts"], + ignoreBinaries: [ + // the built bundle, invoked as `node dist/cli.js` in the runtime-smoke CI + // job; not present when knip runs (it runs before the build step) + "dist/cli.js", + ], ignoreDependencies: [ // TODO WIZ-10341 follow-up: consumed once the web-flow runner imports it. "@playwright/test", diff --git a/package.json b/package.json index c1737f374..4594969a0 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "dependencies": { "@clack/prompts": "1.5.1", "@napi-rs/keyring": "1.3.0", + "@oxc-node/core": "0.1.0", "@playwright/test": "1.60.0", "@qawolf/api-contracts": "0.1.0", "@qawolf/emails": "1.1.1", @@ -91,7 +92,7 @@ "typescript": "6.0.3" }, "engines": { - "node": ">=22.15.0" + "node": ">=20.19.0" }, "packageManager": "bun@1.3.13" } diff --git a/scripts/build.ts b/scripts/build.ts index 7f54f918a..faf3359b9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -6,6 +6,9 @@ import { spawnSync } from "node:child_process"; const externals = [ // native addon — cannot be inlined into a JS bundle "@napi-rs/keyring", + // native-addon TS loader, imported only on the Node path (Node <22.18) to + // transpile/resolve flows; the Bun binary never loads it + "@oxc-node/core", // version-coupled: playwright must match @qawolf/flows' peer range at runtime "@qawolf/flow-targets", "@qawolf/flows", diff --git a/scripts/buildBinary.ts b/scripts/buildBinary.ts index 3f828b7ef..49b404cca 100644 --- a/scripts/buildBinary.ts +++ b/scripts/buildBinary.ts @@ -45,6 +45,11 @@ const buildArgs = [ "@qawolf/emails", "--external", "@qawolf/testkit", + // never loaded in the compiled binary (QAWOLF_COMPILED takes the bundleFlow + // path, not registerFlowLoader); external so --compile does not try to resolve + // its platform-specific native addon during cross-target release builds + "--external", + "@oxc-node/core", "--define", 'process.env.QAWOLF_COMPILED="true"', ]; diff --git a/src/domains/doctor/checks/nodeVersion.test.ts b/src/domains/doctor/checks/nodeVersion.test.ts index 10fa2e50a..b44ce6ee5 100644 --- a/src/domains/doctor/checks/nodeVersion.test.ts +++ b/src/domains/doctor/checks/nodeVersion.test.ts @@ -1,8 +1,35 @@ import { describe, expect, it } from "bun:test"; +import packageJson from "../../../../package.json" with { type: "json" }; import { checkNodeVersion } from "./nodeVersion.js"; describe("checkNodeVersion", () => { + it("accepts a supported Node 20 against the shipped engines.node floor", async () => { + const r = await checkNodeVersion({ + processVersion: "v20.19.0", + enginesNode: packageJson.engines.node, + }); + expect(r.status).toBe("pass"); + }); + + it("passes at exactly the minor/patch floor", async () => { + const r = await checkNodeVersion({ + processVersion: "v20.6.0", + enginesNode: ">=20.6.0", + }); + expect(r.status).toBe("pass"); + }); + + it("fails a same-major version below the minor/patch floor", async () => { + // Node 20.5.x lacks module.register (20.6+); the flow loader cannot register. + const r = await checkNodeVersion({ + processVersion: "v20.5.1", + enginesNode: ">=20.6.0", + }); + expect(r.status).toBe("fail"); + expect(r.detail).toContain("v20.5.1"); + }); + it("passes when version meets minimum and includes version string", async () => { const r = await checkNodeVersion({ processVersion: "v24.1.0", diff --git a/src/domains/doctor/checks/nodeVersion.ts b/src/domains/doctor/checks/nodeVersion.ts index 42c62d3af..2421e1588 100644 --- a/src/domains/doctor/checks/nodeVersion.ts +++ b/src/domains/doctor/checks/nodeVersion.ts @@ -9,8 +9,11 @@ type NodeVersionDeps = { export async function checkNodeVersion( deps: NodeVersionDeps, ): Promise { - const minMajor = extractMajor(deps.enginesNode, /^>=\s*(\d+)/); - if (minMajor === undefined) { + const min = parseVersion( + deps.enginesNode, + /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/, + ); + if (min === undefined) { return { name: "node-version", status: "fail", @@ -18,7 +21,10 @@ export async function checkNodeVersion( }; } - const actual = extractMajor(deps.processVersion, /^v?(\d+)/); + const actual = parseVersion( + deps.processVersion, + /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/, + ); if (actual === undefined) { return { name: "node-version", @@ -29,7 +35,7 @@ export async function checkNodeVersion( }; } - if (actual < minMajor) { + if (compareVersions(actual, min) < 0) { return { name: "node-version", status: "fail", @@ -48,7 +54,24 @@ export async function checkNodeVersion( }; } -function extractMajor(input: string, pattern: RegExp): number | undefined { - const captured = input.trim().match(pattern)?.[1]; - return captured === undefined ? undefined : Number.parseInt(captured, 10); +type Version = readonly [major: number, minor: number, patch: number]; + +/** + * Parses `major[.minor[.patch]]` from the head of a version or range string, + * defaulting missing minor/patch to 0 (so `>=20` means `20.0.0`). Returns + * undefined when no leading major number is present. + */ +function parseVersion(input: string, pattern: RegExp): Version | undefined { + const match = input.trim().match(pattern); + if (match?.[1] === undefined) return undefined; + return [ + Number.parseInt(match[1], 10), + match[2] === undefined ? 0 : Number.parseInt(match[2], 10), + match[3] === undefined ? 0 : Number.parseInt(match[3], 10), + ]; +} + +/** Standard tuple comparison: negative when `a` precedes `b`. */ +function compareVersions(a: Version, b: Version): number { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; } diff --git a/src/domains/runner/loadFlowDefault.ts b/src/domains/runner/loadFlowDefault.ts index aefa5b21b..59b0a43c4 100644 --- a/src/domains/runner/loadFlowDefault.ts +++ b/src/domains/runner/loadFlowDefault.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"; import { runnerMessages } from "~/core/messages/index.js"; import { makeDefaultFs, type Fs } from "~/shell/fs.js"; -import { registerFlowModuleResolver } from "~/shell/resolver/registerFlowModuleResolver.js"; +import { registerFlowLoader } from "~/shell/resolver/registerFlowLoader.js"; import { type FlowBundler, defaultFlowBundler } from "./bundleFlow.js"; /** @@ -72,7 +72,7 @@ export async function loadFlowDefault( } = args; if (bundleFlow === undefined) { - registerFlowModuleResolver(); + await registerFlowLoader(); return importDefaultExport(pathToFileURL(flowPath).href, flowPath); } diff --git a/src/oxcNode.d.ts b/src/oxcNode.d.ts new file mode 100644 index 000000000..d7349565f --- /dev/null +++ b/src/oxcNode.d.ts @@ -0,0 +1,3 @@ +// @oxc-node/core ships no types for its side-effect entry points. The /register +// import self-registers the ESM/CJS TypeScript loader for its side effect only. +declare module "@oxc-node/core/register"; diff --git a/src/shell/resolver/detectRuntimeCapabilities.test.ts b/src/shell/resolver/detectRuntimeCapabilities.test.ts new file mode 100644 index 000000000..a50960cc4 --- /dev/null +++ b/src/shell/resolver/detectRuntimeCapabilities.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; + +import { detectRuntimeCapabilities } from "./detectRuntimeCapabilities.js"; + +describe("detectRuntimeCapabilities", () => { + it("reports Bun when running under Bun", () => { + // The test runner is Bun, so this exercises the real detection path. + const caps = detectRuntimeCapabilities(); + expect(caps.isBun).toBe(true); + }); + + it("returns a boolean for every capability", () => { + const caps = detectRuntimeCapabilities(); + expect(typeof caps.hasSyncHooks).toBe("boolean"); + expect(typeof caps.hasNativeTypeScript).toBe("boolean"); + expect(typeof caps.hasAsyncRegister).toBe("boolean"); + }); +}); diff --git a/src/shell/resolver/detectRuntimeCapabilities.ts b/src/shell/resolver/detectRuntimeCapabilities.ts new file mode 100644 index 000000000..7fb648404 --- /dev/null +++ b/src/shell/resolver/detectRuntimeCapabilities.ts @@ -0,0 +1,22 @@ +import nodeModule from "node:module"; + +import { resolveRegisterHooks } from "./registerFlowModuleResolver.js"; +import type { RuntimeCapabilities } from "./selectFlowLoaderStrategy.js"; + +/** + * Feature-detects the current runtime's TypeScript + module-hook capabilities. + * Kept thin: all decision logic lives in selectFlowLoaderStrategy so it stays + * testable without spawning real runtimes. + */ +export function detectRuntimeCapabilities(): RuntimeCapabilities { + return { + isBun: (globalThis as { Bun?: unknown }).Bun !== undefined, + hasSyncHooks: resolveRegisterHooks(nodeModule) !== undefined, + // process.features.typescript is false, "strip", or "transform" (Node 22.18+). + hasNativeTypeScript: Boolean( + (process.features as { typescript?: unknown }).typescript, + ), + hasAsyncRegister: + typeof (nodeModule as { register?: unknown }).register === "function", + }; +} diff --git a/src/shell/resolver/registerFlowLoader.test.ts b/src/shell/resolver/registerFlowLoader.test.ts new file mode 100644 index 000000000..b064f4c90 --- /dev/null +++ b/src/shell/resolver/registerFlowLoader.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +import { registerFlowLoader } from "./registerFlowLoader.js"; +import type { RuntimeCapabilities } from "./selectFlowLoaderStrategy.js"; + +const bun: RuntimeCapabilities = { + isBun: true, + hasSyncHooks: true, + hasNativeTypeScript: false, + hasAsyncRegister: true, +}; +const modernNode: RuntimeCapabilities = { + isBun: false, + hasSyncHooks: true, + hasNativeTypeScript: true, + hasAsyncRegister: true, +}; +const node20: RuntimeCapabilities = { + isBun: false, + hasSyncHooks: false, + hasNativeTypeScript: false, + hasAsyncRegister: true, +}; +const nodeBelow206: RuntimeCapabilities = { + isBun: false, + hasSyncHooks: false, + hasNativeTypeScript: false, + hasAsyncRegister: false, +}; + +afterEach(() => { + mock.restore(); +}); + +describe("registerFlowLoader", () => { + it("registers nothing under Bun", async () => { + const registerSyncAlias = mock(() => undefined); + const registerOxcLoader = mock(() => Promise.resolve()); + + await registerFlowLoader({ + capabilities: bun, + registerSyncAlias, + registerOxcLoader, + }); + + expect(registerSyncAlias).not.toHaveBeenCalled(); + expect(registerOxcLoader).not.toHaveBeenCalled(); + }); + + it("registers the sync alias hook on modern Node with native TS", async () => { + const registerSyncAlias = mock(() => undefined); + const registerOxcLoader = mock(() => Promise.resolve()); + + await registerFlowLoader({ + capabilities: modernNode, + registerSyncAlias, + registerOxcLoader, + }); + + expect(registerSyncAlias).toHaveBeenCalledTimes(1); + expect(registerOxcLoader).not.toHaveBeenCalled(); + }); + + it("registers the oxc transpiling loader on Node 20", async () => { + const registerSyncAlias = mock(() => undefined); + const registerOxcLoader = mock(() => Promise.resolve()); + + await registerFlowLoader({ + capabilities: node20, + registerSyncAlias, + registerOxcLoader, + }); + + expect(registerOxcLoader).toHaveBeenCalledTimes(1); + expect(registerSyncAlias).not.toHaveBeenCalled(); + }); + + it("throws a clear error on a Node without any TypeScript loader (< 20.6)", async () => { + const registerSyncAlias = mock(() => undefined); + const registerOxcLoader = mock(() => Promise.resolve()); + + let caught: unknown; + try { + await registerFlowLoader({ + capabilities: nodeBelow206, + registerSyncAlias, + registerOxcLoader, + }); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toContain("20.6"); + expect(registerSyncAlias).not.toHaveBeenCalled(); + expect(registerOxcLoader).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shell/resolver/registerFlowLoader.ts b/src/shell/resolver/registerFlowLoader.ts new file mode 100644 index 000000000..c041ccd29 --- /dev/null +++ b/src/shell/resolver/registerFlowLoader.ts @@ -0,0 +1,62 @@ +import { detectRuntimeCapabilities } from "./detectRuntimeCapabilities.js"; +import { registerFlowModuleResolver } from "./registerFlowModuleResolver.js"; +import { + type RuntimeCapabilities, + selectFlowLoaderStrategy, +} from "./selectFlowLoaderStrategy.js"; + +/** + * Imports oxc-node's ESM/CJS loader, which self-registers via `module.register` + * (Node 20.6+) and transpiles + resolves TypeScript flows. Idempotent: the ESM + * module cache runs the registration side effect once per process. Kept as a + * bare dynamic import so `@oxc-node/core` stays external in the npm bundle and + * is loaded only on the Node path that needs it (never in the Bun binary). + */ +async function defaultRegisterOxcLoader(): Promise { + try { + await import("@oxc-node/core/register"); + } catch (cause) { + // Missing package or a native-addon load failure for this platform — give an + // actionable message instead of a raw module-resolution/binding error. + throw new Error( + `Failed to load the @oxc-node/core TypeScript loader needed to run ` + + `flows on Node ${process.version}. Ensure @oxc-node/core is installed ` + + `for this platform (${process.platform}-${process.arch}).`, + { cause }, + ); + } +} + +type RegisterFlowLoaderDeps = { + capabilities?: RuntimeCapabilities; + registerSyncAlias?: () => void; + registerOxcLoader?: () => Promise; +}; + +/** + * Makes flow `.ts` modules loadable by the current runtime, choosing the + * cheapest capable mechanism (see selectFlowLoaderStrategy). No-ops where the + * runtime already handles TypeScript and resolution itself (Bun). + */ +export async function registerFlowLoader( + deps: RegisterFlowLoaderDeps = {}, +): Promise { + const { + capabilities = detectRuntimeCapabilities(), + registerSyncAlias = registerFlowModuleResolver, + registerOxcLoader = defaultRegisterOxcLoader, + } = deps; + + const strategy = selectFlowLoaderStrategy(capabilities); + if (strategy === "sync-alias") { + registerSyncAlias(); + } else if (strategy === "oxc-transpile") { + await registerOxcLoader(); + } else if (strategy === "unsupported") { + throw new Error( + `Running TypeScript flows requires Node 20.6+ (for module.register) or a ` + + `runtime with native TypeScript support. Current runtime: ${process.version}. ` + + `Upgrade Node to 20.6 or newer.`, + ); + } +} diff --git a/src/shell/resolver/selectFlowLoaderStrategy.test.ts b/src/shell/resolver/selectFlowLoaderStrategy.test.ts new file mode 100644 index 000000000..1a1810861 --- /dev/null +++ b/src/shell/resolver/selectFlowLoaderStrategy.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "bun:test"; + +import { selectFlowLoaderStrategy } from "./selectFlowLoaderStrategy.js"; + +describe("selectFlowLoaderStrategy", () => { + it("does nothing under Bun (native TS + resolution)", () => { + expect( + selectFlowLoaderStrategy({ + isBun: true, + hasSyncHooks: true, + hasNativeTypeScript: false, + hasAsyncRegister: true, + }), + ).toBe("none"); + }); + + it("uses the sync extension-alias hook on Node with native TS stripping", () => { + // Node 22.18+: strips types natively but does not rewrite import extensions. + expect( + selectFlowLoaderStrategy({ + isBun: false, + hasSyncHooks: true, + hasNativeTypeScript: true, + hasAsyncRegister: true, + }), + ).toBe("sync-alias"); + }); + + it("uses the oxc transpiling loader on Node 20 (no native TS)", () => { + expect( + selectFlowLoaderStrategy({ + isBun: false, + hasSyncHooks: false, + hasNativeTypeScript: false, + hasAsyncRegister: true, + }), + ).toBe("oxc-transpile"); + }); + + it("uses the oxc loader on Node 22.15-22.17 (sync hooks but no native TS)", () => { + expect( + selectFlowLoaderStrategy({ + isBun: false, + hasSyncHooks: true, + hasNativeTypeScript: false, + hasAsyncRegister: true, + }), + ).toBe("oxc-transpile"); + }); + + it("reports unsupported when Node cannot load TypeScript at all (Node < 20.6)", () => { + expect( + selectFlowLoaderStrategy({ + isBun: false, + hasSyncHooks: false, + hasNativeTypeScript: false, + hasAsyncRegister: false, + }), + ).toBe("unsupported"); + }); +}); diff --git a/src/shell/resolver/selectFlowLoaderStrategy.ts b/src/shell/resolver/selectFlowLoaderStrategy.ts new file mode 100644 index 000000000..744e5fd1f --- /dev/null +++ b/src/shell/resolver/selectFlowLoaderStrategy.ts @@ -0,0 +1,42 @@ +/** + * How a flow's TypeScript module should be made loadable in the current runtime. + * + * - `none` — the runtime transpiles and resolves TS itself (Bun). + * - `sync-alias` — the runtime strips types natively but does not rewrite import + * extensions; a sync `module.registerHooks` hook aliases + * `.js`↔`.ts` siblings (Node 22.18+). + * - `oxc-transpile`— the runtime does not transpile TS; an async `module.register` + * loader (oxc-node) transpiles and resolves (Node 20.6–22.17). + * - `unsupported` — a Node without native TS and without `module.register` + * (Node < 20.6): no way to load a TypeScript flow. + */ +export type FlowLoaderStrategy = + | "none" + | "sync-alias" + | "oxc-transpile" + | "unsupported"; + +export type RuntimeCapabilities = { + /** Running under Bun, which handles TS + resolution natively. */ + readonly isBun: boolean; + /** `module.registerHooks` is available (Node 22.15+). */ + readonly hasSyncHooks: boolean; + /** The runtime strips/transforms TypeScript natively (Node 22.18+). */ + readonly hasNativeTypeScript: boolean; + /** `module.register` is available (Node 20.6+). */ + readonly hasAsyncRegister: boolean; +}; + +/** + * Picks the flow-loader strategy from the runtime's capabilities. Pure: callers + * feature-detect the runtime and pass the booleans in. + */ +export function selectFlowLoaderStrategy( + caps: RuntimeCapabilities, +): FlowLoaderStrategy { + if (caps.isBun) return "none"; + if (caps.hasNativeTypeScript) { + return caps.hasSyncHooks ? "sync-alias" : "none"; + } + return caps.hasAsyncRegister ? "oxc-transpile" : "unsupported"; +} diff --git a/test/node20/greeting.ts b/test/node20/greeting.ts new file mode 100644 index 000000000..843ae3240 --- /dev/null +++ b/test/node20/greeting.ts @@ -0,0 +1,4 @@ +// A type annotation that must be stripped for this to run on Node 20 (which has +// no native TypeScript support) — proves the oxc loader transpiles, not just +// resolves. +export const greeting: string = "node20-ok"; diff --git a/test/node20/sample.flow.ts b/test/node20/sample.flow.ts new file mode 100644 index 000000000..5526b3843 --- /dev/null +++ b/test/node20/sample.flow.ts @@ -0,0 +1,9 @@ +// Imported by its compiled `.js` name though the file on disk is greeting.ts — +// exercises the `.js`->`.ts` extension aliasing that every loader strategy +// (Bun native, sync-alias hook, oxc-node) must provide. +import { greeting } from "./greeting.js"; + +// Typed local — another construct that must be stripped to run on Node 20. +const name: string = greeting; + +export default { name }; diff --git a/test/node20/smoke.mjs b/test/node20/smoke.mjs new file mode 100644 index 000000000..ce9695584 --- /dev/null +++ b/test/node20/smoke.mjs @@ -0,0 +1,23 @@ +// Cross-runtime witness that a TypeScript flow module loads and executes on the +// target runtime, driving the REAL shipped loader selection rather than a copy. +// +// loader.generated.mjs is `src/shell/resolver/registerFlowLoader.ts` bundled by +// bun with @oxc-node/core kept external (see the runtime-smoke CI job) — bundling +// resolves the `~/` path alias that Node/oxc cannot. Calling the real +// registerFlowLoader() means this smoke fails if the strategy selection or +// registration regresses, on each of Node 20 / 22 / 24 and Bun. +import { registerFlowLoader } from "./loader.generated.mjs"; + +await registerFlowLoader(); + +const mod = await import("./sample.flow.ts"); +const name = mod.default?.name; + +if (name !== "node20-ok") { + console.error(`flow-load smoke FAILED: expected "node20-ok", got ${name}`); + process.exit(1); +} + +const runtime = + globalThis.Bun !== undefined ? "bun" : `node ${process.version}`; +console.log(`flow-load smoke OK on ${runtime}`);