{
+ 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
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/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/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json
index b28aeec4847..8b04d7b3b0b 100644
--- a/packages/core/src/shared/telemetry/vscodeTelemetry.json
+++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json
@@ -1110,6 +1110,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/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/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/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..1f618511d11 100644
--- a/packages/toolkit/package.json
+++ b/packages/toolkit/package.json
@@ -826,6 +826,10 @@
"command": "aws.downloadStateMachineDefinition",
"when": "false"
},
+ {
+ "command": "aws.toolkit.lambda.convertToSam",
+ "when": "false"
+ },
{
"command": "aws.ecr.createRepository",
"when": "false"
@@ -1665,6 +1669,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",
@@ -2190,6 +2204,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 +2300,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"
}
],
@@ -3017,6 +3041,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 +3067,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 +3087,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 +4635,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-s3-bucket": {
"description": "AWS Contributed Icon",
"default": {
"fontPath": "./resources/fonts/aws-toolkit-icons.woff",
"fontCharacter": "\\f1dd"
}
},
- "aws-schemas-schema": {
+ "aws-s3-create-bucket": {
"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": [