diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2829569..ec6e41b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,44 @@ 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: + - "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..ef8aa32 100644 --- a/packages/opencode-force-input/vitest.config.ts +++ b/packages/opencode-force-input/vitest.config.ts @@ -3,6 +3,9 @@ import { fileURLToPath } from "node:url"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; +const runtimeConditions = + process.env.NODE_COMPAT === "true" ? undefined : ["bun"]; + export default defineConfig({ plugins: [ solid({ @@ -15,12 +18,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..19506a0 100644 --- a/packages/opencode-usage-limits/vitest.config.ts +++ b/packages/opencode-usage-limits/vitest.config.ts @@ -3,6 +3,9 @@ import { fileURLToPath } from "node:url"; import solid from "vite-plugin-solid"; import { defineConfig } from "vitest/config"; +const runtimeConditions = + process.env.NODE_COMPAT === "true" ? undefined : ["bun"]; + export default defineConfig({ plugins: [ solid({ @@ -15,12 +18,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..59f629d --- /dev/null +++ b/scripts/run-node-compat.mjs @@ -0,0 +1,141 @@ +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(); + +let hasFailedTask = false; +const run = (command, args, cwd = rootDirectory) => { + const result = spawnSync(command, args, { + cwd, + stdio: "inherit", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + hasFailedTask = true; + } +}; + +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), + 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] ?? cliManifest.bin; + 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 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"), + ], + { + cwd: packagePath, + env: { ...process.env, NODE_COMPAT: "true" }, + stdio: "inherit", + } + ); + if (result.error) { + throw result.error; + } + if (result.status === 0) { + run( + "bun", + [ + coverageScript, + path.join(packagePath, "coverage/lcov.info"), + coverageThreshold, + ], + packagePath + ); + } else { + hasFailedTask = true; + } + } else { + runPackageCli(manifest.scripts.test, packagePath); + } + } +} + +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; +}