-
Notifications
You must be signed in to change notification settings - Fork 825
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
0560b0f
commit 4cacaad
Showing
41 changed files
with
1,563 additions
and
87 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
168 changes: 168 additions & 0 deletions
168
packages/amplify-cli-core/src/__tests__/hooks/hooksExecutor.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
88
packages/amplify-cli-core/src/__tests__/hooks/hooksMeta.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.