Skip to content

Commit

Permalink
test(toolkit): watch tests (#33040)
Browse files Browse the repository at this point in the history
tests for watch

Closes #32942 

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
kaizencc authored Jan 27, 2025
1 parent fb7e557 commit 5755b48
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 28 deletions.
16 changes: 16 additions & 0 deletions packages/@aws-cdk/toolkit/lib/actions/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ export interface BaseDeployOptions {
readonly concurrency?: number;
}

/**
* Deploy options needed by the watch command.
* Intentionally not exported because these options are not
* meant to be public facing.
*
* @internal
*/
export interface ExtendedDeployOptions extends DeployOptions {
/**
* The extra string to append to the User-Agent header when performing AWS SDK calls.
*
* @default - nothing extra is appended to the User-Agent header
*/
readonly extraUserAgent?: string;
}

export interface DeployOptions extends BaseDeployOptions {
/**
* ARNs of SNS topics that CloudFormation will notify with stack related events
Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/toolkit/lib/actions/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface WatchOptions extends BaseDeployOptions {
* The output directory to write CloudFormation template to
*
* @deprecated this should be grabbed from the cloud assembly itself
*
* @default 'cdk.out'
*/
readonly outdir?: string;
}
Expand Down
58 changes: 36 additions & 22 deletions packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as chalk from 'chalk';
import * as chokidar from 'chokidar';
import * as fs from 'fs-extra';
import { ToolkitServices } from './private';
import { AssetBuildTime, DeployOptions, RequireApproval } from '../actions/deploy';
import { AssetBuildTime, DeployOptions, ExtendedDeployOptions, RequireApproval } from '../actions/deploy';
import { buildParameterMap, removePublishedAssets } from '../actions/deploy/private';
import { DestroyOptions } from '../actions/destroy';
import { DiffOptions } from '../actions/diff';
Expand Down Expand Up @@ -200,9 +200,16 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
* Deploys the selected stacks into an AWS account
*/
public async deploy(cx: ICloudAssemblySource, options: DeployOptions = {}): Promise<void> {
const ioHost = withAction(this.ioHost, 'deploy');
const timer = Timer.start();
const assembly = await this.assemblyFromSource(cx);
return this._deploy(assembly, 'deploy', options);
}

/**
* Helper to allow deploy being called as part of the watch action.
*/
private async _deploy(assembly: StackAssembly, action: 'deploy' | 'watch', options: ExtendedDeployOptions = {}) {
const ioHost = withAction(this.ioHost, action);
const timer = Timer.start();
const stackCollection = assembly.selectStacksV2(options.stacks ?? ALL_STACKS);
await this.validateStacksMetadata(stackCollection, ioHost);

Expand Down Expand Up @@ -361,8 +368,8 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
ci: options.ci,
rollback,
hotswap: options.hotswap,
extraUserAgent: options.extraUserAgent,
// hotswapPropertyOverrides: hotswapPropertyOverrides,

assetParallelism: options.assetParallelism,
});

Expand All @@ -386,7 +393,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
}

// Perform a rollback
await this.rollback(cx, {
await this._rollback(assembly, action, {
stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE },
orphanFailedResources: options.force,
});
Expand Down Expand Up @@ -511,6 +518,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
* Implies hotswap deployments.
*/
public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<void> {
const assembly = await this.assemblyFromSource(cx, false);
const ioHost = withAction(this.ioHost, 'watch');
const rootDir = options.watchDir ?? process.cwd();
await ioHost.notify(debug(`root directory used for 'watch' is: ${rootDir}`));
Expand All @@ -531,19 +539,20 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
rootDir,
returnRootDirIfEmpty: true,
});
await ioHost.notify(debug(`'include' patterns for 'watch': ${watchIncludes}`));
await ioHost.notify(debug(`'include' patterns for 'watch': ${JSON.stringify(watchIncludes)}`));

// For the "exclude" subkey under the "watch" key,
// the behavior is to add some default excludes in addition to the ones specified by the user:
// 1. The CDK output directory.
// 2. Any file whose name starts with a dot.
// 3. Any directory's content whose name starts with a dot.
// 4. Any node_modules and its content (even if it's not a JS/TS project, you might be using a local aws-cli package)
const outdir = options.outdir ?? 'cdk.out';
const watchExcludes = patternsArrayForWatch(options.exclude, {
rootDir,
returnRootDirIfEmpty: false,
}).concat(`${options.outdir}/**`, '**/.*', '**/.*/**', '**/node_modules/**');
await ioHost.notify(debug(`'exclude' patterns for 'watch': ${watchExcludes}`));
}).concat(`${outdir}/**`, '**/.*', '**/.*/**', '**/node_modules/**');
await ioHost.notify(debug(`'exclude' patterns for 'watch': ${JSON.stringify(watchExcludes)}`));

// Since 'cdk deploy' is a relatively slow operation for a 'watch' process,
// introduce a concurrency latch that tracks the state.
Expand All @@ -564,7 +573,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
latch = 'deploying';
// cloudWatchLogMonitor?.deactivate();

await this.invokeDeployFromWatch(cx, options);
await this.invokeDeployFromWatch(assembly, options);

// If latch is still 'deploying' after the 'await', that's fine,
// but if it's 'queued', that means we need to deploy again
Expand All @@ -573,7 +582,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
// and thinks the above 'while' condition is always 'false' without the cast
latch = 'deploying';
await ioHost.notify(info("Detected file changes during deployment. Invoking 'cdk deploy' again"));
await this.invokeDeployFromWatch(cx, options);
await this.invokeDeployFromWatch(assembly, options);
}
latch = 'open';
// cloudWatchLogMonitor?.activate();
Expand All @@ -583,7 +592,6 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
.watch(watchIncludes, {
ignored: watchExcludes,
cwd: rootDir,
// ignoreInitial: true,
})
.on('ready', async () => {
latch = 'open';
Expand Down Expand Up @@ -613,9 +621,16 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
* Rolls back the selected stacks.
*/
public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise<void> {
const ioHost = withAction(this.ioHost, 'rollback');
const timer = Timer.start();
const assembly = await this.assemblyFromSource(cx);
return this._rollback(assembly, 'rollback', options);
}

/**
* Helper to allow rollback being called as part of the deploy or watch action.
*/
private async _rollback(assembly: StackAssembly, action: 'rollback' | 'deploy' | 'watch', options: RollbackOptions): Promise<void> {
const ioHost = withAction(this.ioHost, action);
const timer = Timer.start();
const stacks = assembly.selectStacksV2(options.stacks);
await this.validateStacksMetadata(stacks, ioHost);
const synthTime = timer.end();
Expand Down Expand Up @@ -751,25 +766,24 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
}

private async invokeDeployFromWatch(
cx: ICloudAssemblySource,
assembly: StackAssembly,
options: WatchOptions,
): Promise<void> {
const deployOptions: DeployOptions = {
const deployOptions: ExtendedDeployOptions = {
...options,
requireApproval: RequireApproval.NEVER,
// if 'watch' is called by invoking 'cdk deploy --watch',
// we need to make sure to not call 'deploy' with 'watch' again,
// as that would lead to a cycle
// watch: false,
// cloudWatchLogMonitor,
// cacheCloudAssembly: false,
hotswap: options.hotswap,
// extraUserAgent: `cdk-watch/hotswap-${options.hotswap !== HotswapMode.FALL_BACK ? 'on' : 'off'}`,
extraUserAgent: `cdk-watch/hotswap-${options.hotswap !== HotswapMode.FALL_BACK ? 'on' : 'off'}`,
concurrency: options.concurrency,
};

try {
await this.deploy(cx, deployOptions);
await this._deploy(
assembly,
'watch',
deployOptions,
);
} catch {
// just continue - deploy will show the error
}
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/toolkit/test/actions/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { builderFixture, TestIoHost } from '../_helpers';

const ioHost = new TestIoHost();
const toolkit = new Toolkit({ ioHost });
jest.spyOn(toolkit, 'rollback').mockResolvedValue();
const rollbackSpy = jest.spyOn(toolkit as any, '_rollback').mockResolvedValue({});

let mockDeployStack = jest.fn().mockResolvedValue({
type: 'did-deploy-stack',
Expand Down Expand Up @@ -173,7 +173,7 @@ describe('deploy', () => {

// THEN
// We called rollback
expect(toolkit.rollback).toHaveBeenCalledTimes(1);
expect(rollbackSpy).toHaveBeenCalledTimes(1);
successfulDeployment();
});

Expand Down
3 changes: 0 additions & 3 deletions packages/@aws-cdk/toolkit/test/actions/destroy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ jest.mock('../../lib/api/aws-cdk', () => {
...jest.requireActual('../../lib/api/aws-cdk'),
Deployments: jest.fn().mockImplementation(() => ({
destroyStack: mockDestroyStack,
// resolveEnvironment: jest.fn().mockResolvedValue({}),
// isSingleAssetPublished: jest.fn().mockResolvedValue(true),
// readCurrentTemplate: jest.fn().mockResolvedValue({ Resources: {} }),
})),
};
});
Expand Down
152 changes: 152 additions & 0 deletions packages/@aws-cdk/toolkit/test/actions/watch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// We need to mock the chokidar library, used by 'cdk watch'
// This needs to happen ABOVE the import statements due to quirks with how jest works
// Apparently, they hoist jest.mock commands just below the import statements so we
// need to make sure that the constants they access are initialized before the imports.
const mockChokidarWatcherOn = jest.fn();
const fakeChokidarWatcher = {
on: mockChokidarWatcherOn,
};
const fakeChokidarWatcherOn = {
get readyCallback(): () => Promise<void> {
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(1);
// The call to the first 'watcher.on()' in the production code is the one we actually want here.
// This is a pretty fragile, but at least with this helper class,
// we would have to change it only in one place if it ever breaks
const firstCall = mockChokidarWatcherOn.mock.calls[0];
// let's make sure the first argument is the 'ready' event,
// just to be double safe
expect(firstCall[0]).toBe('ready');
// the second argument is the callback
return firstCall[1];
},

get fileEventCallback(): (
event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir',
path: string,
) => Promise<void> {
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(2);
const secondCall = mockChokidarWatcherOn.mock.calls[1];
// let's make sure the first argument is not the 'ready' event,
// just to be double safe
expect(secondCall[0]).not.toBe('ready');
// the second argument is the callback
return secondCall[1];
},
};

const mockChokidarWatch = jest.fn();
jest.mock('chokidar', () => ({
watch: mockChokidarWatch,
}));

import { HotswapMode } from '../../lib';
import { Toolkit } from '../../lib/toolkit';
import { builderFixture, TestIoHost } from '../_helpers';

const ioHost = new TestIoHost();
const toolkit = new Toolkit({ ioHost });
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('watch', () => {
test('no include & no exclude results in error', async () => {
// WHEN
const cx = await builderFixture(toolkit, 'stack-with-role');
await expect(async () => toolkit.watch(cx, {})).rejects.toThrow(/Cannot use the 'watch' command without specifying at least one directory to monitor. Make sure to add a \"watch\" key to your cdk.json/);
});

test('observes cwd as default rootdir', async () => {
// WHEN
const cx = await builderFixture(toolkit, 'stack-with-role');
ioHost.level = 'debug';
await toolkit.watch(cx, {
include: [],
});

// THEN
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'watch',
level: 'debug',
message: expect.stringContaining(`root directory used for 'watch' is: ${process.cwd()}`),
}));
});

test('ignores output dir, dot files, dot directories, node_modules by default', async () => {
// WHEN
const cx = await builderFixture(toolkit, 'stack-with-role');
ioHost.level = 'debug';
await toolkit.watch(cx, {
exclude: [],
});

// THEN
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'watch',
level: 'debug',
message: expect.stringContaining('\'exclude\' patterns for \'watch\': ["cdk.out/**","**/.*","**/.*/**","**/node_modules/**"]'),
}));
});

test('can include specific files', async () => {
// WHEN
const cx = await builderFixture(toolkit, 'stack-with-role');
ioHost.level = 'debug';
await toolkit.watch(cx, {
include: ['index.ts'],
});

// THEN
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'watch',
level: 'debug',
message: expect.stringContaining('\'include\' patterns for \'watch\': ["index.ts"]'),
}));
});

test('can exclude specific files', async () => {
// WHEN
const cx = await builderFixture(toolkit, 'stack-with-role');
ioHost.level = 'debug';
await toolkit.watch(cx, {
exclude: ['index.ts'],
});

// THEN
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'watch',
level: 'debug',
message: expect.stringContaining('\'exclude\' patterns for \'watch\': ["index.ts"'),
}));
});

describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hotswapMode) => {
test('passes through the correct hotswap mode to deployStack()', async () => {
// WHEN
const cx = await builderFixture(toolkit, 'stack-with-role');
ioHost.level = 'warn';
await toolkit.watch(cx, {
include: [],
hotswap: hotswapMode,
});

await fakeChokidarWatcherOn.readyCallback();

// THEN
expect(deploySpy).toHaveBeenCalledWith(expect.anything(), 'watch', expect.objectContaining({
hotswap: hotswapMode,
extraUserAgent: `cdk-watch/hotswap-${hotswapMode !== HotswapMode.FALL_BACK ? 'on' : 'off'}`,
}));
});
});
});

// @todo unit test watch with file events
1 change: 0 additions & 1 deletion packages/aws-cdk/lib/cli/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,6 @@ export class CdkToolkit {
.watch(watchIncludes, {
ignored: watchExcludes,
cwd: rootDir,
// ignoreInitial: true,
})
.on('ready', async () => {
latch = 'open';
Expand Down

0 comments on commit 5755b48

Please sign in to comment.