From 97fae4e8f590e3a8153366b79afac7fb33b0b6ff Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Wed, 27 May 2026 04:33:23 +0000 Subject: [PATCH 1/2] perf(cdk): share CDK templates in task-api tests for 61% faster suite Refactors task-api.test.ts to synthesize CDK templates once per describe block via beforeAll instead of per-test. Reduces CDK synth calls from 41 to 11 while preserving all 40 test assertions unchanged. Before: 371s total Jest / task-api.test 370s wall clock After: 145s total Jest / task-api.test 89s wall clock Closes #194 Co-Authored-By: Claude Opus 4.6 (1M context) --- cdk/test/constructs/task-api.test.ts | 236 ++++++++++----------------- 1 file changed, 83 insertions(+), 153 deletions(-) diff --git a/cdk/test/constructs/task-api.test.ts b/cdk/test/constructs/task-api.test.ts index da23a60f..32192ff7 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -75,10 +75,17 @@ function createStackWithWebhooks(overrides?: Partial): { stack: St } describe('TaskApi construct', () => { + let baseTemplate: Template; + let webhookTemplate: Template; + + beforeAll(() => { + baseTemplate = createStack().template; + webhookTemplate = createStackWithWebhooks().template; + }); + test('creates a Cognito User Pool', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::Cognito::UserPool', 1); - template.hasResourceProperties('AWS::Cognito::UserPool', { + baseTemplate.resourceCountIs('AWS::Cognito::UserPool', 1); + baseTemplate.hasResourceProperties('AWS::Cognito::UserPool', { Policies: { PasswordPolicy: { MinimumLength: 12, @@ -92,9 +99,8 @@ describe('TaskApi construct', () => { }); test('creates a Cognito User Pool Client with correct auth flows', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::Cognito::UserPoolClient', 1); - template.hasResourceProperties('AWS::Cognito::UserPoolClient', { + baseTemplate.resourceCountIs('AWS::Cognito::UserPoolClient', 1); + baseTemplate.hasResourceProperties('AWS::Cognito::UserPoolClient', { ExplicitAuthFlows: Match.arrayWith([ 'ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', @@ -104,29 +110,25 @@ describe('TaskApi construct', () => { }); test('creates a REST API with correct stage name', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::ApiGateway::RestApi', 1); - template.hasResourceProperties('AWS::ApiGateway::RestApi', { + baseTemplate.resourceCountIs('AWS::ApiGateway::RestApi', 1); + baseTemplate.hasResourceProperties('AWS::ApiGateway::RestApi', { Name: 'TaskApi', }); - template.hasResourceProperties('AWS::ApiGateway::Stage', { + baseTemplate.hasResourceProperties('AWS::ApiGateway::Stage', { StageName: 'v1', }); }); test('creates 5 Lambda functions without webhookTable', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::Lambda::Function', 5); + baseTemplate.resourceCountIs('AWS::Lambda::Function', 5); }); test('creates 10 Lambda functions with webhookTable', () => { - const { template } = createStackWithWebhooks(); - template.resourceCountIs('AWS::Lambda::Function', 10); + webhookTemplate.resourceCountIs('AWS::Lambda::Function', 10); }); test('Lambda functions use ARM_64 architecture and Node.js 24', () => { - const { template } = createStack(); - const functions = template.findResources('AWS::Lambda::Function'); + const functions = baseTemplate.findResources('AWS::Lambda::Function'); const fnIds = Object.keys(functions); expect(fnIds.length).toBe(5); @@ -137,8 +139,7 @@ describe('TaskApi construct', () => { }); test('Lambda functions have correct environment variables', () => { - const { template } = createStack(); - const functions = template.findResources('AWS::Lambda::Function'); + const functions = baseTemplate.findResources('AWS::Lambda::Function'); for (const fnId of Object.keys(functions)) { const envVars = functions[fnId].Properties.Environment?.Variables ?? {}; @@ -149,43 +150,34 @@ describe('TaskApi construct', () => { }); test('creates API resources for /tasks and /tasks/{task_id}', () => { - const { template } = createStack(); - - // Check for tasks resource - template.hasResourceProperties('AWS::ApiGateway::Resource', { + baseTemplate.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'tasks', }); - // Check for {task_id} resource - template.hasResourceProperties('AWS::ApiGateway::Resource', { + baseTemplate.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: '{task_id}', }); - // Check for events resource - template.hasResourceProperties('AWS::ApiGateway::Resource', { + baseTemplate.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'events', }); }); test('creates 5 API methods with Cognito authorization (no webhooks)', () => { - const { template } = createStack(); - - const methods = template.findResources('AWS::ApiGateway::Method'); + const methods = baseTemplate.findResources('AWS::ApiGateway::Method'); const nonOptionsMethods = Object.entries(methods).filter( ([_, resource]) => (resource as any).Properties.HttpMethod !== 'OPTIONS', ); expect(nonOptionsMethods.length).toBe(5); - // Verify all non-OPTIONS methods use COGNITO authorization for (const [_, resource] of nonOptionsMethods) { expect((resource as any).Properties.AuthorizationType).toBe('COGNITO_USER_POOLS'); } }); test('creates a WAFv2 Web ACL with managed rule groups', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::WAFv2::WebACL', 1); - template.hasResourceProperties('AWS::WAFv2::WebACL', { + baseTemplate.resourceCountIs('AWS::WAFv2::WebACL', 1); + baseTemplate.hasResourceProperties('AWS::WAFv2::WebACL', { Scope: 'REGIONAL', Rules: Match.arrayWith([ Match.objectLike({ Name: 'AWSManagedRulesCommonRuleSet' }), @@ -196,14 +188,12 @@ describe('TaskApi construct', () => { }); test('associates WAF with the API Gateway stage', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 1); + baseTemplate.resourceCountIs('AWS::WAFv2::WebACLAssociation', 1); }); test('creates a Cognito User Pools authorizer', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::ApiGateway::Authorizer', 1); - template.hasResourceProperties('AWS::ApiGateway::Authorizer', { + baseTemplate.resourceCountIs('AWS::ApiGateway::Authorizer', 1); + baseTemplate.hasResourceProperties('AWS::ApiGateway::Authorizer', { Type: 'COGNITO_USER_POOLS', }); }); @@ -239,8 +229,7 @@ describe('TaskApi construct', () => { }); test('stage has throttle settings', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::ApiGateway::Stage', { + baseTemplate.hasResourceProperties('AWS::ApiGateway::Stage', { MethodSettings: Match.arrayWith([ Match.objectLike({ ThrottlingRateLimit: 60, @@ -279,22 +268,10 @@ describe('TaskApi construct', () => { }); test('createTask Lambda has guardrail env vars when provided', () => { - const app = new App(); - const stack = new Stack(app, 'GuardrailStack'); - const taskTable = new dynamodb.Table(stack, 'TaskTable', { - partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, - }); - const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { - partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, - sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, - }); - new TaskApi(stack, 'TaskApi', { - taskTable, - taskEventsTable, + const { template } = createStack({ guardrailId: 'gr-abc123', guardrailVersion: '1', }); - const template = Template.fromStack(stack); template.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ @@ -310,7 +287,6 @@ describe('TaskApi construct', () => { ecsClusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', }); - // Cancel Lambda should have the ECS_CLUSTER_ARN env var template.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ @@ -319,7 +295,6 @@ describe('TaskApi construct', () => { }, }); - // Should have ecs:StopTask permission template.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ @@ -333,10 +308,7 @@ describe('TaskApi construct', () => { }); test('cancelTask Lambda does not get ECS env vars when ecsClusterArn is not set', () => { - const { template } = createStack(); - - // Find all Lambda functions and verify none have ECS_CLUSTER_ARN - const functions = template.findResources('AWS::Lambda::Function'); + const functions = baseTemplate.findResources('AWS::Lambda::Function'); for (const [, fn] of Object.entries(functions)) { const vars = (fn as any).Properties?.Environment?.Variables ?? {}; expect(vars).not.toHaveProperty('ECS_CLUSTER_ARN'); @@ -345,9 +317,13 @@ describe('TaskApi construct', () => { }); describe('TaskApi construct with webhooks', () => { - test('creates webhook API resources', () => { - const { template } = createStackWithWebhooks(); + let template: Template; + beforeAll(() => { + template = createStackWithWebhooks().template; + }); + + test('creates webhook API resources', () => { template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'webhooks', }); @@ -358,7 +334,6 @@ describe('TaskApi construct with webhooks', () => { }); test('creates both Cognito and REQUEST authorizers', () => { - const { template } = createStackWithWebhooks(); template.resourceCountIs('AWS::ApiGateway::Authorizer', 2); template.hasResourceProperties('AWS::ApiGateway::Authorizer', { Type: 'COGNITO_USER_POOLS', @@ -369,7 +344,6 @@ describe('TaskApi construct with webhooks', () => { }); test('webhook Lambdas have WEBHOOK_TABLE_NAME and WEBHOOK_RETENTION_DAYS env vars', () => { - const { template } = createStackWithWebhooks(); template.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ @@ -381,8 +355,6 @@ describe('TaskApi construct with webhooks', () => { }); test('creates 9 non-OPTIONS API methods with webhooks', () => { - const { template } = createStackWithWebhooks(); - const methods = template.findResources('AWS::ApiGateway::Method'); const nonOptionsMethods = Object.entries(methods).filter( ([_, resource]) => (resource as any).Properties.HttpMethod !== 'OPTIONS', @@ -392,8 +364,6 @@ describe('TaskApi construct with webhooks', () => { }); test('webhook task creation uses CUSTOM authorization', () => { - const { template } = createStackWithWebhooks(); - const methods = template.findResources('AWS::ApiGateway::Method'); const customAuthMethods = Object.entries(methods).filter( ([_, resource]) => (resource as any).Properties.AuthorizationType === 'CUSTOM', @@ -402,7 +372,6 @@ describe('TaskApi construct with webhooks', () => { }); test('webhookCreateTask Lambda has Secrets Manager GetSecretValue permission', () => { - const { template } = createStackWithWebhooks(); template.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ @@ -416,7 +385,6 @@ describe('TaskApi construct with webhooks', () => { }); test('createWebhook Lambda has Secrets Manager CreateSecret and TagResource permissions', () => { - const { template } = createStackWithWebhooks(); template.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ @@ -435,7 +403,9 @@ describe('TaskApi construct with webhooks', () => { }); describe('TaskApi construct — nudge endpoint (Phase 2)', () => { - function createStackWithNudges(overrides?: Partial): Template { + let nudgeTemplate: Template; + + beforeAll(() => { const app = new App(); const stack = new Stack(app, 'NudgeStack'); const taskTable = new dynamodb.Table(stack, 'TaskTable', { @@ -455,23 +425,12 @@ describe('TaskApi construct — nudge endpoint (Phase 2)', () => { taskNudgesTable, guardrailId: 'gr-abc', guardrailVersion: '1', - ...overrides, }); - return Template.fromStack(stack); - } + nudgeTemplate = Template.fromStack(stack); + }); test('does NOT create a nudge resource when taskNudgesTable is absent', () => { - const app = new App(); - const stack = new Stack(app, 'NoNudgeStack'); - const taskTable = new dynamodb.Table(stack, 'TaskTable', { - partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, - }); - const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { - partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, - sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, - }); - new TaskApi(stack, 'TaskApi', { taskTable, taskEventsTable }); - const template = Template.fromStack(stack); + const { template } = createStack(); const resources = template.findResources('AWS::ApiGateway::Resource'); const nudgeRes = Object.values(resources).filter( @@ -481,20 +440,17 @@ describe('TaskApi construct — nudge endpoint (Phase 2)', () => { }); test('creates a /nudge resource when taskNudgesTable is provided', () => { - const template = createStackWithNudges(); - template.hasResourceProperties('AWS::ApiGateway::Resource', { + nudgeTemplate.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'nudge', }); }); test('nudge route uses Cognito authorization on POST', () => { - const template = createStackWithNudges(); - const methods = template.findResources('AWS::ApiGateway::Method'); + const methods = nudgeTemplate.findResources('AWS::ApiGateway::Method'); const nudgePost = Object.values(methods).filter(m => { const p = (m as { Properties?: { HttpMethod?: string } }).Properties ?? {}; return p.HttpMethod === 'POST'; }); - // At least one POST is for nudge — assert at least one POST uses COGNITO. const cognitoPosts = nudgePost.filter(m => (m as { Properties?: { AuthorizationType?: string } }).Properties?.AuthorizationType === 'COGNITO_USER_POOLS', ); @@ -502,8 +458,7 @@ describe('TaskApi construct — nudge endpoint (Phase 2)', () => { }); test('nudge Lambda has NUDGES_TABLE_NAME and NUDGE_RATE_LIMIT_PER_MINUTE env vars', () => { - const template = createStackWithNudges(); - template.hasResourceProperties('AWS::Lambda::Function', { + nudgeTemplate.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ NUDGES_TABLE_NAME: Match.anyValue(), @@ -514,8 +469,7 @@ describe('TaskApi construct — nudge endpoint (Phase 2)', () => { }); test('nudge Lambda has guardrail env vars when provided', () => { - const template = createStackWithNudges(); - template.hasResourceProperties('AWS::Lambda::Function', { + nudgeTemplate.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ NUDGES_TABLE_NAME: Match.anyValue(), @@ -527,8 +481,7 @@ describe('TaskApi construct — nudge endpoint (Phase 2)', () => { }); test('nudge Lambda has bedrock:ApplyGuardrail permission when guardrail configured', () => { - const template = createStackWithNudges(); - template.hasResourceProperties('AWS::IAM::Policy', { + nudgeTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -541,7 +494,28 @@ describe('TaskApi construct — nudge endpoint (Phase 2)', () => { }); test('respects custom nudgeRateLimitPerMinute', () => { - const template = createStackWithNudges({ nudgeRateLimitPerMinute: 25 }); + const app = new App(); + const stack = new Stack(app, 'CustomNudgeStack'); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + const taskNudgesTable = new dynamodb.Table(stack, 'TaskNudgesTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'nudge_id', type: dynamodb.AttributeType.STRING }, + }); + new TaskApi(stack, 'TaskApi', { + taskTable, + taskEventsTable, + taskNudgesTable, + guardrailId: 'gr-abc', + guardrailVersion: '1', + nudgeRateLimitPerMinute: 25, + }); + const template = Template.fromStack(stack); template.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ @@ -553,7 +527,9 @@ describe('TaskApi construct — nudge endpoint (Phase 2)', () => { }); describe('TaskApi construct — trace endpoint (design §10.1)', () => { - function createStackWithTrace(overrides?: Partial): Template { + let traceTemplate: Template; + + beforeAll(() => { const app = new App(); const stack = new Stack(app, 'TraceStack'); const taskTable = new dynamodb.Table(stack, 'TaskTable', { @@ -568,23 +544,12 @@ describe('TaskApi construct — trace endpoint (design §10.1)', () => { taskTable, taskEventsTable, traceArtifactsBucket: traceBucket, - ...overrides, }); - return Template.fromStack(stack); - } + traceTemplate = Template.fromStack(stack); + }); test('does NOT create a trace resource when traceArtifactsBucket is absent', () => { - const app = new App(); - const stack = new Stack(app, 'NoTraceStack'); - const taskTable = new dynamodb.Table(stack, 'TaskTable', { - partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, - }); - const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { - partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, - sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, - }); - new TaskApi(stack, 'TaskApi', { taskTable, taskEventsTable }); - const template = Template.fromStack(stack); + const { template } = createStack(); const resources = template.findResources('AWS::ApiGateway::Resource'); const pathParts = Object.values(resources).map(r => r.Properties.PathPart); @@ -592,15 +557,11 @@ describe('TaskApi construct — trace endpoint (design §10.1)', () => { }); test('creates a GET /tasks/{task_id}/trace resource when traceArtifactsBucket is provided', () => { - const template = createStackWithTrace(); - - // There should be an API Gateway Resource with PathPart='trace' - const resources = template.findResources('AWS::ApiGateway::Resource'); + const resources = traceTemplate.findResources('AWS::ApiGateway::Resource'); const tracePath = Object.values(resources).find(r => r.Properties.PathPart === 'trace'); expect(tracePath).toBeDefined(); - // And a GET method on it - template.hasResourceProperties('AWS::ApiGateway::Method', { + traceTemplate.hasResourceProperties('AWS::ApiGateway::Method', { HttpMethod: 'GET', AuthorizationType: 'COGNITO_USER_POOLS', ResourceId: Match.anyValue(), @@ -608,9 +569,7 @@ describe('TaskApi construct — trace endpoint (design §10.1)', () => { }); test('creates the GetTraceUrlFn Lambda with TRACE_ARTIFACTS_BUCKET_NAME env var', () => { - const template = createStackWithTrace(); - - const functions = template.findResources('AWS::Lambda::Function'); + const functions = traceTemplate.findResources('AWS::Lambda::Function'); const traceFns = Object.entries(functions).filter(([id]) => id.startsWith('TaskApiGetTraceUrlFn'), ); @@ -619,21 +578,16 @@ describe('TaskApi construct — trace endpoint (design §10.1)', () => { const envVars = resource.Properties.Environment?.Variables; expect(envVars).toBeDefined(); expect(envVars.TRACE_ARTIFACTS_BUCKET_NAME).toBeDefined(); - // TASK_TABLE_NAME must be present too (for the ownership check) expect(envVars.TASK_TABLE_NAME).toBeDefined(); }); test('grants the handler read-only access to the trace bucket (GetObject, not PutObject)', () => { - const template = createStackWithTrace(); - - // Find the IAM policy attached to the GetTraceUrlFn role - const policies = template.findResources('AWS::IAM::Policy'); + const policies = traceTemplate.findResources('AWS::IAM::Policy'); const handlerPolicies = Object.entries(policies).filter(([id]) => id.includes('GetTraceUrlFn'), ); expect(handlerPolicies.length).toBeGreaterThan(0); - // Walk every policy attached to the handler and check S3 actions. const allS3Actions: string[] = []; for (const [, resource] of handlerPolicies) { const statements = resource.Properties.PolicyDocument?.Statement ?? []; @@ -647,31 +601,18 @@ describe('TaskApi construct — trace endpoint (design §10.1)', () => { } } - // Must be able to GetObject (to presign + HeadObject). L3 item 2 - // tightens this from CDK's ``grantRead`` (which expands to - // ``s3:GetObject*`` / ``s3:GetBucket*`` / ``s3:List*``) down to an - // explicit ``s3:GetObject`` — AWS grants HeadObject implicitly on - // the same permission, so the handler's HEAD-before-presign check - // is still authorized. expect(allS3Actions).toContain('s3:GetObject'); - // The wildcarded ``s3:GetObject*`` form must be absent — L3 pinned - // the handler to the exact action, not the wildcard. expect(allS3Actions).not.toContain('s3:GetObject*'); - // ``ListBucket`` is unnecessary scope (the handler never lists). A - // regression here would reintroduce the ``grantRead`` expansion. expect(allS3Actions).not.toContain('s3:ListBucket'); expect(allS3Actions.some(a => a.startsWith('s3:List'))).toBe(false); expect(allS3Actions.some(a => a.startsWith('s3:GetBucket'))).toBe(false); - // Must NOT have write permissions (including wildcarded forms). expect(allS3Actions.some(a => a.startsWith('s3:PutObject'))).toBe(false); expect(allS3Actions.some(a => a.startsWith('s3:DeleteObject'))).toBe(false); expect(allS3Actions).not.toContain('s3:*'); }); test('grants the handler read access to the task table for ownership checks', () => { - const template = createStackWithTrace(); - - const policies = template.findResources('AWS::IAM::Policy'); + const policies = traceTemplate.findResources('AWS::IAM::Policy'); const handlerPolicies = Object.entries(policies).filter(([id]) => id.includes('GetTraceUrlFn'), ); @@ -689,36 +630,25 @@ describe('TaskApi construct — trace endpoint (design §10.1)', () => { } } expect(allDdbActions).toContain('dynamodb:GetItem'); - // Must NOT have write permissions expect(allDdbActions).not.toContain('dynamodb:PutItem'); expect(allDdbActions).not.toContain('dynamodb:UpdateItem'); }); test('trace endpoint uses Cognito authorization (same as other task endpoints)', () => { - const template = createStackWithTrace(); - - // The trace resource's method must require Cognito auth. - const methods = template.findResources('AWS::ApiGateway::Method'); + const methods = traceTemplate.findResources('AWS::ApiGateway::Method'); const traceMethods = Object.values(methods).filter(m => m.Properties.HttpMethod === 'GET', ); - // Gather all Cognito-authorized GET methods; the trace one must be among them. const cognitoGetMethods = traceMethods.filter(m => m.Properties.AuthorizationType === 'COGNITO_USER_POOLS'); - // There should be at least 3 Cognito GET methods: get-task, list-tasks, get-events, get-trace. - // But we only test that `get-trace` auth is Cognito (which is implied by the creation test above). expect(cognitoGetMethods.length).toBeGreaterThanOrEqual(4); }); test('GetTraceUrlFn has adequate timeout and memory for SDK cold-start', () => { - const template = createStackWithTrace(); - // Find the GetTraceUrlFn by looking for the function whose env has TRACE_ARTIFACTS_BUCKET_NAME. - const functions = template.findResources('AWS::Lambda::Function'); + const functions = traceTemplate.findResources('AWS::Lambda::Function'); const traceFn = Object.values(functions).find( f => f.Properties.Environment?.Variables?.TRACE_ARTIFACTS_BUCKET_NAME !== undefined, ); expect(traceFn).toBeDefined(); - // 15s matches CancelTaskFn precedent for cold-start SDK loads; - // 512 MB is headroom above the observed 126 MB cold-start peak. expect(traceFn!.Properties.Timeout).toBe(15); expect(traceFn!.Properties.MemorySize).toBe(512); }); From 96f6dc158126b59d190af5406852180593a439fe Mon Sep 17 00:00:00 2001 From: bgagent <345885+scottschreckengaust@users.noreply.github.com> Date: Wed, 27 May 2026 04:42:00 +0000 Subject: [PATCH 2/2] perf(cdk): share CDK templates in orchestrator and ECS cluster tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the same beforeAll template-sharing pattern to: - task-orchestrator.test.ts: 33 → 12 synths (4 shared + 8 unique) - ecs-agent-cluster.test.ts: 10 → 2 synths (1 shared + 1 unique) Combined with task-api.test.ts, total CDK synths across the three heaviest suites drop from 84 to 25 (70% fewer). Co-Authored-By: Claude Opus 4.6 (1M context) --- cdk/test/constructs/ecs-agent-cluster.test.ts | 33 +++-- cdk/test/constructs/task-orchestrator.test.ts | 116 ++++++++---------- 2 files changed, 66 insertions(+), 83 deletions(-) diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts index 3e5854d5..2f1fb6ee 100644 --- a/cdk/test/constructs/ecs-agent-cluster.test.ts +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -66,9 +66,14 @@ function createStack(overrides?: { memoryId?: string }): { stack: Stack; templat } describe('EcsAgentCluster construct', () => { + let baseTemplate: Template; + + beforeAll(() => { + baseTemplate = createStack().template; + }); + test('creates an ECS Cluster with container insights', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::ECS::Cluster', { + baseTemplate.hasResourceProperties('AWS::ECS::Cluster', { ClusterSettings: Match.arrayWith([ Match.objectLike({ Name: 'containerInsights', @@ -79,8 +84,7 @@ describe('EcsAgentCluster construct', () => { }); test('creates a Fargate task definition with 2 vCPU and 4 GB', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::ECS::TaskDefinition', { + baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { Cpu: '2048', Memory: '4096', RequiresCompatibilities: ['FARGATE'], @@ -92,8 +96,7 @@ describe('EcsAgentCluster construct', () => { }); test('creates a security group with TCP 443 egress only', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::EC2::SecurityGroup', { + baseTemplate.hasResourceProperties('AWS::EC2::SecurityGroup', { GroupDescription: 'ECS Agent Tasks - egress TCP 443 only', SecurityGroupEgress: Match.arrayWith([ Match.objectLike({ @@ -107,20 +110,17 @@ describe('EcsAgentCluster construct', () => { }); test('creates a CloudWatch log group with 3-month retention and CDK-generated name', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::Logs::LogGroup', { + baseTemplate.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 90, }); - // Verify no hardcoded log group name — CDK auto-generates a unique name - const logGroups = template.findResources('AWS::Logs::LogGroup'); + const logGroups = baseTemplate.findResources('AWS::Logs::LogGroup'); for (const [, lg] of Object.entries(logGroups)) { expect((lg as any).Properties).not.toHaveProperty('LogGroupName'); } }); test('task role has DynamoDB read/write permissions', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::IAM::Policy', { + baseTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -136,8 +136,7 @@ describe('EcsAgentCluster construct', () => { }); test('task role has Secrets Manager read permission', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::IAM::Policy', { + baseTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -152,8 +151,7 @@ describe('EcsAgentCluster construct', () => { }); test('task role has Bedrock InvokeModel permissions', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::IAM::Policy', { + baseTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -170,8 +168,7 @@ describe('EcsAgentCluster construct', () => { }); test('container has required environment variables', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::ECS::TaskDefinition', { + baseTemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { ContainerDefinitions: Match.arrayWith([ Match.objectLike({ Name: 'AgentContainer', diff --git a/cdk/test/constructs/task-orchestrator.test.ts b/cdk/test/constructs/task-orchestrator.test.ts index d3e56df4..ebcba02e 100644 --- a/cdk/test/constructs/task-orchestrator.test.ts +++ b/cdk/test/constructs/task-orchestrator.test.ts @@ -97,17 +97,41 @@ function createStack(overrides?: StackOverrides): { stack: Stack; template: Temp } describe('TaskOrchestrator construct', () => { + let baseTemplate: Template; + let githubTokenTemplate: Template; + let repoTableTemplate: Template; + let ecsTemplate: Template; + + const ecsOverrides: StackOverrides = { + ecsConfig: { + clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + taskDefinitionArn: 'arn:aws:ecs:us-east-1:123456789012:task-definition/agent:1', + subnets: 'subnet-aaa,subnet-bbb', + securityGroup: 'sg-12345', + containerName: 'AgentContainer', + taskRoleArn: 'arn:aws:iam::123456789012:role/TaskRole', + executionRoleArn: 'arn:aws:iam::123456789012:role/ExecutionRole', + }, + }; + + beforeAll(() => { + baseTemplate = createStack().template; + githubTokenTemplate = createStack({ + githubTokenSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:github-token-abc123', + }).template; + repoTableTemplate = createStack({ includeRepoTable: true }).template; + ecsTemplate = createStack(ecsOverrides).template; + }); + test('creates a Lambda function with NODEJS_24_X runtime', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::Lambda::Function', { + baseTemplate.hasResourceProperties('AWS::Lambda::Function', { Runtime: 'nodejs24.x', Architectures: ['arm64'], }); }); test('Lambda function has correct environment variables', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::Lambda::Function', { + baseTemplate.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ TASK_TABLE_NAME: Match.anyValue(), @@ -133,16 +157,14 @@ describe('TaskOrchestrator construct', () => { }); test('creates a Lambda alias', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::Lambda::Alias', 1); - template.hasResourceProperties('AWS::Lambda::Alias', { + baseTemplate.resourceCountIs('AWS::Lambda::Alias', 1); + baseTemplate.hasResourceProperties('AWS::Lambda::Alias', { Name: 'live', }); }); test('grants AgentCore runtime invocation permissions with wildcard sub-resource', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::IAM::Policy', { + baseTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -163,8 +185,7 @@ describe('TaskOrchestrator construct', () => { }); test('attaches durable execution managed policy', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::IAM::Role', { + baseTemplate.hasResourceProperties('AWS::IAM::Role', { ManagedPolicyArns: Match.arrayWith([ Match.objectLike({ 'Fn::Join': Match.arrayWith([ @@ -178,17 +199,13 @@ describe('TaskOrchestrator construct', () => { }); test('Lambda function has 60s timeout', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::Lambda::Function', { + baseTemplate.hasResourceProperties('AWS::Lambda::Function', { Timeout: 60, }); }); test('includes GITHUB_TOKEN_SECRET_ARN when provided', () => { - const { template } = createStack({ - githubTokenSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:github-token-abc123', - }); - template.hasResourceProperties('AWS::Lambda::Function', { + githubTokenTemplate.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ GITHUB_TOKEN_SECRET_ARN: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:github-token-abc123', @@ -198,10 +215,7 @@ describe('TaskOrchestrator construct', () => { }); test('grants Secrets Manager read when githubTokenSecretArn is provided', () => { - const { template } = createStack({ - githubTokenSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:github-token-abc123', - }); - template.hasResourceProperties('AWS::IAM::Policy', { + githubTokenTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -216,17 +230,14 @@ describe('TaskOrchestrator construct', () => { }); test('does not include GITHUB_TOKEN_SECRET_ARN when not provided', () => { - const { template } = createStack(); - // Verify the env vars do NOT contain GITHUB_TOKEN_SECRET_ARN - template.hasResourceProperties('AWS::Lambda::Function', { + baseTemplate.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ TASK_TABLE_NAME: Match.anyValue(), }), }, }); - // Check that no Secrets Manager policy exists (only DynamoDB + AgentCore) - const policies = template.findResources('AWS::IAM::Policy'); + const policies = baseTemplate.findResources('AWS::IAM::Policy'); for (const [, policy] of Object.entries(policies)) { const statements = (policy as any).Properties.PolicyDocument.Statement; for (const stmt of statements) { @@ -249,8 +260,7 @@ describe('TaskOrchestrator construct', () => { }); test('includes REPO_TABLE_NAME when repoTable is provided', () => { - const { template } = createStack({ includeRepoTable: true }); - template.hasResourceProperties('AWS::Lambda::Function', { + repoTableTemplate.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ REPO_TABLE_NAME: Match.anyValue(), @@ -260,8 +270,7 @@ describe('TaskOrchestrator construct', () => { }); test('does not include REPO_TABLE_NAME when repoTable is not provided', () => { - const { template } = createStack(); - const functions = template.findResources('AWS::Lambda::Function'); + const functions = baseTemplate.findResources('AWS::Lambda::Function'); for (const [, fn] of Object.entries(functions)) { const envVars = (fn as any).Properties.Environment?.Variables ?? {}; expect(envVars).not.toHaveProperty('REPO_TABLE_NAME'); @@ -269,8 +278,7 @@ describe('TaskOrchestrator construct', () => { }); test('grants read access on repo table when provided', () => { - const { template } = createStack({ includeRepoTable: true }); - template.hasResourceProperties('AWS::IAM::Policy', { + repoTableTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -322,8 +330,7 @@ describe('TaskOrchestrator construct', () => { }); test('does not include MEMORY_ID when not provided', () => { - const { template } = createStack(); - const functions = template.findResources('AWS::Lambda::Function'); + const functions = baseTemplate.findResources('AWS::Lambda::Function'); for (const [, fn] of Object.entries(functions)) { const envVars = (fn as any).Properties.Environment?.Variables ?? {}; expect(envVars).not.toHaveProperty('MEMORY_ID'); @@ -351,9 +358,8 @@ describe('TaskOrchestrator construct', () => { }); test('creates a CloudWatch alarm for orchestrator errors', () => { - const { template } = createStack(); - template.resourceCountIs('AWS::CloudWatch::Alarm', 1); - template.hasResourceProperties('AWS::CloudWatch::Alarm', { + baseTemplate.resourceCountIs('AWS::CloudWatch::Alarm', 1); + baseTemplate.hasResourceProperties('AWS::CloudWatch::Alarm', { EvaluationPeriods: 2, Threshold: 3, TreatMissingData: 'notBreaching', @@ -361,8 +367,7 @@ describe('TaskOrchestrator construct', () => { }); test('configures async invoke with zero retry attempts', () => { - const { template } = createStack(); - template.hasResourceProperties('AWS::Lambda::EventInvokeConfig', { + baseTemplate.hasResourceProperties('AWS::Lambda::EventInvokeConfig', { MaximumRetryAttempts: 0, }); }); @@ -380,8 +385,7 @@ describe('TaskOrchestrator construct', () => { }); test('does not include GUARDRAIL_ID when not provided', () => { - const { template } = createStack(); - const functions = template.findResources('AWS::Lambda::Function'); + const functions = baseTemplate.findResources('AWS::Lambda::Function'); for (const [, fn] of Object.entries(functions)) { const envVars = (fn as any).Properties.Environment?.Variables ?? {}; expect(envVars).not.toHaveProperty('GUARDRAIL_ID'); @@ -411,8 +415,7 @@ describe('TaskOrchestrator construct', () => { }); test('does not grant bedrock:ApplyGuardrail when guardrailId is not provided', () => { - const { template } = createStack(); - const policies = template.findResources('AWS::IAM::Policy'); + const policies = baseTemplate.findResources('AWS::IAM::Policy'); for (const [, policy] of Object.entries(policies)) { const statements = (policy as any).Properties.PolicyDocument.Statement; for (const stmt of statements) { @@ -438,21 +441,8 @@ describe('TaskOrchestrator construct', () => { }); describe('ECS compute strategy', () => { - const ecsOverrides = { - ecsConfig: { - clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', - taskDefinitionArn: 'arn:aws:ecs:us-east-1:123456789012:task-definition/agent:1', - subnets: 'subnet-aaa,subnet-bbb', - securityGroup: 'sg-12345', - containerName: 'AgentContainer', - taskRoleArn: 'arn:aws:iam::123456789012:role/TaskRole', - executionRoleArn: 'arn:aws:iam::123456789012:role/ExecutionRole', - }, - }; - test('includes ECS env vars when ECS props are provided', () => { - const { template } = createStack(ecsOverrides); - template.hasResourceProperties('AWS::Lambda::Function', { + ecsTemplate.hasResourceProperties('AWS::Lambda::Function', { Environment: { Variables: Match.objectLike({ ECS_CLUSTER_ARN: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', @@ -466,8 +456,7 @@ describe('TaskOrchestrator construct', () => { }); test('does not include ECS env vars when ECS props are omitted', () => { - const { template } = createStack(); - const functions = template.findResources('AWS::Lambda::Function'); + const functions = baseTemplate.findResources('AWS::Lambda::Function'); for (const [, fn] of Object.entries(functions)) { const envVars = (fn as any).Properties.Environment?.Variables ?? {}; expect(envVars).not.toHaveProperty('ECS_CLUSTER_ARN'); @@ -476,8 +465,7 @@ describe('TaskOrchestrator construct', () => { }); test('grants ECS RunTask/DescribeTasks/StopTask permissions when ECS props are provided', () => { - const { template } = createStack(ecsOverrides); - template.hasResourceProperties('AWS::IAM::Policy', { + ecsTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -500,8 +488,7 @@ describe('TaskOrchestrator construct', () => { }); test('grants iam:PassRole scoped to task/execution role ARNs', () => { - const { template } = createStack(ecsOverrides); - template.hasResourceProperties('AWS::IAM::Policy', { + ecsTemplate.hasResourceProperties('AWS::IAM::Policy', { PolicyDocument: { Statement: Match.arrayWith([ Match.objectLike({ @@ -523,8 +510,7 @@ describe('TaskOrchestrator construct', () => { }); test('does not grant ECS permissions when ECS props are omitted', () => { - const { template } = createStack(); - const policies = template.findResources('AWS::IAM::Policy'); + const policies = baseTemplate.findResources('AWS::IAM::Policy'); for (const [, policy] of Object.entries(policies)) { const statements = (policy as any).Properties.PolicyDocument.Statement; for (const stmt of statements) {