diff --git a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerDevSpaceNode.test.ts b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerDevSpaceNode.test.ts new file mode 100644 index 00000000000..44bd8537bb9 --- /dev/null +++ b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerDevSpaceNode.test.ts @@ -0,0 +1,219 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as sinon from 'sinon' +import * as assert from 'assert' +import { SagemakerDevSpaceNode } from '../../../../awsService/sagemaker/explorer/sagemakerDevSpaceNode' +import { SagemakerHyperpodNode } from '../../../../awsService/sagemaker/explorer/sagemakerHyperpodNode' +import { HyperpodDevSpace, HyperpodCluster, KubectlClient } from '../../../../shared/clients/kubectlClient' +import { SagemakerClient } from '../../../../shared/clients/sagemaker' + +describe('SagemakerDevSpaceNode', function () { + let testNode: SagemakerDevSpaceNode + let mockParent: SagemakerHyperpodNode + let mockKubectlClient: sinon.SinonStubbedInstance + let mockDevSpace: HyperpodDevSpace + let mockHyperpodCluster: HyperpodCluster + let mockSagemakerClient: sinon.SinonStubbedInstance + const testRegion = 'us-east-1' + + beforeEach(function () { + mockSagemakerClient = sinon.createStubInstance(SagemakerClient) + mockParent = new SagemakerHyperpodNode(testRegion, mockSagemakerClient as any) + mockKubectlClient = sinon.createStubInstance(KubectlClient) + mockDevSpace = { + name: 'test-space', + namespace: 'test-namespace', + cluster: 'test-cluster', + group: 'sagemaker.aws.amazon.com', + version: 'v1', + plural: 'devspaces', + status: 'Stopped', + appType: 'jupyterlab', + creator: 'test-user', + accessType: 'Public', + } + mockHyperpodCluster = { + clusterName: 'test-cluster', + clusterArn: 'arn:aws:sagemaker:us-east-1:123456789012:cluster/test-cluster', + status: 'InService', + regionCode: testRegion, + } + + sinon.stub(mockParent, 'getKubectlClient').returns(mockKubectlClient as any) + sinon.stub(mockParent, 'trackPendingNode').returns() + + testNode = new SagemakerDevSpaceNode(mockParent, mockDevSpace, mockHyperpodCluster, testRegion) + }) + + afterEach(function () { + sinon.restore() + }) + + describe('buildLabel', function () { + it('should return formatted label with name and status', function () { + const label = testNode.buildLabel() + assert.strictEqual(label, 'test-space (Stopped)') + }) + }) + + describe('buildDescription', function () { + it('should return access type description', function () { + const description = testNode.buildDescription() + assert.strictEqual(description, 'Public space') + }) + + it('should default to Public when accessType is undefined', function () { + const newDevSpace = { ...mockDevSpace, accessType: 'Public' } + const newNode = new SagemakerDevSpaceNode(mockParent, newDevSpace, mockHyperpodCluster, testRegion) + const description = newNode.buildDescription() + assert.strictEqual(description, 'Public space') + }) + }) + + describe('getContext', function () { + it('should return transitional context for Starting status', function () { + const getStatusStub = sinon.stub(testNode, 'status').get(() => 'Starting') + const context = (testNode as any).getContext() + assert.strictEqual(context, 'awsSagemakerHyperpodDevSpaceTransitionalNode') + getStatusStub.restore() + }) + + it('should return stopped context for Stopped status', function () { + const getStatusStub = sinon.stub(testNode, 'status').get(() => 'Stopped') + const context = (testNode as any).getContext() + assert.strictEqual(context, 'awsSagemakerHyperpodDevSpaceStoppedNode') + getStatusStub.restore() + }) + + it('should return running context for Running status', function () { + const getStatusStub = sinon.stub(testNode, 'status').get(() => 'Running') + const context = (testNode as any).getContext() + assert.strictEqual(context, 'awsSagemakerHyperpodDevSpaceRunningNode') + getStatusStub.restore() + }) + + it('should return error context for unknown status', function () { + const getStatusStub = sinon.stub(testNode, 'status').get(() => 'Unknown') + const context = (testNode as any).getContext() + assert.strictEqual(context, 'awsSagemakerHyperpodDevSpaceErrorNode') + getStatusStub.restore() + }) + }) + + describe('isPending', function () { + it('should return false for Running status', function () { + const getStatusStub = sinon.stub(testNode, 'status').get(() => 'Running') + assert.strictEqual(testNode.isPending(), false) + getStatusStub.restore() + }) + + it('should return false for Stopped status', function () { + const getStatusStub = sinon.stub(testNode, 'status').get(() => 'Stopped') + assert.strictEqual(testNode.isPending(), false) + getStatusStub.restore() + }) + + it('should return true for Starting status', function () { + const getStatusStub = sinon.stub(testNode, 'status').get(() => 'Starting') + assert.strictEqual(testNode.isPending(), true) + getStatusStub.restore() + }) + }) + + describe('getDevSpaceKey', function () { + it('should return formatted devspace key', function () { + const key = testNode.getDevSpaceKey() + assert.strictEqual(key, 'test-cluster-test-namespace-test-space') + }) + }) + + describe('updateWorkspaceStatus', function () { + it('should update status from kubectl client', async function () { + mockKubectlClient.getHyperpodSpaceStatus.resolves('Running') + + await testNode.updateWorkspaceStatus() + + assert.strictEqual(testNode.status, 'Running') + sinon.assert.calledOnce(mockKubectlClient.getHyperpodSpaceStatus) + }) + + it('should handle errors gracefully', async function () { + mockKubectlClient.getHyperpodSpaceStatus.rejects(new Error('API Error')) + + await testNode.updateWorkspaceStatus() + + // Should not throw, just log warning + sinon.assert.calledOnce(mockKubectlClient.getHyperpodSpaceStatus) + }) + }) + + describe('buildTooltip', function () { + it('should format tooltip with all devspace details', function () { + const tooltip = testNode.buildTooltip() + + assert.ok(tooltip.includes('test-space')) + assert.ok(tooltip.includes('test-namespace')) + assert.ok(tooltip.includes('test-cluster')) + assert.ok(tooltip.includes('test-user')) + assert.ok(tooltip.includes('Hyperpod')) + }) + }) + + describe('buildIconPath', function () { + it('should return jupyter icon for jupyterlab app type', function () { + testNode.devSpace.appType = 'jupyterlab' + + const iconPath = testNode.buildIconPath() + + assert.ok(iconPath !== undefined) + }) + + it('should return code editor icon for code-editor app type', function () { + testNode.devSpace.appType = 'code-editor' + + const iconPath = testNode.buildIconPath() + + assert.ok(iconPath !== undefined) + }) + + it('should return undefined for unknown app types', function () { + testNode.devSpace.appType = 'unknown-type' + + const iconPath = testNode.buildIconPath() + + assert.strictEqual(iconPath, undefined) + }) + }) + + describe('updateWorkspace', function () { + it('should update all node properties and track pending if needed', function () { + const isPendingStub = sinon.stub(testNode, 'isPending').returns(true) + + testNode.updateWorkspace() + + // Should update properties + assert.ok(testNode.label) + assert.ok(testNode.description) + assert.ok(testNode.tooltip) + assert.ok(testNode.contextValue) + + isPendingStub.restore() + }) + }) + + describe('refreshNode', function () { + it('should update status and refresh VS Code explorer', async function () { + const updateStatusStub = sinon.stub(testNode, 'updateWorkspaceStatus').resolves() + + await testNode.refreshNode() + + sinon.assert.calledOnce(updateStatusStub) + // Note: VS Code commands.executeCommand is mocked by the test framework + + updateStatusStub.restore() + }) + }) +}) diff --git a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerHyperpodNode.test.ts b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerHyperpodNode.test.ts new file mode 100644 index 00000000000..0014db93f2b --- /dev/null +++ b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerHyperpodNode.test.ts @@ -0,0 +1,224 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as sinon from 'sinon' +import * as assert from 'assert' +import { SagemakerHyperpodNode } from '../../../../awsService/sagemaker/explorer/sagemakerHyperpodNode' +import { SagemakerDevSpaceNode } from '../../../../awsService/sagemaker/explorer/sagemakerDevSpaceNode' +import { KubectlClient, HyperpodCluster } from '../../../../shared/clients/kubectlClient' +import { SagemakerClient } from '../../../../shared/clients/sagemaker' + +describe('SagemakerHyperpodNode', function () { + let testNode: SagemakerHyperpodNode + let mockDevSpaceNode: sinon.SinonStubbedInstance + let mockKubectlClient: sinon.SinonStubbedInstance + let mockSagemakerClient: sinon.SinonStubbedInstance + let mockHyperpodCluster: HyperpodCluster + const testRegion = 'us-east-1' + + beforeEach(function () { + mockSagemakerClient = sinon.createStubInstance(SagemakerClient) + // Mock the EKS client that will be returned + const mockEksClient = { send: sinon.stub() } + mockSagemakerClient.getEKSClient.returns(mockEksClient as any) + + testNode = new SagemakerHyperpodNode(testRegion, mockSagemakerClient as any) + mockDevSpaceNode = sinon.createStubInstance(SagemakerDevSpaceNode) + mockKubectlClient = sinon.createStubInstance(KubectlClient) + mockHyperpodCluster = { + clusterName: 'test-cluster', + clusterArn: 'arn:aws:sagemaker:us-east-1:123456789012:cluster/test-cluster', + status: 'InService', + regionCode: testRegion, + } + + mockDevSpaceNode.getDevSpaceKey.returns('test-cluster-test-namespace-test-space') + mockDevSpaceNode.isPending.returns(false) + mockDevSpaceNode.updateWorkspaceStatus.resolves() + mockDevSpaceNode.refreshNode.resolves() + }) + + afterEach(function () { + testNode.pollingSet.clear() + testNode.pollingSet.clearTimer() + sinon.restore() + }) + + describe('constructor', function () { + it('should initialize with correct properties', function () { + assert.strictEqual(testNode.regionCode, testRegion) + assert.strictEqual(testNode.label, 'HyperPod') + assert.ok(testNode.hyperpodDevSpaceNodes instanceof Map) + assert.ok(testNode.kubectlClients instanceof Map) + assert.ok(testNode.pollingSet) + }) + }) + + describe('getKubectlClient', function () { + it('should return kubectl client for cluster', function () { + const clusterName = 'test-cluster' + testNode.kubectlClients.set(clusterName, mockKubectlClient as any) + + const client = testNode.getKubectlClient(clusterName) + assert.strictEqual(client, mockKubectlClient) + }) + }) + + describe('trackPendingNode', function () { + it('should add devspace key to polling set', function () { + const devSpaceKey = 'test-cluster-test-namespace-test-space' + + testNode.trackPendingNode(devSpaceKey) + + assert.ok(testNode.pollingSet.has(devSpaceKey)) + }) + }) + + describe('updatePendingNodes', function () { + it('should update pending nodes and remove from polling when not pending', async function () { + const devSpaceKey = 'test-cluster-test-namespace-test-space' + testNode.hyperpodDevSpaceNodes.set(devSpaceKey, mockDevSpaceNode as any) + testNode.pollingSet.add(devSpaceKey) + + mockDevSpaceNode.isPending.returns(false) + + await (testNode as any).updatePendingNodes() + + sinon.assert.calledOnce(mockDevSpaceNode.updateWorkspaceStatus) + sinon.assert.calledOnce(mockDevSpaceNode.refreshNode) + assert.ok(!testNode.pollingSet.has(devSpaceKey)) + }) + + it('should keep pending nodes in polling set', async function () { + const devSpaceKey = 'test-cluster-test-namespace-test-space' + testNode.hyperpodDevSpaceNodes.set(devSpaceKey, mockDevSpaceNode as any) + testNode.pollingSet.add(devSpaceKey) + + mockDevSpaceNode.isPending.returns(true) + + await (testNode as any).updatePendingNodes() + + sinon.assert.calledOnce(mockDevSpaceNode.updateWorkspaceStatus) + sinon.assert.notCalled(mockDevSpaceNode.refreshNode) + assert.ok(testNode.pollingSet.has(devSpaceKey)) + }) + + it('should throw error when devspace not found in map', async function () { + const devSpaceKey = 'missing-key' + testNode.pollingSet.add(devSpaceKey) + + await assert.rejects( + (testNode as any).updatePendingNodes(), + /Devspace missing-key from polling set not found/ + ) + }) + }) + + describe('listSpaces', function () { + it('should discover spaces across multiple clusters', async function () { + const mockClusters = [ + { + clusterName: 'cluster1', + clusterArn: 'arn:aws:sagemaker:us-east-1:123:cluster/cluster1', + status: 'InService', + eksClusterName: 'eks1', + regionCode: testRegion, + }, + ] + mockSagemakerClient.listHyperpodClusters.resolves(mockClusters) + + const mockEksResponse = { cluster: { name: 'eks1', endpoint: 'https://test.com' } } + ;(testNode.eksClient as any).send.resolves(mockEksResponse) + + const mockKubectl = { getSpacesForCluster: sinon.stub().resolves([]) } + testNode.kubectlClients.set('cluster1', mockKubectl as any) + + const result = await testNode.listSpaces() + + assert.ok(result instanceof Map) + sinon.assert.calledOnce(mockSagemakerClient.listHyperpodClusters) + }) + + it('should handle clusters without EKS integration', async function () { + const mockClusters = [ + { + clusterName: 'cluster1', + clusterArn: 'arn:aws:sagemaker:us-east-1:123:cluster/cluster1', + status: 'InService', + regionCode: testRegion, + }, + ] // No eksClusterName + mockSagemakerClient.listHyperpodClusters.resolves(mockClusters) + + const result = await testNode.listSpaces() + + assert.strictEqual(result.size, 0) + }) + + it('should handle kubectl client creation errors', async function () { + mockSagemakerClient.listHyperpodClusters.rejects(new Error('API Error')) + + await assert.rejects(testNode.listSpaces(), /No workspaces listed/) + }) + }) + + describe('updateChildren', function () { + it('should filter spaces based on selected cluster namespaces', async function () { + const mockDevSpace = { + name: 'test-space', + namespace: 'test-namespace', + cluster: 'test-cluster', // This is the key field needed + environment: 'test-env', + application: 'test-app', + group: 'test-group', + version: 'v1', + plural: 'spaces', + status: 'Running', + appType: 'jupyterlab', + creator: 'test-user', + accessType: 'Public', + } + const mockSpaces = new Map([ + [ + 'key1', + { + cluster: mockHyperpodCluster, + devSpace: mockDevSpace, + }, + ], + ]) + sinon.stub(testNode, 'listSpaces').resolves(mockSpaces) + sinon.stub(testNode, 'getSelectedClusterNamespaces').resolves(new Set(['test-cluster-test-namespace'])) + const stsStub = sinon.stub((testNode as any).stsClient, 'getCallerIdentity').resolves({ Arn: 'test-arn' }) + + await testNode.updateChildren() + + assert.ok(testNode.hyperpodDevSpaceNodes instanceof Map) + stsStub.restore() + }) + + it('should handle caller identity retrieval', async function () { + sinon.stub(testNode, 'listSpaces').resolves(new Map()) + sinon.stub(testNode, 'getSelectedClusterNamespaces').resolves(new Set()) + const stsStub = sinon.stub((testNode as any).stsClient, 'getCallerIdentity').resolves({ Arn: 'test-arn' }) + + await testNode.updateChildren() + + sinon.assert.calledOnce(stsStub) + stsStub.restore() + }) + }) + + describe('getSelectedClusterNamespaces', function () { + it('should return defaults when no cache exists', async function () { + sinon.stub(testNode, 'getDefaultSelectedClusterNamespaces').resolves(['default-selection']) + ;(testNode as any).callerIdentity = { Arn: 'test-arn' } + + const result = await testNode.getSelectedClusterNamespaces() + + assert.ok(result.has('default-selection')) + }) + }) +}) diff --git a/packages/core/src/test/shared/clients/kubectlClient.test.ts b/packages/core/src/test/shared/clients/kubectlClient.test.ts new file mode 100644 index 00000000000..5a7bac8c770 --- /dev/null +++ b/packages/core/src/test/shared/clients/kubectlClient.test.ts @@ -0,0 +1,304 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as sinon from 'sinon' +import * as assert from 'assert' +import * as k8s from '@kubernetes/client-node' +import { KubectlClient, HyperpodDevSpace, HyperpodCluster } from '../../../shared/clients/kubectlClient' +import { SagemakerDevSpaceNode } from '../../../awsService/sagemaker/explorer/sagemakerDevSpaceNode' +import { Cluster } from '@aws-sdk/client-eks' +import { IncomingMessage } from 'http' + +describe('KubectlClient', function () { + let client: KubectlClient + let mockK8sApi: sinon.SinonStubbedInstance + let mockDevSpace: HyperpodDevSpace + let mockHyperpodCluster: HyperpodCluster + let mockDevSpaceNode: sinon.SinonStubbedInstance + let mockEksCluster: Cluster + + beforeEach(function () { + mockK8sApi = sinon.createStubInstance(k8s.CustomObjectsApi) + mockDevSpace = { + name: 'test-space', + namespace: 'test-namespace', + cluster: 'test-cluster', + group: 'sagemaker.aws.amazon.com', + version: 'v1', + plural: 'devspaces', + status: 'Stopped', + appType: 'jupyterlab', + creator: 'test-user', + accessType: 'Public', + } + mockHyperpodCluster = { + clusterName: 'test-cluster', + clusterArn: 'arn:aws:sagemaker:us-east-1:123456789012:cluster/test-cluster', + status: 'InService', + regionCode: 'us-east-1', + } + mockEksCluster = { + name: 'test-cluster', + endpoint: 'https://test-endpoint.com', + certificateAuthority: { data: 'test-cert-data' }, + } + mockDevSpaceNode = sinon.createStubInstance(SagemakerDevSpaceNode) + Object.defineProperty(mockDevSpaceNode, 'devSpace', { + value: mockDevSpace, + writable: false, + }) + + client = new KubectlClient(mockEksCluster, mockHyperpodCluster) + ;(client as any).k8sApi = mockK8sApi + }) + + afterEach(function () { + sinon.restore() + }) + + describe('getHyperpodSpaceStatus', function () { + it('should return Running status when available and not progressing', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: { + status: { + conditions: [ + { type: 'Available', status: 'True' }, + { type: 'Progressing', status: 'False' }, + { type: 'Stopped', status: 'False' }, + ], + }, + spec: { desiredStatus: 'Running' }, + }, + } + mockK8sApi.getNamespacedCustomObject.resolves(mockResponse) + + const status = await client.getHyperpodSpaceStatus(mockDevSpace) + assert.strictEqual(status, 'Running') + }) + + it('should return Starting status when progressing with Running desired status', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: { + status: { + conditions: [ + { type: 'Available', status: 'False' }, + { type: 'Progressing', status: 'True' }, + ], + }, + spec: { desiredStatus: 'Running' }, + }, + } + mockK8sApi.getNamespacedCustomObject.resolves(mockResponse) + + const status = await client.getHyperpodSpaceStatus(mockDevSpace) + assert.strictEqual(status, 'Starting') + }) + + it('should return Error status when degraded', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: { + status: { + conditions: [{ type: 'Degraded', status: 'True' }], + }, + }, + } + mockK8sApi.getNamespacedCustomObject.resolves(mockResponse) + + const status = await client.getHyperpodSpaceStatus(mockDevSpace) + assert.strictEqual(status, 'Error') + }) + + it('should throw error when API call fails', async function () { + mockK8sApi.getNamespacedCustomObject.rejects(new Error('API Error')) + + await assert.rejects( + client.getHyperpodSpaceStatus(mockDevSpace), + /Failed to get status for devSpace: test-space/ + ) + }) + }) + + describe('patchDevSpaceStatus', function () { + it('should patch devspace with Running status', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: {}, + } + mockK8sApi.patchNamespacedCustomObject.resolves(mockResponse) + + await client.patchDevSpaceStatus(mockDevSpace, 'Running') + + sinon.assert.calledOnceWithExactly( + mockK8sApi.patchNamespacedCustomObject, + 'sagemaker.aws.amazon.com', + 'v1', + 'test-namespace', + 'devspaces', + 'test-space', + { spec: { desiredStatus: 'Running' } }, + undefined, + undefined, + undefined, + { headers: { 'Content-Type': 'application/merge-patch+json' } } + ) + }) + + it('should throw error when patch fails', async function () { + mockK8sApi.patchNamespacedCustomObject.rejects(new Error('Patch failed')) + + await assert.rejects( + client.patchDevSpaceStatus(mockDevSpace, 'Stopped'), + /Failed to update transitional status for devSpace test-space/ + ) + }) + }) + + describe('createWorkspaceConnection', function () { + it('should create workspace connection and return connection details', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: { + status: { + workspaceConnectionUrl: 'https://test-url.com', + workspaceConnectionType: 'vscode-remote', + }, + }, + } + mockK8sApi.createNamespacedCustomObject.resolves(mockResponse) + + const result = await client.createWorkspaceConnection(mockDevSpace) + + assert.strictEqual(result.type, 'vscode-remote') + assert.strictEqual(result.url, 'https://test-url.com') + }) + + it('should throw error when workspace connection creation fails', async function () { + mockK8sApi.createNamespacedCustomObject.rejects(new Error('Creation failed')) + + await assert.rejects( + client.createWorkspaceConnection(mockDevSpace), + /Failed to create workspace connection/ + ) + }) + }) + + describe('getSpacesForCluster', function () { + it('should return mapped workspaces from Kubernetes API', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: { + items: [ + { + metadata: { + name: 'test-workspace', + namespace: 'test-namespace', + annotations: { 'workspace.jupyter.org/created-by': 'test-user' }, + }, + spec: { appType: 'jupyterlab', accessType: 'Public', desiredStatus: 'Running' }, + status: { conditions: [{ type: 'Available', status: 'True' }] }, + }, + ], + }, + } + mockK8sApi.listClusterCustomObject.resolves(mockResponse) + + const result = await client.getSpacesForCluster(mockEksCluster) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].name, 'test-workspace') + assert.strictEqual(result[0].namespace, 'test-namespace') + assert.strictEqual(result[0].creator, 'test-user') + }) + + it('should handle 403 permission errors with user message', async function () { + const error = new Error('Forbidden') + ;(error as any).statusCode = 403 + mockK8sApi.listClusterCustomObject.rejects(error) + + const result = await client.getSpacesForCluster(mockEksCluster) + + assert.strictEqual(result.length, 0) + }) + + it('should return empty array when API returns no items', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: {}, + } + mockK8sApi.listClusterCustomObject.resolves(mockResponse) + + const result = await client.getSpacesForCluster(mockEksCluster) + + assert.strictEqual(result.length, 0) + }) + + it('should return empty array when no spaces found', async function () { + const mockResponse = { + response: {} as IncomingMessage, + body: { items: [] }, + } + mockK8sApi.listClusterCustomObject.resolves(mockResponse) + + const result = await client.getSpacesForCluster(mockEksCluster) + + assert.strictEqual(result.length, 0) + }) + }) + + describe('startHyperpodDevSpace', function () { + it('should patch status to Running and track pending node', async function () { + const mockParent = { trackPendingNode: sinon.stub() } + mockDevSpaceNode.getParent.returns(mockParent as any) + mockDevSpaceNode.getDevSpaceKey.returns('test-key') + mockK8sApi.patchNamespacedCustomObject.resolves({} as any) + mockK8sApi.getNamespacedCustomObject.resolves({ + response: {} as IncomingMessage, + body: { status: { conditions: [] } }, + }) + + await client.startHyperpodDevSpace(mockDevSpaceNode as any) + + sinon.assert.calledWith( + mockK8sApi.patchNamespacedCustomObject, + mockDevSpace.group, + mockDevSpace.version, + mockDevSpace.namespace, + mockDevSpace.plural, + mockDevSpace.name, + { spec: { desiredStatus: 'Running' } } + ) + sinon.assert.calledWith(mockParent.trackPendingNode, 'test-key') + }) + }) + + describe('stopHyperpodDevSpace', function () { + it('should patch status to Stopped and track pending node', async function () { + const mockParent = { trackPendingNode: sinon.stub() } + mockDevSpaceNode.getParent.returns(mockParent as any) + mockDevSpaceNode.getDevSpaceKey.returns('test-key') + mockK8sApi.patchNamespacedCustomObject.resolves({} as any) + mockK8sApi.getNamespacedCustomObject.resolves({ + response: {} as IncomingMessage, + body: { status: { conditions: [] } }, + }) + + await client.stopHyperpodDevSpace(mockDevSpaceNode as any) + + sinon.assert.calledWith( + mockK8sApi.patchNamespacedCustomObject, + mockDevSpace.group, + mockDevSpace.version, + mockDevSpace.namespace, + mockDevSpace.plural, + mockDevSpace.name, + { spec: { desiredStatus: 'Stopped' } } + ) + sinon.assert.calledWith(mockParent.trackPendingNode, 'test-key') + }) + }) +})