Skip to content

Commit 834ac92

Browse files
authored
ci(js): add Bun and Deno to JS CI matrix with runtime-compat tests (everruns#889)
Add Bun (latest, canary) and Deno (2.x, canary) to JS CI alongside Node (20, 22, 24, latest). Create runtime-compat test suite using node:test + node:assert that runs natively under all three runtimes, covering core execution, builtins, control flow, error handling, filesystem, VFS API, tool metadata, security, and scripts. Update specs/004-testing.md with maintenance rules.
1 parent 2928e36 commit 834ac92

12 files changed

Lines changed: 904 additions & 11 deletions

File tree

.github/workflows/js.yml

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,40 @@ env:
3535

3636
jobs:
3737
build-and-test:
38-
name: Node ${{ matrix.node }}
38+
name: ${{ matrix.runtime }} ${{ matrix.version }}
3939
runs-on: ubuntu-latest
4040

4141
strategy:
4242
fail-fast: false
4343
matrix:
44-
node: ["20", "22", "24", "latest"]
44+
include:
45+
# Node.js versions
46+
- runtime: node
47+
version: "20"
48+
run: "node"
49+
- runtime: node
50+
version: "22"
51+
run: "node"
52+
- runtime: node
53+
version: "24"
54+
run: "node"
55+
- runtime: node
56+
version: "latest"
57+
run: "node"
58+
# Bun
59+
- runtime: bun
60+
version: "latest"
61+
run: "bun"
62+
- runtime: bun
63+
version: "canary"
64+
run: "bun"
65+
# Deno
66+
- runtime: deno
67+
version: "2.x"
68+
run: "deno run -A"
69+
- runtime: deno
70+
version: "canary"
71+
run: "deno run -A"
4572

4673
steps:
4774
- uses: actions/checkout@v6
@@ -52,9 +79,28 @@ jobs:
5279
- uses: Swatinem/rust-cache@v2
5380

5481
- name: Setup Node.js
82+
if: matrix.runtime == 'node'
5583
uses: actions/setup-node@v6
5684
with:
57-
node-version: ${{ matrix.node }}
85+
node-version: ${{ matrix.version }}
86+
87+
- name: Setup Node.js (for napi build)
88+
if: matrix.runtime != 'node'
89+
uses: actions/setup-node@v6
90+
with:
91+
node-version: "22"
92+
93+
- name: Setup Bun
94+
if: matrix.runtime == 'bun'
95+
uses: oven-sh/setup-bun@v2
96+
with:
97+
bun-version: ${{ matrix.version }}
98+
99+
- name: Setup Deno
100+
if: matrix.runtime == 'deno'
101+
uses: denoland/setup-deno@v2
102+
with:
103+
deno-version: ${{ matrix.version }}
58104

59105
- name: Install dependencies
60106
run: npm install
@@ -64,10 +110,30 @@ jobs:
64110
run: npm run build
65111
working-directory: crates/bashkit-js
66112

67-
- name: Run tests
113+
- name: Run ava tests (Node only)
114+
if: matrix.runtime == 'node'
68115
run: npm test
69116
working-directory: crates/bashkit-js
70117

118+
- name: Run runtime-compat tests (Node)
119+
if: matrix.runtime == 'node'
120+
run: node --test __test__/runtime-compat/*.test.mjs
121+
working-directory: crates/bashkit-js
122+
123+
- name: Run runtime-compat tests (Bun)
124+
if: matrix.runtime == 'bun'
125+
run: bun test __test__/runtime-compat/
126+
working-directory: crates/bashkit-js
127+
128+
- name: Run runtime-compat tests (Deno)
129+
if: matrix.runtime == 'deno'
130+
run: |
131+
for f in __test__/runtime-compat/*.test.mjs; do
132+
echo "--- $f ---"
133+
deno run -A "$f"
134+
done
135+
working-directory: crates/bashkit-js
136+
71137
- name: Install example dependencies and link local build
72138
working-directory: examples
73139
run: |
@@ -79,10 +145,10 @@ jobs:
79145
- name: Run examples (self-contained)
80146
working-directory: examples
81147
run: |
82-
node bash_basics.mjs
83-
node data_pipeline.mjs
84-
node llm_tool.mjs
85-
node langchain_integration.mjs
148+
${{ matrix.run }} bash_basics.mjs
149+
${{ matrix.run }} data_pipeline.mjs
150+
${{ matrix.run }} llm_tool.mjs
151+
${{ matrix.run }} langchain_integration.mjs
86152
87153
- name: Install Doppler CLI
88154
if: env.DOPPLER_TOKEN != ''
@@ -92,9 +158,9 @@ jobs:
92158
if: env.DOPPLER_TOKEN != ''
93159
working-directory: examples
94160
run: |
95-
doppler run -- node openai_tool.mjs
96-
doppler run -- node vercel_ai_tool.mjs
97-
doppler run -- node langchain_agent.mjs
161+
doppler run -- ${{ matrix.run }} openai_tool.mjs
162+
doppler run -- ${{ matrix.run }} vercel_ai_tool.mjs
163+
doppler run -- ${{ matrix.run }} langchain_agent.mjs
98164
99165
# Gate job for branch protection
100166
js-check:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Shared setup for runtime-compat tests.
2+
// Loads the wrapper module (which re-exports native NAPI binding with
3+
// executeSyncOrThrow, BashError, etc.) — works in Node, Bun, Deno.
4+
5+
export { Bash, BashTool, BashError, getVersion } from "../../wrapper.js";
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Core execution: constructors, echo, arithmetic, options, reset.
2+
3+
import { describe, it } from "node:test";
4+
import assert from "node:assert/strict";
5+
import { Bash, getVersion } from "./_setup.mjs";
6+
7+
describe("version", () => {
8+
it("getVersion returns a semver string", () => {
9+
assert.match(getVersion(), /^\d+\.\d+\.\d+/);
10+
});
11+
});
12+
13+
describe("Bash basics", () => {
14+
it("default constructor", () => {
15+
assert.ok(new Bash());
16+
});
17+
18+
it("echo command", () => {
19+
const bash = new Bash();
20+
const r = bash.executeSync('echo "hello"');
21+
assert.equal(r.exitCode, 0);
22+
assert.equal(r.stdout.trim(), "hello");
23+
});
24+
25+
it("empty command", () => {
26+
assert.equal(new Bash().executeSync("").exitCode, 0);
27+
});
28+
29+
it("true returns 0, false returns non-zero", () => {
30+
const bash = new Bash();
31+
assert.equal(bash.executeSync("true").exitCode, 0);
32+
assert.notEqual(bash.executeSync("false").exitCode, 0);
33+
});
34+
35+
it("arithmetic", () => {
36+
const bash = new Bash();
37+
assert.equal(bash.executeSync("echo $((10 * 5 - 3))").stdout.trim(), "47");
38+
assert.equal(bash.executeSync("echo $((17 % 5))").stdout.trim(), "2");
39+
});
40+
41+
it("constructor with options", () => {
42+
const bash = new Bash({
43+
username: "testuser",
44+
hostname: "testhost",
45+
maxCommands: 1000,
46+
maxLoopIterations: 500,
47+
});
48+
assert.equal(bash.executeSync("whoami").stdout.trim(), "testuser");
49+
assert.equal(bash.executeSync("hostname").stdout.trim(), "testhost");
50+
});
51+
});
52+
53+
describe("variables and state", () => {
54+
it("variable assignment and expansion", () => {
55+
const bash = new Bash();
56+
bash.executeSync("NAME=world");
57+
assert.equal(bash.executeSync('echo "Hello $NAME"').stdout.trim(), "Hello world");
58+
});
59+
60+
it("state persists between calls", () => {
61+
const bash = new Bash();
62+
bash.executeSync("X=42");
63+
assert.equal(bash.executeSync("echo $X").stdout.trim(), "42");
64+
});
65+
66+
it("default value expansion", () => {
67+
const bash = new Bash();
68+
assert.equal(bash.executeSync("echo ${MISSING:-default}").stdout.trim(), "default");
69+
});
70+
71+
it("string length", () => {
72+
const bash = new Bash();
73+
bash.executeSync("S=hello");
74+
assert.equal(bash.executeSync("echo ${#S}").stdout.trim(), "5");
75+
});
76+
77+
it("prefix/suffix removal", () => {
78+
const bash = new Bash();
79+
bash.executeSync("F=path/to/file.txt");
80+
assert.equal(bash.executeSync("echo ${F##*/}").stdout.trim(), "file.txt");
81+
bash.executeSync("G=file.tar.gz");
82+
assert.equal(bash.executeSync("echo ${G%%.*}").stdout.trim(), "file");
83+
});
84+
85+
it("string replacement", () => {
86+
const bash = new Bash();
87+
bash.executeSync("S='hello world hello'");
88+
assert.equal(bash.executeSync('echo "${S//hello/bye}"').stdout.trim(), "bye world bye");
89+
});
90+
91+
it("uppercase/lowercase conversion", () => {
92+
const bash = new Bash();
93+
bash.executeSync("S=hello");
94+
assert.equal(bash.executeSync('echo "${S^^}"').stdout.trim(), "HELLO");
95+
bash.executeSync("U=HELLO");
96+
assert.equal(bash.executeSync('echo "${U,,}"').stdout.trim(), "hello");
97+
});
98+
99+
it("arrays", () => {
100+
const bash = new Bash();
101+
bash.executeSync("ARR=(apple banana cherry)");
102+
assert.equal(bash.executeSync('echo "${ARR[0]}"').stdout.trim(), "apple");
103+
assert.equal(bash.executeSync('echo "${#ARR[@]}"').stdout.trim(), "3");
104+
bash.executeSync("ARR+=(date)");
105+
assert.equal(bash.executeSync('echo "${#ARR[@]}"').stdout.trim(), "4");
106+
});
107+
});
108+
109+
describe("reset", () => {
110+
it("clears variables and files", () => {
111+
const bash = new Bash();
112+
bash.executeSync("X=42");
113+
bash.executeSync('echo "data" > /tmp/r.txt');
114+
bash.reset();
115+
assert.equal(bash.executeSync("echo ${X:-unset}").stdout.trim(), "unset");
116+
assert.notEqual(bash.executeSync("cat /tmp/r.txt 2>&1").exitCode, 0);
117+
});
118+
119+
it("preserves config after reset", () => {
120+
const bash = new Bash({ username: "keeper" });
121+
bash.executeSync("X=gone");
122+
bash.reset();
123+
assert.equal(bash.executeSync("whoami").stdout.trim(), "keeper");
124+
});
125+
});
126+
127+
describe("isolation", () => {
128+
it("Bash instances have isolated variables", () => {
129+
const a = new Bash();
130+
const b = new Bash();
131+
a.executeSync("X=from_a");
132+
b.executeSync("X=from_b");
133+
assert.equal(a.executeSync("echo $X").stdout.trim(), "from_a");
134+
assert.equal(b.executeSync("echo $X").stdout.trim(), "from_b");
135+
});
136+
137+
it("Bash instances have isolated filesystems", () => {
138+
const a = new Bash();
139+
const b = new Bash();
140+
a.executeSync('echo "a" > /tmp/iso.txt');
141+
assert.notEqual(b.executeSync("cat /tmp/iso.txt 2>&1").exitCode, 0);
142+
});
143+
});

0 commit comments

Comments
 (0)