Skip to content

Commit 5755b48

Browse files
authored
test(toolkit): watch tests (#33040)
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*
1 parent fb7e557 commit 5755b48

File tree

7 files changed

+208
-28
lines changed

7 files changed

+208
-28
lines changed

packages/@aws-cdk/toolkit/lib/actions/deploy/index.ts

+16
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,22 @@ export interface BaseDeployOptions {
175175
readonly concurrency?: number;
176176
}
177177

178+
/**
179+
* Deploy options needed by the watch command.
180+
* Intentionally not exported because these options are not
181+
* meant to be public facing.
182+
*
183+
* @internal
184+
*/
185+
export interface ExtendedDeployOptions extends DeployOptions {
186+
/**
187+
* The extra string to append to the User-Agent header when performing AWS SDK calls.
188+
*
189+
* @default - nothing extra is appended to the User-Agent header
190+
*/
191+
readonly extraUserAgent?: string;
192+
}
193+
178194
export interface DeployOptions extends BaseDeployOptions {
179195
/**
180196
* ARNs of SNS topics that CloudFormation will notify with stack related events

packages/@aws-cdk/toolkit/lib/actions/watch/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface WatchOptions extends BaseDeployOptions {
4141
* The output directory to write CloudFormation template to
4242
*
4343
* @deprecated this should be grabbed from the cloud assembly itself
44+
*
45+
* @default 'cdk.out'
4446
*/
4547
readonly outdir?: string;
4648
}

packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts

+36-22
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as chalk from 'chalk';
44
import * as chokidar from 'chokidar';
55
import * as fs from 'fs-extra';
66
import { ToolkitServices } from './private';
7-
import { AssetBuildTime, DeployOptions, RequireApproval } from '../actions/deploy';
7+
import { AssetBuildTime, DeployOptions, ExtendedDeployOptions, RequireApproval } from '../actions/deploy';
88
import { buildParameterMap, removePublishedAssets } from '../actions/deploy/private';
99
import { DestroyOptions } from '../actions/destroy';
1010
import { DiffOptions } from '../actions/diff';
@@ -200,9 +200,16 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
200200
* Deploys the selected stacks into an AWS account
201201
*/
202202
public async deploy(cx: ICloudAssemblySource, options: DeployOptions = {}): Promise<void> {
203-
const ioHost = withAction(this.ioHost, 'deploy');
204-
const timer = Timer.start();
205203
const assembly = await this.assemblyFromSource(cx);
204+
return this._deploy(assembly, 'deploy', options);
205+
}
206+
207+
/**
208+
* Helper to allow deploy being called as part of the watch action.
209+
*/
210+
private async _deploy(assembly: StackAssembly, action: 'deploy' | 'watch', options: ExtendedDeployOptions = {}) {
211+
const ioHost = withAction(this.ioHost, action);
212+
const timer = Timer.start();
206213
const stackCollection = assembly.selectStacksV2(options.stacks ?? ALL_STACKS);
207214
await this.validateStacksMetadata(stackCollection, ioHost);
208215

@@ -361,8 +368,8 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
361368
ci: options.ci,
362369
rollback,
363370
hotswap: options.hotswap,
371+
extraUserAgent: options.extraUserAgent,
364372
// hotswapPropertyOverrides: hotswapPropertyOverrides,
365-
366373
assetParallelism: options.assetParallelism,
367374
});
368375

@@ -386,7 +393,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
386393
}
387394

388395
// Perform a rollback
389-
await this.rollback(cx, {
396+
await this._rollback(assembly, action, {
390397
stacks: { patterns: [stack.hierarchicalId], strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE },
391398
orphanFailedResources: options.force,
392399
});
@@ -511,6 +518,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
511518
* Implies hotswap deployments.
512519
*/
513520
public async watch(cx: ICloudAssemblySource, options: WatchOptions): Promise<void> {
521+
const assembly = await this.assemblyFromSource(cx, false);
514522
const ioHost = withAction(this.ioHost, 'watch');
515523
const rootDir = options.watchDir ?? process.cwd();
516524
await ioHost.notify(debug(`root directory used for 'watch' is: ${rootDir}`));
@@ -531,19 +539,20 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
531539
rootDir,
532540
returnRootDirIfEmpty: true,
533541
});
534-
await ioHost.notify(debug(`'include' patterns for 'watch': ${watchIncludes}`));
542+
await ioHost.notify(debug(`'include' patterns for 'watch': ${JSON.stringify(watchIncludes)}`));
535543

536544
// For the "exclude" subkey under the "watch" key,
537545
// the behavior is to add some default excludes in addition to the ones specified by the user:
538546
// 1. The CDK output directory.
539547
// 2. Any file whose name starts with a dot.
540548
// 3. Any directory's content whose name starts with a dot.
541549
// 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)
550+
const outdir = options.outdir ?? 'cdk.out';
542551
const watchExcludes = patternsArrayForWatch(options.exclude, {
543552
rootDir,
544553
returnRootDirIfEmpty: false,
545-
}).concat(`${options.outdir}/**`, '**/.*', '**/.*/**', '**/node_modules/**');
546-
await ioHost.notify(debug(`'exclude' patterns for 'watch': ${watchExcludes}`));
554+
}).concat(`${outdir}/**`, '**/.*', '**/.*/**', '**/node_modules/**');
555+
await ioHost.notify(debug(`'exclude' patterns for 'watch': ${JSON.stringify(watchExcludes)}`));
547556

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

567-
await this.invokeDeployFromWatch(cx, options);
576+
await this.invokeDeployFromWatch(assembly, options);
568577

569578
// If latch is still 'deploying' after the 'await', that's fine,
570579
// but if it's 'queued', that means we need to deploy again
@@ -573,7 +582,7 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
573582
// and thinks the above 'while' condition is always 'false' without the cast
574583
latch = 'deploying';
575584
await ioHost.notify(info("Detected file changes during deployment. Invoking 'cdk deploy' again"));
576-
await this.invokeDeployFromWatch(cx, options);
585+
await this.invokeDeployFromWatch(assembly, options);
577586
}
578587
latch = 'open';
579588
// cloudWatchLogMonitor?.activate();
@@ -583,7 +592,6 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
583592
.watch(watchIncludes, {
584593
ignored: watchExcludes,
585594
cwd: rootDir,
586-
// ignoreInitial: true,
587595
})
588596
.on('ready', async () => {
589597
latch = 'open';
@@ -613,9 +621,16 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
613621
* Rolls back the selected stacks.
614622
*/
615623
public async rollback(cx: ICloudAssemblySource, options: RollbackOptions): Promise<void> {
616-
const ioHost = withAction(this.ioHost, 'rollback');
617-
const timer = Timer.start();
618624
const assembly = await this.assemblyFromSource(cx);
625+
return this._rollback(assembly, 'rollback', options);
626+
}
627+
628+
/**
629+
* Helper to allow rollback being called as part of the deploy or watch action.
630+
*/
631+
private async _rollback(assembly: StackAssembly, action: 'rollback' | 'deploy' | 'watch', options: RollbackOptions): Promise<void> {
632+
const ioHost = withAction(this.ioHost, action);
633+
const timer = Timer.start();
619634
const stacks = assembly.selectStacksV2(options.stacks);
620635
await this.validateStacksMetadata(stacks, ioHost);
621636
const synthTime = timer.end();
@@ -751,25 +766,24 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
751766
}
752767

753768
private async invokeDeployFromWatch(
754-
cx: ICloudAssemblySource,
769+
assembly: StackAssembly,
755770
options: WatchOptions,
756771
): Promise<void> {
757-
const deployOptions: DeployOptions = {
772+
const deployOptions: ExtendedDeployOptions = {
758773
...options,
759774
requireApproval: RequireApproval.NEVER,
760-
// if 'watch' is called by invoking 'cdk deploy --watch',
761-
// we need to make sure to not call 'deploy' with 'watch' again,
762-
// as that would lead to a cycle
763-
// watch: false,
764775
// cloudWatchLogMonitor,
765-
// cacheCloudAssembly: false,
766776
hotswap: options.hotswap,
767-
// extraUserAgent: `cdk-watch/hotswap-${options.hotswap !== HotswapMode.FALL_BACK ? 'on' : 'off'}`,
777+
extraUserAgent: `cdk-watch/hotswap-${options.hotswap !== HotswapMode.FALL_BACK ? 'on' : 'off'}`,
768778
concurrency: options.concurrency,
769779
};
770780

771781
try {
772-
await this.deploy(cx, deployOptions);
782+
await this._deploy(
783+
assembly,
784+
'watch',
785+
deployOptions,
786+
);
773787
} catch {
774788
// just continue - deploy will show the error
775789
}

packages/@aws-cdk/toolkit/test/actions/deploy.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { builderFixture, TestIoHost } from '../_helpers';
44

55
const ioHost = new TestIoHost();
66
const toolkit = new Toolkit({ ioHost });
7-
jest.spyOn(toolkit, 'rollback').mockResolvedValue();
7+
const rollbackSpy = jest.spyOn(toolkit as any, '_rollback').mockResolvedValue({});
88

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

174174
// THEN
175175
// We called rollback
176-
expect(toolkit.rollback).toHaveBeenCalledTimes(1);
176+
expect(rollbackSpy).toHaveBeenCalledTimes(1);
177177
successfulDeployment();
178178
});
179179

packages/@aws-cdk/toolkit/test/actions/destroy.test.ts

-3
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ jest.mock('../../lib/api/aws-cdk', () => {
1414
...jest.requireActual('../../lib/api/aws-cdk'),
1515
Deployments: jest.fn().mockImplementation(() => ({
1616
destroyStack: mockDestroyStack,
17-
// resolveEnvironment: jest.fn().mockResolvedValue({}),
18-
// isSingleAssetPublished: jest.fn().mockResolvedValue(true),
19-
// readCurrentTemplate: jest.fn().mockResolvedValue({ Resources: {} }),
2017
})),
2118
};
2219
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// We need to mock the chokidar library, used by 'cdk watch'
2+
// This needs to happen ABOVE the import statements due to quirks with how jest works
3+
// Apparently, they hoist jest.mock commands just below the import statements so we
4+
// need to make sure that the constants they access are initialized before the imports.
5+
const mockChokidarWatcherOn = jest.fn();
6+
const fakeChokidarWatcher = {
7+
on: mockChokidarWatcherOn,
8+
};
9+
const fakeChokidarWatcherOn = {
10+
get readyCallback(): () => Promise<void> {
11+
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(1);
12+
// The call to the first 'watcher.on()' in the production code is the one we actually want here.
13+
// This is a pretty fragile, but at least with this helper class,
14+
// we would have to change it only in one place if it ever breaks
15+
const firstCall = mockChokidarWatcherOn.mock.calls[0];
16+
// let's make sure the first argument is the 'ready' event,
17+
// just to be double safe
18+
expect(firstCall[0]).toBe('ready');
19+
// the second argument is the callback
20+
return firstCall[1];
21+
},
22+
23+
get fileEventCallback(): (
24+
event: 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir',
25+
path: string,
26+
) => Promise<void> {
27+
expect(mockChokidarWatcherOn.mock.calls.length).toBeGreaterThanOrEqual(2);
28+
const secondCall = mockChokidarWatcherOn.mock.calls[1];
29+
// let's make sure the first argument is not the 'ready' event,
30+
// just to be double safe
31+
expect(secondCall[0]).not.toBe('ready');
32+
// the second argument is the callback
33+
return secondCall[1];
34+
},
35+
};
36+
37+
const mockChokidarWatch = jest.fn();
38+
jest.mock('chokidar', () => ({
39+
watch: mockChokidarWatch,
40+
}));
41+
42+
import { HotswapMode } from '../../lib';
43+
import { Toolkit } from '../../lib/toolkit';
44+
import { builderFixture, TestIoHost } from '../_helpers';
45+
46+
const ioHost = new TestIoHost();
47+
const toolkit = new Toolkit({ ioHost });
48+
const deploySpy = jest.spyOn(toolkit as any, '_deploy').mockResolvedValue({});
49+
50+
beforeEach(() => {
51+
ioHost.notifySpy.mockClear();
52+
ioHost.requestSpy.mockClear();
53+
jest.clearAllMocks();
54+
55+
mockChokidarWatch.mockReturnValue(fakeChokidarWatcher);
56+
// on() in chokidar's Watcher returns 'this'
57+
mockChokidarWatcherOn.mockReturnValue(fakeChokidarWatcher);
58+
});
59+
60+
describe('watch', () => {
61+
test('no include & no exclude results in error', async () => {
62+
// WHEN
63+
const cx = await builderFixture(toolkit, 'stack-with-role');
64+
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/);
65+
});
66+
67+
test('observes cwd as default rootdir', async () => {
68+
// WHEN
69+
const cx = await builderFixture(toolkit, 'stack-with-role');
70+
ioHost.level = 'debug';
71+
await toolkit.watch(cx, {
72+
include: [],
73+
});
74+
75+
// THEN
76+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
77+
action: 'watch',
78+
level: 'debug',
79+
message: expect.stringContaining(`root directory used for 'watch' is: ${process.cwd()}`),
80+
}));
81+
});
82+
83+
test('ignores output dir, dot files, dot directories, node_modules by default', async () => {
84+
// WHEN
85+
const cx = await builderFixture(toolkit, 'stack-with-role');
86+
ioHost.level = 'debug';
87+
await toolkit.watch(cx, {
88+
exclude: [],
89+
});
90+
91+
// THEN
92+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
93+
action: 'watch',
94+
level: 'debug',
95+
message: expect.stringContaining('\'exclude\' patterns for \'watch\': ["cdk.out/**","**/.*","**/.*/**","**/node_modules/**"]'),
96+
}));
97+
});
98+
99+
test('can include specific files', async () => {
100+
// WHEN
101+
const cx = await builderFixture(toolkit, 'stack-with-role');
102+
ioHost.level = 'debug';
103+
await toolkit.watch(cx, {
104+
include: ['index.ts'],
105+
});
106+
107+
// THEN
108+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
109+
action: 'watch',
110+
level: 'debug',
111+
message: expect.stringContaining('\'include\' patterns for \'watch\': ["index.ts"]'),
112+
}));
113+
});
114+
115+
test('can exclude specific files', async () => {
116+
// WHEN
117+
const cx = await builderFixture(toolkit, 'stack-with-role');
118+
ioHost.level = 'debug';
119+
await toolkit.watch(cx, {
120+
exclude: ['index.ts'],
121+
});
122+
123+
// THEN
124+
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
125+
action: 'watch',
126+
level: 'debug',
127+
message: expect.stringContaining('\'exclude\' patterns for \'watch\': ["index.ts"'),
128+
}));
129+
});
130+
131+
describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hotswapMode) => {
132+
test('passes through the correct hotswap mode to deployStack()', async () => {
133+
// WHEN
134+
const cx = await builderFixture(toolkit, 'stack-with-role');
135+
ioHost.level = 'warn';
136+
await toolkit.watch(cx, {
137+
include: [],
138+
hotswap: hotswapMode,
139+
});
140+
141+
await fakeChokidarWatcherOn.readyCallback();
142+
143+
// THEN
144+
expect(deploySpy).toHaveBeenCalledWith(expect.anything(), 'watch', expect.objectContaining({
145+
hotswap: hotswapMode,
146+
extraUserAgent: `cdk-watch/hotswap-${hotswapMode !== HotswapMode.FALL_BACK ? 'on' : 'off'}`,
147+
}));
148+
});
149+
});
150+
});
151+
152+
// @todo unit test watch with file events

packages/aws-cdk/lib/cli/cdk-toolkit.ts

-1
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,6 @@ export class CdkToolkit {
692692
.watch(watchIncludes, {
693693
ignored: watchExcludes,
694694
cwd: rootDir,
695-
// ignoreInitial: true,
696695
})
697696
.on('ready', async () => {
698697
latch = 'open';

0 commit comments

Comments
 (0)