import { readdirSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { createNuBashOperations, executeNu, nuCompletions, parseCompletionsOutput, wrapNuAutocompleteProvider, } from "../index.js"; let dir = ""; const supportsControlCharPaths = process.platform !== "win32"; const directoryCompletion = process.platform === "win32" ? "`my dir`" : "`my dir/`"; async function seedFiles(): Promise { await writeFile( join(dir, "people.json"), JSON.stringify([ { name: "Ada", role: "compiler", score: 99 }, { name: "Grace", role: "systems", score: 95 }, { name: "Linus", role: "kernel", score: 88 }, ]), "utf8", ); await writeFile( join(dir, "logs.csv"), "service,level,count\napi,info,3\napi,error,1\nweb,info,5\n", "utf8", ); await writeFile( join(dir, "config.nuon"), "{host: localhost, ports: [8080, 8081], enabled: true}", "utf8", ); await writeFile( join(dir, "alpha.nu"), "export def greet [name: string] { $'hello ($name)' }", "utf8", ); await writeFile(join(dir, "beta.txt"), "hello\nworld\n", "utf8"); await writeFile(join(dir, "my file.txt"), "space path\n", "utf8"); await writeFile(join(dir, "hash#file.txt"), "hash path\n", "utf8"); await writeFile(join(dir, "semi;file.txt"), "semi path\n", "utf8"); await writeFile(join(dir, "paren(file).txt"), "paren path\n", "utf8"); await writeFile(join(dir, "brack[file].txt"), "brack path\n", "utf8"); await mkdir(join(dir, "my dir")); await mkdir(join(dir, "src")); await writeFile(join(dir, "script.nu"), "let x = 1\n$x + 1\n", "utf8"); } function tmpBangDirs(): string[] { return readdirSync(tmpdir()).filter( (name) => name.startsWith("pi-nu-bang-") && !name.startsWith("pi-nu-bang-test-") && !name.startsWith("pi-nu-bang-output-"), ); } function run(command: string): ReturnType { return executeNu(command, dir); } // One shared seeded cwd avoids fixture churn; tests stay sequential so Nushell // subprocess timings do not balloon under full-suite process contention. beforeAll(async () => { dir = await mkdtemp(join(tmpdir(), "pi-nu-bang-test-")); await seedFiles(); }); afterAll(async () => { await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 50, }); }); describe.sequential("nu-bang real-world execution", () => { test("covers common Nushell data pipelines in one process", async () => { const result = await run(String.raw` [ {name: "ls", value: (ls | sort-by name | first | get name)} {name: "json", value: (open people.json | where score > 90 | get name | str join ",")} {name: "csv", value: (open logs.csv | group-by service | transpose service rows | insert total {|r| $r.rows.count | math sum } | get total | math sum | into string)} {name: "nuon", value: (open config.nuon | get ports | math max | into string)} {name: "path-parse", value: ("themes/nu-themes/tokyo-night.nu" | path parse | get stem)} {name: "path-exists", value: (["alpha.nu" "missing.nu" "beta.txt"] | where ($it | str ends-with ".nu") and ($it | path exists) | str join ",")} {name: "update", value: (open people.json | update name { str uppercase } | first | get name)} {name: "insert", value: (open people.json | insert senior {|r| $r.score >= 95 } | where senior == true | get name | str join ":")} {name: "reduce", value: (open people.json | get score | reduce {|n acc| $acc + $n } | into string)} {name: "each", value: ([1 2 3] | each {|n| {n: $n, double: ($n * 2)} } | where double == 6 | length | into string)} {name: "par-each", value: ([1 2 3 4] | par-each {|n| $n * $n } | sort | to json --raw)} {name: "match", value: (match "prod" { "dev" => "loose", "prod" => "strict", _ => "unknown" })} {name: "def", value: (do { def slug [s: string] { $s | str lowercase | str replace --all " " "-" }; slug "Hello Nu World" })} {name: "module", value: (do { use ./alpha.nu greet; greet Ada })} {name: "from-json", value: ('{"items":[{"id":1},{"id":2}]}' | from json | get items.id | math sum | into string)} {name: "markdown", value: (open people.json | select name score | first 2 | to md | str contains "| Ada | 99 |" | into string)} ] | to json --raw `); expect(result.exitCode).toBe(0); const cases = new Map( (JSON.parse(result.output) as Array<{ name: string; value: string }>).map( (item) => [item.name, item.value], ), ); expect(Object.fromEntries(cases)).toMatchObject({ ls: "alpha.nu", json: "Ada,Grace", csv: "9", nuon: "8081", "path-parse": "tokyo-night", "path-exists": "alpha.nu", update: "ADA", insert: "Ada:Grace", reduce: "282", each: "1", "par-each": "[1,4,9,16]", match: "strict", def: "hello-nu-world", module: "hello Ada", "from-json": "3", markdown: "true", }); }); test("uses complete to preserve stderr and exit code", async () => { const result = await run( '^bash -lc "echo out; echo err >&2; exit 7" | complete', ); expect(result.exitCode).toBe(0); expect(result.output).toContain("stdout"); expect(result.output).toContain("stderr"); expect(result.output).toContain("exit_code"); expect(result.output).toContain("7"); }); test("propagates external exit code without complete", async () => { const result = await run('^bash -lc "echo out; echo err >&2; exit 7"'); expect(result.exitCode).toBe(7); expect(result.output).toContain("out"); expect(result.output).toContain("err"); }); test("preserves interleaved stdout and stderr", async () => { // Short sleeps force ordering without making subprocess timing dominate. const result = await run( '^bash -lc "printf out1; sleep 0.01; printf err1 >&2; sleep 0.01; printf out2; sleep 0.01; printf err2 >&2"', ); expect(result.exitCode).toBe(0); expect(result.output).toBe("out1err1out2err2"); }); test("cancels long-running external commands", async () => { const controller = new AbortController(); const started = Date.now(); setTimeout(() => controller.abort(), 50); const result = await executeNu( '^bash -lc "sleep 5"', dir, controller.signal, ); expect(Date.now() - started).toBeLessThan(2000); expect(result.cancelled).toBe(true); }); test("streams output through bash operations before command exit", async () => { const controller = new AbortController(); const operations = createNuBashOperations( '^bash -lc "echo first; sleep 30; echo second"', ); let done = false; let seen = ""; let resolveFirst: () => void = () => {}; let timeoutTimer: ReturnType | undefined; const firstChunk = new Promise((resolve) => { resolveFirst = resolve; }); const timeout = new Promise((_, reject) => { timeoutTimer = setTimeout( () => reject(new Error("timed out waiting for streamed output")), 10_000, ); }); const exec = operations .exec("ignored", dir, { onData(chunk) { seen += chunk.toString("utf8"); if (seen.includes("first")) resolveFirst(); }, signal: controller.signal, }) .finally(() => { done = true; }); try { await Promise.race([firstChunk, timeout]); } finally { if (timeoutTimer) clearTimeout(timeoutTimer); } expect(seen).toContain("first"); expect(done).toBe(false); controller.abort(); await exec; }); test("does not wait for background children that inherit stdio", async () => { const started = Date.now(); const result = await run('^bash -lc "sleep 4 & echo done"'); expect(Date.now() - started).toBeLessThan(3500); expect(result.exitCode).toBe(0); expect(result.output).toBe("done\n"); }); test("supports save side effects in cwd", async () => { const result = await run('"created" | save generated.txt --force'); expect(result.exitCode).toBe(0); expect(await readFile(join(dir, "generated.txt"), "utf8")).toBe("created"); }); test("supports globbing", async () => { const result = await run('ls **/*.nu | get name | sort | str join ","'); expect(result.output).toContain("alpha.nu"); expect(result.output).toContain("script.nu"); }); test("supports try/catch for recoverable errors", async () => { const result = await run( 'try { open missing.json } catch {|err| $"caught: ($err.msg | str substring 0..10)" }', ); expect(result.exitCode).toBe(0); expect(result.output).toContain("caught:"); }); test("supports metadata command", async () => { const result = await run( 'open people.json | first 1 | metadata | columns | sort | str join ","', ); expect(result.exitCode).toBe(0); expect(result.output.length).toBeGreaterThan(0); }); test("nu emits plain errors when spawned with pipes", async () => { const result = await run("open missing.json"); expect(result.exitCode).not.toBe(0); expect(result.output).toContain("Error:"); expect(result.output).not.toContain("\u001b["); }); test("strips terminal control sequences from returned output", async () => { const result = await run( String.raw`^bash -lc "printf $'plain\\033[31mred\\033[0m\\nosc\\033]0;title\\007done\\nctrl\\001x\\n'"`, ); expect(result.exitCode).toBe(0); expect(result.output).toContain("plainred"); expect(result.output).toContain("oscdone"); expect(result.output).toContain("ctrlx"); expect(result.output).not.toContain("\u001b"); expect(result.output).not.toContain("\u0007"); expect(result.output).not.toContain("\u0001"); expect(result.output).not.toContain("]0;title"); }); test("strips carriage returns and C1 terminal controls from returned output", async () => { const result = await run( String.raw`^bash -lc 'printf "cr\rkeep\nc1\302\23331mred\302\2330m\n"'`, ); expect(result.exitCode).toBe(0); expect(result.output).toContain("crkeep"); expect(result.output).toContain("c1red"); expect(result.output).not.toContain("\r"); expect(result.output).not.toContain("\u009b"); expect(result.output).not.toContain("31m"); expect(result.output).not.toContain("0m"); }); test("strips unterminated terminal string controls from returned output", async () => { const result = await run( String.raw`^bash -lc 'printf "ok\n\033]0;title\nvisible\n\033Psecret\nnext\n\302\235c1title\nlast\n"'`, ); expect(result.exitCode).toBe(0); expect(result.output).toContain("ok\n"); expect(result.output).toContain("visible\n"); expect(result.output).toContain("next\n"); expect(result.output).toContain("last\n"); expect(result.output).not.toContain("title"); expect(result.output).not.toContain("secret"); expect(result.output).not.toContain("\u001b"); expect(result.output).not.toContain("\u009d"); }); test("strips terminated DCS controls without dropping following text", async () => { const result = await run( String.raw`^python3 -c 'import sys; sys.stdout.buffer.write(b"before\x1bPsecret\x1b\\after\n")'`, ); expect(result.exitCode).toBe(0); expect(result.output).toBe("beforeafter\n"); expect(result.output).not.toContain("secret"); expect(result.output).not.toContain("\u001b"); }); test("shows nu's native parser diagnostics", async () => { const result = await run("ls | where"); expect(result.exitCode).toBe(1); expect(result.output).toContain("Missing required positional argument"); expect(result.output).toContain("source:1:"); }); test("shows nu's native delimiter diagnostics", async () => { const result = await run("[1 2 3"); expect(result.exitCode).toBe(1); expect(result.output).toMatch(/nu::parser::[a-z_]+/); expect(result.output).toMatch(/expected .*\]/); }); test("reports a missing working directory clearly", async () => { const missing = join(dir, "missing-cwd"); const result = await executeNu("pwd", missing); expect(result.exitCode).toBe(1); expect(result.output).toContain("Working directory does not exist"); expect(result.output).toContain(missing); expect(result.output).not.toContain("posix_spawn"); expect(result.output).not.toContain("spawn nu"); }); test("escapes controls in execute working directory errors", async () => { const unsafeMissing = join(dir, "missing-\u001b[31mred"); const missingResult = await executeNu("pwd", unsafeMissing); expect(missingResult.output).toContain("Working directory does not exist"); expect(missingResult.output).toContain("missing-\\u001b[31mred"); expect(missingResult.output).not.toContain("\u001b"); if (supportsControlCharPaths) { const unsafeFile = join(dir, "file-\u001b[32mgreen"); await writeFile(unsafeFile, "not a directory", "utf8"); const fileResult = await executeNu("pwd", unsafeFile); expect(fileResult.output).toContain( "Working directory is not a directory", ); expect(fileResult.output).toContain("file-\\u001b[32mgreen"); expect(fileResult.output).not.toContain("\u001b"); } }); test.sequential("does not leave pi-nu-bang temp dirs after execution", async () => { const before = tmpBangDirs(); await run("ls | first 1"); const after = tmpBangDirs(); expect(after.sort()).toEqual(before.sort()); }); test("truncates huge output and exposes readable full output", async () => { const result = await run( "1..2500 | each {|n| $'line-($n)' } | str join (char nl)", ); expect(result.truncated).toBe(true); expect(result.output).toContain("truncated"); expect(result.output).toContain("line-2500"); expect(typeof result.fullOutputPath).toBe("string"); if (!result.fullOutputPath) throw new Error("expected full output path"); try { const fullOutputStat = await stat(result.fullOutputPath); if (process.platform !== "win32") expect(fullOutputStat.mode & 0o777).toBe(0o600); const fullOutput = await readFile(result.fullOutputPath, "utf8"); expect(fullOutput).toContain("line-1"); expect(fullOutput).toContain("line-2500"); expect(fullOutput.length).toBeGreaterThan(result.output.length); } finally { await rm(result.fullOutputPath, { force: true }); } }); }); describe.sequential("nu-bang real-world completions", () => { test("completes pipeline commands", async () => { const completions = await nuCompletions("ls | wh", 7, dir); expect(completions).toContain("where"); expect(completions).toContain("which"); }); test("completes paths for open", async () => { const completions = await nuCompletions("open ", 5, dir); expect(completions).toContain("people.json"); expect(completions).toContain("logs.csv"); }); test("escapes terminal controls in parsed completion output", () => { expect( parseCompletionsOutput( JSON.stringify({ completions: [ "unsafe-\u001b[31mred\u0007.txt", "c1-\u009b31mred.txt", ], }), ), ).toEqual(["unsafe-\\u001b[31mred\\u0007.txt", "c1-\\u009b31mred.txt"]); }); test.skipIf(!supportsControlCharPaths)( "does not expose terminal controls in path completions", async () => { await writeFile( join(dir, "unsafe-\u001b[31mred\u0007.txt"), "unsafe", "utf8", ); const completions = await nuCompletions("open unsafe-", 12, dir); const unsafeCompletion = completions.find((completion) => completion.includes("unsafe-"), ); expect(typeof unsafeCompletion).toBe("string"); expect(unsafeCompletion).toContain("unsafe-\\u001b[31mred\\u0007.txt"); expect(unsafeCompletion).not.toContain("\u001b"); expect(unsafeCompletion).not.toContain("\u0007"); }, ); test("filters escaped-space path suggestions to the typed prefix", async () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion() { throw new Error("not used"); }, }, dir, ); const [broadSuggestions, suggestions, directorySuggestions] = await Promise.all([ provider.getSuggestions(["!open my\\ "], 0, 10, { signal: new AbortController().signal, force: true, }), provider.getSuggestions(["!open my\\ f"], 0, 11, { signal: new AbortController().signal, force: true, }), provider.getSuggestions(["!open my\\ d"], 0, 11, { signal: new AbortController().signal, force: true, }), ]); expect(broadSuggestions?.prefix).toBe("my\\ "); expect(broadSuggestions?.items.map((item) => item.value)).toEqual([ directoryCompletion, "`my file.txt`", ]); expect(suggestions?.prefix).toBe("my\\ f"); expect(suggestions?.items.map((item) => item.value)).toEqual([ "`my file.txt`", ]); expect(directorySuggestions?.prefix).toBe("my\\ d"); expect(directorySuggestions?.items.map((item) => item.value)).toEqual([ directoryCompletion, ]); }); test("completes escaped metacharacter paths instead of command suggestions", async () => { const provider = wrapNuAutocompleteProvider( { async getSuggestions() { return null; }, applyCompletion() { throw new Error("not used"); }, }, dir, ); const cases = [ ["!open hash\\#", "hash\\#", "`hash#file.txt`"], ["!open semi\\;", "semi\\;", "`semi;file.txt`"], ["!open paren\\(", "paren\\(", "`paren(file).txt`"], ["!open brack\\[", "brack\\[", "'brack[file].txt'"], ] as const; const results = await Promise.all( cases.map(async ([line]) => provider.getSuggestions([line], 0, line.length, { signal: new AbortController().signal, force: true, }), ), ); for (const [index, [, prefix, value]] of cases.entries()) { const suggestions = results[index]; expect(suggestions?.prefix).toBe(prefix); expect(suggestions?.items.map((item) => item.value)).toEqual([value]); } }); test("completes env record cells", async () => { const completions = await nuCompletions("$env.", 5, dir); expect(completions).toContain("PATH"); }); test("completes flags", async () => { const completions = await nuCompletions("ls --", 5, dir); expect( completions.some((c) => c.includes("help") || c.includes("long")), ).toBe(true); }); test("reports missing completion working directory clearly", async () => { const missing = join(dir, "missing-completion-cwd"); await expect(nuCompletions("open ", 5, missing)).rejects.toThrow( `Working directory does not exist: ${missing}`, ); }); test("reports file completion working directory clearly", async () => { const fileCwd = join(dir, "not-a-directory.txt"); await writeFile(fileCwd, "not a directory", "utf8"); await expect(nuCompletions("open ", 5, fileCwd)).rejects.toThrow( `Working directory is not a directory: ${fileCwd}`, ); }); test("escapes controls in completion working directory errors", async () => { const unsafeMissing = join(dir, "missing-\u001b]0;title\u0007-cwd"); try { await nuCompletions("open ", 5, unsafeMissing); throw new Error("expected nuCompletions to reject"); } catch (error) { const message = error instanceof Error ? error.message : String(error); expect(message).toContain("Working directory does not exist"); expect(message).toContain("missing-\\u001b]0;title\\u0007-cwd"); expect(message).not.toContain("\u001b"); expect(message).not.toContain("\u0007"); } if (!supportsControlCharPaths) return; const unsafeFile = join(dir, "file-\u001b[33myellow"); await writeFile(unsafeFile, "not a directory", "utf8"); try { await nuCompletions("open ", 5, unsafeFile); throw new Error("expected nuCompletions to reject"); } catch (error) { const message = error instanceof Error ? error.message : String(error); expect(message).toContain("Working directory is not a directory"); expect(message).toContain("file-\\u001b[33myellow"); expect(message).not.toContain("\u001b"); } const unsafeParent = join(dir, "parent-\u001b[36mcyan"); const unsafeChild = join(unsafeParent, "child-\u001b]0;title\u0007-cwd"); await writeFile(unsafeParent, "not a directory", "utf8"); try { await nuCompletions("open ", 5, unsafeChild); throw new Error("expected nuCompletions to reject"); } catch (error) { const message = error instanceof Error ? error.message : String(error); expect(message).toContain("Working directory cannot be checked"); expect(message).toContain("parent-\\u001b[36mcyan"); expect(message).toContain("child-\\u001b]0;title\\u0007-cwd"); expect(message).not.toContain("\u001b"); expect(message).not.toContain("\u0007"); } }); test.skipIf(process.platform === "win32")( "reports inaccessible working directories clearly", async () => { const unsafeDir = join(dir, "deny-\u001b[35msecret"); await mkdir(unsafeDir); await chmod(unsafeDir, 0o000); try { const result = await executeNu("pwd", unsafeDir); expect(result.output).toContain("Working directory is not accessible"); expect(result.output).toContain("deny-\\u001b[35msecret"); expect(result.output).not.toContain("posix_spawn"); expect(result.output).not.toContain("spawn nu"); expect(result.output).not.toContain("\u001b"); try { await nuCompletions("open ", 5, unsafeDir); throw new Error("expected nuCompletions to reject"); } catch (error) { const message = error instanceof Error ? error.message : String(error); expect(message).toContain("Working directory is not accessible"); expect(message).toContain("deny-\\u001b[35msecret"); expect(message).not.toContain("posix_spawn"); expect(message).not.toContain("spawn nu"); expect(message).not.toContain("\u001b"); } } finally { await chmod(unsafeDir, 0o700).catch(() => {}); } }, ); test.sequential("completion temp dirs are cleaned", async () => { const before = tmpBangDirs(); await nuCompletions("open ", 5, dir); const after = tmpBangDirs(); expect(after.sort()).toEqual(before.sort()); }); });