diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/list/cdk-cdk-ls-ci-no-synth-time-on-stdout.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/list/cdk-cdk-ls-ci-no-synth-time-on-stdout.integtest.ts deleted file mode 100644 index c346830e1..000000000 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/list/cdk-cdk-ls-ci-no-synth-time-on-stdout.integtest.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { integTest, withDefaultFixture } from '../../../lib'; - -integTest( - 'cdk ls --json in CI does not print synthesis time to stdout', - withDefaultFixture(async (fixture) => { - // `cdk ls --json` stdout is a machine-readable contract and is often piped (e.g. to `jq`), - // so it must be only the stack listing, not status lines like "✨ Synthesis time: ...". - const listing = await fixture.cdk(['ls', '--json'], { - verbose: false, // fixture defaults verbose on; turn it off so stdout is just the listing - captureStderr: false, // capture stdout only; stderr is folded into the result by default - modEnv: { CI: 'true' }, // CI routes non-error output to stdout (default is stderr) - }); - - const lines = listing.trim().split('\n').filter(line => line.length > 0); - - // every line should be a stack; a synth-time line would not carry the prefix - expect(lines.length).toBeGreaterThan(0); - for (const line of lines) { - expect(line).toContain(fixture.stackNamePrefix); - } - }), -); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/list/cdk-cdk-ls-only-stack-listing.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/list/cdk-cdk-ls-only-stack-listing.integtest.ts new file mode 100644 index 000000000..650af13b5 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/list/cdk-cdk-ls-only-stack-listing.integtest.ts @@ -0,0 +1,26 @@ +import { integTest, withDefaultFixture } from '../../../lib'; + +integTest( + 'cdk ls prints only the stack listing, no status lines', + withDefaultFixture(async (fixture) => { + // `cdk ls` output should be the stack listing only, never status lines like the synth time + // or "Including dependency stacks". `*list-stacks` is selected because it pulls in a dependency. + for (const args of [['ls'], ['ls', `${fixture.stackNamePrefix}-list-stacks`]]) { + const output = await fixture.cdk(args, { + verbose: false, + modEnv: { CI: 'true' }, + }); + + // no status lines + expect(output).not.toContain('Synthesis time'); + expect(output).not.toContain('Including dependency stacks'); + + // every line should be a stack + const lines = output.trim().split('\n').filter(line => line.length > 0); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(line).toContain(fixture.stackNamePrefix); + } + } + }), +); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 8f35662f3..469c076db 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -966,17 +966,19 @@ export class CdkToolkit { ): Promise { this.ioHost.rewriteOnce(IO.CDK_TOOLKIT_I2901, (msg) => formatStackList(msg.data.stacks, options)); - // With `--json`, stdout must stay machine-parsable, so suppress the synth-time line (I1000). - if (options.json) { - this.ioHost.once(IO.CDK_TOOLKIT_I1000, () => ({ preventDefault: true })); + // Only the listing should reach stdout; drop info status lines (synth time, dependency notes). + const previousLevel = this.ioHost.logLevel; + this.ioHost.logLevel = 'warn'; + try { + await this.toolkit.list(this.props.cloudExecutable, { + stacks: selectors.length > 0 + ? { patterns: selectors, strategy: StackSelectionStrategy.PATTERN_MATCH, expand: ExpandStackSelection.UPSTREAM } + : undefined, + }); + } finally { + this.ioHost.logLevel = previousLevel; } - await this.toolkit.list(this.props.cloudExecutable, { - stacks: selectors.length > 0 - ? { patterns: selectors, strategy: StackSelectionStrategy.PATTERN_MATCH, expand: ExpandStackSelection.UPSTREAM } - : undefined, - }); - return 0; // exit-code } diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index 48caf81bd..1ff1f9437 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -216,33 +216,44 @@ describe('list', () => { ]); }); - test('with --json, suppresses the synthesis-time line so stdout stays machine-parsable', async () => { - // `cdk ls --json` stdout is a machine-readable contract; the "Synthesis time" line - // (CDK_TOOLKIT_I1000) must not be written (in CI mode non-error output goes to stdout). + test('raises the log level to warn while listing, so info status lines are dropped', async () => { + // The "Synthesis time" line and "Including dependency stacks" line are info-level; while + // listing, the log level is raised to warn so they never reach stdout next to the listing. const toolkit = defaultToolkitSetup(); - const onceSpy = jest.spyOn(ioHost, 'once'); + let levelDuringList: string | undefined; + const listSpy = jest.spyOn(Toolkit.prototype, 'list').mockImplementation(async () => { + levelDuringList = ioHost.logLevel; + return []; + }); // WHEN - await toolkit.list([], { json: true }); + await toolkit.list([]); - // THEN - a one-shot suppressor for I1000 was registered that prevents default handling. - const i1000Call = onceSpy.mock.calls.find(([code]) => (code as any)?.code === 'CDK_TOOLKIT_I1000'); - expect(i1000Call).toBeDefined(); - const listener = i1000Call![1] as (msg: any) => any; - expect(listener({ code: 'CDK_TOOLKIT_I1000' })).toEqual({ preventDefault: true }); + // THEN - info-level messages are filtered out during the listing + expect(levelDuringList).toEqual('warn'); + listSpy.mockRestore(); }); - test('without --json, does not suppress the synthesis-time line', async () => { - // Plain `cdk ls` is not a machine-readable contract, so the line is left alone. + test('restores the previous log level after listing', async () => { const toolkit = defaultToolkitSetup(); - const onceSpy = jest.spyOn(ioHost, 'once'); + ioHost.logLevel = 'info'; // WHEN await toolkit.list([]); - // THEN - const i1000Call = onceSpy.mock.calls.find(([code]) => (code as any)?.code === 'CDK_TOOLKIT_I1000'); - expect(i1000Call).toBeUndefined(); + // THEN - the temporary raise does not leak into later output (e.g. notices) + expect(ioHost.logLevel).toEqual('info'); + }); + + test('restores the previous log level even if listing throws', async () => { + const toolkit = defaultToolkitSetup(); + ioHost.logLevel = 'info'; + const listSpy = jest.spyOn(Toolkit.prototype, 'list').mockRejectedValue(new Error('boom')); + + // WHEN / THEN + await expect(toolkit.list([])).rejects.toThrow('boom'); + expect(ioHost.logLevel).toEqual('info'); + listSpy.mockRestore(); }); });