11// Doctor completion tests cover final doctor status summaries and completion messaging.
22import fs from "node:fs/promises" ;
3- import os from "node:os" ;
43import path from "node:path" ;
5- import { afterEach , describe , expect , it } from "vitest" ;
4+ import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
5+ import * as noteModule from "../../packages/terminal-core/src/note.js" ;
6+ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js" ;
67import { captureEnv , setTestEnvValue } from "../test-utils/env.js" ;
78import {
89 checkShellCompletionStatus ,
10+ doctorShellCompletion ,
911 shellCompletionStatusToHealthFindings ,
1012 shellCompletionStatusToRepairEffects ,
1113 type ShellCompletionStatus ,
1214} from "./doctor-completion.js" ;
1315
1416const originalEnv = captureEnv ( [ "HOME" , "OPENCLAW_STATE_DIR" , "SHELL" ] ) ;
15- const tempDirs : string [ ] = [ ] ;
17+ const tempDirs = useAutoCleanupTempDirTracker ( afterEach ) ;
1618
1719afterEach ( async ( ) => {
1820 originalEnv . restore ( ) ;
19- for ( const dir of tempDirs . splice ( 0 ) ) {
20- await fs . rm ( dir , { recursive : true , force : true } ) ;
21- }
21+ vi . restoreAllMocks ( ) ;
2222} ) ;
2323
2424function status ( overrides : Partial < ShellCompletionStatus > = { } ) : ShellCompletionStatus {
@@ -34,9 +34,8 @@ function status(overrides: Partial<ShellCompletionStatus> = {}): ShellCompletion
3434
3535describe ( "shell completion health mapping" , ( ) => {
3636 it ( "checks an explicit shell instead of the detected environment shell" , async ( ) => {
37- const homeDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-completion-home-" ) ) ;
38- const stateDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-completion-state-" ) ) ;
39- tempDirs . push ( homeDir , stateDir ) ;
37+ const homeDir = tempDirs . make ( "openclaw-completion-home-" ) ;
38+ const stateDir = tempDirs . make ( "openclaw-completion-state-" ) ;
4039 setTestEnvValue ( "HOME" , homeDir ) ;
4140 setTestEnvValue ( "OPENCLAW_STATE_DIR" , stateDir ) ;
4241 setTestEnvValue ( "SHELL" , "/bin/zsh" ) ;
@@ -102,3 +101,101 @@ describe("shell completion health mapping", () => {
102101 expect ( shellCompletionStatusToRepairEffects ( current ) ) . toEqual ( [ ] ) ;
103102 } ) ;
104103} ) ;
104+
105+ const installCompletionMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
106+ const spawnSyncMock = vi . hoisted ( ( ) => vi . fn ( ( ) => ( { status : 0 } ) ) ) ;
107+ vi . mock ( "node:child_process" , ( ) => ( { spawnSync : spawnSyncMock } ) ) ;
108+ vi . mock ( "../cli/completion-runtime.js" , async ( importOriginal ) => {
109+ const actual = await importOriginal < typeof import ( "../cli/completion-runtime.js" ) > ( ) ;
110+ return {
111+ ...actual ,
112+ installCompletion : installCompletionMock ,
113+ } ;
114+ } ) ;
115+
116+ function mockPrompter ( confirmValue = true ) {
117+ return {
118+ confirm : vi . fn ( async ( ) => confirmValue ) ,
119+ confirmAutoFix : vi . fn ( async ( ) => confirmValue ) ,
120+ confirmAggressiveAutoFix : vi . fn ( async ( ) => confirmValue ) ,
121+ confirmRuntimeRepair : vi . fn ( async ( ) => confirmValue ) ,
122+ select : vi . fn ( async ( _params , fallback ) => fallback ) ,
123+ shouldRepair : true ,
124+ shouldForce : false ,
125+ repairMode : {
126+ shouldRepair : true ,
127+ shouldForce : false ,
128+ nonInteractive : false ,
129+ canPrompt : true ,
130+ updateInProgress : false ,
131+ } ,
132+ } as never ;
133+ }
134+
135+ async function setupDoctorCompletionTest ( usesSlowPattern : boolean ) {
136+ const homeDir = tempDirs . make ( "openclaw-doctor-home-" ) ;
137+ const stateDir = tempDirs . make ( "openclaw-doctor-state-" ) ;
138+ setTestEnvValue ( "HOME" , homeDir ) ;
139+ setTestEnvValue ( "OPENCLAW_STATE_DIR" , stateDir ) ;
140+ setTestEnvValue ( "SHELL" , "/bin/bash" ) ;
141+
142+ const profilePath = path . join ( homeDir , usesSlowPattern ? ".bashrc" : ".bash_profile" ) ;
143+ if ( usesSlowPattern ) {
144+ await fs . writeFile (
145+ profilePath ,
146+ '# test bashrc\n[ -f "/tmp/nonexistent" ] && source <(openclaw completion bash)\n' ,
147+ "utf-8" ,
148+ ) ;
149+ const cacheDir = path . join ( stateDir , "completions" ) ;
150+ await fs . mkdir ( cacheDir , { recursive : true } ) ;
151+ await fs . writeFile ( path . join ( cacheDir , "openclaw.bash" ) , "# completion cache\n" , "utf-8" ) ;
152+ }
153+ return profilePath ;
154+ }
155+
156+ function wrappedFsError ( code : string , profilePath : string ) : Error {
157+ const cause = Object . assign ( new Error ( `${ code } : profile write failed` ) , {
158+ code,
159+ path : profilePath ,
160+ } ) ;
161+ return new Error ( `Failed to install completion: ${ cause . message } ` , { cause } ) ;
162+ }
163+
164+ describe ( "doctorShellCompletion" , ( ) => {
165+ beforeEach ( ( ) => {
166+ installCompletionMock . mockReset ( ) ;
167+ spawnSyncMock . mockClear ( ) ;
168+ } ) ;
169+
170+ it . each ( [
171+ { code : "EACCES" , usesSlowPattern : true , action : "upgraded" } ,
172+ { code : "EPERM" , usesSlowPattern : true , action : "upgraded" } ,
173+ { code : "EROFS" , usesSlowPattern : true , action : "upgraded" } ,
174+ { code : "EACCES" , usesSlowPattern : false , action : "installed" } ,
175+ { code : "EPERM" , usesSlowPattern : false , action : "installed" } ,
176+ { code : "EROFS" , usesSlowPattern : false , action : "installed" } ,
177+ ] ) ( "keeps $action completion best-effort for wrapped $code errors" , async ( testCase ) => {
178+ const profilePath = await setupDoctorCompletionTest ( testCase . usesSlowPattern ) ;
179+ installCompletionMock . mockRejectedValue ( wrappedFsError ( testCase . code , profilePath ) ) ;
180+ const noteSpy = vi . spyOn ( noteModule , "note" ) ;
181+
182+ await expect ( doctorShellCompletion ( { } as never , mockPrompter ( ) ) ) . resolves . not . toThrow ( ) ;
183+
184+ expect ( noteSpy ) . toHaveBeenCalledWith (
185+ expect . stringMatching (
186+ new RegExp (
187+ `Shell completion not ${ testCase . action } : .* is not writable.*completion --install` ,
188+ ) ,
189+ ) ,
190+ "Shell completion" ,
191+ ) ;
192+ expect ( noteSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( profilePath ) , "Shell completion" ) ;
193+ } ) ;
194+
195+ it ( "re-throws non-permission errors from installCompletion" , async ( ) => {
196+ const profilePath = await setupDoctorCompletionTest ( true ) ;
197+ installCompletionMock . mockRejectedValue ( wrappedFsError ( "ENOSPC" , profilePath ) ) ;
198+
199+ await expect ( doctorShellCompletion ( { } as never , mockPrompter ( ) ) ) . rejects . toThrow ( "ENOSPC" ) ;
200+ } ) ;
201+ } ) ;
0 commit comments