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 new file mode 100644 index 000000000..c346830e1 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/list/cdk-cdk-ls-ci-no-synth-time-on-stdout.integtest.ts @@ -0,0 +1,22 @@ +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/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 33209a5c9..e00ceb427 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -960,6 +960,11 @@ 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 })); + } + await this.toolkit.list(this.props.cloudExecutable, { stacks: selectors.length > 0 ? { patterns: selectors, strategy: StackSelectionStrategy.PATTERN_MATCH, expand: ExpandStackSelection.UPSTREAM } diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index ddd61b59d..8543ad486 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -207,6 +207,35 @@ describe('list', () => { 'Test-Stack-B', ]); }); + + 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). + const toolkit = defaultToolkitSetup(); + const onceSpy = jest.spyOn(ioHost, 'once'); + + // WHEN + await toolkit.list([], { json: true }); + + // 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 }); + }); + + 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. + const toolkit = defaultToolkitSetup(); + const onceSpy = jest.spyOn(ioHost, 'once'); + + // WHEN + await toolkit.list([]); + + // THEN + const i1000Call = onceSpy.mock.calls.find(([code]) => (code as any)?.code === 'CDK_TOOLKIT_I1000'); + expect(i1000Call).toBeUndefined(); + }); }); describe('deploy', () => {