{
+ const local = await getFunctionInfo(lambda, 'sha')
+ const remote = await getCodeShaLive(lambda)
+ getLogger().info(`local: ${local}, remote: ${remote}`)
+ return local === remote
+}
+
+export async function getFunctionInfo(lambda: LambdaFunction, field?: 'lastDeployed' | 'undeployed' | 'sha') {
+ try {
+ const data = JSON.parse(await fs.readFileText(getInfoLocation(lambda)))
+ getLogger().debug('Data returned from getFunctionInfo for %s: %O', lambda.name, data)
+ return field ? data[field] : data
+ } catch {
+ return field ? undefined : {}
+ }
+}
+
+export async function setFunctionInfo(
+ lambda: LambdaFunction,
+ info: { lastDeployed?: number; undeployed?: boolean; sha?: string }
+) {
+ try {
+ const existing = await getFunctionInfo(lambda)
+ const updated = {
+ lastDeployed: info.lastDeployed ?? existing.lastDeployed,
+ undeployed: info.undeployed ?? true,
+ sha: info.sha ?? (await getCodeShaLive(lambda)),
+ }
+ await fs.writeFile(getInfoLocation(lambda), JSON.stringify(updated))
+ } catch (err) {
+ getLogger().warn(`codesha: unable to save information at key "${lambda.name}: %s"`, err)
+ }
+}
+
+export const lambdaTempPath = path.join(tempDirPath, 'lambda')
+
+export function getTempRegionLocation(region: string) {
+ return path.join(lambdaTempPath, region)
+}
+
+export function getTempLocation(functionName: string, region: string) {
+ return path.join(getTempRegionLocation(region), functionName)
+}
+
+type LambdaEdit = {
+ location: string
+ functionName: string
+ region: string
+ configuration?: Lambda.FunctionConfiguration
+}
+
+// Array to keep the list of functions that are being edited.
+export const lambdaEdits: LambdaEdit[] = []
+
+// Given a particular function and region, it returns the full LambdaEdit object
+export function getLambdaEditFromNameRegion(name: string, functionRegion: string) {
+ return lambdaEdits.find(({ functionName, region }) => functionName === name && region === functionRegion)
+}
+
+// Given a particular localPath, it returns the full LambdaEdit object
+export function getLambdaEditFromLocation(functionLocation: string) {
+ return lambdaEdits.find(({ location }) => location === functionLocation)
+}
diff --git a/packages/core/src/login/webview/vue/login.vue b/packages/core/src/login/webview/vue/login.vue
index 312aa18029b..7973844f4d4 100644
--- a/packages/core/src/login/webview/vue/login.vue
+++ b/packages/core/src/login/webview/vue/login.vue
@@ -239,6 +239,11 @@
Credentials will be added to the appropriate ~/.aws/ files
+ Learn More
Profile Name
The identifier for these credentials
implements vscode.Dispo
public constructor(
public readonly regionCode: string,
- private readonly clientType: AwsClientConstructor
+ private readonly clientType: AwsClientConstructor,
+ private readonly isSageMaker: boolean = false
) {}
protected getClient(ignoreCache: boolean = false) {
- const args = { serviceClient: this.clientType, region: this.regionCode }
+ const args = {
+ serviceClient: this.clientType,
+ region: this.regionCode,
+ ...(this.isSageMaker
+ ? {
+ clientOptions: {
+ endpoint: `https://sagemaker.${this.regionCode}.amazonaws.com`,
+ region: this.regionCode,
+ },
+ }
+ : {}),
+ }
return ignoreCache
? globals.sdkClientBuilderV3.createAwsService(args)
: globals.sdkClientBuilderV3.getAwsService(args)
diff --git a/packages/core/src/shared/clients/lambdaClient.ts b/packages/core/src/shared/clients/lambdaClient.ts
index 331564521ee..59af6f314a0 100644
--- a/packages/core/src/shared/clients/lambdaClient.ts
+++ b/packages/core/src/shared/clients/lambdaClient.ts
@@ -10,6 +10,11 @@ import globals from '../extensionGlobals'
import { getLogger } from '../logger/logger'
import { ClassToInterfaceType } from '../utilities/tsUtils'
+import { LambdaClient as LambdaSdkClient, GetFunctionCommand, GetFunctionCommandOutput } from '@aws-sdk/client-lambda'
+import { CancellationError } from '../utilities/timeoutUtils'
+import { fromSSO } from '@aws-sdk/credential-provider-sso'
+import { getIAMConnection } from '../../auth/utils'
+
export type LambdaClient = ClassToInterfaceType
export class DefaultLambdaClient {
@@ -80,6 +85,39 @@ export class DefaultLambdaClient {
}
}
+ public async getLayerVersion(name: string, version: number): Promise {
+ getLogger().debug(`getLayerVersion called for LayerName: ${name}, VersionNumber ${version}`)
+ const client = await this.createSdkClient()
+
+ try {
+ const response = await client.getLayerVersion({ LayerName: name, VersionNumber: version }).promise()
+ // prune `Code` from logs so we don't reveal a signed link to customer resources.
+ getLogger().debug('getLayerVersion returned response (code section pruned): %O', {
+ ...response,
+ Code: 'Pruned',
+ })
+ return response
+ } catch (e) {
+ getLogger().error('Failed to get function: %s', e)
+ throw e
+ }
+ }
+
+ public async *listLayerVersions(name: string): AsyncIterableIterator {
+ const client = await this.createSdkClient()
+
+ const request: Lambda.ListLayerVersionsRequest = { LayerName: name }
+ do {
+ const response: Lambda.ListLayerVersionsResponse = await client.listLayerVersions(request).promise()
+
+ if (response.LayerVersions) {
+ yield* response.LayerVersions
+ }
+
+ request.Marker = response.NextMarker
+ } while (request.Marker)
+ }
+
public async getFunctionUrlConfigs(name: string): Promise {
getLogger().debug(`GetFunctionUrlConfig called for function: ${name}`)
const client = await this.createSdkClient()
@@ -128,3 +166,21 @@ export class DefaultLambdaClient {
)
}
}
+
+export async function getFunctionWithCredentials(region: string, name: string): Promise {
+ const connection = await getIAMConnection({
+ prompt: true,
+ messageText: 'Opening a Lambda Function requires you to be authenticated.',
+ })
+
+ if (!connection) {
+ throw new CancellationError('user')
+ }
+
+ const credentials =
+ connection.type === 'iam' ? await connection.getCredentials() : fromSSO({ profile: connection.id })
+ const client = new LambdaSdkClient({ region, credentials })
+
+ const command = new GetFunctionCommand({ FunctionName: name })
+ return client.send(command)
+}
diff --git a/packages/core/src/shared/clients/sagemaker.ts b/packages/core/src/shared/clients/sagemaker.ts
new file mode 100644
index 00000000000..d24a0f74869
--- /dev/null
+++ b/packages/core/src/shared/clients/sagemaker.ts
@@ -0,0 +1,266 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as vscode from 'vscode'
+import {
+ AppDetails,
+ CreateAppCommand,
+ CreateAppCommandInput,
+ CreateAppCommandOutput,
+ DeleteAppCommand,
+ DeleteAppCommandInput,
+ DeleteAppCommandOutput,
+ DescribeAppCommand,
+ DescribeAppCommandInput,
+ DescribeAppCommandOutput,
+ DescribeDomainCommand,
+ DescribeDomainCommandInput,
+ DescribeDomainCommandOutput,
+ DescribeDomainResponse,
+ DescribeSpaceCommand,
+ DescribeSpaceCommandInput,
+ DescribeSpaceCommandOutput,
+ ListAppsCommandInput,
+ ListSpacesCommandInput,
+ ResourceSpec,
+ SageMakerClient,
+ SpaceDetails,
+ UpdateSpaceCommand,
+ UpdateSpaceCommandInput,
+ UpdateSpaceCommandOutput,
+ paginateListApps,
+ paginateListSpaces,
+} from '@amzn/sagemaker-client'
+import { isEmpty } from 'lodash'
+import { sleep } from '../utilities/timeoutUtils'
+import { ClientWrapper } from './clientWrapper'
+import { AsyncCollection } from '../utilities/asyncCollection'
+import { getDomainSpaceKey } from '../../awsService/sagemaker/utils'
+import { getLogger } from '../logger/logger'
+import { ToolkitError } from '../errors'
+
+export interface SagemakerSpaceApp extends SpaceDetails {
+ App?: AppDetails
+ DomainSpaceKey: string
+}
+export class SagemakerClient extends ClientWrapper {
+ public constructor(public override readonly regionCode: string) {
+ super(regionCode, SageMakerClient, true)
+ }
+
+ public listSpaces(request: ListSpacesCommandInput = {}): AsyncCollection {
+ // @ts-ignore: Suppressing type mismatch on paginator return type
+ return this.makePaginatedRequest(paginateListSpaces, request, (page) => page.Spaces)
+ }
+
+ public listApps(request: ListAppsCommandInput = {}): AsyncCollection {
+ // @ts-ignore: Suppressing type mismatch on paginator return type
+ return this.makePaginatedRequest(paginateListApps, request, (page) => page.Apps)
+ }
+
+ public describeApp(request: DescribeAppCommandInput): Promise {
+ return this.makeRequest(DescribeAppCommand, request)
+ }
+
+ public describeDomain(request: DescribeDomainCommandInput): Promise {
+ return this.makeRequest(DescribeDomainCommand, request)
+ }
+
+ public describeSpace(request: DescribeSpaceCommandInput): Promise {
+ return this.makeRequest(DescribeSpaceCommand, request)
+ }
+
+ public updateSpace(request: UpdateSpaceCommandInput): Promise {
+ return this.makeRequest(UpdateSpaceCommand, request)
+ }
+
+ public createApp(request: CreateAppCommandInput): Promise {
+ return this.makeRequest(CreateAppCommand, request)
+ }
+
+ public deleteApp(request: DeleteAppCommandInput): Promise {
+ return this.makeRequest(DeleteAppCommand, request)
+ }
+
+ public async startSpace(spaceName: string, domainId: string) {
+ let spaceDetails
+ try {
+ spaceDetails = await this.describeSpace({
+ DomainId: domainId,
+ SpaceName: spaceName,
+ })
+ } catch (err) {
+ throw this.handleStartSpaceError(err)
+ }
+
+ if (!spaceDetails.SpaceSettings?.RemoteAccess || spaceDetails.SpaceSettings?.RemoteAccess === 'DISABLED') {
+ try {
+ await this.updateSpace({
+ DomainId: domainId,
+ SpaceName: spaceName,
+ SpaceSettings: {
+ RemoteAccess: 'ENABLED',
+ },
+ })
+ await this.waitForSpaceInService(spaceName, domainId)
+ } catch (err) {
+ throw this.handleStartSpaceError(err)
+ }
+ }
+
+ const appType = spaceDetails.SpaceSettings?.AppType
+ if (appType !== 'JupyterLab' && appType !== 'CodeEditor') {
+ throw new ToolkitError(`Unsupported AppType "${appType}" for space "${spaceName}"`)
+ }
+
+ const requestedResourceSpec =
+ appType === 'JupyterLab'
+ ? spaceDetails.SpaceSettings?.JupyterLabAppSettings?.DefaultResourceSpec
+ : spaceDetails.SpaceSettings?.CodeEditorAppSettings?.DefaultResourceSpec
+
+ const fallbackResourceSpec: ResourceSpec = {
+ InstanceType: 'ml.t3.medium',
+ SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:542918446943:image/sagemaker-distribution-cpu',
+ SageMakerImageVersionAlias: '3.2.0',
+ }
+
+ const resourceSpec = requestedResourceSpec?.InstanceType ? requestedResourceSpec : fallbackResourceSpec
+
+ const cleanedResourceSpec =
+ resourceSpec && 'EnvironmentArn' in resourceSpec
+ ? { ...resourceSpec, EnvironmentArn: undefined, EnvironmentVersionArn: undefined }
+ : resourceSpec
+
+ const createAppRequest: CreateAppCommandInput = {
+ DomainId: domainId,
+ SpaceName: spaceName,
+ AppType: appType,
+ AppName: 'default',
+ ResourceSpec: cleanedResourceSpec,
+ }
+
+ try {
+ await this.createApp(createAppRequest)
+ } catch (err) {
+ throw this.handleStartSpaceError(err)
+ }
+ }
+
+ public async fetchSpaceAppsAndDomains(): Promise<
+ [Map, Map]
+ > {
+ try {
+ const appMap: Map = await this.listApps()
+ .flatten()
+ .filter((app) => !!app.DomainId && !!app.SpaceName)
+ .filter((app) => app.AppType === 'JupyterLab' || app.AppType === 'CodeEditor')
+ .toMap((app) => getDomainSpaceKey(app.DomainId || '', app.SpaceName || ''))
+
+ const spaceApps: Map = await this.listSpaces()
+ .flatten()
+ .filter((space) => !!space.DomainId && !!space.SpaceName)
+ .map((space) => {
+ const key = getDomainSpaceKey(space.DomainId || '', space.SpaceName || '')
+ return { ...space, App: appMap.get(key), DomainSpaceKey: key }
+ })
+ .toMap((space) => getDomainSpaceKey(space.DomainId || '', space.SpaceName || ''))
+
+ // Get de-duped list of domain IDs for all of the spaces
+ const domainIds: string[] = [...new Set([...spaceApps].map(([_, spaceApp]) => spaceApp.DomainId || ''))]
+
+ // Get details for each domain
+ const domains: [string, DescribeDomainResponse][] = await Promise.all(
+ domainIds.map(async (domainId, index) => {
+ await sleep(index * 100)
+ const response = await this.describeDomain({ DomainId: domainId })
+ return [domainId, response]
+ })
+ )
+
+ const domainsMap = new Map(domains)
+
+ const filteredSpaceApps = new Map(
+ [...spaceApps]
+ // Filter out SageMaker Unified Studio domains
+ .filter(([_, spaceApp]) =>
+ isEmpty(domainsMap.get(spaceApp.DomainId || '')?.DomainSettings?.UnifiedStudioSettings)
+ )
+ )
+
+ return [filteredSpaceApps, domainsMap]
+ } catch (err) {
+ const error = err as Error
+ getLogger().error('Failed to fetch space apps: %s', err)
+ if (error.name === 'AccessDeniedException') {
+ void vscode.window.showErrorMessage(
+ 'AccessDeniedException: You do not have permission to view spaces. Please contact your administrator',
+ { modal: false, detail: 'AWS Toolkit' }
+ )
+ }
+ throw err
+ }
+ }
+
+ private async waitForSpaceInService(
+ spaceName: string,
+ domainId: string,
+ maxRetries = 30,
+ intervalMs = 5000
+ ): Promise {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ const result = await this.describeSpace({ SpaceName: spaceName, DomainId: domainId })
+
+ if (result.Status === 'InService') {
+ return
+ }
+
+ await sleep(intervalMs)
+ }
+
+ throw new ToolkitError(
+ `Timed out waiting for space "${spaceName}" in domain "${domainId}" to reach "InService" status.`
+ )
+ }
+
+ public async waitForAppInService(
+ domainId: string,
+ spaceName: string,
+ appType: string,
+ maxRetries = 30,
+ intervalMs = 5000
+ ): Promise {
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ const { Status } = await this.describeApp({
+ DomainId: domainId,
+ SpaceName: spaceName,
+ AppType: appType as any,
+ AppName: 'default',
+ })
+
+ if (Status === 'InService') {
+ return
+ }
+
+ if (['Failed', 'DeleteFailed'].includes(Status ?? '')) {
+ throw new ToolkitError(`App failed to start. Status: ${Status}`)
+ }
+
+ await sleep(intervalMs)
+ }
+
+ throw new ToolkitError(`Timed out waiting for app "${spaceName}" to reach "InService" status.`)
+ }
+
+ private handleStartSpaceError(err: unknown) {
+ const error = err as Error
+ if (error.name === 'AccessDeniedException') {
+ throw new ToolkitError('You do not have permission to start spaces. Please contact your administrator', {
+ cause: error,
+ })
+ } else {
+ throw err
+ }
+ }
+}
diff --git a/packages/core/src/shared/cloudformation/cloudformation.ts b/packages/core/src/shared/cloudformation/cloudformation.ts
index 5d08bb836dc..690a5aee73c 100644
--- a/packages/core/src/shared/cloudformation/cloudformation.ts
+++ b/packages/core/src/shared/cloudformation/cloudformation.ts
@@ -16,6 +16,10 @@ import { isUntitledScheme, normalizeVSCodeUri } from '../utilities/vsCodeUtils'
export const SERVERLESS_API_TYPE = 'AWS::Serverless::Api' // eslint-disable-line @typescript-eslint/naming-convention
export const SERVERLESS_FUNCTION_TYPE = 'AWS::Serverless::Function' // eslint-disable-line @typescript-eslint/naming-convention
export const LAMBDA_FUNCTION_TYPE = 'AWS::Lambda::Function' // eslint-disable-line @typescript-eslint/naming-convention
+export const LAMBDA_LAYER_TYPE = 'AWS::Lambda::LayerVersion' // eslint-disable-line @typescript-eslint/naming-convention
+export const LAMBDA_URL_TYPE = 'AWS::Lambda::Url' // eslint-disable-line @typescript-eslint/naming-convention
+export const SERVERLESS_LAYER_TYPE = 'AWS::Serverless::LayerVersion' // eslint-disable-line @typescript-eslint/naming-convention
+
export const serverlessTableType = 'AWS::Serverless::SimpleTable'
export const s3BucketType = 'AWS::S3::Bucket'
export const appRunnerType = 'AWS::AppRunner::Service'
diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts
index 13db46b430a..2ec0a328d24 100644
--- a/packages/core/src/shared/globalState.ts
+++ b/packages/core/src/shared/globalState.ts
@@ -79,6 +79,8 @@ export type globalKey =
| 'aws.toolkit.lambda.walkthroughSelected'
| 'aws.toolkit.lambda.walkthroughCompleted'
| 'aws.toolkit.appComposer.templateToOpenOnStart'
+ // List of Domain-Users to show/hide Sagemaker SpaceApps in AWS Explorer.
+ | 'aws.sagemaker.selectedDomainUsers'
/**
* Extension-local (not visible to other vscode extensions) shared state which persists after IDE
diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts
index eb2602c30b9..95c4c7af769 100644
--- a/packages/core/src/shared/logger/logger.ts
+++ b/packages/core/src/shared/logger/logger.ts
@@ -22,6 +22,7 @@ export type LogTopic =
| 'resourceCache'
| 'telemetry'
| 'proxyUtil'
+ | 'sagemaker'
class ErrorLog {
constructor(
diff --git a/packages/core/src/shared/settings-toolkit.gen.ts b/packages/core/src/shared/settings-toolkit.gen.ts
index 1a518651a4f..55bc77f9828 100644
--- a/packages/core/src/shared/settings-toolkit.gen.ts
+++ b/packages/core/src/shared/settings-toolkit.gen.ts
@@ -52,7 +52,8 @@ export const toolkitSettings = {
"aws.lambda.recentlyUploaded": {},
"aws.accessAnalyzer.policyChecks.checkNoNewAccessFilePath": {},
"aws.accessAnalyzer.policyChecks.checkAccessNotGrantedFilePath": {},
- "aws.accessAnalyzer.policyChecks.cloudFormationParameterFilePath": {}
+ "aws.accessAnalyzer.policyChecks.cloudFormationParameterFilePath": {},
+ "aws.sagemaker.studio.spaces.enableIdentityFiltering": {}
}
export default toolkitSettings
diff --git a/packages/core/src/shared/sshConfig.ts b/packages/core/src/shared/sshConfig.ts
index 2c60b423ab3..db20c173393 100644
--- a/packages/core/src/shared/sshConfig.ts
+++ b/packages/core/src/shared/sshConfig.ts
@@ -192,8 +192,21 @@ Host ${this.configHostName}
`
}
+ private getSageMakerSSHConfig(proxyCommand: string): string {
+ return `
+# Created by AWS Toolkit for VSCode. https://github.com/aws/aws-toolkit-vscode
+Host ${this.configHostName}
+ ForwardAgent yes
+ AddKeysToAgent yes
+ StrictHostKeyChecking accept-new
+ ProxyCommand ${proxyCommand}
+ `
+ }
+
protected createSSHConfigSection(proxyCommand: string): string {
- if (this.keyPath) {
+ if (this.scriptPrefix === 'sagemaker_connect') {
+ return `${this.getSageMakerSSHConfig(proxyCommand)}User '%r'\n`
+ } else if (this.keyPath) {
return `${this.getBaseSSHConfig(proxyCommand)}IdentityFile '${this.keyPath}'\n User '%r'\n`
}
return this.getBaseSSHConfig(proxyCommand)
diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json
index b28aeec4847..9b29d1a65a0 100644
--- a/packages/core/src/shared/telemetry/vscodeTelemetry.json
+++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json
@@ -241,6 +241,33 @@
}
],
"metrics": [
+ {
+ "name": "sagemaker_openRemoteConnection",
+ "description": "Perform a connection to a SageMaker Space",
+ "metadata": [
+ {
+ "type": "result"
+ }
+ ]
+ },
+ {
+ "name": "sagemaker_stopSpace",
+ "description": "Stop a SageMaker Space",
+ "metadata": [
+ {
+ "type": "result"
+ }
+ ]
+ },
+ {
+ "name": "sagemaker_filterSpaces",
+ "description": "Filter SageMaker Spaces",
+ "metadata": [
+ {
+ "type": "result"
+ }
+ ]
+ },
{
"name": "amazonq_didSelectProfile",
"description": "Emitted after the user's Q Profile has been set, whether the user was prompted with a dialog, or a profile was automatically assigned after signing in.",
@@ -1110,6 +1137,10 @@
{
"name": "docdb_addRegion",
"description": "User clicked on add region command"
+ },
+ {
+ "name": "appbuilder_lambda2sam",
+ "description": "User click Convert a lambda function to SAM project"
}
]
}
diff --git a/packages/core/src/shared/vscode/uriHandler.ts b/packages/core/src/shared/vscode/uriHandler.ts
index 24be2b26321..c8beda72fc4 100644
--- a/packages/core/src/shared/vscode/uriHandler.ts
+++ b/packages/core/src/shared/vscode/uriHandler.ts
@@ -46,7 +46,8 @@ export class UriHandler implements vscode.UriHandler {
const { handler, parser } = this.handlers.get(uri.path)!
let parsedQuery: Parameters[0]
- const url = new URL(uri.toString(true))
+ // Ensure '+' is treated as a literal plus sign, not a space, by encoding it as '%2B'
+ const url = new URL(uri.toString(true).replace(/\+/g, '%2B'))
const params = new SearchParams(url.searchParams)
try {
diff --git a/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2sam.test.ts b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2sam.test.ts
new file mode 100644
index 00000000000..d26d0131d1e
--- /dev/null
+++ b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2sam.test.ts
@@ -0,0 +1,272 @@
+/*!
+ * 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 { LambdaFunctionNode } from '../../../../lambda/explorer/lambdaFunctionNode'
+import { DefaultLambdaClient } from '../../../../shared/clients/lambdaClient'
+import { Template } from '../../../../shared/cloudformation/cloudformation'
+import * as lambda2sam from '../../../../awsService/appBuilder/lambda2sam/lambda2sam'
+import * as authUtils from '../../../../auth/utils'
+import * as utils from '../../../../awsService/appBuilder/utils'
+
+describe('lambda2sam', function () {
+ let sandbox: sinon.SinonSandbox
+
+ beforeEach(function () {
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(function () {
+ sandbox.restore()
+ })
+
+ describe('ifSamTemplate', function () {
+ it('returns true when transform is a string and starts with AWS::Serverless', function () {
+ const template: Template = {
+ Transform: 'AWS::Serverless-2016-10-31',
+ }
+ assert.strictEqual(lambda2sam.ifSamTemplate(template), true)
+ })
+
+ it('returns false when transform is a string and does not start with AWS::Serverless', function () {
+ const template: Template = {
+ Transform: 'AWS::Other-Transform',
+ }
+ assert.strictEqual(lambda2sam.ifSamTemplate(template), false)
+ })
+
+ it('returns true when transform is an array and at least one starts with AWS::Serverless', function () {
+ const template: Template = {
+ Transform: ['AWS::Serverless-2016-10-31', 'AWS::Other-Transform'] as any,
+ }
+ assert.strictEqual(lambda2sam.ifSamTemplate(template), true)
+ })
+
+ it('returns false when transform is an array and none start with AWS::Serverless', function () {
+ const template: Template = {
+ Transform: ['AWS::Other-Transform-1', 'AWS::Other-Transform-2'] as any,
+ }
+ assert.strictEqual(lambda2sam.ifSamTemplate(template), false)
+ })
+
+ it('returns false when transform is not present', function () {
+ const template: Template = {}
+ assert.strictEqual(lambda2sam.ifSamTemplate(template), false)
+ })
+
+ it('returns false when transform is an unsupported type', function () {
+ const template: Template = {
+ Transform: { some: 'object' } as any,
+ }
+ assert.strictEqual(lambda2sam.ifSamTemplate(template), false)
+ })
+ })
+
+ describe('extractLogicalIdFromIntrinsic', function () {
+ it('extracts logical ID from Ref intrinsic', function () {
+ const value = { Ref: 'MyResource' }
+ assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(value), 'MyResource')
+ })
+
+ it('extracts logical ID from GetAtt intrinsic with Arn attribute', function () {
+ const value = { 'Fn::GetAtt': ['MyResource', 'Arn'] }
+ assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(value), 'MyResource')
+ })
+
+ it('returns undefined for GetAtt intrinsic with non-Arn attribute', function () {
+ const value = { 'Fn::GetAtt': ['MyResource', 'Name'] }
+ assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(value), undefined)
+ })
+
+ it('returns undefined for non-intrinsic values', function () {
+ assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic('not-an-intrinsic'), undefined)
+ assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic({ NotIntrinsic: 'value' }), undefined)
+ assert.strictEqual(lambda2sam.extractLogicalIdFromIntrinsic(undefined), undefined)
+ })
+ })
+
+ describe('callExternalApiForCfnTemplate', function () {
+ let lambdaClientStub: sinon.SinonStubbedInstance
+ let cfnClientStub: any
+
+ beforeEach(function () {
+ lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient)
+ // Stub at prototype level to avoid TypeScript errors
+ sandbox
+ .stub(DefaultLambdaClient.prototype, 'getFunction')
+ .callsFake((name) => lambdaClientStub.getFunction(name))
+
+ // Mock CloudFormation client for the new external API calls - now returns Promises directly
+ cfnClientStub = {
+ getGeneratedTemplate: sandbox.stub().resolves({
+ Status: 'COMPLETE',
+ TemplateBody: JSON.stringify({
+ AWSTemplateFormatVersion: '2010-09-09',
+ Resources: {
+ testFunc: {
+ DeletionPolicy: 'Retain',
+ Properties: {
+ Code: {
+ S3Bucket: 'aws-sam-cli-managed-default-samclisourcebucket-1n8tvb0jdhsd',
+ S3Key: '1d1c93ec17af7e2666ee20ea1a215c77',
+ },
+ Environment: {
+ Variables: {
+ KEY: 'value',
+ },
+ },
+ FunctionName: 'myFunction',
+ Handler: 'index.handler',
+ MemorySize: 128,
+ PackageType: 'Zip',
+ Role: 'arn:aws:iam::123456789012:role/lambda-role',
+ Runtime: 'nodejs18.x',
+ Timeout: 3,
+ },
+ Type: 'AWS::Lambda::Function',
+ },
+ },
+ }),
+ }),
+ describeGeneratedTemplate: sandbox.stub().resolves({
+ Status: 'COMPLETE',
+ Resources: [
+ {
+ LogicalResourceId: 'testFunc',
+ ResourceType: 'AWS::Lambda::Function',
+ ResourceIdentifier: {
+ FunctionName: 'myFunction',
+ },
+ },
+ ],
+ }),
+ }
+ sandbox.stub(utils, 'getCFNClient').resolves(cfnClientStub)
+
+ // Mock IAM connection
+ const mockConnection = {
+ type: 'iam' as const,
+ id: 'test-connection',
+ label: 'Test Connection',
+ state: 'valid' as const,
+ getCredentials: sandbox.stub().resolves({
+ accessKeyId: 'test-key',
+ secretAccessKey: 'test-secret',
+ }),
+ }
+ sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection)
+
+ // Mock fetch response
+ sandbox.stub(global, 'fetch').resolves({
+ ok: true,
+ json: sandbox.stub().resolves({
+ cloudFormationTemplateId: 'test-template-id',
+ }),
+ } as any)
+ })
+
+ it('creates a basic CloudFormation template for the Lambda function', async function () {
+ const lambdaNode = {
+ name: 'myFunction',
+ regionCode: 'us-east-2',
+ arn: 'arn:aws:lambda:us-east-2:123456789012:function:myFunction',
+ } as LambdaFunctionNode
+
+ lambdaClientStub.getFunction.resolves({
+ Configuration: {
+ FunctionName: 'myFunction',
+ Handler: 'index.handler',
+ Role: 'arn:aws:iam::123456789012:role/lambda-role',
+ Runtime: 'nodejs18.x',
+ Timeout: 3,
+ MemorySize: 128,
+ Environment: { Variables: { KEY: 'value' } },
+ },
+ })
+
+ // Create a simple mock template that matches the Template type
+ const mockTemplate = {
+ AWSTemplateFormatVersion: '2010-09-09',
+ Resources: {
+ testFunc: {
+ DeletionPolicy: 'Retain',
+ Properties: {
+ Code: {
+ S3Bucket: 'aws-sam-cli-managed-default-samclisourcebucket-1n8tvb0jdhsd',
+ S3Key: '1d1c93ec17af7e2666ee20ea1a215c77',
+ },
+ Environment: {
+ Variables: {
+ KEY: 'value',
+ },
+ },
+ FunctionName: 'myFunction',
+ Handler: 'index.handler',
+ MemorySize: 128,
+ PackageType: 'Zip',
+ Role: 'arn:aws:iam::123456789012:role/lambda-role',
+ Runtime: 'nodejs18.x',
+ Timeout: 3,
+ },
+ Type: 'AWS::Lambda::Function',
+ },
+ },
+ }
+ const mockList = [
+ {
+ LogicalResourceId: 'testFunc',
+ ResourceIdentifier: {
+ FunctionName: 'myFunction',
+ },
+ ResourceType: 'AWS::Lambda::Function',
+ },
+ ]
+
+ const result = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode)
+ // Verify the result structure matches expected format
+ assert.strictEqual(Array.isArray(result), true)
+ assert.strictEqual(result.length, 2)
+ const [template, resourcesToImport] = result
+ assert.strictEqual(typeof template, 'object')
+ assert.strictEqual(Array.isArray(resourcesToImport), true)
+ assert.strictEqual(resourcesToImport.length, 1)
+ assert.strictEqual(resourcesToImport[0].ResourceType, 'AWS::Lambda::Function')
+ assert.strictEqual(resourcesToImport[0].LogicalResourceId, 'testFunc')
+ assert.deepStrictEqual(result, [mockTemplate, mockList])
+ })
+ })
+
+ describe('determineStackAssociation', function () {
+ let lambdaClientStub: sinon.SinonStubbedInstance
+
+ beforeEach(function () {
+ lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient)
+ sandbox
+ .stub(DefaultLambdaClient.prototype, 'getFunction')
+ .callsFake((name) => lambdaClientStub.getFunction(name))
+ })
+
+ it('returns undefined when Lambda has no tags', async function () {
+ const lambdaNode = {
+ name: 'myFunction',
+ regionCode: 'us-west-2',
+ } as LambdaFunctionNode
+
+ lambdaClientStub.getFunction.resolves({})
+
+ // Skip CloudFormation mocking for now
+ // This is difficult to mock correctly without errors and would be better tested with integration tests
+
+ const result = await lambda2sam.determineStackAssociation(lambdaNode)
+
+ assert.strictEqual(result, undefined)
+ assert.strictEqual(lambdaClientStub.getFunction.calledOnceWith(lambdaNode.name), true)
+ })
+
+ // For this function, additional testing would require complex mocking of the AWS SDK
+ // Consider adding more specific test cases in an integration test
+ })
+})
diff --git a/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samCoreLogic.test.ts b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samCoreLogic.test.ts
new file mode 100644
index 00000000000..552d0104b7e
--- /dev/null
+++ b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samCoreLogic.test.ts
@@ -0,0 +1,784 @@
+/*!
+ * 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 * as vscode from 'vscode'
+import * as lambda2sam from '../../../../awsService/appBuilder/lambda2sam/lambda2sam'
+import * as cloudFormation from '../../../../shared/cloudformation/cloudformation'
+import * as utils from '../../../../awsService/appBuilder/utils'
+import * as walkthrough from '../../../../awsService/appBuilder/walkthrough'
+import * as authUtils from '../../../../auth/utils'
+import { getTestWindow } from '../../../shared/vscode/window'
+import { fs } from '../../../../shared'
+import { DefaultLambdaClient } from '../../../../shared/clients/lambdaClient'
+import { LambdaFunctionNode } from '../../../../lambda/explorer/lambdaFunctionNode'
+import { ToolkitError } from '../../../../shared/errors'
+import os from 'os'
+import path from 'path'
+import { LAMBDA_FUNCTION_TYPE } from '../../../../shared/cloudformation/cloudformation'
+import { ResourcesToImport } from 'aws-sdk/clients/cloudformation'
+
+describe('lambda2samCoreLogic', function () {
+ let sandbox: sinon.SinonSandbox
+ let tempDir: string
+ let lambdaClientStub: sinon.SinonStubbedInstance
+ let cfnClientStub: any
+ let downloadUnzipStub: sinon.SinonStub
+
+ beforeEach(async function () {
+ sandbox = sinon.createSandbox()
+ tempDir = path.join(os.tmpdir(), `aws-toolkit-test-${Date.now()}`)
+
+ // Create temp directory for tests - actually create it, don't stub
+ if (!(await fs.exists(vscode.Uri.file(tempDir)))) {
+ await fs.mkdir(vscode.Uri.file(tempDir))
+ }
+
+ // Create Lambda client stub with necessary properties
+ lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient)
+ Object.defineProperty(lambdaClientStub, 'defaultTimeoutInMs', {
+ value: 5 * 60 * 1000, // 5 minutes
+ configurable: true,
+ })
+ Object.defineProperty(lambdaClientStub, 'createSdkClient', {
+ value: () => Promise.resolve({}),
+ configurable: true,
+ })
+
+ sandbox.stub(utils, 'getLambdaClient').returns(lambdaClientStub as any)
+
+ // Mock CloudFormation client - now returns Promises directly (no .promise() method)
+ cfnClientStub = {
+ describeStackResource: sandbox.stub().resolves({
+ StackResourceDetail: {
+ PhysicalResourceId: 'test-physical-id',
+ },
+ }),
+ describeStackResources: sandbox.stub().resolves({
+ StackResources: [
+ { LogicalResourceId: 'testResource', PhysicalResourceId: 'test-physical-id' },
+ { LogicalResourceId: 'prefixTestResource', PhysicalResourceId: 'prefix-test-physical-id' },
+ ],
+ }),
+ describeStacks: sandbox.stub().resolves({
+ Stacks: [
+ {
+ StackId: 'stack-id',
+ StackName: 'test-stack',
+ StackStatus: 'CREATE_COMPLETE',
+ },
+ ],
+ }),
+ getTemplate: sandbox.stub().resolves({
+ TemplateBody: '{"Resources": {"TestFunc": {"Type": "AWS::Lambda::Function"}}}',
+ }),
+ getGeneratedTemplate: sandbox.stub().resolves({
+ Status: 'COMPLETE',
+ TemplateBody:
+ '{"Resources": {"TestFunc": {"Type": "AWS::Lambda::Function", "Properties": {"FunctionName": "test-function"}}}}',
+ }),
+ describeGeneratedTemplate: sandbox.stub().resolves({
+ Status: 'COMPLETE',
+ Resources: [
+ {
+ LogicalResourceId: 'TestFunc',
+ ResourceType: 'AWS::Lambda::Function',
+ ResourceIdentifier: {
+ FunctionName: 'arn:aws:lambda:us-east-2:123456789012:function:test-function',
+ },
+ },
+ ],
+ }),
+ createChangeSet: sandbox.stub().resolves({ Id: 'change-set-id' }),
+ waitFor: sandbox.stub().resolves(),
+ executeChangeSet: sandbox.stub().resolves(),
+ describeChangeSet: sandbox.stub().resolves({
+ StatusReason: 'Test reason',
+ }),
+ }
+ sandbox.stub(utils, 'getCFNClient').resolves(cfnClientStub)
+
+ // Setup test window to return appropriate values
+ getTestWindow().onDidShowMessage((msg) => {
+ if (msg.message.includes('Enter Stack Name')) {
+ msg.selectItem('test-stack')
+ }
+ })
+
+ getTestWindow().onDidShowDialog((dialog) => {
+ dialog.selectItem(vscode.Uri.file(tempDir))
+ })
+
+ // Stub downloadUnzip function
+ downloadUnzipStub = sandbox.stub(utils, 'downloadUnzip').callsFake(async (url, outputPath) => {
+ // Create a mock file structure for testing purposes
+ if (!(await fs.exists(outputPath))) {
+ await fs.mkdir(outputPath)
+ }
+
+ await fs.writeFile(
+ vscode.Uri.joinPath(outputPath, 'index.js'),
+ 'exports.handler = async (event) => { return "Hello World" };'
+ )
+ await fs.writeFile(
+ vscode.Uri.joinPath(outputPath, 'package.json'),
+ JSON.stringify(
+ {
+ name: 'test-lambda',
+ version: '1.0.0',
+ description: 'Test Lambda function',
+ },
+ undefined,
+ 2
+ )
+ )
+ })
+
+ // Stub workspace functions
+ sandbox.stub(vscode.workspace, 'openTextDocument').resolves({} as any)
+ sandbox.stub(vscode.window, 'showTextDocument').resolves()
+ })
+
+ afterEach(async function () {
+ sandbox.restore()
+
+ // Clean up the temp directory
+ if (await fs.exists(vscode.Uri.file(tempDir))) {
+ await fs.delete(vscode.Uri.file(tempDir), { recursive: true, force: true })
+ }
+ })
+
+ describe('processLambdaUrlResources', function () {
+ it('converts Lambda URL resources to FunctionUrlConfig', async function () {
+ // Setup resources with Lambda URL - using 'as any' to bypass strict typing for tests
+ const resources: cloudFormation.TemplateResources = {
+ TestFunc: {
+ Type: cloudFormation.SERVERLESS_FUNCTION_TYPE,
+ Properties: {
+ FunctionName: 'test-function',
+ PackageType: 'Zip',
+ },
+ },
+ TestFuncUrl: {
+ Type: cloudFormation.LAMBDA_URL_TYPE,
+ Properties: {
+ TargetFunctionArn: { Ref: 'TestFunc' },
+ AuthType: 'NONE',
+ },
+ },
+ } as any
+
+ // Call the function
+ await lambda2sam.processLambdaUrlResources(resources)
+
+ // Verify URL resource is removed
+ assert.strictEqual(resources['TestFuncUrl'], undefined)
+
+ // Verify FunctionUrlConfig added to function resource using non-null assertion
+ assert.deepStrictEqual(resources['TestFunc']!.Properties!.FunctionUrlConfig, {
+ AuthType: 'NONE',
+ Cors: undefined,
+ InvokeMode: undefined,
+ })
+ })
+
+ it('skips URL resources with Qualifier property', async function () {
+ // Setup resources with Lambda URL including Qualifier - using 'as any' to bypass strict typing for tests
+ const resources: cloudFormation.TemplateResources = {
+ TestFunc: {
+ Type: cloudFormation.SERVERLESS_FUNCTION_TYPE,
+ Properties: {
+ FunctionName: 'test-function',
+ PackageType: 'Zip',
+ },
+ },
+ TestFuncUrl: {
+ Type: cloudFormation.LAMBDA_URL_TYPE,
+ Properties: {
+ TargetFunctionArn: { Ref: 'TestFunc' },
+ AuthType: 'NONE',
+ Qualifier: 'prod',
+ },
+ },
+ } as any
+
+ // Call the function
+ await lambda2sam.processLambdaUrlResources(resources)
+
+ // Verify URL resource is still there (not transformed)
+ assert.notStrictEqual(resources['TestFuncUrl'], undefined)
+
+ // Verify function resource doesn't have FunctionUrlConfig using non-null assertion
+ assert.strictEqual(resources['TestFunc']!.Properties!.FunctionUrlConfig, undefined)
+ })
+ })
+
+ describe('processLambdaResources', function () {
+ it('transforms AWS::Lambda::Function to AWS::Serverless::Function', async function () {
+ // Setup resources with Lambda function - using 'as any' to bypass strict typing for tests
+ const resources: cloudFormation.TemplateResources = {
+ TestFunc: {
+ Type: cloudFormation.LAMBDA_FUNCTION_TYPE,
+ Properties: {
+ FunctionName: 'test-function',
+ Handler: 'index.handler',
+ Runtime: 'nodejs18.x',
+ Code: {
+ S3Bucket: 'test-bucket',
+ S3Key: 'test-key',
+ },
+ Tags: [
+ { Key: 'test-key', Value: 'test-value' },
+ { Key: 'lambda:createdBy', Value: 'test' },
+ ],
+ TracingConfig: {
+ Mode: 'Active',
+ },
+ PackageType: 'Zip',
+ },
+ },
+ } as any
+
+ const stackInfo = {
+ stackId: 'stack-id',
+ stackName: 'test-stack',
+ isSamTemplate: false,
+ template: {},
+ }
+
+ const projectDir = vscode.Uri.file(tempDir)
+
+ // Add necessary stub for getFunction
+ lambdaClientStub.getFunction.resolves({
+ Code: { Location: 'https://lambda-function-code.zip' },
+ })
+
+ // Call the function
+ await lambda2sam.processLambdaResources(resources, projectDir, stackInfo, 'us-west-2')
+
+ // Verify function type was transformed using non-null assertions
+ assert.strictEqual(resources['TestFunc']!.Type, cloudFormation.SERVERLESS_FUNCTION_TYPE)
+
+ // Verify properties were transformed correctly using non-null assertions
+ assert.strictEqual(resources['TestFunc']!.Properties!.Code, undefined)
+ assert.strictEqual(resources['TestFunc']!.Properties!.CodeUri, 'TestFunc')
+ assert.strictEqual(resources['TestFunc']!.Properties!.Tracing, 'Active')
+ assert.strictEqual(resources['TestFunc']!.Properties!.TracingConfig, undefined)
+ assert.deepStrictEqual(resources['TestFunc']!.Properties!.Tags, {
+ 'test-key': 'test-value',
+ })
+
+ // Verify downloadLambdaFunctionCode was called
+ assert.strictEqual(downloadUnzipStub.calledOnce, true)
+ })
+
+ it('updates CodeUri for AWS::Serverless::Function', async function () {
+ // Setup resources with Serverless function - using 'as any' to bypass strict typing for tests
+ const resources: cloudFormation.TemplateResources = {
+ TestFunc: {
+ Type: cloudFormation.SERVERLESS_FUNCTION_TYPE,
+ Properties: {
+ FunctionName: 'test-function',
+ Handler: 'index.handler',
+ Runtime: 'nodejs18.x',
+ CodeUri: 's3://test-bucket/test-key',
+ PackageType: 'Zip',
+ },
+ },
+ } as any
+
+ const stackInfo = {
+ stackId: 'stack-id',
+ stackName: 'test-stack',
+ isSamTemplate: false,
+ template: {},
+ }
+
+ const projectDir = vscode.Uri.file(tempDir)
+
+ // Add necessary stub for getFunction
+ lambdaClientStub.getFunction.resolves({
+ Code: { Location: 'https://lambda-function-code.zip' },
+ })
+
+ // Call the function
+ await lambda2sam.processLambdaResources(resources, projectDir, stackInfo, 'us-west-2')
+
+ // Verify CodeUri was updated using non-null assertions
+ assert.strictEqual(resources['TestFunc']!.Properties!.CodeUri, 'TestFunc')
+
+ // Verify downloadLambdaFunctionCode was called
+ assert.strictEqual(downloadUnzipStub.calledOnce, true)
+ })
+ })
+
+ describe('processLambdaLayerResources', function () {
+ it('transforms AWS::Lambda::LayerVersion to AWS::Serverless::LayerVersion', async function () {
+ // Setup resources with Lambda layer - using 'as any' to bypass strict typing for tests
+ const resources: cloudFormation.TemplateResources = {
+ TestLayer: {
+ Type: cloudFormation.LAMBDA_LAYER_TYPE,
+ Properties: {
+ LayerName: 'test-layer',
+ Content: {
+ S3Bucket: 'test-bucket',
+ S3Key: 'test-key',
+ },
+ CompatibleRuntimes: ['nodejs18.x'],
+ },
+ },
+ } as any
+
+ const stackInfo = {
+ stackId: 'stack-id',
+ stackName: 'test-stack',
+ isSamTemplate: false,
+ template: {},
+ }
+
+ const projectDir = vscode.Uri.file(tempDir)
+
+ // Setup layer version stub
+ cfnClientStub.describeStackResource.resolves({
+ StackResourceDetail: {
+ PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1',
+ },
+ })
+
+ lambdaClientStub.getLayerVersion.resolves({
+ Content: { Location: 'https://lambda-layer-code.zip' },
+ })
+
+ // Call the function
+ await lambda2sam.processLambdaLayerResources(resources, projectDir, stackInfo, 'us-west-2')
+
+ // Verify layer type was transformed using non-null assertions
+ assert.strictEqual(resources['TestLayer']!.Type, cloudFormation.SERVERLESS_LAYER_TYPE)
+
+ // Verify properties were transformed correctly using non-null assertions
+ assert.strictEqual(resources['TestLayer']!.Properties!.Content, undefined)
+ assert.strictEqual(resources['TestLayer']!.Properties!.ContentUri, 'TestLayer')
+ assert.deepStrictEqual(resources['TestLayer']!.Properties!.CompatibleRuntimes, ['nodejs18.x'])
+
+ // Verify downloadLayerVersionResrouceByName was called (through downloadUnzip)
+ assert.strictEqual(downloadUnzipStub.calledOnce, true)
+ })
+
+ it('preserves AWS::Serverless::LayerVersion properties', async function () {
+ // Setup resources with Serverless layer - using 'as any' to bypass strict typing for tests
+ const resources: cloudFormation.TemplateResources = {
+ TestLayer: {
+ Type: cloudFormation.SERVERLESS_LAYER_TYPE,
+ Properties: {
+ LayerName: 'test-layer',
+ ContentUri: 's3://test-bucket/test-key',
+ CompatibleRuntimes: ['nodejs18.x'],
+ },
+ },
+ } as any
+
+ const stackInfo = {
+ stackId: 'stack-id',
+ stackName: 'test-stack',
+ isSamTemplate: false,
+ template: {},
+ }
+
+ const projectDir = vscode.Uri.file(tempDir)
+
+ // Setup layer version stub
+ cfnClientStub.describeStackResource.resolves({
+ StackResourceDetail: {
+ PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1',
+ },
+ })
+
+ lambdaClientStub.getLayerVersion.resolves({
+ Content: { Location: 'https://lambda-layer-code.zip' },
+ })
+
+ // Call the function
+ await lambda2sam.processLambdaLayerResources(resources, projectDir, stackInfo, 'us-west-2')
+
+ // Verify layer type is still serverless using non-null assertions
+ assert.strictEqual(resources['TestLayer']!.Type, cloudFormation.SERVERLESS_LAYER_TYPE)
+
+ // Verify ContentUri was updated using non-null assertions
+ assert.strictEqual(resources['TestLayer']!.Properties!.ContentUri, 'TestLayer')
+
+ // Verify downloadLayerVersionResrouceByName was called (through downloadUnzip)
+ assert.strictEqual(downloadUnzipStub.calledOnce, true)
+ })
+ })
+
+ describe('deployCfnTemplate', function () {
+ it('deploys a CloudFormation template and returns stack info', async function () {
+ // Setup CloudFormation template - using 'as any' to bypass strict typing for tests
+ const template: cloudFormation.Template = {
+ AWSTemplateFormatVersion: '2010-09-09',
+ Resources: {
+ TestFunc: {
+ Type: cloudFormation.LAMBDA_FUNCTION_TYPE,
+ Properties: {
+ FunctionName: 'test-function',
+ PackageType: 'Zip',
+ },
+ },
+ },
+ } as any
+
+ // Setup Lambda node
+ const lambdaNode = {
+ name: 'test-function',
+ regionCode: 'us-west-2',
+ } as LambdaFunctionNode
+
+ const resourceToImport: ResourcesToImport = [
+ {
+ ResourceType: LAMBDA_FUNCTION_TYPE,
+ LogicalResourceId: 'TestFunc',
+ ResourceIdentifier: {
+ FunctionName: lambdaNode.name,
+ },
+ },
+ ]
+
+ // Call the function
+ const result = await lambda2sam.deployCfnTemplate(
+ template,
+ resourceToImport,
+ 'test-stack',
+ lambdaNode.regionCode
+ )
+
+ // Verify createChangeSet was called with correct parameters
+ assert.strictEqual(cfnClientStub.createChangeSet.called, true)
+ const createChangeSetArgs = cfnClientStub.createChangeSet.firstCall.args[0]
+ assert.strictEqual(createChangeSetArgs.StackName, 'test-stack')
+ assert.strictEqual(createChangeSetArgs.ChangeSetType, 'IMPORT')
+
+ // Verify waitFor and executeChangeSet were called
+ assert.strictEqual(cfnClientStub.waitFor.calledWith('changeSetCreateComplete'), true)
+ assert.strictEqual(cfnClientStub.executeChangeSet.called, true)
+
+ // Verify describeStacks was called to get stack ID
+ assert.strictEqual(cfnClientStub.describeStacks.called, true)
+
+ // Verify result structure
+ assert.strictEqual(result.stackId, 'stack-id')
+ assert.strictEqual(result.stackName, 'test-stack')
+ assert.strictEqual(result.isSamTemplate, false)
+ assert.deepStrictEqual(result.template, template)
+ })
+
+ it('throws an error when change set creation fails', async function () {
+ // Setup CloudFormation template - using 'as any' to bypass strict typing for tests
+ const template: cloudFormation.Template = {
+ AWSTemplateFormatVersion: '2010-09-09',
+ Resources: {
+ TestFunc: {
+ Type: cloudFormation.LAMBDA_FUNCTION_TYPE,
+ Properties: {
+ FunctionName: 'test-function',
+ PackageType: 'Zip',
+ },
+ },
+ },
+ } as any
+
+ // Setup Lambda node
+ const lambdaNode = {
+ name: 'test-function',
+ regionCode: 'us-west-2',
+ } as LambdaFunctionNode
+
+ // Make createChangeSet fail
+ cfnClientStub.createChangeSet.resolves({}) // No Id
+
+ const resourceToImport: ResourcesToImport = [
+ {
+ ResourceType: LAMBDA_FUNCTION_TYPE,
+ LogicalResourceId: 'TestFunc',
+ ResourceIdentifier: {
+ FunctionName: lambdaNode.name,
+ },
+ },
+ ]
+
+ // Call the function and expect error
+ await assert.rejects(
+ lambda2sam.deployCfnTemplate(template, resourceToImport, 'test-stack', lambdaNode.regionCode),
+ (err: ToolkitError) => {
+ assert.strictEqual(err.message.includes('Failed to create change set'), true)
+ return true
+ }
+ )
+ })
+ })
+
+ describe('callExternalApiForCfnTemplate', function () {
+ it('extracts function name from ARN in ResourceIdentifier', async function () {
+ // Setup Lambda node
+ const lambdaNode = {
+ name: 'test-function',
+ regionCode: 'us-east-2',
+ arn: 'arn:aws:lambda:us-east-2:123456789012:function:test-function',
+ } as LambdaFunctionNode
+
+ // Mock IAM connection
+ const mockConnection = {
+ type: 'iam' as const,
+ id: 'test-connection',
+ label: 'Test Connection',
+ state: 'valid' as const,
+ getCredentials: sandbox.stub().resolves({
+ accessKeyId: 'test-key',
+ secretAccessKey: 'test-secret',
+ }),
+ }
+ sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection)
+
+ // Mock fetch response
+ const mockFetch = sandbox.stub(global, 'fetch').resolves({
+ ok: true,
+ json: sandbox.stub().resolves({
+ cloudFormationTemplateId: 'test-template-id',
+ }),
+ } as any)
+
+ // Setup CloudFormation client to return ARN in ResourceIdentifier
+ cfnClientStub.describeGeneratedTemplate.resolves({
+ Status: 'COMPLETE',
+ Resources: [
+ {
+ LogicalResourceId: 'TestFunc',
+ ResourceType: 'AWS::Lambda::Function',
+ ResourceIdentifier: {
+ FunctionName: 'arn:aws:lambda:us-east-2:123456789012:function:test-function',
+ },
+ },
+ ],
+ })
+
+ // Call the function
+ const [_, resourcesToImport] = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode)
+
+ // Verify that the ARN was converted to just the function name
+ assert.strictEqual(resourcesToImport.length, 1)
+ assert.strictEqual(resourcesToImport[0].ResourceType, 'AWS::Lambda::Function')
+ assert.strictEqual(resourcesToImport[0].LogicalResourceId, 'TestFunc')
+ assert.strictEqual(resourcesToImport[0].ResourceIdentifier!.FunctionName, 'test-function')
+
+ // Verify API calls were made
+ assert.strictEqual(mockFetch.calledOnce, true)
+ assert.strictEqual(cfnClientStub.getGeneratedTemplate.calledOnce, true)
+ assert.strictEqual(cfnClientStub.describeGeneratedTemplate.calledOnce, true)
+ })
+
+ it('preserves function name when not an ARN', async function () {
+ // Setup Lambda node
+ const lambdaNode = {
+ name: 'test-function',
+ regionCode: 'us-east-2',
+ arn: 'arn:aws:lambda:us-east-2:123456789012:function:test-function',
+ } as LambdaFunctionNode
+
+ // Mock IAM connection
+ const mockConnection = {
+ type: 'iam' as const,
+ id: 'test-connection',
+ label: 'Test Connection',
+ state: 'valid' as const,
+ getCredentials: sandbox.stub().resolves({
+ accessKeyId: 'test-key',
+ secretAccessKey: 'test-secret',
+ }),
+ }
+ sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection)
+
+ // Mock fetch response
+ sandbox.stub(global, 'fetch').resolves({
+ ok: true,
+ json: sandbox.stub().resolves({
+ cloudFormationTemplateId: 'test-template-id',
+ }),
+ } as any)
+
+ // Setup CloudFormation client to return plain function name
+ cfnClientStub.describeGeneratedTemplate.resolves({
+ Status: 'COMPLETE',
+ Resources: [
+ {
+ LogicalResourceId: 'TestFunc',
+ ResourceType: 'AWS::Lambda::Function',
+ ResourceIdentifier: {
+ FunctionName: 'test-function',
+ },
+ },
+ ],
+ })
+
+ // Call the function
+ const [_, resourcesToImport] = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode)
+
+ // Verify that the function name was preserved
+ assert.strictEqual(resourcesToImport.length, 1)
+ assert.strictEqual(resourcesToImport[0].ResourceIdentifier!.FunctionName, 'test-function')
+ })
+
+ it('handles non-Lambda resources without modification', async function () {
+ // Setup Lambda node
+ const lambdaNode = {
+ name: 'test-function',
+ regionCode: 'us-east-2',
+ arn: 'arn:aws:lambda:us-east-2:123456789012:function:test-function',
+ } as LambdaFunctionNode
+
+ // Mock IAM connection
+ const mockConnection = {
+ type: 'iam' as const,
+ id: 'test-connection',
+ label: 'Test Connection',
+ state: 'valid' as const,
+ getCredentials: sandbox.stub().resolves({
+ accessKeyId: 'test-key',
+ secretAccessKey: 'test-secret',
+ }),
+ }
+ sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection)
+
+ // Mock fetch response
+ sandbox.stub(global, 'fetch').resolves({
+ ok: true,
+ json: sandbox.stub().resolves({
+ cloudFormationTemplateId: 'test-template-id',
+ }),
+ } as any)
+
+ // Setup CloudFormation client to return mixed resource types
+ cfnClientStub.describeGeneratedTemplate.resolves({
+ Status: 'COMPLETE',
+ Resources: [
+ {
+ LogicalResourceId: 'TestFunc',
+ ResourceType: 'AWS::Lambda::Function',
+ ResourceIdentifier: {
+ FunctionName: 'arn:aws:lambda:us-east-2:123456789012:function:test-function',
+ },
+ },
+ {
+ LogicalResourceId: 'TestRole',
+ ResourceType: 'AWS::IAM::Role',
+ ResourceIdentifier: {
+ RoleName: 'test-role',
+ },
+ },
+ ],
+ })
+
+ // Call the function
+ const [_, resourcesToImport] = await lambda2sam.callExternalApiForCfnTemplate(lambdaNode)
+
+ // Verify that Lambda function ARN was converted but IAM role was not
+ assert.strictEqual(resourcesToImport.length, 2)
+
+ const lambdaResource = resourcesToImport.find((r) => r.ResourceType === 'AWS::Lambda::Function')
+ const iamResource = resourcesToImport.find((r) => r.ResourceType === 'AWS::IAM::Role')
+
+ assert.strictEqual(lambdaResource!.ResourceIdentifier!.FunctionName, 'test-function')
+ assert.strictEqual(iamResource!.ResourceIdentifier!.RoleName, 'test-role')
+ })
+ })
+
+ describe('lambdaToSam', function () {
+ it('converts a Lambda function to a SAM project', async function () {
+ // Setup Lambda node
+ const lambdaNode = {
+ name: 'test-function',
+ regionCode: 'us-west-2',
+ } as LambdaFunctionNode
+
+ // Setup AWS Lambda client responses
+ lambdaClientStub.getFunction.resolves({
+ Tags: {
+ 'aws:cloudformation:stack-id': 'stack-id',
+ 'aws:cloudformation:stack-name': 'test-stack',
+ },
+ Configuration: {
+ FunctionName: 'test-function',
+ Handler: 'index.handler',
+ Runtime: 'nodejs18.x',
+ },
+ Code: {
+ Location: 'https://lambda-function-code.zip',
+ },
+ })
+
+ // Setup CloudFormation client responses
+ cfnClientStub.describeStacks.resolves({
+ Stacks: [
+ {
+ StackId: 'stack-id',
+ StackName: 'test-stack',
+ StackStatus: 'CREATE_COMPLETE',
+ },
+ ],
+ })
+
+ cfnClientStub.getTemplate.resolves({
+ TemplateBody: JSON.stringify({
+ AWSTemplateFormatVersion: '2010-09-09',
+ Transform: 'AWS::Serverless-2016-10-31',
+ Resources: {
+ TestFunc: {
+ Type: 'AWS::Serverless::Function',
+ Properties: {
+ FunctionName: 'test-function',
+ Handler: 'index.handler',
+ Runtime: 'nodejs18.x',
+ CodeUri: 's3://test-bucket/test-key',
+ PackageType: 'Zip',
+ },
+ },
+ },
+ }),
+ })
+
+ // Setup test window to return a project directory
+ getTestWindow().onDidShowDialog((dialog) => {
+ dialog.selectItem(vscode.Uri.file(tempDir))
+ })
+ // Spy on walkthrough.openProjectInWorkspace
+ const openProjectStub = sandbox.stub(walkthrough, 'openProjectInWorkspace')
+
+ // Call the function
+ await lambda2sam.lambdaToSam(lambdaNode)
+
+ assert.strictEqual(
+ await fs.exists(vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack', 'template.yaml').fsPath),
+ true,
+ 'template.yaml was not written'
+ )
+ assert.strictEqual(
+ await fs.exists(vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack', 'README.md').fsPath),
+ true,
+ 'README.md was not written'
+ )
+ assert.strictEqual(
+ await fs.exists(vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack', 'samconfig.toml').fsPath),
+ true,
+ 'samconfig.toml was not written'
+ )
+
+ // Verify that project was opened in workspace
+ assert.strictEqual(openProjectStub.calledOnce, true)
+ assert.strictEqual(
+ openProjectStub.firstCall.args[0].fsPath,
+ vscode.Uri.joinPath(vscode.Uri.file(tempDir), 'test-stack').fsPath
+ )
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samDownload.test.ts b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samDownload.test.ts
new file mode 100644
index 00000000000..9c4d3122918
--- /dev/null
+++ b/packages/core/src/test/awsService/appBuilder/lambda2sam/lambda2samDownload.test.ts
@@ -0,0 +1,312 @@
+/*!
+ * 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 * as vscode from 'vscode'
+import * as lambda2sam from '../../../../awsService/appBuilder/lambda2sam/lambda2sam'
+import * as utils from '../../../../awsService/appBuilder/utils'
+import { fs } from '../../../../shared'
+import { DefaultLambdaClient } from '../../../../shared/clients/lambdaClient'
+import os from 'os'
+import path from 'path'
+import { LAMBDA_FUNCTION_TYPE, LAMBDA_LAYER_TYPE } from '../../../../shared/cloudformation/cloudformation'
+
+describe('lambda2samDownload', function () {
+ let sandbox: sinon.SinonSandbox
+ let tempDir: string
+ let lambdaClientStub: sinon.SinonStubbedInstance
+ let cfnClientStub: any
+ let downloadUnzipStub: sinon.SinonStub
+
+ beforeEach(async function () {
+ sandbox = sinon.createSandbox()
+ tempDir = path.join(os.tmpdir(), `aws-toolkit-test-${Date.now()}`)
+
+ // Create temp directory for tests - actually create it, don't stub
+ if (!(await fs.exists(vscode.Uri.file(tempDir)))) {
+ await fs.mkdir(vscode.Uri.file(tempDir))
+ }
+
+ // Create Lambda client stub with necessary properties
+ lambdaClientStub = sandbox.createStubInstance(DefaultLambdaClient)
+ // Add required properties that aren't stubbed automatically
+ Object.defineProperty(lambdaClientStub, 'defaultTimeoutInMs', {
+ value: 5 * 60 * 1000, // 5 minutes
+ configurable: true,
+ })
+ Object.defineProperty(lambdaClientStub, 'createSdkClient', {
+ value: () => Promise.resolve({}),
+ configurable: true,
+ })
+
+ sandbox.stub(utils, 'getLambdaClient').returns(lambdaClientStub as any)
+
+ // Stub CloudFormation client - now returns Promises directly (no .promise() method)
+ cfnClientStub = {
+ describeStackResource: sandbox.stub().resolves({
+ StackResourceDetail: {
+ PhysicalResourceId: 'test-physical-id',
+ },
+ }),
+ describeStackResources: sandbox.stub().resolves({
+ StackResources: [
+ { LogicalResourceId: 'testResource', PhysicalResourceId: 'test-physical-id' },
+ { LogicalResourceId: 'prefixTestResource', PhysicalResourceId: 'prefix-test-physical-id' },
+ ],
+ }),
+ }
+ sandbox.stub(utils, 'getCFNClient').resolves(cfnClientStub)
+
+ // Stub downloadUnzip function to create actual files in the temp directory
+ downloadUnzipStub = sandbox.stub(utils, 'downloadUnzip').callsFake(async (url, outputPath) => {
+ // Create a mock file structure for testing purposes
+
+ // Create the output directory if it doesn't exist
+ if (!(await fs.exists(outputPath))) {
+ await fs.mkdir(outputPath)
+ }
+
+ // Create a simple file to simulate extracted content
+ await fs.writeFile(
+ vscode.Uri.joinPath(outputPath, 'index.js'),
+ 'exports.handler = async (event) => { return "Hello World" };'
+ )
+
+ // Create a package.json file
+ await fs.writeFile(
+ vscode.Uri.joinPath(outputPath, 'package.json'),
+ JSON.stringify(
+ {
+ name: 'test-lambda',
+ version: '1.0.0',
+ description: 'Test Lambda function',
+ },
+ undefined,
+ 2
+ )
+ )
+ })
+ })
+
+ afterEach(async function () {
+ sandbox.restore()
+
+ // Clean up the temp directory after each test
+ if (await fs.exists(vscode.Uri.file(tempDir))) {
+ await fs.delete(vscode.Uri.file(tempDir), { recursive: true, force: true })
+ }
+ })
+
+ describe('getPhysicalIdfromCFNResourceName', function () {
+ it('returns the physical ID when an exact match is found', async function () {
+ const result = await lambda2sam.getPhysicalIdfromCFNResourceName(
+ 'testResource',
+ 'us-west-2',
+ 'stack-id',
+ LAMBDA_FUNCTION_TYPE
+ )
+
+ assert.strictEqual(cfnClientStub.describeStackResource.calledOnce, true)
+ assert.strictEqual(cfnClientStub.describeStackResource.firstCall.args[0].StackName, 'stack-id')
+ assert.strictEqual(cfnClientStub.describeStackResource.firstCall.args[0].LogicalResourceId, 'testResource')
+ assert.strictEqual(result, 'test-physical-id')
+ })
+
+ it('returns a prefix match when exact match fails', async function () {
+ // Make exact match fail
+ cfnClientStub.describeStackResource.rejects(new Error('Resource not found'))
+
+ const result = await lambda2sam.getPhysicalIdfromCFNResourceName(
+ 'prefix',
+ 'us-west-2',
+ 'stack-id',
+ LAMBDA_LAYER_TYPE
+ )
+
+ assert.strictEqual(cfnClientStub.describeStackResources.calledOnce, true)
+ assert.strictEqual(cfnClientStub.describeStackResources.firstCall.args[0].StackName, 'stack-id')
+ assert.strictEqual(result, 'prefix-test-physical-id')
+ })
+
+ it('returns undefined when no match is found', async function () {
+ // Make exact match fail
+ cfnClientStub.describeStackResource.rejects(new Error('Resource not found'))
+
+ // Return empty resources
+ cfnClientStub.describeStackResources.resolves({ StackResources: [] })
+
+ const result = await lambda2sam.getPhysicalIdfromCFNResourceName(
+ 'nonexistent',
+ 'us-west-2',
+ 'stack-id',
+ LAMBDA_LAYER_TYPE
+ )
+ assert.strictEqual(result, undefined)
+ })
+ })
+
+ describe('downloadLambdaFunctionCode', function () {
+ it('uses physical ID from CloudFormation when not provided', async function () {
+ const targetDir = vscode.Uri.file(tempDir)
+ const resourceName = 'testResource'
+ const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} }
+
+ lambdaClientStub.getFunction.resolves({
+ Code: { Location: 'https://lambda-function-code.zip' },
+ })
+
+ await lambda2sam.downloadLambdaFunctionCode(resourceName, stackInfo, targetDir, 'us-west-2')
+
+ // Verify CloudFormation was called to get physical ID
+ assert.strictEqual(cfnClientStub.describeStackResource.calledOnce, true)
+
+ // Verify Lambda client was called with correct physical ID
+ assert.strictEqual(lambdaClientStub.getFunction.calledOnce, true)
+ assert.strictEqual(lambdaClientStub.getFunction.firstCall.args[0], 'test-physical-id')
+
+ // Verify downloadUnzip was called with correct parameters
+ assert.strictEqual(downloadUnzipStub.calledOnce, true)
+ assert.strictEqual(downloadUnzipStub.firstCall.args[0], 'https://lambda-function-code.zip')
+ assert.strictEqual(
+ downloadUnzipStub.firstCall.args[1].fsPath,
+ vscode.Uri.joinPath(targetDir, resourceName).fsPath
+ )
+
+ // Verify files were actually created in the temp directory
+ const outputDir = vscode.Uri.joinPath(targetDir, resourceName)
+ assert.strictEqual(await fs.exists(outputDir), true)
+ assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'index.js')), true)
+ assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'package.json')), true)
+ })
+
+ it('uses provided physical ID when available', async function () {
+ const targetDir = vscode.Uri.file(tempDir)
+ const resourceName = 'testResource'
+ const physicalResourceId = 'provided-physical-id'
+ const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} }
+
+ lambdaClientStub.getFunction.resolves({
+ Code: { Location: 'https://lambda-function-code.zip' },
+ })
+
+ await lambda2sam.downloadLambdaFunctionCode(
+ resourceName,
+ stackInfo,
+ targetDir,
+ 'us-west-2',
+ physicalResourceId
+ )
+
+ // Verify CloudFormation was NOT called to get physical ID
+ assert.strictEqual(cfnClientStub.describeStackResource.called, false)
+
+ // Verify Lambda client was called with provided physical ID
+ assert.strictEqual(lambdaClientStub.getFunction.calledOnce, true)
+ assert.strictEqual(lambdaClientStub.getFunction.firstCall.args[0], physicalResourceId)
+
+ // Verify files were actually created in the temp directory
+ const outputDir = vscode.Uri.joinPath(targetDir, resourceName)
+ assert.strictEqual(await fs.exists(outputDir), true)
+ assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'index.js')), true)
+ assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'package.json')), true)
+ })
+
+ it('throws an error when code location is missing', async function () {
+ const targetDir = vscode.Uri.file(tempDir)
+ const resourceName = 'testResource'
+ const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} }
+
+ lambdaClientStub.getFunction.resolves({
+ Code: {}, // No Location
+ })
+
+ await assert.rejects(
+ lambda2sam.downloadLambdaFunctionCode(resourceName, stackInfo, targetDir, 'us-west-2'),
+ /Could not determine code location/
+ )
+ })
+ })
+
+ describe('downloadLayerVersionResourceByName', function () {
+ it('extracts layer name and version from ARN and downloads content', async function () {
+ const targetDir = vscode.Uri.file(tempDir)
+ const resourceName = 'testLayer'
+ const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} }
+
+ // Return an ARN for a layer version
+ cfnClientStub.describeStackResource.resolves({
+ StackResourceDetail: {
+ PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1',
+ },
+ })
+
+ lambdaClientStub.getLayerVersion.resolves({
+ Content: { Location: 'https://lambda-layer-code.zip' },
+ })
+
+ await lambda2sam.downloadLayerVersionResourceByName(resourceName, stackInfo, targetDir, 'us-west-2')
+
+ // Verify Lambda client was called with correct layer name and version
+ assert.strictEqual(lambdaClientStub.getLayerVersion.calledOnce, true)
+ assert.strictEqual(lambdaClientStub.getLayerVersion.firstCall.args[0], 'my-layer')
+ assert.strictEqual(lambdaClientStub.getLayerVersion.firstCall.args[1], 1)
+
+ // Verify downloadUnzip was called with correct parameters
+ assert.strictEqual(downloadUnzipStub.calledOnce, true)
+ assert.strictEqual(downloadUnzipStub.firstCall.args[0], 'https://lambda-layer-code.zip')
+ assert.strictEqual(
+ downloadUnzipStub.firstCall.args[1].fsPath,
+ vscode.Uri.joinPath(targetDir, resourceName).fsPath
+ )
+
+ // Verify files were actually created in the temp directory
+ const outputDir = vscode.Uri.joinPath(targetDir, resourceName)
+ assert.strictEqual(await fs.exists(outputDir), true)
+ assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'index.js')), true)
+ assert.strictEqual(await fs.exists(vscode.Uri.joinPath(outputDir, 'package.json')), true)
+ })
+
+ it('throws an error when ARN format is invalid', async function () {
+ const targetDir = vscode.Uri.file(tempDir)
+ const resourceName = 'testLayer'
+ const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} }
+
+ // Return an invalid ARN
+ cfnClientStub.describeStackResource.resolves({
+ StackResourceDetail: {
+ PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer', // Missing version
+ },
+ })
+
+ await assert.rejects(
+ lambda2sam.downloadLayerVersionResourceByName(resourceName, stackInfo, targetDir, 'us-west-2'),
+ /Invalid layer ARN format/
+ )
+ })
+
+ it('throws an error when layer content location is missing', async function () {
+ const targetDir = vscode.Uri.file(tempDir)
+ const resourceName = 'testLayer'
+ const stackInfo = { stackId: 'stack-id', stackName: 'test-stack', isSamTemplate: false, template: {} }
+
+ // Return an ARN for a layer version
+ cfnClientStub.describeStackResource.resolves({
+ StackResourceDetail: {
+ PhysicalResourceId: 'arn:aws:lambda:us-west-2:123456789012:layer:my-layer:1',
+ },
+ })
+
+ lambdaClientStub.getLayerVersion.resolves({
+ Content: {}, // No Location
+ })
+
+ await assert.rejects(
+ lambda2sam.downloadLayerVersionResourceByName(resourceName, stackInfo, targetDir, 'us-west-2'),
+ /Could not determine code location for layer/
+ )
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/appBuilder/utils.test.ts b/packages/core/src/test/awsService/appBuilder/utils.test.ts
index d74cfc77802..eaaa69254d7 100644
--- a/packages/core/src/test/awsService/appBuilder/utils.test.ts
+++ b/packages/core/src/test/awsService/appBuilder/utils.test.ts
@@ -12,9 +12,20 @@ import fs from '../../../shared/fs/fs'
import { ResourceNode } from '../../../awsService/appBuilder/explorer/nodes/resourceNode'
import path from 'path'
import { SERVERLESS_FUNCTION_TYPE } from '../../../shared/cloudformation/cloudformation'
-import { runOpenHandler, runOpenTemplate } from '../../../awsService/appBuilder/utils'
+import {
+ runOpenHandler,
+ runOpenTemplate,
+ isPermissionError,
+ EnhancedLambdaClient,
+ EnhancedCloudFormationClient,
+ getLambdaClient,
+ getCFNClient,
+} from '../../../awsService/appBuilder/utils'
import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider'
import { assertTextEditorContains } from '../../testUtil'
+import { DefaultLambdaClient } from '../../../shared/clients/lambdaClient'
+import { ToolkitError } from '../../../shared/errors'
+import globals from '../../../shared/extensionGlobals'
interface TestScenario {
runtime: string
@@ -303,4 +314,553 @@ describe('AppBuilder Utils', function () {
assert(showCommand.notCalled)
})
})
+
+ describe('Permission Error Handling', function () {
+ let sandbox: sinon.SinonSandbox
+
+ beforeEach(function () {
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(function () {
+ sandbox.restore()
+ })
+
+ describe('isPermissionError', function () {
+ it('should return true for AccessDeniedException', function () {
+ const error = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ assert.strictEqual(isPermissionError(error), true)
+ })
+
+ it('should return true for UnauthorizedOperation', function () {
+ const error = Object.assign(new Error('Unauthorized'), {
+ code: 'UnauthorizedOperation',
+ time: new Date(),
+ statusCode: 403,
+ })
+ assert.strictEqual(isPermissionError(error), true)
+ })
+
+ it('should return true for Forbidden', function () {
+ const error = Object.assign(new Error('Forbidden'), {
+ code: 'Forbidden',
+ time: new Date(),
+ statusCode: 403,
+ })
+ assert.strictEqual(isPermissionError(error), true)
+ })
+
+ it('should return true for AccessDenied', function () {
+ const error = Object.assign(new Error('Access denied'), {
+ code: 'AccessDenied',
+ time: new Date(),
+ statusCode: 403,
+ })
+ assert.strictEqual(isPermissionError(error), true)
+ })
+
+ it('should return true for 403 status code', function () {
+ const error = Object.assign(new Error('Forbidden'), {
+ code: 'SomeError',
+ statusCode: 403,
+ time: new Date(),
+ })
+ assert.strictEqual(isPermissionError(error), true)
+ })
+
+ it('should return false for non-permission errors', function () {
+ const error = Object.assign(new Error('Resource not found'), {
+ code: 'ResourceNotFoundException',
+ time: new Date(),
+ statusCode: 404,
+ })
+ assert.strictEqual(isPermissionError(error), false)
+ })
+
+ it('should return false for non-AWS errors', function () {
+ const error = new Error('Regular error')
+ assert.strictEqual(isPermissionError(error), false)
+ })
+
+ it('should return false for undefined', function () {
+ assert.strictEqual(isPermissionError(undefined), false)
+ })
+ })
+
+ describe('EnhancedLambdaClient', function () {
+ let mockLambdaClient: sinon.SinonStubbedInstance
+ let enhancedClient: EnhancedLambdaClient
+
+ beforeEach(function () {
+ mockLambdaClient = sandbox.createStubInstance(DefaultLambdaClient)
+ // Add missing properties that EnhancedLambdaClient expects
+ Object.defineProperty(mockLambdaClient, 'defaultTimeoutInMs', {
+ value: 5 * 60 * 1000,
+ configurable: true,
+ })
+ Object.defineProperty(mockLambdaClient, 'createSdkClient', {
+ value: sandbox.stub().resolves({}),
+ configurable: true,
+ })
+ enhancedClient = new EnhancedLambdaClient(mockLambdaClient as any, 'us-east-1')
+ })
+
+ it('should enhance permission errors for getFunction', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockLambdaClient.getFunction.rejects(permissionError)
+
+ try {
+ await enhancedClient.getFunction('test-function')
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes('Permission denied: Missing required permissions for lambda:getFunction')
+ )
+ assert(error.message.includes('lambda:GetFunction'))
+ assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function'))
+ assert(error.message.includes('To fix this issue:'))
+ assert(error.message.includes('Documentation:'))
+ }
+ })
+
+ it('should pass through non-permission errors for getFunction', async function () {
+ const nonPermissionError = new Error('Function not found')
+ mockLambdaClient.getFunction.rejects(nonPermissionError)
+
+ try {
+ await enhancedClient.getFunction('test-function')
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert.strictEqual(error, nonPermissionError)
+ }
+ })
+
+ it('should enhance permission errors for listFunctions', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+
+ // Create a mock async generator that throws the error
+ const mockAsyncGenerator = async function* (): AsyncIterableIterator {
+ throw permissionError
+ yield // This line will never be reached but satisfies ESLint require-yield rule
+ }
+ mockLambdaClient.listFunctions.returns(mockAsyncGenerator())
+
+ try {
+ const iterator = enhancedClient.listFunctions()
+ await iterator.next()
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for lambda:listFunctions'
+ )
+ )
+ assert(error.message.includes('lambda:ListFunctions'))
+ }
+ })
+
+ it('should enhance permission errors for deleteFunction', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockLambdaClient.deleteFunction.rejects(permissionError)
+
+ try {
+ await enhancedClient.deleteFunction('test-function')
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for lambda:deleteFunction'
+ )
+ )
+ assert(error.message.includes('lambda:DeleteFunction'))
+ assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function'))
+ }
+ })
+
+ it('should enhance permission errors for invoke', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockLambdaClient.invoke.rejects(permissionError)
+
+ try {
+ await enhancedClient.invoke('test-function', '{}')
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(error.message.includes('Permission denied: Missing required permissions for lambda:invoke'))
+ assert(error.message.includes('lambda:InvokeFunction'))
+ assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function'))
+ }
+ })
+
+ it('should enhance permission errors for getLayerVersion', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockLambdaClient.getLayerVersion.rejects(permissionError)
+
+ try {
+ await enhancedClient.getLayerVersion('test-layer', 1)
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for lambda:getLayerVersion'
+ )
+ )
+ assert(error.message.includes('lambda:GetLayerVersion'))
+ assert(error.message.includes('arn:aws:lambda:us-east-1:*:layer:test-layer:1'))
+ }
+ })
+
+ it('should enhance permission errors for updateFunctionCode', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockLambdaClient.updateFunctionCode.rejects(permissionError)
+
+ try {
+ await enhancedClient.updateFunctionCode('test-function', new Uint8Array())
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for lambda:updateFunctionCode'
+ )
+ )
+ assert(error.message.includes('lambda:UpdateFunctionCode'))
+ assert(error.message.includes('arn:aws:lambda:us-east-1:*:function:test-function'))
+ }
+ })
+
+ it('should return successful results when no errors occur', async function () {
+ const mockResponse = { Configuration: { FunctionName: 'test-function' } }
+ mockLambdaClient.getFunction.resolves(mockResponse)
+
+ const result = await enhancedClient.getFunction('test-function')
+ assert.strictEqual(result, mockResponse)
+ })
+ })
+
+ describe('EnhancedCloudFormationClient', function () {
+ let mockCfnClient: any
+ let enhancedClient: EnhancedCloudFormationClient
+
+ beforeEach(function () {
+ // Create a mock CloudFormation client with all required methods
+ mockCfnClient = {
+ describeStacks: sandbox.stub(),
+ getTemplate: sandbox.stub(),
+ createChangeSet: sandbox.stub(),
+ describeStackResource: sandbox.stub(),
+ describeStackResources: sandbox.stub(),
+ }
+ enhancedClient = new EnhancedCloudFormationClient(mockCfnClient, 'us-east-1')
+ })
+
+ it('should enhance permission errors for describeStacks', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockCfnClient.describeStacks.returns({
+ promise: sandbox.stub().rejects(permissionError),
+ } as any)
+
+ try {
+ await enhancedClient.describeStacks({ StackName: 'test-stack' })
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for cloudformation:describeStacks'
+ )
+ )
+ assert(error.message.includes('cloudformation:DescribeStacks'))
+ assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*'))
+ assert(error.message.includes('To fix this issue:'))
+ assert(error.message.includes('Documentation:'))
+ }
+ })
+
+ it('should enhance permission errors for getTemplate', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockCfnClient.getTemplate.returns({
+ promise: sandbox.stub().rejects(permissionError),
+ } as any)
+
+ try {
+ await enhancedClient.getTemplate({ StackName: 'test-stack' })
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for cloudformation:getTemplate'
+ )
+ )
+ assert(error.message.includes('cloudformation:GetTemplate'))
+ assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*'))
+ }
+ })
+
+ it('should enhance permission errors for createChangeSet', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockCfnClient.createChangeSet.returns({
+ promise: sandbox.stub().rejects(permissionError),
+ } as any)
+
+ try {
+ await enhancedClient.createChangeSet({
+ StackName: 'test-stack',
+ ChangeSetName: 'test-changeset',
+ TemplateBody: '{}',
+ })
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for cloudformation:createChangeSet'
+ )
+ )
+ assert(error.message.includes('cloudformation:CreateChangeSet'))
+ assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*'))
+ }
+ })
+
+ it('should enhance permission errors for describeStackResource', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockCfnClient.describeStackResource.returns({
+ promise: sandbox.stub().rejects(permissionError),
+ } as any)
+
+ try {
+ await enhancedClient.describeStackResource({
+ StackName: 'test-stack',
+ LogicalResourceId: 'TestResource',
+ })
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for cloudformation:describeStackResource'
+ )
+ )
+ assert(error.message.includes('cloudformation:DescribeStackResource'))
+ assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*'))
+ }
+ })
+
+ it('should enhance permission errors for describeStackResources', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockCfnClient.describeStackResources.returns({
+ promise: sandbox.stub().rejects(permissionError),
+ } as any)
+
+ try {
+ await enhancedClient.describeStackResources({ StackName: 'test-stack' })
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+ assert(
+ error.message.includes(
+ 'Permission denied: Missing required permissions for cloudformation:describeStackResources'
+ )
+ )
+ assert(error.message.includes('cloudformation:DescribeStackResources'))
+ assert(error.message.includes('arn:aws:cloudformation:us-east-1:*:stack/test-stack/*'))
+ }
+ })
+
+ it('should pass through non-permission errors', async function () {
+ const nonPermissionError = new Error('Stack not found')
+ mockCfnClient.describeStacks.returns({
+ promise: sandbox.stub().rejects(nonPermissionError),
+ } as any)
+
+ try {
+ await enhancedClient.describeStacks({ StackName: 'test-stack' })
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert.strictEqual(error, nonPermissionError)
+ }
+ })
+
+ it('should return successful results when no errors occur', async function () {
+ const mockResponse = { Stacks: [{ StackName: 'test-stack' }] }
+ mockCfnClient.describeStacks.returns({
+ promise: sandbox.stub().resolves(mockResponse),
+ } as any)
+
+ const result = await enhancedClient.describeStacks({ StackName: 'test-stack' })
+ assert.strictEqual(result, mockResponse)
+ })
+ })
+
+ describe('Client Factory Functions', function () {
+ beforeEach(function () {
+ // Stub the global SDK client builder
+ sandbox.stub(globals.sdkClientBuilder, 'createAwsService').resolves({} as any)
+ })
+
+ it('should return EnhancedLambdaClient from getLambdaClient', function () {
+ const client = getLambdaClient('us-east-1')
+ assert(client instanceof EnhancedLambdaClient)
+ })
+
+ it('should return EnhancedCloudFormationClient from getCFNClient', async function () {
+ const client = await getCFNClient('us-east-1')
+ assert(client instanceof EnhancedCloudFormationClient)
+ })
+ })
+
+ describe('Error Message Content', function () {
+ let mockLambdaClient: sinon.SinonStubbedInstance
+ let enhancedClient: EnhancedLambdaClient
+
+ beforeEach(function () {
+ mockLambdaClient = sandbox.createStubInstance(DefaultLambdaClient)
+ // Add missing properties that EnhancedLambdaClient expects
+ Object.defineProperty(mockLambdaClient, 'defaultTimeoutInMs', {
+ value: 5 * 60 * 1000,
+ configurable: true,
+ })
+ Object.defineProperty(mockLambdaClient, 'createSdkClient', {
+ value: sandbox.stub().resolves({}),
+ configurable: true,
+ })
+ enhancedClient = new EnhancedLambdaClient(mockLambdaClient as any, 'us-west-2')
+ })
+
+ it('should include all required elements in enhanced error message', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+ mockLambdaClient.getFunction.rejects(permissionError)
+
+ try {
+ await enhancedClient.getFunction('my-test-function')
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+
+ // Check that the error message contains all expected elements
+ const message = error.message
+
+ // Main error description
+ assert(message.includes('Permission denied: Missing required permissions for lambda:getFunction'))
+
+ // Required permissions section
+ assert(message.includes('Required permissions:'))
+ assert(message.includes('- lambda:GetFunction'))
+
+ // Resource ARN
+ assert(message.includes('Resource: arn:aws:lambda:us-west-2:*:function:my-test-function'))
+
+ // Instructions
+ assert(message.includes('To fix this issue:'))
+ assert(message.includes('1. Contact your AWS administrator'))
+ assert(message.includes('2. Add these permissions to your IAM user/role policy'))
+ assert(message.includes('3. If using IAM roles, ensure the role has these permissions attached'))
+
+ // Documentation link
+ assert(
+ message.includes(
+ 'Documentation: https://docs.aws.amazon.com/lambda/latest/api/API_GetFunction.html'
+ )
+ )
+
+ // Check error details
+ assert.strictEqual(error.code, 'InsufficientPermissions')
+ assert(error.details)
+ assert.strictEqual(error.details.service, 'lambda')
+ assert.strictEqual(error.details.action, 'getFunction')
+ assert.deepStrictEqual(error.details.requiredPermissions, ['lambda:GetFunction'])
+ assert.strictEqual(
+ error.details.resourceArn,
+ 'arn:aws:lambda:us-west-2:*:function:my-test-function'
+ )
+ }
+ })
+
+ it('should handle errors without resource ARN', async function () {
+ const permissionError = Object.assign(new Error('Access denied'), {
+ code: 'AccessDeniedException',
+ time: new Date(),
+ statusCode: 403,
+ })
+
+ // Create a mock async generator that throws the error
+ const mockAsyncGenerator = async function* (): AsyncIterableIterator {
+ throw permissionError
+ yield // This line will never be reached but satisfies ESLint require-yield rule
+ }
+ mockLambdaClient.listFunctions.returns(mockAsyncGenerator())
+
+ try {
+ const iterator = enhancedClient.listFunctions()
+ await iterator.next()
+ assert.fail('Expected error to be thrown')
+ } catch (error) {
+ assert(error instanceof ToolkitError)
+
+ const message = error.message
+ assert(message.includes('Permission denied: Missing required permissions for lambda:listFunctions'))
+ assert(message.includes('- lambda:ListFunctions'))
+ // Should not include Resource line for operations without specific resources
+ assert(!message.includes('Resource: arn:'))
+ }
+ })
+ })
+ })
})
diff --git a/packages/core/src/test/awsService/sagemaker/credentialMapping.test.ts b/packages/core/src/test/awsService/sagemaker/credentialMapping.test.ts
new file mode 100644
index 00000000000..1d17651a042
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/credentialMapping.test.ts
@@ -0,0 +1,154 @@
+/*!
+ * 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 { persistLocalCredentials, persistSSMConnection } from '../../../awsService/sagemaker/credentialMapping'
+import { Auth } from '../../../auth'
+import { DevSettings, fs } from '../../../shared'
+import globals from '../../../shared/extensionGlobals'
+
+describe('credentialMapping', () => {
+ describe('persistLocalCredentials', () => {
+ const appArn = 'arn:aws:sagemaker:us-west-2:123456789012:app/domain/space'
+
+ let sandbox: sinon.SinonSandbox
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ it('writes IAM profile to mappings', async () => {
+ sandbox.stub(Auth.instance, 'getCurrentProfileId').returns('profile:my-iam-profile')
+ sandbox.stub(fs, 'existsFile').resolves(false) // simulate no existing mapping file
+ const writeStub = sandbox.stub(fs, 'writeFile').resolves()
+
+ await persistLocalCredentials(appArn)
+
+ assert.ok(writeStub.calledOnce)
+ const raw = writeStub.firstCall.args[1]
+ const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
+
+ assert.deepStrictEqual(data.localCredential?.[appArn], {
+ type: 'iam',
+ profileName: 'profile:my-iam-profile',
+ })
+ })
+
+ it('writes SSO credentials to mappings', async () => {
+ sandbox.stub(Auth.instance, 'getCurrentProfileId').returns('sso:my-sso-profile')
+ sandbox.stub(globals.loginManager.store, 'credentialsCache').value({
+ 'sso:my-sso-profile': {
+ credentials: {
+ accessKeyId: 'AKIA123',
+ secretAccessKey: 'SECRET',
+ sessionToken: 'TOKEN',
+ },
+ },
+ })
+ sandbox.stub(fs, 'existsFile').resolves(false)
+ const writeStub = sandbox.stub(fs, 'writeFile').resolves()
+
+ await persistLocalCredentials(appArn)
+
+ assert.ok(writeStub.calledOnce)
+ const raw = writeStub.firstCall.args[1]
+ const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
+ assert.deepStrictEqual(data.localCredential?.[appArn], {
+ type: 'sso',
+ accessKey: 'AKIA123',
+ secret: 'SECRET',
+ token: 'TOKEN',
+ })
+ })
+
+ it('throws if no current profile ID is available', async () => {
+ sandbox.stub(Auth.instance, 'getCurrentProfileId').returns(undefined)
+
+ await assert.rejects(() => persistLocalCredentials(appArn), {
+ message: 'No current profile ID available for saving space credentials.',
+ })
+ })
+ })
+
+ describe('persistSSMConnection', () => {
+ const appArn = 'arn:aws:sagemaker:us-west-2:123456789012:app/domain/space'
+ const domain = 'my-domain'
+ let sandbox: sinon.SinonSandbox
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ function assertRefreshUrlMatches(writtenUrl: string, expectedSubdomain: string) {
+ assert.ok(
+ writtenUrl.startsWith(`https://studio-${domain}.${expectedSubdomain}`),
+ `Expected refresh URL to start with https://studio-${domain}.${expectedSubdomain}, got ${writtenUrl}`
+ )
+ }
+
+ it('uses default (studio) endpoint if no custom endpoint is set', async () => {
+ sandbox.stub(DevSettings.instance, 'get').returns({})
+ sandbox.stub(fs, 'existsFile').resolves(false)
+ const writeStub = sandbox.stub(fs, 'writeFile').resolves()
+
+ await persistSSMConnection(appArn, domain)
+
+ const raw = writeStub.firstCall.args[1]
+ const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
+
+ assertRefreshUrlMatches(data.deepLink?.[appArn]?.refreshUrl, 'studio.us-west-2.sagemaker.aws')
+ assert.deepStrictEqual(data.deepLink?.[appArn]?.requests['initial-connection'], {
+ sessionId: '-',
+ url: '-',
+ token: '-',
+ status: 'fresh',
+ })
+ })
+
+ it('uses devo subdomain for beta endpoint', async () => {
+ sandbox.stub(DevSettings.instance, 'get').returns({ sagemaker: 'https://beta.whatever' })
+ sandbox.stub(fs, 'existsFile').resolves(false)
+ const writeStub = sandbox.stub(fs, 'writeFile').resolves()
+
+ await persistSSMConnection(appArn, domain, 'sess', 'wss://ws', 'token')
+
+ const raw = writeStub.firstCall.args[1]
+ const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
+
+ assertRefreshUrlMatches(data.deepLink?.[appArn]?.refreshUrl, 'devo.studio.us-west-2.asfiovnxocqpcry.com')
+ assert.deepStrictEqual(data.deepLink?.[appArn]?.requests['initial-connection'], {
+ sessionId: 'sess',
+ url: 'wss://ws',
+ token: 'token',
+ status: 'fresh',
+ })
+ })
+
+ it('uses loadtest subdomain for gamma endpoint', async () => {
+ sandbox.stub(DevSettings.instance, 'get').returns({ sagemaker: 'https://gamma.example' })
+ sandbox.stub(fs, 'existsFile').resolves(false)
+ const writeStub = sandbox.stub(fs, 'writeFile').resolves()
+
+ await persistSSMConnection(appArn, domain)
+
+ const raw = writeStub.firstCall.args[1]
+ const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
+
+ assertRefreshUrlMatches(
+ data.deepLink?.[appArn]?.refreshUrl,
+ 'loadtest.studio.us-west-2.asfiovnxocqpcry.com'
+ )
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/detached-server/credentials.test.ts b/packages/core/src/test/awsService/sagemaker/detached-server/credentials.test.ts
new file mode 100644
index 00000000000..a979c2186d3
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/detached-server/credentials.test.ts
@@ -0,0 +1,89 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import * as sinon from 'sinon'
+import * as utils from '../../../../awsService/sagemaker/detached-server/utils'
+import { resolveCredentialsFor } from '../../../../awsService/sagemaker/detached-server/credentials'
+
+const connectionId = 'arn:aws:sagemaker:region:acct:space/name'
+
+describe('resolveCredentialsFor', () => {
+ afterEach(() => sinon.restore())
+
+ it('throws if no profile is found', async () => {
+ sinon.stub(utils, 'readMapping').resolves({ localCredential: {} })
+
+ await assert.rejects(() => resolveCredentialsFor(connectionId), {
+ message: `No profile found for "${connectionId}"`,
+ })
+ })
+
+ it('throws if IAM profile name is malformed', async () => {
+ sinon.stub(utils, 'readMapping').resolves({
+ localCredential: {
+ [connectionId]: {
+ type: 'iam',
+ profileName: 'dev-profile', // no colon
+ },
+ },
+ })
+
+ await assert.rejects(() => resolveCredentialsFor(connectionId), {
+ message: `Invalid IAM profile name for "${connectionId}"`,
+ })
+ })
+
+ it('resolves SSO credentials correctly', async () => {
+ sinon.stub(utils, 'readMapping').resolves({
+ localCredential: {
+ [connectionId]: {
+ type: 'sso',
+ accessKey: 'key',
+ secret: 'sec',
+ token: 'tok',
+ },
+ },
+ })
+
+ const creds = await resolveCredentialsFor(connectionId)
+ assert.deepStrictEqual(creds, {
+ accessKeyId: 'key',
+ secretAccessKey: 'sec',
+ sessionToken: 'tok',
+ })
+ })
+
+ it('throws if SSO credentials are incomplete', async () => {
+ sinon.stub(utils, 'readMapping').resolves({
+ localCredential: {
+ [connectionId]: {
+ type: 'sso',
+ accessKey: 'key',
+ secret: 'sec',
+ token: '', // token is required but intentionally left empty for this test
+ },
+ },
+ })
+
+ await assert.rejects(() => resolveCredentialsFor(connectionId), {
+ message: `Missing SSO credentials for "${connectionId}"`,
+ })
+ })
+
+ it('throws for unsupported profile types', async () => {
+ sinon.stub(utils, 'readMapping').resolves({
+ localCredential: {
+ [connectionId]: {
+ type: 'unknown',
+ } as any,
+ },
+ })
+
+ await assert.rejects(() => resolveCredentialsFor(connectionId), {
+ message: /Unsupported profile type/, // don't hard-code full value since object might be serialized
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/detached-server/routes/getSession.test.ts b/packages/core/src/test/awsService/sagemaker/detached-server/routes/getSession.test.ts
new file mode 100644
index 00000000000..1e09fdbc8da
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/detached-server/routes/getSession.test.ts
@@ -0,0 +1,99 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as http from 'http'
+import * as sinon from 'sinon'
+import assert from 'assert'
+import { handleGetSession } from '../../../../../awsService/sagemaker/detached-server/routes/getSession'
+import * as credentials from '../../../../../awsService/sagemaker/detached-server/credentials'
+import * as utils from '../../../../../awsService/sagemaker/detached-server/utils'
+import * as errorPage from '../../../../../awsService/sagemaker/detached-server/errorPage'
+
+describe('handleGetSession', () => {
+ let req: Partial
+ let res: Partial
+ let resWriteHead: sinon.SinonSpy
+ let resEnd: sinon.SinonSpy
+
+ beforeEach(() => {
+ resWriteHead = sinon.spy()
+ resEnd = sinon.spy()
+
+ res = {
+ writeHead: resWriteHead,
+ end: resEnd,
+ }
+ sinon.stub(errorPage, 'openErrorPage')
+ })
+
+ it('responds with 400 if connection_identifier is missing', async () => {
+ req = { url: '/session' }
+ await handleGetSession(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(400))
+ assert(resEnd.calledWithMatch(/Missing required query parameter/))
+ })
+
+ it('responds with 500 if resolveCredentialsFor throws', async () => {
+ req = { url: '/session?connection_identifier=arn:aws:sagemaker:region:acc:space/domain/name' }
+ sinon.stub(credentials, 'resolveCredentialsFor').rejects(new Error('creds error'))
+ sinon.stub(utils, 'parseArn').returns({
+ region: 'us-west-2',
+ accountId: '123456789012',
+ })
+
+ await handleGetSession(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(500))
+ assert(resEnd.calledWith('creds error'))
+ })
+
+ it('responds with 500 if startSagemakerSession throws', async () => {
+ req = { url: '/session?connection_identifier=arn:aws:sagemaker:region:acc:space/domain/name' }
+ sinon.stub(credentials, 'resolveCredentialsFor').resolves({})
+ sinon.stub(utils, 'parseArn').returns({
+ region: 'us-west-2',
+ accountId: '123456789012',
+ })
+ sinon.stub(utils, 'startSagemakerSession').rejects(new Error('session error'))
+
+ await handleGetSession(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(500))
+ assert(resEnd.calledWith('Failed to start SageMaker session'))
+ })
+
+ it('responds with 200 and session data on success', async () => {
+ req = { url: '/session?connection_identifier=arn:aws:sagemaker:region:acc:space/domain/name' }
+ sinon.stub(credentials, 'resolveCredentialsFor').resolves({})
+ sinon.stub(utils, 'parseArn').returns({
+ region: 'us-west-2',
+ accountId: '123456789012',
+ })
+ sinon.stub(utils, 'startSagemakerSession').resolves({
+ SessionId: 'abc123',
+ StreamUrl: 'https://stream',
+ TokenValue: 'token123',
+ $metadata: { httpStatusCode: 200 },
+ })
+
+ await handleGetSession(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(200))
+ assert(
+ resEnd.calledWithMatch(
+ JSON.stringify({
+ SessionId: 'abc123',
+ StreamUrl: 'https://stream',
+ TokenValue: 'token123',
+ })
+ )
+ )
+ })
+
+ afterEach(() => {
+ sinon.restore()
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/detached-server/routes/getSessionAsync.test.ts b/packages/core/src/test/awsService/sagemaker/detached-server/routes/getSessionAsync.test.ts
new file mode 100644
index 00000000000..f8d76912b2b
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/detached-server/routes/getSessionAsync.test.ts
@@ -0,0 +1,98 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as http from 'http'
+import * as sinon from 'sinon'
+import assert from 'assert'
+import { SessionStore } from '../../../../../awsService/sagemaker/detached-server/sessionStore'
+import { handleGetSessionAsync } from '../../../../../awsService/sagemaker/detached-server/routes/getSessionAsync'
+
+describe('handleGetSessionAsync', () => {
+ let req: Partial
+ let res: Partial
+ let resWriteHead: sinon.SinonSpy
+ let resEnd: sinon.SinonSpy
+ let storeStub: sinon.SinonStubbedInstance
+
+ beforeEach(() => {
+ resWriteHead = sinon.spy()
+ resEnd = sinon.spy()
+ res = { writeHead: resWriteHead, end: resEnd }
+
+ storeStub = sinon.createStubInstance(SessionStore)
+ sinon.stub(SessionStore.prototype, 'getFreshEntry').callsFake(storeStub.getFreshEntry)
+ sinon.stub(SessionStore.prototype, 'getStatus').callsFake(storeStub.getStatus)
+ sinon.stub(SessionStore.prototype, 'getRefreshUrl').callsFake(storeStub.getRefreshUrl)
+ sinon.stub(SessionStore.prototype, 'markPending').callsFake(storeStub.markPending)
+ })
+
+ it('responds with 400 if required query parameters are missing', async () => {
+ req = { url: '/session_async?connection_identifier=abc' } // missing request_id
+ await handleGetSessionAsync(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(400))
+ assert(resEnd.calledWithMatch(/Missing required query parameters/))
+ })
+
+ it('responds with 200 and session data if freshEntry exists', async () => {
+ req = { url: '/session_async?connection_identifier=abc&request_id=req123' }
+ storeStub.getFreshEntry.returns(Promise.resolve({ sessionId: 'sid', token: 'tok', url: 'wss://test' }))
+
+ await handleGetSessionAsync(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(200))
+ const actualJson = JSON.parse(resEnd.firstCall.args[0])
+ assert.deepStrictEqual(actualJson, {
+ SessionId: 'sid',
+ TokenValue: 'tok',
+ StreamUrl: 'wss://test',
+ })
+ })
+
+ // Temporarily disabling reconnect logic for the 7/3 Phase 1 launch.
+ // Will re-enable in the next release around 7/14.
+
+ // it('responds with 204 if session is pending', async () => {
+ // req = { url: '/session_async?connection_identifier=abc&request_id=req123' }
+ // storeStub.getFreshEntry.returns(Promise.resolve(undefined))
+ // storeStub.getStatus.returns(Promise.resolve('pending'))
+
+ // await handleGetSessionAsync(req as http.IncomingMessage, res as http.ServerResponse)
+
+ // assert(resWriteHead.calledWith(204))
+ // assert(resEnd.calledOnce)
+ // })
+
+ // it('responds with 202 if status is not-started and opens browser', async () => {
+ // req = { url: '/session_async?connection_identifier=abc&request_id=req123' }
+
+ // storeStub.getFreshEntry.returns(Promise.resolve(undefined))
+ // storeStub.getStatus.returns(Promise.resolve('not-started'))
+ // storeStub.getRefreshUrl.returns(Promise.resolve('https://example.com/refresh'))
+ // storeStub.markPending.returns(Promise.resolve())
+
+ // sinon.stub(utils, 'readServerInfo').resolves({ pid: 1234, port: 4567 })
+ // sinon.stub(utils, 'open').resolves()
+ // await handleGetSessionAsync(req as http.IncomingMessage, res as http.ServerResponse)
+
+ // assert(resWriteHead.calledWith(202))
+ // assert(resEnd.calledWithMatch(/Session is not ready yet/))
+ // assert(storeStub.markPending.calledWith('abc', 'req123'))
+ // })
+
+ // it('responds with 500 if unexpected error occurs', async () => {
+ // req = { url: '/session_async?connection_identifier=abc&request_id=req123' }
+ // storeStub.getFreshEntry.throws(new Error('fail'))
+
+ // await handleGetSessionAsync(req as http.IncomingMessage, res as http.ServerResponse)
+
+ // assert(resWriteHead.calledWith(500))
+ // assert(resEnd.calledWith('Unexpected error'))
+ // })
+
+ afterEach(() => {
+ sinon.restore()
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/detached-server/routes/refreshToken.test.ts b/packages/core/src/test/awsService/sagemaker/detached-server/routes/refreshToken.test.ts
new file mode 100644
index 00000000000..2fe6b3c648d
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/detached-server/routes/refreshToken.test.ts
@@ -0,0 +1,74 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as http from 'http'
+import * as sinon from 'sinon'
+import assert from 'assert'
+import { SessionStore } from '../../../../../awsService/sagemaker/detached-server/sessionStore'
+import { handleRefreshToken } from '../../../../../awsService/sagemaker/detached-server/routes/refreshToken'
+
+describe('handleRefreshToken', () => {
+ let req: Partial
+ let res: Partial
+ let resWriteHead: sinon.SinonSpy
+ let resEnd: sinon.SinonSpy
+ let storeStub: sinon.SinonStubbedInstance
+
+ beforeEach(() => {
+ resWriteHead = sinon.spy()
+ resEnd = sinon.spy()
+
+ res = {
+ writeHead: resWriteHead,
+ end: resEnd,
+ }
+
+ storeStub = sinon.createStubInstance(SessionStore)
+ sinon.stub(SessionStore.prototype, 'setSession').callsFake(storeStub.setSession)
+ })
+
+ it('responds with 400 if any required query parameter is missing', async () => {
+ req = { url: '/refresh?connection_identifier=abc&request_id=req123' } // missing others
+
+ await handleRefreshToken(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(400))
+ assert(resEnd.calledWithMatch(/Missing required parameters/))
+ })
+
+ it('responds with 500 if setSession throws', async () => {
+ req = {
+ url: '/refresh?connection_identifier=abc&request_id=req123&ws_url=wss://abc&token=tok123&session=sess123',
+ }
+ storeStub.setSession.throws(new Error('store error'))
+
+ await handleRefreshToken(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(500))
+ assert(resEnd.calledWith('Failed to save session token'))
+ })
+
+ it('responds with 200 if session is saved successfully', async () => {
+ req = {
+ url: '/refresh?connection_identifier=abc&request_id=req123&ws_url=wss://abc&token=tok123&session=sess123',
+ }
+
+ await handleRefreshToken(req as http.IncomingMessage, res as http.ServerResponse)
+
+ assert(resWriteHead.calledWith(200))
+ assert(resEnd.calledWith('Session token refreshed successfully'))
+ assert(
+ storeStub.setSession.calledWith('abc', 'req123', {
+ sessionId: 'sess123',
+ token: 'tok123',
+ url: 'wss://abc',
+ })
+ )
+ })
+
+ afterEach(() => {
+ sinon.restore()
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/detached-server/sessionStore.test.ts b/packages/core/src/test/awsService/sagemaker/detached-server/sessionStore.test.ts
new file mode 100644
index 00000000000..0bb46b7d24b
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/detached-server/sessionStore.test.ts
@@ -0,0 +1,141 @@
+/*!
+ * 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 * as utils from '../../../../awsService/sagemaker/detached-server/utils'
+import { SessionStore } from '../../../../awsService/sagemaker/detached-server/sessionStore'
+import { SsmConnectionInfo } from '../../../../awsService/sagemaker/types'
+
+describe('SessionStore', () => {
+ let readMappingStub: sinon.SinonStub
+ let writeMappingStub: sinon.SinonStub
+ const connectionId = 'abc'
+ const requestId = 'req123'
+
+ const baseMapping = {
+ deepLink: {
+ [connectionId]: {
+ refreshUrl: 'https://refresh.url',
+ requests: {
+ [requestId]: { sessionId: 's1', token: 't1', url: 'u1', status: 'fresh' },
+ 'initial-connection': { sessionId: 's0', token: 't0', url: 'u0', status: 'fresh' },
+ },
+ },
+ },
+ }
+
+ beforeEach(() => {
+ readMappingStub = sinon.stub(utils, 'readMapping').returns(JSON.parse(JSON.stringify(baseMapping)))
+ writeMappingStub = sinon.stub(utils, 'writeMapping')
+ })
+
+ afterEach(() => sinon.restore())
+
+ it('gets refreshUrl', async () => {
+ const store = new SessionStore()
+ const result = await store.getRefreshUrl(connectionId)
+ assert.strictEqual(result, 'https://refresh.url')
+ })
+
+ it('throws if no mapping exists for connectionId', async () => {
+ const store = new SessionStore()
+ readMappingStub.returns({ deepLink: {} })
+
+ await assert.rejects(() => store.getRefreshUrl('missing'), /No mapping found/)
+ })
+
+ it('returns fresh entry and marks consumed', async () => {
+ const store = new SessionStore()
+ const result = await store.getFreshEntry(connectionId, requestId)
+ assert.deepStrictEqual(result, {
+ sessionId: 's0',
+ token: 't0',
+ url: 'u0',
+ status: 'consumed',
+ })
+ assert(writeMappingStub.calledOnce)
+ })
+
+ it('returns async fresh entry and marks consumed', async () => {
+ const store = new SessionStore()
+ // Disable initial-connection freshness
+ readMappingStub.returns({
+ deepLink: {
+ [connectionId]: {
+ refreshUrl: 'url',
+ requests: {
+ 'initial-connection': { status: 'consumed' },
+ [requestId]: { sessionId: 'a', token: 'b', url: 'c', status: 'fresh' },
+ },
+ },
+ },
+ })
+ const result = await store.getFreshEntry(connectionId, requestId)
+ assert.ok(result, 'Expected result to be defined')
+ assert.strictEqual(result.sessionId, 'a')
+ assert(writeMappingStub.calledOnce)
+ })
+
+ it('returns undefined if no fresh entries exist', async () => {
+ const store = new SessionStore()
+ readMappingStub.returns({
+ deepLink: {
+ [connectionId]: {
+ refreshUrl: 'url',
+ requests: {
+ 'initial-connection': { status: 'consumed' },
+ [requestId]: { status: 'pending' },
+ },
+ },
+ },
+ })
+ const result = await store.getFreshEntry(connectionId, requestId)
+ assert.strictEqual(result, undefined)
+ })
+
+ it('gets status of known entry', async () => {
+ const store = new SessionStore()
+ const result = await store.getStatus(connectionId, requestId)
+ assert.strictEqual(result, 'fresh')
+ })
+
+ it('returns not-started if request not found', async () => {
+ const store = new SessionStore()
+ const result = await store.getStatus(connectionId, 'unknown')
+ assert.strictEqual(result, 'not-started')
+ })
+
+ it('marks entry as consumed', async () => {
+ const store = new SessionStore()
+ await store.markConsumed(connectionId, requestId)
+ const updated = writeMappingStub.firstCall.args[0]
+ assert.strictEqual(updated.deepLink[connectionId].requests[requestId].status, 'consumed')
+ })
+
+ it('marks request as pending', async () => {
+ const store = new SessionStore()
+ await store.markPending(connectionId, 'newReq')
+ const updated = writeMappingStub.firstCall.args[0]
+ assert.strictEqual(updated.deepLink[connectionId].requests['newReq'].status, 'pending')
+ })
+
+ it('sets session entry with default fresh status', async () => {
+ const store = new SessionStore()
+ const info: SsmConnectionInfo = {
+ sessionId: 's99',
+ token: 't99',
+ url: 'u99',
+ }
+ await store.setSession(connectionId, 'r99', info)
+ const written = writeMappingStub.firstCall.args[0]
+ assert.deepStrictEqual(written.deepLink[connectionId].requests['r99'], {
+ sessionId: 's99',
+ token: 't99',
+ url: 'u99',
+ status: 'fresh',
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/detached-server/utils.test.ts b/packages/core/src/test/awsService/sagemaker/detached-server/utils.test.ts
new file mode 100644
index 00000000000..66a47747bf9
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/detached-server/utils.test.ts
@@ -0,0 +1,46 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as assert from 'assert'
+import { parseArn } from '../../../../awsService/sagemaker/detached-server/utils'
+
+describe('parseArn', () => {
+ it('parses a standard SageMaker ARN with forward slash', () => {
+ const arn = 'arn:aws:sagemaker:us-west-2:123456789012:space/my-space-name'
+ const result = parseArn(arn)
+ assert.deepStrictEqual(result, {
+ region: 'us-west-2',
+ accountId: '123456789012',
+ })
+ })
+
+ it('parses a standard SageMaker ARN with colon', () => {
+ const arn = 'arn:aws:sagemaker:eu-central-1:123456789012:space:space-name'
+ const result = parseArn(arn)
+ assert.deepStrictEqual(result, {
+ region: 'eu-central-1',
+ accountId: '123456789012',
+ })
+ })
+
+ it('parses an ARN prefixed with sagemaker-user@', () => {
+ const arn = 'sagemaker-user@arn:aws:sagemaker:ap-southeast-1:123456789012:space/foo'
+ const result = parseArn(arn)
+ assert.deepStrictEqual(result, {
+ region: 'ap-southeast-1',
+ accountId: '123456789012',
+ })
+ })
+
+ it('throws on malformed ARN', () => {
+ const invalidArn = 'arn:aws:invalid:format'
+ assert.throws(() => parseArn(invalidArn), /Invalid SageMaker ARN format/)
+ })
+
+ it('throws when missing region/account', () => {
+ const invalidArn = 'arn:aws:sagemaker:::space/xyz'
+ assert.throws(() => parseArn(invalidArn), /Invalid SageMaker ARN format/)
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerParentNode.test.ts b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerParentNode.test.ts
new file mode 100644
index 00000000000..a0a0f807b73
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerParentNode.test.ts
@@ -0,0 +1,355 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as sinon from 'sinon'
+import * as vscode from 'vscode'
+import { DescribeDomainResponse } from '@amzn/sagemaker-client'
+import { GetCallerIdentityResponse } from 'aws-sdk/clients/sts'
+import { SagemakerClient, SagemakerSpaceApp } from '../../../../shared/clients/sagemaker'
+import { SagemakerParentNode } from '../../../../awsService/sagemaker/explorer/sagemakerParentNode'
+import { DefaultStsClient } from '../../../../shared/clients/stsClient'
+import { assertNodeListOnlyHasPlaceholderNode } from '../../../utilities/explorerNodeAssertions'
+import assert from 'assert'
+
+describe('sagemakerParentNode', function () {
+ let testNode: SagemakerParentNode
+ let client: SagemakerClient
+ let fetchSpaceAppsAndDomainsStub: sinon.SinonStub<
+ [],
+ Promise<[Map, Map]>
+ >
+ let getCallerIdentityStub: sinon.SinonStub<[], Promise>
+ const testRegion = 'testRegion'
+ const domainsMap: Map = new Map([
+ ['domain1', { DomainId: 'domain1', DomainName: 'domainName1' }],
+ ['domain2', { DomainId: 'domain2', DomainName: 'domainName2' }],
+ ])
+ const getConfigTrue = {
+ get: () => true,
+ }
+ const getConfigFalse = {
+ get: () => false,
+ }
+
+ before(function () {
+ client = new SagemakerClient(testRegion)
+ })
+
+ beforeEach(function () {
+ fetchSpaceAppsAndDomainsStub = sinon.stub(SagemakerClient.prototype, 'fetchSpaceAppsAndDomains')
+ getCallerIdentityStub = sinon.stub(DefaultStsClient.prototype, 'getCallerIdentity')
+ testNode = new SagemakerParentNode(testRegion, client)
+ })
+
+ afterEach(function () {
+ fetchSpaceAppsAndDomainsStub.restore()
+ getCallerIdentityStub.restore()
+ testNode.pollingSet.clear()
+ testNode.pollingSet.clearTimer()
+ sinon.restore()
+ })
+
+ it('returns placeholder node if no children are present', async function () {
+ fetchSpaceAppsAndDomainsStub.returns(
+ Promise.resolve([new Map(), new Map()])
+ )
+ getCallerIdentityStub.returns(
+ Promise.resolve({
+ UserId: 'test-userId',
+ Account: '123456789012',
+ Arn: 'arn:aws:iam::123456789012:user/test-user',
+ })
+ )
+
+ const childNodes = await testNode.getChildren()
+ assertNodeListOnlyHasPlaceholderNode(childNodes)
+ })
+
+ it('has child nodes', async function () {
+ const spaceAppsMap: Map = new Map([
+ [
+ 'domain1__name1',
+ {
+ SpaceName: 'name1',
+ DomainId: 'domain1',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user1-abcd' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain1__name1',
+ },
+ ],
+ [
+ 'domain2__name2',
+ {
+ SpaceName: 'name2',
+ DomainId: 'domain2',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user2-efgh' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain2__name2',
+ },
+ ],
+ ])
+
+ fetchSpaceAppsAndDomainsStub.returns(Promise.resolve([spaceAppsMap, domainsMap]))
+ getCallerIdentityStub.returns(
+ Promise.resolve({
+ UserId: 'test-userId',
+ Account: '123456789012',
+ Arn: 'arn:aws:iam::123456789012:user/test-user',
+ })
+ )
+ sinon
+ .stub(vscode.workspace, 'getConfiguration')
+ .returns(getConfigFalse as unknown as vscode.WorkspaceConfiguration)
+
+ const childNodes = await testNode.getChildren()
+ assert.strictEqual(childNodes.length, spaceAppsMap.size, 'Unexpected child count')
+ assert.strictEqual(childNodes[0].label, 'name1 (Stopped)', 'Unexpected node label')
+ assert.strictEqual(childNodes[1].label, 'name2 (Stopped)', 'Unexpected node label')
+ })
+
+ it('adds pending nodes to polling nodes set', async function () {
+ const spaceAppsMap: Map = new Map([
+ [
+ 'domain1__name3',
+ {
+ SpaceName: 'name3',
+ DomainId: 'domain1',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user1-abcd' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain1__name3',
+ App: {
+ Status: 'InService',
+ },
+ },
+ ],
+ [
+ 'domain2__name4',
+ {
+ SpaceName: 'name4',
+ DomainId: 'domain2',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user2-efgh' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain2__name4',
+ App: {
+ Status: 'Pending',
+ },
+ },
+ ],
+ ])
+
+ fetchSpaceAppsAndDomainsStub.returns(Promise.resolve([spaceAppsMap, domainsMap]))
+ getCallerIdentityStub.returns(
+ Promise.resolve({
+ UserId: 'test-userId',
+ Account: '123456789012',
+ Arn: 'arn:aws:iam::123456789012:user/test-user',
+ })
+ )
+
+ await testNode.updateChildren()
+ assert.strictEqual(testNode.pollingSet.size, 1)
+ fetchSpaceAppsAndDomainsStub.restore()
+ })
+
+ it('filters spaces owned by user profiles that match the IAM user', async function () {
+ const spaceAppsMap: Map = new Map([
+ [
+ 'domain1__name1',
+ {
+ SpaceName: 'name1',
+ DomainId: 'domain1',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user1-abcd' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain1__name1',
+ },
+ ],
+ [
+ 'domain2__name2',
+ {
+ SpaceName: 'name2',
+ DomainId: 'domain2',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user2-efgh' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain2__name2',
+ },
+ ],
+ ])
+
+ fetchSpaceAppsAndDomainsStub.returns(Promise.resolve([spaceAppsMap, domainsMap]))
+ getCallerIdentityStub.returns(
+ Promise.resolve({
+ UserId: 'test-userId',
+ Account: '123456789012',
+ Arn: 'arn:aws:iam::123456789012:user/user2',
+ })
+ )
+ sinon
+ .stub(vscode.workspace, 'getConfiguration')
+ .returns(getConfigTrue as unknown as vscode.WorkspaceConfiguration)
+
+ const childNodes = await testNode.getChildren()
+ assert.strictEqual(childNodes.length, 1, 'Unexpected child count')
+ assert.strictEqual(childNodes[0].label, 'name2 (Stopped)', 'Unexpected node label')
+ })
+
+ it('filters spaces owned by user profiles that match the IAM assumed-role session name', async function () {
+ const spaceAppsMap: Map = new Map([
+ [
+ 'domain1__name1',
+ {
+ SpaceName: 'name1',
+ DomainId: 'domain1',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user1-abcd' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain1__name1',
+ },
+ ],
+ [
+ 'domain2__name2',
+ {
+ SpaceName: 'name2',
+ DomainId: 'domain2',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user2-efgh' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain2__name2',
+ },
+ ],
+ ])
+
+ fetchSpaceAppsAndDomainsStub.returns(Promise.resolve([spaceAppsMap, domainsMap]))
+ getCallerIdentityStub.returns(
+ Promise.resolve({
+ UserId: 'test-userId',
+ Account: '123456789012',
+ Arn: 'arn:aws:sts::123456789012:assumed-role/UserRole/user2',
+ })
+ )
+ sinon
+ .stub(vscode.workspace, 'getConfiguration')
+ .returns(getConfigTrue as unknown as vscode.WorkspaceConfiguration)
+
+ const childNodes = await testNode.getChildren()
+ assert.strictEqual(childNodes.length, 1, 'Unexpected child count')
+ assert.strictEqual(childNodes[0].label, 'name2 (Stopped)', 'Unexpected node label')
+ })
+
+ it('filters spaces owned by user profiles that match the Identity Center user', async function () {
+ const spaceAppsMap: Map = new Map([
+ [
+ 'domain1__name1',
+ {
+ SpaceName: 'name1',
+ DomainId: 'domain1',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user1-abcd' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain1__name1',
+ },
+ ],
+ [
+ 'domain2__name2',
+ {
+ SpaceName: 'name2',
+ DomainId: 'domain2',
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'user2-efgh' },
+ Status: 'InService',
+ DomainSpaceKey: 'domain2__name2',
+ },
+ ],
+ ])
+
+ fetchSpaceAppsAndDomainsStub.returns(Promise.resolve([spaceAppsMap, domainsMap]))
+ getCallerIdentityStub.returns(
+ Promise.resolve({
+ UserId: 'test-userId',
+ Account: '123456789012',
+ Arn: 'arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_MyPermissionSet_abcd1234/user2',
+ })
+ )
+ sinon
+ .stub(vscode.workspace, 'getConfiguration')
+ .returns(getConfigFalse as unknown as vscode.WorkspaceConfiguration)
+
+ const childNodes = await testNode.getChildren()
+ assert.strictEqual(childNodes.length, 1, 'Unexpected child count')
+ assert.strictEqual(childNodes[0].label, 'name2 (Stopped)', 'Unexpected node label')
+ })
+
+ describe('getLocalSelectedDomainUsers', function () {
+ const createSpaceApp = (ownerName: string): SagemakerSpaceApp => ({
+ SpaceName: 'space1',
+ DomainId: 'domain1',
+ Status: 'InService',
+ OwnershipSettingsSummary: {
+ OwnerUserProfileName: ownerName,
+ },
+ DomainSpaceKey: 'domain1__name1',
+ })
+
+ beforeEach(function () {
+ testNode = new SagemakerParentNode(testRegion, client)
+ })
+
+ it('matches IAM user ARN when filtering is enabled', async function () {
+ testNode.callerIdentity = {
+ Arn: 'arn:aws:iam::123456789012:user/user1',
+ }
+
+ testNode.spaceApps = new Map([
+ ['domain1__space1', createSpaceApp('user1-abc')],
+ ['domain1__space2', createSpaceApp('user2-xyz')],
+ ])
+
+ sinon.stub(vscode.workspace, 'getConfiguration').returns(getConfigTrue as any)
+
+ const result = await testNode.getLocalSelectedDomainUsers()
+ assert.deepStrictEqual(result, ['domain1__user1-abc'], 'Should match only user1-prefixed space')
+ })
+
+ it('matches IAM assumed-role ARN when filtering is enabled', async function () {
+ testNode.callerIdentity = {
+ Arn: 'arn:aws:sts::123456789012:assumed-role/SomeRole/user2',
+ }
+
+ testNode.spaceApps = new Map([
+ ['domain1__space1', createSpaceApp('user2-xyz')],
+ ['domain1__space2', createSpaceApp('user3-def')],
+ ])
+
+ sinon.stub(vscode.workspace, 'getConfiguration').returns(getConfigTrue as any)
+
+ const result = await testNode.getLocalSelectedDomainUsers()
+ assert.deepStrictEqual(result, ['domain1__user2-xyz'], 'Should match only user2-prefixed space')
+ })
+
+ it('matches Identity Center ARN when IAM filtering is disabled', async function () {
+ testNode.callerIdentity = {
+ Arn: 'arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_PermissionSet_abcd/user3',
+ }
+
+ testNode.spaceApps = new Map([
+ ['domain1__space1', createSpaceApp('user3-aaa')],
+ ['domain1__space2', createSpaceApp('other-user')],
+ ])
+
+ sinon.stub(vscode.workspace, 'getConfiguration').returns(getConfigFalse as any)
+
+ const result = await testNode.getLocalSelectedDomainUsers()
+ assert.deepStrictEqual(result, ['domain1__user3-aaa'], 'Should match only user3-prefixed space')
+ })
+
+ it('returns empty array if no match is found', async function () {
+ testNode.callerIdentity = {
+ Arn: 'arn:aws:iam::123456789012:user/no-match',
+ }
+
+ testNode.spaceApps = new Map([['domain1__space1', createSpaceApp('someone-else')]])
+
+ sinon.stub(vscode.workspace, 'getConfiguration').returns(getConfigTrue as any)
+
+ const result = await testNode.getLocalSelectedDomainUsers()
+ assert.deepStrictEqual(result, [], 'Should return empty list when no prefix matches')
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/explorer/sagemakerSpaceNode.test.ts b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerSpaceNode.test.ts
new file mode 100644
index 00000000000..57b4d7a80c6
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/explorer/sagemakerSpaceNode.test.ts
@@ -0,0 +1,93 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as vscode from 'vscode'
+import * as sinon from 'sinon'
+import assert from 'assert'
+import { AppType } from '@aws-sdk/client-sagemaker'
+import { SagemakerClient, SagemakerSpaceApp } from '../../../../shared/clients/sagemaker'
+import { SagemakerSpaceNode } from '../../../../awsService/sagemaker/explorer/sagemakerSpaceNode'
+import { SagemakerParentNode } from '../../../../awsService/sagemaker/explorer/sagemakerParentNode'
+import { PollingSet } from '../../../../shared/utilities/pollingSet'
+
+describe('SagemakerSpaceNode', function () {
+ const testRegion = 'testRegion'
+ let client: SagemakerClient
+ let testParent: SagemakerParentNode
+ let testSpaceApp: SagemakerSpaceApp
+ let describeAppStub: sinon.SinonStub
+ let testSpaceAppNode: SagemakerSpaceNode
+
+ beforeEach(function () {
+ testSpaceApp = {
+ SpaceName: 'TestSpace',
+ DomainId: 'd-12345',
+ App: { AppName: 'TestApp', Status: 'InService' },
+ SpaceSettingsSummary: { AppType: AppType.JupyterLab },
+ OwnershipSettingsSummary: { OwnerUserProfileName: 'test-user' },
+ SpaceSharingSettingsSummary: { SharingType: 'Private' },
+ Status: 'InService',
+ DomainSpaceKey: '123',
+ }
+
+ sinon.stub(PollingSet.prototype, 'add')
+ client = new SagemakerClient(testRegion)
+ testParent = new SagemakerParentNode(testRegion, client)
+
+ describeAppStub = sinon.stub(SagemakerClient.prototype, 'describeApp')
+ testSpaceAppNode = new SagemakerSpaceNode(testParent, client, testRegion, testSpaceApp)
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ it('initializes with correct label, description, and tooltip', function () {
+ const node = new SagemakerSpaceNode(testParent, client, testRegion, testSpaceApp)
+
+ assert.strictEqual(node.label, 'TestSpace (Running)')
+ assert.strictEqual(node.description, 'Private space')
+ assert.ok(node.tooltip instanceof vscode.MarkdownString)
+ assert.ok((node.tooltip as vscode.MarkdownString).value.includes('**Space:** TestSpace'))
+ })
+
+ it('falls back to defaults if optional fields are missing', function () {
+ const partialApp: SagemakerSpaceApp = {
+ SpaceName: undefined,
+ DomainId: 'domainId',
+ Status: 'Failed',
+ DomainSpaceKey: '123',
+ }
+
+ const node = new SagemakerSpaceNode(testParent, client, testRegion, partialApp)
+
+ assert.strictEqual(node.label, '(no name) (Failed)')
+ assert.strictEqual(node.description, 'Unknown space')
+ assert.ok((node.tooltip as vscode.MarkdownString).value.includes('**Space:** -'))
+ })
+
+ it('returns ARN from describeApp', async function () {
+ describeAppStub.resolves({ AppArn: 'arn:aws:sagemaker:1234:app/TestApp' })
+
+ const node = new SagemakerSpaceNode(testParent, client, testRegion, testSpaceApp)
+ const arn = await node.getAppArn()
+
+ assert.strictEqual(arn, 'arn:aws:sagemaker:1234:app/TestApp')
+ sinon.assert.calledOnce(describeAppStub)
+ sinon.assert.calledWithExactly(describeAppStub, {
+ DomainId: 'd-12345',
+ AppName: 'TestApp',
+ AppType: AppType.JupyterLab,
+ SpaceName: 'TestSpace',
+ })
+ })
+
+ it('updates status with new spaceApp', async function () {
+ const newStatus = 'Starting'
+ const newSpaceApp = { ...testSpaceApp, App: { AppName: 'TestApp', Status: 'Pending' } } as SagemakerSpaceApp
+ testSpaceAppNode.updateSpace(newSpaceApp)
+ assert.strictEqual(testSpaceAppNode.getStatus(), newStatus)
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/model.test.ts b/packages/core/src/test/awsService/sagemaker/model.test.ts
new file mode 100644
index 00000000000..892baf2f77b
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/model.test.ts
@@ -0,0 +1,194 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as vscode from 'vscode'
+import * as sinon from 'sinon'
+import * as os from 'os'
+import * as path from 'path'
+import { DevSettings, fs, ToolkitError } from '../../../shared'
+import { removeKnownHost, startLocalServer, stopLocalServer } from '../../../awsService/sagemaker/model'
+import { assertLogsContain } from '../../globalSetup.test'
+import assert from 'assert'
+
+describe('SageMaker Model', () => {
+ describe('startLocalServer', function () {
+ const ctx = {
+ globalStorageUri: vscode.Uri.file(path.join(os.tmpdir(), 'test-storage')),
+ extensionPath: path.join(os.tmpdir(), 'extension'),
+ asAbsolutePath: (relPath: string) => path.join(path.join(os.tmpdir(), 'extension'), relPath),
+ } as vscode.ExtensionContext
+
+ let sandbox: sinon.SinonSandbox
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ it('waits for info file and starts server', async function () {
+ // Simulate the file doesn't exist initially, then appears on 3rd check
+ const existsStub = sandbox.stub(fs, 'existsFile')
+ existsStub.onCall(0).resolves(false)
+ existsStub.onCall(1).resolves(false)
+ existsStub.onCall(2).resolves(true)
+
+ sandbox.stub(require('fs'), 'openSync').returns(42)
+
+ const stopStub = sandbox.stub().resolves()
+ sandbox.replace(require('../../../awsService/sagemaker/model'), 'stopLocalServer', stopStub)
+
+ const spawnStub = sandbox.stub().returns({ unref: sandbox.stub() })
+ sandbox.replace(require('../../../awsService/sagemaker/utils'), 'spawnDetachedServer', spawnStub)
+
+ sandbox.stub(DevSettings.instance, 'get').returns({ sagemaker: 'https://fake-endpoint' })
+
+ await startLocalServer(ctx)
+
+ sinon.assert.called(spawnStub)
+ sinon.assert.calledWith(
+ spawnStub,
+ process.execPath,
+ [ctx.asAbsolutePath('dist/src/awsService/sagemaker/detached-server/server.js')],
+ sinon.match.any
+ )
+
+ assert.ok(existsStub.callCount >= 3, 'should have retried for file existence')
+ })
+ })
+
+ describe('stopLocalServer', function () {
+ const ctx = {
+ globalStorageUri: vscode.Uri.file(path.join(os.tmpdir(), 'test-storage')),
+ } as vscode.ExtensionContext
+
+ const infoFilePath = path.join(ctx.globalStorageUri.fsPath, 'sagemaker-local-server-info.json')
+ const validPid = 12345
+ const validJson = JSON.stringify({ pid: validPid })
+ let sandbox: sinon.SinonSandbox
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ it('logs debug when successfully stops server and deletes file', async function () {
+ sandbox.stub(fs, 'existsFile').resolves(true)
+ sandbox.stub(fs, 'readFileText').resolves(validJson)
+ const killStub = sandbox.stub(process, 'kill').returns(true)
+ const deleteStub = sandbox.stub(fs, 'delete').resolves()
+
+ await stopLocalServer(ctx)
+
+ sinon.assert.calledWith(killStub, validPid)
+ sinon.assert.calledWith(deleteStub, infoFilePath)
+ assertLogsContain(`stopped local server with PID ${validPid}`, false, 'debug')
+ assertLogsContain('removed server info file.', false, 'debug')
+ })
+
+ it('throws ToolkitError when info file is invalid JSON', async function () {
+ sandbox.stub(fs, 'existsFile').resolves(true)
+ sandbox.stub(fs, 'readFileText').resolves('invalid json')
+
+ try {
+ await stopLocalServer(ctx)
+ assert.ok(false, 'Expected error not thrown')
+ } catch (err) {
+ assert.ok(err instanceof ToolkitError)
+ assert.strictEqual(err.message, 'failed to parse server info file')
+ }
+ })
+
+ it('throws ToolkitError when killing process fails for another reason', async function () {
+ sandbox.stub(fs, 'existsFile').resolves(true)
+ sandbox.stub(fs, 'readFileText').resolves(validJson)
+ sandbox.stub(fs, 'delete').resolves()
+ sandbox.stub(process, 'kill').throws({ code: 'EPERM', message: 'permission denied' })
+
+ try {
+ await stopLocalServer(ctx)
+ assert.ok(false)
+ } catch (err) {
+ assert.ok(err instanceof ToolkitError)
+ assert.strictEqual(err.message, 'failed to stop local server')
+ }
+ })
+ })
+
+ describe('removeKnownHost', function () {
+ const knownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
+ const hostname = 'test.host.com'
+ let sandbox: sinon.SinonSandbox
+
+ beforeEach(function () {
+ sandbox = sinon.createSandbox()
+ })
+
+ afterEach(function () {
+ sandbox.restore()
+ })
+
+ it('removes line with hostname and writes updated file', async function () {
+ sandbox.stub(fs, 'existsFile').resolves(true)
+
+ const inputContent = `${hostname} ssh-rsa AAAA\nsome.other.com ssh-rsa BBBB`
+ const expectedOutput = `some.other.com ssh-rsa BBBB`
+
+ sandbox.stub(fs, 'readFileText').resolves(inputContent)
+
+ const writeStub = sandbox.stub(fs, 'writeFile').resolves()
+ await removeKnownHost(hostname)
+
+ sinon.assert.calledWith(
+ writeStub,
+ knownHostsPath,
+ sinon.match((value: string) => value.trim() === expectedOutput),
+ { atomic: true }
+ )
+ })
+
+ it('logs warning when known_hosts does not exist', async function () {
+ sandbox.stub(fs, 'existsFile').resolves(false)
+
+ await removeKnownHost('test.host.com')
+
+ assertLogsContain(`known_hosts not found at`, false, 'warn')
+ })
+
+ it('throws ToolkitError when reading known_hosts fails', async function () {
+ sandbox.stub(fs, 'existsFile').resolves(true)
+ sandbox.stub(fs, 'readFileText').rejects(new Error('read failed'))
+
+ try {
+ await removeKnownHost(hostname)
+ assert.ok(false, 'Expected error was not thrown')
+ } catch (err) {
+ assert.ok(err instanceof ToolkitError)
+ assert.strictEqual(err.message, 'Failed to read known_hosts file')
+ assert.strictEqual((err as ToolkitError).cause?.message, 'read failed')
+ }
+ })
+
+ it('throws ToolkitError when writing known_hosts fails', async function () {
+ sandbox.stub(fs, 'existsFile').resolves(true)
+ sandbox.stub(fs, 'readFileText').resolves(`${hostname} ssh-rsa key\nsomehost ssh-rsa key`)
+ sandbox.stub(fs, 'writeFile').rejects(new Error('write failed'))
+
+ try {
+ await removeKnownHost(hostname)
+ assert.ok(false, 'Expected error was not thrown')
+ } catch (err) {
+ assert.ok(err instanceof ToolkitError)
+ assert.strictEqual(err.message, 'Failed to write updated known_hosts file')
+ assert.strictEqual((err as ToolkitError).cause?.message, 'write failed')
+ }
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/remoteUtils.test.ts b/packages/core/src/test/awsService/sagemaker/remoteUtils.test.ts
new file mode 100644
index 00000000000..b2e1071e0db
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/remoteUtils.test.ts
@@ -0,0 +1,74 @@
+/*!
+ * 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 { getRemoteAppMetadata } from '../../../awsService/sagemaker/remoteUtils'
+import { fs } from '../../../shared/fs/fs'
+import { SagemakerClient } from '../../../shared/clients/sagemaker'
+
+describe('getRemoteAppMetadata', function () {
+ let sandbox: sinon.SinonSandbox
+ let fsStub: sinon.SinonStub
+ let parseRegionStub: sinon.SinonStub
+ let describeSpaceStub: sinon.SinonStub
+ let loggerStub: sinon.SinonStub
+
+ const mockMetadata = {
+ AppType: 'JupyterLab',
+ DomainId: 'd-f0lwireyzpjp',
+ SpaceName: 'test-ae-3',
+ ExecutionRoleArn: 'arn:aws:iam::177118115371:role/service-role/AmazonSageMaker-ExecutionRole-20250415T091941',
+ ResourceArn: 'arn:aws:sagemaker:us-west-2:177118115371:app/d-f0lwireyzpjp/test-ae-3/JupyterLab/default',
+ ResourceName: 'default',
+ AppImageVersion: '',
+ ResourceArnCaseSensitive:
+ 'arn:aws:sagemaker:us-west-2:177118115371:app/d-f0lwireyzpjp/test-ae-3/JupyterLab/default',
+ IpAddressType: 'ipv4',
+ }
+
+ const mockSpaceDetails = {
+ OwnershipSettings: {
+ OwnerUserProfileName: 'test-user-profile',
+ },
+ }
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox()
+ fsStub = sandbox.stub(fs, 'readFileText')
+ parseRegionStub = sandbox.stub().returns('us-west-2')
+ sandbox.replace(require('../../../awsService/sagemaker/utils'), 'parseRegionFromArn', parseRegionStub)
+
+ describeSpaceStub = sandbox.stub().resolves(mockSpaceDetails)
+ sandbox.stub(SagemakerClient.prototype, 'describeSpace').callsFake(describeSpaceStub)
+
+ loggerStub = sandbox.stub().returns({
+ error: sandbox.stub(),
+ })
+ sandbox.replace(require('../../../shared/logger/logger'), 'getLogger', loggerStub)
+ })
+
+ afterEach(() => {
+ sandbox.restore()
+ })
+
+ it('successfully reads metadata file and returns remote app metadata', async function () {
+ fsStub.resolves(JSON.stringify(mockMetadata))
+
+ const result = await getRemoteAppMetadata()
+
+ assert.deepStrictEqual(result, {
+ DomainId: 'd-f0lwireyzpjp',
+ UserProfileName: 'test-user-profile',
+ })
+
+ sinon.assert.calledWith(fsStub, '/opt/ml/metadata/resource-metadata.json')
+ sinon.assert.calledWith(parseRegionStub, mockMetadata.ResourceArn)
+ sinon.assert.calledWith(describeSpaceStub, {
+ DomainId: 'd-f0lwireyzpjp',
+ SpaceName: 'test-ae-3',
+ })
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/uriHandlers.test.ts b/packages/core/src/test/awsService/sagemaker/uriHandlers.test.ts
new file mode 100644
index 00000000000..9ff24b2a3f9
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/uriHandlers.test.ts
@@ -0,0 +1,59 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as sinon from 'sinon'
+import * as vscode from 'vscode'
+import assert from 'assert'
+import { UriHandler } from '../../../shared/vscode/uriHandler'
+import { VSCODE_EXTENSION_ID } from '../../../shared/extensions'
+import { register } from '../../../awsService/sagemaker/uriHandlers'
+
+function createConnectUri(params: { [key: string]: string }): vscode.Uri {
+ const query = Object.entries(params)
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+ .join('&')
+ return vscode.Uri.parse(`vscode://${VSCODE_EXTENSION_ID.awstoolkit}/connect/sagemaker?${query}`)
+}
+
+describe('SageMaker URI handler', function () {
+ let handler: UriHandler
+ let deeplinkConnectStub: sinon.SinonStub
+
+ beforeEach(function () {
+ handler = new UriHandler()
+ deeplinkConnectStub = sinon.stub().resolves()
+ sinon.replace(require('../../../awsService/sagemaker/commands'), 'deeplinkConnect', deeplinkConnectStub)
+
+ register({
+ uriHandler: handler,
+ } as any)
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ it('calls deeplinkConnect with all expected params', async function () {
+ const params = {
+ connection_identifier: 'abc123',
+ domain: 'my-domain',
+ user_profile: 'me',
+ session: 'sess-xyz',
+ ws_url: 'wss://example.com',
+ 'cell-number': '4',
+ token: 'my-token',
+ }
+
+ const uri = createConnectUri(params)
+ await handler.handleUri(uri)
+
+ assert.ok(deeplinkConnectStub.calledOnce)
+ assert.deepStrictEqual(deeplinkConnectStub.firstCall.args[1], 'abc123')
+ assert.deepStrictEqual(deeplinkConnectStub.firstCall.args[2], 'sess-xyz')
+ assert.deepStrictEqual(deeplinkConnectStub.firstCall.args[3], 'wss://example.com&cell-number=4')
+ assert.deepStrictEqual(deeplinkConnectStub.firstCall.args[4], 'my-token')
+ assert.deepStrictEqual(deeplinkConnectStub.firstCall.args[5], 'my-domain')
+ })
+})
diff --git a/packages/core/src/test/awsService/sagemaker/utils.test.ts b/packages/core/src/test/awsService/sagemaker/utils.test.ts
new file mode 100644
index 00000000000..b7376790106
--- /dev/null
+++ b/packages/core/src/test/awsService/sagemaker/utils.test.ts
@@ -0,0 +1,66 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { AppStatus, SpaceStatus } from '@aws-sdk/client-sagemaker'
+import { generateSpaceStatus } from '../../../awsService/sagemaker/utils'
+import * as assert from 'assert'
+
+describe('generateSpaceStatus', function () {
+ it('returns Failed if space status is Failed', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.Failed, AppStatus.InService), 'Failed')
+ })
+
+ it('returns Failed if space status is Delete_Failed', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.Delete_Failed, AppStatus.InService), 'Failed')
+ })
+
+ it('returns Failed if space status is Update_Failed', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.Update_Failed, AppStatus.InService), 'Failed')
+ })
+
+ it('returns Failed if app status is Failed and space status is not Updating', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.Deleting, AppStatus.Failed), 'Failed')
+ })
+
+ it('does not return Failed if app status is Failed but space status is Updating', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.Updating, AppStatus.Failed), 'Updating')
+ })
+
+ it('returns Running if both statuses are InService', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.InService, AppStatus.InService), 'Running')
+ })
+
+ it('returns Starting if app is Pending and space is InService', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.InService, AppStatus.Pending), 'Starting')
+ })
+
+ it('returns Updating if space status is Updating', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.Updating, AppStatus.Deleting), 'Updating')
+ })
+
+ it('returns Stopping if app is Deleting and space is InService', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.InService, AppStatus.Deleting), 'Stopping')
+ })
+
+ it('returns Stopped if app is Deleted and space is InService', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.InService, AppStatus.Deleted), 'Stopped')
+ })
+
+ it('returns Stopped if app status is undefined and space is InService', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.InService, undefined), 'Stopped')
+ })
+
+ it('returns Deleting if space is Deleting', function () {
+ assert.strictEqual(generateSpaceStatus(SpaceStatus.Deleting, AppStatus.InService), 'Deleting')
+ })
+
+ it('returns Unknown if none of the above match', function () {
+ assert.strictEqual(generateSpaceStatus(undefined, undefined), 'Unknown')
+ assert.strictEqual(
+ generateSpaceStatus('SomeOtherStatus' as SpaceStatus, 'RandomAppStatus' as AppStatus),
+ 'Unknown'
+ )
+ })
+})
diff --git a/packages/core/src/test/lambda/commands/editLambda.test.ts b/packages/core/src/test/lambda/commands/editLambda.test.ts
new file mode 100644
index 00000000000..9c38f767885
--- /dev/null
+++ b/packages/core/src/test/lambda/commands/editLambda.test.ts
@@ -0,0 +1,251 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as vscode from 'vscode'
+import assert from 'assert'
+import * as sinon from 'sinon'
+import {
+ editLambda,
+ watchForUpdates,
+ promptForSync,
+ deployFromTemp,
+ openLambdaFolderForEdit,
+} from '../../../lambda/commands/editLambda'
+import { LambdaFunction } from '../../../lambda/commands/uploadLambda'
+import * as downloadLambda from '../../../lambda/commands/downloadLambda'
+import * as uploadLambda from '../../../lambda/commands/uploadLambda'
+import * as utils from '../../../lambda/utils'
+import * as messages from '../../../shared/utilities/messages'
+import fs from '../../../shared/fs/fs'
+import { LambdaFunctionNodeDecorationProvider } from '../../../lambda/explorer/lambdaFunctionNodeDecorationProvider'
+import path from 'path'
+
+describe('editLambda', function () {
+ let mockLambda: LambdaFunction
+ let mockTemp: string
+ let mockUri: vscode.Uri
+
+ // Stub variables
+ let getFunctionInfoStub: sinon.SinonStub
+ let setFunctionInfoStub: sinon.SinonStub
+ let compareCodeShaStub: sinon.SinonStub
+ let downloadLambdaStub: sinon.SinonStub
+ let openLambdaFileStub: sinon.SinonStub
+ let runUploadDirectoryStub: sinon.SinonStub
+ let showConfirmationMessageStub: sinon.SinonStub
+ let createFileSystemWatcherStub: sinon.SinonStub
+ let executeCommandStub: sinon.SinonStub
+ let existsDirStub: sinon.SinonStub
+ let mkdirStub: sinon.SinonStub
+ let promptDeployStub: sinon.SinonStub
+
+ beforeEach(function () {
+ mockLambda = {
+ name: 'test-function',
+ region: 'us-east-1',
+ configuration: {
+ FunctionName: 'test-function',
+ CodeSha256: 'test-sha',
+ Runtime: 'nodejs18.x',
+ },
+ }
+ mockTemp = utils.getTempLocation(mockLambda.name, mockLambda.region)
+ mockUri = vscode.Uri.file(mockTemp)
+
+ // Create stubs
+ getFunctionInfoStub = sinon.stub(utils, 'getFunctionInfo').resolves(undefined)
+ setFunctionInfoStub = sinon.stub(utils, 'setFunctionInfo').resolves()
+ compareCodeShaStub = sinon.stub(utils, 'compareCodeSha').resolves(true)
+ downloadLambdaStub = sinon.stub(downloadLambda, 'downloadLambdaInLocation').resolves()
+ openLambdaFileStub = sinon.stub(downloadLambda, 'openLambdaFile').resolves()
+ runUploadDirectoryStub = sinon.stub(uploadLambda, 'runUploadDirectory').resolves()
+ showConfirmationMessageStub = sinon.stub(messages, 'showConfirmationMessage').resolves(true)
+ createFileSystemWatcherStub = sinon.stub(vscode.workspace, 'createFileSystemWatcher').returns({
+ onDidChange: sinon.stub(),
+ onDidCreate: sinon.stub(),
+ onDidDelete: sinon.stub(),
+ dispose: sinon.stub(),
+ } as any)
+ executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves()
+ existsDirStub = sinon.stub(fs, 'existsDir').resolves(true)
+ mkdirStub = sinon.stub(fs, 'mkdir').resolves()
+ promptDeployStub = sinon.stub().resolves(true)
+ sinon.replace(require('../../../lambda/commands/editLambda'), 'promptDeploy', promptDeployStub)
+
+ // Other stubs
+ sinon.stub(utils, 'lambdaEdits').value([])
+ sinon.stub(utils, 'getLambdaDetails').returns({ fileName: 'index.js', functionName: 'test-function' })
+ sinon.stub(fs, 'readdir').resolves([])
+ sinon.stub(fs, 'delete').resolves()
+ sinon.stub(fs, 'stat').resolves({ ctime: Date.now() } as any)
+ sinon.stub(vscode.workspace, 'saveAll').resolves(true)
+ sinon.stub(LambdaFunctionNodeDecorationProvider.prototype, 'addBadge').resolves()
+ sinon.stub(LambdaFunctionNodeDecorationProvider.prototype, 'removeBadge').resolves()
+ sinon.stub(LambdaFunctionNodeDecorationProvider, 'getInstance').returns({
+ addBadge: sinon.stub().resolves(),
+ removeBadge: sinon.stub().resolves(),
+ } as any)
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ describe('editLambda', function () {
+ it('returns early if folder already exists in workspace', async function () {
+ sinon.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file(mockTemp) }])
+
+ const result = await editLambda(mockLambda)
+
+ assert.strictEqual(result, mockTemp)
+ })
+
+ it('downloads lambda when no local code exists', async function () {
+ await editLambda(mockLambda)
+
+ assert(downloadLambdaStub.calledOnce)
+ })
+
+ it('prompts for overwrite when local code differs from remote', async function () {
+ getFunctionInfoStub.resolves('old-sha')
+ compareCodeShaStub.resolves(false)
+
+ await editLambda(mockLambda)
+
+ assert(showConfirmationMessageStub.calledOnce)
+ })
+
+ it('opens existing file when user declines overwrite', async function () {
+ getFunctionInfoStub.resolves('old-sha')
+ compareCodeShaStub.resolves(false)
+ showConfirmationMessageStub.resolves(false)
+
+ await editLambda(mockLambda)
+
+ assert(openLambdaFileStub.calledOnce)
+ })
+
+ it('sets up file watcher after download', async function () {
+ const watcherStub = {
+ onDidChange: sinon.stub(),
+ onDidCreate: sinon.stub(),
+ onDidDelete: sinon.stub(),
+ }
+ createFileSystemWatcherStub.returns(watcherStub)
+
+ await editLambda(mockLambda)
+
+ assert(watcherStub.onDidChange.calledOnce)
+ assert(watcherStub.onDidCreate.calledOnce)
+ assert(watcherStub.onDidDelete.calledOnce)
+ })
+ })
+
+ describe('watchForUpdates', function () {
+ it('creates file system watcher with correct pattern', function () {
+ const watcher = {
+ onDidChange: sinon.stub(),
+ onDidCreate: sinon.stub(),
+ onDidDelete: sinon.stub(),
+ }
+ createFileSystemWatcherStub.returns(watcher)
+
+ watchForUpdates(mockLambda, mockUri)
+
+ assert(createFileSystemWatcherStub.calledOnce)
+ const pattern = createFileSystemWatcherStub.firstCall.args[0]
+ assert(pattern instanceof vscode.RelativePattern)
+ })
+
+ it('sets up change, create, and delete handlers', function () {
+ const watcher = {
+ onDidChange: sinon.stub(),
+ onDidCreate: sinon.stub(),
+ onDidDelete: sinon.stub(),
+ }
+ createFileSystemWatcherStub.returns(watcher)
+
+ watchForUpdates(mockLambda, mockUri)
+
+ assert(watcher.onDidChange.calledOnce)
+ assert(watcher.onDidCreate.calledOnce)
+ assert(watcher.onDidDelete.calledOnce)
+ })
+ })
+
+ describe('promptForSync', function () {
+ it('returns early if directory does not exist', async function () {
+ existsDirStub.resolves(false)
+
+ await promptForSync(mockLambda, mockUri, vscode.Uri.file('/test/file.js'))
+
+ assert(setFunctionInfoStub.notCalled)
+ })
+ })
+
+ describe('deployFromTemp', function () {
+ it('uploads without confirmation when code is up to date', async function () {
+ await deployFromTemp(mockLambda, mockUri)
+
+ assert(showConfirmationMessageStub.notCalled)
+ assert(runUploadDirectoryStub.calledOnce)
+ })
+
+ it('prompts for confirmation when code is outdated', async function () {
+ compareCodeShaStub.resolves(false)
+
+ await deployFromTemp(mockLambda, mockUri)
+
+ assert(showConfirmationMessageStub.calledOnce)
+ })
+
+ it('does not upload when user declines overwrite', async function () {
+ compareCodeShaStub.resolves(false)
+ showConfirmationMessageStub.resolves(false)
+
+ await deployFromTemp(mockLambda, mockUri)
+
+ assert(runUploadDirectoryStub.notCalled)
+ })
+
+ it('updates function info after successful upload', async function () {
+ await deployFromTemp(mockLambda, mockUri)
+
+ assert(runUploadDirectoryStub.calledOnce)
+ assert(
+ setFunctionInfoStub.calledWith(mockLambda, {
+ lastDeployed: sinon.match.number,
+ undeployed: false,
+ })
+ )
+ })
+ })
+
+ describe('openLambdaFolderForEdit', function () {
+ it('focuses existing workspace folder if already open', async function () {
+ const subfolderPath = path.normalize(path.join(mockTemp, 'subfolder'))
+ sinon.stub(vscode.workspace, 'workspaceFolders').value([{ uri: vscode.Uri.file(subfolderPath) }])
+
+ await openLambdaFolderForEdit('test-function', 'us-east-1')
+
+ assert(executeCommandStub.calledWith('workbench.action.focusSideBar'))
+ assert(executeCommandStub.calledWith('workbench.view.explorer'))
+ })
+
+ it('opens new folder when not in workspace', async function () {
+ sinon.stub(vscode.workspace, 'workspaceFolders').value([])
+
+ await openLambdaFolderForEdit('test-function', 'us-east-1')
+
+ assert(mkdirStub.calledOnce)
+ assert(
+ executeCommandStub.calledWith('vscode.openFolder', sinon.match.any, {
+ newWindow: true,
+ noRecentEntry: true,
+ })
+ )
+ })
+ })
+})
diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionFileNode.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionFileNode.test.ts
new file mode 100644
index 00000000000..59235bf7558
--- /dev/null
+++ b/packages/core/src/test/lambda/explorer/lambdaFunctionFileNode.test.ts
@@ -0,0 +1,58 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode'
+import { TestAWSTreeNode } from '../../shared/treeview/nodes/testAWSTreeNode'
+import { LambdaFunctionFileNode } from '../../../lambda/explorer/lambdaFunctionFileNode'
+import path from 'path'
+
+describe('LambdaFunctionFileNode', function () {
+ const fakeFunctionConfig = {
+ FunctionName: 'testFunctionName',
+ FunctionArn: 'testFunctionARN',
+ }
+ const fakeFilename = 'fakeFile'
+ const fakeRegion = 'fakeRegion'
+ const functionNode = new LambdaFunctionNode(new TestAWSTreeNode('test node'), fakeRegion, fakeFunctionConfig)
+ const filePath = path.join(
+ '/tmp/aws-toolkit-vscode/lambda',
+ fakeRegion,
+ fakeFunctionConfig.FunctionName,
+ fakeFilename
+ )
+
+ let testNode: LambdaFunctionFileNode
+
+ before(async function () {
+ testNode = new LambdaFunctionFileNode(functionNode, fakeFilename, filePath)
+ })
+
+ it('instantiates without issue', function () {
+ assert.ok(testNode)
+ })
+
+ it('initializes the parent node', function () {
+ assert.equal(testNode.parent, functionNode, 'unexpected parent node')
+ })
+
+ it('initializes the label', function () {
+ assert.equal(testNode.label, fakeFilename)
+ })
+
+ it('has no children', async function () {
+ const childNodes = await testNode.getChildren()
+ assert.ok(childNodes)
+ assert.strictEqual(childNodes.length, 0, 'Expected zero children')
+ })
+
+ it('has correct command', function () {
+ assert.deepStrictEqual(testNode.command, {
+ command: 'aws.openLambdaFile',
+ title: 'Open file',
+ arguments: [filePath],
+ })
+ })
+})
diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionFolderNode.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionFolderNode.test.ts
new file mode 100644
index 00000000000..67471bc4e24
--- /dev/null
+++ b/packages/core/src/test/lambda/explorer/lambdaFunctionFolderNode.test.ts
@@ -0,0 +1,57 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode'
+import { TestAWSTreeNode } from '../../shared/treeview/nodes/testAWSTreeNode'
+import path from 'path'
+import { LambdaFunctionFolderNode } from '../../../lambda/explorer/lambdaFunctionFolderNode'
+import { fs } from '../../../shared/fs/fs'
+
+describe('LambdaFunctionFileNode', function () {
+ const fakeFunctionConfig = {
+ FunctionName: 'testFunctionName',
+ FunctionArn: 'testFunctionARN',
+ }
+ const fakeRegion = 'fakeRegion'
+ const fakeSubFolder = 'fakeSubFolder'
+ const fakeFile = 'fakeFilename'
+ const functionNode = new LambdaFunctionNode(new TestAWSTreeNode('test node'), fakeRegion, fakeFunctionConfig)
+
+ const regionPath = path.join('/tmp/aws-toolkit-vscode/lambda', fakeRegion)
+ const functionPath = path.join(regionPath, fakeFunctionConfig.FunctionName)
+ const subFolderPath = path.join(functionPath, fakeSubFolder)
+
+ let testNode: LambdaFunctionFolderNode
+
+ before(async function () {
+ await fs.mkdir(subFolderPath)
+ await fs.writeFile(path.join(subFolderPath, fakeFile), 'fakefilecontent')
+
+ testNode = new LambdaFunctionFolderNode(functionNode, fakeSubFolder, subFolderPath)
+ })
+
+ after(async function () {
+ await fs.delete(regionPath, { recursive: true })
+ })
+
+ it('instantiates without issue', function () {
+ assert.ok(testNode)
+ })
+
+ it('initializes the parent node', function () {
+ assert.equal(testNode.parent, functionNode, 'unexpected parent node')
+ })
+
+ it('initializes the label', function () {
+ assert.equal(testNode.label, fakeSubFolder)
+ })
+
+ it('loads function files', async function () {
+ const functionFiles = await testNode.loadFunctionFiles()
+ assert.equal(functionFiles.length, 1)
+ assert.equal(functionFiles[0].label, fakeFile)
+ })
+})
diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts
index cb9ffc9b5f2..184bdd915b8 100644
--- a/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts
+++ b/packages/core/src/test/lambda/explorer/lambdaFunctionNode.test.ts
@@ -4,23 +4,54 @@
*/
import assert from 'assert'
-import { Lambda } from 'aws-sdk'
import * as os from 'os'
import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode'
import { TestAWSTreeNode } from '../../shared/treeview/nodes/testAWSTreeNode'
+import path from 'path'
+import { fs } from '../../../shared/fs/fs'
+import {
+ contextValueLambdaFunction,
+ contextValueLambdaFunctionImportable,
+} from '../../../lambda/explorer/lambdaFunctionNode'
+import sinon from 'sinon'
+import * as editLambdaModule from '../../../lambda/commands/editLambda'
describe('LambdaFunctionNode', function () {
const parentNode = new TestAWSTreeNode('test node')
+ const fakeRegion = 'fakeRegion'
+ const fakeFilename = 'fakeFilename'
+
+ const fakeFunctionConfig = {
+ FunctionName: 'testFunctionName',
+ FunctionArn: 'testFunctionARN',
+ }
+
+ const regionPath = path.join('/tmp/aws-toolkit-vscode/lambda', fakeRegion)
+ const functionPath = path.join(regionPath, fakeFunctionConfig.FunctionName)
+ const filePath = path.join(functionPath, fakeFilename)
+
let testNode: LambdaFunctionNode
- let fakeFunctionConfig: Lambda.FunctionConfiguration
- before(function () {
- fakeFunctionConfig = {
- FunctionName: 'testFunctionName',
- FunctionArn: 'testFunctionARN',
- }
+ let editLambdaStub: sinon.SinonStub
+
+ before(async function () {
+ await fs.mkdir(functionPath)
+ await fs.writeFile(filePath, 'fakefilecontent')
+
+ // Stub the editLambdaCommand to return the function path
+ editLambdaStub = sinon.stub(editLambdaModule, 'editLambdaCommand').resolves(functionPath)
+
+ testNode = new LambdaFunctionNode(
+ parentNode,
+ 'someregioncode',
+ fakeFunctionConfig,
+ contextValueLambdaFunctionImportable
+ )
+ })
- testNode = new LambdaFunctionNode(parentNode, 'someregioncode', fakeFunctionConfig)
+ after(async function () {
+ await fs.delete(regionPath, { recursive: true })
+ editLambdaStub.restore()
})
it('instantiates without issue', async function () {
@@ -43,6 +74,11 @@ describe('LambdaFunctionNode', function () {
assert.strictEqual(testNode.functionName, fakeFunctionConfig.FunctionName)
})
+ it('initializes resourceUri', async function () {
+ assert.strictEqual(testNode.resourceUri?.scheme, 'lambda')
+ assert.strictEqual(testNode.resourceUri?.path, `someregioncode/${fakeFunctionConfig.FunctionName}`)
+ })
+
it('initializes the tooltip', async function () {
assert.strictEqual(
testNode.tooltip,
@@ -50,9 +86,27 @@ describe('LambdaFunctionNode', function () {
)
})
- it('has no children', async function () {
+ it('loads function files', async function () {
+ const functionFiles = await testNode.loadFunctionFiles(functionPath)
+ assert.equal(functionFiles.length, 1)
+ assert.equal(functionFiles[0].label, fakeFilename)
+ })
+
+ it('has child if importable', async function () {
const childNodes = await testNode.getChildren()
assert.ok(childNodes)
- assert.strictEqual(childNodes.length, 0, 'Expected node to have no children')
+ assert.equal(childNodes.length, 1, 'Expected node to have one child, should be "failed to load resources"')
+ })
+
+ it('is not collapsible if not importable', async function () {
+ const nonImportableNode = new LambdaFunctionNode(
+ parentNode,
+ fakeRegion,
+ fakeFunctionConfig,
+ contextValueLambdaFunction
+ )
+ const childNodes = await nonImportableNode.getChildren()
+ assert.ok(childNodes)
+ assert.equal(childNodes.length, 0, 'Expected node to have no children')
})
})
diff --git a/packages/core/src/test/lambda/explorer/lambdaFunctionNodeDecorationProvider.test.ts b/packages/core/src/test/lambda/explorer/lambdaFunctionNodeDecorationProvider.test.ts
new file mode 100644
index 00000000000..19a2662815f
--- /dev/null
+++ b/packages/core/src/test/lambda/explorer/lambdaFunctionNodeDecorationProvider.test.ts
@@ -0,0 +1,147 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import * as vscode from 'vscode'
+import * as sinon from 'sinon'
+import * as path from 'path'
+import { LambdaFunctionNodeDecorationProvider } from '../../../lambda/explorer/lambdaFunctionNodeDecorationProvider'
+import * as utils from '../../../lambda/utils'
+import { fs } from '../../../shared/fs/fs'
+
+describe('LambdaFunctionNodeDecorationProvider', function () {
+ let provider: LambdaFunctionNodeDecorationProvider
+ let getFunctionInfoStub: sinon.SinonStub
+ let fsStatStub: sinon.SinonStub
+ let fsReaddirStub: sinon.SinonStub
+
+ const filepath = path.join(utils.getTempLocation('test-function', 'us-east-1'), 'index.js')
+ const functionUri = vscode.Uri.parse('lambda:us-east-1/test-function')
+ const fileUri = vscode.Uri.file(filepath)
+
+ beforeEach(function () {
+ provider = LambdaFunctionNodeDecorationProvider.getInstance()
+ getFunctionInfoStub = sinon.stub(utils, 'getFunctionInfo')
+ fsStatStub = sinon.stub(fs, 'stat')
+ fsReaddirStub = sinon.stub(fs, 'readdir')
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ describe('provideFileDecoration', function () {
+ it('returns decoration for lambda URI with undeployed changes', async function () {
+ getFunctionInfoStub.resolves(true)
+
+ const decoration = await provider.provideFileDecoration(functionUri)
+
+ assert.ok(decoration)
+ assert.strictEqual(decoration.badge, 'M')
+ assert.strictEqual(decoration.tooltip, 'This function has undeployed changes')
+ assert.strictEqual(decoration.propagate, false)
+ })
+
+ it('returns undefined for lambda URI without undeployed changes', async function () {
+ getFunctionInfoStub.resolves(false)
+
+ const decoration = await provider.provideFileDecoration(functionUri)
+
+ assert.strictEqual(decoration, undefined)
+ })
+
+ it('returns decoration for file URI with modifications after deployment', async function () {
+ const lastDeployed = 1
+ const fileModified = 2
+
+ getFunctionInfoStub.resolves({ lastDeployed, undeployed: true })
+ fsStatStub.resolves({ mtime: fileModified })
+
+ const decoration = await provider.provideFileDecoration(fileUri)
+
+ assert.ok(decoration)
+ assert.strictEqual(decoration.badge, 'M')
+ assert.strictEqual(decoration.tooltip, 'This function has undeployed changes')
+ assert.strictEqual(decoration.propagate, true)
+ })
+
+ it('returns undefined for file URI without modifications after deployment', async function () {
+ const lastDeployed = 2
+ const fileModified = 1
+
+ getFunctionInfoStub.resolves({ lastDeployed, undeployed: true })
+ fsStatStub.resolves({ mtime: fileModified })
+
+ const decoration = await provider.provideFileDecoration(fileUri)
+
+ assert.strictEqual(decoration, undefined)
+ })
+
+ it('returns undefined for file URI when no deployment info exists', async function () {
+ getFunctionInfoStub.resolves(undefined)
+
+ const decoration = await provider.provideFileDecoration(fileUri)
+
+ assert.strictEqual(decoration, undefined)
+ })
+
+ it('returns undefined for file URI that does not match lambda pattern', async function () {
+ const uri = vscode.Uri.file(path.join('not', 'in', 'tmp'))
+
+ const decoration = await provider.provideFileDecoration(uri)
+
+ assert.strictEqual(decoration, undefined)
+ })
+
+ it('handles errors gracefully when checking file modification', async function () {
+ getFunctionInfoStub.resolves(0)
+ fsStatStub.rejects(new Error('File not found'))
+
+ const decoration = await provider.provideFileDecoration(fileUri)
+
+ assert.strictEqual(decoration, undefined)
+ })
+ })
+
+ describe('addBadge', function () {
+ it('fires decoration change events for both URIs', async function () {
+ const fileUri = vscode.Uri.file(path.join('test', 'file.js'))
+ const functionUri = vscode.Uri.parse('lambda:us-east-1/test-function')
+
+ let eventCount = 0
+ const disposable = provider.onDidChangeFileDecorations(() => {
+ eventCount++
+ })
+
+ await provider.addBadge(fileUri, functionUri)
+
+ assert.strictEqual(eventCount, 2)
+ disposable.dispose()
+ })
+ })
+
+ describe('getFilePaths', function () {
+ it('returns all file paths recursively', async function () {
+ const basePath = path.join('test', 'dir')
+
+ // Mock first readdir call
+ fsReaddirStub.onFirstCall().resolves([
+ ['file1.js', vscode.FileType.File],
+ ['subdir', vscode.FileType.Directory],
+ ])
+
+ // Mock second readdir call for subdirectory
+ fsReaddirStub.onSecondCall().resolves([['file2.js', vscode.FileType.File]])
+
+ // Access private method through any cast for testing
+ const paths = await (provider as any).getFilePaths(basePath)
+
+ assert.ok(paths.includes(basePath))
+ assert.ok(paths.includes(path.join('test', 'dir', 'file1.js')))
+ assert.ok(paths.includes(path.join('test', 'dir', 'subdir')))
+ assert.ok(paths.includes(path.join('test', 'dir', 'subdir', 'file2.js')))
+ })
+ })
+})
diff --git a/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts b/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts
index ba494680911..2c94d28ba9b 100644
--- a/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts
+++ b/packages/core/src/test/lambda/explorer/lambdaNodes.test.ts
@@ -4,8 +4,8 @@
*/
import assert from 'assert'
-import { LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode'
-import { contextValueLambdaFunction, LambdaNode } from '../../../lambda/explorer/lambdaNodes'
+import { contextValueLambdaFunction, LambdaFunctionNode } from '../../../lambda/explorer/lambdaFunctionNode'
+import { LambdaNode } from '../../../lambda/explorer/lambdaNodes'
import { asyncGenerator } from '../../../shared/utilities/collectionUtils'
import {
assertNodeListOnlyHasErrorNode,
diff --git a/packages/core/src/test/lambda/uriHandlers.test.ts b/packages/core/src/test/lambda/uriHandlers.test.ts
new file mode 100644
index 00000000000..f3e8ae7c368
--- /dev/null
+++ b/packages/core/src/test/lambda/uriHandlers.test.ts
@@ -0,0 +1,36 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import assert from 'assert'
+import { SearchParams } from '../../shared/vscode/uriHandler'
+import { parseOpenParams } from '../../lambda/uriHandlers'
+import { globals } from '../../shared'
+
+describe('Lambda URI Handler', function () {
+ describe('load-function', function () {
+ it('registers for "/lambda/load-function"', function () {
+ assert.throws(() => globals.uriHandler.onPath('/lambda/load-function', () => {}))
+ })
+
+ it('parses parameters', function () {
+ let query = new SearchParams({
+ functionName: 'example',
+ })
+ assert.throws(() => parseOpenParams(query), /A region must be provided/)
+ query = new SearchParams({
+ region: 'example',
+ })
+ assert.throws(() => parseOpenParams(query), /A function name must be provided/)
+
+ const valid = {
+ functionName: 'example',
+ region: 'example',
+ isCfn: 'false',
+ }
+ query = new SearchParams(valid)
+ assert.deepEqual(parseOpenParams(query), valid)
+ })
+ })
+})
diff --git a/packages/core/src/test/lambda/utils.test.ts b/packages/core/src/test/lambda/utils.test.ts
index b7c1edb0aa4..72f5d3e63e4 100644
--- a/packages/core/src/test/lambda/utils.test.ts
+++ b/packages/core/src/test/lambda/utils.test.ts
@@ -4,9 +4,30 @@
*/
import assert from 'assert'
-import { getLambdaDetails } from '../../lambda/utils'
+import * as sinon from 'sinon'
+import {
+ getLambdaDetails,
+ getTempLocation,
+ getTempRegionLocation,
+ getFunctionInfo,
+ setFunctionInfo,
+ compareCodeSha,
+ lambdaEdits,
+ getLambdaEditFromNameRegion,
+ getLambdaEditFromLocation,
+} from '../../lambda/utils'
+import { LambdaFunction } from '../../lambda/commands/uploadLambda'
+import { DefaultLambdaClient } from '../../shared/clients/lambdaClient'
+import { fs } from '../../shared/fs/fs'
+import { tempDirPath } from '../../shared/filesystemUtilities'
+import path from 'path'
-describe('lambda utils', async function () {
+describe('lambda utils', function () {
+ const mockLambda = {
+ name: 'test-function',
+ region: 'us-east-1',
+ configuration: { FunctionName: 'test-function' },
+ }
describe('getLambdaDetails', function () {
it('returns valid filenames and function names', function () {
const jsNonNestedParsedName = getLambdaDetails({
@@ -52,4 +73,162 @@ describe('lambda utils', async function () {
assert.throws(() => getLambdaDetails({ Runtime: 'COBOL-60', Handler: 'asdf.asdf' }))
})
})
+
+ describe('getTempLocation', function () {
+ it('returns correct temp location path', function () {
+ const result = getTempLocation('test-function', 'us-east-1')
+ const expected = path.join(tempDirPath, 'lambda', 'us-east-1', 'test-function')
+ assert.strictEqual(result, expected)
+ })
+ })
+
+ describe('getTempRegionLocation', function () {
+ it('returns correct temp region path', function () {
+ const result = getTempRegionLocation('us-west-2')
+ const expected = path.join(tempDirPath, 'lambda', 'us-west-2')
+ assert.strictEqual(result, expected)
+ })
+ })
+
+ describe('getFunctionInfo', function () {
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ it('returns parsed data when file exists', async function () {
+ const mockData = { lastDeployed: 123456, undeployed: false, sha: 'test-sha' }
+ sinon.stub(fs, 'readFileText').resolves(JSON.stringify(mockData))
+
+ const result = await getFunctionInfo(mockLambda)
+ assert.deepStrictEqual(result, mockData)
+ })
+
+ it('returns specific field when requested', async function () {
+ const mockData = { lastDeployed: 123456, undeployed: false, sha: 'test-sha' }
+ sinon.stub(fs, 'readFileText').resolves(JSON.stringify(mockData))
+
+ const result = await getFunctionInfo(mockLambda, 'sha')
+ assert.strictEqual(result, 'test-sha')
+ })
+
+ it('returns empty object when file does not exist', async function () {
+ sinon.stub(fs, 'readFileText').rejects(new Error('File not found'))
+
+ const result = await getFunctionInfo(mockLambda)
+ assert.deepStrictEqual(result, {})
+ })
+ })
+
+ describe('setFunctionInfo', function () {
+ let mockLambda: LambdaFunction
+
+ beforeEach(function () {
+ mockLambda = {
+ name: 'test-function',
+ region: 'us-east-1',
+ configuration: { FunctionName: 'test-function' },
+ }
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ it('merges with existing data', async function () {
+ const existingData = { lastDeployed: 123456, undeployed: true, sha: 'old-sha' }
+ sinon.stub(fs, 'readFileText').resolves(JSON.stringify(existingData))
+ const writeStub = sinon.stub(fs, 'writeFile').resolves()
+ sinon.stub(DefaultLambdaClient.prototype, 'getFunction').resolves({
+ Configuration: { CodeSha256: 'new-sha' },
+ } as any)
+
+ await setFunctionInfo(mockLambda, { undeployed: false })
+
+ assert(writeStub.calledOnce)
+ const writtenData = JSON.parse(writeStub.firstCall.args[1] as string)
+ assert.strictEqual(writtenData.lastDeployed, 123456)
+ assert.strictEqual(writtenData.undeployed, false)
+ assert.strictEqual(writtenData.sha, 'new-sha')
+ })
+ })
+
+ describe('compareCodeSha', function () {
+ let mockLambda: LambdaFunction
+
+ beforeEach(function () {
+ mockLambda = {
+ name: 'test-function',
+ region: 'us-east-1',
+ configuration: { FunctionName: 'test-function' },
+ }
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ it('returns true when local and remote SHA match', async function () {
+ sinon.stub(fs, 'readFileText').resolves(JSON.stringify({ sha: 'same-sha' }))
+ sinon.stub(DefaultLambdaClient.prototype, 'getFunction').resolves({
+ Configuration: { CodeSha256: 'same-sha' },
+ } as any)
+
+ const result = await compareCodeSha(mockLambda)
+ assert.strictEqual(result, true)
+ })
+
+ it('returns false when local and remote SHA differ', async function () {
+ sinon.stub(fs, 'readFileText').resolves(JSON.stringify({ sha: 'local-sha' }))
+ sinon.stub(DefaultLambdaClient.prototype, 'getFunction').resolves({
+ Configuration: { CodeSha256: 'remote-sha' },
+ } as any)
+
+ const result = await compareCodeSha(mockLambda)
+ assert.strictEqual(result, false)
+ })
+ })
+
+ describe('lambdaEdits array functions', function () {
+ beforeEach(function () {
+ lambdaEdits.length = 0
+ lambdaEdits.push(
+ {
+ location: '/tmp/func1',
+ functionName: 'func1',
+ region: 'us-east-1',
+ },
+ {
+ location: '/tmp/func2',
+ functionName: 'func2',
+ region: 'us-west-2',
+ }
+ )
+ })
+
+ describe('getLambdaEditFromNameRegion', function () {
+ it('finds edit by name and region', function () {
+ const result = getLambdaEditFromNameRegion('func1', 'us-east-1')
+ assert.strictEqual(result?.functionName, 'func1')
+ assert.strictEqual(result?.region, 'us-east-1')
+ })
+
+ it('returns undefined when not found', function () {
+ const result = getLambdaEditFromNameRegion('nonexistent', 'us-east-1')
+ assert.strictEqual(result, undefined)
+ })
+ })
+
+ describe('getLambdaEditFromLocation', function () {
+ it('finds edit by location', function () {
+ const result = getLambdaEditFromLocation('/tmp/func2')
+ assert.strictEqual(result?.functionName, 'func2')
+ assert.strictEqual(result?.location, '/tmp/func2')
+ })
+
+ it('returns undefined when not found', function () {
+ const result = getLambdaEditFromLocation('/tmp/nonexistent')
+ assert.strictEqual(result, undefined)
+ })
+ })
+ })
})
diff --git a/packages/core/src/test/shared/clients/sagemakerClient.test.ts b/packages/core/src/test/shared/clients/sagemakerClient.test.ts
new file mode 100644
index 00000000000..94a07dd32eb
--- /dev/null
+++ b/packages/core/src/test/shared/clients/sagemakerClient.test.ts
@@ -0,0 +1,210 @@
+/*!
+ * 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 { SagemakerClient } from '../../../shared/clients/sagemaker'
+import { AppDetails, SpaceDetails, DescribeDomainCommandOutput } from '@aws-sdk/client-sagemaker'
+import { DescribeDomainResponse } from '@amzn/sagemaker-client'
+import { intoCollection } from '../../../shared/utilities/collectionUtils'
+
+describe('SagemakerClient.fetchSpaceAppsAndDomains', function () {
+ const region = 'test-region'
+ let client: SagemakerClient
+ let listAppsStub: sinon.SinonStub
+
+ const appDetails: AppDetails[] = [
+ { AppName: 'app1', DomainId: 'domain1', SpaceName: 'space1', AppType: 'CodeEditor' },
+ { AppName: 'app2', DomainId: 'domain2', SpaceName: 'space2', AppType: 'CodeEditor' },
+ { AppName: 'app3', DomainId: 'domain2', SpaceName: 'space3', AppType: 'JupyterLab' },
+ ]
+
+ const spaceDetails: SpaceDetails[] = [
+ { SpaceName: 'space1', DomainId: 'domain1' },
+ { SpaceName: 'space2', DomainId: 'domain2' },
+ { SpaceName: 'space3', DomainId: 'domain2' },
+ { SpaceName: 'space4', DomainId: 'domain3' },
+ ]
+
+ const domain1: DescribeDomainResponse = { DomainId: 'domain1', DomainName: 'domainName1' }
+ const domain2: DescribeDomainResponse = { DomainId: 'domain2', DomainName: 'domainName2' }
+ const domain3: DescribeDomainResponse = {
+ DomainId: 'domain3',
+ DomainName: 'domainName3',
+ DomainSettings: { UnifiedStudioSettings: { DomainId: 'unifiedStudioDomain1' } },
+ }
+
+ beforeEach(function () {
+ client = new SagemakerClient(region)
+
+ listAppsStub = sinon.stub(client, 'listApps').returns(intoCollection([appDetails]))
+ sinon.stub(client, 'listSpaces').returns(intoCollection([spaceDetails]))
+ sinon.stub(client, 'describeDomain').callsFake(async ({ DomainId }) => {
+ switch (DomainId) {
+ case 'domain1':
+ return domain1 as DescribeDomainCommandOutput
+ case 'domain2':
+ return domain2 as DescribeDomainCommandOutput
+ case 'domain3':
+ return domain3 as DescribeDomainCommandOutput
+ default:
+ return {} as DescribeDomainCommandOutput
+ }
+ })
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ it('returns a map of space details with corresponding app details', async function () {
+ const [spaceApps, domains] = await client.fetchSpaceAppsAndDomains()
+
+ assert.strictEqual(spaceApps.size, 3)
+ assert.strictEqual(domains.size, 3)
+
+ const spaceAppKey1 = 'domain1__space1'
+ const spaceAppKey2 = 'domain2__space2'
+ const spaceAppKey3 = 'domain2__space3'
+
+ assert.ok(spaceApps.has(spaceAppKey1), 'Expected spaceApps to have key for domain1__space1')
+ assert.ok(spaceApps.has(spaceAppKey2), 'Expected spaceApps to have key for domain2__space2')
+ assert.ok(spaceApps.has(spaceAppKey3), 'Expected spaceApps to have key for domain2__space3')
+
+ assert.deepStrictEqual(spaceApps.get(spaceAppKey1)?.App?.AppName, 'app1')
+ assert.deepStrictEqual(spaceApps.get(spaceAppKey2)?.App?.AppName, 'app2')
+ assert.deepStrictEqual(spaceApps.get(spaceAppKey3)?.App?.AppName, 'app3')
+
+ const domainKey1 = 'domain1'
+ const domainKey2 = 'domain2'
+
+ assert.ok(domains.has(domainKey1), 'Expected domains to have key for domain1')
+ assert.ok(domains.has(domainKey2), 'Expected domains to have key for domain2')
+
+ assert.deepStrictEqual(domains.get(domainKey1)?.DomainName, 'domainName1')
+ assert.deepStrictEqual(domains.get(domainKey2)?.DomainName, 'domainName2')
+ })
+
+ it('returns map even if some spaces have no matching apps', async function () {
+ listAppsStub.returns(intoCollection([{ AppName: 'app1', DomainId: 'domain1', SpaceName: 'space1' }]))
+
+ const [spaceApps] = await client.fetchSpaceAppsAndDomains()
+ for (const space of spaceApps) {
+ console.log(space[0])
+ console.log(space[1])
+ }
+
+ const spaceAppKey2 = 'domain2__space2'
+ const spaceAppKey3 = 'domain2__space3'
+
+ assert.strictEqual(spaceApps.size, 3)
+ assert.strictEqual(spaceApps.get(spaceAppKey2)?.App, undefined)
+ assert.strictEqual(spaceApps.get(spaceAppKey3)?.App, undefined)
+ })
+
+ describe('SagemakerClient.startSpace', function () {
+ const region = 'test-region'
+ let client: SagemakerClient
+ let describeSpaceStub: sinon.SinonStub
+ let updateSpaceStub: sinon.SinonStub
+ let waitForSpaceStub: sinon.SinonStub
+ let createAppStub: sinon.SinonStub
+
+ beforeEach(function () {
+ client = new SagemakerClient(region)
+ describeSpaceStub = sinon.stub(client, 'describeSpace')
+ updateSpaceStub = sinon.stub(client, 'updateSpace')
+ waitForSpaceStub = sinon.stub(client as any, 'waitForSpaceInService')
+ createAppStub = sinon.stub(client, 'createApp')
+ })
+
+ afterEach(function () {
+ sinon.restore()
+ })
+
+ it('enables remote access and starts the app', async function () {
+ describeSpaceStub.resolves({
+ SpaceSettings: {
+ RemoteAccess: 'DISABLED',
+ AppType: 'CodeEditor',
+ CodeEditorAppSettings: {
+ DefaultResourceSpec: {
+ InstanceType: 'ml.t3.medium',
+ SageMakerImageArn: 'arn:aws:sagemaker:us-west-2:img',
+ SageMakerImageVersionAlias: '1.0.0',
+ },
+ },
+ },
+ })
+
+ updateSpaceStub.resolves({})
+ waitForSpaceStub.resolves()
+ createAppStub.resolves({})
+
+ await client.startSpace('my-space', 'my-domain')
+
+ sinon.assert.calledOnce(updateSpaceStub)
+ sinon.assert.calledOnce(waitForSpaceStub)
+ sinon.assert.calledOnce(createAppStub)
+ })
+
+ it('skips enabling remote access if already enabled', async function () {
+ describeSpaceStub.resolves({
+ SpaceSettings: {
+ RemoteAccess: 'ENABLED',
+ AppType: 'CodeEditor',
+ CodeEditorAppSettings: {},
+ },
+ })
+
+ createAppStub.resolves({})
+
+ await client.startSpace('my-space', 'my-domain')
+
+ sinon.assert.notCalled(updateSpaceStub)
+ sinon.assert.notCalled(waitForSpaceStub)
+ sinon.assert.calledOnce(createAppStub)
+ })
+
+ it('throws error on unsupported app type', async function () {
+ describeSpaceStub.resolves({
+ SpaceSettings: {
+ RemoteAccess: 'ENABLED',
+ AppType: 'Studio',
+ },
+ })
+
+ await assert.rejects(client.startSpace('my-space', 'my-domain'), /Unsupported AppType "Studio"/)
+ })
+
+ it('uses fallback resource spec when none provided', async function () {
+ describeSpaceStub.resolves({
+ SpaceSettings: {
+ RemoteAccess: 'ENABLED',
+ AppType: 'JupyterLab',
+ JupyterLabAppSettings: {},
+ },
+ })
+
+ createAppStub.resolves({})
+
+ await client.startSpace('my-space', 'my-domain')
+
+ sinon.assert.calledOnceWithExactly(
+ createAppStub,
+ sinon.match.hasNested('ResourceSpec.InstanceType', 'ml.t3.medium')
+ )
+ })
+
+ it('handles AccessDeniedException gracefully', async function () {
+ describeSpaceStub.rejects({ name: 'AccessDeniedException', message: 'no access' })
+
+ await assert.rejects(
+ client.startSpace('my-space', 'my-domain'),
+ /You do not have permission to start spaces/
+ )
+ })
+ })
+})
diff --git a/packages/core/src/testLint/gitSecrets.test.ts b/packages/core/src/testLint/gitSecrets.test.ts
index fce29585d1c..49091665ea1 100644
--- a/packages/core/src/testLint/gitSecrets.test.ts
+++ b/packages/core/src/testLint/gitSecrets.test.ts
@@ -23,7 +23,12 @@ describe('git-secrets', function () {
/** git-secrets patterns that will not cause a failure during the scan */
function setAllowListPatterns(gitSecrets: string) {
- const allowListPatterns: string[] = ['"accountId": "123456789012"']
+ const allowListPatterns: string[] = [
+ '"accountId": "123456789012"',
+ "'accountId': '123456789012'",
+ "Account: '123456789012'",
+ "accountId: '123456789012'",
+ ]
for (const pattern of allowListPatterns) {
// Returns non-zero exit code if pattern already exists
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index 3baba69a575..702a86ee8e6 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -7,5 +7,6 @@
"declaration": true,
"declarationMap": true
},
- "exclude": ["node_modules", ".vscode-test", "src/testFixtures", "dist"]
+ "exclude": ["node_modules", ".vscode-test", "src/testFixtures", "dist"],
+ "noEmitOnError": false // allow emitting even with type errors
}
diff --git a/packages/core/webpack.config.js b/packages/core/webpack.config.js
index fba19d133b2..b58c990704a 100644
--- a/packages/core/webpack.config.js
+++ b/packages/core/webpack.config.js
@@ -23,6 +23,7 @@ module.exports = (env, argv) => {
...baseConfig,
entry: {
'src/stepFunctions/asl/aslServer': './src/stepFunctions/asl/aslServer.ts',
+ 'src/awsService/sagemaker/detached-server/server': './src/awsService/sagemaker/detached-server/server.ts',
},
}
diff --git a/packages/toolkit/.changes/next-release/Feature-b2f13a50-0474-464c-b503-611edce7c356.json b/packages/toolkit/.changes/next-release/Feature-b2f13a50-0474-464c-b503-611edce7c356.json
new file mode 100644
index 00000000000..d9d220cf51f
--- /dev/null
+++ b/packages/toolkit/.changes/next-release/Feature-b2f13a50-0474-464c-b503-611edce7c356.json
@@ -0,0 +1,4 @@
+{
+ "type": "Feature",
+ "description": "Lambda to SAM Transformation: AWS Toolkit Explorer now can convert existing Lambda functions into SAM (Serverless Application Model) projects. This conversion creates a project structure that's ready for local development and can be managed using Application Builder"
+}
diff --git a/packages/toolkit/.changes/next-release/Feature-fc398d68-b7e1-4f37-8746-867381f402c6.json b/packages/toolkit/.changes/next-release/Feature-fc398d68-b7e1-4f37-8746-867381f402c6.json
new file mode 100644
index 00000000000..0e73c5e6c84
--- /dev/null
+++ b/packages/toolkit/.changes/next-release/Feature-fc398d68-b7e1-4f37-8746-867381f402c6.json
@@ -0,0 +1,4 @@
+{
+ "type": "Feature",
+ "description": "Lambda Quick Edit: AWS Toolkit Explorer now offers a streamlined editing experience for Lambda functions. Download a function's code with double-click, make local modifications, and easily synchronize changes back to the cloud."
+}
diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json
index aab186d7535..6e2fe61db3a 100644
--- a/packages/toolkit/package.json
+++ b/packages/toolkit/package.json
@@ -299,6 +299,11 @@
"default": "",
"description": "A JSON formatted file that specifies template parameter values, a stack policy, and tags. Only parameters are used from this file.",
"scope": "machine-overridable"
+ },
+ "aws.sagemaker.studio.spaces.enableIdentityFiltering": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable automatic filtration of spaces based on your AWS identity."
}
}
},
@@ -826,6 +831,10 @@
"command": "aws.downloadStateMachineDefinition",
"when": "false"
},
+ {
+ "command": "aws.toolkit.lambda.convertToSam",
+ "when": "false"
+ },
{
"command": "aws.ecr.createRepository",
"when": "false"
@@ -1244,6 +1253,10 @@
{
"command": "aws.newThreatComposerFile",
"when": "false"
+ },
+ {
+ "command": "aws.sagemaker.filterSpaceApps",
+ "when": "false"
}
],
"editor/title": [
@@ -1450,6 +1463,16 @@
}
],
"view/item/context": [
+ {
+ "command": "aws.sagemaker.stopSpace",
+ "group": "inline@0",
+ "when": "viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceRunningRemoteDisabledNode)$/"
+ },
+ {
+ "command": "aws.sagemaker.openRemoteConnection",
+ "group": "inline@1",
+ "when": "viewItem =~ /^(awsSagemakerSpaceRunningRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteEnabledNode|awsSagemakerSpaceStoppedRemoteDisabledNode)$/"
+ },
{
"command": "_aws.toolkit.notifications.dismiss",
"when": "viewItem == toolkitNotificationStartUp",
@@ -1605,6 +1628,11 @@
"when": "view == aws.explorer && viewItem == awsRegionNode",
"group": "0@1"
},
+ {
+ "command": "aws.sagemaker.filterSpaceApps",
+ "when": "view == aws.explorer && viewItem == awsSagemakerParentNode",
+ "group": "inline@1"
+ },
{
"command": "aws.toolkit.lambda.createServerlessLandProject",
"when": "view == aws.explorer && viewItem == awsLambdaNode || viewItem == awsRegionNode",
@@ -1665,6 +1693,16 @@
"when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable)$/ || viewItem == awsAppBuilderDeployedNode",
"group": "0@2"
},
+ {
+ "command": "aws.lambda.openWorkspace",
+ "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable",
+ "group": "0@6"
+ },
+ {
+ "command": "aws.toolkit.lambda.convertToSam",
+ "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable",
+ "group": "0@3"
+ },
{
"command": "aws.uploadLambda",
"when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable)$/ || viewItem == awsAppBuilderDeployedNode",
@@ -1775,6 +1813,11 @@
"when": "view == aws.explorer && viewItem =~ /^awsIotCertificateNode.(Things|Policies)/",
"group": "0@1"
},
+ {
+ "command": "aws.sagemaker.filterSpaceApps",
+ "when": "view == aws.explorer && viewItem == awsSagemakerParentNode",
+ "group": "0@1"
+ },
{
"command": "aws.s3.createBucket",
"when": "view == aws.explorer && viewItem == awsS3Node",
@@ -2190,6 +2233,16 @@
"when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode)$/",
"group": "inline@2"
},
+ {
+ "command": "aws.quickDeployLambda",
+ "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable",
+ "group": "inline@3"
+ },
+ {
+ "command": "aws.toolkit.lambda.convertToSam",
+ "when": "view == aws.explorer && viewItem == awsRegionFunctionNodeDownloadable",
+ "group": "inline@4"
+ },
{
"command": "aws.docdb.createCluster",
"when": "view == aws.explorer && viewItem == awsDocDBNode",
@@ -2276,7 +2329,7 @@
},
{
"command": "aws.appBuilder.tailLogs",
- "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode)$/",
+ "when": "view =~ /^(aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode)$/",
"group": "inline@3"
}
],
@@ -2565,6 +2618,42 @@
}
}
},
+ {
+ "command": "aws.sagemaker.filterSpaceApps",
+ "title": "%AWS.command.sagemaker.filterSpaces%",
+ "category": "%AWS.title%",
+ "enablement": "isCloud9 || !aws.isWebExtHost",
+ "icon": "$(extensions-filter)",
+ "cloud9": {
+ "cn": {
+ "category": "%AWS.title.cn%"
+ }
+ }
+ },
+ {
+ "command": "aws.sagemaker.openRemoteConnection",
+ "title": "Connect to SageMaker Space",
+ "icon": "$(remote-explorer)",
+ "category": "%AWS.title%",
+ "enablement": "isCloud9 || !aws.isWebExtHost",
+ "cloud9": {
+ "cn": {
+ "category": "%AWS.title.cn%"
+ }
+ }
+ },
+ {
+ "command": "aws.sagemaker.stopSpace",
+ "title": "Stop SageMaker Space",
+ "icon": "$(debug-stop)",
+ "category": "%AWS.title%",
+ "enablement": "isCloud9 || !aws.isWebExtHost",
+ "cloud9": {
+ "cn": {
+ "category": "%AWS.title.cn%"
+ }
+ }
+ },
{
"command": "aws.ec2.startInstance",
"title": "%AWS.command.ec2.startInstance%",
@@ -3017,6 +3106,21 @@
}
}
},
+ {
+ "command": "aws.toolkit.lambda.convertToSam",
+ "title": "%AWS.command.lambda.convertToSam%",
+ "category": "%AWS.title%",
+ "enablement": "viewItem == awsRegionFunctionNodeDownloadable",
+ "cloud9": {
+ "cn": {
+ "category": "%AWS.title.cn%"
+ }
+ },
+ "icon": {
+ "light": "resources/icons/aws/lambda/create-stack.svg",
+ "dark": "resources/icons/aws/lambda/create-stack-light.svg"
+ }
+ },
{
"command": "aws.downloadLambda",
"title": "%AWS.command.downloadLambda%",
@@ -3028,6 +3132,17 @@
}
}
},
+ {
+ "command": "aws.lambda.openWorkspace",
+ "title": "%AWS.command.openLambdaWorkspace%",
+ "category": "%AWS.title%",
+ "enablement": "viewItem == awsRegionFunctionNodeDownloadable",
+ "cloud9": {
+ "cn": {
+ "category": "%AWS.title.cn%"
+ }
+ }
+ },
{
"command": "aws.uploadLambda",
"title": "%AWS.command.uploadLambda%",
@@ -3037,7 +3152,27 @@
"cn": {
"category": "%AWS.title.cn%"
}
- }
+ },
+ "icon": "$(cloud-upload)"
+ },
+ {
+ "command": "aws.openLambdaFile",
+ "title": "%AWS.command.openLambdaFile%",
+ "category": "%AWS.title%",
+ "enablement": "isCloud9 || !aws.isWebExtHost",
+ "icon": "$(preview)"
+ },
+ {
+ "command": "aws.quickDeployLambda",
+ "title": "%AWS.command.quickDeployLambda%",
+ "category": "%AWS.title%",
+ "enablement": "viewItem == awsRegionFunctionNodeDownloadable",
+ "cloud9": {
+ "cn": {
+ "category": "%AWS.title.cn%"
+ }
+ },
+ "icon": "$(cloud-upload)"
},
{
"command": "aws.deleteLambda",
@@ -4565,110 +4700,124 @@
"fontCharacter": "\\f1d0"
}
},
- "aws-lambda-function": {
+ "aws-lambda-create-stack": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d1"
}
},
- "aws-mynah-MynahIconBlack": {
+ "aws-lambda-create-stack-light": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d2"
}
},
- "aws-mynah-MynahIconWhite": {
+ "aws-lambda-function": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d3"
}
},
- "aws-mynah-logo": {
+ "aws-mynah-MynahIconBlack": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d4"
}
},
- "aws-redshift-cluster": {
+ "aws-mynah-MynahIconWhite": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d5"
}
},
- "aws-redshift-cluster-connected": {
+ "aws-mynah-logo": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d6"
}
},
- "aws-redshift-database": {
+ "aws-redshift-cluster": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d7"
}
},
- "aws-redshift-redshift-cluster-connected": {
+ "aws-redshift-cluster-connected": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d8"
}
},
- "aws-redshift-schema": {
+ "aws-redshift-database": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1d9"
}
},
- "aws-redshift-table": {
+ "aws-redshift-redshift-cluster-connected": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1da"
}
},
- "aws-s3-bucket": {
+ "aws-redshift-schema": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1db"
}
},
- "aws-s3-create-bucket": {
+ "aws-redshift-table": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1dc"
}
},
- "aws-schemas-registry": {
+ "aws-sagemaker-code-editor": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1dd"
}
},
- "aws-schemas-schema": {
+ "aws-sagemaker-jupyter-lab": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1de"
}
},
- "aws-stepfunctions-preview": {
+ "aws-schemas-registry": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1df"
}
+ },
+ "aws-schemas-schema": {
+ "description": "AWS Contributed Icon",
+ "default": {
+ "fontPath": "./resources/fonts/aws-toolkit-icons.woff",
+ "fontCharacter": "\\f1e0"
+ }
+ },
+ "aws-stepfunctions-preview": {
+ "description": "AWS Contributed Icon",
+ "default": {
+ "fontPath": "./resources/fonts/aws-toolkit-icons.woff",
+ "fontCharacter": "\\f1e1"
+ }
}
},
"notebooks": [
diff --git a/packages/toolkit/scripts/build/copyFiles.ts b/packages/toolkit/scripts/build/copyFiles.ts
index e081a2eb9b4..782c16ddb50 100644
--- a/packages/toolkit/scripts/build/copyFiles.ts
+++ b/packages/toolkit/scripts/build/copyFiles.ts
@@ -29,7 +29,6 @@ const tasks: CopyTask[] = [
...['LICENSE', 'NOTICE'].map((f) => {
return { target: path.join('../../', f), destination: path.join(projectRoot, f) }
}),
-
{ target: path.join('../core', 'resources'), destination: path.join('..', 'resources') },
{
target: path.join('../core/', 'package.nls.json'),
@@ -69,6 +68,21 @@ const tasks: CopyTask[] = [
destination: path.join('src', 'stepFunctions', 'asl', 'aslServer.js'),
},
+ // Sagemaker local server
+ {
+ target: path.join(
+ '../../node_modules',
+ 'aws-core-vscode',
+ 'dist',
+ 'src',
+ 'awsService',
+ 'sagemaker',
+ 'detached-server',
+ 'server.js'
+ ),
+ destination: path.join('src', 'awsService', 'sagemaker', 'detached-server', 'server.js'),
+ },
+
// Serverless Land
{
target: path.join(
diff --git a/packages/toolkit/tsconfig.json b/packages/toolkit/tsconfig.json
index 2ec1c0534c1..0aef63efe5a 100644
--- a/packages/toolkit/tsconfig.json
+++ b/packages/toolkit/tsconfig.json
@@ -5,5 +5,6 @@
"baseUrl": ".",
"rootDir": "."
},
- "exclude": ["node_modules", ".vscode-test", "src/testFixtures", "dist"]
+ "exclude": ["node_modules", ".vscode-test", "src/testFixtures", "dist"],
+ "noEmitOnError": false // allow emitting even with type errors
}
diff --git a/src.gen/@amzn/sagemaker-client/1.0.0.tgz b/src.gen/@amzn/sagemaker-client/1.0.0.tgz
new file mode 100644
index 00000000000..4821da0e727
Binary files /dev/null and b/src.gen/@amzn/sagemaker-client/1.0.0.tgz differ
diff --git a/tsconfig.json b/tsconfig.json
index b5676bad46b..c1c1b0ee221 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,7 +15,7 @@
"jsx": "preserve",
"esModuleInterop": true,
"incremental": true,
- "noEmitOnError": true,
+ "noEmitOnError": false,
"skipLibCheck": true
}
}