-
Notifications
You must be signed in to change notification settings - Fork 793
Expand file tree
/
Copy pathcommands.test.ts
More file actions
553 lines (490 loc) · 23.3 KB
/
commands.test.ts
File metadata and controls
553 lines (490 loc) · 23.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import * as sinon from 'sinon'
import assert from 'assert'
import { SagemakerClient } from '../../../shared/clients/sagemaker'
import { getTestWindow } from '../../shared/vscode/window'
import {
RemoteAccessRequiredMessage,
InstanceTypeInsufficientMemoryMessage,
} from '../../../awsService/sagemaker/constants'
// Import types only, actual functions will be dynamically imported
import type { openRemoteConnect as openRemoteConnectStatic } from '../../../awsService/sagemaker/commands'
describe('SageMaker Commands', () => {
let sandbox: sinon.SinonSandbox
let mockClient: any
let mockNode: any
beforeEach(() => {
sandbox = sinon.createSandbox()
mockClient = sandbox.createStubInstance(SagemakerClient)
mockNode = {
regionCode: 'us-east-1',
spaceApp: {
DomainId: 'domain-123',
SpaceName: 'test-space',
},
}
})
afterEach(() => {
sandbox.restore()
getTestWindow().dispose()
for (const key of Object.keys(require.cache)) {
if (key.includes('awsService/sagemaker/commands')) {
delete require.cache[key]
}
}
})
describe('openRemoteConnect handler integration tests', () => {
let mockTryRefreshNode: sinon.SinonStub
let mockTryRemoteConnection: sinon.SinonStub
let mockIsRemoteWorkspace: sinon.SinonStub
let openRemoteConnect: typeof openRemoteConnectStatic
beforeEach(() => {
mockNode = {
regionCode: 'us-east-1',
spaceApp: {
DomainId: 'domain-123',
SpaceName: 'test-space',
App: {
AppType: 'JupyterLab',
AppName: 'default',
},
SpaceSettingsSummary: {
AppType: 'JupyterLab',
RemoteAccess: 'DISABLED',
},
},
getStatus: sandbox.stub().returns('Running'),
}
// Mock helper functions
mockTryRefreshNode = sandbox.stub().resolves()
mockTryRemoteConnection = sandbox.stub().resolves()
mockIsRemoteWorkspace = sandbox.stub().returns(false)
sandbox.replace(
require('../../../awsService/sagemaker/explorer/sagemakerSpaceNode'),
'tryRefreshNode',
mockTryRefreshNode
)
sandbox.replace(
require('../../../awsService/sagemaker/model'),
'tryRemoteConnection',
mockTryRemoteConnection
)
sandbox.replace(require('../../../shared/vscode/env'), 'isRemoteWorkspace', mockIsRemoteWorkspace)
const freshModule = require('../../../awsService/sagemaker/commands')
openRemoteConnect = freshModule.openRemoteConnect
})
describe('handleRunningSpaceWithDisabledAccess', () => {
beforeEach(() => {
mockNode.getStatus.returns('Running')
mockNode.spaceApp.SpaceSettingsSummary.RemoteAccess = 'DISABLED'
})
/**
* Test 1: Shows confirmation dialog mentioning "remote access" when instance type is sufficient
*
* Scenario: User tries to connect to a running space that doesn't have remote access enabled,
* but the instance type (ml.t3.large) has sufficient memory for remote access.
*
* Expected behavior:
* - System checks instance type via describeSpace
* - Shows confirmation dialog mentioning only "remote access" (no instance upgrade needed)
* - User confirms, then space is restarted with remote access enabled
* - Connection is established
*/
it('shows confirmation dialog with remote access message when no upgrade needed', async () => {
mockClient.describeSpace.resolves({
$metadata: {},
SpaceSettings: {
AppType: 'JupyterLab',
JupyterLabAppSettings: {
DefaultResourceSpec: {
InstanceType: 'ml.t3.large', // Sufficient memory
},
},
},
})
mockClient.deleteApp.resolves()
mockClient.startSpace.resolves()
mockClient.waitForAppInService.resolves()
// Setup test window to handle confirmation dialog
getTestWindow().onDidShowMessage((message) => {
if (message.message.includes(RemoteAccessRequiredMessage)) {
message.selectItem('Restart Space and Connect')
}
})
await openRemoteConnect(mockNode, {} as any, mockClient)
// Verify describeSpace was called to check instance type
assert(mockClient.describeSpace.calledOnce)
assert(
mockClient.describeSpace.calledWith({
DomainId: 'domain-123',
SpaceName: 'test-space',
})
)
// Verify confirmation dialog was shown
const messages = getTestWindow().shownMessages
assert(messages.length > 0)
const confirmMessage = messages.find((m) => m.message.includes('remote access'))
assert(confirmMessage, 'Should show remote access message')
assert(!confirmMessage.message.includes('ml.t3'), 'Should not mention instance type upgrade')
})
/**
* Test 2: Shows confirmation dialog mentioning instance upgrade when needed
*
* Scenario: User tries to connect to a running space with an instance type (ml.t3.medium)
* that has insufficient memory for remote access.
*
* Expected behavior:
* - System checks instance type via describeSpace
* - Detects ml.t3.medium is insufficient (needs upgrade to ml.t3.large)
* - Dialog includes current type (ml.t3.medium) and target type (ml.t3.large)
* - User confirms, then space is restarted with upgraded instance and remote access
*/
it('shows confirmation dialog with instance upgrade message when upgrade needed', async () => {
mockClient.describeSpace.resolves({
$metadata: {},
SpaceSettings: {
AppType: 'JupyterLab',
JupyterLabAppSettings: {
DefaultResourceSpec: {
InstanceType: 'ml.t3.medium', // Insufficient memory
},
},
},
})
mockClient.deleteApp.resolves()
mockClient.startSpace.resolves()
mockClient.waitForAppInService.resolves()
// Setup test window to handle confirmation dialog
getTestWindow().onDidShowMessage((message) => {
if (
message.message.includes(
InstanceTypeInsufficientMemoryMessage('test-space', 'ml.t3.medium', 'ml.t3.large')
)
) {
message.selectItem('Restart Space and Connect')
}
})
await openRemoteConnect(mockNode, {} as any, mockClient)
// Verify describeSpace was called to check instance type
assert(mockClient.describeSpace.calledOnce)
// Verify confirmation dialog includes instance type upgrade info
const messages = getTestWindow().shownMessages
const expectedMessage = InstanceTypeInsufficientMemoryMessage(
'test-space',
'ml.t3.medium',
'ml.t3.large'
)
const confirmMessage = messages.find((m) => m.message.includes(expectedMessage))
assert(confirmMessage, 'Should show instance upgrade message')
})
/**
* Test 3: Verifies the full workflow when user confirms
*
* Scenario: User confirms the restart dialog for a running space with disabled remote access.
*
* Expected behavior (in order):
* 1. tryRefreshNode() - Refresh node state before starting
* 2. describeSpace() - Check instance type requirements
* 3. Show confirmation dialog
* 4. User confirms
* 5. deleteApp() - Stop the running space
* 6. startSpace() - Restart with remote access enabled (3rd param = true)
* 7. tryRefreshNode() - Refresh node state after restart
* 8. waitForAppInService() - Wait for space to be ready
* 9. tryRemoteConnection() - Establish the remote connection
*/
it('performs space restart and connection when user confirms', async () => {
mockClient.describeSpace.resolves({
$metadata: {},
SpaceSettings: {
AppType: 'JupyterLab',
JupyterLabAppSettings: {
DefaultResourceSpec: {
InstanceType: 'ml.t3.large',
},
},
},
})
mockClient.deleteApp.resolves()
mockClient.startSpace.resolves()
mockClient.waitForAppInService.resolves()
// Setup test window to confirm
getTestWindow().onDidShowMessage((message) => {
if (message.items.some((item) => item.title === 'Restart Space and Connect')) {
message.selectItem('Restart Space and Connect')
}
})
await openRemoteConnect(mockNode, {} as any, mockClient)
// Verify tryRefreshNode was called at the start of openRemoteConnect
assert(mockTryRefreshNode.calledBefore(mockClient.deleteApp))
// Verify space operations were performed in correct order
assert(mockClient.deleteApp.calledOnce)
assert(
mockClient.deleteApp.calledWith({
DomainId: 'domain-123',
SpaceName: 'test-space',
AppType: 'JupyterLab',
AppName: 'default',
})
)
assert(mockClient.startSpace.calledOnce)
assert(mockClient.startSpace.calledWith('test-space', 'domain-123', true)) // Remote access enabled
// Verify tryRefreshNode was called after startSpace
assert(mockTryRefreshNode.calledAfter(mockClient.startSpace))
assert(mockClient.waitForAppInService.calledOnce)
assert(mockClient.waitForAppInService.calledWith('domain-123', 'test-space', 'JupyterLab'))
assert(mockTryRemoteConnection.calledOnce)
})
/**
* Test 4: Verifies nothing happens when user cancels
*
* Scenario: User is shown the confirmation dialog but clicks "Cancel" instead of confirming.
*
* Expected behavior:
* - tryRefreshNode() is called (happens before showing dialog)
* - describeSpace() is called (to check instance type)
* - Confirmation dialog is shown
* - User cancels
* - NO space operations are performed (no deleteApp, startSpace, or connection attempts)
*/
it('does not perform operations when user cancels', async () => {
mockClient.describeSpace.resolves({
$metadata: {},
SpaceSettings: {
AppType: 'JupyterLab',
JupyterLabAppSettings: {
DefaultResourceSpec: {
InstanceType: 'ml.t3.large',
},
},
},
})
// Setup test window to cancel
getTestWindow().onDidShowMessage((message) => {
message.selectItem('Cancel')
})
await openRemoteConnect(mockNode, {} as any, mockClient)
// Verify tryRefreshNode was called (happens before confirmation)
assert(mockTryRefreshNode.calledOnce)
// Verify no space operations were performed after cancellation
assert(mockClient.deleteApp.notCalled)
assert(mockClient.startSpace.notCalled)
assert(mockTryRemoteConnection.notCalled)
})
})
describe('handleStoppedSpace', () => {
beforeEach(() => {
mockNode.getStatus.returns('Stopped')
})
/**
* Test: Starts space and connects without showing confirmation dialog
*
* Scenario: User tries to connect to a stopped space.
*
* Expected behavior:
* - NO confirmation dialog is shown
* - tryRefreshNode() is called at the start
* - startSpace() is called WITHOUT remote access flag (2 params only)
* - tryRefreshNode() is called again after starting
* - waitForAppInService() waits for space to be ready
* - tryRemoteConnection() establishes the connection
*
* Key difference from running space: No confirmation needed because starting
* a stopped space is non-destructive
*/
it('starts space and connects without confirmation', async () => {
mockClient.startSpace.resolves()
mockClient.waitForAppInService.resolves()
await openRemoteConnect(mockNode, {} as any, mockClient)
// Verify no confirmation dialog shown for stopped space
const confirmMessages = getTestWindow().shownMessages.filter((m) =>
m.message.includes('Restart Space and Connect')
)
assert.strictEqual(confirmMessages.length, 0, 'Should not show confirmation for stopped space')
// Verify tryRefreshNode was called at start of openRemoteConnect
assert(mockTryRefreshNode.calledBefore(mockClient.startSpace))
// Verify space operations - startSpace is called before withProgress
assert(mockClient.startSpace.calledOnce)
assert(mockClient.startSpace.calledWith('test-space', 'domain-123')) // No remote access flag
// Verify tryRefreshNode was called after startSpace (before progress)
assert(mockTryRefreshNode.calledAfter(mockClient.startSpace))
assert.strictEqual(mockTryRefreshNode.callCount, 2) // Once at start, once after startSpace
// Verify operations inside progress callback
assert(mockClient.waitForAppInService.calledOnce)
assert(mockClient.waitForAppInService.calledWith('domain-123', 'test-space', 'JupyterLab'))
assert(mockTryRemoteConnection.calledOnce)
})
})
describe('handleRunningSpaceWithEnabledAccess', () => {
beforeEach(() => {
mockNode.getStatus.returns('Running')
mockNode.spaceApp.SpaceSettingsSummary.RemoteAccess = 'ENABLED'
})
/**
* Test: Connects directly without any space operations
*
* Scenario: User tries to connect to a running space that already has remote access enabled.
*
* Expected behavior:
* - tryRefreshNode() is called once at the start
* - NO confirmation dialog is shown (space is already configured correctly)
* - NO space operations are performed:
* - No deleteApp() (no need to stop)
* - No startSpace() (already running)
* - No waitForAppInService() (already ready)
* - ONLY tryRemoteConnection() is called to establish the connection
*
* This is the "happy path" - space is ready, just connect directly.
*/
it('connects directly without any space operations', async () => {
await openRemoteConnect(mockNode, {} as any, mockClient)
// Verify tryRefreshNode was called at start
assert(mockTryRefreshNode.calledOnce)
// Verify no confirmation needed
const confirmMessages = getTestWindow().shownMessages.filter((m) =>
m.message.includes('Restart Space and Connect')
)
assert.strictEqual(confirmMessages.length, 0)
// Verify no space operations performed
assert(mockClient.deleteApp.notCalled)
assert(mockClient.startSpace.notCalled)
assert(mockClient.waitForAppInService.notCalled)
// Only remote connection should be attempted
assert(mockTryRemoteConnection.calledOnce)
})
})
})
describe('HyperPod connection with eksClusterArn', function () {
let mockDeeplinkConnect: sinon.SinonStub
let mockIsRemoteWorkspace: sinon.SinonStub
let deeplinkConnect: any
beforeEach(function () {
mockDeeplinkConnect = sandbox.stub().resolves()
mockIsRemoteWorkspace = sandbox.stub().returns(false)
sandbox.replace(require('../../../shared/vscode/env'), 'isRemoteWorkspace', mockIsRemoteWorkspace)
const freshModule = require('../../../awsService/sagemaker/commands')
deeplinkConnect = freshModule.deeplinkConnect
sandbox.replace(freshModule, 'deeplinkConnect', mockDeeplinkConnect)
})
it('should create session with underscores from HyperPod eksClusterArn', async function () {
const ctx = {
extensionContext: {},
} as any
await deeplinkConnect(
ctx,
'',
'session-id',
'wss://example.com',
'token',
'',
undefined,
'demo0',
'default',
'arn:aws:sagemaker:us-east-2:123456789012:cluster/n4nkkc5fbwg5'
)
// Verify the session format uses underscores
const sessionArg = mockDeeplinkConnect.firstCall?.args[10] // session parameter
if (sessionArg) {
assert.ok(sessionArg.includes('_'), 'Session should use underscores as separators')
assert.ok(sessionArg.includes('demo0'), 'Session should include workspace name')
assert.ok(sessionArg.includes('default'), 'Session should include namespace')
assert.ok(sessionArg.includes('n4nkkc5fbwg5'), 'Session should include cluster name')
assert.ok(sessionArg.includes('us-east-2'), 'Session should include region')
assert.ok(sessionArg.includes('123456789012'), 'Session should include account ID')
}
})
it('should handle EKS eksClusterArn format', async function () {
const ctx = {
extensionContext: {},
} as any
await deeplinkConnect(
ctx,
'',
'session-id',
'wss://example.com',
'token',
'',
undefined,
'workspace',
'namespace',
'arn:aws:eks:us-west-2:987654321098:cluster/eks-cluster-name'
)
const sessionArg = mockDeeplinkConnect.firstCall?.args[10]
if (sessionArg) {
assert.ok(sessionArg.includes('eks-cluster-name'), 'Session should include EKS cluster name')
assert.ok(sessionArg.includes('us-west-2'), 'Session should include region')
assert.ok(sessionArg.includes('987654321098'), 'Session should include account ID')
}
})
it('should sanitize invalid characters in session components', async function () {
const ctx = {
extensionContext: {},
} as any
await deeplinkConnect(
ctx,
'',
'session-id',
'wss://example.com',
'token',
'',
undefined,
'My@Workspace!',
'my_namespace',
'arn:aws:sagemaker:us-east-2:123456789012:cluster/test-cluster'
)
const sessionArg = mockDeeplinkConnect.firstCall?.args[10]
if (sessionArg) {
assert.ok(!sessionArg.includes('@'), 'Session should not contain @ symbol')
assert.ok(!sessionArg.includes('!'), 'Session should not contain ! symbol')
assert.strictEqual(sessionArg, sessionArg.toLowerCase(), 'Session should be lowercase')
}
})
it('should handle long component names by truncating to 253 chars', async function () {
const ctx = {
extensionContext: {},
} as any
const longWorkspace = 'a'.repeat(100)
const longNamespace = 'b'.repeat(100)
const longCluster = 'c'.repeat(100)
await deeplinkConnect(
ctx,
'',
'session-id',
'wss://example.com',
'token',
'',
undefined,
longWorkspace,
longNamespace,
`arn:aws:sagemaker:us-east-2:123456789012:cluster/${longCluster}`
)
const sessionArg = mockDeeplinkConnect.firstCall?.args[10]
if (sessionArg) {
assert.ok(sessionArg.length <= 253, 'Session should not exceed 253 char max length')
}
})
it('should not create HyperPod session when domain is provided', async function () {
const ctx = {
extensionContext: {},
} as any
await deeplinkConnect(
ctx,
'connection-id',
'session-id',
'wss://example.com',
'token',
'my-domain', // Domain provided - should use SageMaker Studio flow
undefined,
'workspace',
'namespace',
'arn:aws:sagemaker:us-east-2:123456789012:cluster/cluster'
)
// Should not create HyperPod session when domain is present
const sessionArg = mockDeeplinkConnect.firstCall?.args[10]
assert.strictEqual(sessionArg, 'session-id', 'Should use original session when domain is provided')
})
})
})