From 0b836bd7c91321e552198ba0eacb59dc7c8526a9 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Sun, 4 Oct 2026 14:23:49 +1300 Subject: [PATCH 1/5] ci: test builds and suites across Node and Bun --- .github/workflows/ci.yml | 39 +++++++ .../opencode-force-input/vitest.config.ts | 6 +- .../opencode-usage-limits/vitest.config.ts | 6 +- scripts/run-node-compat.mjs | 100 ++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 scripts/run-node-compat.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2829569..8adca77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,45 @@ permissions: contents: read jobs: + runtime-compat: + name: build and test (${{ matrix.runtime == 'bun' && 'Bun' || format('Node {0}', matrix.runtime) }}) + runs-on: ubuntu-24.04 + continue-on-error: ${{ matrix.runtime != 'bun' }} + strategy: + fail-fast: false + matrix: + runtime: + - "20" + - "22" + - "24" + - "26" + - bun + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node + if: matrix.runtime != 'bun' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.runtime }} + package-manager-cache: false + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Build and test with Bun + if: matrix.runtime == 'bun' + run: | + bun run build + bun run test + - name: Build and test with Node + if: matrix.runtime != 'bun' + run: node scripts/run-node-compat.mjs + check: name: ${{ matrix.task }} runs-on: ubuntu-24.04 diff --git a/packages/opencode-force-input/vitest.config.ts b/packages/opencode-force-input/vitest.config.ts index cf94cb1..a244af6 100644 --- a/packages/opencode-force-input/vitest.config.ts +++ b/packages/opencode-force-input/vitest.config.ts @@ -3,6 +3,8 @@ import { fileURLToPath } from "node:url"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; +const runtimeConditions = process.env.NODE_COMPAT === "true" ? [] : ["bun"]; + export default defineConfig({ plugins: [ solid({ @@ -15,12 +17,12 @@ export default defineConfig({ alias: { "@": fileURLToPath(new URL("src", import.meta.url)), }, - conditions: ["bun"], + conditions: runtimeConditions, dedupe: ["solid-js"], }, ssr: { resolve: { - conditions: ["bun"], + conditions: runtimeConditions, }, }, test: { diff --git a/packages/opencode-usage-limits/vitest.config.ts b/packages/opencode-usage-limits/vitest.config.ts index eecb65d..4fe4520 100644 --- a/packages/opencode-usage-limits/vitest.config.ts +++ b/packages/opencode-usage-limits/vitest.config.ts @@ -3,6 +3,8 @@ import { fileURLToPath } from "node:url"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; +const runtimeConditions = process.env.NODE_COMPAT === "true" ? [] : ["bun"]; + export default defineConfig({ plugins: [ solid({ @@ -15,12 +17,12 @@ export default defineConfig({ alias: { "@": fileURLToPath(new URL("src", import.meta.url)), }, - conditions: ["bun"], + conditions: runtimeConditions, dedupe: ["solid-js"], }, ssr: { resolve: { - conditions: ["bun"], + conditions: runtimeConditions, }, }, test: { diff --git a/scripts/run-node-compat.mjs b/scripts/run-node-compat.mjs new file mode 100644 index 0000000..61e2bfc --- /dev/null +++ b/scripts/run-node-compat.mjs @@ -0,0 +1,100 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDirectory = fileURLToPath(new URL("../", import.meta.url)); +const nodeModulesDirectory = "node_modules"; +const packageManifestFile = "package.json"; +const packageDirectories = ["packages", "apps"] + .flatMap((parent) => { + const parentPath = path.join(rootDirectory, parent); + return readdirSync(parentPath, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + readdirSync(path.join(parentPath, entry.name)).includes( + packageManifestFile + ) + ) + .map((entry) => path.join(parent, entry.name)); + }) + .toSorted(); + +const run = (command, args, cwd = rootDirectory) => { + const result = spawnSync(command, args, { + cwd, + stdio: "inherit", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +}; + +const vitestCli = path.join(rootDirectory, "node_modules/vitest/vitest.mjs"); +const runPackageCli = (script, packagePath) => { + const [command, ...args] = script.split(/\s+/u); + const manifestPath = [ + path.join(packagePath, nodeModulesDirectory, command, packageManifestFile), + path.join( + rootDirectory, + nodeModulesDirectory, + command, + packageManifestFile + ), + ].find(existsSync); + if (!manifestPath) { + throw new Error(`Could not resolve package manifest for ${command}.`); + } + const cliManifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + const bin = cliManifest.bin[command]; + if (!bin) { + throw new Error(`Could not resolve executable for ${command}.`); + } + run( + process.execPath, + [path.resolve(path.dirname(manifestPath), bin), ...args], + packagePath + ); +}; + +for (const packageDirectory of packageDirectories) { + const packagePath = path.join(rootDirectory, packageDirectory); + const manifest = JSON.parse( + readFileSync(path.join(packagePath, packageManifestFile), "utf-8") + ); + if (manifest.scripts?.build) { + console.log(`\n==> ${packageDirectory} build (Node)`); + runPackageCli(manifest.scripts.build, packagePath); + } + if (manifest.scripts?.test) { + console.log(`\n==> ${packageDirectory} test (Node)`); + if (existsSync(path.join(packagePath, "vitest.config.ts"))) { + const result = spawnSync( + process.execPath, + [ + vitestCli, + "run", + "--config", + path.join(packagePath, "vitest.config.ts"), + ], + { + cwd: packagePath, + env: { ...process.env, NODE_COMPAT: "true" }, + stdio: "inherit", + } + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } else { + runPackageCli(manifest.scripts.test, packagePath); + } + } +} From 92b2e6c70e4e1caac7595871b71d22d2ee4d3e75 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Sun, 4 Oct 2026 19:02:25 +1300 Subject: [PATCH 2/5] ci: remove unsupported Node 20 test target --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8adca77..ec6e41b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: fail-fast: false matrix: runtime: - - "20" - "22" - "24" - "26" From 2a706e16108220f83c50a3a44a1d097724c52c1c Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Sun, 4 Oct 2026 19:02:30 +1300 Subject: [PATCH 3/5] ci: enforce coverage thresholds in Node tests --- scripts/run-node-compat.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/run-node-compat.mjs b/scripts/run-node-compat.mjs index 61e2bfc..c37c3a6 100644 --- a/scripts/run-node-compat.mjs +++ b/scripts/run-node-compat.mjs @@ -35,6 +35,7 @@ const run = (command, args, cwd = rootDirectory) => { }; const vitestCli = path.join(rootDirectory, "node_modules/vitest/vitest.mjs"); +const coverageScript = path.join(rootDirectory, "scripts/check-coverage.ts"); const runPackageCli = (script, packagePath) => { const [command, ...args] = script.split(/\s+/u); const manifestPath = [ @@ -73,11 +74,20 @@ for (const packageDirectory of packageDirectories) { if (manifest.scripts?.test) { console.log(`\n==> ${packageDirectory} test (Node)`); if (existsSync(path.join(packagePath, "vitest.config.ts"))) { + const coverageThreshold = manifest.scripts.test.match( + /check-coverage\.ts\s+coverage\/lcov\.info\s+(?\S+)/u + )?.groups?.threshold; + if (!coverageThreshold) { + throw new Error( + `Could not resolve coverage threshold for ${packageDirectory}.` + ); + } const result = spawnSync( process.execPath, [ vitestCli, "run", + "--coverage", "--config", path.join(packagePath, "vitest.config.ts"), ], @@ -93,6 +103,15 @@ for (const packageDirectory of packageDirectories) { if (result.status !== 0) { process.exit(result.status ?? 1); } + run( + "bun", + [ + coverageScript, + path.join(packagePath, "coverage/lcov.info"), + coverageThreshold, + ], + packagePath + ); } else { runPackageCli(manifest.scripts.test, packagePath); } From f4bd992e4320f94b3e45ee1fd48c935855d41086 Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Sun, 4 Oct 2026 19:07:31 +1300 Subject: [PATCH 4/5] ci: continue Node checks after package failures --- scripts/run-node-compat.mjs | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/scripts/run-node-compat.mjs b/scripts/run-node-compat.mjs index c37c3a6..9ef1876 100644 --- a/scripts/run-node-compat.mjs +++ b/scripts/run-node-compat.mjs @@ -21,6 +21,7 @@ const packageDirectories = ["packages", "apps"] }) .toSorted(); +let hasFailedTask = false; const run = (command, args, cwd = rootDirectory) => { const result = spawnSync(command, args, { cwd, @@ -30,7 +31,7 @@ const run = (command, args, cwd = rootDirectory) => { throw result.error; } if (result.status !== 0) { - process.exit(result.status ?? 1); + hasFailedTask = true; } }; @@ -100,20 +101,25 @@ for (const packageDirectory of packageDirectories) { if (result.error) { throw result.error; } - if (result.status !== 0) { - process.exit(result.status ?? 1); + if (result.status === 0) { + run( + "bun", + [ + coverageScript, + path.join(packagePath, "coverage/lcov.info"), + coverageThreshold, + ], + packagePath + ); + } else { + hasFailedTask = true; } - run( - "bun", - [ - coverageScript, - path.join(packagePath, "coverage/lcov.info"), - coverageThreshold, - ], - packagePath - ); } else { runPackageCli(manifest.scripts.test, packagePath); } } } + +if (hasFailedTask) { + process.exitCode = 1; +} From 77752f46ce2a9200e8cd8a828d0480dc0c7b0edf Mon Sep 17 00:00:00 2001 From: My Name is Tito Date: Sun, 4 Oct 2026 19:13:19 +1300 Subject: [PATCH 5/5] fix(ci): preserve Node resolution and root tests --- packages/opencode-force-input/vitest.config.ts | 3 ++- .../opencode-usage-limits/vitest.config.ts | 3 ++- scripts/run-node-compat.mjs | 18 +++++++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/opencode-force-input/vitest.config.ts b/packages/opencode-force-input/vitest.config.ts index a244af6..ef8aa32 100644 --- a/packages/opencode-force-input/vitest.config.ts +++ b/packages/opencode-force-input/vitest.config.ts @@ -3,7 +3,8 @@ import { fileURLToPath } from "node:url"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; -const runtimeConditions = process.env.NODE_COMPAT === "true" ? [] : ["bun"]; +const runtimeConditions = + process.env.NODE_COMPAT === "true" ? undefined : ["bun"]; export default defineConfig({ plugins: [ diff --git a/packages/opencode-usage-limits/vitest.config.ts b/packages/opencode-usage-limits/vitest.config.ts index 4fe4520..19506a0 100644 --- a/packages/opencode-usage-limits/vitest.config.ts +++ b/packages/opencode-usage-limits/vitest.config.ts @@ -3,7 +3,8 @@ import { fileURLToPath } from "node:url"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; -const runtimeConditions = process.env.NODE_COMPAT === "true" ? [] : ["bun"]; +const runtimeConditions = + process.env.NODE_COMPAT === "true" ? undefined : ["bun"]; export default defineConfig({ plugins: [ diff --git a/scripts/run-node-compat.mjs b/scripts/run-node-compat.mjs index 9ef1876..59f629d 100644 --- a/scripts/run-node-compat.mjs +++ b/scripts/run-node-compat.mjs @@ -38,6 +38,9 @@ const run = (command, args, cwd = rootDirectory) => { const vitestCli = path.join(rootDirectory, "node_modules/vitest/vitest.mjs"); const coverageScript = path.join(rootDirectory, "scripts/check-coverage.ts"); const runPackageCli = (script, packagePath) => { + if (/[\n\r"'`;&|<>]/u.test(script)) { + throw new Error(`Unsupported shell syntax in package script: ${script}`); + } const [command, ...args] = script.split(/\s+/u); const manifestPath = [ path.join(packagePath, nodeModulesDirectory, command, packageManifestFile), @@ -52,7 +55,7 @@ const runPackageCli = (script, packagePath) => { throw new Error(`Could not resolve package manifest for ${command}.`); } const cliManifest = JSON.parse(readFileSync(manifestPath, "utf-8")); - const bin = cliManifest.bin[command]; + const bin = cliManifest.bin?.[command] ?? cliManifest.bin; if (!bin) { throw new Error(`Could not resolve executable for ${command}.`); } @@ -120,6 +123,19 @@ for (const packageDirectory of packageDirectories) { } } +console.log("\n==> scripts/__tests__ (Node)"); +const rootTestResult = spawnSync( + process.execPath, + [vitestCli, "run", "scripts/__tests__"], + { cwd: rootDirectory, stdio: "inherit" } +); +if (rootTestResult.error) { + throw rootTestResult.error; +} +if (rootTestResult.status !== 0) { + hasFailedTask = true; +} + if (hasFailedTask) { process.exitCode = 1; }