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
+ } )