diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/context-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/context-app/app.js new file mode 100644 index 000000000..54cf3ca92 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/context-app/app.js @@ -0,0 +1,21 @@ +const cdk = require('aws-cdk-lib'); + +const app = new cdk.App(); + +const contextValue = app.node.tryGetContext('myContextParam'); + +if (!contextValue) { + throw new Error('Context parameter "myContextParam" is required'); +} + +const stack = new cdk.Stack(app, 'TestStack', { + description: `Stack created with context value: ${contextValue}`, +}); + +// Add a simple resource +new cdk.CfnOutput(stack, 'ContextValue', { + value: contextValue, + description: 'The context value passed via CLI', +}); + +app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/context-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/context-app/cdk.json new file mode 100644 index 000000000..b291e31a9 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/context-app/cdk.json @@ -0,0 +1,6 @@ +{ + "app": "node app.js", + "context": { + "@aws-cdk/core:newStyleStackSynthesis": true + } +} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/flags/cdk-flags-with-cli-context.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/flags/cdk-flags-with-cli-context.integtest.ts new file mode 100644 index 000000000..a1266e497 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/flags/cdk-flags-with-cli-context.integtest.ts @@ -0,0 +1,25 @@ +import { integTest, withAws, withSpecificCdkApp } from '../../../lib'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'flags command works with CLI context parameters', + withAws( + withSpecificCdkApp('context-app', async (fixture) => { + await fixture.cdk(['bootstrap', '-c', 'myContextParam=testValue']); + + const output = await fixture.cdk([ + 'flags', + '--unstable=flags', + '--set', + '--recommended', + '--all', + '-c', 'myContextParam=testValue', + '--yes', + ]); + + expect(output).toContain('Flag changes:'); + }), + true, + ), +); diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index cba6bc509..01ef7e138 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -498,7 +498,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise = {}, + ) { this.flags = flagData.filter(flag => !OBSOLETE_FLAGS.includes(flag.name)); this.options = { ...options, concurrency: options.concurrency ?? 4 }; this.ioHelper = ioHelper; const validator = new FlagValidator(ioHelper); - const flagOperations = new FlagOperations(this.flags, toolkit, ioHelper); + const flagOperations = new FlagOperations(this.flags, toolkit, ioHelper, cliContextValues); const interactiveHandler = new InteractiveHandler(this.flags, flagOperations); this.router = new FlagOperationRouter(validator, interactiveHandler, flagOperations); diff --git a/packages/aws-cdk/lib/commands/flags/operations.ts b/packages/aws-cdk/lib/commands/flags/operations.ts index 8ecef959c..d3d117027 100644 --- a/packages/aws-cdk/lib/commands/flags/operations.ts +++ b/packages/aws-cdk/lib/commands/flags/operations.ts @@ -48,6 +48,7 @@ export class FlagOperations { private readonly flags: FeatureFlag[], private readonly toolkit: Toolkit, private readonly ioHelper: IoHelper, + private readonly cliContextValues: Record = {}, ) { this.app = ''; this.baseContextValues = {}; @@ -159,11 +160,12 @@ export class FlagOperations { /** Initializes the safety check by reading context and synthesizing baseline templates */ private async initializeSafetyCheck(): Promise { const baseContext = new CdkAppMultiContext(process.cwd()); - this.baseContextValues = await baseContext.read(); + this.baseContextValues = { ...await baseContext.read(), ...this.cliContextValues }; this.baselineTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-baseline-')); + const mergedContext = new MemoryContext(this.baseContextValues); const baseSource = await this.toolkit.fromCdkApp(this.app, { - contextStore: baseContext, + contextStore: mergedContext, outdir: this.baselineTempDir, }); @@ -270,14 +272,14 @@ export class FlagOperations { /** Prototypes flag changes by synthesizing templates and showing diffs to the user */ private async prototypeChanges(flagNames: string[], params: FlagOperationsParams): Promise { const baseContext = new CdkAppMultiContext(process.cwd()); - const baseContextValues = await baseContext.read(); + const baseContextValues = { ...await baseContext.read(), ...this.cliContextValues }; const memoryContext = new MemoryContext(baseContextValues); const cdkJson = await JSON.parse(await fs.readFile(path.join(process.cwd(), 'cdk.json'), 'utf-8')); const app = cdkJson.app; const source = await this.toolkit.fromCdkApp(app, { - contextStore: baseContext, + contextStore: memoryContext, outdir: fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-original-')), }); diff --git a/packages/aws-cdk/test/cli/cli.test.ts b/packages/aws-cdk/test/cli/cli.test.ts index 7acbc533c..df753f36b 100644 --- a/packages/aws-cdk/test/cli/cli.test.ts +++ b/packages/aws-cdk/test/cli/cli.test.ts @@ -1,3 +1,4 @@ +import { Toolkit } from '@aws-cdk/toolkit-lib'; import { Notices } from '../../lib/api/notices'; import * as cdkToolkitModule from '../../lib/cli/cdk-toolkit'; import { exec } from '../../lib/cli/cli'; @@ -65,6 +66,8 @@ jest.mock('../../lib/cli/parse-command-line-arguments', () => ({ _: ['deploy'], parameters: [], }; + } else if (args.includes('flags')) { + result = { ...result, _: ['flags'] }; } // Handle notices flags @@ -93,6 +96,21 @@ jest.mock('../../lib/cli/parse-command-line-arguments', () => ({ }), })); +// Mock FlagCommandHandler to capture constructor calls +const mockFlagCommandHandlerConstructor = jest.fn(); +const mockProcessFlagsCommand = jest.fn().mockResolvedValue(undefined); + +jest.mock('../../lib/commands/flags/flags', () => { + return { + FlagCommandHandler: jest.fn().mockImplementation((...args) => { + mockFlagCommandHandlerConstructor(...args); + return { + processFlagsCommand: mockProcessFlagsCommand, + }; + }), + }; +}); + describe('exec verbose flag tests', () => { beforeEach(() => { jest.clearAllMocks(); @@ -513,3 +531,61 @@ describe('--yes', () => { execSpy.mockRestore(); }); }); + +describe('flags command tests', () => { + let mockConfig: any; + let flagsSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockFlagCommandHandlerConstructor.mockClear(); + mockProcessFlagsCommand.mockClear(); + + flagsSpy = jest.spyOn(Toolkit.prototype, 'flags').mockResolvedValue([]); + + mockConfig = { + loadConfigFiles: jest.fn().mockResolvedValue(undefined), + settings: { + get: jest.fn().mockImplementation((key: string[]) => { + if (key[0] === 'unstable') return ['flags']; + return undefined; + }), + }, + context: { + all: { + myContextParam: 'testValue', + }, + get: jest.fn().mockReturnValue([]), + }, + }; + + Configuration.fromArgsAndFiles = jest.fn().mockResolvedValue(mockConfig); + }); + + afterEach(() => { + flagsSpy.mockRestore(); + }); + + test('passes CLI context to FlagCommandHandler', async () => { + // WHEN + await exec([ + 'flags', + '--unstable=flags', + '--set', + '--recommended', + '--all', + '-c', 'myContextParam=testValue', + '--yes', + ]); + + // THEN + expect(mockFlagCommandHandlerConstructor).toHaveBeenCalledWith( + expect.anything(), // flagsData + expect.anything(), // ioHelper + expect.anything(), // args + expect.anything(), // toolkit + mockConfig.context.all, // cliContextValues + ); + expect(mockProcessFlagsCommand).toHaveBeenCalled(); + }); +}); diff --git a/packages/aws-cdk/test/commands/flag-operations.test.ts b/packages/aws-cdk/test/commands/flag-operations.test.ts index 3bc5b99d2..ba99f4cf9 100644 --- a/packages/aws-cdk/test/commands/flag-operations.test.ts +++ b/packages/aws-cdk/test/commands/flag-operations.test.ts @@ -1197,6 +1197,101 @@ describe('interactive prompts lead to the correct function calls', () => { }); }); +describe('CLI context parameters', () => { + beforeEach(() => { + setupMockToolkitForPrototyping(mockToolkit); + jest.clearAllMocks(); + }); + + test('CLI context values are merged with file context during prototyping', async () => { + const cdkJsonPath = await createCdkJsonFile({ + '@aws-cdk/core:existingFlag': true, + }); + + setupMockToolkitForPrototyping(mockToolkit); + + const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); + requestResponseSpy.mockResolvedValue(false); + + const cliContextValues = { + foo: 'bar', + myContextParam: 'myValue', + }; + + const options: FlagsOptions = { + FLAGNAME: ['@aws-cdk/core:testFlag'], + set: true, + value: 'true', + }; + + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit, cliContextValues); + await flagOperations.processFlagsCommand(); + + // Get the first call's context store and verify it contains merged context + // fromCdkApp(app, { contextStore: ..., outdir: ... }) was called + const firstCallArgs = mockToolkit.fromCdkApp.mock.calls[0]; // Get first call arguments + const contextStore = firstCallArgs[1]?.contextStore; // Extract contextStore from second argument (options object) + expect(contextStore).toBeDefined(); + + // contextStore is defined as we've verified above + const contextData = await contextStore!.read(); + + expect(contextData).toEqual({ + '@aws-cdk/core:existingFlag': true, + '@aws-cdk/core:testFlag': true, + 'foo': 'bar', + 'myContextParam': 'myValue', + }); + + await cleanupCdkJsonFile(cdkJsonPath); + requestResponseSpy.mockRestore(); + }); + + test('CLI context values are passed to synthesis during safe flag checking', async () => { + const cdkJsonPath = await createCdkJsonFile({ + '@aws-cdk/core:existingFlag': true, + }); + + mockToolkit.diff.mockResolvedValue({ + TestStack: { differenceCount: 0 } as any, + }); + + const requestResponseSpy = jest.spyOn(ioHelper, 'requestResponse'); + requestResponseSpy.mockResolvedValue(false); + + const cliContextValues = { + foo: 'bar', + myContextParam: 'myValue', + }; + + const options: FlagsOptions = { + safe: true, + concurrency: 4, + }; + + const flagOperations = new FlagCommandHandler(mockFlagsData, ioHelper, options, mockToolkit, cliContextValues); + await flagOperations.processFlagsCommand(); + + // Get the first call's context store and verify it contains merged context + // fromCdkApp(app, { contextStore: ..., outdir: ... }) was called + const firstCallArgs = mockToolkit.fromCdkApp.mock.calls[0]; // Get first call arguments + const contextStore = firstCallArgs[1]?.contextStore; // Extract contextStore from second argument (options object) + expect(contextStore).toBeDefined(); + + // contextStore is defined as we've verified above + const contextData = await contextStore!.read(); + + expect(contextData).toEqual({ + '@aws-cdk/core:existingFlag': true, + 'foo': 'bar', + 'myContextParam': 'myValue', + }); + + await cleanupCdkJsonFile(cdkJsonPath); + requestResponseSpy.mockRestore(); + }); +}); + describe('setSafeFlags', () => { beforeEach(() => { setupMockToolkitForPrototyping(mockToolkit); @@ -1390,7 +1485,13 @@ async function displayFlags(params: FlagOperationsParams): Promise { await f.displayFlags(params); } -async function handleFlags(flagData: FeatureFlag[], _ioHelper: IoHelper, options: FlagsOptions, toolkit: Toolkit) { - const f = new FlagCommandHandler(flagData, _ioHelper, options, toolkit); +async function handleFlags( + flagData: FeatureFlag[], + _ioHelper: IoHelper, + options: FlagsOptions, + toolkit: Toolkit, + cliContextValues: Record = {}, +) { + const f = new FlagCommandHandler(flagData, _ioHelper, options, toolkit, cliContextValues); await f.processFlagsCommand(); }