diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/watch/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/watch/index.ts index b06fb9929..a607a6182 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/watch/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/watch/index.ts @@ -1,4 +1,5 @@ import type { DeploymentMethod, BaseDeployOptions } from '../deploy'; +import type { SynthOptions } from '../synth'; import type { ValidateOptions } from '../validate'; /** @@ -44,6 +45,12 @@ export interface WatchOptions extends WatchFileOptions, BaseDeployOptions { readonly deploymentMethod?: DeploymentMethod; } +/** + * Options for watch-synth mode. + */ +export interface WatchSynthOptions extends WatchFileOptions, SynthOptions { +} + /** * Options for watch-validate mode. */ diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 8d712d187..8ae6ff64e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -59,7 +59,7 @@ import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; import type { ValidateOptions, ValidateResult } from '../actions/validate'; -import type { IWatcher, WatchFileOptions, WatchOptions, WatchValidateOptions } from '../actions/watch'; +import type { IWatcher, WatchFileOptions, WatchOptions, WatchSynthOptions, WatchValidateOptions } from '../actions/watch'; import { countAssemblyResults } from './private/count-assembly-results'; import { WATCH_EXCLUDE_DEFAULTS } from '../actions/watch/private'; import { EnvironmentAccess } from '../api'; @@ -353,9 +353,20 @@ export class Toolkit extends CloudAssemblySourceBuilder { const ioHelper = asIoHelper(this.ioHost, 'synth'); await using assembly = new AsyncDisposableBox(await synthAndMeasure(ioHelper, cx, stacksOpt(options))); - const stacks = await assembly.value.selectStacksV2(stacksOpt(options)); - const autoValidateStacks = options.validateStacks ? [assembly.value.selectStacksForValidation()] : []; - await throwIfValidationFailures(assembly.value, stacks.concat(...autoValidateStacks), this.assemblyFailureAt, ioHelper); + await this._synth(assembly.value, options); + return new CachedCloudAssembly(assembly.take()); + } + + /** + * Helper to allow synth being called with an already-produced assembly, + * e.g. as part of the watch action which reuses the startup assembly. + */ + private async _synth(assembly: StackAssembly, options: SynthOptions = {}): Promise { + const ioHelper = asIoHelper(this.ioHost, 'synth'); + + const stacks = await assembly.selectStacksV2(stacksOpt(options)); + const autoValidateStacks = options.validateStacks ? [assembly.selectStacksForValidation()] : []; + await throwIfValidationFailures(assembly, stacks.concat(...autoValidateStacks), this.assemblyFailureAt, ioHelper); // if we have a single stack, print it to STDOUT const message = `Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`; @@ -384,8 +395,6 @@ export class Toolkit extends CloudAssemblySourceBuilder { await ioHelper.notify(IO.CDK_TOOLKIT_I1902.msg(chalk.green(message), assemblyData)); await ioHelper.defaults.info(`Supply a stack id (${stacks.stackArtifacts.map((s) => chalk.green(s.hierarchicalId)).join(', ')}) to display its template.`); } - - return new CachedCloudAssembly(assembly.take()); } /** @@ -1191,6 +1200,35 @@ export class Toolkit extends CloudAssemblySourceBuilder { }); } + /** + * Continuously observe project files and re-synthesize the selected stacks + * automatically when changes are detected. + * + * Never deploys: each iteration re-synthesizes the app to the cloud assembly + * output directory and runs the same checks as the `synth` action. + * + * This function returns immediately, starting a watcher in the background. + */ + public async watchSynth(cx: ICloudAssemblySource, options: WatchSynthOptions = {}): Promise { + const ioHelper = asIoHelper(this.ioHost, 'synth'); + + return this._watch(cx, options, { + command: 'cdk synth', + activity: 'synthesis', + // `_watch` runs this inside `invokeSafe`, which reports (and swallows) + // failures so the loop survives synth errors while the user is mid-edit. + invoke: async (initialAssembly) => { + // Reuse the initial assembly, for the same reason as watchDeploy() + if (initialAssembly) { + await this._synth(initialAssembly, options); + return; + } + await using assembly = await synthAndMeasure(ioHelper, cx, stacksOpt(options)); + await this._synth(assembly, options); + }, + }); + } + /** * Continuously observe project files and validate the selected stacks * automatically when changes are detected. diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/watch-synth.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/watch-synth.test.ts new file mode 100644 index 000000000..99052eba8 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/watch-synth.test.ts @@ -0,0 +1,223 @@ +// We need to mock the chokidar library, used by 'cdk watch' +// This needs to happen ABOVE the import statements because +// jest.mock commands are hoisted just below the import statements, +// which would otherwise bring in the real chokidar. +const mockChokidarWatcherOn = jest.fn(); +const mockChokidarWatcherClose = jest.fn(); +const fakeChokidarWatcher = { + on: mockChokidarWatcherOn, + close: mockChokidarWatcherClose, +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +} satisfies Partial>; +const fakeChokidarWatcherOn = { + get readyCallback(): () => Promise { + expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(1); + const firstCall = mockChokidarWatcherOn.mock.calls[0]; + expect(firstCall[0]).toBe('ready'); + return firstCall[1]; + }, + + get fileEventCallback(): ( + event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir', + path: string, + ) => Promise { + expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(2); + const secondCall = mockChokidarWatcherOn.mock.calls[1]; + expect(secondCall[0]).not.toBe('ready'); + return secondCall[1]; + }, +}; + +const mockChokidarWatch = jest.fn(); +jest.mock('chokidar', () => ({ + watch: mockChokidarWatch, +})); + +import { StackSelectionStrategy } from '../../lib/api/cloud-assembly'; +import { Toolkit } from '../../lib/toolkit'; +import { builderFixture, TestIoHost } from '../_helpers'; + +const ioHost = new TestIoHost(); +const toolkit = new Toolkit({ ioHost }); +// watchSynth reuses the assembly produced by the watch loop and calls the +// private `_synth(assembly, options)` directly, so we spy on that. +const synthSpy = jest.spyOn(toolkit as any, '_synth').mockResolvedValue(undefined); +const deploySpy = jest.spyOn(toolkit as any, '_deploy').mockResolvedValue({}); + +beforeEach(() => { + ioHost.notifySpy.mockClear(); + ioHost.requestSpy.mockClear(); + jest.clearAllMocks(); + + mockChokidarWatch.mockReturnValue(fakeChokidarWatcher); + // on() in chokidar's Watcher returns 'this' + mockChokidarWatcherOn.mockReturnValue(fakeChokidarWatcher); +}); + +describe('watchSynth', () => { + test('runs an initial synthesis on the ready event', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + await toolkit.watchSynth(cx, { include: [] }); + + // WHEN + await fakeChokidarWatcherOn.readyCallback(); + + // THEN + expect(synthSpy).toHaveBeenCalledTimes(1); + }); + + test('synthesizes again on a file change', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + await toolkit.watchSynth(cx, { include: [] }); + await fakeChokidarWatcherOn.readyCallback(); + + // WHEN + await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts'); + + // THEN + expect(synthSpy).toHaveBeenCalledTimes( + 1 // from ready event + + 1, // from file event + ); + }); + + test('never deploys', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + await toolkit.watchSynth(cx, { include: [] }); + + // WHEN + await fakeChokidarWatcherOn.readyCallback(); + await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts'); + + // THEN + expect(deploySpy).not.toHaveBeenCalled(); + }); + + test('passes synth options through to each invocation', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + const options = { + include: [], + validateStacks: true, + stacks: { patterns: ['Stack1'], strategy: StackSelectionStrategy.PATTERN_MATCH }, + }; + await toolkit.watchSynth(cx, options); + + // WHEN + await fakeChokidarWatcherOn.readyCallback(); + + // THEN + // match anything but null or undefined, since we are only testing for options here + expect(synthSpy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + validateStacks: true, + stacks: { patterns: ['Stack1'], strategy: StackSelectionStrategy.PATTERN_MATCH }, + })); + }); + + test('keeps watching after a synthesis error', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + synthSpy.mockRejectedValueOnce(new Error('synth failed mid-edit')); + await toolkit.watchSynth(cx, { include: [] }); + + // WHEN: the initial synthesis fails ... + await fakeChokidarWatcherOn.readyCallback(); + + // THEN: the error is reported ... + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + level: 'error', + message: expect.stringContaining('synth failed mid-edit'), + })); + + // ... and the loop is still alive: the next file change synthesizes again + await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts'); + expect(synthSpy).toHaveBeenCalledTimes(2); + }); + + test('batches file changes that arrive during a synthesis', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + await toolkit.watchSynth(cx, { include: [] }); + await fakeChokidarWatcherOn.readyCallback(); + + // Simulate a slow synthesis so subsequent changes queue up. `started` + // resolves once the synthesis is actually running (each iteration first + // produces the assembly, so we cannot rely on a fixed delay), and + // `resolveSynth` lets us release it on demand. + let resolveSynth!: () => void; + let signalStarted!: () => void; + const started = new Promise((resolve) => { + signalStarted = resolve; + }); + synthSpy.mockImplementationOnce(() => new Promise((resolve) => { + resolveSynth = resolve; + signalStarted(); + })); + + // WHEN: a file change starts the slow synthesis ... + const firstEvent = fakeChokidarWatcherOn.fileEventCallback('change', 'file1.ts'); + await started; + + // ... and more changes arrive while it is still running + const queuedEvents = [ + fakeChokidarWatcherOn.fileEventCallback('change', 'file2.ts'), + fakeChokidarWatcherOn.fileEventCallback('unlink', 'file3.ts'), + ]; + + resolveSynth(); + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + await Promise.all([firstEvent, ...queuedEvents]); + + // THEN: the queued changes are batched into a single follow-up synthesis + expect(synthSpy).toHaveBeenCalledTimes( + 1 // from ready event + + 1 // from the first file event + + 1, // from the batched queued events + ); + }); + + test('returns a watcher that can be disposed', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + const watcher = await toolkit.watchSynth(cx, { include: [] }); + + expect(mockChokidarWatcherClose).not.toHaveBeenCalled(); + + // WHEN + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + await Promise.all([ + watcher.waitForEnd(), + watcher.dispose(), + ]); + + // THEN + expect(mockChokidarWatcherClose).toHaveBeenCalled(); + }); + + test('reuses the startup assembly for the initial synthesis, re-produces on file changes', async () => { + // GIVEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + const produceSpy = jest.spyOn(cx, 'produce'); + + await toolkit.watchSynth(cx, { include: [] }); + + // WHEN: initial synthesis (ready event) ... + await fakeChokidarWatcherOn.readyCallback(); + const producesAfterReady = produceSpy.mock.calls.length; + + // ... then two file-change iterations + await fakeChokidarWatcherOn.fileEventCallback('change', 'app.ts'); + await fakeChokidarWatcherOn.fileEventCallback('change', 'lib.ts'); + const producesTotal = produceSpy.mock.calls.length; + + // THEN: the initial synthesis reuses the assembly produced at watch startup + // (fresh by definition), so no extra synth happens for it; every file-change + // iteration re-produces so the changes are picked up. + expect(synthSpy).toHaveBeenCalledTimes(3); + expect(producesAfterReady).toBe(1); // startup produce, reused by the initial synthesis + expect(producesTotal).toBe(3); // startup + one per file-change iteration + }); +}); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 04bca0bf9..13b017ea1 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -4,7 +4,7 @@ import { format } from 'node:util'; import type { IManifestEntry } from '@aws-cdk/cdk-assets-lib'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; -import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, StackSelector, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; +import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, StackSelector, SynthOptions as ToolkitSynthOptions, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; import { PermissionChangeType, Toolkit, ToolkitError, AbortError } from '@aws-cdk/toolkit-lib'; import chalk from 'chalk'; import * as chokidar from 'chokidar'; @@ -1130,6 +1130,46 @@ export class CdkToolkit { return undefined; } + /** + * Continuously synthesize the project, re-synthesizing on every file change. + * Never deploys, and never prints templates to stdout. + * + * The files to observe are configured with the "watch" key of `cdk.json`, + * exactly like `cdk deploy --watch`. + */ + public async synthWatch(options: ToolkitSynthOptions): Promise { + const rootDir = path.dirname(path.resolve(PROJECT_CONFIG)); + + const watchSettings: { include?: string | string[]; exclude?: string | string[] } | undefined = + this.props.configuration.settings.get(['watch']); + if (!watchSettings) { + throw new ToolkitError( + 'WatchConfigMissing', + "Cannot use '--watch' without specifying at least one directory to monitor. " + + 'Make sure to add a "watch" key to your cdk.json', + ); + } + + const include = this.patternsArrayForWatch(watchSettings.include, { + defaultPattern: '**', + returnDefaultIfEmpty: true, + }); + // Pass the user's excludes explicitly; toolkit-lib always appends the + // outdir, dot-file, and node_modules excludes on top of these. + const exclude = this.patternsArrayForWatch(watchSettings.exclude, { + defaultPattern: '', + returnDefaultIfEmpty: false, + }); + + const watcher = await this.toolkit.watchSynth(this.props.cloudExecutable.uncachedSource(), { + ...options, + watchDir: rootDir, + include, + exclude, + }); + return watcher.waitForEnd(); + } + /** * Bootstrap the CDK Toolkit stack in the accounts used by the specified stack(s). * diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index a071e003f..fdd54993c 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -75,6 +75,12 @@ export async function makeConfig(): Promise { exclusively: { type: 'boolean', alias: 'e', desc: 'Only synthesize requested stacks, don\'t include dependencies' }, validation: { type: 'boolean', desc: 'After synthesis, validate stacks with the "validateOnSynth" attribute set (can also be controlled with CDK_VALIDATION)', default: true }, quiet: { type: 'boolean', alias: 'q', desc: 'Do not output CloudFormation Template to stdout', default: false }, + watch: { + type: 'boolean', + desc: 'Continuously observe the project files, ' + + 'and synthesize the given stack(s) automatically when changes are detected. ' + + 'Never deploys, and never prints templates to stdout (--quiet and --json have no effect)', + }, }, }, 'bootstrap': { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 9d7194d9f..772c379de 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -208,6 +208,10 @@ "alias": "q", "desc": "Do not output CloudFormation Template to stdout", "default": false + }, + "watch": { + "type": "boolean", + "desc": "Continuously observe the project files, and synthesize the given stack(s) automatically when changes are detected. Never deploys, and never prints templates to stdout (--quiet and --json have no effect)" } } }, diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 75c0dca23..aac2baa1c 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -601,6 +601,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { type: 'boolean', alias: 'q', desc: 'Do not output CloudFormation Template to stdout', + }) + .option('watch', { + default: undefined, + type: 'boolean', + desc: 'Continuously observe the project files, and synthesize the given stack(s) automatically when changes are detected. Never deploys, and never prints templates to stdout (--quiet and --json have no effect)', }), ) .command('bootstrap [ENVIRONMENTS..]', 'Deploys the CDK toolkit stack into an AWS environment', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 688635f5b..779c752b3 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -454,6 +454,13 @@ export interface SynthOptions { */ readonly quiet?: boolean; + /** + * Continuously observe the project files, and synthesize the given stack(s) automatically when changes are detected. Never deploys, and never prints templates to stdout (--quiet and --json have no effect) + * + * @default - undefined + */ + readonly watch?: boolean; + /** * Positional argument for synth */ diff --git a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts index 9159bf07e..cdfb61895 100644 --- a/packages/aws-cdk/test/cli/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cli/cdk-toolkit.test.ts @@ -2402,6 +2402,92 @@ describe('validate --watch', () => { }); }); +describe('synth --watch', () => { + let watchSynthSpy: jest.SpyInstance; + const fakeWatcher = { + dispose: jest.fn().mockResolvedValue(undefined), + waitForEnd: jest.fn().mockResolvedValue(undefined), + [Symbol.asyncDispose]: jest.fn().mockResolvedValue(undefined), + }; + + beforeEach(() => { + watchSynthSpy = jest.spyOn(Toolkit.prototype, 'watchSynth').mockResolvedValue(fakeWatcher); + }); + + test("fails when no 'watch' settings are found", async () => { + const toolkit = defaultToolkitSetup(); + + await expect(() => { + return toolkit.synthWatch({ + stacks: { patterns: [], strategy: StackSelectionStrategy.ALL_STACKS }, + }); + }).rejects.toThrow( + "Cannot use '--watch' without specifying at least one directory to monitor. " + + 'Make sure to add a "watch" key to your cdk.json', + ); + + expect(watchSynthSpy).not.toHaveBeenCalled(); + }); + + test('delegates to toolkit-lib watchSynth with the synth options', async () => { + cloudExecutable.configuration.settings.set(['watch'], {}); + const toolkit = defaultToolkitSetup(); + + await toolkit.synthWatch({ + stacks: { patterns: ['Test-Stack-A-Display-Name'], strategy: StackSelectionStrategy.PATTERN_MATCH }, + validateStacks: true, + }); + + expect(watchSynthSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + stacks: { patterns: ['Test-Stack-A-Display-Name'], strategy: StackSelectionStrategy.PATTERN_MATCH }, + validateStacks: true, + include: ['**'], + exclude: [], + }), + ); + expect(fakeWatcher.waitForEnd).toHaveBeenCalled(); + }); + + test("passes the 'watch' include and exclude settings from cdk.json", async () => { + cloudExecutable.configuration.settings.set(['watch'], { + include: ['lib/**'], + exclude: ['lib/generated/**'], + }); + const toolkit = defaultToolkitSetup(); + + await toolkit.synthWatch({ + stacks: { patterns: [], strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + expect(watchSynthSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + include: ['lib/**'], + exclude: ['lib/generated/**'], + }), + ); + }); + + test('passes an uncached assembly source so every iteration re-synthesizes', async () => { + cloudExecutable.configuration.settings.set(['watch'], {}); + const toolkit = defaultToolkitSetup(); + + await toolkit.synthWatch({ + stacks: { patterns: [], strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + // The source handed to watchSynth must re-synthesize on every produce(). + const source = watchSynthSpy.mock.calls[0][0]; + const synthesizeSpy = jest.spyOn(cloudExecutable, 'synthesize'); + await source.produce(); + await source.produce(); + expect(synthesizeSpy).toHaveBeenCalledTimes(2); + expect(synthesizeSpy).toHaveBeenCalledWith(false); + }); +}); + describe('synth', () => { test('successful synth outputs hierarchical stack ids', async () => { const toolkit = defaultToolkitSetup();