From 6c62f08a8148d77aa9ca055995104ec79258c2d2 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 12:43:10 +0330 Subject: [PATCH 01/13] tests --- .github/workflows/shell-argv-contract.yml | 87 ++++++ tests/shell-argv-contract.test.ts | 315 ++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 .github/workflows/shell-argv-contract.yml create mode 100644 tests/shell-argv-contract.test.ts diff --git a/.github/workflows/shell-argv-contract.yml b/.github/workflows/shell-argv-contract.yml new file mode 100644 index 0000000..6403621 --- /dev/null +++ b/.github/workflows/shell-argv-contract.yml @@ -0,0 +1,87 @@ +name: Shell argv contract + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +jobs: + unix-shells: + name: Bash / Zsh / Fish on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4.0.0 + + - name: Set node version + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install shell deps on Linux + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y zsh fish + + - name: Install shell deps on macOS + if: runner.os == 'macOS' + run: | + brew install fish + + - name: Verify Unix shells are available + run: | + bash --version + zsh --version + fish --version + + - name: Install deps + run: pnpm install + + - name: Run shell argv contract tests + run: pnpm test tests/shell-argv-contract.test.ts + + powershell: + name: PowerShell on Windows + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4.0.0 + + - name: Set node version + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Verify PowerShell is available + shell: pwsh + run: | + $PSVersionTable.PSVersion + Get-Command pwsh + + - name: Install deps + run: pnpm install + + - name: Run shell argv contract tests + run: pnpm test tests/shell-argv-contract.test.ts \ No newline at end of file diff --git a/tests/shell-argv-contract.test.ts b/tests/shell-argv-contract.test.ts new file mode 100644 index 0000000..bdf0c9f --- /dev/null +++ b/tests/shell-argv-contract.test.ts @@ -0,0 +1,315 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; + +import { generate as generateBash } from "../src/bash"; +import { generate as generateFish } from "../src/fish"; +import { generate as generatePowerShell } from "../src/powershell"; +import { generate as generateZsh } from "../src/zsh"; + +type ShellName = "bash" | "zsh" | "fish" | "powershell"; + +interface ContractCase { + name: string; + commandLine: string; + words: string[]; + current?: number; + expected: string[]; +} + +const cases: ContractCase[] = [ + { + name: "root empty word", + commandLine: "demo ", + words: ["demo", ""], + current: 2, + expected: [""], + }, + { + name: "root prefix", + commandLine: "demo d", + words: ["demo", "d"], + current: 2, + expected: ["d"], + }, + { + name: "after subcommand space", + commandLine: "demo dev ", + words: ["demo", "dev", ""], + current: 3, + expected: ["dev", ""], + }, + { + name: "flag value empty word", + commandLine: "demo dev --mode ", + words: ["demo", "dev", "--mode", ""], + current: 4, + expected: ["dev", "--mode", ""], + }, + { + name: "flag value prefix", + commandLine: "demo dev --mode p", + words: ["demo", "dev", "--mode", "p"], + current: 4, + expected: ["dev", "--mode", "p"], + }, +]; + +let tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + + tempDirs = []; +}); + +function createFixture(shell: ShellName) { + const dir = mkdtempSync(join(tmpdir(), `tab-${shell}-argv-`)); + tempDirs.push(dir); + + const argLogPath = join(dir, "args.log"); + const spyPath = join(dir, "spy.mjs"); + const scriptPath = join(dir, `demo.${shell}`); + + writeFileSync( + spyPath, + ` +import { appendFileSync } from "node:fs"; + +appendFileSync( + process.env.TAB_ARG_LOG, + JSON.stringify(process.argv.slice(2)) + "\\n", +); + +// No completions; only the directive. +// We only care about what argv reached this process. +process.stdout.write(":4\\n"); +`, + ); + + const exec = `${process.execPath} ${spyPath}`; + + const generated = + shell === "bash" + ? generateBash("demo", exec) + : shell === "zsh" + ? generateZsh("demo", exec) + : shell === "fish" + ? generateFish("demo", exec) + : generatePowerShell("demo", exec); + + writeFileSync(scriptPath, generated); + + return { + dir, + argLogPath, + spyPath, + scriptPath, + }; +} + +function hasCommand(command: string, args: string[] = ["--version"]): boolean { + const result = spawnSync(command, args, { + stdio: "ignore", + }); + + return result.status === 0; +} + +function shQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function psQuote(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function readPayloads(argLogPath: string): string[][] { + const raw = readFileSync(argLogPath, "utf8").trim(); + + if (!raw) { + return []; + } + + return raw + .split("\n") + .filter(Boolean) + .map((line) => { + const argv = JSON.parse(line) as string[]; + const separatorIndex = argv.indexOf("--"); + + if (separatorIndex === -1) { + return argv; + } + + return argv.slice(separatorIndex + 1); + }); +} + +function expectLastPayload(argLogPath: string, expected: string[]) { + const payloads = readPayloads(argLogPath); + expect(payloads.length).toBeGreaterThan(0); + expect(payloads[payloads.length - 1]).toEqual(expected); +} + +describe("generated shell argv contract", () => { + describe.skipIf(!hasCommand("zsh"))("zsh", () => { + for (const item of cases) { + it(item.name, () => { + const fixture = createFixture("zsh"); + const zshWords = item.words.map(shQuote).join(" "); + + const runner = ` +source ${shQuote(fixture.scriptPath)} + +# Avoid needing real zsh completion UI functions. +compadd() { :; } +_describe() { return 1; } +_arguments() { return 0; } + +export TAB_ARG_LOG=${shQuote(fixture.argLogPath)} + +words=(${zshWords}) +CURRENT=${item.current ?? item.words.length} + +_demo >/dev/null 2>&1 || true +`; + + const result = spawnSync("zsh", ["-fc", runner], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expectLastPayload(fixture.argLogPath, item.expected); + }); + } + }); + + describe.skipIf(!hasCommand("bash"))("bash", () => { + for (const item of cases) { + it(item.name, () => { + const fixture = createFixture("bash"); + const bashWords = item.words.map(shQuote).join(" "); + const cur = item.words[item.words.length - 1] ?? ""; + const prev = item.words[item.words.length - 2] ?? ""; + + const runner = ` +source ${shQuote(fixture.scriptPath)} + +# Avoid requiring bash-completion to be installed. +_get_comp_words_by_ref() { + while [[ $# -gt 0 && "$1" == -* ]]; do + if [[ "$1" == "-n" ]]; then + shift 2 + else + shift + fi + done + + local cur_var="$1" + local prev_var="$2" + local words_var="$3" + local cword_var="$4" + + printf -v "$cur_var" "%s" "$TEST_CUR" + printf -v "$prev_var" "%s" "$TEST_PREV" + eval "$words_var=(\\"\\\${TEST_WORDS[@]}\\")" + printf -v "$cword_var" "%s" "$TEST_CWORD" +} + +# compopt fails outside a real programmable completion context. +compopt() { :; } + +export TAB_ARG_LOG=${shQuote(fixture.argLogPath)} + +TEST_WORDS=(${bashWords}) +TEST_CUR=${shQuote(cur)} +TEST_PREV=${shQuote(prev)} +TEST_CWORD=${item.words.length - 1} + +__demo_complete >/dev/null 2>&1 || true +`; + + const result = spawnSync("bash", ["-c", runner], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expectLastPayload(fixture.argLogPath, item.expected); + }); + } + }); + + describe.skipIf(!hasCommand("fish"))("fish", () => { + for (const item of cases) { + it(item.name, () => { + const fixture = createFixture("fish"); + + const runner = ` +set -gx TAB_ARG_LOG ${shQuote(fixture.argLogPath)} + +function demo +end + +source ${shQuote(fixture.scriptPath)} + +# Clear possible calls caused while sourcing. +echo -n "" > $TAB_ARG_LOG + +complete --do-complete ${shQuote(item.commandLine)} >/dev/null 2>&1 +`; + + const result = spawnSync("fish", ["-c", runner], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expectLastPayload(fixture.argLogPath, item.expected); + }); + } + }); + + describe.skipIf(!hasCommand("pwsh", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion"]))( + "powershell", + () => { + for (const item of cases) { + it(item.name, () => { + const fixture = createFixture("powershell"); + + const cursor = item.commandLine.length; + + const runner = ` +$env:TAB_ARG_LOG = ${psQuote(fixture.argLogPath)} +. ${psQuote(fixture.scriptPath)} + +try { + [System.Management.Automation.CommandCompletion]::CompleteInput( + ${psQuote(item.commandLine)}, + ${cursor}, + $null + ) | Out-Null +} catch { + # The generated completer may reference interactive-only APIs after it + # calls the completion command. The argv log is what this test audits. +} +`; + + const result = spawnSync( + "pwsh", + ["-NoProfile", "-Command", runner], + { + encoding: "utf8", + }, + ); + + expect(result.status).toBe(0); + expectLastPayload(fixture.argLogPath, item.expected); + }); + } + }, + ); +}); \ No newline at end of file From bc594186d52f225f48e2f636deea459005232f89 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 17:55:12 +0330 Subject: [PATCH 02/13] update --- .github/workflows/shell-argv-contract.yml | 87 ----- .github/workflows/shell-completions.yml | 58 ++++ examples/demo.t.ts | 26 +- src/zsh.ts | 2 +- tests/shell-argv-contract.test.ts | 315 ------------------ tests/shell-empty-argv.test.ts | 375 ++++++++++++++++++++++ 6 files changed, 452 insertions(+), 411 deletions(-) delete mode 100644 .github/workflows/shell-argv-contract.yml create mode 100644 .github/workflows/shell-completions.yml delete mode 100644 tests/shell-argv-contract.test.ts create mode 100644 tests/shell-empty-argv.test.ts diff --git a/.github/workflows/shell-argv-contract.yml b/.github/workflows/shell-argv-contract.yml deleted file mode 100644 index 6403621..0000000 --- a/.github/workflows/shell-argv-contract.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Shell argv contract - -on: - pull_request: - branches: - - main - push: - branches: - - main - workflow_dispatch: - -jobs: - unix-shells: - name: Bash / Zsh / Fish on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - os: - - ubuntu-latest - - macos-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4.0.0 - - - name: Set node version - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Install shell deps on Linux - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y zsh fish - - - name: Install shell deps on macOS - if: runner.os == 'macOS' - run: | - brew install fish - - - name: Verify Unix shells are available - run: | - bash --version - zsh --version - fish --version - - - name: Install deps - run: pnpm install - - - name: Run shell argv contract tests - run: pnpm test tests/shell-argv-contract.test.ts - - powershell: - name: PowerShell on Windows - runs-on: windows-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4.0.0 - - - name: Set node version - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - - - name: Verify PowerShell is available - shell: pwsh - run: | - $PSVersionTable.PSVersion - Get-Command pwsh - - - name: Install deps - run: pnpm install - - - name: Run shell argv contract tests - run: pnpm test tests/shell-argv-contract.test.ts \ No newline at end of file diff --git a/.github/workflows/shell-completions.yml b/.github/workflows/shell-completions.yml new file mode 100644 index 0000000..306c3dd --- /dev/null +++ b/.github/workflows/shell-completions.yml @@ -0,0 +1,58 @@ +name: Shell Completions + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + shell-argv-protocol: + name: Shell argv protocol + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install shell dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y zsh fish wget apt-transport-https software-properties-common + + - name: Install PowerShell + shell: bash + run: | + source /etc/os-release + wget -q https://packages.microsoft.com/config/ubuntu/$VERSION_ID/packages-microsoft-prod.deb + sudo dpkg -i packages-microsoft-prod.deb + rm packages-microsoft-prod.deb + sudo apt-get update + sudo apt-get install -y powershell + + - name: Install pnpm + uses: pnpm/action-setup@v4.0.0 + + - name: Set node version to 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + cache: pnpm + + - name: Install deps + run: pnpm install + + - name: Print shell versions + shell: bash + run: | + bash --version + zsh --version + fish --version + pwsh --version + + - name: Run shell argv protocol tests + run: pnpm test tests/shell-empty-argv.test.ts diff --git a/examples/demo.t.ts b/examples/demo.t.ts index 508302c..5d5a0ab 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -120,17 +120,27 @@ t.command('lint', 'Lint project').argument( true ); // Variadic argument for multiple files +const supportedShells = ['zsh', 'bash', 'fish', 'powershell']; +const completeUsage = + 'ERROR: Usage: vite complete | vite complete -- '; + +function printCompleteUsageAndExit() { + console.error(completeUsage); + process.exit(1); +} + // Handle completion command if (process.argv[2] === 'complete') { - const shell = process.argv[3]; - if (shell && ['zsh', 'bash', 'fish', 'powershell'].includes(shell)) { - t.setup('vite', 'pnpm tsx examples/demo.t.ts', shell); + const mode = process.argv[3]; + + if (mode === '--') { + // Runtime completion request from the generated shell script. + t.parse(process.argv.slice(4)); + } else if (mode && supportedShells.includes(mode)) { + // Shell script generation. + t.setup('vite', 'pnpm tsx examples/demo.t.ts', mode); } else { - // Parse completion arguments (everything after --) - const separatorIndex = process.argv.indexOf('--'); - const completionArgs = - separatorIndex !== -1 ? process.argv.slice(separatorIndex + 1) : []; - t.parse(completionArgs); + printCompleteUsageAndExit(); } } else { // Regular CLI usage (just show help for demo) diff --git a/src/zsh.ts b/src/zsh.ts index 81503fd..46d3512 100644 --- a/src/zsh.ts +++ b/src/zsh.ts @@ -48,7 +48,7 @@ _${name}() { # Prepare the command to obtain completions, ensuring arguments are quoted for eval local -a args_to_quote=("\${(@)words[2,-1]}") - if [ "\${lastChar}" = "" ]; then + if [ "\${lastChar}" = "" ] && [ "\${args_to_quote[-1]}" != "" ]; then # If the last parameter is complete (there is a space following it) # We add an extra empty parameter so we can indicate this to the go completion code. __${name}_debug "Adding extra empty parameter" diff --git a/tests/shell-argv-contract.test.ts b/tests/shell-argv-contract.test.ts deleted file mode 100644 index bdf0c9f..0000000 --- a/tests/shell-argv-contract.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; -import { afterEach, describe, expect, it } from "vitest"; - -import { generate as generateBash } from "../src/bash"; -import { generate as generateFish } from "../src/fish"; -import { generate as generatePowerShell } from "../src/powershell"; -import { generate as generateZsh } from "../src/zsh"; - -type ShellName = "bash" | "zsh" | "fish" | "powershell"; - -interface ContractCase { - name: string; - commandLine: string; - words: string[]; - current?: number; - expected: string[]; -} - -const cases: ContractCase[] = [ - { - name: "root empty word", - commandLine: "demo ", - words: ["demo", ""], - current: 2, - expected: [""], - }, - { - name: "root prefix", - commandLine: "demo d", - words: ["demo", "d"], - current: 2, - expected: ["d"], - }, - { - name: "after subcommand space", - commandLine: "demo dev ", - words: ["demo", "dev", ""], - current: 3, - expected: ["dev", ""], - }, - { - name: "flag value empty word", - commandLine: "demo dev --mode ", - words: ["demo", "dev", "--mode", ""], - current: 4, - expected: ["dev", "--mode", ""], - }, - { - name: "flag value prefix", - commandLine: "demo dev --mode p", - words: ["demo", "dev", "--mode", "p"], - current: 4, - expected: ["dev", "--mode", "p"], - }, -]; - -let tempDirs: string[] = []; - -afterEach(() => { - for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); - } - - tempDirs = []; -}); - -function createFixture(shell: ShellName) { - const dir = mkdtempSync(join(tmpdir(), `tab-${shell}-argv-`)); - tempDirs.push(dir); - - const argLogPath = join(dir, "args.log"); - const spyPath = join(dir, "spy.mjs"); - const scriptPath = join(dir, `demo.${shell}`); - - writeFileSync( - spyPath, - ` -import { appendFileSync } from "node:fs"; - -appendFileSync( - process.env.TAB_ARG_LOG, - JSON.stringify(process.argv.slice(2)) + "\\n", -); - -// No completions; only the directive. -// We only care about what argv reached this process. -process.stdout.write(":4\\n"); -`, - ); - - const exec = `${process.execPath} ${spyPath}`; - - const generated = - shell === "bash" - ? generateBash("demo", exec) - : shell === "zsh" - ? generateZsh("demo", exec) - : shell === "fish" - ? generateFish("demo", exec) - : generatePowerShell("demo", exec); - - writeFileSync(scriptPath, generated); - - return { - dir, - argLogPath, - spyPath, - scriptPath, - }; -} - -function hasCommand(command: string, args: string[] = ["--version"]): boolean { - const result = spawnSync(command, args, { - stdio: "ignore", - }); - - return result.status === 0; -} - -function shQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - -function psQuote(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function readPayloads(argLogPath: string): string[][] { - const raw = readFileSync(argLogPath, "utf8").trim(); - - if (!raw) { - return []; - } - - return raw - .split("\n") - .filter(Boolean) - .map((line) => { - const argv = JSON.parse(line) as string[]; - const separatorIndex = argv.indexOf("--"); - - if (separatorIndex === -1) { - return argv; - } - - return argv.slice(separatorIndex + 1); - }); -} - -function expectLastPayload(argLogPath: string, expected: string[]) { - const payloads = readPayloads(argLogPath); - expect(payloads.length).toBeGreaterThan(0); - expect(payloads[payloads.length - 1]).toEqual(expected); -} - -describe("generated shell argv contract", () => { - describe.skipIf(!hasCommand("zsh"))("zsh", () => { - for (const item of cases) { - it(item.name, () => { - const fixture = createFixture("zsh"); - const zshWords = item.words.map(shQuote).join(" "); - - const runner = ` -source ${shQuote(fixture.scriptPath)} - -# Avoid needing real zsh completion UI functions. -compadd() { :; } -_describe() { return 1; } -_arguments() { return 0; } - -export TAB_ARG_LOG=${shQuote(fixture.argLogPath)} - -words=(${zshWords}) -CURRENT=${item.current ?? item.words.length} - -_demo >/dev/null 2>&1 || true -`; - - const result = spawnSync("zsh", ["-fc", runner], { - encoding: "utf8", - }); - - expect(result.status).toBe(0); - expectLastPayload(fixture.argLogPath, item.expected); - }); - } - }); - - describe.skipIf(!hasCommand("bash"))("bash", () => { - for (const item of cases) { - it(item.name, () => { - const fixture = createFixture("bash"); - const bashWords = item.words.map(shQuote).join(" "); - const cur = item.words[item.words.length - 1] ?? ""; - const prev = item.words[item.words.length - 2] ?? ""; - - const runner = ` -source ${shQuote(fixture.scriptPath)} - -# Avoid requiring bash-completion to be installed. -_get_comp_words_by_ref() { - while [[ $# -gt 0 && "$1" == -* ]]; do - if [[ "$1" == "-n" ]]; then - shift 2 - else - shift - fi - done - - local cur_var="$1" - local prev_var="$2" - local words_var="$3" - local cword_var="$4" - - printf -v "$cur_var" "%s" "$TEST_CUR" - printf -v "$prev_var" "%s" "$TEST_PREV" - eval "$words_var=(\\"\\\${TEST_WORDS[@]}\\")" - printf -v "$cword_var" "%s" "$TEST_CWORD" -} - -# compopt fails outside a real programmable completion context. -compopt() { :; } - -export TAB_ARG_LOG=${shQuote(fixture.argLogPath)} - -TEST_WORDS=(${bashWords}) -TEST_CUR=${shQuote(cur)} -TEST_PREV=${shQuote(prev)} -TEST_CWORD=${item.words.length - 1} - -__demo_complete >/dev/null 2>&1 || true -`; - - const result = spawnSync("bash", ["-c", runner], { - encoding: "utf8", - }); - - expect(result.status).toBe(0); - expectLastPayload(fixture.argLogPath, item.expected); - }); - } - }); - - describe.skipIf(!hasCommand("fish"))("fish", () => { - for (const item of cases) { - it(item.name, () => { - const fixture = createFixture("fish"); - - const runner = ` -set -gx TAB_ARG_LOG ${shQuote(fixture.argLogPath)} - -function demo -end - -source ${shQuote(fixture.scriptPath)} - -# Clear possible calls caused while sourcing. -echo -n "" > $TAB_ARG_LOG - -complete --do-complete ${shQuote(item.commandLine)} >/dev/null 2>&1 -`; - - const result = spawnSync("fish", ["-c", runner], { - encoding: "utf8", - }); - - expect(result.status).toBe(0); - expectLastPayload(fixture.argLogPath, item.expected); - }); - } - }); - - describe.skipIf(!hasCommand("pwsh", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion"]))( - "powershell", - () => { - for (const item of cases) { - it(item.name, () => { - const fixture = createFixture("powershell"); - - const cursor = item.commandLine.length; - - const runner = ` -$env:TAB_ARG_LOG = ${psQuote(fixture.argLogPath)} -. ${psQuote(fixture.scriptPath)} - -try { - [System.Management.Automation.CommandCompletion]::CompleteInput( - ${psQuote(item.commandLine)}, - ${cursor}, - $null - ) | Out-Null -} catch { - # The generated completer may reference interactive-only APIs after it - # calls the completion command. The argv log is what this test audits. -} -`; - - const result = spawnSync( - "pwsh", - ["-NoProfile", "-Command", runner], - { - encoding: "utf8", - }, - ); - - expect(result.status).toBe(0); - expectLastPayload(fixture.argLogPath, item.expected); - }); - } - }, - ); -}); \ No newline at end of file diff --git a/tests/shell-empty-argv.test.ts b/tests/shell-empty-argv.test.ts new file mode 100644 index 0000000..11fdffe --- /dev/null +++ b/tests/shell-empty-argv.test.ts @@ -0,0 +1,375 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import * as bash from '../src/bash'; +import * as fish from '../src/fish'; +import * as powershell from '../src/powershell'; +import * as zsh from '../src/zsh'; + +type ExecResult = { + code: number | string | null; + stdout: string; + stderr: string; +}; + +type Fixture = { + dir: string; + scriptPath: string; + capturePath: string; +}; + +type CompletionCase = { + label: string; + expected: string[]; + + // zsh / bash simulation state + words: string[]; + current: number; + + // fish / PowerShell simulation state + line: string; +}; + +const cases: CompletionCase[] = [ + { + label: 'root empty completion', + words: ['demo', ''], + current: 2, + line: 'demo ', + expected: [''], + }, + { + label: 'typed root prefix', + words: ['demo', 'd'], + current: 2, + line: 'demo d', + expected: ['d'], + }, + { + label: 'next word after completed command', + words: ['demo', 'dev', ''], + current: 3, + line: 'demo dev ', + expected: ['dev', ''], + }, +]; + +function execFileAsync( + command: string, + args: string[], + env: NodeJS.ProcessEnv = {} +): Promise { + return new Promise((resolve) => { + execFile( + command, + args, + { + env: { + ...process.env, + ...env, + }, + timeout: 10_000, + }, + (error, stdout, stderr) => { + resolve({ + code: error ? ((error as NodeJS.ErrnoException).code ?? 1) : 0, + stdout, + stderr, + }); + } + ); + }); +} + +async function findExecutable(candidates: string[]): Promise { + for (const candidate of candidates) { + const result = await execFileAsync(candidate, ['--version']); + + if (result.code === 0) { + return candidate; + } + } + + return null; +} + +function shQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function psQuote(value: string): string { + return `'${value.replace(/'/g, `''`)}'`; +} + +async function createFixture( + shell: 'bash' | 'fish' | 'powershell' | 'zsh' +): Promise { + const dir = await mkdtemp(join(tmpdir(), 'tab-shell-empty-argv-')); + const helperPath = join(dir, 'capture-argv.cjs'); + const scriptPath = join(dir, `${shell}.completion`); + const capturePath = join(dir, 'captured-argv.jsonl'); + + await writeFile( + helperPath, + ` + const fs = require('node:fs'); + + const capturePath = process.env.TAB_ARGV_CAPTURE; + if (!capturePath) { + throw new Error('TAB_ARGV_CAPTURE is not set'); + } + + const separatorIndex = process.argv.indexOf('--'); + const completionArgs = + separatorIndex === -1 ? [] : process.argv.slice(separatorIndex + 1); + + fs.appendFileSync(capturePath, JSON.stringify(completionArgs) + '\\n'); + + // Emit one matching completion so bash's compgen exits successfully. + // The argv capture is what this test really asserts. + process.stdout.write('dev\\tStart dev server\\n:4\\n'); + `.trimStart() + ); + + const posixExec = `${shQuote(process.execPath)} ${shQuote(helperPath)}`; + const powerShellExec = `${psQuote(process.execPath)} ${psQuote(helperPath)}`; + + const generatedScript = + shell === 'bash' + ? bash.generate('demo', posixExec) + : shell === 'fish' + ? fish.generate('demo', posixExec) + : shell === 'powershell' + ? powershell.generate('demo', powerShellExec) + : zsh.generate('demo', posixExec); + + await writeFile(scriptPath, generatedScript); + + return { + dir, + scriptPath, + capturePath, + }; +} + +async function readLastCapturedArgs(capturePath: string): Promise { + const content = await readFile(capturePath, 'utf8'); + const lines = content.trim().split(/\r?\n/); + const lastLine = lines.at(-1); + + if (!lastLine) { + throw new Error(`No argv capture found in ${capturePath}`); + } + + return JSON.parse(lastLine); +} + +async function withFixture( + shell: 'bash' | 'fish' | 'powershell' | 'zsh', + fn: (fixture: Fixture) => Promise +) { + const fixture = await createFixture(shell); + + try { + await fn(fixture); + } finally { + await rm(fixture.dir, { + recursive: true, + force: true, + }); + } +} + +async function assertZshCase( + shell: string, + fixture: Fixture, + testCase: CompletionCase +) { + const wordsLiteral = `(${testCase.words.map(shQuote).join(' ')})`; + + const script = ` +function compdef() { :; } +function _describe() { return 1; } +function _arguments() { return 0; } + +source ${shQuote(fixture.scriptPath)} + +words=${wordsLiteral} +CURRENT=${testCase.current} + +_demo >/dev/null +`; + + const result = await execFileAsync(shell, ['-f', '-c', script], { + TAB_ARGV_CAPTURE: fixture.capturePath, + }); + + expect(result.code, result.stderr).toBe(0); + await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( + testCase.expected + ); +} + +async function assertBashCase( + shell: string, + fixture: Fixture, + testCase: CompletionCase +) { + const wordsLiteral = `(${testCase.words.map(shQuote).join(' ')})`; + + const script = ` +function _get_comp_words_by_ref() { + local names=("$@") + local len=\${#names[@]} + + local curvar=\${names[$((len - 4))]} + local prevvar=\${names[$((len - 3))]} + local wordsvar=\${names[$((len - 2))]} + local cwordvar=\${names[$((len - 1))]} + + printf -v "$curvar" '%s' "\${COMP_WORDS[$COMP_CWORD]}" + printf -v "$prevvar" '%s' "\${COMP_WORDS[$((COMP_CWORD - 1))]}" + eval "$wordsvar=(\\"\\\${COMP_WORDS[@]}\\")" + printf -v "$cwordvar" '%s' "$COMP_CWORD" +} + +function compopt() { :; } + +source ${shQuote(fixture.scriptPath)} + +COMP_WORDS=${wordsLiteral} +COMP_CWORD=${testCase.current - 1} + +__demo_complete >/dev/null +`; + + const result = await execFileAsync(shell, ['-c', script], { + TAB_ARGV_CAPTURE: fixture.capturePath, + }); + + expect( + result.code, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ).toBe(0); + await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( + testCase.expected + ); +} + +async function assertFishCase( + shell: string, + fixture: Fixture, + testCase: CompletionCase +) { + const script = ` +set -gx TAB_ARGV_CAPTURE ${shQuote(fixture.capturePath)} +source ${shQuote(fixture.scriptPath)} + +complete --do-complete ${shQuote(testCase.line)} >/dev/null +`; + + const result = await execFileAsync(shell, ['-c', script]); + + expect(result.code, result.stderr).toBe(0); + await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( + testCase.expected + ); +} + +async function assertPowerShellCase( + shell: string, + fixture: Fixture, + testCase: CompletionCase +) { + const cursorPosition = testCase.line.length; + + const script = ` +$env:TAB_ARGV_CAPTURE = ${psQuote(fixture.capturePath)} +. ${psQuote(fixture.scriptPath)} + +[System.Management.Automation.CommandCompletion]::CompleteInput( + ${psQuote(testCase.line)}, + ${cursorPosition}, + $null +) | Out-Null +`; + + const result = await execFileAsync(shell, [ + '-NoProfile', + '-NonInteractive', + '-Command', + script, + ]); + + expect(result.code, result.stderr).toBe(0); + await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( + testCase.expected + ); +} + +describe('generated shell argv protocol', () => { + it('zsh sends exactly one empty arg for root empty completion', async () => { + const shell = await findExecutable(['zsh']); + + if (!shell) { + console.warn('Skipping zsh argv protocol test: zsh is not installed'); + return; + } + + await withFixture('zsh', async (fixture) => { + for (const testCase of cases) { + await assertZshCase(shell, fixture, testCase); + } + }); + }); + + it('bash sends exactly one empty arg for root empty completion', async () => { + const shell = await findExecutable(['bash']); + + if (!shell) { + console.warn('Skipping bash argv protocol test: bash is not installed'); + return; + } + + await withFixture('bash', async (fixture) => { + for (const testCase of cases) { + await assertBashCase(shell, fixture, testCase); + } + }); + }); + + it('fish sends exactly one empty arg for root empty completion', async () => { + const shell = await findExecutable(['fish']); + + if (!shell) { + console.warn('Skipping fish argv protocol test: fish is not installed'); + return; + } + + await withFixture('fish', async (fixture) => { + for (const testCase of cases) { + await assertFishCase(shell, fixture, testCase); + } + }); + }); + + it('PowerShell sends exactly one empty arg for root empty completion', async () => { + const shell = await findExecutable(['pwsh', 'powershell']); + + if (!shell) { + console.warn( + 'Skipping PowerShell argv protocol test: pwsh/powershell is not installed' + ); + return; + } + + await withFixture('powershell', async (fixture) => { + for (const testCase of cases) { + await assertPowerShellCase(shell, fixture, testCase); + } + }); + }); +}); From 560ad984fc5dea64ac8b4cfb746d779183790ac0 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:05:36 +0330 Subject: [PATCH 03/13] update --- tests/shell-empty-argv.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/shell-empty-argv.test.ts b/tests/shell-empty-argv.test.ts index 11fdffe..5cfaa50 100644 --- a/tests/shell-empty-argv.test.ts +++ b/tests/shell-empty-argv.test.ts @@ -71,7 +71,7 @@ function execFileAsync( ...process.env, ...env, }, - timeout: 10_000, + timeout: 30_000, }, (error, stdout, stderr) => { resolve({ @@ -298,6 +298,7 @@ $env:TAB_ARGV_CAPTURE = ${psQuote(fixture.capturePath)} `; const result = await execFileAsync(shell, [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', @@ -371,5 +372,5 @@ describe('generated shell argv protocol', () => { await assertPowerShellCase(shell, fixture, testCase); } }); - }); + }, 30_000); }); From af39516914bbc3356ddf79c49033b51b0b9d0cc6 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:11:43 +0330 Subject: [PATCH 04/13] update --- tests/shell-empty-argv.test.ts | 135 +++++++++++++++++++++++---------- 1 file changed, 93 insertions(+), 42 deletions(-) diff --git a/tests/shell-empty-argv.test.ts b/tests/shell-empty-argv.test.ts index 5cfaa50..29057cc 100644 --- a/tests/shell-empty-argv.test.ts +++ b/tests/shell-empty-argv.test.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { delimiter, join } from 'node:path'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import * as bash from '../src/bash'; @@ -71,7 +71,7 @@ function execFileAsync( ...process.env, ...env, }, - timeout: 30_000, + timeout: 20_000, }, (error, stdout, stderr) => { resolve({ @@ -115,23 +115,43 @@ async function createFixture( await writeFile( helperPath, ` - const fs = require('node:fs'); - - const capturePath = process.env.TAB_ARGV_CAPTURE; - if (!capturePath) { - throw new Error('TAB_ARGV_CAPTURE is not set'); - } - - const separatorIndex = process.argv.indexOf('--'); - const completionArgs = - separatorIndex === -1 ? [] : process.argv.slice(separatorIndex + 1); - - fs.appendFileSync(capturePath, JSON.stringify(completionArgs) + '\\n'); - - // Emit one matching completion so bash's compgen exits successfully. - // The argv capture is what this test really asserts. - process.stdout.write('dev\\tStart dev server\\n:4\\n'); - `.trimStart() +const fs = require('node:fs'); + +const capturePath = process.env.TAB_ARGV_CAPTURE; +if (!capturePath) { + throw new Error('TAB_ARGV_CAPTURE is not set'); +} + +const separatorIndex = process.argv.indexOf('--'); +const completionArgs = + separatorIndex === -1 ? [] : process.argv.slice(separatorIndex + 1); + +fs.appendFileSync(capturePath, JSON.stringify(completionArgs) + '\\n'); + +// Emit one matching completion so shells that use native filtering still exit successfully. +// The argv capture is what this test actually asserts. +process.stdout.write('dev\\tStart dev server\\n:4\\n'); +`.trimStart() + ); + + // PowerShell's native completion engine may not invoke a registered native + // argument completer unless the command exists on PATH. Create a dummy command + // so the test environment matches real CLI usage. + const posixCommandPath = join(dir, 'demo'); + await writeFile( + posixCommandPath, + `#!/usr/bin/env sh +exit 0 +` + ); + await chmod(posixCommandPath, 0o755); + + // Harmless on Unix, useful if this test is ever run on Windows. + await writeFile( + join(dir, 'demo.cmd'), + `@echo off +exit /b 0 +` ); const posixExec = `${shQuote(process.execPath)} ${shQuote(helperPath)}`; @@ -156,12 +176,24 @@ async function createFixture( } async function readLastCapturedArgs(capturePath: string): Promise { - const content = await readFile(capturePath, 'utf8'); + let content: string; + + try { + content = await readFile(capturePath, 'utf8'); + } catch (error) { + throw new Error( + `No argv capture found at ${capturePath}. The generated completer probably did not invoke the fake backend.`, + { + cause: error, + } + ); + } + const lines = content.trim().split(/\r?\n/); const lastLine = lines.at(-1); if (!lastLine) { - throw new Error(`No argv capture found in ${capturePath}`); + throw new Error(`Argv capture file was empty: ${capturePath}`); } return JSON.parse(lastLine); @@ -208,9 +240,13 @@ _demo >/dev/null }); expect(result.code, result.stderr).toBe(0); - await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( - testCase.expected - ); + + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + + expect( + capturedArgs, + `zsh did not send expected argv for case: ${testCase.label}` + ).toEqual(testCase.expected); } async function assertBashCase( @@ -254,9 +290,13 @@ __demo_complete >/dev/null result.code, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` ).toBe(0); - await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( - testCase.expected - ); + + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + + expect( + capturedArgs, + `bash did not send expected argv for case: ${testCase.label}` + ).toEqual(testCase.expected); } async function assertFishCase( @@ -274,9 +314,13 @@ complete --do-complete ${shQuote(testCase.line)} >/dev/null const result = await execFileAsync(shell, ['-c', script]); expect(result.code, result.stderr).toBe(0); - await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( - testCase.expected - ); + + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + + expect( + capturedArgs, + `fish did not send expected argv for case: ${testCase.label}` + ).toEqual(testCase.expected); } async function assertPowerShellCase( @@ -297,18 +341,25 @@ $env:TAB_ARGV_CAPTURE = ${psQuote(fixture.capturePath)} ) | Out-Null `; - const result = await execFileAsync(shell, [ - '-NoLogo', - '-NoProfile', - '-NonInteractive', - '-Command', - script, - ]); - - expect(result.code, result.stderr).toBe(0); - await expect(readLastCapturedArgs(fixture.capturePath)).resolves.toEqual( - testCase.expected + const result = await execFileAsync( + shell, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + PATH: `${fixture.dir}${delimiter}${process.env.PATH ?? ''}`, + } ); + + expect( + result.code, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ).toBe(0); + + const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + + expect( + capturedArgs, + `PowerShell did not send expected argv for case: ${testCase.label}` + ).toEqual(testCase.expected); } describe('generated shell argv protocol', () => { From fcc79e3aca54bff6b6ed468a4ecf1c72da79e8f3 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:16:49 +0330 Subject: [PATCH 05/13] update --- tests/shell-empty-argv.test.ts | 43 +++++++++++++--------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/tests/shell-empty-argv.test.ts b/tests/shell-empty-argv.test.ts index 29057cc..a74aaa8 100644 --- a/tests/shell-empty-argv.test.ts +++ b/tests/shell-empty-argv.test.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { delimiter, join } from 'node:path'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import * as bash from '../src/bash'; @@ -134,26 +134,6 @@ process.stdout.write('dev\\tStart dev server\\n:4\\n'); `.trimStart() ); - // PowerShell's native completion engine may not invoke a registered native - // argument completer unless the command exists on PATH. Create a dummy command - // so the test environment matches real CLI usage. - const posixCommandPath = join(dir, 'demo'); - await writeFile( - posixCommandPath, - `#!/usr/bin/env sh -exit 0 -` - ); - await chmod(posixCommandPath, 0o755); - - // Harmless on Unix, useful if this test is ever run on Windows. - await writeFile( - join(dir, 'demo.cmd'), - `@echo off -exit /b 0 -` - ); - const posixExec = `${shQuote(process.execPath)} ${shQuote(helperPath)}`; const powerShellExec = `${psQuote(process.execPath)} ${psQuote(helperPath)}`; @@ -329,23 +309,32 @@ async function assertPowerShellCase( testCase: CompletionCase ) { const cursorPosition = testCase.line.length; + const wordToComplete = testCase.line.endsWith(' ') + ? '' + : (testCase.line.split(/\s+/).at(-1) ?? ''); const script = ` $env:TAB_ARGV_CAPTURE = ${psQuote(fixture.capturePath)} . ${psQuote(fixture.scriptPath)} -[System.Management.Automation.CommandCompletion]::CompleteInput( +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseInput( ${psQuote(testCase.line)}, - ${cursorPosition}, - $null -) | Out-Null + [ref]$tokens, + [ref]$errors +) + +$commandAst = $ast.EndBlock.Statements[0].PipelineElements[0] + +& $__demoCompleterBlock ${psQuote(wordToComplete)} $commandAst ${cursorPosition} | Out-Null `; const result = await execFileAsync( shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { - PATH: `${fixture.dir}${delimiter}${process.env.PATH ?? ''}`, + TAB_ARGV_CAPTURE: fixture.capturePath, } ); From 9547f8886bd563572bd6aa54618caf78bd288490 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:21:37 +0330 Subject: [PATCH 06/13] final test --- tests/shell-empty-argv.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/shell-empty-argv.test.ts b/tests/shell-empty-argv.test.ts index a74aaa8..960af36 100644 --- a/tests/shell-empty-argv.test.ts +++ b/tests/shell-empty-argv.test.ts @@ -109,7 +109,10 @@ async function createFixture( ): Promise { const dir = await mkdtemp(join(tmpdir(), 'tab-shell-empty-argv-')); const helperPath = join(dir, 'capture-argv.cjs'); - const scriptPath = join(dir, `${shell}.completion`); + const scriptPath = join( + dir, + shell === 'powershell' ? 'powershell.completion.ps1' : `${shell}.completion` + ); const capturePath = join(dir, 'captured-argv.jsonl'); await writeFile( From 6c8720123fc1db741efa4752c973c7f45f663da7 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:37:31 +0330 Subject: [PATCH 07/13] update --- .github/workflows/ci.yml | 2 +- package.json | 2 ++ tests/shell-empty-argv.test.ts | 36 +++++++++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b849f9..ee67166 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: run: pnpm install - name: Run tests - run: pnpm test + run: pnpm test:unit typecheck: name: Lint and Type Check diff --git a/package.json b/package.json index 4248050..c881c13 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ }, "scripts": { "test": "vitest run", + "test:unit": "vitest run --exclude tests/shell-empty-argv.test.ts", + "test:shell": "vitest run tests/shell-empty-argv.test.ts", "type-check": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/tests/shell-empty-argv.test.ts b/tests/shell-empty-argv.test.ts index 960af36..e8fe9aa 100644 --- a/tests/shell-empty-argv.test.ts +++ b/tests/shell-empty-argv.test.ts @@ -96,6 +96,26 @@ async function findExecutable(candidates: string[]): Promise { return null; } +function formatArgs(args: string[]): string { + return JSON.stringify(args); +} + +function logShellCase( + shell: string, + testCase: CompletionCase, + expected: string[], + received: string[] +) { + const passed = JSON.stringify(received) === JSON.stringify(expected); + const status = passed ? 'PASS' : 'FAIL'; + + console.log( + `[${shell}] ${status} ${testCase.label}\n` + + ` expected: ${formatArgs(expected)}\n` + + ` received: ${formatArgs(received)}` + ); +} + function shQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } @@ -226,6 +246,8 @@ _demo >/dev/null const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + logShellCase('zsh', testCase, testCase.expected, capturedArgs); + expect( capturedArgs, `zsh did not send expected argv for case: ${testCase.label}` @@ -276,6 +298,8 @@ __demo_complete >/dev/null const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + logShellCase('bash', testCase, testCase.expected, capturedArgs); + expect( capturedArgs, `bash did not send expected argv for case: ${testCase.label}` @@ -300,6 +324,8 @@ complete --do-complete ${shQuote(testCase.line)} >/dev/null const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + logShellCase('fish', testCase, testCase.expected, capturedArgs); + expect( capturedArgs, `fish did not send expected argv for case: ${testCase.label}` @@ -348,6 +374,8 @@ $commandAst = $ast.EndBlock.Statements[0].PipelineElements[0] const capturedArgs = await readLastCapturedArgs(fixture.capturePath); + logShellCase('powershell', testCase, testCase.expected, capturedArgs); + expect( capturedArgs, `PowerShell did not send expected argv for case: ${testCase.label}` @@ -410,10 +438,16 @@ describe('generated shell argv protocol', () => { return; } + const failures: string[] = []; await withFixture('powershell', async (fixture) => { for (const testCase of cases) { - await assertPowerShellCase(shell, fixture, testCase); + try { + await assertPowerShellCase(shell, fixture, testCase); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } } }); + expect(failures.join('\n\n')).toBe(''); }, 30_000); }); From ef307a1712b0c08096490412ecf1dbda4dbca0e6 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:44:46 +0330 Subject: [PATCH 08/13] update tests --- tests/shell-empty-argv.test.ts | 100 +++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/tests/shell-empty-argv.test.ts b/tests/shell-empty-argv.test.ts index e8fe9aa..5a8bcad 100644 --- a/tests/shell-empty-argv.test.ts +++ b/tests/shell-empty-argv.test.ts @@ -96,6 +96,14 @@ async function findExecutable(candidates: string[]): Promise { return null; } +function shQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function psQuote(value: string): string { + return `'${value.replace(/'/g, `''`)}'`; +} + function formatArgs(args: string[]): string { return JSON.stringify(args); } @@ -116,14 +124,6 @@ function logShellCase( ); } -function shQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function psQuote(value: string): string { - return `'${value.replace(/'/g, `''`)}'`; -} - async function createFixture( shell: 'bash' | 'fish' | 'powershell' | 'zsh' ): Promise { @@ -242,7 +242,10 @@ _demo >/dev/null TAB_ARGV_CAPTURE: fixture.capturePath, }); - expect(result.code, result.stderr).toBe(0); + expect( + result.code, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ).toBe(0); const capturedArgs = await readLastCapturedArgs(fixture.capturePath); @@ -320,7 +323,10 @@ complete --do-complete ${shQuote(testCase.line)} >/dev/null const result = await execFileAsync(shell, ['-c', script]); - expect(result.code, result.stderr).toBe(0); + expect( + result.code, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ).toBe(0); const capturedArgs = await readLastCapturedArgs(fixture.capturePath); @@ -382,8 +388,30 @@ $commandAst = $ast.EndBlock.Statements[0].PipelineElements[0] ).toEqual(testCase.expected); } +async function collectCaseFailures( + shellName: string, + casesToRun: CompletionCase[], + runCase: (testCase: CompletionCase) => Promise +): Promise { + const failures: string[] = []; + + for (const testCase of casesToRun) { + try { + await runCase(testCase); + } catch (error) { + failures.push( + `[${shellName}] ${testCase.label}\n${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + return failures; +} + describe('generated shell argv protocol', () => { - it('zsh sends exactly one empty arg for root empty completion', async () => { + it('zsh sends the expected argv for every completion case', async () => { const shell = await findExecutable(['zsh']); if (!shell) { @@ -391,14 +419,18 @@ describe('generated shell argv protocol', () => { return; } + let failures: string[] = []; + await withFixture('zsh', async (fixture) => { - for (const testCase of cases) { - await assertZshCase(shell, fixture, testCase); - } + failures = await collectCaseFailures('zsh', cases, (testCase) => + assertZshCase(shell, fixture, testCase) + ); }); + + expect(failures.join('\n\n')).toBe(''); }); - it('bash sends exactly one empty arg for root empty completion', async () => { + it('bash sends the expected argv for every completion case', async () => { const shell = await findExecutable(['bash']); if (!shell) { @@ -406,14 +438,18 @@ describe('generated shell argv protocol', () => { return; } + let failures: string[] = []; + await withFixture('bash', async (fixture) => { - for (const testCase of cases) { - await assertBashCase(shell, fixture, testCase); - } + failures = await collectCaseFailures('bash', cases, (testCase) => + assertBashCase(shell, fixture, testCase) + ); }); + + expect(failures.join('\n\n')).toBe(''); }); - it('fish sends exactly one empty arg for root empty completion', async () => { + it('fish sends the expected argv for every completion case', async () => { const shell = await findExecutable(['fish']); if (!shell) { @@ -421,14 +457,18 @@ describe('generated shell argv protocol', () => { return; } + let failures: string[] = []; + await withFixture('fish', async (fixture) => { - for (const testCase of cases) { - await assertFishCase(shell, fixture, testCase); - } + failures = await collectCaseFailures('fish', cases, (testCase) => + assertFishCase(shell, fixture, testCase) + ); }); + + expect(failures.join('\n\n')).toBe(''); }); - it('PowerShell sends exactly one empty arg for root empty completion', async () => { + it('PowerShell sends the expected argv for every completion case', async () => { const shell = await findExecutable(['pwsh', 'powershell']); if (!shell) { @@ -438,16 +478,14 @@ describe('generated shell argv protocol', () => { return; } - const failures: string[] = []; + let failures: string[] = []; + await withFixture('powershell', async (fixture) => { - for (const testCase of cases) { - try { - await assertPowerShellCase(shell, fixture, testCase); - } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)); - } - } + failures = await collectCaseFailures('powershell', cases, (testCase) => + assertPowerShellCase(shell, fixture, testCase) + ); }); + expect(failures.join('\n\n')).toBe(''); }, 30_000); }); From ca8d65b8e7ff541ef746d0f51efed76be0d598a4 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:45:13 +0330 Subject: [PATCH 09/13] add failing zsh test --- src/zsh.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zsh.ts b/src/zsh.ts index 46d3512..81503fd 100644 --- a/src/zsh.ts +++ b/src/zsh.ts @@ -48,7 +48,7 @@ _${name}() { # Prepare the command to obtain completions, ensuring arguments are quoted for eval local -a args_to_quote=("\${(@)words[2,-1]}") - if [ "\${lastChar}" = "" ] && [ "\${args_to_quote[-1]}" != "" ]; then + if [ "\${lastChar}" = "" ]; then # If the last parameter is complete (there is a space following it) # We add an extra empty parameter so we can indicate this to the go completion code. __${name}_debug "Adding extra empty parameter" From eca830dae854affd24e29a3e1c86c4d212f3be20 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:48:21 +0330 Subject: [PATCH 10/13] fix again --- src/zsh.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zsh.ts b/src/zsh.ts index 81503fd..46d3512 100644 --- a/src/zsh.ts +++ b/src/zsh.ts @@ -48,7 +48,7 @@ _${name}() { # Prepare the command to obtain completions, ensuring arguments are quoted for eval local -a args_to_quote=("\${(@)words[2,-1]}") - if [ "\${lastChar}" = "" ]; then + if [ "\${lastChar}" = "" ] && [ "\${args_to_quote[-1]}" != "" ]; then # If the last parameter is complete (there is a space following it) # We add an extra empty parameter so we can indicate this to the go completion code. __${name}_debug "Adding extra empty parameter" From ac353148ac3e640ba0ee3e7aa0eada028ed8ef29 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 18:58:05 +0330 Subject: [PATCH 11/13] edit --- examples/demo.t.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/demo.t.ts b/examples/demo.t.ts index 5d5a0ab..9bfe607 100644 --- a/examples/demo.t.ts +++ b/examples/demo.t.ts @@ -121,8 +121,7 @@ t.command('lint', 'Lint project').argument( ); // Variadic argument for multiple files const supportedShells = ['zsh', 'bash', 'fish', 'powershell']; -const completeUsage = - 'ERROR: Usage: vite complete | vite complete -- '; +const completeUsage = 'Usage: vite complete | vite complete -- '; function printCompleteUsageAndExit() { console.error(completeUsage); From 3175329d5f4d7e38242b35ef2fbed95027b69138 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 19:05:19 +0330 Subject: [PATCH 12/13] fix powershell --- src/powershell.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/powershell.ts b/src/powershell.ts index 1a555a5..44e1bc4 100644 --- a/src/powershell.ts +++ b/src/powershell.ts @@ -93,8 +93,10 @@ export function generate(name: string, exec: string): string { # Remove the flag part $Flag, $WordToComplete = $WordToComplete.Split("=", 2) } - - if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag )) { + $HasTrailingEmptyArg = $QuotedArgs -match "(^| )''$" + __${name}_debug "HasTrailingEmptyArg: $HasTrailingEmptyArg" + + if ( $WordToComplete -eq "" -And ( -Not $IsEqualFlag ) -And ( -Not $HasTrailingEmptyArg )) { # If the last parameter is complete (there is a space following it) # We add an extra empty parameter so we can indicate this to the go method. __${name}_debug "Adding extra empty parameter" From 932a8d3879b82c2751af3eecac917adaea89f1fb Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Thu, 18 Jun 2026 19:09:24 +0330 Subject: [PATCH 13/13] add changeset --- .changeset/fast-lamps-march.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fast-lamps-march.md diff --git a/.changeset/fast-lamps-march.md b/.changeset/fast-lamps-march.md new file mode 100644 index 0000000..6eccf8a --- /dev/null +++ b/.changeset/fast-lamps-march.md @@ -0,0 +1,5 @@ +--- +'@bomb.sh/tab': patch +--- + +prevent duplicate empty args in generated completions