From a9afa64ba1bbc417f82e2339f4bff297309ab8a0 Mon Sep 17 00:00:00 2001 From: Rosen Vladimirov Date: Wed, 19 Apr 2017 17:23:58 +0300 Subject: [PATCH 1/6] Fix debug on iOS simulator with watch (#2721) During `tns debug ios`, in case you make changes, the application must be restarted and the debugger must attached again. However, in many cases we kill the old lldb process and immediately try to start the new one. The childProcess.kill operation finishes, but lldb process does not die immedietely. So in some occasions, the attach of new debugger fails. This leads to multiple errors - you cannot start this application on simulator anymore, you cannot exit CLI's process with `Ctrl + C`, etc. Fix this by attaching to "close" event of the processes and waiting for them to be really finish their execution. --- lib/services/ios-debug-service.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/services/ios-debug-service.ts b/lib/services/ios-debug-service.ts index 22b01dffaf..26105413af 100644 --- a/lib/services/ios-debug-service.ts +++ b/lib/services/ios-debug-service.ts @@ -78,20 +78,31 @@ class IOSDebugService extends DebugServiceBase implements IPlatformDebugService } _.forEach(this._sockets, socket => socket.destroy()); + this._sockets = []; if (this._lldbProcess) { this._lldbProcess.stdin.write("process detach\n"); - this._lldbProcess.kill(); + + await this.killProcess(this._lldbProcess); this._lldbProcess = undefined; } if (this._childProcess) { - this._childProcess.kill(); + await this.killProcess(this._childProcess); this._childProcess = undefined; } } + private async killProcess(childProcess: ChildProcess): Promise { + if (childProcess) { + return new Promise((resolve, reject) => { + childProcess.on("close", resolve); + childProcess.kill(); + }); + } + } + private async emulatorDebugBrk(debugData: IDebugData, debugOptions: IDebugOptions): Promise { let args = debugOptions.debugBrk ? "--nativescript-debug-brk" : "--nativescript-debug-start"; let child_process = await this.$iOSEmulatorServices.runApplicationOnEmulator(debugData.pathToAppPackage, { From 6ad0cef725d501d319c21a1acf253950378d3c51 Mon Sep 17 00:00:00 2001 From: Rosen Vladimirov Date: Fri, 21 Apr 2017 11:12:28 +0300 Subject: [PATCH 2/6] Implement extensibility model for CLI (#2724) Implement extensibilty of CLI that allows anyone to add easily create packages that add new functionality to NativeScript CLI. The packages are installed in a specific directory, so they are persisted through CLI's updated. The directory where extensions are installed contains a package.json and each extension is npm package installed there. The extensions can be mainatined in two different ways: - navigate to the directory where extensions are installed and use `npm` for install/uninstall/update of packages. - use CLI's commands to update them: `tns extension install `, `tns extension uninstall `, `tns extension` Implement extensibilityService that executes all operations and expose it to public API. In {N} CLI the extensions are loaded in the entry point, before parsing command line arguments. This way extensions can add new commands. In Fusion, after CLI is required as a library, the `extensibilityService.loadExtensions` method should be called. It returns array of Promises - one for each installed extension. Add help for the new commands, but do not link the new commands in other commands help for the moment. Add unit tests for the new service. --- PublicAPI.md | 201 +++--- docs/man_pages/general/extension-install.md | 35 + docs/man_pages/general/extension-uninstall.md | 31 + docs/man_pages/general/extension.md | 25 + lib/bootstrap.ts | 7 + .../extensibility/install-extension.ts | 13 + lib/commands/extensibility/list-extensions.ts | 24 + .../extensibility/uninstall-extension.ts | 14 + lib/definitions/extensibility.d.ts | 48 ++ lib/definitions/platform.d.ts | 2 +- lib/definitions/require.d.ts | 12 + lib/nativescript-cli.ts | 19 +- lib/services/extensibility-service.ts | 137 ++++ lib/services/require-service.ts | 7 + test/nativescript-cli-lib.ts | 3 +- test/services/extensibility-service.ts | 612 ++++++++++++++++++ 16 files changed, 1104 insertions(+), 86 deletions(-) create mode 100644 docs/man_pages/general/extension-install.md create mode 100644 docs/man_pages/general/extension-uninstall.md create mode 100644 docs/man_pages/general/extension.md create mode 100644 lib/commands/extensibility/install-extension.ts create mode 100644 lib/commands/extensibility/list-extensions.ts create mode 100644 lib/commands/extensibility/uninstall-extension.ts create mode 100644 lib/definitions/extensibility.d.ts create mode 100644 lib/definitions/require.d.ts create mode 100644 lib/services/extensibility-service.ts create mode 100644 lib/services/require-service.ts create mode 100644 test/services/extensibility-service.ts diff --git a/PublicAPI.md b/PublicAPI.md index e29610ab9a..0434108d54 100644 --- a/PublicAPI.md +++ b/PublicAPI.md @@ -3,35 +3,17 @@ Public API This document describes all methods that can be invoked when NativeScript CLI is required as library, i.e. - - - - - - - - - - -
- JavaScript - - TypeScript -
-
+```JavaScript
 const tns = require("nativescript");
-
-
-
-import * as tns from "nativescript";
-
-
+``` ## Module projectService `projectService` modules allow you to create new NativeScript application. -* `createProject(projectSettings: IProjectSettings): Promise` - Creates new NativeScript application. By passing `projectSettings` argument you specify the name of the application, the template that will be used, etc.: +### createProject +* Description: `createProject(projectSettings: IProjectSettings): Promise` - Creates new NativeScript application. By passing `projectSettings` argument you specify the name of the application, the template that will be used, etc.: + ```TypeScript /** * Describes available settings when creating new NativeScript application. @@ -73,71 +55,130 @@ interface IProjectSettings { } ``` -Sample usage: - - - - - - - - - -
- JavaScript - - TypeScript -
-
+* Sample usage:
+```JavaScript
 const projectSettings = {
 	projectName: "my-ns-app",
-    template: "ng",
-    pathToProject: "/home/my-user/project-dir"
+	template: "ng",
+	pathToProject: "/home/my-user/project-dir"
 };
 
 tns.projectService.createProject(projectSettings)
 	.then(() => console.log("Project successfully created."))
-    .catch((err) => console.log("Unable to create project, reason: ", err);
-
-
-
-const projectSettings: IProjectSettings = {
-	projectName: "my-ns-app",
-    template: "ng",
-    pathToProject: "/home/my-user/project-dir"
-};
+	.catch((err) => console.log("Unable to create project, reason: ", err);
+```
 
-tns.projectService.createProject(projectSettings)
-	.then(() => console.log("Project successfully created."))
-    .catch((err) => console.log("Unable to create project, reason: ", err);
-
-
- -* `isValidNativeScriptProject(projectDir: string): boolean` - Checks if the specified path is a valid NativeScript project. Returns `true` in case the directory is a valid project, `false` otherwise. - -Sample usage: - - - - - - - - - -
- JavaScript - - TypeScript -
-
-const isValidProject = tns.projectService.isValidNativeScriptProject("/tmp/myProject");
-
-
-
+### isValidNativeScriptProject
+* Definition: `isValidNativeScriptProject(projectDir: string): boolean` - Checks if the specified path is a valid NativeScript project. Returns `true` in case the directory is a valid project, `false` otherwise.
+
+* Sample usage:
+```JavaScript
 const isValidProject = tns.projectService.isValidNativeScriptProject("/tmp/myProject");
-
-
+console.log(isValidProject); // true or false +``` + +## extensibilityService +`extensibilityService` module gives access to methods for working with CLI's extensions - list, install, uninstall, load them. The extensions add new functionality to CLI, so once an extension is loaded, all methods added to it's public API are accessible directly through CLI when it is used as a library. Extensions may also add new commands, so they are accessible through command line when using NativeScript CLI. + +A common interface describing the results of a method is `IExtensionData`: +```TypeScript +/** + * Describes each extension. + */ +interface IExtensionData { + /** + * The name of the extension. + */ + extensionName: string; +} +``` + +### installExtension +Installs specified extension and loads it in the current process, so the functionality that it adds can be used immediately. + +* Definition: +```TypeScript +/** + * Installs and loads specified extension. + * @param {string} extensionName Name of the extension to be installed. It may contain version as well, i.e. myPackage, myPackage@1.0.0, myPackage.tgz, https://github.com/myOrganization/myPackage/tarball/master, https://github.com/myOrganization/myPackage etc. + * @returns {Promise} Information about installed extensions. + */ +installExtension(extensionName: string): Promise; +``` + +* Usage: +```JavaScript +tns.extensibilityService.installExtension("extension-package") + .then(extensionData => console.log(`Successfully installed extension ${extensionData.extensionName}.`)) + .catch(err => console.log("Failed to install extension.")); +``` + +### uninstallExtension +Uninstalls specified extensions, so its functionality will no longer be available through CLI. + +* Definition: +```TypeScript +/** + * Uninstalls extension from the installation. + * @param {string} extensionName Name of the extension to be uninstalled. + * @returns {Promise} + */ +uninstallExtension(extensionName: string): Promise; +``` + +* Usage: +```JavaScript +tns.extensibilityService.uninstallExtension("extension-package") + .then(() => console.log("Successfully uninstalled extension.")) + .catch(err => console.log("Failed to uninstall extension.")); +``` + +### getInstalledExtensions +Gets information about all installed extensions. + +* Definition: +```TypeScript +/** + * Gets information about installed dependencies - names and versions. + * @returns {IStringDictionary} + */ +getInstalledExtensions(): IStringDictionary; +``` + +* Usage: +```JavaScript +const installedExtensions = tns.extensibilityService.getInstalledExtensions(); +for (let extensionName in installedExtensions) { + const version = installedExtensions[extensionName]; + console.log(`The extension ${extensionName} is installed with version ${version}.`); +} +``` + +### loadExtensions +Loads all currently installed extensions. The method returns array of Promises, one for each installed extension. In case any of the extensions cannot be loaded, only its Promise is rejected. + +* Definition +```TypeScript +/** + * Loads all extensions, so their methods and commands can be used from CLI. + * For each of the extensions, a new Promise is returned. It will be rejected in case the extension cannot be loaded. However other promises will not be reflected by this failure. + * In case a promise is rejected, the error will have additional property (extensionName) that shows which is the extension that cannot be loaded in the process. + * @returns {Promise[]} Array of promises, each is resolved with information about loaded extension. + */ +loadExtensions(): Promise[]; +``` + +* Usage: +```JavaScript +const loadExtensionsPromises = tns.extensibilityService.loadExtensions(); +for (let promise of loadExtensionsPromises) { + promise.then(extensionData => console.log(`Loaded extension: ${extensionData.extensionName}.`), + err => { + console.log(`Failed to load extension: ${err.extensionName}`); + console.log(err); + }); +} +``` ## How to add a new method to Public API CLI is designed as command line tool and when it is used as a library, it does not give you access to all of the methods. This is mainly implementation detail. Most of the CLI's code is created to work in command line, not as a library, so before adding method to public API, most probably it will require some modification. diff --git a/docs/man_pages/general/extension-install.md b/docs/man_pages/general/extension-install.md new file mode 100644 index 0000000000..0c98c4a3d6 --- /dev/null +++ b/docs/man_pages/general/extension-install.md @@ -0,0 +1,35 @@ +extension install +========== + +Usage | Synopsis +------|------- +General | `$ tns extension install ` + +Installs specified extension. Each extension adds additional functionality that's accessible directly from NativeScript CLI. + +### Attributes + +* `` is any of the following. + * A `` or `@` where `` is the name of a package that is published in the npm registry and `` is a valid version of this plugin. + * A `` to the directory which contains the extension, including its `package.json` file. + * A `` to a `.tar.gz` archive containing a directory with the extension and its `package.json` file. + * A `` which resolves to a `.tar.gz` archive containing a directory with the extension and its `package.json` file. + * A `` which resolves to a `.tar.gz` archive containing a directory with the extension and its `package.json` file. + +<% if(isHtml) { %> +### Related Commands + +Command | Description +----------|---------- +[extension](extension.html) | Prints information about all installed extensions. +[extension-uninstall](extension-uninstall.html) | Uninstalls specified extension. +[autocomplete-status](autocomplete-status.html) | Prints the current status of your command-line completion settings. +[autocomplete-enable](autocomplete-enable.html) | Configures your current command-line completion settings. +[autocomplete-disable](autocomplete-disable.html) | Disables command-line completion for bash and zsh shells. +[usage-reporting](usage-reporting.html) | Configures anonymous usage reporting for the NativeScript CLI. +[error-reporting](error-reporting.html) | Configures anonymous error reporting for the NativeScript CLI. +[doctor](doctor.html) | Checks your system for configuration problems which might prevent the NativeScript CLI from working properly. +[proxy](proxy.html) | Displays proxy settings. +[proxy clear](proxy-clear.html) | Clears proxy settings. +[proxy set](proxy-set.html) | Sets proxy settings. +<% } %> \ No newline at end of file diff --git a/docs/man_pages/general/extension-uninstall.md b/docs/man_pages/general/extension-uninstall.md new file mode 100644 index 0000000000..8c7be09c88 --- /dev/null +++ b/docs/man_pages/general/extension-uninstall.md @@ -0,0 +1,31 @@ +extension uninstall +========== + +Usage | Synopsis +------|------- +General | `$ tns extension uninstall ` + +Uninstalls specified extension. After that you will not be able to use the functionality that this extensions adds to NativeScript CLI. + +### Attributes + +* `` is the name of the extension as listed in its `package.json` file. + +<% if(isHtml) { %> +### Related Commands + +Command | Description +----------|---------- +[extension](extension.html) | Prints information about all installed extensions. +[extension-uninstall](extension-uninstall.html) | Uninstalls specified extension. +[extension-install](extension-install.html) | Installs specified extension. +[autocomplete-status](autocomplete-status.html) | Prints the current status of your command-line completion settings. +[autocomplete-enable](autocomplete-enable.html) | Configures your current command-line completion settings. +[autocomplete-disable](autocomplete-disable.html) | Disables command-line completion for bash and zsh shells. +[usage-reporting](usage-reporting.html) | Configures anonymous usage reporting for the NativeScript CLI. +[error-reporting](error-reporting.html) | Configures anonymous error reporting for the NativeScript CLI. +[doctor](doctor.html) | Checks your system for configuration problems which might prevent the NativeScript CLI from working properly. +[proxy](proxy.html) | Displays proxy settings. +[proxy clear](proxy-clear.html) | Clears proxy settings. +[proxy set](proxy-set.html) | Sets proxy settings. +<% } %> \ No newline at end of file diff --git a/docs/man_pages/general/extension.md b/docs/man_pages/general/extension.md new file mode 100644 index 0000000000..d0ed3b5413 --- /dev/null +++ b/docs/man_pages/general/extension.md @@ -0,0 +1,25 @@ +extension +========== + +Usage | Synopsis +------|------- +General | `$ tns extension` + +Prints information about all installed extensions. + +<% if(isHtml) { %> +### Related Commands + +Command | Description +----------|---------- +[extension-install](extension-install.html) | Installs specified extension. +[autocomplete-status](autocomplete-status.html) | Prints the current status of your command-line completion settings. +[autocomplete-enable](autocomplete-enable.html) | Configures your current command-line completion settings. +[autocomplete-disable](autocomplete-disable.html) | Disables command-line completion for bash and zsh shells. +[usage-reporting](usage-reporting.html) | Configures anonymous usage reporting for the NativeScript CLI. +[error-reporting](error-reporting.html) | Configures anonymous error reporting for the NativeScript CLI. +[doctor](doctor.html) | Checks your system for configuration problems which might prevent the NativeScript CLI from working properly. +[proxy](proxy.html) | Displays proxy settings. +[proxy clear](proxy-clear.html) | Clears proxy settings. +[proxy set](proxy-set.html) | Sets proxy settings. +<% } %> \ No newline at end of file diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 5c7a73533d..cb26b7ef7d 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -125,3 +125,10 @@ $injector.require("projectChangesService", "./services/project-changes-service") $injector.require("emulatorPlatformService", "./services/emulator-platform-service"); $injector.require("staticConfig", "./config"); + +$injector.require("requireService", "./services/require-service"); + +$injector.requireCommand("extension|*list", "./commands/extensibility/list-extensions"); +$injector.requireCommand("extension|install", "./commands/extensibility/install-extension"); +$injector.requireCommand("extension|uninstall", "./commands/extensibility/uninstall-extension"); +$injector.requirePublic("extensibilityService", "./services/extensibility-service"); diff --git a/lib/commands/extensibility/install-extension.ts b/lib/commands/extensibility/install-extension.ts new file mode 100644 index 0000000000..91bea2a76a --- /dev/null +++ b/lib/commands/extensibility/install-extension.ts @@ -0,0 +1,13 @@ +export class InstallExtensionCommand implements ICommand { + constructor(private $extensibilityService: IExtensibilityService, + private $stringParameterBuilder: IStringParameterBuilder, + private $logger: ILogger) { } + + public async execute(args: string[]): Promise { + const extensionData = await this.$extensibilityService.installExtension(args[0]); + this.$logger.info(`Successfully installed extension ${extensionData.extensionName}.`); + } + + allowedParameters: ICommandParameter[] = [this.$stringParameterBuilder.createMandatoryParameter("You have to provide a valid name for extension that you want to install.")]; +} +$injector.registerCommand("extension|install", InstallExtensionCommand); diff --git a/lib/commands/extensibility/list-extensions.ts b/lib/commands/extensibility/list-extensions.ts new file mode 100644 index 0000000000..aabc3f9c13 --- /dev/null +++ b/lib/commands/extensibility/list-extensions.ts @@ -0,0 +1,24 @@ +import * as helpers from "../../common/helpers"; + +export class ListExtensionsCommand implements ICommand { + constructor(private $extensibilityService: IExtensibilityService, + private $logger: ILogger) { } + + public async execute(args: string[]): Promise { + const installedExtensions = this.$extensibilityService.getInstalledExtensions(); + if (_.keys(installedExtensions).length) { + this.$logger.info("Installed extensions:"); + const data = _.map(installedExtensions, (version, name) => { + return [name, version]; + }); + + const table = helpers.createTable(["Name", "Version"], data); + this.$logger.out(table.toString()); + } else { + this.$logger.info("No extensions installed."); + } + } + + allowedParameters: ICommandParameter[] = []; +} +$injector.registerCommand("extension|*list", ListExtensionsCommand); diff --git a/lib/commands/extensibility/uninstall-extension.ts b/lib/commands/extensibility/uninstall-extension.ts new file mode 100644 index 0000000000..76ad4f882c --- /dev/null +++ b/lib/commands/extensibility/uninstall-extension.ts @@ -0,0 +1,14 @@ +export class UninstallExtensionCommand implements ICommand { + constructor(private $extensibilityService: IExtensibilityService, + private $stringParameterBuilder: IStringParameterBuilder, + private $logger: ILogger) { } + + public async execute(args: string[]): Promise { + const extensionName = args[0]; + await this.$extensibilityService.uninstallExtension(extensionName); + this.$logger.info(`Successfully uninstalled extension ${extensionName}`); + } + + allowedParameters: ICommandParameter[] = [this.$stringParameterBuilder.createMandatoryParameter("You have to provide a valid name for extension that you want to uninstall.")]; +} +$injector.registerCommand("extension|uninstall", UninstallExtensionCommand); diff --git a/lib/definitions/extensibility.d.ts b/lib/definitions/extensibility.d.ts new file mode 100644 index 0000000000..3aa9dba5cd --- /dev/null +++ b/lib/definitions/extensibility.d.ts @@ -0,0 +1,48 @@ +/** + * Describes each extension. + */ +interface IExtensionData { + /** + * The name of the extension. + */ + extensionName: string; +} + +/** + * Defines methods for working with CLI's extensions. + */ +interface IExtensibilityService { + /** + * Installs and loads specified extension. + * @param {string} extensionName Name of the extension to be installed. It may contain version as well, i.e. myPackage, myPackage@1.0.0, + * myPackage.tgz, https://github.com/myOrganization/myPackage/tarball/master, https://github.com/myOrganization/myPackage, etc. + * @returns {Promise} Information about installed extensions. + */ + installExtension(extensionName: string): Promise; + + /** + * Uninstalls extension from the installation. + * @param {string} extensionName Name of the extension to be uninstalled. + * @returns {Promise} + */ + uninstallExtension(extensionName: string): Promise; + + /** + * Loads all extensions, so their methods and commands can be used from CLI. + * For each of the extensions, a new Promise is returned. It will be rejected in case the extension cannot be loaded. However other promises will not be reflected by this failure. + * In case a promise is rejected, the error will have additional property (extensionName) that shows which is the extension that cannot be loaded in the process. + * @returns {Promise[]} Array of promises, each is resolved with information about loaded extension. + */ + loadExtensions(): Promise[]; + + /** + * Gets information about installed dependencies - names and versions. + * @returns {IStringDictionary} + */ + getInstalledExtensions(): IStringDictionary; +} + +/** + * Describes the error that will be raised when a problem with extension is detected. + */ +interface IExtensionLoadingError extends Error, IExtensionData { } \ No newline at end of file diff --git a/lib/definitions/platform.d.ts b/lib/definitions/platform.d.ts index 8f5e22fc8d..638d675f7c 100644 --- a/lib/definitions/platform.d.ts +++ b/lib/definitions/platform.d.ts @@ -211,7 +211,7 @@ interface IPlatformSpecificData { provision: any; /** - * Target SDK for Android.s + * Target SDK for Android. */ sdk: string; } diff --git a/lib/definitions/require.d.ts b/lib/definitions/require.d.ts new file mode 100644 index 0000000000..83a40e595a --- /dev/null +++ b/lib/definitions/require.d.ts @@ -0,0 +1,12 @@ +/** + * Describes methods available in the require. + */ +interface IRequireService { + + /** + * Wrapper for the Node.js `require` method. + * @param {string} module Module to be required. + * @returns {any} The result of the require action. + */ + require(module: string): any; +} diff --git a/lib/nativescript-cli.ts b/lib/nativescript-cli.ts index 3d7434c688..143c5268d5 100644 --- a/lib/nativescript-cli.ts +++ b/lib/nativescript-cli.ts @@ -5,14 +5,25 @@ shelljs.config.fatal = true; import { installUncaughtExceptionListener } from "./common/errors"; installUncaughtExceptionListener(process.exit); +import { settlePromises } from "./common/helpers"; + (async () => { - let config: Config.IConfig = $injector.resolve("$config"); - let err: IErrors = $injector.resolve("$errors"); + const config: Config.IConfig = $injector.resolve("$config"); + const err: IErrors = $injector.resolve("$errors"); err.printCallStack = config.DEBUG; - let commandDispatcher: ICommandDispatcher = $injector.resolve("commandDispatcher"); + const logger: ILogger = $injector.resolve("logger"); + + const extensibilityService: IExtensibilityService = $injector.resolve("extensibilityService"); + try { + await settlePromises(extensibilityService.loadExtensions()); + } catch (err) { + logger.trace("Unable to load extensions. Error is: ", err); + } + + const commandDispatcher: ICommandDispatcher = $injector.resolve("commandDispatcher"); - let messages: IMessagesService = $injector.resolve("$messagesService"); + const messages: IMessagesService = $injector.resolve("$messagesService"); messages.pathsToMessageJsonFiles = [/* Place client-specific json message file paths here */]; if (process.argv[2] === "completion") { diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts new file mode 100644 index 0000000000..756e037f00 --- /dev/null +++ b/lib/services/extensibility-service.ts @@ -0,0 +1,137 @@ +import * as path from "path"; +import { cache, exported } from "../common/decorators"; +import * as constants from "../constants"; + +export class ExtensibilityService implements IExtensibilityService { + private get pathToExtensions(): string { + return path.join(this.$options.profileDir, "extensions"); + } + + private get pathToPackageJson(): string { + return path.join(this.pathToExtensions, constants.PACKAGE_JSON_FILE_NAME); + } + + constructor(private $fs: IFileSystem, + private $logger: ILogger, + private $npm: INodePackageManager, + private $options: IOptions, + private $requireService: IRequireService) { + } + + @exported("extensibilityService") + public async installExtension(extensionName: string): Promise { + this.$logger.trace(`Start installation of extension '${extensionName}'.`); + + await this.assertPackageJsonExists(); + + const npmOpts: any = { + save: true, + ["save-exact"]: true + }; + + const localPath = path.resolve(extensionName); + const packageName = this.$fs.exists(localPath) ? localPath : extensionName; + + const realName = (await this.$npm.install(packageName, this.pathToExtensions, npmOpts))[0]; + this.$logger.trace(`Finished installation of extension '${extensionName}'. Trying to load it now.`); + + // In case the extension is already installed, the $npm.install method will not return the name of the package. + // Fallback to the original value. + // NOTE: This will not be required once $npm.install starts working correctly. + return await this.loadExtension(realName || extensionName); + } + + @exported("extensibilityService") + public async uninstallExtension(extensionName: string): Promise { + this.$logger.trace(`Start uninstallation of extension '${extensionName}'.`); + + await this.assertPackageJsonExists(); + + await this.$npm.uninstall(extensionName, { save: true }, this.pathToExtensions); + + this.$logger.trace(`Finished uninstallation of extension '${extensionName}'.`); + } + + @exported("extensibilityService") + public loadExtensions(): Promise[] { + this.$logger.trace("Loading extensions."); + + let dependencies: IStringDictionary = null; + + try { + dependencies = this.getInstalledExtensions(); + } catch (err) { + this.$logger.trace(`Error while getting installed dependencies: ${err.message}. No extensions will be loaded.`); + } + + return _.keys(dependencies) + .map(name => this.loadExtension(name)); + } + + @exported("extensibilityService") + public getInstalledExtensions(): IStringDictionary { + if (this.$fs.exists(this.pathToPackageJson)) { + return this.$fs.readJson(this.pathToPackageJson).dependencies; + } + + return null; + } + + private async loadExtension(extensionName: string): Promise { + try { + await this.assertExtensionIsInstalled(extensionName); + + const pathToExtension = path.join(this.pathToExtensions, constants.NODE_MODULES_FOLDER_NAME, extensionName); + this.$requireService.require(pathToExtension); + return { extensionName }; + } catch (error) { + this.$logger.warn(`Error while loading ${extensionName} is: ${error.message}`); + const err = new Error(`Unable to load extension ${extensionName}. You will not be able to use the functionality that it adds.`); + err.extensionName = extensionName; + throw err; + } + } + + private async assertExtensionIsInstalled(extensionName: string): Promise { + this.$logger.trace(`Asserting extension ${extensionName} is installed.`); + const installedExtensions = this.$fs.readDirectory(path.join(this.pathToExtensions, constants.NODE_MODULES_FOLDER_NAME)); + + if (installedExtensions.indexOf(extensionName) === -1) { + this.$logger.trace(`Extension ${extensionName} is not installed, starting installation.`); + await this.installExtension(extensionName); + } + + this.$logger.trace(`Extension ${extensionName} is installed.`); + } + + @cache() + private assertExtensionsDirExists(): void { + if (!this.$fs.exists(this.pathToExtensions)) { + this.$fs.createDirectory(this.pathToExtensions); + } + } + + @cache() + private assertPackageJsonExists(): void { + this.assertExtensionsDirExists(); + + if (!this.$fs.exists(this.pathToPackageJson)) { + this.$logger.trace(`Creating ${this.pathToPackageJson}.`); + + // create default package.json + this.$fs.writeJson(this.pathToPackageJson, { + name: "nativescript-extensibility", + version: "1.0.0", + description: "The place where all packages that extend CLI will be installed.", + license: "Apache-2.0", + readme: "The place where all packages that extend CLI will be installed.", + repository: "none", + dependencies: {} + }); + + this.$logger.trace(`Created ${this.pathToPackageJson}.`); + } + } +} + +$injector.register("extensibilityService", ExtensibilityService); diff --git a/lib/services/require-service.ts b/lib/services/require-service.ts new file mode 100644 index 0000000000..6902389e02 --- /dev/null +++ b/lib/services/require-service.ts @@ -0,0 +1,7 @@ +export class RequireService implements IRequireService { + public require(module: string): any { + return require(module); + } +} + +$injector.register("requireService", RequireService); diff --git a/test/nativescript-cli-lib.ts b/test/nativescript-cli-lib.ts index f4fe159e97..394eebd871 100644 --- a/test/nativescript-cli-lib.ts +++ b/test/nativescript-cli-lib.ts @@ -16,7 +16,8 @@ describe("nativescript-cli-lib", () => { deviceEmitter: null, projectService: ["createProject", "isValidNativeScriptProject"], localBuildService: ["build"], - deviceLogProvider: null + deviceLogProvider: null, + extensibilityService: ["loadExtensions", "getInstalledExtensions", "installExtension", "uninstallExtension"] }; const pathToEntryPoint = path.join(__dirname, "..", "lib", "nativescript-cli-lib.js").replace(/\\/g, "\\\\"); diff --git a/test/services/extensibility-service.ts b/test/services/extensibility-service.ts new file mode 100644 index 0000000000..3e02472ee8 --- /dev/null +++ b/test/services/extensibility-service.ts @@ -0,0 +1,612 @@ +import { ExtensibilityService } from "../../lib/services/extensibility-service"; +import { Yok } from "../../lib/common/yok"; +import * as stubs from "../stubs"; +import { assert } from "chai"; +import * as constants from "../../lib/constants"; +import * as path from "path"; + +describe("extensibilityService", () => { + const getTestInjector = (): IInjector => { + const testInjector = new Yok(); + testInjector.register("fs", {}); + testInjector.register("logger", stubs.LoggerStub); + testInjector.register("npm", {}); + testInjector.register("options", { + profileDir: "profileDir" + }); + testInjector.register("requireService", { + require: (pathToRequire: string): any => undefined + }); + return testInjector; + }; + + describe("installExtension", () => { + describe("fails", () => { + it("when extensions dir does not exist and trying to create it fails", async () => { + const expectedErrorMessage = "Unable to create dir"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => false; + fs.createDirectory = (dirToCreate: string): void => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await assert.isRejected(extensibilityService.installExtension("extensionToInstall"), expectedErrorMessage); + }); + + it("when extensions dir exists, but default package.json is missing and trying to create it fails", async () => { + const expectedErrorMessage = "Unable to write json"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) !== constants.PACKAGE_JSON_FILE_NAME; + fs.writeJson = (pathToFile: string, content: any) => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await assert.isRejected(extensibilityService.installExtension("extensionToInstall"), expectedErrorMessage); + }); + + it("when npm install fails", async () => { + const expectedErrorMessage = "Unable to install package"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => true; + const npm: INodePackageManager = testInjector.resolve("npm"); + npm.install = async (packageName: string, pathToSave: string, config?: any): Promise => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await assert.isRejected(extensibilityService.installExtension("extensionToInstall"), expectedErrorMessage); + }); + }); + + describe("passes correct arguments to npm install", () => { + const getArgsPassedToNpmInstallDuringInstallExtensionCall = async (userSpecifiedValue: string, testInjector?: IInjector): Promise => { + testInjector = testInjector || getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => true; + + fs.readDirectory = (dir: string): string[] => [userSpecifiedValue]; + + const npm: INodePackageManager = testInjector.resolve("npm"); + let argsPassedToNpmInstall: any = {}; + npm.install = async (packageName: string, pathToSave: string, config?: any): Promise => { + argsPassedToNpmInstall.packageName = packageName; + argsPassedToNpmInstall.pathToSave = pathToSave; + argsPassedToNpmInstall.config = config; + return [userSpecifiedValue]; + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await extensibilityService.installExtension(userSpecifiedValue); + + return argsPassedToNpmInstall; + }; + + const assertPackageNamePassedToNpmInstall = async (userSpecifiedValue: string, expectedValue: string): Promise => { + const argsPassedToNpmInstall = await getArgsPassedToNpmInstallDuringInstallExtensionCall(userSpecifiedValue); + assert.deepEqual(argsPassedToNpmInstall.packageName, expectedValue); + }; + + it("passes full path for installation, when trying to install local package (user specifies relative path)", async () => { + const extensionName = "../extension1"; + await assertPackageNamePassedToNpmInstall(extensionName, path.resolve(extensionName)); + }); + + it("passes the value specified by user for installation, when the local path does not exist", async () => { + const extensionName = "extension1"; + await assertPackageNamePassedToNpmInstall(extensionName, path.resolve(extensionName)); + }); + + it("passes save and save-exact options to npm install", async () => { + const extensionName = "extension1"; + const argsPassedToNpmInstall = await getArgsPassedToNpmInstallDuringInstallExtensionCall(extensionName); + const expectedNpmConfg: any = { save: true }; + expectedNpmConfg["save-exact"] = true; + assert.deepEqual(argsPassedToNpmInstall.config, expectedNpmConfg); + }); + + it("passes full path to extensions dir for installation", async () => { + const extensionName = "extension1"; + const testInjector = getTestInjector(); + const options: IOptions = testInjector.resolve("options"); + options.profileDir = "my-profile-dir"; + + const expectedDirForInstallation = path.join(options.profileDir, "extensions"); + const argsPassedToNpmInstall = await getArgsPassedToNpmInstallDuringInstallExtensionCall(extensionName, testInjector); + assert.deepEqual(argsPassedToNpmInstall.pathToSave, expectedDirForInstallation); + }); + }); + + it("returns the name of the installed extension", async () => { + const extensionName = "extension1"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) !== extensionName; + + fs.readDirectory = (dir: string): string[] => [extensionName]; + + const npm: INodePackageManager = testInjector.resolve("npm"); + npm.install = async (packageName: string, pathToSave: string, config?: any): Promise => [extensionName]; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const actualResult = await extensibilityService.installExtension(extensionName); + assert.deepEqual(actualResult, { extensionName }); + }); + + it("throws error that has extensionName property when unable to load extension", async () => { + const expectedErrorMessage = "Require failed"; + + const extensionName = "extension1"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) !== extensionName; + + fs.readDirectory = (dir: string): string[] => [extensionName]; + + const npm: INodePackageManager = testInjector.resolve("npm"); + npm.install = async (packageName: string, pathToSave: string, config?: any): Promise => [extensionName]; + + const requireService: IRequireService = testInjector.resolve("requireService"); + requireService.require = (pathToRequire: string) => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + let isErrorRaised = false; + try { + await extensibilityService.installExtension(extensionName); + } catch (err) { + isErrorRaised = true; + assert.deepEqual(err.message, `Unable to load extension ${extensionName}. You will not be able to use the functionality that it adds.`); + assert.deepEqual(err.extensionName, extensionName); + } + + assert.isTrue(isErrorRaised); + }); + }); + + describe("loadExtensions", () => { + describe("returns correct results for each extension", () => { + it("resolves all Promises with correct values when all extensions can be loaded", async () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + const extensionNames = ["extension1", "extension2", "extension3"]; + fs.exists = (pathToCheck: string): boolean => true; + fs.readDirectory = (dir: string): string[] => { + assert.deepEqual(path.basename(dir), constants.NODE_MODULES_FOLDER_NAME); + // Simulates extensions are installed in node_modules + return extensionNames; + }; + + fs.readJson = (filename: string, encoding?: string): any => { + const dependencies: any = {}; + _.each(extensionNames, name => { + dependencies[name] = "1.0.0"; + }); + + return { dependencies }; + }; + + const expectedResults: IExtensionData[] = _.map(extensionNames, extensionName => ({ extensionName })); + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const actualResult = await Promise.all(extensibilityService.loadExtensions()); + assert.deepEqual(actualResult, expectedResults); + }); + + it("installs extensions that are available in package.json, but are not available in node_modules", async () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + const extensionNames = ["extension1", "extension2", "extension3"]; + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) !== extensionNames[0]; + + let isFirstReadDirExecution = true; + fs.readDirectory = (dir: string): string[] => { + assert.deepEqual(path.basename(dir), constants.NODE_MODULES_FOLDER_NAME); + // Simulates extensions are installed in node_modules + if (isFirstReadDirExecution) { + isFirstReadDirExecution = false; + return extensionNames.filter(ext => ext !== "extension1"); + } else { + return extensionNames; + } + }; + + fs.readJson = (filename: string, encoding?: string): any => { + const dependencies: any = {}; + _.each(extensionNames, name => { + dependencies[name] = "1.0.0"; + }); + + return { dependencies }; + }; + + let isNpmInstallCalled = false; + const npm: INodePackageManager = testInjector.resolve("npm"); + npm.install = async (packageName: string, pathToSave: string, config?: any): Promise => { + assert.deepEqual(packageName, extensionNames[0]); + isNpmInstallCalled = true; + return [packageName]; + }; + + const expectedResults: IExtensionData[] = _.map(extensionNames, extensionName => ({ extensionName })); + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const actualResult = await Promise.all(extensibilityService.loadExtensions()); + assert.deepEqual(actualResult, expectedResults); + assert.isTrue(isNpmInstallCalled); + }); + + it("rejects only promises for extensions that cannot be loaded", async () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + const extensionNames = ["extension1", "extension2", "extension3"]; + fs.exists = (pathToCheck: string): boolean => true; + fs.readDirectory = (dir: string): string[] => { + assert.deepEqual(path.basename(dir), constants.NODE_MODULES_FOLDER_NAME); + // Simulates extensions are installed in node_modules + return extensionNames; + }; + + fs.readJson = (filename: string, encoding?: string): any => { + const dependencies: any = {}; + _.each(extensionNames, name => { + dependencies[name] = "1.0.0"; + }); + + return { dependencies }; + }; + + const requireService: IRequireService = testInjector.resolve("requireService"); + requireService.require = (module: string) => { + if (path.basename(module) === extensionNames[0]) { + throw new Error("Unable to load module."); + } + }; + + const expectedResults: any[] = _.map(extensionNames, extensionName => ({ extensionName })); + expectedResults[0] = new Error("Unable to load extension extension1. You will not be able to use the functionality that it adds."); + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const promises = extensibilityService.loadExtensions(); + assert.deepEqual(promises.length, extensionNames.length); + + for (let index = 0; index < promises.length; index++) { + const loadExtensionPromise = promises[index]; + await loadExtensionPromise + .then(result => assert.deepEqual(result, expectedResults[index]), + err => { + assert.deepEqual(err.message, expectedResults[index].message); + assert.deepEqual(err.extensionName, extensionNames[index]); + }); + }; + }); + + it("rejects all promises when unable to read node_modules dir (simulate EPERM error)", async () => { + const testInjector = getTestInjector(); + const extensionNames = ["extension1", "extension2", "extension3"]; + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) === "extensions" || path.basename(pathToCheck) === constants.PACKAGE_JSON_FILE_NAME; + fs.readJson = (filename: string, encoding?: string): any => { + const dependencies: any = {}; + _.each(extensionNames, name => { + dependencies[name] = "1.0.0"; + }); + + return { dependencies }; + }; + + let isReadDirCalled = false; + fs.readDirectory = (dir: string): string[] => { + isReadDirCalled = true; + assert.deepEqual(path.basename(dir), constants.NODE_MODULES_FOLDER_NAME); + throw new Error(`Unable to read ${constants.NODE_MODULES_FOLDER_NAME} dir.`); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const promises = extensibilityService.loadExtensions(); + + for (let index = 0; index < promises.length; index++) { + const loadExtensionPromise = promises[index]; + await loadExtensionPromise.then(res => { throw new Error("Shouldn't get here!"); }, + err => { + const extensionName = extensionNames[index]; + assert.deepEqual(err.message, `Unable to load extension ${extensionName}. You will not be able to use the functionality that it adds.`); + assert.deepEqual(err.extensionName, extensionName); + }); + }; + + assert.deepEqual(promises.length, extensionNames.length); + assert.isTrue(isReadDirCalled, "readDirectory should have been called for the extensions."); + }); + + it("rejects all promises when unable to install extensions to extension dir (simulate EPERM error)", async () => { + const testInjector = getTestInjector(); + const extensionNames = ["extension1", "extension2", "extension3"]; + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) === "extensions" || path.basename(pathToCheck) === constants.PACKAGE_JSON_FILE_NAME; + fs.readJson = (filename: string, encoding?: string): any => { + const dependencies: any = {}; + _.each(extensionNames, name => { + dependencies[name] = "1.0.0"; + }); + + return { dependencies }; + }; + + let isReadDirCalled = false; + fs.readDirectory = (dir: string): string[] => { + isReadDirCalled = true; + assert.deepEqual(path.basename(dir), constants.NODE_MODULES_FOLDER_NAME); + return []; + }; + + let isNpmInstallCalled = false; + const npm: INodePackageManager = testInjector.resolve("npm"); + npm.install = async (packageName: string, pathToSave: string, config?: any): Promise => { + assert.deepEqual(packageName, extensionNames[0]); + isNpmInstallCalled = true; + throw new Error(`Unable to install to ${constants.NODE_MODULES_FOLDER_NAME} dir.`); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const promises = extensibilityService.loadExtensions(); + + for (let index = 0; index < promises.length; index++) { + const loadExtensionPromise = promises[index]; + await loadExtensionPromise.then(res => { + console.log("######### res = ", res); throw new Error("Shouldn't get here!"); + }, + err => { + const extensionName = extensionNames[index]; + assert.deepEqual(err.message, `Unable to load extension ${extensionName}. You will not be able to use the functionality that it adds.`); + assert.deepEqual(err.extensionName, extensionName); + }); + }; + + assert.deepEqual(promises.length, extensionNames.length); + assert.isTrue(isNpmInstallCalled, "Npm install should have been called for the extensions."); + assert.isTrue(isReadDirCalled, "readDirectory should have been called for the extensions."); + }); + + it("does not return any promises when its unable to create extensions dir", () => { + + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => false; + const expectedErrorMessage = "Unable to create dir"; + fs.createDirectory = (dirToCreate: string): void => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const promises = extensibilityService.loadExtensions(); + + assert.deepEqual(promises.length, 0); + }); + + it("does not return any promises when its unable to read extensions package.json", () => { + + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => true; + const expectedErrorMessage = "Unable to read json"; + fs.readJson = (filename: string, encoding?: string): any => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const promises = extensibilityService.loadExtensions(); + + assert.deepEqual(promises.length, 0); + }); + + it("does not fail when package.json in extension dir does not exist", async () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => { + // Add the assert here, so we are sure the only call to fs.exists is for package.json of the extensions dir. + assert.deepEqual(path.basename(pathToCheck), constants.PACKAGE_JSON_FILE_NAME); + return false; + }; + + const expectedResults: IExtensionData[] = []; + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const actualResult = await Promise.all(extensibilityService.loadExtensions()); + assert.deepEqual(actualResult, expectedResults, "When there's no package.json in extensions dir, there's nothing for loading."); + }); + + it("does not fail when unable to read extensions dir package.json", async () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => { + // Add the assert here, so we are sure the only call to fs.exists is for package.json of the extensions dir. + assert.deepEqual(path.basename(pathToCheck), constants.PACKAGE_JSON_FILE_NAME); + return true; + }; + + fs.readJson = (filename: string, encoding?: string): any => { + throw new Error("Unable to read JSON"); + }; + + const expectedResults: IExtensionData[] = []; + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + const actualResult = await Promise.all(extensibilityService.loadExtensions()); + assert.deepEqual(actualResult, expectedResults, "When unable to read package.json in extensions dir, there's nothing for loading."); + }); + + }); + }); + + describe("uninstallExtension", () => { + describe("fails", () => { + it("when extensions dir does not exist and trying to create it fails", async () => { + const expectedErrorMessage = "Unable to create dir"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => false; + fs.createDirectory = (dirToCreate: string): void => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await assert.isRejected(extensibilityService.uninstallExtension("extensionToInstall"), expectedErrorMessage); + }); + + it("when extensions dir exists, but default package.json is missing and trying to create it fails", async () => { + const expectedErrorMessage = "Unable to write json"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) !== constants.PACKAGE_JSON_FILE_NAME; + fs.writeJson = (pathToFile: string, content: any) => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await assert.isRejected(extensibilityService.uninstallExtension("extensionToInstall"), expectedErrorMessage); + }); + + it("when npm uninstall fails", async () => { + const expectedErrorMessage = "Unable to install package"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => true; + const npm: INodePackageManager = testInjector.resolve("npm"); + npm.uninstall = async (packageName: string, config?: any, path?: string): Promise => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await assert.isRejected(extensibilityService.uninstallExtension("extensionToInstall"), expectedErrorMessage); + }); + }); + + describe("passes correct arguments to npm uninstall", () => { + const getArgsPassedToNpmUninstallDuringUninstallExtensionCall = async (userSpecifiedValue: string, testInjector?: IInjector): Promise => { + testInjector = testInjector || getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => true; + + fs.readDirectory = (dir: string): string[] => [userSpecifiedValue]; + + const npm: INodePackageManager = testInjector.resolve("npm"); + let argsPassedToNpmInstall: any = {}; + npm.uninstall = async (packageName: string, config?: any, path?: string): Promise => { + argsPassedToNpmInstall.packageName = packageName; + argsPassedToNpmInstall.pathToSave = path; + argsPassedToNpmInstall.config = config; + return [userSpecifiedValue]; + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await extensibilityService.uninstallExtension(userSpecifiedValue); + + return argsPassedToNpmInstall; + }; + + const assertPackageNamePassedToNpmUninstall = async (userSpecifiedValue: string, expectedValue: string): Promise => { + const argsPassedToNpmInstall = await getArgsPassedToNpmUninstallDuringUninstallExtensionCall(userSpecifiedValue); + assert.deepEqual(argsPassedToNpmInstall.packageName, expectedValue); + }; + + it("passes the value specified by user for installation", async () => { + const extensionName = "extension1"; + await assertPackageNamePassedToNpmUninstall(extensionName, extensionName); + + const relativePathToExtension = "../extension1"; + await assertPackageNamePassedToNpmUninstall(relativePathToExtension, relativePathToExtension); + }); + + it("passes save option to npm uninstall", async () => { + const extensionName = "extension1"; + const argsPassedToNpmUninstall = await getArgsPassedToNpmUninstallDuringUninstallExtensionCall(extensionName); + const expectedNpmConfg: any = { save: true }; + assert.deepEqual(argsPassedToNpmUninstall.config, expectedNpmConfg); + }); + + it("passes full path to extensions dir for uninstallation", async () => { + const extensionName = "extension1"; + const testInjector = getTestInjector(); + const options: IOptions = testInjector.resolve("options"); + options.profileDir = "my-profile-dir"; + + const expectedDirForInstallation = path.join(options.profileDir, "extensions"); + const argsPassedToNpmUninstall = await getArgsPassedToNpmUninstallDuringUninstallExtensionCall(extensionName, testInjector); + assert.deepEqual(argsPassedToNpmUninstall.pathToSave, expectedDirForInstallation); + }); + }); + + it("executes successfully uninstall operation", async () => { + const extensionName = "extension1"; + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string): boolean => path.basename(pathToCheck) !== extensionName; + + fs.readDirectory = (dir: string): string[] => [extensionName]; + + const npm: INodePackageManager = testInjector.resolve("npm"); + npm.uninstall = async (packageName: string, config?: any, path?: string): Promise => [extensionName]; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + await extensibilityService.uninstallExtension(extensionName); + }); + + }); + + describe("getInstalledExtensions", () => { + it("fails when unable to read package.json from extensions dir", () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string) => true; + const expectedErrorMessage = "Failed to read package.json"; + fs.readJson = (filename: string, encoding?: string): any => { + throw new Error(expectedErrorMessage); + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + assert.throws(() => extensibilityService.getInstalledExtensions(), expectedErrorMessage); + }); + + it("returns null when there's no package.json dir", () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string) => false; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + assert.isNull(extensibilityService.getInstalledExtensions()); + }); + + it("returns undefined when package.json does not have dependencies section", () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string) => true; + fs.readJson = (filename: string, encoding?: string): any => { + return {}; + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + assert.isUndefined(extensibilityService.getInstalledExtensions()); + }); + + it("returns dependencies section of package.json", () => { + const testInjector = getTestInjector(); + const fs: IFileSystem = testInjector.resolve("fs"); + fs.exists = (pathToCheck: string) => true; + const dependencies = { + "dep1": "1.0.0", + "dep2": "~1.0.0", + "dep3": "^1.0.0" + }; + + fs.readJson = (filename: string, encoding?: string): any => { + return { dependencies }; + }; + + const extensibilityService: IExtensibilityService = testInjector.resolve(ExtensibilityService); + assert.deepEqual(extensibilityService.getInstalledExtensions(), dependencies); + }); + }); +}); From 53e56f5ff30d9daa6a17223012c9abe4f071a81d Mon Sep 17 00:00:00 2001 From: Emil Tabakov Date: Fri, 21 Apr 2017 11:11:50 +0300 Subject: [PATCH 3/6] Fix installation scripts for Mac (#2714) * Fix android sdk commands to use sdkmanager * Replace brew install with brew cask * Fix installation for haxm * Change brew formulae repository before installing android sdk * Fix setting the ENV variable missing cast --- setup/native-script.rb | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) mode change 100644 => 100755 setup/native-script.rb diff --git a/setup/native-script.rb b/setup/native-script.rb old mode 100644 new mode 100755 index 81dc86b610..b70aa25d31 --- a/setup/native-script.rb +++ b/setup/native-script.rb @@ -76,7 +76,7 @@ def install(program_name, message, script, run_as_root = false, show_all_option end def install_environment_variable(name, value) - ENV[name] = value + ENV[name] = value.to_s execute("echo \"export #{name}=#{value}\" >> ~/.bash_profile", "Unable to set #{name}") @@ -93,7 +93,7 @@ def install_environment_variable(name, value) end install("Java SE Development Kit", "Installing the Java SE Development Kit... This might take some time, please, be patient. (You will be prompted for your password)", 'brew cask install java', false, false) -install("Android SDK", "Installing Android SDK", 'brew install android-sdk', false) +install("Android SDK", "Installing Android SDK", 'brew tap caskroom/cask; brew cask install android-sdk', false) unless ENV["ANDROID_HOME"] require 'pathname' @@ -129,30 +129,31 @@ def install_environment_variable(name, value) # the android tool will introduce a --accept-license option in subsequent releases error_msg = "There seem to be some problems with the Android configuration" -android_executable = File.join(ENV["ANDROID_HOME"], "tools", "android") -execute("echo y | #{android_executable} update sdk --filter platform-tools --all --no-ui", error_msg) -execute("echo y | #{android_executable} update sdk --filter tools --all --no-ui", error_msg) -execute("echo y | #{android_executable} update sdk --filter android-23 --all --no-ui", error_msg) -execute("echo y | #{android_executable} update sdk --filter build-tools-25.0.2 --all --no-ui", error_msg) -execute("echo y | #{android_executable} update sdk --filter build-tools-23.0.3 --all --no-ui", error_msg) -execute("echo y | #{android_executable} update sdk --filter extra-android-m2repository --all --no-ui", error_msg) -execute("echo y | #{android_executable} update sdk --filter extra-google-m2repository --all --no-ui", error_msg) +android_executable = File.join(ENV["ANDROID_HOME"], "tools", "bin", "sdkmanager") +execute("echo y | #{android_executable} \"platform-tools\"", error_msg) +execute("echo y | #{android_executable} \"tools\"", error_msg) +execute("echo y | #{android_executable} \"build-tools;25.0.2\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-25\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-24\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-23\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-22\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-21\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-19\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-18\"", error_msg) +execute("echo y | #{android_executable} \"platforms;android-17\"", error_msg) puts "Do you want to install Android emulator? (y/n)" if gets.chomp.downcase == "y" puts "Do you want to install HAXM (Hardware accelerated Android emulator)? (y/n)" if gets.chomp.downcase == "y" - execute("echo y | #{android_executable} update sdk --filter extra-intel-Hardware_Accelerated_Execution_Manager --all --no-ui", error_msg) - + execute("echo y | #{android_executable} \"extras;intel;Hardware_Accelerated_Execution_Manager\"", error_msg) + haxm_silent_installer = File.join(ENV["ANDROID_HOME"], "extras", "intel", "Hardware_Accelerated_Execution_Manager", "silent_install.sh") - execute("#{haxm_silent_installer}", "There seem to be some problems with the Android configuration") - - execute("echo y | #{android_executable} update sdk --filter sys-img-x86-android-23 --all --no-ui", error_msg) - execute("echo no | #{android_executable} create avd -n Emulator-Api23-Default -t android-23 --abi default/x86 -c 12M -f", error_msg) - else - execute("echo y | #{android_executable} update sdk --filter sys-img-armeabi-v7a-android-23 --all --no-ui", error_msg) - execute("echo no | #{android_executable} create avd -n Emulator-Api23-Default -t android-23 --abi default/armeabi-v7a -c 12M -f", error_msg) + execute("sudo #{haxm_silent_installer}", "There seem to be some problems with the Android configuration") + else end + execute("echo y | #{android_executable} \"system-images;android-25;google_apis;x86\"", error_msg) + execute("echo y | #{android_executable} \"system-images;android-24;default;x86\"", error_msg) end puts "The ANDROID_HOME and JAVA_HOME environment variables have been added to your .bash_profile/.zprofile" From e654156b0f9839f903f23e423de9811c28640ac8 Mon Sep 17 00:00:00 2001 From: Rosen Vladimirov Date: Tue, 25 Apr 2017 10:09:05 +0300 Subject: [PATCH 4/6] Do not start emulator when `--available-devices` is passed (#2736) In case there's no devices attached and no emulators running, trying ` devices --available-devices` will start emulator. In order to fix this, modify the `startEmulatorIfNecessary` method to skip the starting in case `skipInferPlatform` option is passed. This option indicates that we are not concerned of specific platform, so the method does not know which is the target platform for which to start emulator. Add unit test for this behavior. --- lib/common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/common b/lib/common index d874d4d627..b96484eb79 160000 --- a/lib/common +++ b/lib/common @@ -1 +1 @@ -Subproject commit d874d4d627b6b6b9ecf8b717016e387508dc07d5 +Subproject commit b96484eb79704a84a6b2e3f94376072ac77065d8 From 67bb17e9a7fcfa86ea1716c7283b717ea462bc2e Mon Sep 17 00:00:00 2001 From: Peter Kanev Date: Tue, 25 Apr 2017 11:21:47 +0300 Subject: [PATCH 5/6] Install karma peer dependencies on `test init` (#2693) * implicitly install karma-'s peer dependencies on test init command * add exception handling when installing packages already present in the project * log warning instead of throwing errors when a package's name couldn't be determined when installed using the node-package-manager service --- lib/commands/test-init.ts | 19 +++++++++++++++++++ lib/node-package-manager.ts | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts index 0593173dbc..69736ac8a7 100644 --- a/lib/commands/test-init.ts +++ b/lib/commands/test-init.ts @@ -37,6 +37,25 @@ class TestInitCommand implements ICommand { 'save-dev': true, optional: false, }); + + const modulePath = path.join(projectDir, "node_modules", mod); + const modulePackageJsonPath = path.join(modulePath, "package.json"); + const modulePackageJsonContent = this.$fs.readJson(modulePackageJsonPath); + const modulePeerDependencies = modulePackageJsonContent.peerDependencies || {}; + + for (let peerDependency in modulePeerDependencies) { + let dependencyVersion = modulePeerDependencies[peerDependency] || "*"; + + // catch errors when a peerDependency is already installed + // e.g karma is installed; karma-jasmine depends on karma and will try to install it again + try { + await this.$npm.install(`${peerDependency}@${dependencyVersion}`, projectDir, { + 'save-dev': true + }); + } catch (e) { + this.$logger.error(e.message); + } + } } await this.$pluginsService.add('nativescript-unit-test-runner', this.$projectData); diff --git a/lib/node-package-manager.ts b/lib/node-package-manager.ts index fd863640b4..45591a8122 100644 --- a/lib/node-package-manager.ts +++ b/lib/node-package-manager.ts @@ -76,10 +76,10 @@ export class NodePackageManager implements INodePackageManager { let diff = dependencyDiff.concat(devDependencyDiff); if (diff.length <= 0 && dependenciesBefore.length === dependenciesAfter.length && packageName !== pathToSave) { - this.$errors.failWithoutHelp(`The plugin ${packageName} is already installed`); + this.$logger.warn(`The plugin ${packageName} is already installed`); } if (diff.length <= 0 && dependenciesBefore.length !== dependenciesAfter.length) { - this.$errors.failWithoutHelp(`Couldn't install package correctly`); + this.$logger.warn(`Couldn't install package ${packageName} correctly`); } return diff; From e55ac87ee6ead4e36db2b6f176b1d2a530b7f7d3 Mon Sep 17 00:00:00 2001 From: rosen-vladimirov Date: Tue, 25 Apr 2017 12:44:40 +0300 Subject: [PATCH 6/6] Update to latest common --- lib/common | 2 +- test/stubs.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/common b/lib/common index b96484eb79..c81c94373b 160000 --- a/lib/common +++ b/lib/common @@ -1 +1 @@ -Subproject commit b96484eb79704a84a6b2e3f94376072ac77065d8 +Subproject commit c81c94373b25572644222001ff25ec32e0869ec4 diff --git a/test/stubs.ts b/test/stubs.ts index be91dca62e..00f09ba5cd 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -169,6 +169,8 @@ export class FileSystemStub implements IFileSystem { } deleteEmptyParents(directory: string): void { } + + utimes(path: string, atime: Date, mtime: Date): void { } } export class ErrorsStub implements IErrors {