diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b849f9..c2d791a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: jobs: test: - name: Tests + name: Tests (${{ matrix.os }}) strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] @@ -33,8 +33,143 @@ jobs: - name: Install deps run: pnpm install - - name: Run tests - run: pnpm test + - name: Run core tests (excluding shell integration) + run: pnpm vitest run --exclude "**/shell-integration.test.ts" + + shell-tests: + name: Shell Tests (${{ matrix.shell }} on ${{ matrix.os }}) + strategy: + matrix: + shell: [bash, zsh, fish, powershell] + os: [ubuntu-latest, macos-latest, windows-latest] + exclude: + # PowerShell installation can be flaky on macOS in CI + - shell: powershell + os: macos-latest + # Some shells are not easily available on Windows + - shell: zsh + os: windows-latest + - shell: fish + os: windows-latest + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install shell dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + case "${{ matrix.shell }}" in + bash) + sudo apt-get install -y bash-completion + ;; + zsh) + sudo apt-get install -y zsh + ;; + fish) + sudo apt-get install -y fish + ;; + powershell) + # PowerShell Core is pre-installed on GitHub Actions runners + echo "PowerShell Core already available" + ;; + esac + + - name: Install shell dependencies (macOS) + if: matrix.os == 'macos-latest' + run: | + case "${{ matrix.shell }}" in + bash) + brew install bash-completion@2 + ;; + zsh) + # zsh is already installed on macOS + echo "zsh already available" + ;; + fish) + brew install fish + ;; + powershell) + # Skip PowerShell on macOS for now due to CI flakiness + echo "PowerShell skipped on macOS" + ;; + esac + + - name: Install shell dependencies (Windows) + if: matrix.os == 'windows-latest' + shell: powershell + run: | + # Windows specific shell setup + switch ("${{ matrix.shell }}") { + "bash" { + # Git for Windows includes bash + if (!(Get-Command git -ErrorAction SilentlyContinue)) { + choco install git -y + } + Write-Host "Bash available via Git for Windows" + } + "powershell" { + # PowerShell Core is pre-installed on GitHub Actions runners + Write-Host "PowerShell Core already available" + } + default { + Write-Host "Shell ${{ matrix.shell }} not supported on Windows" + } + } + + - name: Verify shell installation (Unix) + if: matrix.os != 'windows-latest' + run: | + case "${{ matrix.shell }}" in + bash) + bash --version + ;; + zsh) + zsh --version + ;; + fish) + fish --version + ;; + powershell) + pwsh --version + ;; + esac + + - name: Verify shell installation (Windows) + if: matrix.os == 'windows-latest' + shell: powershell + run: | + switch ("${{ matrix.shell }}") { + "bash" { + bash --version + } + "powershell" { + pwsh --version + } + default { + Write-Host "Verification not needed for ${{ matrix.shell }} on Windows" + } + } + + - 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: Run shell-specific tests + run: pnpm test tests/shell-integration.test.ts + env: + TEST_SHELL: ${{ matrix.shell }} typecheck: name: Lint and Type Check diff --git a/src/bash.ts b/src/bash.ts index 3e3a4f2..7bcb680 100644 --- a/src/bash.ts +++ b/src/bash.ts @@ -118,5 +118,6 @@ __${nameForVar}_complete() { # Register completion function complete -F __${nameForVar}_complete ${name} + `; } diff --git a/tests/package-manager-integration.test.ts b/tests/package-manager-integration.test.ts new file mode 100644 index 0000000..e22ec70 --- /dev/null +++ b/tests/package-manager-integration.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect } from 'vitest'; +import { promisify } from 'node:util'; +import { exec as execCb } from 'node:child_process'; +import { writeFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +const exec = promisify(execCb); + +describe('package manager integration tests', () => { + const packageManagers = ['npm', 'pnpm', 'yarn', 'bun']; + const shells = ['zsh', 'bash', 'fish', 'powershell']; + + describe('package manager completion script generation', () => { + packageManagers.forEach((pm) => { + shells.forEach((shell) => { + it(`should generate ${shell} completion script for ${pm}`, async () => { + const command = `pnpm tsx bin/cli.ts ${pm} ${shell}`; + + try { + const { stdout, stderr } = await exec(command); + + // Should have output + expect(stdout).toBeTruthy(); + expect(stdout.length).toBeGreaterThan(100); + + // Should contain shell-specific markers + expect(stdout).toContain(`# ${shell} completion for`); + + // Should contain package manager name + expect(stdout).toContain(pm); + + // Should not have errors + expect(stderr).toBe(''); + } catch (error) { + throw new Error( + `Failed to generate ${shell} script for ${pm}: ${error}` + ); + } + }); + }); + }); + }); + + // Test package manager completion functionality + describe('package manager completion functionality', () => { + packageManagers.forEach((pm) => { + describe(`${pm} completion`, () => { + it('should handle basic command completion', async () => { + const command = `pnpm tsx bin/cli.ts ${pm} complete -- install`; + + try { + const { stdout, stderr } = await exec(command); + + // Should have some output (completions or directive) + expect(stdout).toBeTruthy(); + + // Should end with completion directive + expect(stdout.trim()).toMatch(/:\d+$/); + + // Should not have errors + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed ${pm} completion test: ${error}`); + } + }); + + it('should handle script completion', async () => { + const command = `pnpm tsx bin/cli.ts ${pm} complete -- run`; + + try { + const { stdout, stderr } = await exec(command); + + // Should have output + expect(stdout).toBeTruthy(); + expect(stdout.trim()).toMatch(/:\d+$/); + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed ${pm} script completion: ${error}`); + } + }); + + it('should handle no match scenarios', async () => { + const command = `pnpm tsx bin/cli.ts ${pm} complete -- nonexistentcommand`; + + try { + const { stdout, stderr } = await exec(command); + + // Should have output (even if no matches) + expect(stdout).toBeTruthy(); + expect(stdout.trim()).toMatch(/:\d+$/); + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed ${pm} no-match test: ${error}`); + } + }); + }); + }); + }); + + // Test bash-specific package manager issues + describe('bash package manager completion validation', () => { + packageManagers.forEach((pm) => { + it(`should generate syntactically valid bash completion for ${pm}`, async () => { + const command = `pnpm tsx bin/cli.ts ${pm} bash`; + const { stdout } = await exec(command); + + // Write script to temp file + const scriptPath = join(process.cwd(), `temp-${pm}-bash-completion.sh`); + await writeFile(scriptPath, stdout); + + try { + // Test bash syntax with -n flag (syntax check only) + await exec(`bash -n ${scriptPath}`); + // If we get here, syntax is valid + expect(true).toBe(true); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Provide helpful error message about bash-completion dependency + const helpMessage = ` +${pm} bash script has syntax errors: ${errorMessage} + +This might be due to missing bash-completion dependency. +To fix this, install bash-completion@2: + + brew install bash-completion@2 + +Then source the completion in your shell profile: + echo 'source $(brew --prefix)/share/bash-completion/bash_completion' >> ~/.bashrc +`; + throw new Error(helpMessage); + } finally { + // Clean up + await unlink(scriptPath).catch(() => {}); + } + }); + + it(`should generate bash completion for ${pm} without problematic syntax`, async () => { + const command = `pnpm tsx bin/cli.ts ${pm} bash`; + const { stdout } = await exec(command); + + // Check that it uses the correct ${words[@]:1} syntax (requires bash-completion@2) + expect(stdout).toContain('${words[@]:1}'); // Should use ${words[@]:1} (requires bash-completion@2) + expect(stdout).toContain('complete -F'); // Should register completion properly + expect(stdout).toContain('_get_comp_words_by_ref'); // Should use bash-completion functions + + // Should contain package manager specific function + expect(stdout).toMatch( + new RegExp(`__${pm.replace('-', '_')}_complete\\(\\)`) + ); + }); + }); + }); + + describe('package manager completion registration', () => { + shells.forEach((shell) => { + it(`should generate ${shell} completion with proper registration for all package managers`, async () => { + for (const pm of packageManagers) { + const command = `pnpm tsx bin/cli.ts ${pm} ${shell}`; + const { stdout } = await exec(command); + + switch (shell) { + case 'bash': + expect(stdout).toMatch(/complete -F __\w+_complete/); + expect(stdout).toContain(pm); // Should register for the specific package manager + break; + case 'zsh': + expect(stdout).toMatch(/#compdef \w+/); + expect(stdout).toMatch(/compdef _\w+ \w+/); + break; + case 'fish': + expect(stdout).toContain('complete -c'); + expect(stdout).toContain(pm); + break; + case 'powershell': + expect(stdout).toContain('Register-ArgumentCompleter'); + expect(stdout).toContain(pm); + break; + } + } + }, 15000); // Increase timeout to 15 seconds for these intensive tests + }); + }); + + // Test error handling + describe('package manager error handling', () => { + it('should handle unsupported package manager', async () => { + const command = `pnpm tsx bin/cli.ts unsupported complete -- test`; + + try { + await exec(command); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + expect(errorMessage).toContain('Unsupported package manager'); + } + }); + + it('should handle missing -- separator', async () => { + const command = `pnpm tsx bin/cli.ts pnpm complete test`; + + try { + await exec(command); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + expect(errorMessage).toContain("Expected '--' followed by command"); + } + }); + }); + + // Test specific package manager features + describe('package manager specific features', () => { + it('should handle pnpm-specific commands', async () => { + const command = `pnpm tsx bin/cli.ts pnpm complete -- dlx`; + + try { + const { stdout, stderr } = await exec(command); + expect(stdout).toBeTruthy(); + expect(stdout.trim()).toMatch(/:\d+$/); + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed pnpm dlx completion: ${error}`); + } + }); + + it('should handle yarn-specific commands', async () => { + const command = `pnpm tsx bin/cli.ts yarn complete -- workspace`; + + try { + const { stdout, stderr } = await exec(command); + expect(stdout).toBeTruthy(); + expect(stdout.trim()).toMatch(/:\d+$/); + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed yarn workspace completion: ${error}`); + } + }); + + it('should handle npm-specific commands', async () => { + const command = `pnpm tsx bin/cli.ts npm complete -- audit`; + + try { + const { stdout, stderr } = await exec(command); + expect(stdout).toBeTruthy(); + expect(stdout.trim()).toMatch(/:\d+$/); + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed npm audit completion: ${error}`); + } + }); + + it('should handle bun-specific commands', async () => { + const command = `pnpm tsx bin/cli.ts bun complete -- create`; + + try { + const { stdout, stderr } = await exec(command); + expect(stdout).toBeTruthy(); + expect(stdout.trim()).toMatch(/:\d+$/); + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed bun create completion: ${error}`); + } + }); + }); +}); diff --git a/tests/shell-integration.test.ts b/tests/shell-integration.test.ts new file mode 100644 index 0000000..b2a01f8 --- /dev/null +++ b/tests/shell-integration.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest'; +import { promisify } from 'node:util'; +import { exec as execCb } from 'node:child_process'; +import { writeFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +const exec = promisify(execCb); + +describe('shell integration tests', () => { + // Support matrix testing - if TEST_SHELL is set, only test that shell + const testShell = process.env.TEST_SHELL; + const shells = testShell + ? [testShell] + : ['zsh', 'bash', 'fish', 'powershell']; + const cliTool = 'cac'; + + describe('shell script generation', () => { + shells.forEach((shell) => { + it(`should generate ${shell} completion script without errors`, async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete ${shell}`; + + try { + const { stdout, stderr } = await exec(command); + + // Should have output + expect(stdout).toBeTruthy(); + expect(stdout.length).toBeGreaterThan(100); + + // Should contain shell-specific markers + expect(stdout).toContain(`# ${shell} completion for`); + + // Should not have errors + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed to generate ${shell} script: ${error}`); + } + }); + }); + }); + + describe('completion functionality', () => { + const completionTests = [ + { name: 'command completion', args: 'serve' }, + { name: 'option completion', args: 'serve --p' }, + { name: 'no match', args: 'nonexistent' }, + ]; + + completionTests.forEach(({ name, args }) => { + it(`should handle ${name}`, async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete -- ${args}`; + + try { + const { stdout, stderr } = await exec(command); + + // Should have some output (completions or directive) + expect(stdout).toBeTruthy(); + + // Should end with completion directive (e.g., :0, :1, etc.) + expect(stdout.trim()).toMatch(/:\d+$/); + + // Should not have errors + expect(stderr).toBe(''); + } catch (error) { + throw new Error(`Failed completion test '${name}': ${error}`); + } + }); + }); + }); + + describe('shell script syntax validation', () => { + // Only run syntax validation for the shells we're testing + const syntaxTestShells = shells.filter( + (shell) => + shell === 'bash' || + shell === 'zsh' || + shell === 'fish' || + shell === 'powershell' + ); + + syntaxTestShells.forEach((shell) => { + it( + `should generate syntactically valid ${shell} script`, + async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete ${shell}`; + const { stdout } = await exec(command); + + // Write script to temp file + const scriptPath = join( + process.cwd(), + `temp-${shell}-completion.${shell === 'powershell' ? 'ps1' : shell === 'fish' ? 'fish' : 'sh'}` + ); + await writeFile(scriptPath, stdout); + + try { + // Test syntax based on shell type + switch (shell) { + case 'bash': + await exec(`bash -n ${scriptPath}`); + break; + case 'zsh': + await exec(`zsh -n ${scriptPath}`); + break; + case 'fish': + await exec(`fish -n ${scriptPath}`); + break; + case 'powershell': + // Test PowerShell syntax with timeout to prevent hanging in CI + const testPromise = exec( + `pwsh -NoProfile -Command "& { . '${scriptPath}'; exit 0 }"` + ); + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error('PowerShell test timed out')), + 10000 + ) + ); + + await Promise.race([testPromise, timeoutPromise]); + break; + } + // If we get here, syntax is valid + expect(true).toBe(true); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Provide helpful error message for bash-completion dependency + if (shell === 'bash' && errorMessage.includes('syntax')) { + const helpMessage = ` +Bash script has syntax errors: ${errorMessage} + +This might be due to missing bash-completion dependency. +To fix this, install bash-completion@2: + + brew install bash-completion@2 + +Then source the completion in your shell profile: + echo 'source $(brew --prefix)/share/bash-completion/bash_completion' >> ~/.bashrc +`; + throw new Error(helpMessage); + } + + throw new Error(`${shell} script has syntax errors: ${error}`); + } finally { + // Clean up + await unlink(scriptPath).catch(() => {}); + } + }, + shell === 'powershell' ? 15000 : 5000 + ); // Longer timeout for PowerShell + }); + }); + + // Test shell-specific features + describe('shell-specific functionality', () => { + shells.forEach((shell) => { + switch (shell) { + case 'bash': + it('bash completion should include proper function definitions', async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete bash`; + const { stdout } = await exec(command); + + // Should contain bash completion function + expect(stdout).toMatch(/__\w+_complete\(\)/); + + // Should contain complete command registration + expect(stdout).toMatch(/complete -F __\w+_complete/); + + // Should handle bash completion variables (using _get_comp_words_by_ref) + expect(stdout).toContain('_get_comp_words_by_ref'); + expect(stdout).toContain('cur prev words cword'); + }); + break; + + case 'zsh': + it('zsh completion should include proper compdef', async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete zsh`; + const { stdout } = await exec(command); + + // Should contain compdef directive + expect(stdout).toMatch(/#compdef \w+/); + + // Should contain completion function + expect(stdout).toMatch(/_\w+\(\)/); + + // Should register the completion + expect(stdout).toMatch(/compdef _\w+ \w+/); + }); + break; + + case 'fish': + it('fish completion should include proper complete commands', async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete fish`; + const { stdout } = await exec(command); + + // Should contain fish complete commands + expect(stdout).toContain('complete -c'); + + // Should handle command completion + expect(stdout).toMatch(/complete -c \w+ -f/); + }); + break; + + case 'powershell': + it('powershell completion should include proper functions', async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete powershell`; + const { stdout } = await exec(command); + + // Should contain PowerShell function definition + expect(stdout).toContain('function __'); + + // Should contain Register-ArgumentCompleter + expect(stdout).toContain('Register-ArgumentCompleter'); + + // Should handle PowerShell completion parameters + expect(stdout).toContain('$WordToComplete'); + expect(stdout).toContain('$CommandAst'); + expect(stdout).toContain('$CursorPosition'); + }); + break; + + default: + // For shells without specific tests, add a basic test to avoid empty suites + it(`should generate ${shell} completion script`, async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete ${shell}`; + const { stdout } = await exec(command); + expect(stdout).toBeTruthy(); + expect(stdout).toContain(`# ${shell} completion for`); + }); + break; + } + }); + }); + + // Test for potential bash issues (only run for bash) + if (shells.includes('bash')) { + describe('bash-specific issue detection', () => { + it('should generate bash script with proper syntax (requires bash-completion@2)', async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete bash`; + const { stdout } = await exec(command); + + // Check that it uses the correct ${words[@]:1} syntax + expect(stdout).toContain('${words[@]:1}'); // Should use ${words[@]:1} (requires bash-completion@2) + expect(stdout).toContain('requestComp='); // Should have proper variable assignment + expect(stdout).toContain('complete -F'); // Should register completion properly + expect(stdout).toContain('_get_comp_words_by_ref'); // Should use bash-completion functions + }); + + it('should generate bash script that handles empty parameters correctly', async () => { + const command = `pnpm tsx examples/demo.${cliTool}.ts complete bash`; + const { stdout } = await exec(command); + + // Should handle empty parameters + expect(stdout).toContain(`''`); // Should add empty parameter handling + expect(stdout).toContain('requestComp='); // Should build command properly + }); + }); + } +});