diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index 161a12887..34204be21 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -1216,6 +1216,10 @@ switch (stackSet) { case 'stage-with-no-stacks': break; + case 'stage-only': + new SomeStage(app, `${stackPrefix}-stage`); + break; + default: throw new Error(`Unrecognized INTEG_STACK_SET: '${stackSet}'`); } diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts new file mode 100644 index 000000000..429e6c5be --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-nonexistent-stack.integtest.ts @@ -0,0 +1,21 @@ +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy does not fail even if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2])).resolves.not.toThrow(); +})); + +integTest('cdk destroy with no force option exits without prompt if the stacks do not exist', withDefaultFixture(async (fixture) => { + const nonExistingStackName1 = 'non-existing-stack-1'; + const nonExistingStackName2 = 'non-existing-stack-2'; + + await expect(fixture.cdkDestroy([nonExistingStackName1, nonExistingStackName2], { + force: false, + })).resolves.not.toThrow(); +})); + +integTest('cdk destroy does not fail even if the stages do not exist', withDefaultFixture(async (fixture) => { + await expect(fixture.cdkDestroy('NonExistent/*')).resolves.not.toThrow(); +})); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts new file mode 100644 index 000000000..95a7d1c50 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/destroy/cdk-destroy-stage-only.integtest.ts @@ -0,0 +1,28 @@ +import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest('cdk destroy can destroy stacks in stage-only configuration', withDefaultFixture(async (fixture) => { + const integStackSet = 'stage-only'; + + const stageNameSuffix = 'stage'; + const specifiedStackName = `${stageNameSuffix}/*`; + + await fixture.cdkDeploy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + const stackName = `${fixture.fullStackName(stageNameSuffix)}-StackInStage`; + const stack = await fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName })); + expect(stack.Stacks?.length ?? 0).toEqual(1); + + await fixture.cdkDestroy(specifiedStackName, { + modEnv: { + INTEG_STACK_SET: integStackSet, + }, + }); + + await expect(fixture.aws.cloudFormation.send(new DescribeStacksCommand({ StackName: stackName }))) + .rejects.toThrow(/does not exist/); +})); diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 7be969552..292e60b97 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -129,6 +129,8 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I7010` | Confirm destroy stacks | `info` | {@link ConfirmationRequest} | | `CDK_TOOLKIT_I7100` | Stack destroy progress | `info` | {@link StackDestroyProgress} | | `CDK_TOOLKIT_I7101` | Start stack destroying | `trace` | {@link StackDestroy} | +| `CDK_TOOLKIT_W7010` | A provided stack name does not match any stack | `warn` | n/a | +| `CDK_TOOLKIT_W7011` | No stacks match the provided names, nothing to destroy | `warn` | n/a | | `CDK_TOOLKIT_I7900` | Stack deletion succeeded | `result` | [cxapi.CloudFormationStackArtifact](https://docs.aws.amazon.com/cdk/api/v2/docs/@aws-cdk_cx-api.CloudFormationStackArtifact.html) | | `CDK_TOOLKIT_W7902` | Express Mode deletion completed with resources still tearing down | `warn` | n/a | | `CDK_TOOLKIT_E7010` | Action was aborted due to negative confirmation of request | `error` | n/a | diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts index 9405cec99..7b0d084f7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/stack-assembly.ts @@ -1,4 +1,6 @@ import '../../../private/dispose-polyfill'; +import type { CloudFormationStackArtifact } from '@aws-cdk/cloud-assembly-api'; +import { isMatch as picomatch } from 'picomatch'; import { major } from 'semver'; import { ToolkitError } from '../../../toolkit/toolkit-error'; import type { IoHelper } from '../../io/private'; @@ -8,10 +10,44 @@ import type { StackSelector } from '../stack-selector'; import { ExpandStackSelection, StackSelectionStrategy } from '../stack-selector'; import type { IReadableCloudAssembly } from '../types'; +/** + * Options for `StackAssembly.selectStacksV3`. + */ +export interface SelectStacksV3Options { + /** + * For a pattern selector, also compute suggestions for every pattern that + * matched no stack (see `SelectStacksV3Result.suggestions`). + * + * @default false + */ + readonly suggestPatternMatches?: boolean; +} + +/** + * Result of `StackAssembly.selectStacksV3`. + */ +export interface SelectStacksV3Result { + /** + * The selected stacks. + */ + readonly stacks: StackCollection; + + /** + * Only present when `suggestPatternMatches` was requested and the selector is + * a pattern selector: for every provided pattern that matched no stack, the + * hierarchical ids of stacks that loosely (case-insensitively) resemble it. + * The array is empty when there is no close match. Patterns that matched at + * least one stack do not appear. + */ + readonly suggestions?: Record; +} + /** * A single Cloud Assembly wrapped to provide additional stack operations. */ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAssembly { + private _allStacks: CloudFormationStackArtifact[] | undefined; + constructor(private readonly _asm: IReadableCloudAssembly, ioHelper: IoHelper) { super(_asm.cloudAssembly, ioHelper); } @@ -32,15 +68,39 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs return this.dispose(); } + /** + * Cached get the fetch all CloudFormationStackArtifacts for the assembly. + */ + private get allStacks(): CloudFormationStackArtifact[] { + if (!this._allStacks) { + this._allStacks = major(this.assembly.version) < 10 ? this.assembly.stacks : this.assembly.stacksRecursively; + } + + return this._allStacks; + } + /** * Improved stack selection interface with a single selector * @throws when the assembly does not contain any stacks, unless `selector.failOnEmpty` is `false` * @throws when individual selection strategies are not satisfied + * + * Thin wrapper around `selectStacksV3` that keeps the historic return shape. */ public async selectStacksV2(selector: StackSelector): Promise { + return (await this.selectStacksV3(selector)).stacks; + } + + /** + * Improved stack selection interface with a single selector, optionally + * reporting suggestions for patterns that matched no stack. + * + * @throws when the assembly does not contain any stacks, unless `selector.failOnEmpty` is `false` + * @throws when individual selection strategies are not satisfied + */ + public async selectStacksV3(selector: StackSelector, options: SelectStacksV3Options = {}): Promise { const asm = this.assembly; const topLevelStacks = asm.stacks; - const allStacks = major(asm.version) < 10 ? asm.stacks : asm.stacksRecursively; + const allStacks = this.allStacks; if (allStacks.length === 0 && (selector.failOnEmpty ?? true)) { throw new ToolkitError('NoStacksInApp', 'This app contains no stacks'); @@ -51,20 +111,20 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs switch (selector.strategy) { case StackSelectionStrategy.ALL_STACKS: - return new StackCollection(this, allStacks); + return { stacks: new StackCollection(this, allStacks) }; case StackSelectionStrategy.MAIN_ASSEMBLY: if (topLevelStacks.length < 1) { // @todo text should probably be handled in io host throw new ToolkitError('NoStackInMainAssembly', 'No stack found in the main cloud assembly. Use "list" to print manifest'); } - return this.extendStacks(topLevelStacks, allStacks, extend); + return { stacks: await this.extendStacks(topLevelStacks, allStacks, extend) }; case StackSelectionStrategy.ONLY_SINGLE: if (topLevelStacks.length !== 1) { // @todo text should probably be handled in io host throw new ToolkitError('MultipleStacksWithoutSelector', 'Since this app includes more than a single stack, specify which stacks to use (wildcards are supported) or specify `--all`\n' + `Stacks: ${allStacks.map(x => x.hierarchicalId).join(' ยท ')}`); } - return new StackCollection(this, topLevelStacks); + return { stacks: new StackCollection(this, topLevelStacks) }; default: const matched = await this.selectMatchingStacks(allStacks, patterns, extend); if ( @@ -88,20 +148,30 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs ); } - return matched; + return { + stacks: matched, + suggestions: options.suggestPatternMatches ? this.suggestionsForPatterns(patterns, matched) : undefined, + }; } } /** - * Select all stacks. - * - * This method never throws and can safely be used as a basis for other calculations. - * - * @returns a `StackCollection` of all stacks + * For every pattern that matched no stack, collect the hierarchical ids of + * stacks that loosely (case-insensitively) resemble it. Patterns that matched + * at least one stack are omitted; the array is empty when there is no close + * match. Pure computation, never throws, emits no output. */ - public selectAllStacks() { - const allStacks = major(this.assembly.version) < 10 ? this.assembly.stacks : this.assembly.stacksRecursively; - return new StackCollection(this, allStacks); + private suggestionsForPatterns(patterns: string[], matched: StackCollection): Record { + const suggestions: Record = {}; + for (const pattern of patterns) { + if (matched.stackArtifacts.some((stack) => picomatch(stack.hierarchicalId, pattern))) { + continue; + } + suggestions[pattern] = this.allStacks + .filter((stack) => picomatch(stack.hierarchicalId.toLowerCase(), pattern.toLowerCase())) + .map((stack) => stack.hierarchicalId); + } + return suggestions; } /** @@ -110,8 +180,8 @@ export class StackAssembly extends BaseStackAssembly implements IReadableCloudAs * @returns a `StackCollection` of all stacks that needs to be validated */ public selectStacksForValidation() { - const allStacks = this.selectAllStacks(); - return allStacks.filter((art) => art.validateOnSynth ?? false); + const selected = this.allStacks.filter((art) => art.validateOnSynth ?? false); + return new StackCollection(this, selected); } } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 02b953c7a..415ad977e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -398,6 +398,15 @@ export const IO = { interface: 'StackDestroy', }), + CDK_TOOLKIT_W7010: make.warn({ + code: 'CDK_TOOLKIT_W7010', + description: 'A provided stack name does not match any stack', + }), + CDK_TOOLKIT_W7011: make.warn({ + code: 'CDK_TOOLKIT_W7011', + description: 'No stacks match the provided names, nothing to destroy', + }), + CDK_TOOLKIT_I7900: make.result({ code: 'CDK_TOOLKIT_I7900', description: 'Stack deletion succeeded', diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 0cb2e0355..8d712d187 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1772,14 +1772,28 @@ export class Toolkit extends CloudAssemblySourceBuilder { private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise { const selectStacks = stacksOpt(options); const ioHelper = asIoHelper(this.ioHost, action); - const stacks = await assembly.selectStacksV2(selectStacks); + const { stacks, suggestions } = await assembly.selectStacksV3(selectStacks, { suggestPatternMatches: true }); + + // Warn about each provided pattern that matched no stack, suggesting a close + // match when one exists (e.g. only the casing differs). + for (const [pattern, closeMatches] of Object.entries(suggestions ?? {})) { + const suggestion = closeMatches.length > 0 ? ` Do you mean ${chalk.blue(closeMatches.join(', '))}?` : ''; + await ioHelper.notify(IO.CDK_TOOLKIT_W7010.msg(`${chalk.red(pattern)} does not exist.${suggestion}`)); + } const ret: DestroyResult = { stacks: [], }; + if (stacks.stackCount === 0) { + await ioHelper.notify(IO.CDK_TOOLKIT_W7011.msg( + `No stacks match the name(s): ${chalk.red((selectStacks.patterns ?? []).join(', '))}`, + )); + return ret; + } + const motivation = 'Destroying stacks is an irreversible action'; - const question = `Are you sure you want to delete: ${chalk.red(stacks.hierarchicalIds.join(', '))}`; + const question = `Are you sure you want to delete: ${chalk.blue(stacks.hierarchicalIds.join(', '))}`; const confirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I7010.req(question, { motivation })); if (!confirmed) { await ioHelper.notify(IO.CDK_TOOLKIT_E7010.msg('Aborted by user')); diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts new file mode 100644 index 000000000..51219a694 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-stage/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app with a top-level stack plus a stack nested inside a Stage. + * + * Hierarchical ids: `TopLevelStack` and `Stage/StackInStage`. Used to exercise + * destroy/suggestion behavior across both top-level and nested-stage stacks. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + new core.Stack(app, 'TopLevelStack'); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts new file mode 100644 index 000000000..ed0835628 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stage-only/index.ts @@ -0,0 +1,16 @@ +import * as core from 'aws-cdk-lib/core'; + +/** + * An app whose only stack lives inside a Stage (no top-level stacks). + * + * This is the configuration that regressed the original `cdk destroy` warning + * feature: code that only looked at top-level stacks could not see (or suggest) + * stacks nested in a Stage. The stack's hierarchical id is `Stage/StackInStage`. + */ +export default async () => { + const app = new core.App({ autoSynth: false }); + const stage = new core.Stage(app, 'Stage'); + new core.Stack(stage, 'StackInStage'); + + return app.synth(); +}; diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts index 88b8cf2d6..eaae88baa 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/destroy.test.ts @@ -40,7 +40,7 @@ describe('destroy', () => { action: 'destroy', level: 'info', code: 'CDK_TOOLKIT_I7010', - message: expect.stringContaining('Are you sure you want to delete'), + message: expect.stringContaining(`Are you sure you want to delete: ${chalk.blue('Stack1')}`), })); }); @@ -177,6 +177,199 @@ describe('destroy', () => { expect(mockDispose).toHaveBeenCalled(); await realDispose(); }); + + test('warns when no stacks match the given name(s)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a closely matching stack when the name does not exist', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stack1'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'destroy', + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining('does not exist. Do you mean'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('warns about a missing name but still destroys the matching stacks', async () => { + // WHEN: one name matches (Stack1), the other does not + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stack1', 'DoesNotExist'] }, + }); + + // THEN: warn for the missing name, but no "no stacks match" and the match is destroyed + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('DoesNotExist')} does not exist.`), + })); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys all top-level stacks with the MAIN_ASSEMBLY strategy (cdk destroy --all)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.MAIN_ASSEMBLY }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(2); + }); + + test('destroys the single stack with the ONLY_SINGLE strategy (cdk destroy with no patterns)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.ONLY_SINGLE }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('warns about every non-existent name when none of them match', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['NopeA', 'NopeB'] }, + }); + + // THEN: a per-name warning for each, plus the overall no-match warning + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeA')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`${chalk.red('NopeB')} does not exist`), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + code: 'CDK_TOOLKIT_W7011', + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + describe('stacks nested in a stage', () => { + test('destroys a stack inside a stage by its hierarchical id', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + }); + + // THEN: only the staged stack is destroyed, not the top-level one + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a staged stack via a wildcard pattern', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-stage'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + expect(mockDestroyStack.mock.calls[0][0].stack.hierarchicalId).toEqual('Stage/StackInStage'); + }); + + test('destroys a stack in a stage-only app (no top-level stacks)', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/StackInStage'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('destroys a staged stack via wildcard in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['Stage/*'] }, + }); + + // THEN + expect(mockDestroyStack).toHaveBeenCalledTimes(1); + }); + + test('warns without failing for a non-existent name in a stage-only app', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['DoesNotExist/*'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7011', + message: expect.stringContaining('No stacks match the name(s)'), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a nested-stage stack when only the casing differs', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/stackinstage'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + + test('suggests a nested-stage stack for a wildcard pattern that differs only in case', async () => { + // WHEN: a lower-cased wildcard that matches nothing but resembles the staged stack + const cx = await builderFixture(toolkit, 'stage-only'); + await toolkit.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MATCH, patterns: ['stage/*'] }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'warn', + code: 'CDK_TOOLKIT_W7010', + message: expect.stringContaining(`does not exist. Do you mean ${chalk.blue('Stage/StackInStage')}?`), + })); + expect(mockDestroyStack).not.toHaveBeenCalled(); + }); + }); }); function successfulDestroy() {