Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/support-node-20.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
7 changes: 6 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 84 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -91,7 +92,7 @@
"typescript": "6.0.3"
},
"engines": {
"node": ">=22.15.0"
"node": ">=20.19.0"
},
"packageManager": "bun@1.3.13"
}
3 changes: 3 additions & 0 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions scripts/buildBinary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
];
Expand Down
27 changes: 27 additions & 0 deletions src/domains/doctor/checks/nodeVersion.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
37 changes: 30 additions & 7 deletions src/domains/doctor/checks/nodeVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,22 @@ type NodeVersionDeps = {
export async function checkNodeVersion(
deps: NodeVersionDeps,
): Promise<CheckResult> {
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",
detail: doctorMessages.nodeVersion.couldNotParseEngines(deps.enginesNode),
};
}

const actual = extractMajor(deps.processVersion, /^v?(\d+)/);
const actual = parseVersion(
deps.processVersion,
/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/,
);
if (actual === undefined) {
return {
name: "node-version",
Expand All @@ -29,7 +35,7 @@ export async function checkNodeVersion(
};
}

if (actual < minMajor) {
if (compareVersions(actual, min) < 0) {
return {
name: "node-version",
status: "fail",
Expand All @@ -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];
}
4 changes: 2 additions & 2 deletions src/domains/runner/loadFlowDefault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -72,7 +72,7 @@ export async function loadFlowDefault<T>(
} = args;

if (bundleFlow === undefined) {
registerFlowModuleResolver();
await registerFlowLoader();
return importDefaultExport<T>(pathToFileURL(flowPath).href, flowPath);
}

Expand Down
3 changes: 3 additions & 0 deletions src/oxcNode.d.ts
Original file line number Diff line number Diff line change
@@ -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";
18 changes: 18 additions & 0 deletions src/shell/resolver/detectRuntimeCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
22 changes: 22 additions & 0 deletions src/shell/resolver/detectRuntimeCapabilities.ts
Original file line number Diff line number Diff line change
@@ -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",
};
}
Loading
Loading