diff --git a/lib/common/services/hooks-service.ts b/lib/common/services/hooks-service.ts index 78a485f7ae..043e60b6f7 100644 --- a/lib/common/services/hooks-service.ts +++ b/lib/common/services/hooks-service.ts @@ -14,6 +14,10 @@ import { IProjectHelper, IStringDictionary, } from "../declarations"; +import { + INsConfigHooks, + IProjectConfigService, +} from "../../definitions/project"; import { IInjector } from "../definitions/yok"; import { injector } from "../yok"; @@ -38,7 +42,8 @@ export class HooksService implements IHooksService { private $injector: IInjector, private $projectHelper: IProjectHelper, private $options: IOptions, - private $performanceService: IPerformanceService + private $performanceService: IPerformanceService, + private $projectConfigService: IProjectConfigService ) {} public get hookArgsName(): string { @@ -61,6 +66,12 @@ export class HooksService implements IHooksService { this.$logger.trace( "Hooks directories: " + util.inspect(this.hooksDirectories) ); + + const customHooks = this.$projectConfigService.getValue("hooks", []); + + if (customHooks.length) { + this.$logger.trace("Custom hooks: " + util.inspect(customHooks)); + } } private static formatHookName(commandName: string): string { @@ -118,6 +129,19 @@ export class HooksService implements IHooksService { ) ); } + + const customHooks = this.getCustomHooksByName(hookName); + + for (const hook of customHooks) { + results.push( + await this.executeHook( + this.$projectHelper.projectDir, + hookName, + hook, + hookArguments + ) + ); + } } catch (err) { this.$logger.trace(`Failed during hook execution ${hookName}.`); this.$errors.fail(err.message || err); @@ -126,142 +150,186 @@ export class HooksService implements IHooksService { return _.flatten(results); } - private async executeHooksInDirectory( + private async executeHook( directoryPath: string, hookName: string, + hook: IHook, hookArguments?: IDictionary - ): Promise { + ): Promise { hookArguments = hookArguments || {}; - const results: any[] = []; - const hooks = this.getHooksByName(directoryPath, hookName); - for (let i = 0; i < hooks.length; ++i) { - const hook = hooks[i]; - const relativePath = path.relative(directoryPath, hook.fullPath); - const trackId = relativePath.replace( - new RegExp("\\" + path.sep, "g"), - AnalyticsEventLabelDelimiter - ); - let command = this.getSheBangInterpreter(hook); - let inProc = false; - if (!command) { - command = hook.fullPath; - if (path.extname(hook.fullPath).toLowerCase() === ".js") { - command = process.argv[0]; - inProc = this.shouldExecuteInProcess( - this.$fs.readText(hook.fullPath) - ); - } + let result; + + const relativePath = path.relative(directoryPath, hook.fullPath); + const trackId = relativePath.replace( + new RegExp("\\" + path.sep, "g"), + AnalyticsEventLabelDelimiter + ); + let command = this.getSheBangInterpreter(hook); + let inProc = false; + if (!command) { + command = hook.fullPath; + if (path.extname(hook.fullPath).toLowerCase() === ".js") { + command = process.argv[0]; + inProc = this.shouldExecuteInProcess(this.$fs.readText(hook.fullPath)); } + } - const startTime = this.$performanceService.now(); - if (inProc) { - this.$logger.trace( - "Executing %s hook at location %s in-process", - hookName, - hook.fullPath - ); - const hookEntryPoint = require(hook.fullPath); + const startTime = this.$performanceService.now(); + if (inProc) { + this.$logger.trace( + "Executing %s hook at location %s in-process", + hookName, + hook.fullPath + ); + const hookEntryPoint = require(hook.fullPath); - this.$logger.trace(`Validating ${hookName} arguments.`); + this.$logger.trace(`Validating ${hookName} arguments.`); - const invalidArguments = this.validateHookArguments( - hookEntryPoint, - hook.fullPath - ); + const invalidArguments = this.validateHookArguments( + hookEntryPoint, + hook.fullPath + ); - if (invalidArguments.length) { - this.$logger.warn( - `${ - hook.fullPath - } will NOT be executed because it has invalid arguments - ${ - invalidArguments.join(", ").grey - }.` - ); - continue; - } + if (invalidArguments.length) { + this.$logger.warn( + `${ + hook.fullPath + } will NOT be executed because it has invalid arguments - ${ + invalidArguments.join(", ").grey + }.` + ); + return; + } - // HACK for backwards compatibility: - // In case $projectData wasn't resolved by the time we got here (most likely we got here without running a command but through a service directly) - // then it is probably passed as a hookArg - // if that is the case then pass it directly to the hook instead of trying to resolve $projectData via injector - // This helps make hooks stateless - const projectDataHookArg = - hookArguments["hookArgs"] && hookArguments["hookArgs"]["projectData"]; - if (projectDataHookArg) { - hookArguments["projectData"] = hookArguments[ - "$projectData" - ] = projectDataHookArg; - } + // HACK for backwards compatibility: + // In case $projectData wasn't resolved by the time we got here (most likely we got here without running a command but through a service directly) + // then it is probably passed as a hookArg + // if that is the case then pass it directly to the hook instead of trying to resolve $projectData via injector + // This helps make hooks stateless + const projectDataHookArg = + hookArguments["hookArgs"] && hookArguments["hookArgs"]["projectData"]; + if (projectDataHookArg) { + hookArguments["projectData"] = hookArguments[ + "$projectData" + ] = projectDataHookArg; + } - const maybePromise = this.$injector.resolve( - hookEntryPoint, - hookArguments - ); - if (maybePromise) { - this.$logger.trace("Hook promises to signal completion"); - try { - const result = await maybePromise; - results.push(result); - } catch (err) { - if ( - err && - _.isBoolean(err.stopExecution) && - err.errorAsWarning === true - ) { - this.$logger.warn(err.message || err); - } else { - // Print the actual error with its callstack, so it is easy to find out which hooks is causing troubles. - this.$logger.error(err); - throw ( - err || new Error(`Failed to execute hook: ${hook.fullPath}.`) - ); - } + const maybePromise = this.$injector.resolve( + hookEntryPoint, + hookArguments + ); + if (maybePromise) { + this.$logger.trace("Hook promises to signal completion"); + try { + result = await maybePromise; + } catch (err) { + if ( + err && + _.isBoolean(err.stopExecution) && + err.errorAsWarning === true + ) { + this.$logger.warn(err.message || err); + } else { + // Print the actual error with its callstack, so it is easy to find out which hooks is causing troubles. + this.$logger.error(err); + throw err || new Error(`Failed to execute hook: ${hook.fullPath}.`); } - - this.$logger.trace("Hook completed"); } - } else { - const environment = this.prepareEnvironment(hook.fullPath); - this.$logger.trace( - "Executing %s hook at location %s with environment ", - hookName, - hook.fullPath, - environment - ); - const output = await this.$childProcess.spawnFromEvent( - command, - [hook.fullPath], - "close", - environment, - { throwError: false } - ); - results.push(output); + this.$logger.trace("Hook completed"); + } + } else { + const environment = this.prepareEnvironment(hook.fullPath); + this.$logger.trace( + "Executing %s hook at location %s with environment ", + hookName, + hook.fullPath, + environment + ); - if (output.exitCode !== 0) { - throw new Error(output.stdout + output.stderr); - } + const output = await this.$childProcess.spawnFromEvent( + command, + [hook.fullPath], + "close", + environment, + { throwError: false } + ); + result = output; - this.$logger.trace( - "Finished executing %s hook at location %s with environment ", - hookName, - hook.fullPath, - environment - ); + if (output.exitCode !== 0) { + throw new Error(output.stdout + output.stderr); } - const endTime = this.$performanceService.now(); - this.$performanceService.processExecutionData( - trackId, - startTime, - endTime, - [hookArguments] + + this.$logger.trace( + "Finished executing %s hook at location %s with environment ", + hookName, + hook.fullPath, + environment + ); + } + const endTime = this.$performanceService.now(); + this.$performanceService.processExecutionData(trackId, startTime, endTime, [ + hookArguments, + ]); + + return result; + } + + private async executeHooksInDirectory( + directoryPath: string, + hookName: string, + hookArguments?: IDictionary + ): Promise { + hookArguments = hookArguments || {}; + const results: any[] = []; + const hooks = this.getHooksByName(directoryPath, hookName); + + for (let i = 0; i < hooks.length; ++i) { + const hook = hooks[i]; + const result = await this.executeHook( + directoryPath, + hookName, + hook, + hookArguments ); + + if (result) { + results.push(result); + } } return results; } + private getCustomHooksByName(hookName: string): IHook[] { + const hooks: IHook[] = []; + const customHooks: INsConfigHooks[] = + this.$projectConfigService.getValue("hooks", []); + + for (const cHook of customHooks) { + if (cHook.type === hookName) { + const fullPath = path.join( + this.$projectHelper.projectDir, + cHook.script + ); + const isFile = this.$fs.getFsStats(fullPath).isFile(); + + if (isFile) { + const fileNameParts = cHook.script.split("/"); + hooks.push( + new Hook( + this.getBaseFilename(fileNameParts[fileNameParts.length - 1]), + fullPath + ) + ); + } + } + } + + return hooks; + } + private getHooksByName(directoryPath: string, hookName: string): IHook[] { const allBaseHooks = this.getHooksInDirectory(directoryPath); const baseHooks = _.filter( diff --git a/lib/common/test/unit-tests/services/hook-service.ts b/lib/common/test/unit-tests/services/hook-service.ts new file mode 100644 index 0000000000..e1ee8dd1cd --- /dev/null +++ b/lib/common/test/unit-tests/services/hook-service.ts @@ -0,0 +1,175 @@ +import * as fs from "fs"; +import * as path from "path"; +import { assert, expect } from "chai"; +import { Yok } from "../../../yok"; +import { IInjector } from "../../../definitions/yok"; +import { + ErrorsStub, + LoggerStub, + PerformanceService, + ProjectConfigServiceStub, + ProjectHelperStub, +} from "../../../../../test/stubs"; +import * as FileSystemLib from "../../../file-system"; +import * as ChildProcessLib from "../../../child-process"; +import { IHooksService } from "../../../declarations"; +import { HooksService } from "../../../services/hooks-service"; +import temp = require("temp"); + +temp.track(); + +function createTestInjector(opts?: { projectDir?: string }): IInjector { + const testInjector = new Yok(); + testInjector.register("fs", FileSystemLib.FileSystem); + testInjector.register("childProcess", ChildProcessLib.ChildProcess); + testInjector.register("injector", testInjector); + testInjector.register("config", {}); + testInjector.register("logger", LoggerStub); + testInjector.register("errors", ErrorsStub); + testInjector.register("options", { hooks: true }); + testInjector.register("staticConfig", { + CLIENT_NAME: "tns", + VERSION: "1.0.0", + }); + testInjector.register("projectConfigService", ProjectConfigServiceStub); + testInjector.register( + "projectHelper", + new ProjectHelperStub("", opts && opts.projectDir) + ); + testInjector.register("performanceService", PerformanceService); + testInjector.register("hooksService", HooksService); + + return testInjector; +} + +describe("hooks-service", () => { + let service: IHooksService; + + it("should run hooks from hooks folder", async () => { + const projectName = "projectDirectory"; + const projectPath = temp.mkdirSync(projectName); + + const testInjector = createTestInjector({ projectDir: projectPath }); + + const script = [ + `module.exports = function ($logger, hookArgs) {`, + ` return new Promise(function (resolve, reject) {`, + ` $logger.info("after-prepare hook is running");`, + ` resolve();`, + ` });`, + `};`, + ].join("\n"); + + fs.mkdirSync(path.join(projectPath, "hooks")); + fs.mkdirSync(path.join(projectPath, "hooks/after-prepare")); + fs.writeFileSync( + path.join(projectPath, "hooks/after-prepare/hook.js"), + script + ); + + service = testInjector.resolve("$hooksService"); + + await service.executeAfterHooks("prepare", { hookArgs: {} }); + + assert.equal( + testInjector.resolve("$logger").output, + "after-prepare hook is running\n" + ); + }); + + it("should run custom hooks from nativescript config", async () => { + const projectName = "projectDirectory"; + const projectPath = temp.mkdirSync(projectName); + + const testInjector = createTestInjector({ projectDir: projectPath }); + + const script = [ + `module.exports = function ($logger, hookArgs) {`, + ` return new Promise(function (resolve, reject) {`, + ` $logger.info("custom hook is running");`, + ` resolve();`, + ` });`, + `};`, + ].join("\n"); + + fs.mkdirSync(path.join(projectPath, "scripts")); + fs.writeFileSync(path.join(projectPath, "scripts/custom-hook.js"), script); + + testInjector.register( + "projectConfigService", + ProjectConfigServiceStub.initWithConfig({ + hooks: [{ type: "before-prepare", script: "scripts/custom-hook.js" }], + }) + ); + + service = testInjector.resolve("$hooksService"); + + await service.executeBeforeHooks("prepare", { hookArgs: {} }); + + assert.equal( + testInjector.resolve("$logger").output, + "custom hook is running\n" + ); + }); + + it("skip when missing hook args", async () => { + const projectName = "projectDirectory"; + const projectPath = temp.mkdirSync(projectName); + + const testInjector = createTestInjector({ projectDir: projectPath }); + + const script = [ + `module.exports = function ($logger, $projectData, hookArgs) {`, + ` return new Promise(function (resolve, reject) {`, + ` $logger.info("after-prepare hook is running");`, + ` resolve();`, + ` });`, + `};`, + ].join("\n"); + + fs.mkdirSync(path.join(projectPath, "hooks")); + fs.mkdirSync(path.join(projectPath, "hooks/after-prepare")); + fs.writeFileSync( + path.join(projectPath, "hooks/after-prepare/hook.js"), + script + ); + + service = testInjector.resolve("$hooksService"); + + await service.executeAfterHooks("prepare", { hookArgs: {} }); + + expect(testInjector.resolve("$logger").warnOutput).to.have.string( + "invalid arguments", + "$projectData should be missing" + ); + }); + + it("should run non-hook files", async () => { + const projectName = "projectDirectory"; + const projectPath = temp.mkdirSync(projectName); + + const testInjector = createTestInjector({ projectDir: projectPath }); + + const script = [ + `var fs = require("fs");`, + `var path = require("path");`, + `fs.writeFileSync(path.join(__dirname, "../../js-test.txt"), "test");`, + ].join("\n"); + + fs.mkdirSync(path.join(projectPath, "hooks")); + fs.mkdirSync(path.join(projectPath, "hooks/after-prepare")); + fs.writeFileSync( + path.join(projectPath, "hooks/after-prepare/script.js"), + script + ); + + service = testInjector.resolve("$hooksService"); + + await service.executeAfterHooks("prepare", { hookArgs: {} }); + + assert( + fs.existsSync(path.join(projectPath, "js-test.txt")), + "javascript file did not run" + ); + }); +}); diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 45eecd2de7..81263f42b2 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -134,6 +134,11 @@ interface INsConfigAndroid extends INsConfigPlaform { enableMultithreadedJavascript?: boolean; } +interface INsConfigHooks { + type?: string; + script: string; +} + interface INsConfig { id?: string; main?: string; @@ -146,6 +151,7 @@ interface INsConfig { ios?: INsConfigIOS; android?: INsConfigAndroid; ignoredNativeDependencies?: string[]; + hooks?: INsConfigHooks[]; } interface IProjectData extends ICreateProjectData {