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
88 changes: 77 additions & 11 deletions .github/workflows/js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,40 @@ env:

jobs:
build-and-test:
name: Node ${{ matrix.node }}
name: ${{ matrix.runtime }} ${{ matrix.version }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
node: ["20", "22", "24", "latest"]
include:
# Node.js versions
- runtime: node
version: "20"
run: "node"
- runtime: node
version: "22"
run: "node"
- runtime: node
version: "24"
run: "node"
- runtime: node
version: "latest"
run: "node"
# Bun
- runtime: bun
version: "latest"
run: "bun"
- runtime: bun
version: "canary"
run: "bun"
# Deno
- runtime: deno
version: "2.x"
run: "deno run -A"
- runtime: deno
version: "canary"
run: "deno run -A"

steps:
- uses: actions/checkout@v6
Expand All @@ -52,9 +79,28 @@ jobs:
- uses: Swatinem/rust-cache@v2

- name: Setup Node.js
if: matrix.runtime == 'node'
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
node-version: ${{ matrix.version }}

- name: Setup Node.js (for napi build)
if: matrix.runtime != 'node'
uses: actions/setup-node@v6
with:
node-version: "22"

- name: Setup Bun
if: matrix.runtime == 'bun'
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ matrix.version }}

- name: Setup Deno
if: matrix.runtime == 'deno'
uses: denoland/setup-deno@v2
with:
deno-version: ${{ matrix.version }}

- name: Install dependencies
run: npm install
Expand All @@ -64,10 +110,30 @@ jobs:
run: npm run build
working-directory: crates/bashkit-js

- name: Run tests
- name: Run ava tests (Node only)
if: matrix.runtime == 'node'
run: npm test
working-directory: crates/bashkit-js

- name: Run runtime-compat tests (Node)
if: matrix.runtime == 'node'
run: node --test __test__/runtime-compat/*.test.mjs
working-directory: crates/bashkit-js

- name: Run runtime-compat tests (Bun)
if: matrix.runtime == 'bun'
run: bun test __test__/runtime-compat/
working-directory: crates/bashkit-js

- name: Run runtime-compat tests (Deno)
if: matrix.runtime == 'deno'
run: |
for f in __test__/runtime-compat/*.test.mjs; do
echo "--- $f ---"
deno run -A "$f"
done
working-directory: crates/bashkit-js

- name: Install example dependencies and link local build
working-directory: examples
run: |
Expand All @@ -79,10 +145,10 @@ jobs:
- name: Run examples (self-contained)
working-directory: examples
run: |
node bash_basics.mjs
node data_pipeline.mjs
node llm_tool.mjs
node langchain_integration.mjs
${{ matrix.run }} bash_basics.mjs
${{ matrix.run }} data_pipeline.mjs
${{ matrix.run }} llm_tool.mjs
${{ matrix.run }} langchain_integration.mjs

- name: Install Doppler CLI
if: env.DOPPLER_TOKEN != ''
Expand All @@ -92,9 +158,9 @@ jobs:
if: env.DOPPLER_TOKEN != ''
working-directory: examples
run: |
doppler run -- node openai_tool.mjs
doppler run -- node vercel_ai_tool.mjs
doppler run -- node langchain_agent.mjs
doppler run -- ${{ matrix.run }} openai_tool.mjs
doppler run -- ${{ matrix.run }} vercel_ai_tool.mjs
doppler run -- ${{ matrix.run }} langchain_agent.mjs

# Gate job for branch protection
js-check:
Expand Down
5 changes: 5 additions & 0 deletions crates/bashkit-js/__test__/runtime-compat/_setup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Shared setup for runtime-compat tests.
// Loads the wrapper module (which re-exports native NAPI binding with
// executeSyncOrThrow, BashError, etc.) — works in Node, Bun, Deno.

export { Bash, BashTool, BashError, getVersion } from "../../wrapper.js";
143 changes: 143 additions & 0 deletions crates/bashkit-js/__test__/runtime-compat/basics.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Core execution: constructors, echo, arithmetic, options, reset.

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { Bash, getVersion } from "./_setup.mjs";

describe("version", () => {
it("getVersion returns a semver string", () => {
assert.match(getVersion(), /^\d+\.\d+\.\d+/);
});
});

describe("Bash basics", () => {
it("default constructor", () => {
assert.ok(new Bash());
});

it("echo command", () => {
const bash = new Bash();
const r = bash.executeSync('echo "hello"');
assert.equal(r.exitCode, 0);
assert.equal(r.stdout.trim(), "hello");
});

it("empty command", () => {
assert.equal(new Bash().executeSync("").exitCode, 0);
});

it("true returns 0, false returns non-zero", () => {
const bash = new Bash();
assert.equal(bash.executeSync("true").exitCode, 0);
assert.notEqual(bash.executeSync("false").exitCode, 0);
});

it("arithmetic", () => {
const bash = new Bash();
assert.equal(bash.executeSync("echo $((10 * 5 - 3))").stdout.trim(), "47");
assert.equal(bash.executeSync("echo $((17 % 5))").stdout.trim(), "2");
});

it("constructor with options", () => {
const bash = new Bash({
username: "testuser",
hostname: "testhost",
maxCommands: 1000,
maxLoopIterations: 500,
});
assert.equal(bash.executeSync("whoami").stdout.trim(), "testuser");
assert.equal(bash.executeSync("hostname").stdout.trim(), "testhost");
});
});

describe("variables and state", () => {
it("variable assignment and expansion", () => {
const bash = new Bash();
bash.executeSync("NAME=world");
assert.equal(bash.executeSync('echo "Hello $NAME"').stdout.trim(), "Hello world");
});

it("state persists between calls", () => {
const bash = new Bash();
bash.executeSync("X=42");
assert.equal(bash.executeSync("echo $X").stdout.trim(), "42");
});

it("default value expansion", () => {
const bash = new Bash();
assert.equal(bash.executeSync("echo ${MISSING:-default}").stdout.trim(), "default");
});

it("string length", () => {
const bash = new Bash();
bash.executeSync("S=hello");
assert.equal(bash.executeSync("echo ${#S}").stdout.trim(), "5");
});

it("prefix/suffix removal", () => {
const bash = new Bash();
bash.executeSync("F=path/to/file.txt");
assert.equal(bash.executeSync("echo ${F##*/}").stdout.trim(), "file.txt");
bash.executeSync("G=file.tar.gz");
assert.equal(bash.executeSync("echo ${G%%.*}").stdout.trim(), "file");
});

it("string replacement", () => {
const bash = new Bash();
bash.executeSync("S='hello world hello'");
assert.equal(bash.executeSync('echo "${S//hello/bye}"').stdout.trim(), "bye world bye");
});

it("uppercase/lowercase conversion", () => {
const bash = new Bash();
bash.executeSync("S=hello");
assert.equal(bash.executeSync('echo "${S^^}"').stdout.trim(), "HELLO");
bash.executeSync("U=HELLO");
assert.equal(bash.executeSync('echo "${U,,}"').stdout.trim(), "hello");
});

it("arrays", () => {
const bash = new Bash();
bash.executeSync("ARR=(apple banana cherry)");
assert.equal(bash.executeSync('echo "${ARR[0]}"').stdout.trim(), "apple");
assert.equal(bash.executeSync('echo "${#ARR[@]}"').stdout.trim(), "3");
bash.executeSync("ARR+=(date)");
assert.equal(bash.executeSync('echo "${#ARR[@]}"').stdout.trim(), "4");
});
});

describe("reset", () => {
it("clears variables and files", () => {
const bash = new Bash();
bash.executeSync("X=42");
bash.executeSync('echo "data" > /tmp/r.txt');
bash.reset();
assert.equal(bash.executeSync("echo ${X:-unset}").stdout.trim(), "unset");
assert.notEqual(bash.executeSync("cat /tmp/r.txt 2>&1").exitCode, 0);
});

it("preserves config after reset", () => {
const bash = new Bash({ username: "keeper" });
bash.executeSync("X=gone");
bash.reset();
assert.equal(bash.executeSync("whoami").stdout.trim(), "keeper");
});
});

describe("isolation", () => {
it("Bash instances have isolated variables", () => {
const a = new Bash();
const b = new Bash();
a.executeSync("X=from_a");
b.executeSync("X=from_b");
assert.equal(a.executeSync("echo $X").stdout.trim(), "from_a");
assert.equal(b.executeSync("echo $X").stdout.trim(), "from_b");
});

it("Bash instances have isolated filesystems", () => {
const a = new Bash();
const b = new Bash();
a.executeSync('echo "a" > /tmp/iso.txt');
assert.notEqual(b.executeSync("cat /tmp/iso.txt 2>&1").exitCode, 0);
});
});
Loading
Loading