1+ import { execFile , spawnSync } from 'node:child_process'
2+ import { chmod , mkdtemp , rm , writeFile } from 'node:fs/promises'
3+ import { tmpdir } from 'node:os'
4+ import { join } from 'node:path'
15import { Cli , Completions , z } from 'incur'
26
37const originalIsTTY = process . stdout . isTTY
@@ -15,6 +19,45 @@ vi.mock('./SyncSkills.js', async (importOriginal) => {
1519 return { ...actual , readHash : ( ) => undefined }
1620} )
1721
22+ function hasShell ( shell : string ) : boolean {
23+ return spawnSync ( shell , [ '-c' , ':' ] , { stdio : 'ignore' } ) . status === 0
24+ }
25+
26+ const bash = hasShell ( 'bash' )
27+ const zsh = hasShell ( 'zsh' )
28+ const fish = hasShell ( 'fish' )
29+
30+ function exec ( cmd : string , args : string [ ] , env : NodeJS . ProcessEnv ) : Promise < string > {
31+ return new Promise ( ( resolve , reject ) => {
32+ execFile ( cmd , args , { env, timeout : 30_000 } , ( error , stdout , stderr ) => {
33+ if ( error ) reject ( new Error ( stderr ?. trim ( ) || stdout ?. trim ( ) || error . message ) )
34+ else resolve ( stdout )
35+ } )
36+ } )
37+ }
38+
39+ async function withFakeCli ( run : ( dir : string ) => Promise < void > ) {
40+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'incur-completions-' ) )
41+ const bin = join ( dir , 'fake-cli' )
42+
43+ try {
44+ await writeFile (
45+ bin ,
46+ `#!/bin/sh
47+ if [ -n "$COMPLETE" ]; then
48+ printf '%s' "$COMPLETE:\${_COMPLETE_INDEX:-missing}"
49+ else
50+ printf 'missing'
51+ fi
52+ ` ,
53+ )
54+ await chmod ( bin , 0o755 )
55+ await run ( dir )
56+ } finally {
57+ await rm ( dir , { recursive : true , force : true } )
58+ }
59+ }
60+
1861async function serve (
1962 cli : { serve : Cli . Cli [ 'serve' ] } ,
2063 argv : string [ ] ,
@@ -231,27 +274,79 @@ describe('register', () => {
231274 test ( 'bash: generates complete -F script with nospace support' , ( ) => {
232275 const script = Completions . register ( 'bash' , 'mycli' )
233276 expect ( script ) . toContain ( '_incur_complete_mycli()' )
234- expect ( script ) . toContain ( 'COMPLETE="bash"' )
277+ expect ( script ) . toContain ( 'export COMPLETE="bash"' )
235278 expect ( script ) . toContain ( 'complete -o default -o bashdefault -o nosort -F' )
236279 expect ( script ) . toContain ( '"mycli" -- "${COMP_WORDS[@]}"' )
237280 expect ( script ) . toContain ( 'compopt -o nospace' )
238281 } )
239282
283+ test . skipIf ( ! bash ) ( 'bash: exports completion env vars to the CLI subprocess' , async ( ) => {
284+ await withFakeCli ( async ( dir ) => {
285+ const output = await exec (
286+ 'bash' ,
287+ [
288+ '-lc' ,
289+ `${ Completions . register ( 'bash' , 'fake-cli' ) }
290+ COMP_WORDS=('fake-cli' 'build' '')
291+ COMP_CWORD=2
292+ _incur_complete_fake_cli
293+ printf '%s' "\${COMPREPLY[*]}"` ,
294+ ] ,
295+ { ...process . env , PATH : `${ dir } :${ process . env . PATH ?? '' } ` } ,
296+ )
297+
298+ expect ( output ) . toBe ( 'bash:2' )
299+ } )
300+ } )
301+
240302 test ( 'zsh: generates compdef script' , ( ) => {
241303 const script = Completions . register ( 'zsh' , 'mycli' )
242304 expect ( script ) . toContain ( '#compdef mycli' )
243- expect ( script ) . toContain ( 'COMPLETE="zsh"' )
305+ expect ( script ) . toContain ( 'export COMPLETE="zsh"' )
244306 expect ( script ) . toContain ( 'compdef _incur_complete_mycli mycli' )
245307 expect ( script ) . toContain ( '_describe' )
246308 } )
247309
310+ test . skipIf ( ! zsh ) ( 'zsh: exports completion env vars to the CLI subprocess' , async ( ) => {
311+ await withFakeCli ( async ( dir ) => {
312+ const output = await exec (
313+ 'zsh' ,
314+ [
315+ '-lc' ,
316+ `compdef() { : }
317+ _describe() { print -r -- "\${(j:|:)\${(@P)2}}" }
318+ ${ Completions . register ( 'zsh' , 'fake-cli' ) }
319+ words=('fake-cli' 'build' '')
320+ CURRENT=3
321+ _incur_complete_fake_cli` ,
322+ ] ,
323+ { ...process . env , PATH : `${ dir } :${ process . env . PATH ?? '' } ` } ,
324+ )
325+
326+ expect ( output . trim ( ) ) . toBe ( 'zsh:2' )
327+ } )
328+ } )
329+
248330 test ( 'fish: generates complete command' , ( ) => {
249331 const script = Completions . register ( 'fish' , 'mycli' )
250332 expect ( script ) . toContain ( 'complete --keep-order --exclusive --command mycli' )
251333 expect ( script ) . toContain ( 'COMPLETE=fish' )
252334 expect ( script ) . toContain ( 'commandline --current-token' )
253335 } )
254336
337+ test . skipIf ( ! fish ) ( 'fish: passes completion env vars to the CLI subprocess' , async ( ) => {
338+ await withFakeCli ( async ( dir ) => {
339+ const output = await exec (
340+ 'fish' ,
341+ [ '-c' , `${ Completions . register ( 'fish' , 'fake-cli' ) }
342+ complete --do-complete 'fake-cli '` ] ,
343+ { ...process . env , PATH : `${ dir } :${ process . env . PATH ?? '' } ` } ,
344+ )
345+
346+ expect ( output . trim ( ) ) . toBe ( 'fish:missing' )
347+ } )
348+ } )
349+
255350 test ( 'nushell: generates external completer closure' , ( ) => {
256351 const script = Completions . register ( 'nushell' , 'mycli' )
257352 expect ( script ) . toContain ( 'COMPLETE=nushell' )
0 commit comments