diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index aa369e63..90d135cd 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -35,13 +35,40 @@ env: jobs: build-and-test: - name: Node ${{ matrix.node }} + name: ${{ matrix.runtime }} ${{ matrix.version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: - node: ["20", "22", "24", "latest"] + include: + # Node.js versions + - runtime: node + version: "20" + run: "node" + - runtime: node + version: "22" + run: "node" + - runtime: node + version: "24" + run: "node" + - runtime: node + version: "latest" + run: "node" + # Bun + - runtime: bun + version: "latest" + run: "bun" + - runtime: bun + version: "canary" + run: "bun" + # Deno + - runtime: deno + version: "2.x" + run: "deno run -A" + - runtime: deno + version: "canary" + run: "deno run -A" steps: - uses: actions/checkout@v6 @@ -52,9 +79,28 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Setup Node.js + if: matrix.runtime == 'node' uses: actions/setup-node@v6 with: - node-version: ${{ matrix.node }} + node-version: ${{ matrix.version }} + + - name: Setup Node.js (for napi build) + if: matrix.runtime != 'node' + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Setup Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ matrix.version }} + + - name: Setup Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@v2 + with: + deno-version: ${{ matrix.version }} - name: Install dependencies run: npm install @@ -64,10 +110,30 @@ jobs: run: npm run build working-directory: crates/bashkit-js - - name: Run tests + - name: Run ava tests (Node only) + if: matrix.runtime == 'node' run: npm test working-directory: crates/bashkit-js + - name: Run runtime-compat tests (Node) + if: matrix.runtime == 'node' + run: node --test __test__/runtime-compat/*.test.mjs + working-directory: crates/bashkit-js + + - name: Run runtime-compat tests (Bun) + if: matrix.runtime == 'bun' + run: bun test __test__/runtime-compat/ + working-directory: crates/bashkit-js + + - name: Run runtime-compat tests (Deno) + if: matrix.runtime == 'deno' + run: | + for f in __test__/runtime-compat/*.test.mjs; do + echo "--- $f ---" + deno run -A "$f" + done + working-directory: crates/bashkit-js + - name: Install example dependencies and link local build working-directory: examples run: | @@ -79,10 +145,10 @@ jobs: - name: Run examples (self-contained) working-directory: examples run: | - node bash_basics.mjs - node data_pipeline.mjs - node llm_tool.mjs - node langchain_integration.mjs + ${{ matrix.run }} bash_basics.mjs + ${{ matrix.run }} data_pipeline.mjs + ${{ matrix.run }} llm_tool.mjs + ${{ matrix.run }} langchain_integration.mjs - name: Install Doppler CLI if: env.DOPPLER_TOKEN != '' @@ -92,9 +158,9 @@ jobs: if: env.DOPPLER_TOKEN != '' working-directory: examples run: | - doppler run -- node openai_tool.mjs - doppler run -- node vercel_ai_tool.mjs - doppler run -- node langchain_agent.mjs + doppler run -- ${{ matrix.run }} openai_tool.mjs + doppler run -- ${{ matrix.run }} vercel_ai_tool.mjs + doppler run -- ${{ matrix.run }} langchain_agent.mjs # Gate job for branch protection js-check: 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..4d1d92cb --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/_setup.mjs @@ -0,0 +1,5 @@ +// Shared setup for runtime-compat tests. +// Loads the wrapper module (which re-exports native NAPI binding with +// executeSyncOrThrow, BashError, etc.) — works in Node, Bun, Deno. + +export { Bash, BashTool, BashError, getVersion } from "../../wrapper.js"; 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..8efacbaf --- /dev/null +++ b/crates/bashkit-js/__test__/runtime-compat/builtins.test.mjs @@ -0,0 +1,129 @@ +// 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", () => { + 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 makes variable accessible", () => { + const bash = new Bash(); + bash.executeSync("export MY_VAR=hello"); + assert.equal(bash.executeSync("echo $MY_VAR").stdout.trim(), "hello"); + }); + + 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"); + }); +}); 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..6afdc5d1 --- /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, BashError } 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