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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
7 changes: 4 additions & 3 deletions packages/@aws-cdk/toolkit/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,19 +531,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 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
171 changes: 171 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,171 @@
// 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(): () => 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 });
jest.spyOn(toolkit, 'rollback').mockResolvedValue();

let mockDeployStack = jest.fn().mockResolvedValue({
type: 'did-deploy-stack',
stackArn: 'arn:aws:cloudformation:region:account:stack/test-stack',
outputs: {},
noOp: false,
});

jest.mock('../../lib/api/aws-cdk', () => {
return {
...jest.requireActual('../../lib/api/aws-cdk'),
Deployments: jest.fn().mockImplementation(() => ({
deployStack: mockDeployStack,
resolveEnvironment: jest.fn().mockResolvedValue({}),
isSingleAssetPublished: jest.fn().mockResolvedValue(true),
readCurrentTemplate: jest.fn().mockResolvedValue({ Resources: {} }),
})),
};
});

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,
});

fakeChokidarWatcherOn.readyCallback();
await new Promise(resolve => setTimeout(resolve, 3000));
Comment on lines +160 to +161
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm frustrated but this is all i can think of at the moment. the problem is this:

  1. the right thing to do here is to mock toolkit.deploy but that just... doesn't work.
const mockDeploy = jest.fn();
toolkit.deploy = mockDeploy;
expect(mockDeploy).toHaveBeenCalled();

I can see that toolkit.deploy is called normally, and then not called when it is mocked. But jest refuses to show that mockDeploy has been called.

  1. in the current state of things, fakeChokidaorWatcherOn.readyCallback() triggers watch to in turn invoke deploy. But that all happens asynchronously, and ioHost.notifySpy does not get the right messages since there's nothing to await. Just waiting for 3 seconds cannot be the best thing to do here, but my gut tells me that we want to figure out how to do 1), and then this point will be moot


// THEN
expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({
action: 'deploy',
level: 'warn',
message: expect.stringContaining('The --hotswap and --hotswap-fallback flags deliberately'),
}));
});
});
});
Loading