Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test(toolkit): watch tests #33040

Merged
merged 7 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading