diff --git a/lib/constants.ts b/lib/constants.ts index b0a9de8a4e..becb8280e6 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -222,3 +222,8 @@ export class PluginNativeDirNames { } export const PODFILE_NAME = "Podfile"; + +export class IosProjectConstants { + public static XcodeProjExtName = ".xcodeproj"; + public static XcodeSchemeExtName = ".xcscheme"; +} diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 1045c39b85..f59d13ec5f 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -773,6 +773,16 @@ interface IProjectNameService { ensureValidName(projectName: string, validateOptions?: { force: boolean }): Promise; } +/** + * Describes options that can be passed to xcprojService.verifyXcproj method. + */ +interface IVerifyXcprojOptions { + /** + * Whether to fail with error message or not + */ + shouldFail: boolean; +} + /** * Designed for getting information about xcproj. */ @@ -780,10 +790,10 @@ interface IXcprojService { /** * Checks whether the system needs xcproj to execute ios builds successfully. * In case the system does need xcproj but does not have it, prints an error message. - * @param {boolean} whether to fail with error message or not + * @param {IVerifyXcprojOptions} opts whether to fail with error message or not * @return {Promise} whether an error occurred or not. */ - verifyXcproj(shouldFail: boolean): Promise; + verifyXcproj(opts: IVerifyXcprojOptions): Promise; /** * Collects information about xcproj. * @return {Promise} collected info about xcproj. @@ -903,4 +913,4 @@ interface IRuntimeGradleVersions { interface INetworkConnectivityValidator { validate(): Promise; -} \ No newline at end of file +} diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index bd0ec0a546..b873227812 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -493,6 +493,14 @@ interface ICocoaPodsService { * @returns {string} Path to project's Podfile. */ getProjectPodfilePath(nativeProjectPath: string): string; + + /** + * Executes `pod install` or `sanboxpod install` in the passed project. + * @param {string} projectRoot The root directory of the native iOS project. + * @param {string} xcodeProjPath The full path to the .xcodeproj file. + * @returns {Promise} Information about the spawned process. + */ + executePodInstall(projectRoot: string, xcodeProjPath: string): Promise; } interface IRubyFunction { diff --git a/lib/services/cocoapods-service.ts b/lib/services/cocoapods-service.ts index 9afedc6aab..47c0e484e1 100644 --- a/lib/services/cocoapods-service.ts +++ b/lib/services/cocoapods-service.ts @@ -6,7 +6,12 @@ export class CocoaPodsService implements ICocoaPodsService { private static PODFILE_POST_INSTALL_SECTION_NAME = "post_install"; private static INSTALLER_BLOCK_PARAMETER_NAME = "installer"; - constructor(private $fs: IFileSystem) { } + constructor(private $fs: IFileSystem, + private $childProcess: IChildProcess, + private $errors: IErrors, + private $xcprojService: IXcprojService, + private $logger: ILogger, + private $config: IConfiguration) { } public getPodfileHeader(targetName: string): string { return `use_frameworks!${EOL}${EOL}target "${targetName}" do${EOL}`; @@ -20,6 +25,33 @@ export class CocoaPodsService implements ICocoaPodsService { return path.join(projectRoot, PODFILE_NAME); } + public async executePodInstall(projectRoot: string, xcodeProjPath: string): Promise { + // Check availability + try { + await this.$childProcess.exec("which pod"); + await this.$childProcess.exec("which xcodeproj"); + } catch (e) { + this.$errors.failWithoutHelp("CocoaPods or ruby gem 'xcodeproj' is not installed. Run `sudo gem install cocoapods` and try again."); + } + + await this.$xcprojService.verifyXcproj({ shouldFail: true }); + + this.$logger.info("Installing pods..."); + const podTool = this.$config.USE_POD_SANDBOX ? "sandbox-pod" : "pod"; + // cocoapods print a lot of non-error information on stderr. Pipe the `stderr` to `stdout`, so we won't polute CLI's stderr output. + const podInstallResult = await this.$childProcess.spawnFromEvent(podTool, ["install"], "close", { cwd: projectRoot, stdio: ['pipe', process.stdout, process.stdout] }, { throwError: false }); + + if (podInstallResult.exitCode !== 0) { + this.$errors.failWithoutHelp(`'${podTool} install' command failed.${podInstallResult.stderr ? " Error is: " + podInstallResult.stderr : ""}`); + } + + if ((await this.$xcprojService.getXcprojInfo()).shouldUseXcproj) { + await this.$childProcess.spawnFromEvent("xcproj", ["--project", xcodeProjPath, "touch"], "close"); + } + + return podInstallResult; + } + public async applyPluginPodfileToProject(pluginData: IPluginData, projectData: IProjectData, nativeProjectPath: string): Promise { const pluginPodFilePath = this.getPluginPodfilePath(pluginData); if (!this.$fs.exists(pluginPodFilePath)) { diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 109db3c309..655b3dabea 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -13,7 +13,7 @@ import { IOSProvisionService } from "./ios-provision-service"; import { IOSEntitlementsService } from "./ios-entitlements-service"; import { XCConfigService } from "./xcconfig-service"; import * as mobileprovision from "ios-mobileprovision-finder"; -import { BUILD_XCCONFIG_FILE_NAME } from "../constants"; +import { BUILD_XCCONFIG_FILE_NAME, IosProjectConstants } from "../constants"; interface INativeSourceCodeGroup { name: string; @@ -22,8 +22,6 @@ interface INativeSourceCodeGroup { } export class IOSProjectService extends projectServiceBaseLib.PlatformProjectServiceBase implements IPlatformProjectService { - private static XCODE_PROJECT_EXT_NAME = ".xcodeproj"; - private static XCODE_SCHEME_EXT_NAME = ".xcscheme"; private static XCODEBUILD_MIN_VERSION = "6.0"; private static IOS_PROJECT_NAME_PLACEHOLDER = "__PROJECT_NAME__"; private static IOS_PLATFORM_NAME = "ios"; @@ -36,7 +34,6 @@ export class IOSProjectService extends projectServiceBaseLib.PlatformProjectServ private $injector: IInjector, $projectDataService: IProjectDataService, private $prompter: IPrompter, - private $config: IConfiguration, private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, private $devicesService: Mobile.IDevicesService, private $mobileHelper: Mobile.IMobileHelper, @@ -174,21 +171,21 @@ export class IOSProjectService extends projectServiceBaseLib.PlatformProjectServ } this.replaceFileName("-Prefix.pch", projectRootFilePath, projectData); - const xcschemeDirPath = path.join(this.getPlatformData(projectData).projectRoot, IOSProjectService.IOS_PROJECT_NAME_PLACEHOLDER + IOSProjectService.XCODE_PROJECT_EXT_NAME, "xcshareddata/xcschemes"); - const xcschemeFilePath = path.join(xcschemeDirPath, IOSProjectService.IOS_PROJECT_NAME_PLACEHOLDER + IOSProjectService.XCODE_SCHEME_EXT_NAME); + const xcschemeDirPath = path.join(this.getPlatformData(projectData).projectRoot, IOSProjectService.IOS_PROJECT_NAME_PLACEHOLDER + IosProjectConstants.XcodeProjExtName, "xcshareddata/xcschemes"); + const xcschemeFilePath = path.join(xcschemeDirPath, IOSProjectService.IOS_PROJECT_NAME_PLACEHOLDER + IosProjectConstants.XcodeSchemeExtName); if (this.$fs.exists(xcschemeFilePath)) { this.$logger.debug("Found shared scheme at xcschemeFilePath, renaming to match project name."); this.$logger.debug("Checkpoint 0"); this.replaceFileContent(xcschemeFilePath, projectData); this.$logger.debug("Checkpoint 1"); - this.replaceFileName(IOSProjectService.XCODE_SCHEME_EXT_NAME, xcschemeDirPath, projectData); + this.replaceFileName(IosProjectConstants.XcodeSchemeExtName, xcschemeDirPath, projectData); this.$logger.debug("Checkpoint 2"); } else { this.$logger.debug("Copying xcscheme from template not found at " + xcschemeFilePath); } - this.replaceFileName(IOSProjectService.XCODE_PROJECT_EXT_NAME, this.getPlatformData(projectData).projectRoot, projectData); + this.replaceFileName(IosProjectConstants.XcodeProjExtName, this.getPlatformData(projectData).projectRoot, projectData); const pbxprojFilePath = this.getPbxProjPath(projectData); this.replaceFileContent(pbxprojFilePath, projectData); @@ -891,7 +888,7 @@ We will now place an empty obsolete compatability white screen LauncScreen.xib f } private getXcodeprojPath(projectData: IProjectData): string { - return path.join(this.getPlatformData(projectData).projectRoot, projectData.projectName + IOSProjectService.XCODE_PROJECT_EXT_NAME); + return path.join(this.getPlatformData(projectData).projectRoot, projectData.projectName + IosProjectConstants.XcodeProjExtName); } private getPluginsDebugXcconfigFilePath(projectData: IProjectData): string { @@ -941,7 +938,9 @@ We will now place an empty obsolete compatability white screen LauncScreen.xib f await this.prepareResources(pluginPlatformsFolderPath, pluginData, projectData); await this.prepareFrameworks(pluginPlatformsFolderPath, pluginData, projectData); await this.prepareStaticLibs(pluginPlatformsFolderPath, pluginData, projectData); - await this.prepareCocoapods(pluginPlatformsFolderPath, pluginData, projectData); + + const projectRoot = this.getPlatformData(projectData).projectRoot; + await this.$cocoapodsService.applyPluginPodfileToProject(pluginData, projectData, projectRoot); } public async removePluginNativeCode(pluginData: IPluginData, projectData: IProjectData): Promise { @@ -958,8 +957,9 @@ We will now place an empty obsolete compatability white screen LauncScreen.xib f public async afterPrepareAllPlugins(projectData: IProjectData): Promise { const projectRoot = this.getPlatformData(projectData).projectRoot; if (this.$fs.exists(this.$cocoapodsService.getProjectPodfilePath(projectRoot))) { - const xcuserDataPath = path.join(this.getXcodeprojPath(projectData), "xcuserdata"); - const sharedDataPath = path.join(this.getXcodeprojPath(projectData), "xcshareddata"); + const xcodeProjPath = this.getXcodeprojPath(projectData); + const xcuserDataPath = path.join(xcodeProjPath, "xcuserdata"); + const sharedDataPath = path.join(xcodeProjPath, "xcshareddata"); if (!this.$fs.exists(xcuserDataPath) && !this.$fs.exists(sharedDataPath)) { this.$logger.info("Creating project scheme..."); @@ -969,7 +969,7 @@ We will now place an empty obsolete compatability white screen LauncScreen.xib f await this.$childProcess.exec(createSchemeRubyScript, { cwd: this.getPlatformData(projectData).projectRoot }); } - await this.executePodInstall(projectData); + await this.$cocoapodsService.executePodInstall(projectRoot, xcodeProjPath); } } @@ -1070,43 +1070,6 @@ We will now place an empty obsolete compatability white screen LauncScreen.xib f this.$fs.rename(path.join(fileRootLocation, oldFileName), path.join(fileRootLocation, newFileName)); } - private async executePodInstall(projectData: IProjectData): Promise { - // Check availability - try { - await this.$childProcess.exec("which pod"); - await this.$childProcess.exec("which xcodeproj"); - } catch (e) { - this.$errors.failWithoutHelp("CocoaPods or ruby gem 'xcodeproj' is not installed. Run `sudo gem install cocoapods` and try again."); - } - - await this.$xcprojService.verifyXcproj(true); - - this.$logger.info("Installing pods..."); - const podTool = this.$config.USE_POD_SANDBOX ? "sandbox-pod" : "pod"; - const childProcess = await this.$childProcess.spawnFromEvent(podTool, ["install"], "close", { cwd: this.getPlatformData(projectData).projectRoot, stdio: ['pipe', process.stdout, 'pipe'] }); - if (childProcess.stderr) { - const warnings = childProcess.stderr.match(/(\u001b\[(?:\d*;){0,5}\d*m[\s\S]+?\u001b\[(?:\d*;){0,5}\d*m)|(\[!\].*?\n)|(.*?warning.*)/gi); - _.each(warnings, (warning: string) => { - this.$logger.warnWithLabel(warning.replace("\n", "")); - }); - - let errors = childProcess.stderr; - _.each(warnings, warning => { - errors = errors.replace(warning, ""); - }); - - if (errors.trim()) { - this.$errors.failWithoutHelp(`Pod install command failed. Error output: ${errors}`); - } - } - - if ((await this.$xcprojService.getXcprojInfo()).shouldUseXcproj) { - await this.$childProcess.spawnFromEvent("xcproj", ["--project", this.getXcodeprojPath(projectData), "touch"], "close"); - } - - return childProcess; - } - private async prepareNativeSourceCode(pluginName: string, pluginPlatformsFolderPath: string, projectData: IProjectData): Promise { const project = this.createPbxProj(projectData); const group = this.getRootGroup(pluginName, pluginPlatformsFolderPath); @@ -1153,15 +1116,6 @@ We will now place an empty obsolete compatability white screen LauncScreen.xib f } } - private async prepareCocoapods(pluginPlatformsFolderPath: string, pluginData: IPluginData, projectData: IProjectData, opts?: any): Promise { - const projectRoot = this.getPlatformData(projectData).projectRoot; - await this.$cocoapodsService.applyPluginPodfileToProject(pluginData, projectData, projectRoot); - const pluginPodFilePath = path.join(pluginPlatformsFolderPath, "Podfile"); - - if (opts && opts.executePodInstall && this.$fs.exists(pluginPodFilePath)) { - await this.executePodInstall(projectData); - } - } private removeNativeSourceCode(pluginPlatformsFolderPath: string, pluginData: IPluginData, projectData: IProjectData): void { const project = this.createPbxProj(projectData); const group = this.getRootGroup(pluginData.name, pluginPlatformsFolderPath); diff --git a/lib/services/xcproj-service.ts b/lib/services/xcproj-service.ts index 395db55707..c4ca1f4c8f 100644 --- a/lib/services/xcproj-service.ts +++ b/lib/services/xcproj-service.ts @@ -12,11 +12,11 @@ class XcprojService implements IXcprojService { private $xcodeSelectService: IXcodeSelectService) { } - public async verifyXcproj(shouldFail: boolean): Promise { + public async verifyXcproj(opts: IVerifyXcprojOptions): Promise { const xcprojInfo = await this.getXcprojInfo(); if (xcprojInfo.shouldUseXcproj && !xcprojInfo.xcprojAvailable) { const errorMessage = `You are using CocoaPods version ${xcprojInfo.cocoapodVer} which does not support Xcode ${xcprojInfo.xcodeVersion.major}.${xcprojInfo.xcodeVersion.minor} yet.${EOL}${EOL}You can update your cocoapods by running $sudo gem install cocoapods from a terminal.${EOL}${EOL}In order for the NativeScript CLI to be able to work correctly with this setup you need to install xcproj command line tool and add it to your PATH. Xcproj can be installed with homebrew by running $ brew install xcproj from the terminal`; - if (shouldFail) { + if (opts.shouldFail) { this.$errors.failWithoutHelp(errorMessage); } else { this.$logger.warn(errorMessage); diff --git a/test/cocoapods-service.ts b/test/cocoapods-service.ts index 49602612d0..e00ea20393 100644 --- a/test/cocoapods-service.ts +++ b/test/cocoapods-service.ts @@ -2,6 +2,7 @@ import * as yok from "../lib/common/yok"; import { assert } from "chai"; import { CocoaPodsService } from "../lib/services/cocoapods-service"; import { EOL } from "os"; +import { LoggerStub, ErrorsStub } from "./stubs"; interface IMergePodfileHooksTestCase { input: string; @@ -16,6 +17,11 @@ function createTestInjector(): IInjector { testInjector.register("fs", {}); testInjector.register("cocoapodsService", CocoaPodsService); + testInjector.register("childProcess", {}); + testInjector.register("errors", ErrorsStub); + testInjector.register("xcprojService", {}); + testInjector.register("logger", LoggerStub); + testInjector.register("config", {}); return testInjector; } @@ -300,6 +306,50 @@ end post_install do |installer| post_installplugin1_0 installer end +end`, + }, + { + testCaseDescription: "adds plugin with postinstall when project's Podfile has content, but does not have postinstall", + input: ` +target 'MyApp' do + pod 'GoogleAnalytics', '~> 3.1' +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + puts target.name + end +end`, + output: `use_frameworks! + +target "projectName" do +# Begin Podfile - pluginPlatformsFolderPath/Podfile + +target 'MyApp' do + pod 'GoogleAnalytics', '~> 3.1' +end + +def post_installplugin1_0 (installer) + installer.pods_project.targets.each do |target| + puts target.name + end +end +# End Podfile + +# Begin Podfile - secondPluginPlatformsFolderPath/Podfile +pod 'OCMock', '~> 2.0.1' +# End Podfile + +post_install do |installer| + post_installplugin1_0 installer +end +end`, + projectPodfileContent: `use_frameworks! + +target "projectName" do +# Begin Podfile - secondPluginPlatformsFolderPath/Podfile +pod 'OCMock', '~> 2.0.1' +# End Podfile end`, }, { @@ -676,4 +726,181 @@ end` }); }); }); + + describe("executePodInstall", () => { + const projectRoot = "nativeProjectRoot"; + const xcodeProjPath = "xcodeProjectPath"; + + beforeEach(() => { + const childProcess = testInjector.resolve("childProcess"); + childProcess.exec = async (command: string, options?: any, execOptions?: IExecOptions): Promise => null; + childProcess.spawnFromEvent = async (command: string, args: string[], event: string, options?: any, spawnFromEventOptions?: ISpawnFromEventOptions): Promise => ({ + stdout: "", + stderr: "", + exitCode: 0 + }); + + const xcprojService = testInjector.resolve("xcprojService"); + xcprojService.verifyXcproj = async (opts: IVerifyXcprojOptions): Promise => false; + xcprojService.getXcprojInfo = async (): Promise => ({}); + }); + + it("fails when pod executable is not found", async () => { + const childProcess = testInjector.resolve("childProcess"); + childProcess.exec = async (command: string, options?: any, execOptions?: IExecOptions): Promise => { + assert.equal(command, "which pod"); + throw new Error("Missing pod executable"); + }; + + await assert.isRejected(cocoapodsService.executePodInstall(projectRoot, xcodeProjPath), "CocoaPods or ruby gem 'xcodeproj' is not installed. Run `sudo gem install cocoapods` and try again."); + }); + + it("fails when xcodeproj executable is not found", async () => { + const childProcess = testInjector.resolve("childProcess"); + childProcess.exec = async (command: string, options?: any, execOptions?: IExecOptions): Promise => { + if (command === "which pod") { + return; + } + + assert.equal(command, "which xcodeproj"); + throw new Error("Missing xcodeproj executable"); + + }; + + await assert.isRejected(cocoapodsService.executePodInstall(projectRoot, xcodeProjPath), "CocoaPods or ruby gem 'xcodeproj' is not installed. Run `sudo gem install cocoapods` and try again."); + }); + + it("fails with correct error when xcprojService.verifyXcproj throws", async () => { + const expectedError = new Error("err"); + const xcprojService = testInjector.resolve("xcprojService"); + xcprojService.verifyXcproj = async (opts: IVerifyXcprojOptions): Promise => { + throw expectedError; + }; + + await assert.isRejected(cocoapodsService.executePodInstall(projectRoot, xcodeProjPath), expectedError); + }); + + ["pod", "sandbox-pod"].forEach(podExecutable => { + it(`uses ${podExecutable} executable when USE_POD_SANDBOX is ${podExecutable === "sandbox-pod"}`, async () => { + const config = testInjector.resolve("config"); + config.USE_POD_SANDBOX = podExecutable === "sandbox-pod"; + const childProcess = testInjector.resolve("childProcess"); + let commandCalled = ""; + childProcess.spawnFromEvent = async (command: string, args: string[], event: string, options?: any, spawnFromEventOptions?: ISpawnFromEventOptions): Promise => { + commandCalled = command; + return { + stdout: "", + stderr: "", + exitCode: 0 + }; + }; + + await cocoapodsService.executePodInstall(projectRoot, xcodeProjPath); + assert.equal(commandCalled, podExecutable); + }); + }); + + it("calls xcprojService.verifyXcproj with correct arguments", async () => { + const xcprojService = testInjector.resolve("xcprojService"); + let optsPassedToVerifyXcproj: any = null; + xcprojService.verifyXcproj = async (opts: IVerifyXcprojOptions): Promise => { + optsPassedToVerifyXcproj = opts; + return false; + }; + + await cocoapodsService.executePodInstall(projectRoot, xcodeProjPath); + assert.deepEqual(optsPassedToVerifyXcproj, { shouldFail: true }); + }); + + it("calls pod install spawnFromEvent with correct arguments", async () => { + const childProcess = testInjector.resolve("childProcess"); + let commandCalled = ""; + childProcess.spawnFromEvent = async (command: string, args: string[], event: string, options?: any, spawnFromEventOptions?: ISpawnFromEventOptions): Promise => { + commandCalled = command; + assert.deepEqual(args, ["install"]); + assert.equal(event, "close"); + assert.deepEqual(options, { cwd: projectRoot, stdio: ['pipe', process.stdout, process.stdout] }); + assert.deepEqual(spawnFromEventOptions, { throwError: false }); + return { + stdout: "", + stderr: "", + exitCode: 0 + }; + }; + + await cocoapodsService.executePodInstall(projectRoot, xcodeProjPath); + assert.equal(commandCalled, "pod"); + }); + + it("fails when pod install exits with code that is not 0", async () => { + const childProcess = testInjector.resolve("childProcess"); + childProcess.spawnFromEvent = async (command: string, args: string[], event: string, options?: any, spawnFromEventOptions?: ISpawnFromEventOptions): Promise => { + return { + stdout: "", + stderr: "", + exitCode: 1 + }; + }; + + await assert.isRejected(cocoapodsService.executePodInstall(projectRoot, xcodeProjPath), "'pod install' command failed."); + }); + + it("returns the result of the pod install spawnFromEvent methdo", async () => { + const childProcess = testInjector.resolve("childProcess"); + const expectedResult = { + stdout: "pod install finished", + stderr: "", + exitCode: 0 + }; + childProcess.spawnFromEvent = async (command: string, args: string[], event: string, options?: any, spawnFromEventOptions?: ISpawnFromEventOptions): Promise => { + return expectedResult; + }; + + const result = await cocoapodsService.executePodInstall(projectRoot, xcodeProjPath); + assert.deepEqual(result, expectedResult); + }); + + it("executes xcproj command with correct arguments when is true", async () => { + const xcprojService = testInjector.resolve("xcprojService"); + xcprojService.getXcprojInfo = async (): Promise => ({ + shouldUseXcproj: true + }); + + const spawnFromEventCalls: any[] = []; + const childProcess = testInjector.resolve("childProcess"); + childProcess.spawnFromEvent = async (command: string, args: string[], event: string, options?: any, spawnFromEventOptions?: ISpawnFromEventOptions): Promise => { + spawnFromEventCalls.push({ + command, + args, + event, + options, + spawnFromEventOptions + }); + return { + stdout: "", + stderr: "", + exitCode: 0 + }; + }; + + await cocoapodsService.executePodInstall(projectRoot, xcodeProjPath); + assert.deepEqual(spawnFromEventCalls, [ + { + command: "pod", + args: ["install"], + event: "close", + options: { cwd: projectRoot, stdio: ['pipe', process.stdout, process.stdout] }, + spawnFromEventOptions: { throwError: false } + }, + { + command: "xcproj", + args: ["--project", xcodeProjPath, "touch"], + event: "close", + options: undefined, + spawnFromEventOptions: undefined + } + ]); + + }); + }); });