From b63508722c836ab68b9336d26e570c0c3ed9c683 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 27 Mar 2026 23:24:00 +0000 Subject: [PATCH 1/8] ci(js): add Bun runtime to JS CI matrix Test Node bindings under Bun (latest + canary) alongside the existing Node 20/22/24/latest matrix. Gate job now requires both runtimes green. --- .github/workflows/js.yml | 73 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index aa369e63..c1da3786 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -96,11 +96,78 @@ jobs: doppler run -- node vercel_ai_tool.mjs doppler run -- node langchain_agent.mjs + bun-test: + name: Bun ${{ matrix.bun }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + bun: ["latest", "canary"] + + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ matrix.bun }} + + - name: Install dependencies + run: bun install + working-directory: crates/bashkit-js + + - name: Build native binding + run: bun run build + working-directory: crates/bashkit-js + + - name: Run tests + run: bun run test + working-directory: crates/bashkit-js + + - name: Install example dependencies and link local build + working-directory: examples + run: | + bun install + rm -rf node_modules/@everruns/bashkit + mkdir -p node_modules/@everruns + ln -s ${{ github.workspace }}/crates/bashkit-js node_modules/@everruns/bashkit + + - name: Run examples (self-contained) + working-directory: examples + run: | + bun bash_basics.mjs + bun data_pipeline.mjs + bun llm_tool.mjs + bun langchain_integration.mjs + + - name: Install Doppler CLI + if: env.DOPPLER_TOKEN != '' + uses: dopplerhq/cli-action@v4 + + - name: Run AI examples + if: env.DOPPLER_TOKEN != '' + working-directory: examples + run: | + doppler run -- bun openai_tool.mjs + doppler run -- bun vercel_ai_tool.mjs + doppler run -- bun langchain_agent.mjs + # Gate job for branch protection js-check: name: JS Check if: always() - needs: [build-and-test] + needs: [build-and-test, bun-test] runs-on: ubuntu-latest steps: - name: Verify all jobs passed @@ -109,3 +176,7 @@ jobs: echo "JS build/test failed" exit 1 fi + if [[ "${{ needs.bun-test.result }}" != "success" ]]; then + echo "Bun test failed" + exit 1 + fi From f01284f471dc7415b3e0c8fa458c44ac4ca68c5e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 27 Mar 2026 23:32:38 +0000 Subject: [PATCH 2/8] ci(js): unify Node and Bun into single matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace separate build-and-test + bun-test jobs with one matrix using runtime (node/bun) × version. Conditional steps handle runtime-specific setup (setup-node vs setup-bun, npm test vs bun run test). --- .github/workflows/js.yml | 110 ++++++++++++--------------------------- 1 file changed, 32 insertions(+), 78 deletions(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index c1da3786..50775ce4 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -35,13 +35,25 @@ 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: + - runtime: node + version: "20" + - runtime: node + version: "22" + - runtime: node + version: "24" + - runtime: node + version: "latest" + - runtime: bun + version: "latest" + - runtime: bun + version: "canary" steps: - uses: actions/checkout@v6 @@ -52,93 +64,39 @@ 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: Install dependencies - run: npm install - working-directory: crates/bashkit-js - - - name: Build native binding - run: npm run build - working-directory: crates/bashkit-js - - - name: Run tests - run: npm test - working-directory: crates/bashkit-js - - - name: Install example dependencies and link local build - working-directory: examples - run: | - npm install - rm -rf node_modules/@everruns/bashkit - mkdir -p node_modules/@everruns - ln -s ${{ github.workspace }}/crates/bashkit-js node_modules/@everruns/bashkit - - - 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 - - - name: Install Doppler CLI - if: env.DOPPLER_TOKEN != '' - uses: dopplerhq/cli-action@v4 - - - name: Run AI examples - 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 - - bun-test: - name: Bun ${{ matrix.bun }} - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - bun: ["latest", "canary"] - - steps: - - uses: actions/checkout@v6 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - - - name: Setup Node.js + - name: Setup Node.js (for napi build) + if: matrix.runtime == 'bun' 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.bun }} + bun-version: ${{ matrix.version }} - name: Install dependencies - run: bun install + run: ${{ matrix.runtime }} install working-directory: crates/bashkit-js - name: Build native binding - run: bun run build + run: ${{ matrix.runtime }} run build working-directory: crates/bashkit-js - name: Run tests - run: bun run test + run: ${{ matrix.runtime == 'node' && 'npm test' || 'bun run test' }} working-directory: crates/bashkit-js - name: Install example dependencies and link local build working-directory: examples run: | - bun install + ${{ matrix.runtime }} install rm -rf node_modules/@everruns/bashkit mkdir -p node_modules/@everruns ln -s ${{ github.workspace }}/crates/bashkit-js node_modules/@everruns/bashkit @@ -146,10 +104,10 @@ jobs: - name: Run examples (self-contained) working-directory: examples run: | - bun bash_basics.mjs - bun data_pipeline.mjs - bun llm_tool.mjs - bun langchain_integration.mjs + ${{ matrix.runtime }} bash_basics.mjs + ${{ matrix.runtime }} data_pipeline.mjs + ${{ matrix.runtime }} llm_tool.mjs + ${{ matrix.runtime }} langchain_integration.mjs - name: Install Doppler CLI if: env.DOPPLER_TOKEN != '' @@ -159,15 +117,15 @@ jobs: if: env.DOPPLER_TOKEN != '' working-directory: examples run: | - doppler run -- bun openai_tool.mjs - doppler run -- bun vercel_ai_tool.mjs - doppler run -- bun langchain_agent.mjs + doppler run -- ${{ matrix.runtime }} openai_tool.mjs + doppler run -- ${{ matrix.runtime }} vercel_ai_tool.mjs + doppler run -- ${{ matrix.runtime }} langchain_agent.mjs # Gate job for branch protection js-check: name: JS Check if: always() - needs: [build-and-test, bun-test] + needs: [build-and-test] runs-on: ubuntu-latest steps: - name: Verify all jobs passed @@ -176,7 +134,3 @@ jobs: echo "JS build/test failed" exit 1 fi - if [[ "${{ needs.bun-test.result }}" != "success" ]]; then - echo "Bun test failed" - exit 1 - fi From 7c5dc8e8c6a4d8d47ef4e6eac5396b76f85fbb7f Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 27 Mar 2026 23:34:15 +0000 Subject: [PATCH 3/8] ci(js): add Deno to JS runtime matrix Add Deno 2.x and canary to the CI matrix. Refactor matrix to use `run` and `pkg` variables per entry: Deno uses npm for install/build (napi-rs needs Node) and `deno run -A` for executing examples. --- .github/workflows/js.yml | 53 +++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 50775ce4..69fd9100 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -42,18 +42,41 @@ jobs: fail-fast: false matrix: include: + # Node.js versions - runtime: node version: "20" + run: "node" + pkg: "npm" - runtime: node version: "22" + run: "node" + pkg: "npm" - runtime: node version: "24" + run: "node" + pkg: "npm" - runtime: node version: "latest" + run: "node" + pkg: "npm" + # Bun - runtime: bun version: "latest" + run: "bun" + pkg: "bun" - runtime: bun version: "canary" + run: "bun" + pkg: "bun" + # Deno + - runtime: deno + version: "2.x" + run: "deno run -A" + pkg: "npm" + - runtime: deno + version: "canary" + run: "deno run -A" + pkg: "npm" steps: - uses: actions/checkout@v6 @@ -70,7 +93,7 @@ jobs: node-version: ${{ matrix.version }} - name: Setup Node.js (for napi build) - if: matrix.runtime == 'bun' + if: matrix.runtime != 'node' uses: actions/setup-node@v6 with: node-version: "22" @@ -81,22 +104,28 @@ jobs: 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: ${{ matrix.runtime }} install + run: ${{ matrix.pkg }} install working-directory: crates/bashkit-js - name: Build native binding - run: ${{ matrix.runtime }} run build + run: ${{ matrix.pkg }} run build working-directory: crates/bashkit-js - name: Run tests - run: ${{ matrix.runtime == 'node' && 'npm test' || 'bun run test' }} + run: ${{ matrix.pkg }} test working-directory: crates/bashkit-js - name: Install example dependencies and link local build working-directory: examples run: | - ${{ matrix.runtime }} install + ${{ matrix.pkg }} install rm -rf node_modules/@everruns/bashkit mkdir -p node_modules/@everruns ln -s ${{ github.workspace }}/crates/bashkit-js node_modules/@everruns/bashkit @@ -104,10 +133,10 @@ jobs: - name: Run examples (self-contained) working-directory: examples run: | - ${{ matrix.runtime }} bash_basics.mjs - ${{ matrix.runtime }} data_pipeline.mjs - ${{ matrix.runtime }} llm_tool.mjs - ${{ matrix.runtime }} 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 != '' @@ -117,9 +146,9 @@ jobs: if: env.DOPPLER_TOKEN != '' working-directory: examples run: | - doppler run -- ${{ matrix.runtime }} openai_tool.mjs - doppler run -- ${{ matrix.runtime }} vercel_ai_tool.mjs - doppler run -- ${{ matrix.runtime }} 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: From 8314edc66ee54a9ec42b7e37b881664cad23b632 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 28 Mar 2026 00:00:01 +0000 Subject: [PATCH 4/8] fix(ci): use 'run test' instead of 'test' for Bun compatibility 'bun test' invokes Bun's built-in test runner instead of the package.json script. 'bun run test' correctly delegates to ava. --- .github/workflows/js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 69fd9100..081e22e6 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -119,7 +119,7 @@ jobs: working-directory: crates/bashkit-js - name: Run tests - run: ${{ matrix.pkg }} test + run: ${{ matrix.pkg }} run test working-directory: crates/bashkit-js - name: Install example dependencies and link local build From e95c13163946e8557f452f5171f59c3e1beb7c12 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 28 Mar 2026 00:25:04 +0000 Subject: [PATCH 5/8] test(js): add runtime-agnostic test suite for Bun/Deno CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add runtime-compat.test.mjs using node:test + node:assert — runs directly under Bun and Deno without ava dependency. Covers core execution, variables, filesystem, pipes, VFS API, error handling, control flow, all builtins, BashTool metadata, security/resource limits, and instance isolation. Node continues running the full ava suite; Bun/Deno run this file directly with their respective runtimes plus examples. --- .github/workflows/js.yml | 24 +- .../__test__/runtime-compat.test.mjs | 768 ++++++++++++++++++ 2 files changed, 779 insertions(+), 13 deletions(-) create mode 100644 crates/bashkit-js/__test__/runtime-compat.test.mjs diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 081e22e6..0e1bfcd2 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -46,37 +46,29 @@ jobs: - runtime: node version: "20" run: "node" - pkg: "npm" - runtime: node version: "22" run: "node" - pkg: "npm" - runtime: node version: "24" run: "node" - pkg: "npm" - runtime: node version: "latest" run: "node" - pkg: "npm" # Bun - runtime: bun version: "latest" run: "bun" - pkg: "bun" - runtime: bun version: "canary" run: "bun" - pkg: "bun" # Deno - runtime: deno version: "2.x" run: "deno run -A" - pkg: "npm" - runtime: deno version: "canary" run: "deno run -A" - pkg: "npm" steps: - uses: actions/checkout@v6 @@ -111,21 +103,27 @@ jobs: deno-version: ${{ matrix.version }} - name: Install dependencies - run: ${{ matrix.pkg }} install + run: npm install working-directory: crates/bashkit-js - name: Build native binding - run: ${{ matrix.pkg }} run build + run: npm run build working-directory: crates/bashkit-js - - name: Run tests - run: ${{ matrix.pkg }} run test + - name: Run ava tests (Node only) + if: matrix.runtime == 'node' + run: npm test + working-directory: crates/bashkit-js + + - name: Run runtime-compat tests + if: matrix.runtime != 'node' + run: ${{ matrix.run }} __test__/runtime-compat.test.mjs working-directory: crates/bashkit-js - name: Install example dependencies and link local build working-directory: examples run: | - ${{ matrix.pkg }} install + npm install rm -rf node_modules/@everruns/bashkit mkdir -p node_modules/@everruns ln -s ${{ github.workspace }}/crates/bashkit-js node_modules/@everruns/bashkit diff --git a/crates/bashkit-js/__test__/runtime-compat.test.mjs b/crates/bashkit-js/__test__/runtime-compat.test.mjs new file mode 100644 index 00000000..6ae6b186 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat.test.mjs @@ -0,0 +1,768 @@ +// Runtime-agnostic test suite using node:test + node:assert. +// Runs under Node, Bun, and Deno to verify NAPI bindings work across runtimes. +// +// Covers: core execution, variables, filesystem, pipes, VFS API, error handling, +// control flow, builtins, BashTool metadata, security/resource limits, isolation. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const native = require("../index.cjs"); +const { Bash, BashTool, getVersion } = native; + +// ============================================================================ +// Version +// ============================================================================ + +describe("version", () => { + it("getVersion returns a semver string", () => { + assert.match(getVersion(), /^\d+\.\d+\.\d+/); + }); +}); + +// ============================================================================ +// Bash — constructor and basic execution +// ============================================================================ + +describe("Bash basics", () => { + it("default constructor", () => { + const bash = new Bash(); + assert.ok(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", () => { + const bash = new Bash(); + assert.equal(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"); + }); +}); + +// ============================================================================ +// Variables and state +// ============================================================================ + +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"); + }); +}); + +// ============================================================================ +// Filesystem +// ============================================================================ + +describe("filesystem", () => { + it("write, read, append", () => { + const bash = new Bash(); + bash.executeSync('echo "line1" > /tmp/f.txt'); + bash.executeSync('echo "line2" >> /tmp/f.txt'); + const r = bash.executeSync("cat /tmp/f.txt"); + assert.ok(r.stdout.includes("line1")); + assert.ok(r.stdout.includes("line2")); + }); + + it("mkdir, touch, ls", () => { + const bash = new Bash(); + bash.executeSync("mkdir -p /tmp/d/sub"); + bash.executeSync("touch /tmp/d/sub/file.txt"); + assert.ok(bash.executeSync("ls /tmp/d/sub").stdout.includes("file.txt")); + }); + + it("cp and mv", () => { + const bash = new Bash(); + bash.executeSync('echo "data" > /tmp/src.txt'); + bash.executeSync("cp /tmp/src.txt /tmp/cp.txt"); + assert.equal(bash.executeSync("cat /tmp/cp.txt").stdout.trim(), "data"); + bash.executeSync("mv /tmp/cp.txt /tmp/mv.txt"); + assert.equal(bash.executeSync("cat /tmp/mv.txt").stdout.trim(), "data"); + assert.notEqual(bash.executeSync("cat /tmp/cp.txt 2>&1").exitCode, 0); + }); + + it("rm and test flags", () => { + const bash = new Bash(); + bash.executeSync("touch /tmp/rm.txt"); + assert.equal(bash.executeSync("test -f /tmp/rm.txt && echo yes").stdout.trim(), "yes"); + bash.executeSync("rm /tmp/rm.txt"); + assert.notEqual(bash.executeSync("test -f /tmp/rm.txt").exitCode, 0); + }); + + it("cd and pwd", () => { + const bash = new Bash(); + bash.executeSync("mkdir -p /tmp/nav"); + bash.executeSync("cd /tmp/nav"); + assert.equal(bash.executeSync("pwd").stdout.trim(), "/tmp/nav"); + }); +}); + +// ============================================================================ +// Pipes and redirection +// ============================================================================ + +describe("pipes and redirection", () => { + it("pipe echo to grep", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "foo\\nbar\\nbaz" | grep bar').stdout.trim(), + "bar", + ); + }); + + it("pipe chain", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "c\\na\\nb" | sort | head -1').stdout.trim(), + "a", + ); + }); + + it("stderr redirect", () => { + const bash = new Bash(); + const r = bash.executeSync("echo err >&2"); + assert.ok(r.stderr.includes("err")); + }); + + it("command substitution", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo "result: $(echo 42)"').stdout.trim(), + "result: 42", + ); + }); + + it("heredoc", () => { + const bash = new Bash(); + bash.executeSync("NAME=alice"); + const r = bash.executeSync("cat < { + it("if/elif/else", () => { + const bash = new Bash(); + const r = bash.executeSync(` + X=2 + if [ "$X" = "1" ]; then echo one + elif [ "$X" = "2" ]; then echo two + else echo other + fi + `); + assert.equal(r.stdout.trim(), "two"); + }); + + it("for loop", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync("for i in a b c; do echo $i; done").stdout.trim(), + "a\nb\nc", + ); + }); + + it("while loop", () => { + const bash = new Bash(); + const r = bash.executeSync(` + i=0 + while [ $i -lt 3 ]; do echo $i; i=$((i + 1)); done + `); + assert.equal(r.stdout.trim(), "0\n1\n2"); + }); + + it("break and continue", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync(` + for i in 1 2 3 4 5; do + if [ $i -eq 4 ]; then break; fi + if [ $i -eq 2 ]; then continue; fi + echo $i + done + `).stdout.trim(), + "1\n3", + ); + }); + + it("case statement", () => { + const bash = new Bash(); + const r = bash.executeSync(` + FILE=image.png + case "$FILE" in + *.png) echo "png";; + *.jpg) echo "jpg";; + *) echo "other";; + esac + `); + assert.equal(r.stdout.trim(), "png"); + }); + + it("functions with local vars and recursion", () => { + const bash = new Bash(); + const r = bash.executeSync(` + factorial() { + if [ $1 -le 1 ]; then echo 1; return; fi + local sub=$(factorial $(($1 - 1))) + echo $(($1 * sub)) + } + factorial 5 + `); + assert.equal(r.stdout.trim(), "120"); + }); + + it("subshell does not leak variables", () => { + const bash = new Bash(); + bash.executeSync("(X=inner)"); + assert.equal(bash.executeSync("echo ${X:-unset}").stdout.trim(), "unset"); + }); + + it("$? captures last exit code", () => { + const bash = new Bash(); + assert.equal(bash.executeSync("false; echo $?").stdout.trim(), "1"); + }); +}); + +// ============================================================================ +// Builtins +// ============================================================================ + +describe("builtins", () => { + it("grep variations", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "apple\\nbanana\\ncherry" | grep banana').stdout.trim(), + "banana", + ); + assert.equal( + bash.executeSync('echo -e "Hello\\nworld" | grep -i hello').stdout.trim(), + "Hello", + ); + assert.equal( + bash.executeSync('echo -e "a\\nb\\nc" | grep -v b').stdout.trim(), + "a\nc", + ); + }); + + it("sed substitute", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync("echo 'aaa' | sed 's/a/b/g'").stdout.trim(), + "bbb", + ); + }); + + it("awk field extraction and sum", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync("echo 'one two three' | awk '{print $2}'").stdout.trim(), + "two", + ); + assert.equal( + bash.executeSync("echo -e '1\\n2\\n3\\n4' | awk '{s+=$1} END {print s}'").stdout.trim(), + "10", + ); + }); + + it("sort, uniq, tr, cut", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "c\\na\\nb" | sort').stdout.trim(), + "a\nb\nc", + ); + assert.equal( + bash.executeSync('echo -e "a\\na\\nb\\nb\\nc" | uniq').stdout.trim(), + "a\nb\nc", + ); + assert.equal( + bash.executeSync("echo 'hello' | tr 'a-z' 'A-Z'").stdout.trim(), + "HELLO", + ); + assert.equal( + bash.executeSync("echo 'a,b,c' | cut -d, -f2").stdout.trim(), + "b", + ); + }); + + it("head, tail, wc", () => { + const bash = new Bash(); + bash.executeSync('echo -e "1\\n2\\n3\\n4\\n5" > /tmp/hw.txt'); + assert.equal(bash.executeSync("head -n 2 /tmp/hw.txt").stdout.trim(), "1\n2"); + assert.equal(bash.executeSync("tail -n 2 /tmp/hw.txt").stdout.trim(), "4\n5"); + assert.equal(bash.executeSync("wc -l < /tmp/hw.txt").stdout.trim(), "5"); + }); + + it("base64 encode/decode", () => { + const bash = new Bash(); + const encoded = bash.executeSync("echo -n 'hello' | base64").stdout.trim(); + assert.equal(encoded, "aGVsbG8="); + assert.equal(bash.executeSync(`echo -n '${encoded}' | base64 -d`).stdout, "hello"); + }); + + it("jq JSON processing", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo \'{"name":"alice"}\' | jq -r ".name"').stdout.trim(), + "alice", + ); + assert.equal( + bash.executeSync("echo '[1,2,3]' | jq 'length'").stdout.trim(), + "3", + ); + const arr = JSON.parse( + bash.executeSync("echo '[1,2,3,4,5]' | jq '[.[] | select(. > 3)]'").stdout, + ); + assert.deepEqual(arr, [4, 5]); + }); + + it("md5sum and sha256sum", () => { + const bash = new Bash(); + assert.ok( + bash.executeSync("echo -n 'hello' | md5sum").stdout.includes("5d41402abc4b2a76b9719d911017c592"), + ); + assert.ok( + bash.executeSync("echo -n 'hello' | sha256sum").stdout.includes( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ), + ); + }); + + it("seq, printf, date, export/unset", () => { + const bash = new Bash(); + assert.equal(bash.executeSync("seq 1 5").stdout.trim(), "1\n2\n3\n4\n5"); + assert.equal(bash.executeSync('printf "Hello %s" "World"').stdout, "Hello World"); + assert.equal(bash.executeSync("date").exitCode, 0); + bash.executeSync("export MY_VAR=hello"); + assert.equal(bash.executeSync("echo $MY_VAR").stdout.trim(), "hello"); + bash.executeSync("unset MY_VAR"); + assert.equal(bash.executeSync("echo ${MY_VAR:-gone}").stdout.trim(), "gone"); + }); +}); + +// ============================================================================ +// VFS API +// ============================================================================ + +describe("VFS API", () => { + it("writeFile + readFile roundtrip", () => { + const bash = new Bash(); + bash.writeFile("/tmp/hello.txt", "Hello, VFS!"); + assert.equal(bash.readFile("/tmp/hello.txt"), "Hello, VFS!"); + }); + + it("writeFile overwrites", () => { + const bash = new Bash(); + bash.writeFile("/tmp/o.txt", "first"); + bash.writeFile("/tmp/o.txt", "second"); + assert.equal(bash.readFile("/tmp/o.txt"), "second"); + }); + + it("readFile throws on missing file", () => { + const bash = new Bash(); + assert.throws(() => bash.readFile("/nonexistent/file.txt")); + }); + + it("mkdir, exists, remove", () => { + const bash = new Bash(); + bash.mkdir("/tmp/vdir"); + assert.ok(bash.exists("/tmp/vdir")); + bash.writeFile("/tmp/vdir/f.txt", "data"); + assert.ok(bash.exists("/tmp/vdir/f.txt")); + bash.remove("/tmp/vdir", true); + assert.ok(!bash.exists("/tmp/vdir")); + }); + + it("mkdir recursive", () => { + const bash = new Bash(); + bash.mkdir("/a/b/c/d", true); + assert.ok(bash.exists("/a/b/c/d")); + assert.ok(bash.exists("/a/b")); + }); + + it("VFS ↔ bash interop", () => { + const bash = new Bash(); + bash.writeFile("/tmp/from-vfs.txt", "vfs-content"); + assert.equal(bash.executeSync("cat /tmp/from-vfs.txt").stdout, "vfs-content"); + bash.executeSync("echo bash-content > /tmp/from-bash.txt"); + assert.equal(bash.readFile("/tmp/from-bash.txt"), "bash-content\n"); + }); + + it("reset clears VFS state", () => { + const bash = new Bash(); + bash.writeFile("/tmp/p.txt", "data"); + assert.ok(bash.exists("/tmp/p.txt")); + bash.reset(); + assert.ok(!bash.exists("/tmp/p.txt")); + }); +}); + +// ============================================================================ +// Error handling +// ============================================================================ + +describe("error handling", () => { + it("failed command has non-zero exit code", () => { + const bash = new Bash(); + assert.notEqual(bash.executeSync("false").exitCode, 0); + }); + + it("exit with specific codes", () => { + const bash = new Bash(); + assert.equal(bash.executeSync("exit 0").exitCode, 0); + assert.equal(bash.executeSync("exit 1").exitCode, 1); + assert.equal(bash.executeSync("exit 127").exitCode, 127); + }); + + it("stderr captured separately", () => { + const bash = new Bash(); + const r = bash.executeSync("echo out; echo err >&2"); + assert.ok(r.stdout.includes("out")); + assert.ok(r.stderr.includes("err")); + }); + + it("executeSyncOrThrow succeeds on exit 0", () => { + const bash = new Bash(); + const r = bash.executeSyncOrThrow("echo ok"); + assert.equal(r.exitCode, 0); + assert.equal(r.stdout.trim(), "ok"); + }); + + it("executeSyncOrThrow throws on failure", () => { + const bash = new Bash(); + assert.throws(() => bash.executeSyncOrThrow("exit 42"), (err) => { + assert.equal(err.name, "BashError"); + assert.equal(err.exitCode, 42); + assert.equal(typeof err.message, "string"); + assert.ok(err.display().includes("BashError")); + return true; + }); + }); + + it("interpreter usable after error, state preserved", () => { + const bash = new Bash(); + bash.executeSync("X=before"); + bash.executeSync("false"); + assert.equal(bash.executeSync("echo $X").stdout.trim(), "before"); + assert.equal(bash.executeSync("echo recovered").stdout.trim(), "recovered"); + }); + + it("syntax error returns non-zero", () => { + const bash = new Bash(); + assert.notEqual(bash.executeSync("if then fi").exitCode, 0); + }); + + it("pre-exec parse error surfaces in stderr", () => { + const bash = new Bash(); + const r = bash.executeSync("echo $("); + assert.notEqual(r.exitCode, 0); + assert.ok(r.error); + assert.ok(r.stderr.length > 0); + }); +}); + +// ============================================================================ +// Reset +// ============================================================================ + +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"); + }); +}); + +// ============================================================================ +// BashTool metadata +// ============================================================================ + +describe("BashTool metadata", () => { + it("name, version, shortDescription", () => { + const tool = new BashTool(); + assert.equal(tool.name, "bashkit"); + assert.match(tool.version, /^\d+\.\d+\.\d+/); + assert.equal(tool.version, getVersion()); + assert.ok(tool.shortDescription.length > 0); + }); + + it("description, help, systemPrompt", () => { + const tool = new BashTool(); + assert.ok(tool.description().length > 10); + assert.ok(tool.help().length > 10); + assert.notEqual(tool.description(), tool.help()); + assert.ok(tool.systemPrompt().toLowerCase().includes("bash")); + }); + + it("inputSchema and outputSchema are valid JSON", () => { + const tool = new BashTool(); + const input = JSON.parse(tool.inputSchema()); + const output = JSON.parse(tool.outputSchema()); + assert.equal(typeof input, "object"); + assert.equal(typeof output, "object"); + assert.ok(JSON.stringify(input).includes("command")); + }); + + it("schemas stable across calls and instances", () => { + const a = new BashTool(); + const b = new BashTool(); + assert.equal(a.inputSchema(), a.inputSchema()); + assert.equal(a.inputSchema(), b.inputSchema()); + assert.equal(a.outputSchema(), b.outputSchema()); + }); + + it("metadata unchanged after execution and reset", () => { + const tool = new BashTool(); + const nameBefore = tool.name; + const schemaBefore = tool.inputSchema(); + tool.executeSync("echo hello"); + tool.reset(); + assert.equal(tool.name, nameBefore); + assert.equal(tool.inputSchema(), schemaBefore); + }); + + it("systemPrompt reflects configured username", () => { + const tool = new BashTool({ username: "agent", hostname: "sandbox" }); + const prompt = tool.systemPrompt(); + assert.ok(prompt.includes("agent")); + assert.ok(prompt.includes("/home/agent")); + }); + + it("BashTool execution and reset", () => { + const tool = new BashTool({ username: "keep" }); + tool.executeSync("VAR=gone"); + tool.reset(); + assert.equal(tool.executeSync("echo ${VAR:-unset}").stdout.trim(), "unset"); + assert.equal(tool.executeSync("whoami").stdout.trim(), "keep"); + }); +}); + +// ============================================================================ +// Security / resource limits +// ============================================================================ + +describe("security", () => { + it("command limit enforced", () => { + const bash = new Bash({ maxCommands: 5 }); + const r = bash.executeSync("true; true; true; true; true; true; true; true; true; true"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("loop iteration limit enforced", () => { + const bash = new Bash({ maxLoopIterations: 5 }); + const r = bash.executeSync("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("infinite while loop capped", () => { + const bash = new Bash({ maxLoopIterations: 10 }); + const r = bash.executeSync("i=0; while true; do i=$((i+1)); done"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("recursive function depth limited", () => { + const bash = new Bash({ maxCommands: 10000 }); + const r = bash.executeSync("bomb() { bomb; }; bomb"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("sandbox escape blocked", () => { + const bash = new Bash(); + assert.notEqual(bash.executeSync("exec /bin/bash").exitCode, 0); + assert.notEqual(bash.executeSync("cat /proc/self/maps 2>&1").exitCode, 0); + assert.notEqual(bash.executeSync("cat /etc/passwd 2>&1").exitCode, 0); + }); + + it("VFS path traversal blocked", () => { + const bash = new Bash(); + bash.executeSync('echo "secret" > /home/data.txt'); + assert.notEqual(bash.executeSync("cat /home/../../../etc/shadow 2>&1").exitCode, 0); + }); + + it("recovery after exceeding limits", () => { + const bash = new Bash({ maxCommands: 3 }); + bash.executeSync("true; true; true; true; true; true"); + const r = bash.executeSync("echo recovered"); + assert.equal(r.exitCode, 0); + assert.equal(r.stdout.trim(), "recovered"); + }); +}); + +// ============================================================================ +// Instance isolation +// ============================================================================ + +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); + }); + + it("BashTool instances are isolated", () => { + const a = new BashTool(); + const b = new BashTool(); + a.executeSync("VAR=toolA"); + b.executeSync("VAR=toolB"); + assert.equal(a.executeSync("echo $VAR").stdout.trim(), "toolA"); + assert.equal(b.executeSync("echo $VAR").stdout.trim(), "toolB"); + }); +}); + +// ============================================================================ +// Real-world script patterns +// ============================================================================ + +describe("scripts", () => { + it("JSON processing pipeline", () => { + const bash = new Bash(); + const r = bash.executeSync(` + echo '[{"name":"alice","age":30},{"name":"bob","age":25}]' | \ + jq -r '.[] | select(.age > 28) | .name' + `); + assert.equal(r.stdout.trim(), "alice"); + }); + + it("data transformation pipeline", () => { + const bash = new Bash(); + bash.executeSync('echo -e "Alice,30\\nBob,25\\nCharlie,35" > /tmp/data.csv'); + assert.equal( + bash.executeSync("cat /tmp/data.csv | sort -t, -k2 -n | head -1 | cut -d, -f1").stdout.trim(), + "Bob", + ); + }); + + it("config file generation via heredoc", () => { + const bash = new Bash(); + const r = bash.executeSync(` + APP_NAME=myapp + APP_PORT=8080 + cat < { + const bash = new Bash(); + for (let i = 0; i < 50; i++) { + bash.executeSync(`echo ${i}`); + } + assert.equal(bash.executeSync("echo done").stdout.trim(), "done"); + }); + + it("large output", () => { + const bash = new Bash(); + const r = bash.executeSync("seq 1 1000"); + const lines = r.stdout.trim().split("\n"); + assert.equal(lines.length, 1000); + assert.equal(lines[0], "1"); + assert.equal(lines[999], "1000"); + }); +}); From 4e895a8670cb5e5e974fb107a213fafb0c0c6278 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 28 Mar 2026 00:36:50 +0000 Subject: [PATCH 6/8] refactor(js): split runtime-compat into focused files, run on all runtimes - Split monolithic runtime-compat.test.mjs into 8 focused files in __test__/runtime-compat/ (basics, builtins, control-flow, error-handling, filesystem, vfs, tool-metadata, security, scripts) plus shared _setup.mjs. - Node now runs BOTH ava (full functional suite) AND runtime-compat tests. - Bun/Deno run runtime-compat + examples. - Update specs/004-testing.md with JS runtime compat testing strategy, CI matrix, maintenance rules, and local run instructions. --- .github/workflows/js.yml | 13 +- .../__test__/runtime-compat.test.mjs | 768 ------------------ .../__test__/runtime-compat/_setup.mjs | 9 + .../__test__/runtime-compat/basics.test.mjs | 143 ++++ .../__test__/runtime-compat/builtins.test.mjs | 120 +++ .../runtime-compat/control-flow.test.mjs | 88 ++ .../runtime-compat/error-handling.test.mjs | 64 ++ .../runtime-compat/filesystem.test.mjs | 86 ++ .../__test__/runtime-compat/scripts.test.mjs | 60 ++ .../__test__/runtime-compat/security.test.mjs | 52 ++ .../runtime-compat/tool-metadata.test.mjs | 75 ++ .../__test__/runtime-compat/vfs.test.mjs | 57 ++ specs/004-testing.md | 68 ++ 13 files changed, 833 insertions(+), 770 deletions(-) delete mode 100644 crates/bashkit-js/__test__/runtime-compat.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/_setup.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/basics.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/control-flow.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/filesystem.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/scripts.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/security.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/tool-metadata.test.mjs create mode 100644 crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 0e1bfcd2..9f19445c 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -115,9 +115,18 @@ jobs: run: npm test working-directory: crates/bashkit-js - - name: Run runtime-compat tests + - 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/Deno) if: matrix.runtime != 'node' - run: ${{ matrix.run }} __test__/runtime-compat.test.mjs + run: | + for f in __test__/runtime-compat/*.test.mjs; do + echo "--- $f ---" + ${{ matrix.run }} "$f" + done working-directory: crates/bashkit-js - name: Install example dependencies and link local build diff --git a/crates/bashkit-js/__test__/runtime-compat.test.mjs b/crates/bashkit-js/__test__/runtime-compat.test.mjs deleted file mode 100644 index 6ae6b186..00000000 --- a/crates/bashkit-js/__test__/runtime-compat.test.mjs +++ /dev/null @@ -1,768 +0,0 @@ -// Runtime-agnostic test suite using node:test + node:assert. -// Runs under Node, Bun, and Deno to verify NAPI bindings work across runtimes. -// -// Covers: core execution, variables, filesystem, pipes, VFS API, error handling, -// control flow, builtins, BashTool metadata, security/resource limits, isolation. - -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const native = require("../index.cjs"); -const { Bash, BashTool, getVersion } = native; - -// ============================================================================ -// Version -// ============================================================================ - -describe("version", () => { - it("getVersion returns a semver string", () => { - assert.match(getVersion(), /^\d+\.\d+\.\d+/); - }); -}); - -// ============================================================================ -// Bash — constructor and basic execution -// ============================================================================ - -describe("Bash basics", () => { - it("default constructor", () => { - const bash = new Bash(); - assert.ok(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", () => { - const bash = new Bash(); - assert.equal(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"); - }); -}); - -// ============================================================================ -// Variables and state -// ============================================================================ - -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"); - }); -}); - -// ============================================================================ -// Filesystem -// ============================================================================ - -describe("filesystem", () => { - it("write, read, append", () => { - const bash = new Bash(); - bash.executeSync('echo "line1" > /tmp/f.txt'); - bash.executeSync('echo "line2" >> /tmp/f.txt'); - const r = bash.executeSync("cat /tmp/f.txt"); - assert.ok(r.stdout.includes("line1")); - assert.ok(r.stdout.includes("line2")); - }); - - it("mkdir, touch, ls", () => { - const bash = new Bash(); - bash.executeSync("mkdir -p /tmp/d/sub"); - bash.executeSync("touch /tmp/d/sub/file.txt"); - assert.ok(bash.executeSync("ls /tmp/d/sub").stdout.includes("file.txt")); - }); - - it("cp and mv", () => { - const bash = new Bash(); - bash.executeSync('echo "data" > /tmp/src.txt'); - bash.executeSync("cp /tmp/src.txt /tmp/cp.txt"); - assert.equal(bash.executeSync("cat /tmp/cp.txt").stdout.trim(), "data"); - bash.executeSync("mv /tmp/cp.txt /tmp/mv.txt"); - assert.equal(bash.executeSync("cat /tmp/mv.txt").stdout.trim(), "data"); - assert.notEqual(bash.executeSync("cat /tmp/cp.txt 2>&1").exitCode, 0); - }); - - it("rm and test flags", () => { - const bash = new Bash(); - bash.executeSync("touch /tmp/rm.txt"); - assert.equal(bash.executeSync("test -f /tmp/rm.txt && echo yes").stdout.trim(), "yes"); - bash.executeSync("rm /tmp/rm.txt"); - assert.notEqual(bash.executeSync("test -f /tmp/rm.txt").exitCode, 0); - }); - - it("cd and pwd", () => { - const bash = new Bash(); - bash.executeSync("mkdir -p /tmp/nav"); - bash.executeSync("cd /tmp/nav"); - assert.equal(bash.executeSync("pwd").stdout.trim(), "/tmp/nav"); - }); -}); - -// ============================================================================ -// Pipes and redirection -// ============================================================================ - -describe("pipes and redirection", () => { - it("pipe echo to grep", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync('echo -e "foo\\nbar\\nbaz" | grep bar').stdout.trim(), - "bar", - ); - }); - - it("pipe chain", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync('echo -e "c\\na\\nb" | sort | head -1').stdout.trim(), - "a", - ); - }); - - it("stderr redirect", () => { - const bash = new Bash(); - const r = bash.executeSync("echo err >&2"); - assert.ok(r.stderr.includes("err")); - }); - - it("command substitution", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync('echo "result: $(echo 42)"').stdout.trim(), - "result: 42", - ); - }); - - it("heredoc", () => { - const bash = new Bash(); - bash.executeSync("NAME=alice"); - const r = bash.executeSync("cat < { - it("if/elif/else", () => { - const bash = new Bash(); - const r = bash.executeSync(` - X=2 - if [ "$X" = "1" ]; then echo one - elif [ "$X" = "2" ]; then echo two - else echo other - fi - `); - assert.equal(r.stdout.trim(), "two"); - }); - - it("for loop", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync("for i in a b c; do echo $i; done").stdout.trim(), - "a\nb\nc", - ); - }); - - it("while loop", () => { - const bash = new Bash(); - const r = bash.executeSync(` - i=0 - while [ $i -lt 3 ]; do echo $i; i=$((i + 1)); done - `); - assert.equal(r.stdout.trim(), "0\n1\n2"); - }); - - it("break and continue", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync(` - for i in 1 2 3 4 5; do - if [ $i -eq 4 ]; then break; fi - if [ $i -eq 2 ]; then continue; fi - echo $i - done - `).stdout.trim(), - "1\n3", - ); - }); - - it("case statement", () => { - const bash = new Bash(); - const r = bash.executeSync(` - FILE=image.png - case "$FILE" in - *.png) echo "png";; - *.jpg) echo "jpg";; - *) echo "other";; - esac - `); - assert.equal(r.stdout.trim(), "png"); - }); - - it("functions with local vars and recursion", () => { - const bash = new Bash(); - const r = bash.executeSync(` - factorial() { - if [ $1 -le 1 ]; then echo 1; return; fi - local sub=$(factorial $(($1 - 1))) - echo $(($1 * sub)) - } - factorial 5 - `); - assert.equal(r.stdout.trim(), "120"); - }); - - it("subshell does not leak variables", () => { - const bash = new Bash(); - bash.executeSync("(X=inner)"); - assert.equal(bash.executeSync("echo ${X:-unset}").stdout.trim(), "unset"); - }); - - it("$? captures last exit code", () => { - const bash = new Bash(); - assert.equal(bash.executeSync("false; echo $?").stdout.trim(), "1"); - }); -}); - -// ============================================================================ -// Builtins -// ============================================================================ - -describe("builtins", () => { - it("grep variations", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync('echo -e "apple\\nbanana\\ncherry" | grep banana').stdout.trim(), - "banana", - ); - assert.equal( - bash.executeSync('echo -e "Hello\\nworld" | grep -i hello').stdout.trim(), - "Hello", - ); - assert.equal( - bash.executeSync('echo -e "a\\nb\\nc" | grep -v b').stdout.trim(), - "a\nc", - ); - }); - - it("sed substitute", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync("echo 'aaa' | sed 's/a/b/g'").stdout.trim(), - "bbb", - ); - }); - - it("awk field extraction and sum", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync("echo 'one two three' | awk '{print $2}'").stdout.trim(), - "two", - ); - assert.equal( - bash.executeSync("echo -e '1\\n2\\n3\\n4' | awk '{s+=$1} END {print s}'").stdout.trim(), - "10", - ); - }); - - it("sort, uniq, tr, cut", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync('echo -e "c\\na\\nb" | sort').stdout.trim(), - "a\nb\nc", - ); - assert.equal( - bash.executeSync('echo -e "a\\na\\nb\\nb\\nc" | uniq').stdout.trim(), - "a\nb\nc", - ); - assert.equal( - bash.executeSync("echo 'hello' | tr 'a-z' 'A-Z'").stdout.trim(), - "HELLO", - ); - assert.equal( - bash.executeSync("echo 'a,b,c' | cut -d, -f2").stdout.trim(), - "b", - ); - }); - - it("head, tail, wc", () => { - const bash = new Bash(); - bash.executeSync('echo -e "1\\n2\\n3\\n4\\n5" > /tmp/hw.txt'); - assert.equal(bash.executeSync("head -n 2 /tmp/hw.txt").stdout.trim(), "1\n2"); - assert.equal(bash.executeSync("tail -n 2 /tmp/hw.txt").stdout.trim(), "4\n5"); - assert.equal(bash.executeSync("wc -l < /tmp/hw.txt").stdout.trim(), "5"); - }); - - it("base64 encode/decode", () => { - const bash = new Bash(); - const encoded = bash.executeSync("echo -n 'hello' | base64").stdout.trim(); - assert.equal(encoded, "aGVsbG8="); - assert.equal(bash.executeSync(`echo -n '${encoded}' | base64 -d`).stdout, "hello"); - }); - - it("jq JSON processing", () => { - const bash = new Bash(); - assert.equal( - bash.executeSync('echo \'{"name":"alice"}\' | jq -r ".name"').stdout.trim(), - "alice", - ); - assert.equal( - bash.executeSync("echo '[1,2,3]' | jq 'length'").stdout.trim(), - "3", - ); - const arr = JSON.parse( - bash.executeSync("echo '[1,2,3,4,5]' | jq '[.[] | select(. > 3)]'").stdout, - ); - assert.deepEqual(arr, [4, 5]); - }); - - it("md5sum and sha256sum", () => { - const bash = new Bash(); - assert.ok( - bash.executeSync("echo -n 'hello' | md5sum").stdout.includes("5d41402abc4b2a76b9719d911017c592"), - ); - assert.ok( - bash.executeSync("echo -n 'hello' | sha256sum").stdout.includes( - "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - ), - ); - }); - - it("seq, printf, date, export/unset", () => { - const bash = new Bash(); - assert.equal(bash.executeSync("seq 1 5").stdout.trim(), "1\n2\n3\n4\n5"); - assert.equal(bash.executeSync('printf "Hello %s" "World"').stdout, "Hello World"); - assert.equal(bash.executeSync("date").exitCode, 0); - bash.executeSync("export MY_VAR=hello"); - assert.equal(bash.executeSync("echo $MY_VAR").stdout.trim(), "hello"); - bash.executeSync("unset MY_VAR"); - assert.equal(bash.executeSync("echo ${MY_VAR:-gone}").stdout.trim(), "gone"); - }); -}); - -// ============================================================================ -// VFS API -// ============================================================================ - -describe("VFS API", () => { - it("writeFile + readFile roundtrip", () => { - const bash = new Bash(); - bash.writeFile("/tmp/hello.txt", "Hello, VFS!"); - assert.equal(bash.readFile("/tmp/hello.txt"), "Hello, VFS!"); - }); - - it("writeFile overwrites", () => { - const bash = new Bash(); - bash.writeFile("/tmp/o.txt", "first"); - bash.writeFile("/tmp/o.txt", "second"); - assert.equal(bash.readFile("/tmp/o.txt"), "second"); - }); - - it("readFile throws on missing file", () => { - const bash = new Bash(); - assert.throws(() => bash.readFile("/nonexistent/file.txt")); - }); - - it("mkdir, exists, remove", () => { - const bash = new Bash(); - bash.mkdir("/tmp/vdir"); - assert.ok(bash.exists("/tmp/vdir")); - bash.writeFile("/tmp/vdir/f.txt", "data"); - assert.ok(bash.exists("/tmp/vdir/f.txt")); - bash.remove("/tmp/vdir", true); - assert.ok(!bash.exists("/tmp/vdir")); - }); - - it("mkdir recursive", () => { - const bash = new Bash(); - bash.mkdir("/a/b/c/d", true); - assert.ok(bash.exists("/a/b/c/d")); - assert.ok(bash.exists("/a/b")); - }); - - it("VFS ↔ bash interop", () => { - const bash = new Bash(); - bash.writeFile("/tmp/from-vfs.txt", "vfs-content"); - assert.equal(bash.executeSync("cat /tmp/from-vfs.txt").stdout, "vfs-content"); - bash.executeSync("echo bash-content > /tmp/from-bash.txt"); - assert.equal(bash.readFile("/tmp/from-bash.txt"), "bash-content\n"); - }); - - it("reset clears VFS state", () => { - const bash = new Bash(); - bash.writeFile("/tmp/p.txt", "data"); - assert.ok(bash.exists("/tmp/p.txt")); - bash.reset(); - assert.ok(!bash.exists("/tmp/p.txt")); - }); -}); - -// ============================================================================ -// Error handling -// ============================================================================ - -describe("error handling", () => { - it("failed command has non-zero exit code", () => { - const bash = new Bash(); - assert.notEqual(bash.executeSync("false").exitCode, 0); - }); - - it("exit with specific codes", () => { - const bash = new Bash(); - assert.equal(bash.executeSync("exit 0").exitCode, 0); - assert.equal(bash.executeSync("exit 1").exitCode, 1); - assert.equal(bash.executeSync("exit 127").exitCode, 127); - }); - - it("stderr captured separately", () => { - const bash = new Bash(); - const r = bash.executeSync("echo out; echo err >&2"); - assert.ok(r.stdout.includes("out")); - assert.ok(r.stderr.includes("err")); - }); - - it("executeSyncOrThrow succeeds on exit 0", () => { - const bash = new Bash(); - const r = bash.executeSyncOrThrow("echo ok"); - assert.equal(r.exitCode, 0); - assert.equal(r.stdout.trim(), "ok"); - }); - - it("executeSyncOrThrow throws on failure", () => { - const bash = new Bash(); - assert.throws(() => bash.executeSyncOrThrow("exit 42"), (err) => { - assert.equal(err.name, "BashError"); - assert.equal(err.exitCode, 42); - assert.equal(typeof err.message, "string"); - assert.ok(err.display().includes("BashError")); - return true; - }); - }); - - it("interpreter usable after error, state preserved", () => { - const bash = new Bash(); - bash.executeSync("X=before"); - bash.executeSync("false"); - assert.equal(bash.executeSync("echo $X").stdout.trim(), "before"); - assert.equal(bash.executeSync("echo recovered").stdout.trim(), "recovered"); - }); - - it("syntax error returns non-zero", () => { - const bash = new Bash(); - assert.notEqual(bash.executeSync("if then fi").exitCode, 0); - }); - - it("pre-exec parse error surfaces in stderr", () => { - const bash = new Bash(); - const r = bash.executeSync("echo $("); - assert.notEqual(r.exitCode, 0); - assert.ok(r.error); - assert.ok(r.stderr.length > 0); - }); -}); - -// ============================================================================ -// Reset -// ============================================================================ - -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"); - }); -}); - -// ============================================================================ -// BashTool metadata -// ============================================================================ - -describe("BashTool metadata", () => { - it("name, version, shortDescription", () => { - const tool = new BashTool(); - assert.equal(tool.name, "bashkit"); - assert.match(tool.version, /^\d+\.\d+\.\d+/); - assert.equal(tool.version, getVersion()); - assert.ok(tool.shortDescription.length > 0); - }); - - it("description, help, systemPrompt", () => { - const tool = new BashTool(); - assert.ok(tool.description().length > 10); - assert.ok(tool.help().length > 10); - assert.notEqual(tool.description(), tool.help()); - assert.ok(tool.systemPrompt().toLowerCase().includes("bash")); - }); - - it("inputSchema and outputSchema are valid JSON", () => { - const tool = new BashTool(); - const input = JSON.parse(tool.inputSchema()); - const output = JSON.parse(tool.outputSchema()); - assert.equal(typeof input, "object"); - assert.equal(typeof output, "object"); - assert.ok(JSON.stringify(input).includes("command")); - }); - - it("schemas stable across calls and instances", () => { - const a = new BashTool(); - const b = new BashTool(); - assert.equal(a.inputSchema(), a.inputSchema()); - assert.equal(a.inputSchema(), b.inputSchema()); - assert.equal(a.outputSchema(), b.outputSchema()); - }); - - it("metadata unchanged after execution and reset", () => { - const tool = new BashTool(); - const nameBefore = tool.name; - const schemaBefore = tool.inputSchema(); - tool.executeSync("echo hello"); - tool.reset(); - assert.equal(tool.name, nameBefore); - assert.equal(tool.inputSchema(), schemaBefore); - }); - - it("systemPrompt reflects configured username", () => { - const tool = new BashTool({ username: "agent", hostname: "sandbox" }); - const prompt = tool.systemPrompt(); - assert.ok(prompt.includes("agent")); - assert.ok(prompt.includes("/home/agent")); - }); - - it("BashTool execution and reset", () => { - const tool = new BashTool({ username: "keep" }); - tool.executeSync("VAR=gone"); - tool.reset(); - assert.equal(tool.executeSync("echo ${VAR:-unset}").stdout.trim(), "unset"); - assert.equal(tool.executeSync("whoami").stdout.trim(), "keep"); - }); -}); - -// ============================================================================ -// Security / resource limits -// ============================================================================ - -describe("security", () => { - it("command limit enforced", () => { - const bash = new Bash({ maxCommands: 5 }); - const r = bash.executeSync("true; true; true; true; true; true; true; true; true; true"); - assert.ok(r.exitCode !== 0 || r.error !== undefined); - }); - - it("loop iteration limit enforced", () => { - const bash = new Bash({ maxLoopIterations: 5 }); - const r = bash.executeSync("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done"); - assert.ok(r.exitCode !== 0 || r.error !== undefined); - }); - - it("infinite while loop capped", () => { - const bash = new Bash({ maxLoopIterations: 10 }); - const r = bash.executeSync("i=0; while true; do i=$((i+1)); done"); - assert.ok(r.exitCode !== 0 || r.error !== undefined); - }); - - it("recursive function depth limited", () => { - const bash = new Bash({ maxCommands: 10000 }); - const r = bash.executeSync("bomb() { bomb; }; bomb"); - assert.ok(r.exitCode !== 0 || r.error !== undefined); - }); - - it("sandbox escape blocked", () => { - const bash = new Bash(); - assert.notEqual(bash.executeSync("exec /bin/bash").exitCode, 0); - assert.notEqual(bash.executeSync("cat /proc/self/maps 2>&1").exitCode, 0); - assert.notEqual(bash.executeSync("cat /etc/passwd 2>&1").exitCode, 0); - }); - - it("VFS path traversal blocked", () => { - const bash = new Bash(); - bash.executeSync('echo "secret" > /home/data.txt'); - assert.notEqual(bash.executeSync("cat /home/../../../etc/shadow 2>&1").exitCode, 0); - }); - - it("recovery after exceeding limits", () => { - const bash = new Bash({ maxCommands: 3 }); - bash.executeSync("true; true; true; true; true; true"); - const r = bash.executeSync("echo recovered"); - assert.equal(r.exitCode, 0); - assert.equal(r.stdout.trim(), "recovered"); - }); -}); - -// ============================================================================ -// Instance isolation -// ============================================================================ - -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); - }); - - it("BashTool instances are isolated", () => { - const a = new BashTool(); - const b = new BashTool(); - a.executeSync("VAR=toolA"); - b.executeSync("VAR=toolB"); - assert.equal(a.executeSync("echo $VAR").stdout.trim(), "toolA"); - assert.equal(b.executeSync("echo $VAR").stdout.trim(), "toolB"); - }); -}); - -// ============================================================================ -// Real-world script patterns -// ============================================================================ - -describe("scripts", () => { - it("JSON processing pipeline", () => { - const bash = new Bash(); - const r = bash.executeSync(` - echo '[{"name":"alice","age":30},{"name":"bob","age":25}]' | \ - jq -r '.[] | select(.age > 28) | .name' - `); - assert.equal(r.stdout.trim(), "alice"); - }); - - it("data transformation pipeline", () => { - const bash = new Bash(); - bash.executeSync('echo -e "Alice,30\\nBob,25\\nCharlie,35" > /tmp/data.csv'); - assert.equal( - bash.executeSync("cat /tmp/data.csv | sort -t, -k2 -n | head -1 | cut -d, -f1").stdout.trim(), - "Bob", - ); - }); - - it("config file generation via heredoc", () => { - const bash = new Bash(); - const r = bash.executeSync(` - APP_NAME=myapp - APP_PORT=8080 - cat < { - const bash = new Bash(); - for (let i = 0; i < 50; i++) { - bash.executeSync(`echo ${i}`); - } - assert.equal(bash.executeSync("echo done").stdout.trim(), "done"); - }); - - it("large output", () => { - const bash = new Bash(); - const r = bash.executeSync("seq 1 1000"); - const lines = r.stdout.trim().split("\n"); - assert.equal(lines.length, 1000); - assert.equal(lines[0], "1"); - assert.equal(lines[999], "1000"); - }); -}); diff --git a/crates/bashkit-js/__test__/runtime-compat/_setup.mjs b/crates/bashkit-js/__test__/runtime-compat/_setup.mjs new file mode 100644 index 00000000..8dbdbff7 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/_setup.mjs @@ -0,0 +1,9 @@ +// Shared setup for runtime-compat tests. +// Loads the native NAPI binding via createRequire (works in Node, Bun, Deno). + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const native = require("../../index.cjs"); + +export const { Bash, BashTool, getVersion } = native; diff --git a/crates/bashkit-js/__test__/runtime-compat/basics.test.mjs b/crates/bashkit-js/__test__/runtime-compat/basics.test.mjs new file mode 100644 index 00000000..82e32b78 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/basics.test.mjs @@ -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); + }); +}); diff --git a/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs b/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs new file mode 100644 index 00000000..9aa38f41 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs @@ -0,0 +1,120 @@ +// Builtin commands: grep, sed, awk, sort, uniq, tr, cut, head, tail, wc, +// base64, jq, md5sum, sha256sum, seq, printf, date, export/unset. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Bash } from "./_setup.mjs"; + +describe("builtins", () => { + it("grep variations", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "apple\\nbanana\\ncherry" | grep banana').stdout.trim(), + "banana", + ); + assert.equal( + bash.executeSync('echo -e "Hello\\nworld" | grep -i hello').stdout.trim(), + "Hello", + ); + assert.equal( + bash.executeSync('echo -e "a\\nb\\nc" | grep -v b').stdout.trim(), + "a\nc", + ); + }); + + it("sed substitute", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync("echo 'aaa' | sed 's/a/b/g'").stdout.trim(), + "bbb", + ); + }); + + it("awk field extraction and sum", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync("echo 'one two three' | awk '{print $2}'").stdout.trim(), + "two", + ); + assert.equal( + bash.executeSync("echo -e '1\\n2\\n3\\n4' | awk '{s+=$1} END {print s}'").stdout.trim(), + "10", + ); + }); + + it("sort, uniq, tr, cut", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "c\\na\\nb" | sort').stdout.trim(), + "a\nb\nc", + ); + assert.equal( + bash.executeSync('echo -e "a\\na\\nb\\nb\\nc" | uniq').stdout.trim(), + "a\nb\nc", + ); + assert.equal( + bash.executeSync("echo 'hello' | tr 'a-z' 'A-Z'").stdout.trim(), + "HELLO", + ); + assert.equal( + bash.executeSync("echo 'a,b,c' | cut -d, -f2").stdout.trim(), + "b", + ); + }); + + it("head, tail, wc", () => { + const bash = new Bash(); + bash.executeSync('echo -e "1\\n2\\n3\\n4\\n5" > /tmp/hw.txt'); + assert.equal(bash.executeSync("head -n 2 /tmp/hw.txt").stdout.trim(), "1\n2"); + assert.equal(bash.executeSync("tail -n 2 /tmp/hw.txt").stdout.trim(), "4\n5"); + assert.equal(bash.executeSync("wc -l < /tmp/hw.txt").stdout.trim(), "5"); + }); + + it("base64 encode/decode", () => { + const bash = new Bash(); + const encoded = bash.executeSync("echo -n 'hello' | base64").stdout.trim(); + assert.equal(encoded, "aGVsbG8="); + assert.equal(bash.executeSync(`echo -n '${encoded}' | base64 -d`).stdout, "hello"); + }); + + it("jq JSON processing", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo \'{"name":"alice"}\' | jq -r ".name"').stdout.trim(), + "alice", + ); + assert.equal( + bash.executeSync("echo '[1,2,3]' | jq 'length'").stdout.trim(), + "3", + ); + const arr = JSON.parse( + bash.executeSync("echo '[1,2,3,4,5]' | jq '[.[] | select(. > 3)]'").stdout, + ); + assert.deepEqual(arr, [4, 5]); + }); + + it("md5sum and sha256sum", () => { + const bash = new Bash(); + assert.ok( + bash.executeSync("echo -n 'hello' | md5sum").stdout.includes( + "5d41402abc4b2a76b9719d911017c592", + ), + ); + assert.ok( + bash.executeSync("echo -n 'hello' | sha256sum").stdout.includes( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ), + ); + }); + + it("seq, printf, date, export/unset", () => { + const bash = new Bash(); + assert.equal(bash.executeSync("seq 1 5").stdout.trim(), "1\n2\n3\n4\n5"); + assert.equal(bash.executeSync('printf "Hello %s" "World"').stdout, "Hello World"); + assert.equal(bash.executeSync("date").exitCode, 0); + bash.executeSync("export MY_VAR=hello"); + assert.equal(bash.executeSync("echo $MY_VAR").stdout.trim(), "hello"); + bash.executeSync("unset MY_VAR"); + assert.equal(bash.executeSync("echo ${MY_VAR:-gone}").stdout.trim(), "gone"); + }); +}); diff --git a/crates/bashkit-js/__test__/runtime-compat/control-flow.test.mjs b/crates/bashkit-js/__test__/runtime-compat/control-flow.test.mjs new file mode 100644 index 00000000..2f6ab9b9 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/control-flow.test.mjs @@ -0,0 +1,88 @@ +// Control flow: if/elif/else, for, while, break, continue, case, functions, +// subshells, exit codes. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Bash } from "./_setup.mjs"; + +describe("control flow", () => { + it("if/elif/else", () => { + const bash = new Bash(); + const r = bash.executeSync(` + X=2 + if [ "$X" = "1" ]; then echo one + elif [ "$X" = "2" ]; then echo two + else echo other + fi + `); + assert.equal(r.stdout.trim(), "two"); + }); + + it("for loop", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync("for i in a b c; do echo $i; done").stdout.trim(), + "a\nb\nc", + ); + }); + + it("while loop", () => { + const bash = new Bash(); + const r = bash.executeSync(` + i=0 + while [ $i -lt 3 ]; do echo $i; i=$((i + 1)); done + `); + assert.equal(r.stdout.trim(), "0\n1\n2"); + }); + + it("break and continue", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync(` + for i in 1 2 3 4 5; do + if [ $i -eq 4 ]; then break; fi + if [ $i -eq 2 ]; then continue; fi + echo $i + done + `).stdout.trim(), + "1\n3", + ); + }); + + it("case statement", () => { + const bash = new Bash(); + const r = bash.executeSync(` + FILE=image.png + case "$FILE" in + *.png) echo "png";; + *.jpg) echo "jpg";; + *) echo "other";; + esac + `); + assert.equal(r.stdout.trim(), "png"); + }); + + it("functions with local vars and recursion", () => { + const bash = new Bash(); + const r = bash.executeSync(` + factorial() { + if [ $1 -le 1 ]; then echo 1; return; fi + local sub=$(factorial $(($1 - 1))) + echo $(($1 * sub)) + } + factorial 5 + `); + assert.equal(r.stdout.trim(), "120"); + }); + + it("subshell does not leak variables", () => { + const bash = new Bash(); + bash.executeSync("(X=inner)"); + assert.equal(bash.executeSync("echo ${X:-unset}").stdout.trim(), "unset"); + }); + + it("$? captures last exit code", () => { + const bash = new Bash(); + assert.equal(bash.executeSync("false; echo $?").stdout.trim(), "1"); + }); +}); diff --git a/crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs b/crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs new file mode 100644 index 00000000..928e058f --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs @@ -0,0 +1,64 @@ +// Error handling: exit codes, stderr, BashError, executeSyncOrThrow, +// recovery, syntax errors, parse errors. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Bash } from "./_setup.mjs"; + +describe("error handling", () => { + it("failed command has non-zero exit code", () => { + assert.notEqual(new Bash().executeSync("false").exitCode, 0); + }); + + it("exit with specific codes", () => { + const bash = new Bash(); + assert.equal(bash.executeSync("exit 0").exitCode, 0); + assert.equal(bash.executeSync("exit 1").exitCode, 1); + assert.equal(bash.executeSync("exit 127").exitCode, 127); + }); + + it("stderr captured separately", () => { + const bash = new Bash(); + const r = bash.executeSync("echo out; echo err >&2"); + assert.ok(r.stdout.includes("out")); + assert.ok(r.stderr.includes("err")); + }); + + it("executeSyncOrThrow succeeds on exit 0", () => { + const bash = new Bash(); + const r = bash.executeSyncOrThrow("echo ok"); + assert.equal(r.exitCode, 0); + assert.equal(r.stdout.trim(), "ok"); + }); + + it("executeSyncOrThrow throws on failure", () => { + const bash = new Bash(); + assert.throws(() => bash.executeSyncOrThrow("exit 42"), (err) => { + assert.equal(err.name, "BashError"); + assert.equal(err.exitCode, 42); + assert.equal(typeof err.message, "string"); + assert.ok(err.display().includes("BashError")); + return true; + }); + }); + + it("interpreter usable after error, state preserved", () => { + const bash = new Bash(); + bash.executeSync("X=before"); + bash.executeSync("false"); + assert.equal(bash.executeSync("echo $X").stdout.trim(), "before"); + assert.equal(bash.executeSync("echo recovered").stdout.trim(), "recovered"); + }); + + it("syntax error returns non-zero", () => { + assert.notEqual(new Bash().executeSync("if then fi").exitCode, 0); + }); + + it("pre-exec parse error surfaces in stderr", () => { + const bash = new Bash(); + const r = bash.executeSync("echo $("); + assert.notEqual(r.exitCode, 0); + assert.ok(r.error); + assert.ok(r.stderr.length > 0); + }); +}); diff --git a/crates/bashkit-js/__test__/runtime-compat/filesystem.test.mjs b/crates/bashkit-js/__test__/runtime-compat/filesystem.test.mjs new file mode 100644 index 00000000..dcb8e998 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/filesystem.test.mjs @@ -0,0 +1,86 @@ +// Filesystem operations: read, write, append, mkdir, cp, mv, rm, cd, pwd, +// pipes, redirection, command substitution, heredocs. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Bash } from "./_setup.mjs"; + +describe("filesystem", () => { + it("write, read, append", () => { + const bash = new Bash(); + bash.executeSync('echo "line1" > /tmp/f.txt'); + bash.executeSync('echo "line2" >> /tmp/f.txt'); + const r = bash.executeSync("cat /tmp/f.txt"); + assert.ok(r.stdout.includes("line1")); + assert.ok(r.stdout.includes("line2")); + }); + + it("mkdir, touch, ls", () => { + const bash = new Bash(); + bash.executeSync("mkdir -p /tmp/d/sub"); + bash.executeSync("touch /tmp/d/sub/file.txt"); + assert.ok(bash.executeSync("ls /tmp/d/sub").stdout.includes("file.txt")); + }); + + it("cp and mv", () => { + const bash = new Bash(); + bash.executeSync('echo "data" > /tmp/src.txt'); + bash.executeSync("cp /tmp/src.txt /tmp/cp.txt"); + assert.equal(bash.executeSync("cat /tmp/cp.txt").stdout.trim(), "data"); + bash.executeSync("mv /tmp/cp.txt /tmp/mv.txt"); + assert.equal(bash.executeSync("cat /tmp/mv.txt").stdout.trim(), "data"); + assert.notEqual(bash.executeSync("cat /tmp/cp.txt 2>&1").exitCode, 0); + }); + + it("rm and test flags", () => { + const bash = new Bash(); + bash.executeSync("touch /tmp/rm.txt"); + assert.equal(bash.executeSync("test -f /tmp/rm.txt && echo yes").stdout.trim(), "yes"); + bash.executeSync("rm /tmp/rm.txt"); + assert.notEqual(bash.executeSync("test -f /tmp/rm.txt").exitCode, 0); + }); + + it("cd and pwd", () => { + const bash = new Bash(); + bash.executeSync("mkdir -p /tmp/nav"); + bash.executeSync("cd /tmp/nav"); + assert.equal(bash.executeSync("pwd").stdout.trim(), "/tmp/nav"); + }); +}); + +describe("pipes and redirection", () => { + it("pipe echo to grep", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "foo\\nbar\\nbaz" | grep bar').stdout.trim(), + "bar", + ); + }); + + it("pipe chain", () => { + const bash = new Bash(); + assert.equal( + bash.executeSync('echo -e "c\\na\\nb" | sort | head -1').stdout.trim(), + "a", + ); + }); + + it("stderr redirect", () => { + const r = new Bash().executeSync("echo err >&2"); + assert.ok(r.stderr.includes("err")); + }); + + it("command substitution", () => { + assert.equal( + new Bash().executeSync('echo "result: $(echo 42)"').stdout.trim(), + "result: 42", + ); + }); + + it("heredoc", () => { + const bash = new Bash(); + bash.executeSync("NAME=alice"); + const r = bash.executeSync("cat < { + it("JSON processing pipeline", () => { + const bash = new Bash(); + const r = bash.executeSync(` + echo '[{"name":"alice","age":30},{"name":"bob","age":25}]' | \ + jq -r '.[] | select(.age > 28) | .name' + `); + assert.equal(r.stdout.trim(), "alice"); + }); + + it("data transformation pipeline", () => { + const bash = new Bash(); + bash.executeSync('echo -e "Alice,30\\nBob,25\\nCharlie,35" > /tmp/data.csv'); + assert.equal( + bash.executeSync("cat /tmp/data.csv | sort -t, -k2 -n | head -1 | cut -d, -f1").stdout.trim(), + "Bob", + ); + }); + + it("config file generation via heredoc", () => { + const bash = new Bash(); + const r = bash.executeSync(` + APP_NAME=myapp + APP_PORT=8080 + cat < { + const bash = new Bash(); + for (let i = 0; i < 50; i++) { + bash.executeSync(`echo ${i}`); + } + assert.equal(bash.executeSync("echo done").stdout.trim(), "done"); + }); + + it("large output", () => { + const bash = new Bash(); + const r = bash.executeSync("seq 1 1000"); + const lines = r.stdout.trim().split("\n"); + assert.equal(lines.length, 1000); + assert.equal(lines[0], "1"); + assert.equal(lines[999], "1000"); + }); +}); diff --git a/crates/bashkit-js/__test__/runtime-compat/security.test.mjs b/crates/bashkit-js/__test__/runtime-compat/security.test.mjs new file mode 100644 index 00000000..6ed03f99 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/security.test.mjs @@ -0,0 +1,52 @@ +// Security: resource limits, sandbox escape, VFS path traversal, recovery. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Bash } from "./_setup.mjs"; + +describe("security", () => { + it("command limit enforced", () => { + const bash = new Bash({ maxCommands: 5 }); + const r = bash.executeSync("true; true; true; true; true; true; true; true; true; true"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("loop iteration limit enforced", () => { + const bash = new Bash({ maxLoopIterations: 5 }); + const r = bash.executeSync("for i in 1 2 3 4 5 6 7 8 9 10; do echo $i; done"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("infinite while loop capped", () => { + const bash = new Bash({ maxLoopIterations: 10 }); + const r = bash.executeSync("i=0; while true; do i=$((i+1)); done"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("recursive function depth limited", () => { + const bash = new Bash({ maxCommands: 10000 }); + const r = bash.executeSync("bomb() { bomb; }; bomb"); + assert.ok(r.exitCode !== 0 || r.error !== undefined); + }); + + it("sandbox escape blocked", () => { + const bash = new Bash(); + assert.notEqual(bash.executeSync("exec /bin/bash").exitCode, 0); + assert.notEqual(bash.executeSync("cat /proc/self/maps 2>&1").exitCode, 0); + assert.notEqual(bash.executeSync("cat /etc/passwd 2>&1").exitCode, 0); + }); + + it("VFS path traversal blocked", () => { + const bash = new Bash(); + bash.executeSync('echo "secret" > /home/data.txt'); + assert.notEqual(bash.executeSync("cat /home/../../../etc/shadow 2>&1").exitCode, 0); + }); + + it("recovery after exceeding limits", () => { + const bash = new Bash({ maxCommands: 3 }); + bash.executeSync("true; true; true; true; true; true"); + const r = bash.executeSync("echo recovered"); + assert.equal(r.exitCode, 0); + assert.equal(r.stdout.trim(), "recovered"); + }); +}); diff --git a/crates/bashkit-js/__test__/runtime-compat/tool-metadata.test.mjs b/crates/bashkit-js/__test__/runtime-compat/tool-metadata.test.mjs new file mode 100644 index 00000000..02d01a64 --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/tool-metadata.test.mjs @@ -0,0 +1,75 @@ +// BashTool metadata: name, version, schemas, description, help, systemPrompt, +// stability, execution, reset, isolation. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { BashTool, getVersion } from "./_setup.mjs"; + +describe("BashTool metadata", () => { + it("name, version, shortDescription", () => { + const tool = new BashTool(); + assert.equal(tool.name, "bashkit"); + assert.match(tool.version, /^\d+\.\d+\.\d+/); + assert.equal(tool.version, getVersion()); + assert.ok(tool.shortDescription.length > 0); + }); + + it("description, help, systemPrompt", () => { + const tool = new BashTool(); + assert.ok(tool.description().length > 10); + assert.ok(tool.help().length > 10); + assert.notEqual(tool.description(), tool.help()); + assert.ok(tool.systemPrompt().toLowerCase().includes("bash")); + }); + + it("inputSchema and outputSchema are valid JSON", () => { + const tool = new BashTool(); + const input = JSON.parse(tool.inputSchema()); + const output = JSON.parse(tool.outputSchema()); + assert.equal(typeof input, "object"); + assert.equal(typeof output, "object"); + assert.ok(JSON.stringify(input).includes("command")); + }); + + it("schemas stable across calls and instances", () => { + const a = new BashTool(); + const b = new BashTool(); + assert.equal(a.inputSchema(), a.inputSchema()); + assert.equal(a.inputSchema(), b.inputSchema()); + assert.equal(a.outputSchema(), b.outputSchema()); + }); + + it("metadata unchanged after execution and reset", () => { + const tool = new BashTool(); + const nameBefore = tool.name; + const schemaBefore = tool.inputSchema(); + tool.executeSync("echo hello"); + tool.reset(); + assert.equal(tool.name, nameBefore); + assert.equal(tool.inputSchema(), schemaBefore); + }); + + it("systemPrompt reflects configured username", () => { + const tool = new BashTool({ username: "agent", hostname: "sandbox" }); + const prompt = tool.systemPrompt(); + assert.ok(prompt.includes("agent")); + assert.ok(prompt.includes("/home/agent")); + }); + + it("BashTool execution and reset", () => { + const tool = new BashTool({ username: "keep" }); + tool.executeSync("VAR=gone"); + tool.reset(); + assert.equal(tool.executeSync("echo ${VAR:-unset}").stdout.trim(), "unset"); + assert.equal(tool.executeSync("whoami").stdout.trim(), "keep"); + }); + + it("BashTool instances are isolated", () => { + const a = new BashTool(); + const b = new BashTool(); + a.executeSync("VAR=toolA"); + b.executeSync("VAR=toolB"); + assert.equal(a.executeSync("echo $VAR").stdout.trim(), "toolA"); + assert.equal(b.executeSync("echo $VAR").stdout.trim(), "toolB"); + }); +}); diff --git a/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs new file mode 100644 index 00000000..7caa7c7f --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/vfs.test.mjs @@ -0,0 +1,57 @@ +// VFS API: writeFile, readFile, mkdir, exists, remove, bash interop, reset. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { Bash } from "./_setup.mjs"; + +describe("VFS API", () => { + it("writeFile + readFile roundtrip", () => { + const bash = new Bash(); + bash.writeFile("/tmp/hello.txt", "Hello, VFS!"); + assert.equal(bash.readFile("/tmp/hello.txt"), "Hello, VFS!"); + }); + + it("writeFile overwrites", () => { + const bash = new Bash(); + bash.writeFile("/tmp/o.txt", "first"); + bash.writeFile("/tmp/o.txt", "second"); + assert.equal(bash.readFile("/tmp/o.txt"), "second"); + }); + + it("readFile throws on missing file", () => { + assert.throws(() => new Bash().readFile("/nonexistent/file.txt")); + }); + + it("mkdir, exists, remove", () => { + const bash = new Bash(); + bash.mkdir("/tmp/vdir"); + assert.ok(bash.exists("/tmp/vdir")); + bash.writeFile("/tmp/vdir/f.txt", "data"); + assert.ok(bash.exists("/tmp/vdir/f.txt")); + bash.remove("/tmp/vdir", true); + assert.ok(!bash.exists("/tmp/vdir")); + }); + + it("mkdir recursive", () => { + const bash = new Bash(); + bash.mkdir("/a/b/c/d", true); + assert.ok(bash.exists("/a/b/c/d")); + assert.ok(bash.exists("/a/b")); + }); + + it("VFS ↔ bash interop", () => { + const bash = new Bash(); + bash.writeFile("/tmp/from-vfs.txt", "vfs-content"); + assert.equal(bash.executeSync("cat /tmp/from-vfs.txt").stdout, "vfs-content"); + bash.executeSync("echo bash-content > /tmp/from-bash.txt"); + assert.equal(bash.readFile("/tmp/from-bash.txt"), "bash-content\n"); + }); + + it("reset clears VFS state", () => { + const bash = new Bash(); + bash.writeFile("/tmp/p.txt", "data"); + assert.ok(bash.exists("/tmp/p.txt")); + bash.reset(); + assert.ok(!bash.exists("/tmp/p.txt")); + }); +}); diff --git a/specs/004-testing.md b/specs/004-testing.md index 3cacdd28..704f6e60 100644 --- a/specs/004-testing.md +++ b/specs/004-testing.md @@ -301,6 +301,74 @@ Some features are intentionally excluded from fuzzing: - `wc` - Output formatting differs (column alignment) - Filesystem operations - Bashkit uses virtual filesystem +## JavaScript Runtime Compatibility Tests + +### Motivation + +The NAPI-RS JS bindings must work across Node.js, Bun, and Deno. The primary +test suite uses ava (a Node-specific test runner), so it can only validate Node. +To prove the bindings work under other runtimes, we maintain a separate +**runtime-compat** test suite using only `node:test` and `node:assert` — APIs +supported natively by all three runtimes. + +### Architecture + +``` +crates/bashkit-js/__test__/ +├── *.spec.ts # ava tests (Node only, TypeScript) +└── runtime-compat/ + ├── _setup.mjs # Shared: loads native NAPI binding + ├── basics.test.mjs # Constructors, execution, variables, reset, isolation + ├── builtins.test.mjs # grep, sed, awk, sort, uniq, tr, cut, jq, etc. + ├── control-flow.test.mjs # if/elif, for, while, case, functions, subshells + ├── error-handling.test.mjs # Exit codes, BashError, recovery, parse errors + ├── filesystem.test.mjs # File I/O, pipes, redirection, heredocs + ├── vfs.test.mjs # VFS API (writeFile, readFile, mkdir, exists, remove) + ├── tool-metadata.test.mjs # BashTool name, version, schemas, systemPrompt + ├── security.test.mjs # Resource limits, sandbox escape, path traversal + └── scripts.test.mjs # Real-world patterns: JSON pipelines, large output +``` + +### CI Matrix + +All runtimes build with npm (napi-rs requires Node tooling). Test execution: + +| Runtime | Versions | ava tests | runtime-compat | Examples | +|---------|----------|-----------|----------------|----------| +| Node | 20, 22, 24, latest | Yes | Yes | Yes | +| Bun | latest, canary | No | Yes | Yes | +| Deno | 2.x, canary | No | Yes | Yes | + +- **Node** runs both ava (full functional suite) and runtime-compat (via `node --test`) +- **Bun/Deno** run runtime-compat files directly with their native runtimes +- All runtimes run the example `.mjs` files + +### Maintenance Rules + +1. **When adding a new ava test**: consider if it covers a new API surface or + behavior that should also be validated across runtimes. If so, add a + corresponding test to the appropriate `runtime-compat/*.test.mjs` file. +2. **runtime-compat tests use only** `node:test`, `node:assert`, and + `node:module` — no npm dependencies. This ensures they run under all runtimes. +3. **Files are plain `.mjs`** (not TypeScript) to avoid transpilation steps. +4. **Shared setup** lives in `_setup.mjs` — it loads the native binding via + `createRequire` which works in Node, Bun, and Deno. +5. **Keep files focused** — one file per concern area, mirroring the ava test + structure. Each file should be independently runnable. + +### Running Locally + +```bash +# Node (native test runner) +node --test crates/bashkit-js/__test__/runtime-compat/*.test.mjs + +# Bun +for f in crates/bashkit-js/__test__/runtime-compat/*.test.mjs; do bun "$f"; done + +# Deno +for f in crates/bashkit-js/__test__/runtime-compat/*.test.mjs; do deno run -A "$f"; done +``` + ## Alternatives Considered ### Bash test suite From 0eb237f6dbe1a295d6074f640d5eac9cfd8f3fab Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 28 Mar 2026 01:30:50 +0000 Subject: [PATCH 7/8] fix(ci): fix runtime-compat test loading and Bun invocation - Load wrapper.js instead of index.cjs in _setup.mjs so executeSyncOrThrow and BashError are available - Use 'bun test' instead of 'bun ' for Bun (required for node:test describe/it APIs) - Split export/unset into own test case for isolation --- .github/workflows/js.yml | 11 ++++++++--- crates/bashkit-js/__test__/runtime-compat/_setup.mjs | 10 +++------- .../__test__/runtime-compat/builtins.test.mjs | 6 +++++- .../__test__/runtime-compat/error-handling.test.mjs | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 9f19445c..90d135cd 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -120,12 +120,17 @@ jobs: run: node --test __test__/runtime-compat/*.test.mjs working-directory: crates/bashkit-js - - name: Run runtime-compat tests (Bun/Deno) - if: matrix.runtime != 'node' + - 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 ---" - ${{ matrix.run }} "$f" + deno run -A "$f" done working-directory: crates/bashkit-js diff --git a/crates/bashkit-js/__test__/runtime-compat/_setup.mjs b/crates/bashkit-js/__test__/runtime-compat/_setup.mjs index 8dbdbff7..4d1d92cb 100644 --- a/crates/bashkit-js/__test__/runtime-compat/_setup.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/_setup.mjs @@ -1,9 +1,5 @@ // Shared setup for runtime-compat tests. -// Loads the native NAPI binding via createRequire (works in Node, Bun, Deno). +// Loads the wrapper module (which re-exports native NAPI binding with +// executeSyncOrThrow, BashError, etc.) — works in Node, Bun, Deno. -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const native = require("../../index.cjs"); - -export const { Bash, BashTool, getVersion } = native; +export { Bash, BashTool, BashError, getVersion } from "../../wrapper.js"; diff --git a/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs b/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs index 9aa38f41..2c6c3067 100644 --- a/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs @@ -107,11 +107,15 @@ describe("builtins", () => { ); }); - it("seq, printf, date, export/unset", () => { + it("seq, printf, date", () => { const bash = new Bash(); assert.equal(bash.executeSync("seq 1 5").stdout.trim(), "1\n2\n3\n4\n5"); assert.equal(bash.executeSync('printf "Hello %s" "World"').stdout, "Hello World"); assert.equal(bash.executeSync("date").exitCode, 0); + }); + + it("export and unset", () => { + const bash = new Bash(); bash.executeSync("export MY_VAR=hello"); assert.equal(bash.executeSync("echo $MY_VAR").stdout.trim(), "hello"); bash.executeSync("unset MY_VAR"); diff --git a/crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs b/crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs index 928e058f..6afdc5d1 100644 --- a/crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/error-handling.test.mjs @@ -3,7 +3,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { Bash } from "./_setup.mjs"; +import { Bash, BashError } from "./_setup.mjs"; describe("error handling", () => { it("failed command has non-zero exit code", () => { From 672df3a4f0582143a8089bb0685b82c573f815cb Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 28 Mar 2026 01:33:46 +0000 Subject: [PATCH 8/8] fix(test): match ava test pattern for unset (plain var, not export) Unset of exported variables may behave differently. Match the ava test's pattern: assign with X=123, then unset X. --- .../__test__/runtime-compat/builtins.test.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs b/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs index 2c6c3067..8efacbaf 100644 --- a/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs @@ -114,11 +114,16 @@ describe("builtins", () => { assert.equal(bash.executeSync("date").exitCode, 0); }); - it("export and unset", () => { + it("export makes variable accessible", () => { const bash = new Bash(); bash.executeSync("export MY_VAR=hello"); assert.equal(bash.executeSync("echo $MY_VAR").stdout.trim(), "hello"); - bash.executeSync("unset MY_VAR"); - assert.equal(bash.executeSync("echo ${MY_VAR:-gone}").stdout.trim(), "gone"); + }); + + it("unset removes variable", () => { + const bash = new Bash(); + bash.executeSync("X=123"); + bash.executeSync("unset X"); + assert.equal(bash.executeSync("echo ${X:-gone}").stdout.trim(), "gone"); }); });