diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md index d537fa081c..6552337bd7 100644 --- a/docs/man_pages/project/testing/build-android.md +++ b/docs/man_pages/project/testing/build-android.md @@ -13,13 +13,14 @@ Builds the project for Android and produces an APK that you can manually deploy Usage | Synopsis ---|--- -General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--copy-to ] [--env.*]] [--aab]` +General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--compiled]] [--copy-to ] [--env.*]] [--aab]` ### Options * `--compileSdk` - Sets the Android SDK that will be used to build the project. `` is a valid Android API level. For example: 28, 29. The minimum supported SDK is 28. <% if(isHtml) { %> For a complete list of the Android API levels and their corresponding Android versions, click [here](http://developer.android.com/guide/topics/manifest/uses-sdk-element.html#platform).<% } %> * `--clean` - If set, forces the complete rebuild of the native application. * `--release` - If set, produces a release build. Otherwise, produces a debug build. When set, you must also specify the `--key-store-*` options. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `release: { compiled: true }` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--key-store-path` - Specifies the file path to the keystore file (P12) which you want to use to code sign your APK. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-password` - Provides the password for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-alias` - Provides the alias for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. diff --git a/docs/man_pages/project/testing/build-ios.md b/docs/man_pages/project/testing/build-ios.md index 41331624bf..1046a967f6 100644 --- a/docs/man_pages/project/testing/build-ios.md +++ b/docs/man_pages/project/testing/build-ios.md @@ -17,11 +17,12 @@ Builds the project for iOS and produces an `APP` or `IPA` that you can manually Usage | Synopsis ---|--- -General | `$ ns build ios [--for-device] [--release] [--copy-to ] [--provision []] [--env.*]]` +General | `$ ns build ios [--for-device] [--release [--compiled]] [--copy-to ] [--provision []] [--env.*]]` ### Options * `--release` - If set, produces a release build. Otherwise, produces a debug build. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `release: { compiled: true }` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--for-device` - If set, produces an application package that you can deploy on device. Otherwise, produces a build that you can run only in the native iOS Simulator. * `--i-cloud-container-environment` - If set, adds the passed `iCloudContainerEnvironment` when exporting an application package with the `--for-device` option. * `--copy-to` - Specifies the file path where the built `.ipa` will be copied. If it points to a non-existent directory path, it will be created. If the specified value is existing directory, the original file name will be used. diff --git a/docs/man_pages/project/testing/compiled-verify.md b/docs/man_pages/project/testing/compiled-verify.md new file mode 100644 index 0000000000..90e75f1cb2 --- /dev/null +++ b/docs/man_pages/project/testing/compiled-verify.md @@ -0,0 +1,46 @@ +<% if (isJekyll) { %>--- +title: ns compiled verify +position: 20 +---<% } %> + +# ns compiled verify + +### Description + +Builds the project's JavaScript release and its compiled release (`--compiled`), runs both the same way on one iOS Simulator and compares them: a screenshot of each step, pixel by pixel, and whether either app stops running. Use it before shipping a compiled release. + +The steps are the project's `verify.json`, when it has one. Each screen launches the app afresh; coordinates are points on the simulator's screen: + +```json +{ + "screens": [ + { "name": "home", "steps": [["shot", "start"], ["tap", 200, 400], ["wait", 1], ["type", "Ada"], ["shot", "typed"]] } + ] +} +``` + +Steps: `shot `, `tap [seconds held]`, `taps [count]`, `swipe `, `drag [seconds]`, `type `, `wait `, `openurl `. Without `verify.json`, the app is compared once it settles after launch. + +The screenshots and `report.json` are written to `platforms/compiled/verify`. The command fails when a screenshot differs or an app stops running. + +<% if(isConsole && (isWindows || isLinux)) { %>WARNING: You can run this command only on macOS systems.<% } %> + +### Commands + +Usage | Synopsis +---|--- +General | `$ ns compiled verify [ios]` + +### Prerequisites + +* `@nativescript/compiler` as a devDependency of the project. +* Xcode with an iOS Simulator runtime. + +<% if(isHtml) { %> + +### Related Commands + +Command | Description +----------|---------- +[build ios](build-ios.html) | Builds the project for iOS, `--compiled` to compile it to native code. +<% } %> diff --git a/docs/man_pages/project/testing/run-android.md b/docs/man_pages/project/testing/run-android.md index ab651eff42..b1b9199aa7 100644 --- a/docs/man_pages/project/testing/run-android.md +++ b/docs/man_pages/project/testing/run-android.md @@ -17,7 +17,7 @@ When running this command without passing `--release` flag, the HMR (Hot Module Usage | Synopsis ---|--- -Run on all connected devices and running emulators | `$ ns run android [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` +Run on all connected devices and running emulators | `$ ns run android [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--compiled]] [--justlaunch] [--env.*]] [--aab]` Run on a selected connected device or running emulator. Will start emulator with specified `Device Identifier`, if not already running. | `$ ns run android --device [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` Start a default emulator if none are running, or run application on all connected emulators. | `$ ns run android --emulator [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` @@ -29,6 +29,7 @@ Start a default emulator if none are running, or run application on all connecte * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. When set, you must also specify the --key-store-* options. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `release: { compiled: true }` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--key-store-path` - Specifies the file path to the keystore file (P12) which you want to use to code sign your APK. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-password` - Provides the password for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-alias` - Provides the alias for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. diff --git a/docs/man_pages/project/testing/run-ios.md b/docs/man_pages/project/testing/run-ios.md index 3e8d23de6f..1b27d2db2d 100644 --- a/docs/man_pages/project/testing/run-ios.md +++ b/docs/man_pages/project/testing/run-ios.md @@ -21,7 +21,7 @@ When running this command without passing `--release` flag, the HMR (Hot Module Usage | Synopsis ---|--- -Run on all connected devices | `$ ns run ios [--release] [--justlaunch] [--env.*]]` +Run on all connected devices | `$ ns run ios [--release [--compiled]] [--justlaunch] [--env.*]]` Run on a selected connected device. Will start simulator with specified `Device Identifier`, if not already running. | `$ ns run ios [--device ] [--release] [--justlaunch] [--env.*]]` Start an emulator and run the app inside it | `$ ns run ios --emulator [--release] [--env.*]]` Start an emulator with specified device name and sdk | `$ ns run ios [--device ] [--sdk ]` @@ -36,6 +36,7 @@ Start an emulator with specified device identifier and sdk | `$ ns run ios [--de * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `release: { compiled: true }` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--no-hmr` - Disables Hot Module Replacement (HMR). In this case, when a change in the code is applied, CLI will transfer the modified files and restart the application. * `--env.*` - Specifies additional flags that the bundler may process. Can be passed multiple times. * `--env.aot` - creates Ahead-Of-Time build (Angular only). diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 634c916e47..0e34542877 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -184,6 +184,7 @@ injector.requireCommand("run|android", "./commands/run"); injector.requireCommand("run|vision", "./commands/run"); injector.requireCommand("run|visionos", "./commands/run"); injector.requireCommand("typings", "./commands/typings"); +injector.requireCommand("compiled|verify", "./commands/compiled-verify"); injector.requireCommand("preview", "./commands/preview"); @@ -423,6 +424,7 @@ injector.require( "viteHmrPortService", "./services/bundler/vite-hmr-port-service", ); +injector.require("compiledReleaseService", "./services/compiled-release-service"); injector.require( "applePortalSessionService", diff --git a/lib/commands/compiled-verify.ts b/lib/commands/compiled-verify.ts new file mode 100644 index 0000000000..28e970ce63 --- /dev/null +++ b/lib/commands/compiled-verify.ts @@ -0,0 +1,89 @@ +import * as path from "path"; +import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; +import { ICommand, ICommandParameter } from "../common/definitions/commands"; +import { injector } from "../common/yok"; +import { IProjectData } from "../definitions/project"; +import { CompiledReleaseService } from "../services/compiled-release-service"; + +/** + * `ns compiled verify [ios]`: builds the JavaScript release and the compiled + * release of the project, runs both the same way on one simulator and compares + * them, a screenshot of each step pixel by pixel and whether either stops + * running. The steps are the project's `verify.json`, when it has one + * (`{ "screens": [{ "name": "home", "steps": [["shot", "start"], ["tap", 200, 400], ["shot", "tapped"]] }] }`); + * without it, a screenshot once the app settles. + */ +export class CompiledVerifyCommand implements ICommand { + public allowedParameters: ICommandParameter[] = []; + + constructor( + private $childProcess: IChildProcess, + private $compiledReleaseService: CompiledReleaseService, + private $errors: IErrors, + private $fs: IFileSystem, + private $logger: ILogger, + private $projectData: IProjectData, + ) {} + + public async execute(args: string[]): Promise { + const platform = (args[0] ?? "ios").toLowerCase(); + if (platform !== "ios") { + this.$errors.fail( + "ns compiled verify compares iOS builds; Android is to follow.", + ); + } + this.$projectData.initializeProjectData(); + const projectDir = this.$projectData.projectDir; + const cli = process.argv[1]; + const ns = (...buildArgs: string[]) => + this.$childProcess.spawnFromEvent( + process.execPath, + [cli, "build", "ios", ...buildArgs], + "close", + { cwd: projectDir, stdio: "inherit" }, + ); + + this.$logger.info("Building the JavaScript release..."); + await ns("--release", "--no-compiled"); + this.$logger.info("Building the compiled release..."); + await ns("--compiled"); + + const js = this.findApp( + path.join(this.$projectData.platformsDir, "ios", "build", "Release-iphonesimulator"), + ); + const compiled = this.findApp( + path.join(this.$projectData.platformsDir, "compiled", "ios", "DerivedData", "Build", "Products", "Release-iphonesimulator"), + ); + const steps = path.join(projectDir, "verify.json"); + const out = path.join(this.$projectData.platformsDir, "compiled", "verify"); + const verify = this.$compiledReleaseService.getCompilerBin( + this.$projectData, + "ns-compiled-verify", + ); + try { + await this.$childProcess.spawnFromEvent( + process.execPath, + [verify, "--js", js, "--compiled", compiled, "--out", out, ...(this.$fs.exists(steps) ? ["--steps", steps] : [])], + "close", + { cwd: projectDir, stdio: "inherit" }, + ); + } catch { + this.$errors.fail( + `The compiled release differs from its JavaScript release: see ${out}`, + ); + } + } + + private findApp(dir: string): string { + const app = this.$fs.exists(dir) + ? this.$fs.readDirectory(dir).find((f) => f.endsWith(".app")) + : undefined; + if (!app) { + this.$errors.fail(`No simulator build in ${dir}.`); + } + + return path.join(dir, app); + } +} + +injector.registerCommand("compiled|verify", CompiledVerifyCommand); diff --git a/lib/common/services/hooks-service.ts b/lib/common/services/hooks-service.ts index c7a843e02d..a5ad5a058c 100644 --- a/lib/common/services/hooks-service.ts +++ b/lib/common/services/hooks-service.ts @@ -26,7 +26,6 @@ import { import { IInjector } from "../definitions/yok"; import { injector } from "../yok"; import { color } from "../../color"; -import { memoize } from "../decorators"; class Hook implements IHook { constructor( @@ -60,27 +59,19 @@ export class HooksService implements IHooksService { return "hookArgs"; } - @memoize({ - shouldCache() { - // only cache if we have hooks directories, the only case to - // not have hooks directories is when the project dir is - // not set yet, ie. when creating a project. - return !!this.hooksDirectories.length; - }, - }) private initialize(projectDir: string): void { - this.cachedHooks = {}; - - this.hooksDirectories = []; - projectDir = projectDir || this.$projectHelper.projectDir; - - if (projectDir) { - this.hooksDirectories.push( - path.join(projectDir, HooksService.HOOKS_DIRECTORY_NAME), - ); + const hooksDirectories = projectDir + ? [path.join(projectDir, HooksService.HOOKS_DIRECTORY_NAME)] + : []; + // Re-read whenever the project changes: `ns create` run inside another project resolves that one first. + if (_.isEqual(hooksDirectories, this.hooksDirectories)) { + return; } + this.cachedHooks = {}; + this.hooksDirectories = hooksDirectories; + this.$logger.trace( "Hooks directories: " + util.inspect(this.hooksDirectories), ); diff --git a/lib/constants.ts b/lib/constants.ts index 7420893fab..b3373f248b 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -16,6 +16,7 @@ export const SCOPED_TNS_CORE_MODULES = "@nativescript/core"; export const TNS_CORE_THEME_NAME = "nativescript-theme-core"; export const SCOPED_TNS_CORE_THEME_NAME = "@nativescript/theme"; export const WEBPACK_PLUGIN_NAME = "@nativescript/webpack"; +export const COMPILER_PACKAGE_NAME = "@nativescript/compiler"; export const RSPACK_PLUGIN_NAME = "@nativescript/rspack"; // Root of the project-relative directory the Vite bundler writes its build // output to before the CLI copies it into the platforms app folder. The CLI diff --git a/lib/controllers/build-controller.ts b/lib/controllers/build-controller.ts index f5ebb1666c..a4d257eb04 100644 --- a/lib/controllers/build-controller.ts +++ b/lib/controllers/build-controller.ts @@ -14,6 +14,8 @@ import { IAnalyticsService, IFileSystem } from "../common/declarations"; import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; import { performance } from "perf_hooks"; +import * as path from "path"; +import { CompiledReleaseService } from "../services/compiled-release-service"; export class BuildController extends EventEmitter implements IBuildController { constructor( @@ -26,7 +28,8 @@ export class BuildController extends EventEmitter implements IBuildController { private $mobileHelper: Mobile.IMobileHelper, private $projectDataService: IProjectDataService, private $projectChangesService: IProjectChangesService, - private $prepareController: IPrepareController + private $prepareController: IPrepareController, + private $compiledReleaseService: CompiledReleaseService ) { super(); } @@ -74,6 +77,24 @@ export class BuildController extends EventEmitter implements IBuildController { }`, }); + if (this.$compiledReleaseService.isCompiledRelease(buildData)) { + const packageFile = await this.$compiledReleaseService.build( + platformData, + projectData, + buildData + ); + this.logBuildTime(startTime); + if (buildData.copyTo) { + const target = path.resolve(buildData.copyTo); + this.$fs.copyFile(packageFile, target); + this.$logger.info(`Copied file '${packageFile}' to '${target}'.`); + } else { + this.$logger.info(`The build result is located at: ${packageFile}`); + } + + return packageFile; + } + if (buildData.clean) { await platformData.platformProjectService.cleanProject( platformData.projectRoot @@ -104,11 +125,7 @@ export class BuildController extends EventEmitter implements IBuildController { buildInfoFileDir ); - const endTime = performance.now(); - const buildTime = (endTime - startTime) / 1000; - - this.$logger.info("Project successfully built."); - this.$logger.info(`Build time: ${buildTime.toFixed(3)} s.`); + this.logBuildTime(startTime); const result = await this.$buildArtifactsService.getLatestAppPackagePath( platformData, @@ -128,6 +145,13 @@ export class BuildController extends EventEmitter implements IBuildController { return result; } + private logBuildTime(startTime: number): void { + const buildTime = (performance.now() - startTime) / 1000; + + this.$logger.info("Project successfully built."); + this.$logger.info(`Build time: ${buildTime.toFixed(3)} s.`); + } + public async buildIfNeeded(buildData: IBuildData): Promise { let result = null; @@ -140,6 +164,11 @@ export class BuildController extends EventEmitter implements IBuildController { } public async shouldBuild(buildData: IBuildData): Promise { + // xcodebuild and Gradle decide what is stale in the native project. + if (this.$compiledReleaseService.isCompiledRelease(buildData)) { + return true; + } + const projectData = this.$projectDataService.getProjectData( buildData.projectDir ); diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index 26e480db97..f18de97b53 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -41,6 +41,7 @@ import { IProjectService, } from "../definitions/project"; import { resolvePackageJSONPath } from "@rigor789/resolve-package-path"; +import { CompiledReleaseService } from "../services/compiled-release-service"; interface IPlatformWatcherData { hasWebpackCompilerProcess: boolean; @@ -81,6 +82,7 @@ export class PrepareController private $markingModeService: IMarkingModeService, private $projectConfigService: IProjectConfigService, private $projectService: IProjectService, + private $compiledReleaseService: CompiledReleaseService, ) { super(); } @@ -143,6 +145,25 @@ export class PrepareController projectData: IProjectData, ): Promise { await this.$projectService.ensureAppResourcesExist(projectData.projectDir); + + // The compiled build needs neither the bundle nor the runtime's platform project. + if (this.$compiledReleaseService.isCompiledRelease(prepareData)) { + await this.$compiledReleaseService.prepare( + this.$platformsDataService.getPlatformData( + prepareData.platform, + projectData, + ), + projectData, + ); + this.$logger.info( + `Project successfully prepared (${prepareData.platform.toLowerCase()}, compiled)`, + ); + return { + hasNativeChanges: true, + platform: prepareData.platform.toLowerCase(), + }; + } + await this.$platformController.addPlatformIfNeeded( prepareData, projectData, diff --git a/lib/data/prepare-data.ts b/lib/data/prepare-data.ts index 0df388906f..9d2a7c0145 100644 --- a/lib/data/prepare-data.ts +++ b/lib/data/prepare-data.ts @@ -4,6 +4,7 @@ import * as _ from "lodash"; export class PrepareData extends ControllerDataBase { public release: boolean; + public compiled?: boolean; public hmr: boolean; public env: any; public watch?: boolean; @@ -34,6 +35,7 @@ export class PrepareData extends ControllerDataBase { } this.release = data.release; + this.compiled = data.compiled; this.hmr = data.hmr || data.useHotModuleReload; this.env = { ...env, diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 79f4559d76..71fe354b4e 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -693,6 +693,7 @@ interface IOptions inspector: boolean; // the counterpart to --chrome background: string; hmr: boolean; + compiled: boolean; link: boolean; performance: Object; cleanupLogFile: string; diff --git a/lib/definitions/prepare.d.ts b/lib/definitions/prepare.d.ts index 0755bc52bb..bb4281553d 100644 --- a/lib/definitions/prepare.d.ts +++ b/lib/definitions/prepare.d.ts @@ -6,6 +6,11 @@ import { IProjectData } from "./project"; declare global { interface IPrepareData extends IControllerDataBase { release: boolean; + /** + * Compile a release build to native code with no JavaScript runtime. + * Unset defers to `release.compiled` in nativescript.config. + */ + compiled?: boolean; hmr: boolean; env: any; watch?: boolean; diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..0b4e17fc11 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -99,8 +99,39 @@ interface IProjectService { ensureAppResourcesExist(projectDir: string): Promise; } +/** + * Release builds compiled to native code by `@nativescript/compiler`, and the + * compiler's options. + */ +interface INsConfigRelease { + /** + * Release builds compile the app to native code instead of bundling it for + * the JavaScript runtime. `--compiled` / `--no-compiled` override it per command. + */ + compiled?: boolean; + /** + * Packages the compiled build replaces with a module of the app's, as + * `'package': 'path'`. + */ + pluginReplacements?: Record; + /** + * Checkouts of plugins' TypeScript source, as `'package': 'path'`, used + * instead of fetching the published revision. + */ + pluginSources?: Record; + /** + * Build despite properties core declares that the compiled build does not + * apply, with a warning for each. + */ + allowUnimplementedProperties?: boolean; +} + interface INsConfigPlaform { id?: string; + /** + * Merged over the top-level `release` for this platform; object options merge by key. + */ + release?: INsConfigRelease; } interface IOSSPMPackageBase { @@ -201,6 +232,7 @@ interface INsConfig { webpackConfigPath?: string; bundlerConfigPath?: string; bundler?: BundlerType; + release?: INsConfigRelease; ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; diff --git a/lib/helpers/livesync-command-helper.ts b/lib/helpers/livesync-command-helper.ts index 432b931e76..3cef37e136 100644 --- a/lib/helpers/livesync-command-helper.ts +++ b/lib/helpers/livesync-command-helper.ts @@ -294,19 +294,25 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { await this.$deployController.deploy({ deviceDescriptors }); - for (const deviceDescriptor of deviceDescriptors) { - const device = this.$devicesService.getDeviceByIdentifier( - deviceDescriptor.identifier, - ); - await device.applicationManager.startApplication({ - appId: - this.$projectData.projectIdentifiers[ - device.deviceInfo.platform.toLowerCase() - ], - projectName: this.$projectData.projectName, - projectDir: this.$projectData.projectDir, - }); - } + // Descriptors cover every attached device; execute narrows them to --device as the deploy did. + await this.$devicesService.execute( + async (device: Mobile.IDevice) => { + await device.applicationManager.startApplication({ + appId: + this.$projectData.projectIdentifiers[ + device.deviceInfo.platform.toLowerCase() + ], + projectName: this.$projectData.projectName, + projectDir: this.$projectData.projectDir, + }); + }, + (device: Mobile.IDevice) => + _.some( + deviceDescriptors, + (deviceDescriptor) => + deviceDescriptor.identifier === device.deviceInfo.identifier, + ), + ); } public async stop() { diff --git a/lib/options.ts b/lib/options.ts index df6ce62f4d..9f47c572ae 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -202,6 +202,7 @@ export class Options { alias: "r", hasSensitiveValue: false, }, + compiled: { type: OptionType.Boolean, hasSensitiveValue: false }, markingMode: { type: OptionType.Boolean, hasSensitiveValue: false }, var: { type: OptionType.Object, hasSensitiveValue: true }, default: { type: OptionType.Boolean, hasSensitiveValue: false }, @@ -517,6 +518,11 @@ export class Options { this.argv.emulator = this.argv.simulator; } + // a compiled build has no debug variant: it is the release build + if (this.argv.compiled) { + this.argv.release = true; + } + this.argv.bundle = "webpack"; this.adjustDashedOptions(); diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts new file mode 100644 index 0000000000..732b24ca59 --- /dev/null +++ b/lib/services/compiled-release-service.ts @@ -0,0 +1,521 @@ +import * as path from "path"; +import { resolvePackagePath } from "@rigor789/resolve-package-path"; +import { COMPILER_PACKAGE_NAME } from "../constants"; +import { IChildProcess, IErrors, IFileSystem, IHooksService } from "../common/declarations"; +import { IPlatformData } from "../definitions/platform"; +import { IProjectData, IProjectDataService } from "../definitions/project"; +import { + IAndroidBuildData, + IBuildData, + IiOSBuildData, +} from "../definitions/build"; +import { IGradleCommandService } from "../definitions/gradle"; +import { IOSProvisionService } from "./ios-provision-service"; +import { injector } from "../common/yok"; +import { hook } from "../common/helpers"; + +export interface ICompiledReleaseOptions { + projectDir: string; + platform: string; + release?: boolean; + compiled?: boolean; +} + +/** + * A release build compiled by `@nativescript/compiler` into Swift or + * Kotlin: no JavaScript runtime, no webpack bundle and no `platforms/` + * runtime project. Prepare writes the native project to + * `platforms/compiled/`; build compiles it there and returns the + * .app, .ipa, .apk or .aab for the deploy services to install. + */ +export class CompiledReleaseService { + constructor( + private $childProcess: IChildProcess, + private $errors: IErrors, + private $exportOptionsPlistService: IExportOptionsPlistService, + private $fs: IFileSystem, + private $gradleCommandService: IGradleCommandService, + public $hooksService: IHooksService, + private $iOSProvisionService: IOSProvisionService, + private $logger: ILogger, + private $mobileHelper: Mobile.IMobileHelper, + private $projectDataService: IProjectDataService, + private $xcodebuildCommandService: IXcodebuildCommandService, + ) {} + + /** + * `--compiled` (or `--no-compiled`) wins; otherwise `release.compiled` in + * nativescript.config, where `ios.release.compiled` and + * `android.release.compiled` override the top-level value. Debug builds + * always run on the JavaScript runtime. + */ + public isCompiledRelease(options: ICompiledReleaseOptions): boolean { + if (!options.release) { + return false; + } + + if (typeof options.compiled === "boolean") { + return options.compiled; + } + + const nsConfig = this.$projectDataService.getProjectData( + options.projectDir, + ).nsConfig; + const platformConfig: { release?: { compiled?: boolean } } = + nsConfig?.[options.platform.toLowerCase() as "ios" | "android"]; + + return !!( + platformConfig?.release?.compiled ?? nsConfig?.release?.compiled + ); + } + + private getProjectRoot(projectData: IProjectData, platform: string): string { + return path.join( + projectData.platformsDir, + "compiled", + platform.toLowerCase(), + ); + } + + public async prepare( + platformData: IPlatformData, + projectData: IProjectData, + ): Promise { + const platform = platformData.platformNameLowerCase; + if ( + !this.$mobileHelper.isiOSPlatform(platform) && + !this.$mobileHelper.isAndroidPlatform(platform) + ) { + this.$errors.fail( + `A compiled release build is available for iOS and Android, not ${platformData.normalizedPlatformName}.`, + ); + } + + const args = [ + this.getCompilerPath(projectData), + projectData.projectDir, + "--platform", + platform, + "--out", + this.getProjectRoot(projectData, platform), + "--name", + projectData.projectName, + "--bundle", + projectData.projectIdentifiers[platform], + ]; + + this.$logger.info(`Compiling the app to native code (${platform})...`); + try { + await this.$childProcess.spawnFromEvent(process.execPath, args, "close", { + cwd: projectData.projectDir, + stdio: "inherit", + }); + } catch (err) { + this.$errors.fail(`The compile to native code failed. ${err.message}`); + } + } + + public async build( + platformData: IPlatformData, + projectData: IProjectData, + buildData: IBuildData, + ): Promise { + if (buildData.clean) { + const projectRoot = this.getProjectRoot( + projectData, + platformData.platformNameLowerCase, + ); + this.$fs.deleteDirectory(path.join(projectRoot, "DerivedData")); + this.$fs.deleteDirectory(path.join(projectRoot, "build")); + } + + const platform = platformData.platformNameLowerCase; + const projectRoot = this.getProjectRoot(projectData, platform); + const packageFile = this.$mobileHelper.isAndroidPlatform(platform) + ? await this.buildAndroidProject(projectRoot, projectData, buildData) + : await this.buildIOSProject(projectRoot, projectData, buildData); + if (!this.$fs.exists(packageFile)) { + this.$errors.fail( + `The compiled release build finished without producing ${packageFile}.`, + ); + } + + return packageFile; + } + + // The platform services' build hooks, with the arguments they pass, so a project's + // before-/after-build hooks run for a compiled build too; projectRoot is the compiled project. + @hook("buildIOS") + public async buildIOSProject( + projectRoot: string, + projectData: IProjectData, + buildData: IiOSBuildData, + ): Promise { + return this.buildIOS(projectData, buildData); + } + + @hook("buildAndroid") + public async buildAndroidProject( + projectRoot: string, + projectData: IProjectData, + buildData: IAndroidBuildData, + ): Promise { + return this.buildAndroid(projectData, buildData); + } + + private getCompilerPath(projectData: IProjectData): string { + return this.getCompilerBin(projectData, "ns-native"); + } + + /** A command of the project's `@nativescript/compiler` (`ns-native`, `ns-compiled-verify`), as a script for Node. */ + public getCompilerBin(projectData: IProjectData, name: string): string { + const packageDir = this.getPackageDir(projectData); + const { bin } = this.$fs.readJson(path.join(packageDir, "package.json")); + if (!bin?.[name]) { + this.$errors.fail( + `The project's ${COMPILER_PACKAGE_NAME} has no ${name}: update it.`, + ); + } + + return path.join(packageDir, bin[name]); + } + + private getPackageDir(projectData: IProjectData): string { + const packageDir = resolvePackagePath(COMPILER_PACKAGE_NAME, { + paths: [projectData.projectDir], + }); + if (!packageDir) { + this.$errors.fail( + `A compiled release build needs ${COMPILER_PACKAGE_NAME} in the project. ` + + `Install it with 'npm install --save-dev ${COMPILER_PACKAGE_NAME}', ` + + `or build on the JavaScript runtime with --no-compiled.`, + ); + } + + return packageDir; + } + + private async buildIOS( + projectData: IProjectData, + buildData: IiOSBuildData, + ): Promise { + const projectRoot = this.getProjectRoot(projectData, "ios"); + const name = projectData.projectName; + + // The compiler generates the Xcode project (and integrates CocoaPods) and names + // the project or workspace to build; a compiler that does not is run through XcodeGen here. + const integrated = path.join(projectRoot, "ns-native-project.json"); + const generated: { workspace?: string; project?: string } | null = this.$fs.exists(integrated) + ? this.$fs.readJson(integrated) + : null; + const workspace = generated?.workspace ?? null; + if (!generated) { + try { + await this.$childProcess.spawnFromEvent( + "xcodegen", + ["generate", "--quiet"], + "close", + { cwd: projectRoot, stdio: "inherit" }, + ); + } catch (err) { + this.$errors.fail( + err.code === "ENOENT" + ? "The native iOS build generates its Xcode project with XcodeGen. Install it with 'brew install xcodegen'." + : err.message, + ); + } + } + + const projectArgs = [ + ...(workspace + ? ["-workspace", workspace] + : ["-project", generated?.project ?? `${name}.xcodeproj`]), + "-scheme", + name, + "-configuration", + "Release", + "-derivedDataPath", + "DerivedData", + "-quiet", + ]; + + if (!buildData.buildForDevice && !buildData.buildForAppStore) { + await this.$xcodebuildCommandService.executeCommand( + [ + ...projectArgs, + "-destination", + "generic/platform=iOS Simulator", + // A generic destination builds every architecture whatever ONLY_ACTIVE_ARCH says; + // a simulator app only ever runs on this Mac. + "ARCHS=$(NATIVE_ARCH_ACTUAL)", + "build", + ], + { cwd: projectRoot, message: "Xcode build (compiled release)..." }, + ); + + return path.join( + projectRoot, + "DerivedData", + "Build", + "Products", + "Release-iphonesimulator", + `${name}.app`, + ); + } + + const outputDir = path.join(projectRoot, "build"); + const archivePath = path.join(outputDir, `${name}.xcarchive`); + // Signing settings given to xcodebuild reach every target, and the kit's + // Swift package rejects a provisioning profile, so with --provision the + // archive stays unsigned and the export signs the app with the profile. + // The generated project leaves device builds signable (for Xcode), so an + // archive without a team says it is unsigned here. + const automaticSigning = + buildData.teamId && !buildData.provision + ? [ + "CODE_SIGNING_ALLOWED=YES", + "CODE_SIGN_STYLE=Automatic", + `DEVELOPMENT_TEAM=${buildData.teamId}`, + "-allowProvisioningUpdates", + ] + : ["CODE_SIGNING_ALLOWED=NO"]; + this.$fs.deleteDirectory(archivePath); + await this.$xcodebuildCommandService.executeCommand( + [ + ...projectArgs, + "-destination", + "generic/platform=iOS", + "-archivePath", + archivePath, + "archive", + ...automaticSigning, + ], + { cwd: projectRoot, message: "Xcode archive (compiled release)..." }, + ); + + if (!buildData.teamId && !buildData.provision) { + this.$logger.warn( + `The archive is unsigned: pass --team-id or --provision to sign it and export an .ipa that installs on a device. ` + + `The unsigned .ipa is for re-signing elsewhere.`, + ); + return this.packageUnsignedIpa(archivePath, outputDir, name); + } + + const exportOptions = buildData.provision + ? await this.createProvisionExportOptions( + projectData, + buildData.provision, + outputDir, + ) + : buildData.buildForAppStore + ? await this.$exportOptionsPlistService.createDistributionExportOptionsPlist( + archivePath, + projectData, + buildData, + ) + : await this.$exportOptionsPlistService.createDevelopmentExportOptionsPlist( + archivePath, + projectData, + buildData, + ); + await this.$xcodebuildCommandService.executeCommand( + [ + "-exportArchive", + "-archivePath", + archivePath, + "-exportPath", + exportOptions.exportFileDir, + "-exportOptionsPlist", + exportOptions.exportOptionsPlistFilePath, + ...(buildData.provision ? [] : ["-allowProvisioningUpdates"]), + ], + { cwd: projectRoot, message: "Exporting the .ipa..." }, + ); + + return exportOptions.exportFilePath; + } + + private async createProvisionExportOptions( + projectData: IProjectData, + provision: string, + outputDir: string, + ): Promise { + const bundleId = projectData.projectIdentifiers.ios; + const profile = await this.$iOSProvisionService.pick(provision, bundleId); + if (!profile) { + this.$errors.fail( + `Failed to find mobile provision with UUID or Name: ${provision}`, + ); + } + + const method = { + Development: "development", + AdHoc: "ad-hoc", + Distribution: "app-store", + Enterprise: "enterprise", + }[profile.Type]; + const exportOptionsPlistFilePath = path.join( + outputDir, + "ExportOptions.plist", + ); + this.$fs.writeFile( + exportOptionsPlistFilePath, + ` + + + + method + ${method} + signingStyle + manual + teamID + ${profile.TeamIdentifier[0]} + provisioningProfiles + + ${bundleId} + ${profile.UUID} + + + +`, + ); + + return { + exportFileDir: outputDir, + exportFilePath: path.join(outputDir, `${projectData.projectName}.ipa`), + exportOptionsPlistFilePath, + }; + } + + private async packageUnsignedIpa( + archivePath: string, + outputDir: string, + name: string, + ): Promise { + const payloadDir = path.join(outputDir, "Payload"); + const ipaPath = path.join(outputDir, `${name}.ipa`); + this.$fs.deleteDirectory(payloadDir); + this.$fs.deleteFile(ipaPath); + this.$fs.ensureDirectoryExists(payloadDir); + await this.$childProcess.spawnFromEvent( + "cp", + [ + "-R", + path.join(archivePath, "Products", "Applications", `${name}.app`), + payloadDir, + ], + "close", + ); + await this.$childProcess.spawnFromEvent( + "zip", + ["-qr", "-y", ipaPath, "Payload"], + "close", + { cwd: outputDir }, + ); + this.$fs.deleteDirectory(payloadDir); + + return ipaPath; + } + + /** Kotlin's errors in a failed build, at the TypeScript lines the compiled statements came from. */ + private async printKotlinErrorsInSource( + projectData: IProjectData, + projectRoot: string, + log: string, + ): Promise { + const errors = log.split("\n").filter((line) => /^e: /.test(line)); + if (!errors.length || !this.$fs.exists(path.join(projectRoot, "source-lines.json"))) { + return; + } + const logFile = path.join(projectRoot, "build", "compile-errors.log"); + this.$fs.writeFile(logFile, errors.join("\n") + "\n"); + try { + const result = await this.$childProcess.spawnFromEvent( + process.execPath, + [this.getCompilerBin(projectData, "ns-native-retrace"), "--build-log", projectRoot, logFile], + "close", + { cwd: projectData.projectDir }, + { throwError: false }, + ); + const mapped = result.stdout.split("\n").filter((line, k) => line && line !== errors[k]); + if (mapped.length) { + this.$logger.error(`Kotlin errors in your source:\n${mapped.join("\n")}`); + } + } catch (err) { + this.$logger.trace(`Mapping Kotlin errors to source lines failed: ${err.message}`); + } + } + + private async buildAndroid( + projectData: IProjectData, + buildData: IAndroidBuildData, + ): Promise { + const projectRoot = this.getProjectRoot(projectData, "android"); + const args = [ + "-p", + projectRoot, + buildData.androidBundle ? ":bundleRelease" : ":assembleRelease", + "--quiet", + ]; + + // AGP's injected signing replaces the generated project's debug signing + // for this build only, as Android Studio's "Generate Signed Bundle" does. + if (buildData.keyStorePath) { + args.push( + `-Pandroid.injected.signing.store.file=${path.resolve(buildData.keyStorePath)}`, + `-Pandroid.injected.signing.store.password=${buildData.keyStorePassword}`, + `-Pandroid.injected.signing.key.alias=${buildData.keyStoreAlias}`, + `-Pandroid.injected.signing.key.password=${buildData.keyStoreAliasPassword}`, + ); + } + + // Gradle's output is shown as it comes and kept: Kotlin's errors point into + // the generated files, and the compiler's line table names the TypeScript + // line each came from. + const output: string[] = []; + const eventName = "compiledReleaseGradleOutput"; + const onOutput = ({ data, pipe }: { data: Buffer | string; pipe: string }) => { + output.push(data.toString()); + (pipe === "stderr" ? process.stderr : process.stdout).write(data); + }; + this.$childProcess.on(eventName, onOutput); + try { + await this.$gradleCommandService.executeCommand(args, { + cwd: projectRoot, + message: "Gradle build (compiled release)...", + gradlePath: + buildData.gradlePath ?? + path.join(this.getPackageDir(projectData), "kit-android", "gradlew"), + stdio: "pipe", + spawnOptions: { throwError: true, emitOptions: { eventName } }, + }); + } catch (err) { + await this.printKotlinErrorsInSource(projectData, projectRoot, output.join("")); + throw err; + } finally { + this.$childProcess.removeListener(eventName, onOutput); + } + + // The generated project is the app module itself, so AGP names its + // outputs after the root project, which the compiler names --name. + const outputName = projectData.projectName; + return buildData.androidBundle + ? path.join( + projectRoot, + "build", + "outputs", + "bundle", + "release", + `${outputName}-release.aab`, + ) + : path.join( + projectRoot, + "build", + "outputs", + "apk", + "release", + `${outputName}-release.apk`, + ); + } +} +injector.register("compiledReleaseService", CompiledReleaseService); diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts index ed8c1d9f66..c0ca3a6ece 100644 --- a/test/compat/legacy-hooks.ts +++ b/test/compat/legacy-hooks.ts @@ -92,6 +92,28 @@ describe("legacy hook contract", () => { assert.include(logger().traceOutput, "hooks.param-name-signature"); }); + it("runs a new project's hooks when an earlier hook ran for the enclosing project", async () => { + writeHook( + projectDir, + "after-outer", + `module.exports = function () { global.__hookCapture.outer = true; };`, + ); + const created = path.join(projectDir, "created"); + writeHook( + created, + "after-createProject", + `module.exports = function (hookArgs) { global.__hookCapture.created = hookArgs.projectDir; };`, + ); + + await hooksService().executeAfterHooks("outer", { hookArgs: {} }); + await hooksService().executeAfterHooks("createProject", { + hookArgs: { projectDir: created }, + }); + + assert.isTrue(capture.outer); + assert.strictEqual(capture.created, created); + }); + it("derives the hook name from the pre-| part of hierarchical command names", async () => { writeHook( projectDir, diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index e3982de1e4..254fba23e7 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -151,6 +151,41 @@ describe("prepareController", () => { }); }); + describe("preparePlatform for a compiled release", () => { + _.each(["ios", "android"], (platform) => { + it(`compiles to native code instead of bundling or adding the ${platform} runtime`, async () => { + const injector = createTestInjector({ hasNativeChanges: false }); + let isAddPlatformCalled = false; + injector.resolve("platformController").addPlatformIfNeeded = () => { + isAddPlatformCalled = true; + }; + const nativePrepareCalls: any[] = []; + Object.assign(injector.resolve("compiledReleaseService"), { + isCompiledRelease: (data: IPrepareData) => data.release, + prepare: async (platformData: any, projectData: any) => { + nativePrepareCalls.push({ platformData, projectData }); + }, + }); + + const prepareController: PrepareController = + injector.resolve("prepareController"); + const result = await prepareController.prepare({ + ...prepareData, + release: true, + watch: false, + platform, + }); + + assert.lengthOf(nativePrepareCalls, 1); + assert.isFalse(isCompileWithoutWatchCalled); + assert.isFalse(isNativePrepareCalled); + assert.isFalse(isAddPlatformCalled); + assert.isTrue(isEnsuringAppResourcesExist); + assert.deepStrictEqual(result, { hasNativeChanges: true, platform }); + }); + }); + }); + describe("preparePlatform without watch", () => { _.each(["ios", "android"], (platform) => { it("shouldn't start the watcher when watch is false", async () => { diff --git a/test/options.ts b/test/options.ts index e6e2557fd1..7c23c78681 100644 --- a/test/options.ts +++ b/test/options.ts @@ -486,6 +486,11 @@ describe("options", () => { args: ["--debugBrk"], expectedHmrValue: false, }, + { + name: "should set hmr to false by default when --compiled option is provided", + args: ["--compiled"], + expectedHmrValue: false, + }, ]; _.each(testCases, (testCase) => { @@ -502,6 +507,48 @@ describe("options", () => { }); }); }); + + describe("compiled option", () => { + const testCases = [ + { + name: "is a release build", + args: ["--compiled"], + expected: { compiled: true, release: true }, + }, + { + name: "stays a release build with --release", + args: ["--release", "--compiled"], + expected: { compiled: true, release: true }, + }, + { + name: "is left unset without the flag, so the config decides", + args: ["--release"], + expected: { compiled: undefined, release: true }, + }, + { + name: "can be turned off with --no-compiled", + args: ["--release", "--no-compiled"], + expected: { compiled: false, release: true }, + }, + ]; + + _.each(testCases, (testCase) => { + it(testCase.name, () => { + testCase.args.forEach((arg) => process.argv.push(arg)); + + const options: any = createOptions(testInjector); + options.setupOptions(null); + + testCase.args.forEach(() => process.argv.pop()); + + assert.deepStrictEqual( + { compiled: options.compiled, release: options.release }, + testCase.expected, + ); + assert.isFalse(isExecutionStopped); + }); + }); + }); }); }); diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts new file mode 100644 index 0000000000..ea27f41500 --- /dev/null +++ b/test/services/compiled-release-service.ts @@ -0,0 +1,477 @@ +import { assert } from "chai"; +import { EventEmitter } from "events"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Yok } from "../../lib/common/yok"; +import { IInjector } from "../../lib/common/definitions/yok"; +import { CompiledReleaseService } from "../../lib/services/compiled-release-service"; +import { IGradleCommandOptions } from "../../lib/definitions/gradle"; + +interface ISpawnCall { + command: string; + args: string[]; + options: any; +} + +let projectDir: string; +let spawnCalls: ISpawnCall[]; +let gradleCalls: { args: string[]; options: IGradleCommandOptions }[]; +let xcodebuildCalls: string[][]; +let writtenFiles: { [path: string]: string }; +let warnings: string[]; +let hookCalls: { name: string; args: any }[]; +let errorsLogged: string[]; +let gradleOutput: { data: string; pipe: string }[] | null; +let retraceOutput: string; + +function createProjectData(nsConfig: any = {}): any { + return { + projectDir, + projectName: "demo", + platformsDir: path.join(projectDir, "platforms"), + projectIdentifiers: { + ios: "org.example.demo", + android: "org.example.demo", + }, + nsConfig, + }; +} + +function createService(nsConfig: any = {}): CompiledReleaseService { + const injector: IInjector = new Yok(); + const childProcess = Object.assign(new EventEmitter(), { + spawnFromEvent: async ( + command: string, + args: string[], + _: string, + options: any, + ) => { + spawnCalls.push({ command, args, options }); + return { stdout: retraceOutput, stderr: "", exitCode: 0 }; + }, + }); + injector.register("childProcess", childProcess); + injector.register("errors", { + fail: (message: string) => { + throw new Error(message); + }, + }); + injector.register("exportOptionsPlistService", {}); + injector.register("fs", { + exists: (file: string) => + path.basename(file) === "ns-native-project.json" + ? fs.existsSync(file) + : true, + readJson: (file: string) => JSON.parse(fs.readFileSync(file, "utf8")), + deleteDirectory: (): void => undefined, + deleteFile: (): void => undefined, + ensureDirectoryExists: (): void => undefined, + writeFile: (file: string, content: string) => { + writtenFiles[file] = content; + }, + }); + injector.register("gradleCommandService", { + executeCommand: async (args: string[], options: IGradleCommandOptions) => { + gradleCalls.push({ args, options }); + if (gradleOutput) { + for (const chunk of gradleOutput) { + childProcess.emit(options.spawnOptions.emitOptions.eventName, chunk); + } + throw new Error("Command gradlew failed with exit code 1"); + } + }, + }); + injector.register("iOSProvisionService", { + pick: async (uuidOrName: string) => + uuidOrName === "Demo Profile" + ? { UUID: "1234-ABCD", Type: "AdHoc", TeamIdentifier: ["TEAM123"] } + : undefined, + }); + injector.register("logger", { + info: (): void => undefined, + warn: (message: string) => warnings.push(message), + error: (message: string) => errorsLogged.push(message), + trace: (): void => undefined, + }); + injector.register("mobileHelper", { + isAndroidPlatform: (platform: string) => + platform.toLowerCase() === "android", + isiOSPlatform: (platform: string) => platform.toLowerCase() === "ios", + }); + injector.register("projectDataService", { + getProjectData: () => createProjectData(nsConfig), + }); + injector.register("xcodebuildCommandService", { + executeCommand: async (args: string[]) => { + xcodebuildCalls.push(args); + }, + }); + injector.register("hooksService", { + hookArgsName: "hookArgs", + executeBeforeHooks: async (name: string, args: any) => { + hookCalls.push({ name: `before-${name}`, args }); + }, + executeAfterHooks: async (name: string, args: any) => { + hookCalls.push({ name: `after-${name}`, args }); + }, + }); + injector.register("compiledReleaseService", CompiledReleaseService); + + return injector.resolve("compiledReleaseService"); +} + +function installCompilerPackage(): string { + const packageDir = path.join( + projectDir, + "node_modules", + "@nativescript", + "compiler", + ); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "@nativescript/compiler", + bin: { + "ns-native": "bin/ns-native.js", + "ns-native-retrace": "bin/ns-native-retrace.js", + }, + }), + ); + return fs.realpathSync(packageDir); +} + +const platformData = (platform: string): any => ({ + platformNameLowerCase: platform.toLowerCase(), + normalizedPlatformName: platform, +}); + +describe("compiledReleaseService", () => { + beforeEach(() => { + projectDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "compiled-release-")), + ); + spawnCalls = []; + gradleCalls = []; + errorsLogged = []; + gradleOutput = null; + retraceOutput = ""; + xcodebuildCalls = []; + writtenFiles = {}; + warnings = []; + hookCalls = []; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + describe("isCompiledRelease", () => { + const testCases = [ + { + name: "a debug build, even with --compiled", + options: { release: false, compiled: true }, + nsConfig: { release: { compiled: true } }, + expected: false, + }, + { + name: "a release build without flag or config", + options: { release: true }, + nsConfig: {}, + expected: false, + }, + { + name: "a release build with --compiled", + options: { release: true, compiled: true }, + nsConfig: {}, + expected: true, + }, + { + name: "a release build with release.compiled in the config", + options: { release: true }, + nsConfig: { release: { compiled: true } }, + expected: true, + }, + { + name: "a release build with --no-compiled over the config", + options: { release: true, compiled: false }, + nsConfig: { release: { compiled: true } }, + expected: false, + }, + { + name: "a release build whose platform opts out in the config", + options: { release: true }, + nsConfig: { + release: { compiled: true }, + android: { release: { compiled: false } }, + }, + expected: false, + }, + { + name: "a release build with --compiled over the platform's opt-out", + options: { release: true, compiled: true }, + nsConfig: { android: { release: { compiled: false } } }, + expected: true, + }, + { + name: "a release build whose platform alone opts in", + options: { release: true }, + nsConfig: { android: { release: { compiled: true } } }, + expected: true, + }, + ]; + + testCases.forEach((testCase) => { + it(`is ${testCase.expected} for ${testCase.name}`, () => { + const service = createService(testCase.nsConfig); + assert.equal( + service.isCompiledRelease({ + projectDir, + platform: "Android", + ...testCase.options, + }), + testCase.expected, + ); + }); + }); + }); + + describe("prepare", () => { + it("runs the project's compiler with the CLI's node, writing to platforms/compiled/", async () => { + const packageDir = installCompilerPackage(); + const service = createService(); + + await service.prepare(platformData("iOS"), createProjectData()); + + assert.lengthOf(spawnCalls, 1); + assert.equal(spawnCalls[0].command, process.execPath); + assert.deepStrictEqual(spawnCalls[0].args, [ + path.join(packageDir, "bin", "ns-native.js"), + projectDir, + "--platform", + "ios", + "--out", + path.join(projectDir, "platforms", "compiled", "ios"), + "--name", + "demo", + "--bundle", + "org.example.demo", + ]); + }); + + it("explains how to install the compiler when the project lacks it", async () => { + const service = createService(); + + let error: Error; + try { + await service.prepare(platformData("iOS"), createProjectData()); + } catch (err) { + error = err; + } + + assert.include( + error.message, + "npm install --save-dev @nativescript/compiler", + ); + assert.lengthOf(spawnCalls, 0); + }); + + it("refuses platforms the compiler does not target", async () => { + installCompilerPackage(); + const service = createService(); + + let error: Error; + try { + await service.prepare(platformData("visionOS"), createProjectData()); + } catch (err) { + error = err; + } + + assert.include(error.message, "not visionOS"); + }); + }); + + describe("build", () => { + const keyStore = { + keyStorePath: "keys/release.keystore", + keyStorePassword: "store-pass", + keyStoreAlias: "upload", + keyStoreAliasPassword: "key-pass", + }; + + it("signs the Android build with the --key-store-* options", async () => { + const packageDir = installCompilerPackage(); + const service = createService(); + + const result = await service.build( + platformData("Android"), + createProjectData(), + { release: true, ...keyStore }, + ); + + const projectRoot = path.join( + projectDir, + "platforms", + "compiled", + "android", + ); + assert.lengthOf(gradleCalls, 1); + assert.deepStrictEqual(gradleCalls[0].args, [ + "-p", + projectRoot, + ":assembleRelease", + "--quiet", + `-Pandroid.injected.signing.store.file=${path.resolve("keys/release.keystore")}`, + "-Pandroid.injected.signing.store.password=store-pass", + "-Pandroid.injected.signing.key.alias=upload", + "-Pandroid.injected.signing.key.password=key-pass", + ]); + assert.equal( + gradleCalls[0].options.gradlePath, + path.join(packageDir, "kit-android", "gradlew"), + ); + assert.equal( + result, + path.join( + projectRoot, + "build", + "outputs", + "apk", + "release", + "demo-release.apk", + ), + ); + }); + + it("names the TypeScript line of each Kotlin error when the Gradle build fails", async () => { + const packageDir = installCompilerPackage(); + const service = createService(); + const projectRoot = path.join(projectDir, "platforms", "compiled", "android"); + const kotlinError = `e: file://${projectRoot}/src/main/kotlin/org/example/demo/Home.kt:14:9 Unresolved reference 'x'.`; + gradleOutput = [ + { data: "> Task :compileReleaseKotlin FAILED\n", pipe: "stdout" }, + { data: `${kotlinError}\n`, pipe: "stderr" }, + ]; + retraceOutput = "e: app/home.ts:7: Unresolved reference 'x'. (Home.kt:14:9)\n"; + + let failure: Error; + try { + await service.build(platformData("Android"), createProjectData(), { release: true, ...keyStore }); + } catch (err) { + failure = err; + } + + assert.match(failure.message, /gradlew failed/); + assert.equal(gradleCalls[0].options.stdio, "pipe"); + const retrace = spawnCalls.find((c) => c.args[0].endsWith("ns-native-retrace.js")); + assert.deepStrictEqual(retrace.args.slice(1, 3), ["--build-log", projectRoot]); + assert.equal(retrace.args[0], path.join(packageDir, "bin", "ns-native-retrace.js")); + assert.equal(writtenFiles[retrace.args[3]], `${kotlinError}\n`); + assert.deepStrictEqual(errorsLogged, ["Kotlin errors in your source:\ne: app/home.ts:7: Unresolved reference 'x'. (Home.kt:14:9)"]); + }); + + it("builds an App Bundle for --aab", async () => { + installCompilerPackage(); + const service = createService(); + + const result = await service.build( + platformData("Android"), + createProjectData(), + { release: true, androidBundle: true, ...keyStore }, + ); + + assert.equal(gradleCalls[0].args[2], ":bundleRelease"); + assert.match(result, /bundle\/release\/demo-release\.aab$/); + }); + + it("builds the iOS simulator app", async () => { + const service = createService(); + + const result = await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: false }, + ); + + assert.deepStrictEqual(spawnCalls[0].args, ["generate", "--quiet"]); + assert.lengthOf(xcodebuildCalls, 1); + assert.include(xcodebuildCalls[0], "generic/platform=iOS Simulator"); + assert.include(xcodebuildCalls[0], "ARCHS=$(NATIVE_ARCH_ACTUAL)"); + assert.match( + result, + /DerivedData\/Build\/Products\/Release-iphonesimulator\/demo\.app$/, + ); + }); + + it("runs the project's build hooks around the compiled build, with the compiled project as projectRoot", async () => { + const service = createService(); + + await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: false }, + ); + + assert.deepStrictEqual( + hookCalls.map((c) => c.name), + ["before-buildIOS", "after-buildIOS"], + ); + assert.equal( + hookCalls[0].args.hookArgs.projectRoot, + path.join(projectDir, "platforms", "compiled", "ios"), + ); + assert.equal(hookCalls[0].args.hookArgs.buildData.release, true); + }); + + it("archives an unsigned device build without --team-id or --provision", async () => { + const service = createService(); + + const result = await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: true }, + ); + + assert.lengthOf(xcodebuildCalls, 1); + assert.include(xcodebuildCalls[0], "archive"); + assert.isFalse(xcodebuildCalls[0].some((a: string) => a.startsWith("ARCHS="))); + assert.notInclude(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=YES"); + assert.include(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=NO"); + assert.lengthOf(warnings, 1); + assert.match(result, /build\/demo\.ipa$/); + }); + + it("signs with --provision at export, leaving the kit's package target unsigned", async () => { + const service = createService(); + + const result = await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: true, provision: "Demo Profile" }, + ); + + assert.lengthOf(xcodebuildCalls, 2); + assert.include(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=NO"); + assert.isFalse( + xcodebuildCalls[0].some((arg) => + arg.startsWith("PROVISIONING_PROFILE"), + ), + ); + assert.include(xcodebuildCalls[1], "-exportArchive"); + const exportOptions = + writtenFiles[ + xcodebuildCalls[1][ + xcodebuildCalls[1].indexOf("-exportOptionsPlist") + 1 + ] + ]; + assert.include(exportOptions, "ad-hoc"); + assert.include(exportOptions, "TEAM123"); + assert.include( + exportOptions, + "org.example.demo\n\t\t1234-ABCD", + ); + assert.match(result, /build\/demo\.ipa$/); + }); + }); +}); diff --git a/test/stubs.ts b/test/stubs.ts index 7be77bc26a..360e4689c9 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -1522,6 +1522,9 @@ export class InjectorStub extends Yok implements IInjector { getDeviceByIdentifier: (): Mobile.IDevice => undefined, }); this.register("terminalSpinnerService", TerminalSpinnerServiceStub); + this.register("compiledReleaseService", { + isCompiledRelease: () => false, + }); } }