1+ import { describe , it , expect , vi , beforeEach } from "vitest"
2+ import * as vscode from "vscode"
3+ import { Commands } from "./commands"
4+ import { Storage } from "./storage"
5+ import { Api } from "coder/site/src/api/api"
6+ import { User , Workspace } from "coder/site/src/api/typesGenerated"
7+ import * as apiModule from "./api"
8+ import { CertificateError } from "./error"
9+ import { getErrorMessage } from "coder/site/src/api/errors"
10+
11+ // Mock vscode module
12+ vi . mock ( "vscode" , ( ) => ( {
13+ commands :{
14+ executeCommand :vi . fn ( ) ,
15+ } ,
16+ window :{
17+ showInputBox :vi . fn ( ) ,
18+ showErrorMessage :vi . fn ( ) ,
19+ showInformationMessage :vi . fn ( ) . mockResolvedValue ( undefined ) ,
20+ createQuickPick :vi . fn ( ) ,
21+ showQuickPick :vi . fn ( ) ,
22+ createTerminal :vi . fn ( ) ,
23+ withProgress :vi . fn ( ) ,
24+ showTextDocument :vi . fn ( ) ,
25+ } ,
26+ workspace :{
27+ getConfiguration :vi . fn ( ) ,
28+ openTextDocument :vi . fn ( ) ,
29+ workspaceFolders :[ ] ,
30+ } ,
31+ Uri :{
32+ parse :vi . fn ( ) . mockReturnValue ( { toString :( ) => "parsed-uri" } ) ,
33+ file :vi . fn ( ) . mockReturnValue ( { toString :( ) => "file-uri" } ) ,
34+ from :vi . fn ( ) . mockImplementation ( ( options :any ) => ( {
35+ scheme :options . scheme ,
36+ authority :options . authority ,
37+ path :options . path ,
38+ toString :( ) => `${ options . scheme } ://${ options . authority } ${ options . path } ` ,
39+ } ) ) ,
40+ } ,
41+ env :{
42+ openExternal :vi . fn ( ) . mockResolvedValue ( undefined ) ,
43+ } ,
44+ ProgressLocation :{
45+ Notification :15 ,
46+ } ,
47+ InputBoxValidationSeverity :{
48+ Error :3 ,
49+ } ,
50+ } ) )
51+
52+ // Mock dependencies
53+ vi . mock ( "./api" , ( ) => ( {
54+ makeCoderSdk :vi . fn ( ) ,
55+ needToken :vi . fn ( ) ,
56+ } ) )
57+
58+ vi . mock ( "./error" , ( ) => ( {
59+ CertificateError :vi . fn ( ) ,
60+ } ) )
61+
62+ vi . mock ( "coder/site/src/api/errors" , ( ) => ( {
63+ getErrorMessage :vi . fn ( ) ,
64+ } ) )
65+
66+ vi . mock ( "./storage" , ( ) => ( {
67+ Storage :vi . fn ( ) ,
68+ } ) )
69+
70+ vi . mock ( "./util" , ( ) => ( {
71+ toRemoteAuthority :vi . fn ( ( baseUrl :string , owner :string , name :string , agent ?:string ) => {
72+ const host = baseUrl . replace ( "https://" , "" ) . replace ( "http://" , "" )
73+ return `coder-${ host } -${ owner } -${ name } ${ agent ?`-${ agent } ` :"" } `
74+ } ) ,
75+ toSafeHost :vi . fn ( ( url :string ) => url . replace ( "https://" , "" ) . replace ( "http://" , "" ) ) ,
76+ } ) )
77+
78+ describe ( "Commands" , ( ) => {
79+ let commands :Commands
80+ let mockVscodeProposed :typeof vscode
81+ let mockRestClient :Api
82+ let mockStorage :Storage
83+ let mockQuickPick :any
84+ let mockTerminal :any
85+
86+ beforeEach ( ( ) => {
87+ vi . clearAllMocks ( )
88+
89+ mockVscodeProposed = vscode as any
90+
91+ mockRestClient = {
92+ setHost :vi . fn ( ) ,
93+ setSessionToken :vi . fn ( ) ,
94+ getAuthenticatedUser :vi . fn ( ) ,
95+ getWorkspaces :vi . fn ( ) ,
96+ updateWorkspaceVersion :vi . fn ( ) ,
97+ getAxiosInstance :vi . fn ( ( ) => ( {
98+ defaults :{
99+ baseURL :"https://coder.example.com" ,
100+ } ,
101+ } ) ) ,
102+ } as any
103+
104+ mockStorage = {
105+ getUrl :vi . fn ( ( ) => "https://coder.example.com" ) ,
106+ setUrl :vi . fn ( ) ,
107+ getSessionToken :vi . fn ( ) ,
108+ setSessionToken :vi . fn ( ) ,
109+ configureCli :vi . fn ( ) ,
110+ withUrlHistory :vi . fn ( ( ) => [ "https://coder.example.com" ] ) ,
111+ fetchBinary :vi . fn ( ) ,
112+ getSessionTokenPath :vi . fn ( ) ,
113+ writeToCoderOutputChannel :vi . fn ( ) ,
114+ } as any
115+
116+ mockQuickPick = {
117+ value :"" ,
118+ placeholder :"" ,
119+ title :"" ,
120+ items :[ ] ,
121+ busy :false ,
122+ show :vi . fn ( ) ,
123+ dispose :vi . fn ( ) ,
124+ onDidHide :vi . fn ( ) ,
125+ onDidChangeValue :vi . fn ( ) ,
126+ onDidChangeSelection :vi . fn ( ) ,
127+ }
128+
129+ mockTerminal = {
130+ sendText :vi . fn ( ) ,
131+ show :vi . fn ( ) ,
132+ }
133+
134+ vi . mocked ( vscode . window . createQuickPick ) . mockReturnValue ( mockQuickPick )
135+ vi . mocked ( vscode . window . createTerminal ) . mockReturnValue ( mockTerminal )
136+ vi . mocked ( vscode . workspace . getConfiguration ) . mockReturnValue ( {
137+ get :vi . fn ( ( ) => "" ) ,
138+ } as any )
139+
140+ // Default mock for vscode.commands.executeCommand
141+ vi . mocked ( vscode . commands . executeCommand ) . mockImplementation ( async ( command :string ) => {
142+ if ( command === "_workbench.getRecentlyOpened" ) {
143+ return { workspaces :[ ] }
144+ }
145+ return undefined
146+ } )
147+
148+ commands = new Commands ( mockVscodeProposed , mockRestClient , mockStorage )
149+ } )
150+
151+ describe ( "basic Commands functionality" , ( ) => {
152+ const mockUser :User = {
153+ id :"user-1" ,
154+ username :"testuser" ,
155+ roles :[ { name :"owner" } ] ,
156+ } as User
157+
158+ beforeEach ( ( ) => {
159+ vi . mocked ( apiModule . makeCoderSdk ) . mockResolvedValue ( mockRestClient )
160+ vi . mocked ( apiModule . needToken ) . mockReturnValue ( true )
161+ vi . mocked ( mockRestClient . getAuthenticatedUser ) . mockResolvedValue ( mockUser )
162+ vi . mocked ( getErrorMessage ) . mockReturnValue ( "Test error" )
163+ } )
164+
165+ it ( "should login with provided URL and token" , async ( ) => {
166+ vi . mocked ( vscode . window . showInputBox ) . mockImplementation ( async ( options :any ) => {
167+ if ( options . validateInput ) {
168+ await options . validateInput ( "test-token" )
169+ }
170+ return "test-token"
171+ } )
172+ vi . mocked ( vscode . window . showInformationMessage ) . mockResolvedValue ( undefined )
173+ vi . mocked ( vscode . env . openExternal ) . mockResolvedValue ( true )
174+
175+ await commands . login ( "https://coder.example.com" , "test-token" )
176+
177+ expect ( mockRestClient . setHost ) . toHaveBeenCalledWith ( "https://coder.example.com" )
178+ expect ( mockRestClient . setSessionToken ) . toHaveBeenCalledWith ( "test-token" )
179+ } )
180+
181+ it ( "should logout successfully" , async ( ) => {
182+ vi . mocked ( vscode . window . showInformationMessage ) . mockResolvedValue ( undefined )
183+
184+ await commands . logout ( )
185+
186+ expect ( mockRestClient . setHost ) . toHaveBeenCalledWith ( "" )
187+ expect ( mockRestClient . setSessionToken ) . toHaveBeenCalledWith ( "" )
188+ } )
189+
190+ it ( "should view logs when path is set" , async ( ) => {
191+ const logPath = "/tmp/workspace.log"
192+ const mockUri = { toString :( ) => `file://${ logPath } ` }
193+ const mockDoc = { fileName :logPath }
194+
195+ commands . workspaceLogPath = logPath
196+ vi . mocked ( vscode . Uri . file ) . mockReturnValue ( mockUri as any )
197+ vi . mocked ( vscode . workspace . openTextDocument ) . mockResolvedValue ( mockDoc as any )
198+
199+ await commands . viewLogs ( )
200+
201+ expect ( vscode . Uri . file ) . toHaveBeenCalledWith ( logPath )
202+ expect ( vscode . workspace . openTextDocument ) . toHaveBeenCalledWith ( mockUri )
203+ } )
204+ } )
205+
206+ describe ( "workspace operations" , ( ) => {
207+ const mockTreeItem = {
208+ workspaceOwner :"testuser" ,
209+ workspaceName :"testworkspace" ,
210+ workspaceAgent :"main" ,
211+ workspaceFolderPath :"/workspace" ,
212+ }
213+
214+ it ( "should open workspace from sidebar" , async ( ) => {
215+ await commands . openFromSidebar ( mockTreeItem as any )
216+
217+ // Should call _workbench.getRecentlyOpened first, then vscode.openFolder
218+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith ( "_workbench.getRecentlyOpened" )
219+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith (
220+ "vscode.openFolder" ,
221+ expect . objectContaining ( {
222+ scheme :"vscode-remote" ,
223+ path :"/workspace" ,
224+ } ) ,
225+ false // newWindow is false when no workspace folders exist
226+ )
227+ } )
228+
229+ it ( "should open workspace with direct arguments" , async ( ) => {
230+ await commands . open ( "testuser" , "testworkspace" , undefined , "/custom/path" , false )
231+
232+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith (
233+ "vscode.openFolder" ,
234+ expect . objectContaining ( {
235+ scheme :"vscode-remote" ,
236+ path :"/custom/path" ,
237+ } ) ,
238+ false
239+ )
240+ } )
241+
242+ it ( "should open dev container" , async ( ) => {
243+ await commands . openDevContainer ( "testuser" , "testworkspace" , undefined , "mycontainer" , "/container/path" )
244+
245+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith (
246+ "vscode.openFolder" ,
247+ expect . objectContaining ( {
248+ scheme :"vscode-remote" ,
249+ authority :expect . stringContaining ( "attached-container+" ) ,
250+ path :"/container/path" ,
251+ } ) ,
252+ false
253+ )
254+ } )
255+
256+ it ( "should use first recent workspace when openRecent=true with multiple workspaces" , async ( ) => {
257+ const recentWorkspaces = {
258+ workspaces :[
259+ {
260+ folderUri :{
261+ authority :"coder-coder.example.com-testuser-testworkspace-main" ,
262+ path :"/recent/path1" ,
263+ } ,
264+ } ,
265+ {
266+ folderUri :{
267+ authority :"coder-coder.example.com-testuser-testworkspace-main" ,
268+ path :"/recent/path2" ,
269+ } ,
270+ } ,
271+ ] ,
272+ }
273+
274+ vi . mocked ( vscode . commands . executeCommand ) . mockImplementation ( async ( command :string ) => {
275+ if ( command === "_workbench.getRecentlyOpened" ) {
276+ return recentWorkspaces
277+ }
278+ return undefined
279+ } )
280+
281+ const treeItemWithoutPath = {
282+ ...mockTreeItem ,
283+ workspaceFolderPath :undefined ,
284+ }
285+
286+ await commands . openFromSidebar ( treeItemWithoutPath as any )
287+
288+ // openFromSidebar passes openRecent=true, so with multiple recent workspaces it should use the first one
289+ expect ( vscode . window . showQuickPick ) . not . toHaveBeenCalled ( )
290+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith (
291+ "vscode.openFolder" ,
292+ expect . objectContaining ( {
293+ scheme :"vscode-remote" ,
294+ path :"/recent/path1" ,
295+ } ) ,
296+ false
297+ )
298+ } )
299+
300+ it ( "should use single recent workspace automatically" , async ( ) => {
301+ const recentWorkspaces = {
302+ workspaces :[
303+ {
304+ folderUri :{
305+ authority :"coder-coder.example.com-testuser-testworkspace-main" ,
306+ path :"/recent/single" ,
307+ } ,
308+ } ,
309+ ] ,
310+ }
311+
312+ vi . mocked ( vscode . commands . executeCommand ) . mockImplementation ( async ( command :string ) => {
313+ if ( command === "_workbench.getRecentlyOpened" ) {
314+ return recentWorkspaces
315+ }
316+ return undefined
317+ } )
318+
319+ const treeItemWithoutPath = {
320+ ...mockTreeItem ,
321+ workspaceFolderPath :undefined ,
322+ }
323+
324+ await commands . openFromSidebar ( treeItemWithoutPath as any )
325+
326+ expect ( vscode . window . showQuickPick ) . not . toHaveBeenCalled ( )
327+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith (
328+ "vscode.openFolder" ,
329+ expect . objectContaining ( {
330+ path :"/recent/single" ,
331+ } ) ,
332+ false
333+ )
334+ } )
335+
336+ it ( "should open new window when no folder path available" , async ( ) => {
337+ const recentWorkspaces = { workspaces :[ ] }
338+
339+ vi . mocked ( vscode . commands . executeCommand ) . mockImplementation ( async ( command :string ) => {
340+ if ( command === "_workbench.getRecentlyOpened" ) {
341+ return recentWorkspaces
342+ }
343+ return undefined
344+ } )
345+
346+ const treeItemWithoutPath = {
347+ ...mockTreeItem ,
348+ workspaceFolderPath :undefined ,
349+ }
350+
351+ await commands . openFromSidebar ( treeItemWithoutPath as any )
352+
353+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith ( "vscode.newWindow" , {
354+ remoteAuthority :"coder-coder.example.com-testuser-testworkspace-main" ,
355+ reuseWindow :true ,
356+ } )
357+ } )
358+
359+ it ( "should use new window when workspace folders exist" , async ( ) => {
360+ vi . mocked ( vscode . workspace ) . workspaceFolders = [ { uri :{ path :"/existing" } } ] as any
361+
362+ await commands . openDevContainer ( "testuser" , "testworkspace" , undefined , "mycontainer" , "/container/path" )
363+
364+ expect ( vscode . commands . executeCommand ) . toHaveBeenCalledWith (
365+ "vscode.openFolder" ,
366+ expect . anything ( ) ,
367+ true
368+ )
369+ } )
370+
371+ } )
372+
373+ describe ( "error handling" , ( ) => {
374+ it ( "should throw error if not logged in for openFromSidebar" , async ( ) => {
375+ vi . mocked ( mockRestClient . getAxiosInstance ) . mockReturnValue ( {
376+ defaults :{ baseURL :undefined } ,
377+ } as any )
378+
379+ const mockTreeItem = {
380+ workspaceOwner :"testuser" ,
381+ workspaceName :"testworkspace" ,
382+ }
383+
384+ await expect ( commands . openFromSidebar ( mockTreeItem as any ) ) . rejects . toThrow (
385+ "You are not logged in"
386+ )
387+ } )
388+
389+ it ( "should call open() method when no tree item provided to openFromSidebar" , async ( ) => {
390+ const openSpy = vi . spyOn ( commands , "open" ) . mockResolvedValue ( )
391+
392+ await commands . openFromSidebar ( null as any )
393+
394+ expect ( openSpy ) . toHaveBeenCalled ( )
395+ openSpy . mockRestore ( )
396+ } )
397+ } )
398+ } )