Skip to content

Commit

Permalink
feat: Amplify Command Hooks (#7633)
Browse files Browse the repository at this point in the history
* feat: hooks execution, hooks on amplify init, pull, push

Co-authored-by: Edward Foyle <foyleef@amazon.com>
Co-authored-by: Akshay <akshayupadhyay3@gmail.com>
  • Loading branch information
3 people authored Sep 2, 2021
1 parent 0560b0f commit 4cacaad
Show file tree
Hide file tree
Showing 41 changed files with 1,563 additions and 87 deletions.
130 changes: 80 additions & 50 deletions .circleci/config.yml

Large diffs are not rendered by default.

168 changes: 168 additions & 0 deletions packages/amplify-cli-core/src/__tests__/hooks/hooksExecutor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as path from 'path';
import { executeHooks, HooksMeta, skipHooksFilePath } from '../../hooks';
import * as skipHooksModule from '../../hooks/skipHooks';
import * as execa from 'execa';
import { pathManager, stateManager } from '../../state-manager';
import * as fs from 'fs-extra';

const pathToPython3Runtime = 'path/to/python3/runtime';
const pathToPythonRuntime = 'path/to/python/runtime';
const pathToNodeRuntime = 'path/to/node/runtime';
const preStatusNodeFileName = 'pre-status.js';
const preStatusPythonFileName = 'pre-status.py';
const preAddFileName = 'pre-add.js';
const preAddAuthFileName = 'pre-add-auth.js';
const postAddFileName = 'post-add.js';
const postAddAuthFileName = 'post-add-auth.js';
const testProjectRootPath = 'testProjectRootPath';
const testProjectHooksDirPath = 'testProjectHooksDirPath';

const testProjectHooksFiles = [
preStatusNodeFileName,
preStatusPythonFileName,
preAddFileName,
preAddAuthFileName,
postAddFileName,
postAddAuthFileName,
'pre-pull.py',
'pre-push.py',
];

const stateManager_mock = stateManager as jest.Mocked<typeof stateManager>;
const pathManager_mock = pathManager as jest.Mocked<typeof pathManager>;

pathManager_mock.findProjectRoot.mockReturnValue(testProjectRootPath);
pathManager_mock.getHooksDirPath.mockReturnValue(testProjectHooksDirPath);
stateManager_mock.getHooksConfigJson.mockReturnValueOnce({ extensions: { py: { runtime: 'python3' } } });

jest.mock('execa');
jest.mock('process');
jest.mock('../../state-manager');
jest.mock('which', () => ({
sync: jest.fn().mockImplementation(runtimeName => {
if (runtimeName == 'python3') return pathToPython3Runtime;
else if (runtimeName == 'python') return pathToPythonRuntime;
else if (runtimeName == 'node') return pathToNodeRuntime;
}),
}));
jest.mock('fs-extra', () => {
const actualFs = jest.requireActual('fs-extra');
return {
...Object.assign({}, actualFs),
readdirSync: jest.fn().mockImplementation((path, options) => {
if (path === testProjectHooksDirPath) {
return testProjectHooksFiles;
}
return actualFs.readdirSync(path, options);
}),
lstatSync: jest.fn().mockImplementation(pathStr => {
if (testProjectHooksFiles.includes(path.relative(testProjectHooksDirPath, pathStr)))
return { isFile: jest.fn().mockReturnValue(true) };
return actualFs.lstatSync(pathStr);
}),
existsSync: jest.fn().mockImplementation(path => {
if (path === testProjectHooksDirPath) return true;
return actualFs.existsSync(path);
}),
};
});
jest.mock('../../hooks/hooksConstants', () => {
const orgConstants = jest.requireActual('../../hooks/hooksConstants');
return { ...Object.assign({}, orgConstants), skipHooksFilePath: path.join(__dirname, '..', 'testFiles', 'skiphooktestfile') };
});

let mockSkipHooks = jest.spyOn(skipHooksModule, 'skipHooks');

describe('hooksExecutioner tests', () => {
beforeEach(async () => {
HooksMeta.getInstance();
mockSkipHooks.mockReturnValue(false);
jest.clearAllMocks();
});
afterEach(() => {
HooksMeta.releaseInstance();
});

test('skip Hooks test', async () => {
mockSkipHooks.mockRestore();

const orgSkipHooksExist = fs.existsSync(skipHooksFilePath);

fs.ensureFileSync(skipHooksFilePath);
// skip hooks file exists so no execa calls should be made
await executeHooks(HooksMeta.getInstance({ command: 'push', plugin: 'core' }, 'pre'));
expect(execa).toHaveBeenCalledTimes(0);

fs.removeSync(skipHooksFilePath);
// skip hooks file does not exists so execa calls should be made
await executeHooks(HooksMeta.getInstance({ command: 'push', plugin: 'core' }, 'pre'));
expect(execa).not.toHaveBeenCalledTimes(0);

// resoring the original state of skip hooks file
if (!orgSkipHooksExist) fs.removeSync(skipHooksFilePath);
else fs.ensureFileSync(skipHooksFilePath);
mockSkipHooks = jest.spyOn(skipHooksModule, 'skipHooks');
});

test('executeHooks with no context', async () => {
await executeHooks(HooksMeta.getInstance());
expect(execa).toHaveBeenCalledTimes(0);
const hooksMeta = HooksMeta.getInstance();
hooksMeta.setEventCommand('add');
await executeHooks(HooksMeta.getInstance(undefined, 'pre'));
expect(execa).toHaveBeenCalledTimes(1);
});

test('should not call execa for unrecognised events', async () => {
await executeHooks(HooksMeta.getInstance({ command: 'init', plugin: 'core' }, 'pre'));
expect(execa).toHaveBeenCalledTimes(0);
});

test('should execute in specificity execution order', async () => {
await executeHooks(HooksMeta.getInstance({ command: 'add', plugin: 'auth' }, 'pre'));
expect(execa).toHaveBeenNthCalledWith(1, pathToNodeRuntime, [path.join(testProjectHooksDirPath, preAddFileName)], expect.anything());
expect(execa).toHaveBeenNthCalledWith(
2,
pathToNodeRuntime,
[path.join(testProjectHooksDirPath, preAddAuthFileName)],
expect.anything(),
);

await executeHooks(HooksMeta.getInstance({ command: 'add', plugin: 'auth' }, 'post'));
expect(execa).toHaveBeenNthCalledWith(3, pathToNodeRuntime, [path.join(testProjectHooksDirPath, postAddFileName)], expect.anything());
expect(execa).toHaveBeenNthCalledWith(
4,
pathToNodeRuntime,
[path.join(testProjectHooksDirPath, postAddAuthFileName)],
expect.anything(),
);
});

test('should determine runtime from hooks-config', async () => {
stateManager_mock.getHooksConfigJson.mockReturnValueOnce({ extensions: { py: { runtime: 'python3' } } });
await executeHooks(HooksMeta.getInstance({ command: 'pull', plugin: 'core' }, 'pre'));
expect(execa).toHaveBeenCalledWith(pathToPython3Runtime, expect.anything(), expect.anything());
});

test('should determine windows runtime from hooks-config', async () => {
stateManager_mock.getHooksConfigJson.mockReturnValueOnce({
extensions: { py: { runtime: 'python3', runtime_windows: 'python' } },
});
Object.defineProperty(process, 'platform', { value: 'win32' });
await executeHooks(HooksMeta.getInstance({ command: 'pull', plugin: 'core' }, 'pre'));
expect(execa).toHaveBeenCalledWith(pathToPythonRuntime, expect.anything(), expect.anything());
});

test('should not run the script for undefined extension/runtime', async () => {
await executeHooks(HooksMeta.getInstance({ command: 'pull', plugin: 'core' }, 'pre'));
expect(execa).toBeCalledTimes(0);
});

test('should throw error if duplicate hook scripts are present', async () => {
const duplicateErrorThrown = 'found duplicate hook scripts: ' + preStatusNodeFileName + ', ' + preStatusPythonFileName;
stateManager_mock.getHooksConfigJson.mockReturnValueOnce({
extensions: { py: { runtime: 'python3' } },
});
await expect(executeHooks(HooksMeta.getInstance({ command: 'status', plugin: 'core' }, 'pre'))).rejects.toThrow(duplicateErrorThrown);
});
});
88 changes: 88 additions & 0 deletions packages/amplify-cli-core/src/__tests__/hooks/hooksMeta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { HooksMeta } from '../../hooks/hooksMeta';

describe('HooksMeta tests', () => {
beforeEach(async () => {
HooksMeta.getInstance();
});
afterEach(() => {
HooksMeta.releaseInstance();
});

test('should identify env commands', () => {
const input = { command: 'env', plugin: 'core', subCommands: ['add'] };
const hooksMeta = HooksMeta.getInstance();
hooksMeta.setHookEventFromInput(input);

expect(hooksMeta.getHookEvent()?.command).toEqual('add');
expect(hooksMeta.getHookEvent()?.subCommand).toEqual('env');
});

test('should identify configure as update for notification and hosting', () => {
let hooksMeta = HooksMeta.getInstance();

hooksMeta.setHookEventFromInput({ command: 'configure', plugin: 'notifications' });
expect(hooksMeta.getHookEvent()?.command).toEqual('update');
expect(hooksMeta.getHookEvent()?.subCommand).toEqual('notifications');

HooksMeta.releaseInstance();
hooksMeta = HooksMeta.getInstance();

hooksMeta.setHookEventFromInput({ command: 'configure', plugin: 'hosting' });
expect(hooksMeta.getHookEvent()?.command).toEqual('update');
expect(hooksMeta.getHookEvent()?.subCommand).toEqual('hosting');
});

test('should idenfity mock commands', () => {
const input = { command: 'api', plugin: 'mock' };
const hooksMeta = HooksMeta.getInstance();
hooksMeta.setHookEventFromInput(input);

expect(hooksMeta.getHookEvent()?.command).toEqual('mock');
expect(hooksMeta.getHookEvent()?.subCommand).toEqual('api');
});

test('should not set the command and subcommand on unknown/unsupported events', () => {
const input = { command: 'init', plugin: 'core' };
const hooksMeta = HooksMeta.getInstance();
hooksMeta.setHookEventFromInput(input);

expect(hooksMeta.getHookEvent()?.command).toEqual(undefined);
expect(hooksMeta.getHookEvent()?.subCommand).toEqual(undefined);
});

test('should return correct HooksMeta object - getInstance', () => {
let hooksMeta = HooksMeta.getInstance();

expect(hooksMeta).toBeDefined();
expect(hooksMeta.getHookEvent()).toBeDefined();
expect(hooksMeta.getDataParameter()).toBeDefined();
expect(hooksMeta.getErrorParameter()).not.toBeDefined();

HooksMeta.releaseInstance();

hooksMeta = HooksMeta.getInstance(
{
command: 'pull',
plugin: 'core',
subCommands: undefined,
options: {
forcePush: true,
},
},
'pre',
);
expect(hooksMeta).toBeDefined();
expect(hooksMeta.getHookEvent().command).toEqual('pull');
expect(hooksMeta.getHookEvent().eventPrefix).toEqual('pre');
expect(hooksMeta.getHookEvent().forcePush).toEqual(true);
expect(hooksMeta.getErrorParameter()).not.toBeDefined();

// if the event was defined and Amplify emits an error, getInstance should attatch the error parameter to the already defined event
hooksMeta = HooksMeta.getInstance(undefined, 'post', { message: 'test_message', stack: 'test_stack' });
expect(hooksMeta).toBeDefined();
expect(hooksMeta.getHookEvent().command).toEqual('pull');
expect(hooksMeta.getHookEvent().eventPrefix).toEqual('post');
expect(hooksMeta.getHookEvent().forcePush).toEqual(true);
expect(hooksMeta.getErrorParameter()).toEqual({ message: 'test_message', stack: 'test_stack' });
});
});
45 changes: 45 additions & 0 deletions packages/amplify-cli-core/src/hooks/hooksConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { join } from 'path';
import { homedir } from 'os';
import { HookExtensions, HooksNoun, HooksVerb } from './hooksTypes';

export const hookFileSeperator = '-';

export const suppportedEvents: Record<HooksVerb, HooksNoun[]> = {
add: [
'notifications',
'analytics',
'api',
'auth',
'function',
'hosting',
'interactions',
'predictions',
'storage',
'xr',
'codegen',
'env',
],
update: ['notifications', 'analytics', 'api', 'auth', 'function', 'hosting', 'interactions', 'predictions', 'storage', 'xr', 'env'],
remove: ['notifications', 'analytics', 'api', 'auth', 'function', 'hosting', 'interactions', 'predictions', 'storage', 'xr', 'env'],
push: ['analytics', 'api', 'auth', 'function', 'hosting', 'interactions', 'storage', 'xr'],
pull: ['env'],
publish: [],
delete: [],
checkout: ['env'],
list: ['env'],
get: ['env'],
mock: ['api', 'storage', 'function'],
build: ['function'],
status: ['notifications'],
import: ['auth', 'storage', 'env'],
gqlcompile: ['api'],
addgraphqldatasource: ['api'],
statements: ['codegen'],
types: ['codegen'],
};

export const supportedEnvEvents: HooksVerb[] = ['add', 'update', 'remove', 'pull', 'checkout', 'list', 'get', 'import'];

export const defaultSupportedExt: HookExtensions = { js: { runtime: 'node' }, sh: { runtime: 'bash' } };

export const skipHooksFilePath: string = '/opt/amazon';
Loading

0 comments on commit 4cacaad

Please sign in to comment.