From cc1cdd7c9304e5abdacda6f514556e7a0da4d198 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 00:15:28 -0700 Subject: [PATCH 01/16] feat: native release builds with --native and nativeRelease A release build can compile the app to Swift or Kotlin with @nativescript/native-release, a devDependency resolved from the project, instead of bundling it for the JavaScript runtime. - --native (implies --release) or nativeRelease in nativescript.config, per platform under ios/android; --no-native opts out for one build. - Prepare runs the project's compiler into platforms/native/, skipping webpack and the runtime's platform project. - Build runs xcodegen and xcodebuild (simulator .app, or a device archive exported with --team-id/--provision, unsigned otherwise) or Gradle with the --key-store-* options as AGP injected signing, --aab included. - The built package goes to the existing install and launch path, so ns run --release and ns deploy work unchanged. --- .../project/testing/build-android.md | 3 +- docs/man_pages/project/testing/build-ios.md | 3 +- docs/man_pages/project/testing/run-android.md | 3 +- docs/man_pages/project/testing/run-ios.md | 3 +- lib/bootstrap.ts | 1 + lib/constants.ts | 1 + lib/controllers/build-controller.ts | 41 +- lib/controllers/prepare-controller.ts | 21 + lib/data/prepare-data.ts | 2 + lib/declarations.d.ts | 1 + lib/definitions/prepare.d.ts | 5 + lib/definitions/project.d.ts | 10 + lib/options.ts | 6 + lib/services/native-release-service.ts | 424 ++++++++++++++++++ test/controllers/prepare-controller.ts | 35 ++ test/options.ts | 47 ++ test/services/native-release-service.ts | 384 ++++++++++++++++ test/stubs.ts | 3 + 18 files changed, 983 insertions(+), 10 deletions(-) create mode 100644 lib/services/native-release-service.ts create mode 100644 test/services/native-release-service.ts diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md index d537fa081c..5e5db141f0 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 [--native]] [--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. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` 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..565573dbe5 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 [--native]] [--copy-to ] [--provision []] [--env.*]]` ### Options * `--release` - If set, produces a release build. Otherwise, produces a debug build. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` 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/run-android.md b/docs/man_pages/project/testing/run-android.md index ab651eff42..94d8e2cd01 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 [--native]] [--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. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` 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..49bd2d78c0 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 [--native]] [--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. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` 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..87100c64ec 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -423,6 +423,7 @@ injector.require( "viteHmrPortService", "./services/bundler/vite-hmr-port-service", ); +injector.require("nativeReleaseService", "./services/native-release-service"); injector.require( "applePortalSessionService", diff --git a/lib/constants.ts b/lib/constants.ts index 7420893fab..383fc3e623 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 NATIVE_RELEASE_PACKAGE_NAME = "@nativescript/native-release"; 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..a7df443f7e 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 { NativeReleaseService } from "../services/native-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 $nativeReleaseService: NativeReleaseService ) { super(); } @@ -74,6 +77,24 @@ export class BuildController extends EventEmitter implements IBuildController { }`, }); + if (this.$nativeReleaseService.isNativeRelease(buildData)) { + const packageFile = await this.$nativeReleaseService.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.$nativeReleaseService.isNativeRelease(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..4d8e03b9f6 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 { NativeReleaseService } from "../services/native-release-service"; interface IPlatformWatcherData { hasWebpackCompilerProcess: boolean; @@ -81,6 +82,7 @@ export class PrepareController private $markingModeService: IMarkingModeService, private $projectConfigService: IProjectConfigService, private $projectService: IProjectService, + private $nativeReleaseService: NativeReleaseService, ) { super(); } @@ -143,6 +145,25 @@ export class PrepareController projectData: IProjectData, ): Promise { await this.$projectService.ensureAppResourcesExist(projectData.projectDir); + + // The native build needs neither the bundle nor the runtime's platform project. + if (this.$nativeReleaseService.isNativeRelease(prepareData)) { + await this.$nativeReleaseService.prepare( + this.$platformsDataService.getPlatformData( + prepareData.platform, + projectData, + ), + projectData, + ); + this.$logger.info( + `Project successfully prepared (${prepareData.platform.toLowerCase()}, native)`, + ); + 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..5e185ef5e9 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 native?: boolean; public hmr: boolean; public env: any; public watch?: boolean; @@ -34,6 +35,7 @@ export class PrepareData extends ControllerDataBase { } this.release = data.release; + this.native = data.native; this.hmr = data.hmr || data.useHotModuleReload; this.env = { ...env, diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 79f4559d76..b0b6e030fb 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; + native: boolean; link: boolean; performance: Object; cleanupLogFile: string; diff --git a/lib/definitions/prepare.d.ts b/lib/definitions/prepare.d.ts index 0755bc52bb..e15f0111bd 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 `nativeRelease` in nativescript.config. + */ + native?: boolean; hmr: boolean; env: any; watch?: boolean; diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..10fee1ed42 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -101,6 +101,10 @@ interface IProjectService { interface INsConfigPlaform { id?: string; + /** + * Overrides the top-level `nativeRelease` for this platform. + */ + nativeRelease?: boolean; } interface IOSSPMPackageBase { @@ -201,6 +205,12 @@ interface INsConfig { webpackConfigPath?: string; bundlerConfigPath?: string; bundler?: BundlerType; + /** + * Release builds compile the app to native code with + * `@nativescript/native-release` instead of bundling it for the + * JavaScript runtime. `--native` / `--no-native` override it per command. + */ + nativeRelease?: boolean; ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; diff --git a/lib/options.ts b/lib/options.ts index df6ce62f4d..b4f59ea54d 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -202,6 +202,7 @@ export class Options { alias: "r", hasSensitiveValue: false, }, + native: { 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 native build has no debug variant: it is the release build + if (this.argv.native) { + this.argv.release = true; + } + this.argv.bundle = "webpack"; this.adjustDashedOptions(); diff --git a/lib/services/native-release-service.ts b/lib/services/native-release-service.ts new file mode 100644 index 0000000000..2761fe3cac --- /dev/null +++ b/lib/services/native-release-service.ts @@ -0,0 +1,424 @@ +import * as path from "path"; +import { resolvePackagePath } from "@rigor789/resolve-package-path"; +import { NATIVE_RELEASE_PACKAGE_NAME } from "../constants"; +import { IChildProcess, IErrors, IFileSystem } 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"; + +export interface INativeReleaseOptions { + projectDir: string; + platform: string; + release?: boolean; + native?: boolean; +} + +/** + * A release build compiled by `@nativescript/native-release` into Swift or + * Kotlin: no JavaScript runtime, no webpack bundle and no `platforms/` + * runtime project. Prepare writes the native project to + * `platforms/native/`; build compiles it there and returns the + * .app, .ipa, .apk or .aab for the deploy services to install. + */ +export class NativeReleaseService { + constructor( + private $childProcess: IChildProcess, + private $errors: IErrors, + private $exportOptionsPlistService: IExportOptionsPlistService, + private $fs: IFileSystem, + private $gradleCommandService: IGradleCommandService, + private $iOSProvisionService: IOSProvisionService, + private $logger: ILogger, + private $mobileHelper: Mobile.IMobileHelper, + private $projectDataService: IProjectDataService, + private $xcodebuildCommandService: IXcodebuildCommandService, + ) {} + + /** + * `--native` (or `--no-native`) wins; otherwise `nativeRelease` in + * nativescript.config, where `ios.nativeRelease` and `android.nativeRelease` + * override the top-level value. Debug builds always run on the JavaScript + * runtime. + */ + public isNativeRelease(options: INativeReleaseOptions): boolean { + if (!options.release) { + return false; + } + + if (typeof options.native === "boolean") { + return options.native; + } + + const nsConfig = this.$projectDataService.getProjectData( + options.projectDir, + ).nsConfig; + const platformConfig: { nativeRelease?: boolean } = + nsConfig?.[options.platform.toLowerCase() as "ios" | "android"]; + + return !!(platformConfig?.nativeRelease ?? nsConfig?.nativeRelease); + } + + private getProjectRoot(projectData: IProjectData, platform: string): string { + return path.join( + projectData.platformsDir, + "native", + 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 native 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 native release compile 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 packageFile = this.$mobileHelper.isAndroidPlatform( + platformData.platformNameLowerCase, + ) + ? await this.buildAndroid(projectData, buildData) + : await this.buildIOS(projectData, buildData); + if (!this.$fs.exists(packageFile)) { + this.$errors.fail( + `The native release build finished without producing ${packageFile}.`, + ); + } + + return packageFile; + } + + private getCompilerPath(projectData: IProjectData): string { + const packageDir = this.getPackageDir(projectData); + const { bin } = this.$fs.readJson(path.join(packageDir, "package.json")); + + return path.join(packageDir, bin["ns-native"]); + } + + private getPackageDir(projectData: IProjectData): string { + const packageDir = resolvePackagePath(NATIVE_RELEASE_PACKAGE_NAME, { + paths: [projectData.projectDir], + }); + if (!packageDir) { + this.$errors.fail( + `A native release build needs the ${NATIVE_RELEASE_PACKAGE_NAME} compiler in the project. ` + + `Install it with 'npm install --save-dev ${NATIVE_RELEASE_PACKAGE_NAME}', ` + + `or build on the JavaScript runtime with --no-native.`, + ); + } + + return packageDir; + } + + private async buildIOS( + projectData: IProjectData, + buildData: IiOSBuildData, + ): Promise { + const projectRoot = this.getProjectRoot(projectData, "ios"); + const name = projectData.projectName; + + 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 = [ + "-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", + "build", + ], + { cwd: projectRoot, message: "Xcode build (native 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. + const automaticSigning = + buildData.teamId && !buildData.provision + ? [ + "CODE_SIGNING_ALLOWED=YES", + "CODE_SIGN_STYLE=Automatic", + `DEVELOPMENT_TEAM=${buildData.teamId}`, + "-allowProvisioningUpdates", + ] + : []; + this.$fs.deleteDirectory(archivePath); + await this.$xcodebuildCommandService.executeCommand( + [ + ...projectArgs, + "-destination", + "generic/platform=iOS", + "-archivePath", + archivePath, + "archive", + ...automaticSigning, + ], + { cwd: projectRoot, message: "Xcode archive (native 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; + } + + 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}`, + ); + } + + await this.$gradleCommandService.executeCommand(args, { + cwd: projectRoot, + message: "Gradle build (native release)...", + gradlePath: + buildData.gradlePath ?? + path.join(this.getPackageDir(projectData), "kit-android", "gradlew"), + }); + + // 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("nativeReleaseService", NativeReleaseService); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index e3982de1e4..1095654947 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -151,6 +151,41 @@ describe("prepareController", () => { }); }); + describe("preparePlatform for a native 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("nativeReleaseService"), { + isNativeRelease: (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..4843f08b02 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 --native option is provided", + args: ["--native"], + expectedHmrValue: false, + }, ]; _.each(testCases, (testCase) => { @@ -502,6 +507,48 @@ describe("options", () => { }); }); }); + + describe("native option", () => { + const testCases = [ + { + name: "is a release build", + args: ["--native"], + expected: { native: true, release: true }, + }, + { + name: "stays a release build with --release", + args: ["--release", "--native"], + expected: { native: true, release: true }, + }, + { + name: "is left unset without the flag, so the config decides", + args: ["--release"], + expected: { native: undefined, release: true }, + }, + { + name: "can be turned off with --no-native", + args: ["--release", "--no-native"], + expected: { native: 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( + { native: options.native, release: options.release }, + testCase.expected, + ); + assert.isFalse(isExecutionStopped); + }); + }); + }); }); }); diff --git a/test/services/native-release-service.ts b/test/services/native-release-service.ts new file mode 100644 index 0000000000..d83ef7835b --- /dev/null +++ b/test/services/native-release-service.ts @@ -0,0 +1,384 @@ +import { assert } from "chai"; +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 { NativeReleaseService } from "../../lib/services/native-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[]; + +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 = {}): NativeReleaseService { + const injector: IInjector = new Yok(); + injector.register("childProcess", { + spawnFromEvent: async ( + command: string, + args: string[], + _: string, + options: any, + ) => { + spawnCalls.push({ command, args, options }); + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }); + injector.register("errors", { + fail: (message: string) => { + throw new Error(message); + }, + }); + injector.register("exportOptionsPlistService", {}); + injector.register("fs", { + exists: () => 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 }); + }, + }); + 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), + }); + 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("nativeReleaseService", NativeReleaseService); + + return injector.resolve("nativeReleaseService"); +} + +function installCompilerPackage(): string { + const packageDir = path.join( + projectDir, + "node_modules", + "@nativescript", + "native-release", + ); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "@nativescript/native-release", + bin: { "ns-native": "bin/ns-native.js" }, + }), + ); + return fs.realpathSync(packageDir); +} + +const platformData = (platform: string): any => ({ + platformNameLowerCase: platform.toLowerCase(), + normalizedPlatformName: platform, +}); + +describe("nativeReleaseService", () => { + beforeEach(() => { + projectDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "native-release-")), + ); + spawnCalls = []; + gradleCalls = []; + xcodebuildCalls = []; + writtenFiles = {}; + warnings = []; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + describe("isNativeRelease", () => { + const testCases = [ + { + name: "a debug build, even with --native", + options: { release: false, native: true }, + nsConfig: { nativeRelease: true }, + expected: false, + }, + { + name: "a release build without flag or config", + options: { release: true }, + nsConfig: {}, + expected: false, + }, + { + name: "a release build with --native", + options: { release: true, native: true }, + nsConfig: {}, + expected: true, + }, + { + name: "a release build with nativeRelease in the config", + options: { release: true }, + nsConfig: { nativeRelease: true }, + expected: true, + }, + { + name: "a release build with --no-native over the config", + options: { release: true, native: false }, + nsConfig: { nativeRelease: true }, + expected: false, + }, + { + name: "a release build whose platform opts out in the config", + options: { release: true }, + nsConfig: { nativeRelease: true, android: { nativeRelease: false } }, + expected: false, + }, + { + name: "a release build whose platform alone opts in", + options: { release: true }, + nsConfig: { android: { nativeRelease: true } }, + expected: true, + }, + ]; + + testCases.forEach((testCase) => { + it(`is ${testCase.expected} for ${testCase.name}`, () => { + const service = createService(testCase.nsConfig); + assert.equal( + service.isNativeRelease({ + projectDir, + platform: "Android", + ...testCase.options, + }), + testCase.expected, + ); + }); + }); + }); + + describe("prepare", () => { + it("runs the project's compiler with the CLI's node, writing to platforms/native/", 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", "native", "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/native-release", + ); + 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", + "native", + "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("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.match( + result, + /DerivedData\/Build\/Products\/Release-iphonesimulator\/demo\.app$/, + ); + }); + + 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.notInclude(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=YES"); + 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.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..d1ab2a4d5c 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("nativeReleaseService", { + isNativeRelease: () => false, + }); } } From 8a84527aa358a500be502823c2da95bd294b3769 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 16:07:52 -0700 Subject: [PATCH 02/16] native release: build the workspace the compiler integrated when the project has pods or Swift packages --- lib/services/native-release-service.ts | 39 ++++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/lib/services/native-release-service.ts b/lib/services/native-release-service.ts index 2761fe3cac..c5f68d8e5a 100644 --- a/lib/services/native-release-service.ts +++ b/lib/services/native-release-service.ts @@ -168,24 +168,33 @@ export class NativeReleaseService { const projectRoot = this.getProjectRoot(projectData, "ios"); const name = projectData.projectName; - 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, - ); + // A project with CocoaPods or Swift packages is generated and integrated by + // the compiler, which names the workspace to build. + const integrated = path.join(projectRoot, "ns-native-project.json"); + const workspace = this.$fs.exists(integrated) + ? this.$fs.readJson(integrated).workspace + : null; + if (!workspace) { + 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 = [ - "-project", - `${name}.xcodeproj`, + ...(workspace + ? ["-workspace", workspace] + : ["-project", `${name}.xcodeproj`]), "-scheme", name, "-configuration", From cccf2ec7e207f08c8564534cf9564324baa5da1b Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 19:28:49 -0700 Subject: [PATCH 03/16] Rename the native release flag to --compiled and the compiler package to @nativescript/compiler The flag is --compiled (--no-compiled to opt out), the CLI resolves @nativescript/compiler, and builds go to platforms/compiled/. NativeReleaseService becomes CompiledReleaseService. The nativescript.config key nativeRelease is unchanged. --- .../project/testing/build-android.md | 4 +- docs/man_pages/project/testing/build-ios.md | 4 +- docs/man_pages/project/testing/run-android.md | 4 +- docs/man_pages/project/testing/run-ios.md | 4 +- lib/bootstrap.ts | 2 +- lib/constants.ts | 2 +- lib/controllers/build-controller.ts | 10 ++--- lib/controllers/prepare-controller.ts | 12 ++--- lib/data/prepare-data.ts | 4 +- lib/declarations.d.ts | 2 +- lib/definitions/prepare.d.ts | 2 +- lib/definitions/project.d.ts | 4 +- lib/options.ts | 6 +-- ...service.ts => compiled-release-service.ts} | 44 +++++++++--------- test/controllers/prepare-controller.ts | 6 +-- test/options.ts | 24 +++++----- ...service.ts => compiled-release-service.ts} | 45 ++++++++++--------- test/stubs.ts | 4 +- 18 files changed, 93 insertions(+), 90 deletions(-) rename lib/services/{native-release-service.ts => compiled-release-service.ts} (89%) rename test/services/{native-release-service.ts => compiled-release-service.ts} (89%) diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md index 5e5db141f0..17ae0f214a 100644 --- a/docs/man_pages/project/testing/build-android.md +++ b/docs/man_pages/project/testing/build-android.md @@ -13,14 +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 [--native]] [--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. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one 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 `nativeRelease: 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 565573dbe5..27422034b2 100644 --- a/docs/man_pages/project/testing/build-ios.md +++ b/docs/man_pages/project/testing/build-ios.md @@ -17,12 +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 [--native]] [--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. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one 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 `nativeRelease: 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/run-android.md b/docs/man_pages/project/testing/run-android.md index 94d8e2cd01..09754a9fe3 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 [--native]] [--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,7 +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. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one 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 `nativeRelease: 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 49bd2d78c0..de56338cdd 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 [--native]] [--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,7 +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. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one 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 `nativeRelease: 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 87100c64ec..b606e0b8a9 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -423,7 +423,7 @@ injector.require( "viteHmrPortService", "./services/bundler/vite-hmr-port-service", ); -injector.require("nativeReleaseService", "./services/native-release-service"); +injector.require("compiledReleaseService", "./services/compiled-release-service"); injector.require( "applePortalSessionService", diff --git a/lib/constants.ts b/lib/constants.ts index 383fc3e623..b3373f248b 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -16,7 +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 NATIVE_RELEASE_PACKAGE_NAME = "@nativescript/native-release"; +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 a7df443f7e..a4d257eb04 100644 --- a/lib/controllers/build-controller.ts +++ b/lib/controllers/build-controller.ts @@ -15,7 +15,7 @@ import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; import { performance } from "perf_hooks"; import * as path from "path"; -import { NativeReleaseService } from "../services/native-release-service"; +import { CompiledReleaseService } from "../services/compiled-release-service"; export class BuildController extends EventEmitter implements IBuildController { constructor( @@ -29,7 +29,7 @@ export class BuildController extends EventEmitter implements IBuildController { private $projectDataService: IProjectDataService, private $projectChangesService: IProjectChangesService, private $prepareController: IPrepareController, - private $nativeReleaseService: NativeReleaseService + private $compiledReleaseService: CompiledReleaseService ) { super(); } @@ -77,8 +77,8 @@ export class BuildController extends EventEmitter implements IBuildController { }`, }); - if (this.$nativeReleaseService.isNativeRelease(buildData)) { - const packageFile = await this.$nativeReleaseService.build( + if (this.$compiledReleaseService.isCompiledRelease(buildData)) { + const packageFile = await this.$compiledReleaseService.build( platformData, projectData, buildData @@ -165,7 +165,7 @@ 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.$nativeReleaseService.isNativeRelease(buildData)) { + if (this.$compiledReleaseService.isCompiledRelease(buildData)) { return true; } diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index 4d8e03b9f6..f18de97b53 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -41,7 +41,7 @@ import { IProjectService, } from "../definitions/project"; import { resolvePackageJSONPath } from "@rigor789/resolve-package-path"; -import { NativeReleaseService } from "../services/native-release-service"; +import { CompiledReleaseService } from "../services/compiled-release-service"; interface IPlatformWatcherData { hasWebpackCompilerProcess: boolean; @@ -82,7 +82,7 @@ export class PrepareController private $markingModeService: IMarkingModeService, private $projectConfigService: IProjectConfigService, private $projectService: IProjectService, - private $nativeReleaseService: NativeReleaseService, + private $compiledReleaseService: CompiledReleaseService, ) { super(); } @@ -146,9 +146,9 @@ export class PrepareController ): Promise { await this.$projectService.ensureAppResourcesExist(projectData.projectDir); - // The native build needs neither the bundle nor the runtime's platform project. - if (this.$nativeReleaseService.isNativeRelease(prepareData)) { - await this.$nativeReleaseService.prepare( + // 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, @@ -156,7 +156,7 @@ export class PrepareController projectData, ); this.$logger.info( - `Project successfully prepared (${prepareData.platform.toLowerCase()}, native)`, + `Project successfully prepared (${prepareData.platform.toLowerCase()}, compiled)`, ); return { hasNativeChanges: true, diff --git a/lib/data/prepare-data.ts b/lib/data/prepare-data.ts index 5e185ef5e9..9d2a7c0145 100644 --- a/lib/data/prepare-data.ts +++ b/lib/data/prepare-data.ts @@ -4,7 +4,7 @@ import * as _ from "lodash"; export class PrepareData extends ControllerDataBase { public release: boolean; - public native?: boolean; + public compiled?: boolean; public hmr: boolean; public env: any; public watch?: boolean; @@ -35,7 +35,7 @@ export class PrepareData extends ControllerDataBase { } this.release = data.release; - this.native = data.native; + 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 b0b6e030fb..71fe354b4e 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -693,7 +693,7 @@ interface IOptions inspector: boolean; // the counterpart to --chrome background: string; hmr: boolean; - native: boolean; + compiled: boolean; link: boolean; performance: Object; cleanupLogFile: string; diff --git a/lib/definitions/prepare.d.ts b/lib/definitions/prepare.d.ts index e15f0111bd..e0ac8f2016 100644 --- a/lib/definitions/prepare.d.ts +++ b/lib/definitions/prepare.d.ts @@ -10,7 +10,7 @@ declare global { * Compile a release build to native code with no JavaScript runtime. * Unset defers to `nativeRelease` in nativescript.config. */ - native?: boolean; + compiled?: boolean; hmr: boolean; env: any; watch?: boolean; diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 10fee1ed42..428fc8b47c 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -207,8 +207,8 @@ interface INsConfig { bundler?: BundlerType; /** * Release builds compile the app to native code with - * `@nativescript/native-release` instead of bundling it for the - * JavaScript runtime. `--native` / `--no-native` override it per command. + * `@nativescript/compiler` instead of bundling it for the + * JavaScript runtime. `--compiled` / `--no-compiled` override it per command. */ nativeRelease?: boolean; ios?: INsConfigIOS; diff --git a/lib/options.ts b/lib/options.ts index b4f59ea54d..9f47c572ae 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -202,7 +202,7 @@ export class Options { alias: "r", hasSensitiveValue: false, }, - native: { type: OptionType.Boolean, 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 }, @@ -518,8 +518,8 @@ export class Options { this.argv.emulator = this.argv.simulator; } - // a native build has no debug variant: it is the release build - if (this.argv.native) { + // a compiled build has no debug variant: it is the release build + if (this.argv.compiled) { this.argv.release = true; } diff --git a/lib/services/native-release-service.ts b/lib/services/compiled-release-service.ts similarity index 89% rename from lib/services/native-release-service.ts rename to lib/services/compiled-release-service.ts index c5f68d8e5a..026484b1d7 100644 --- a/lib/services/native-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -1,6 +1,6 @@ import * as path from "path"; import { resolvePackagePath } from "@rigor789/resolve-package-path"; -import { NATIVE_RELEASE_PACKAGE_NAME } from "../constants"; +import { COMPILER_PACKAGE_NAME } from "../constants"; import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; import { IPlatformData } from "../definitions/platform"; import { IProjectData, IProjectDataService } from "../definitions/project"; @@ -13,21 +13,21 @@ import { IGradleCommandService } from "../definitions/gradle"; import { IOSProvisionService } from "./ios-provision-service"; import { injector } from "../common/yok"; -export interface INativeReleaseOptions { +export interface ICompiledReleaseOptions { projectDir: string; platform: string; release?: boolean; - native?: boolean; + compiled?: boolean; } /** - * A release build compiled by `@nativescript/native-release` into Swift or + * 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/native/`; build compiles it there and returns the + * `platforms/compiled/`; build compiles it there and returns the * .app, .ipa, .apk or .aab for the deploy services to install. */ -export class NativeReleaseService { +export class CompiledReleaseService { constructor( private $childProcess: IChildProcess, private $errors: IErrors, @@ -42,18 +42,18 @@ export class NativeReleaseService { ) {} /** - * `--native` (or `--no-native`) wins; otherwise `nativeRelease` in + * `--compiled` (or `--no-compiled`) wins; otherwise `nativeRelease` in * nativescript.config, where `ios.nativeRelease` and `android.nativeRelease` * override the top-level value. Debug builds always run on the JavaScript * runtime. */ - public isNativeRelease(options: INativeReleaseOptions): boolean { + public isCompiledRelease(options: ICompiledReleaseOptions): boolean { if (!options.release) { return false; } - if (typeof options.native === "boolean") { - return options.native; + if (typeof options.compiled === "boolean") { + return options.compiled; } const nsConfig = this.$projectDataService.getProjectData( @@ -68,7 +68,7 @@ export class NativeReleaseService { private getProjectRoot(projectData: IProjectData, platform: string): string { return path.join( projectData.platformsDir, - "native", + "compiled", platform.toLowerCase(), ); } @@ -83,7 +83,7 @@ export class NativeReleaseService { !this.$mobileHelper.isAndroidPlatform(platform) ) { this.$errors.fail( - `A native release build is available for iOS and Android, not ${platformData.normalizedPlatformName}.`, + `A compiled release build is available for iOS and Android, not ${platformData.normalizedPlatformName}.`, ); } @@ -107,7 +107,7 @@ export class NativeReleaseService { stdio: "inherit", }); } catch (err) { - this.$errors.fail(`The native release compile failed. ${err.message}`); + this.$errors.fail(`The compile to native code failed. ${err.message}`); } } @@ -132,7 +132,7 @@ export class NativeReleaseService { : await this.buildIOS(projectData, buildData); if (!this.$fs.exists(packageFile)) { this.$errors.fail( - `The native release build finished without producing ${packageFile}.`, + `The compiled release build finished without producing ${packageFile}.`, ); } @@ -147,14 +147,14 @@ export class NativeReleaseService { } private getPackageDir(projectData: IProjectData): string { - const packageDir = resolvePackagePath(NATIVE_RELEASE_PACKAGE_NAME, { + const packageDir = resolvePackagePath(COMPILER_PACKAGE_NAME, { paths: [projectData.projectDir], }); if (!packageDir) { this.$errors.fail( - `A native release build needs the ${NATIVE_RELEASE_PACKAGE_NAME} compiler in the project. ` + - `Install it with 'npm install --save-dev ${NATIVE_RELEASE_PACKAGE_NAME}', ` + - `or build on the JavaScript runtime with --no-native.`, + `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.`, ); } @@ -212,7 +212,7 @@ export class NativeReleaseService { "generic/platform=iOS Simulator", "build", ], - { cwd: projectRoot, message: "Xcode build (native release)..." }, + { cwd: projectRoot, message: "Xcode build (compiled release)..." }, ); return path.join( @@ -250,7 +250,7 @@ export class NativeReleaseService { "archive", ...automaticSigning, ], - { cwd: projectRoot, message: "Xcode archive (native release)..." }, + { cwd: projectRoot, message: "Xcode archive (compiled release)..." }, ); if (!buildData.teamId && !buildData.provision) { @@ -402,7 +402,7 @@ export class NativeReleaseService { await this.$gradleCommandService.executeCommand(args, { cwd: projectRoot, - message: "Gradle build (native release)...", + message: "Gradle build (compiled release)...", gradlePath: buildData.gradlePath ?? path.join(this.getPackageDir(projectData), "kit-android", "gradlew"), @@ -430,4 +430,4 @@ export class NativeReleaseService { ); } } -injector.register("nativeReleaseService", NativeReleaseService); +injector.register("compiledReleaseService", CompiledReleaseService); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index 1095654947..254fba23e7 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -151,7 +151,7 @@ describe("prepareController", () => { }); }); - describe("preparePlatform for a native release", () => { + 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 }); @@ -160,8 +160,8 @@ describe("prepareController", () => { isAddPlatformCalled = true; }; const nativePrepareCalls: any[] = []; - Object.assign(injector.resolve("nativeReleaseService"), { - isNativeRelease: (data: IPrepareData) => data.release, + Object.assign(injector.resolve("compiledReleaseService"), { + isCompiledRelease: (data: IPrepareData) => data.release, prepare: async (platformData: any, projectData: any) => { nativePrepareCalls.push({ platformData, projectData }); }, diff --git a/test/options.ts b/test/options.ts index 4843f08b02..7c23c78681 100644 --- a/test/options.ts +++ b/test/options.ts @@ -487,8 +487,8 @@ describe("options", () => { expectedHmrValue: false, }, { - name: "should set hmr to false by default when --native option is provided", - args: ["--native"], + name: "should set hmr to false by default when --compiled option is provided", + args: ["--compiled"], expectedHmrValue: false, }, ]; @@ -508,27 +508,27 @@ describe("options", () => { }); }); - describe("native option", () => { + describe("compiled option", () => { const testCases = [ { name: "is a release build", - args: ["--native"], - expected: { native: true, release: true }, + args: ["--compiled"], + expected: { compiled: true, release: true }, }, { name: "stays a release build with --release", - args: ["--release", "--native"], - expected: { native: true, release: true }, + args: ["--release", "--compiled"], + expected: { compiled: true, release: true }, }, { name: "is left unset without the flag, so the config decides", args: ["--release"], - expected: { native: undefined, release: true }, + expected: { compiled: undefined, release: true }, }, { - name: "can be turned off with --no-native", - args: ["--release", "--no-native"], - expected: { native: false, release: true }, + name: "can be turned off with --no-compiled", + args: ["--release", "--no-compiled"], + expected: { compiled: false, release: true }, }, ]; @@ -542,7 +542,7 @@ describe("options", () => { testCase.args.forEach(() => process.argv.pop()); assert.deepStrictEqual( - { native: options.native, release: options.release }, + { compiled: options.compiled, release: options.release }, testCase.expected, ); assert.isFalse(isExecutionStopped); diff --git a/test/services/native-release-service.ts b/test/services/compiled-release-service.ts similarity index 89% rename from test/services/native-release-service.ts rename to test/services/compiled-release-service.ts index d83ef7835b..13cc3eb000 100644 --- a/test/services/native-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -4,7 +4,7 @@ import * as os from "os"; import * as path from "path"; import { Yok } from "../../lib/common/yok"; import { IInjector } from "../../lib/common/definitions/yok"; -import { NativeReleaseService } from "../../lib/services/native-release-service"; +import { CompiledReleaseService } from "../../lib/services/compiled-release-service"; import { IGradleCommandOptions } from "../../lib/definitions/gradle"; interface ISpawnCall { @@ -33,7 +33,7 @@ function createProjectData(nsConfig: any = {}): any { }; } -function createService(nsConfig: any = {}): NativeReleaseService { +function createService(nsConfig: any = {}): CompiledReleaseService { const injector: IInjector = new Yok(); injector.register("childProcess", { spawnFromEvent: async ( @@ -53,7 +53,10 @@ function createService(nsConfig: any = {}): NativeReleaseService { }); injector.register("exportOptionsPlistService", {}); injector.register("fs", { - exists: () => true, + 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, @@ -90,9 +93,9 @@ function createService(nsConfig: any = {}): NativeReleaseService { xcodebuildCalls.push(args); }, }); - injector.register("nativeReleaseService", NativeReleaseService); + injector.register("compiledReleaseService", CompiledReleaseService); - return injector.resolve("nativeReleaseService"); + return injector.resolve("compiledReleaseService"); } function installCompilerPackage(): string { @@ -100,13 +103,13 @@ function installCompilerPackage(): string { projectDir, "node_modules", "@nativescript", - "native-release", + "compiler", ); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync( path.join(packageDir, "package.json"), JSON.stringify({ - name: "@nativescript/native-release", + name: "@nativescript/compiler", bin: { "ns-native": "bin/ns-native.js" }, }), ); @@ -118,10 +121,10 @@ const platformData = (platform: string): any => ({ normalizedPlatformName: platform, }); -describe("nativeReleaseService", () => { +describe("compiledReleaseService", () => { beforeEach(() => { projectDir = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), "native-release-")), + fs.mkdtempSync(path.join(os.tmpdir(), "compiled-release-")), ); spawnCalls = []; gradleCalls = []; @@ -134,11 +137,11 @@ describe("nativeReleaseService", () => { fs.rmSync(projectDir, { recursive: true, force: true }); }); - describe("isNativeRelease", () => { + describe("isCompiledRelease", () => { const testCases = [ { - name: "a debug build, even with --native", - options: { release: false, native: true }, + name: "a debug build, even with --compiled", + options: { release: false, compiled: true }, nsConfig: { nativeRelease: true }, expected: false, }, @@ -149,8 +152,8 @@ describe("nativeReleaseService", () => { expected: false, }, { - name: "a release build with --native", - options: { release: true, native: true }, + name: "a release build with --compiled", + options: { release: true, compiled: true }, nsConfig: {}, expected: true, }, @@ -161,8 +164,8 @@ describe("nativeReleaseService", () => { expected: true, }, { - name: "a release build with --no-native over the config", - options: { release: true, native: false }, + name: "a release build with --no-compiled over the config", + options: { release: true, compiled: false }, nsConfig: { nativeRelease: true }, expected: false, }, @@ -184,7 +187,7 @@ describe("nativeReleaseService", () => { it(`is ${testCase.expected} for ${testCase.name}`, () => { const service = createService(testCase.nsConfig); assert.equal( - service.isNativeRelease({ + service.isCompiledRelease({ projectDir, platform: "Android", ...testCase.options, @@ -196,7 +199,7 @@ describe("nativeReleaseService", () => { }); describe("prepare", () => { - it("runs the project's compiler with the CLI's node, writing to platforms/native/", async () => { + it("runs the project's compiler with the CLI's node, writing to platforms/compiled/", async () => { const packageDir = installCompilerPackage(); const service = createService(); @@ -210,7 +213,7 @@ describe("nativeReleaseService", () => { "--platform", "ios", "--out", - path.join(projectDir, "platforms", "native", "ios"), + path.join(projectDir, "platforms", "compiled", "ios"), "--name", "demo", "--bundle", @@ -230,7 +233,7 @@ describe("nativeReleaseService", () => { assert.include( error.message, - "npm install --save-dev @nativescript/native-release", + "npm install --save-dev @nativescript/compiler", ); assert.lengthOf(spawnCalls, 0); }); @@ -271,7 +274,7 @@ describe("nativeReleaseService", () => { const projectRoot = path.join( projectDir, "platforms", - "native", + "compiled", "android", ); assert.lengthOf(gradleCalls, 1); diff --git a/test/stubs.ts b/test/stubs.ts index d1ab2a4d5c..360e4689c9 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -1522,8 +1522,8 @@ export class InjectorStub extends Yok implements IInjector { getDeviceByIdentifier: (): Mobile.IDevice => undefined, }); this.register("terminalSpinnerService", TerminalSpinnerServiceStub); - this.register("nativeReleaseService", { - isNativeRelease: () => false, + this.register("compiledReleaseService", { + isCompiledRelease: () => false, }); } } From a1f2fdedc44703c753fe006152cde8f1423c50e3 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 19:40:07 -0700 Subject: [PATCH 04/16] Read the compiled release from release.compiled in nativescript.config release.compiled replaces nativeRelease, and ios.release / android.release override it per platform; --compiled / --no-compiled still win. The release object also carries the compiler's options, which replace nativeReleaseOptions. --- .../project/testing/build-android.md | 2 +- docs/man_pages/project/testing/build-ios.md | 2 +- docs/man_pages/project/testing/run-android.md | 2 +- docs/man_pages/project/testing/run-ios.md | 2 +- lib/definitions/prepare.d.ts | 2 +- lib/definitions/project.d.ts | 38 +++++++++++++++---- lib/services/compiled-release-service.ts | 14 ++++--- test/services/compiled-release-service.ts | 21 +++++++--- 8 files changed, 58 insertions(+), 25 deletions(-) diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md index 17ae0f214a..6552337bd7 100644 --- a/docs/man_pages/project/testing/build-android.md +++ b/docs/man_pages/project/testing/build-android.md @@ -20,7 +20,7 @@ General | `$ ns build android [--compileSdk ] [--key-store-path ` 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 `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one 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. * `--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 27422034b2..1046a967f6 100644 --- a/docs/man_pages/project/testing/build-ios.md +++ b/docs/man_pages/project/testing/build-ios.md @@ -22,7 +22,7 @@ General | `$ ns build ios [--for-device] [--release [--compiled]] [--copy-to ; } +/** + * 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; /** - * Overrides the top-level `nativeRelease` for this platform. + * Merged over the top-level `release` for this platform; object options merge by key. */ - nativeRelease?: boolean; + release?: INsConfigRelease; } interface IOSSPMPackageBase { @@ -205,12 +232,7 @@ interface INsConfig { webpackConfigPath?: string; bundlerConfigPath?: string; bundler?: BundlerType; - /** - * Release builds compile the app to native code with - * `@nativescript/compiler` instead of bundling it for the - * JavaScript runtime. `--compiled` / `--no-compiled` override it per command. - */ - nativeRelease?: boolean; + release?: INsConfigRelease; ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 026484b1d7..11e2eaf068 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -42,10 +42,10 @@ export class CompiledReleaseService { ) {} /** - * `--compiled` (or `--no-compiled`) wins; otherwise `nativeRelease` in - * nativescript.config, where `ios.nativeRelease` and `android.nativeRelease` - * override the top-level value. Debug builds always run on the JavaScript - * runtime. + * `--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) { @@ -59,10 +59,12 @@ export class CompiledReleaseService { const nsConfig = this.$projectDataService.getProjectData( options.projectDir, ).nsConfig; - const platformConfig: { nativeRelease?: boolean } = + const platformConfig: { release?: { compiled?: boolean } } = nsConfig?.[options.platform.toLowerCase() as "ios" | "android"]; - return !!(platformConfig?.nativeRelease ?? nsConfig?.nativeRelease); + return !!( + platformConfig?.release?.compiled ?? nsConfig?.release?.compiled + ); } private getProjectRoot(projectData: IProjectData, platform: string): string { diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index 13cc3eb000..e27fea893c 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -142,7 +142,7 @@ describe("compiledReleaseService", () => { { name: "a debug build, even with --compiled", options: { release: false, compiled: true }, - nsConfig: { nativeRelease: true }, + nsConfig: { release: { compiled: true } }, expected: false, }, { @@ -158,27 +158,36 @@ describe("compiledReleaseService", () => { expected: true, }, { - name: "a release build with nativeRelease in the config", + name: "a release build with release.compiled in the config", options: { release: true }, - nsConfig: { nativeRelease: true }, + nsConfig: { release: { compiled: true } }, expected: true, }, { name: "a release build with --no-compiled over the config", options: { release: true, compiled: false }, - nsConfig: { nativeRelease: true }, + nsConfig: { release: { compiled: true } }, expected: false, }, { name: "a release build whose platform opts out in the config", options: { release: true }, - nsConfig: { nativeRelease: true, android: { nativeRelease: false } }, + 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: { nativeRelease: true } }, + nsConfig: { android: { release: { compiled: true } } }, expected: true, }, ]; From dd2cdb1f109a2d06bc5455bb1e0321f43a66712a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 8 Oct 2026 14:53:48 -0700 Subject: [PATCH 05/16] Compiled release: unsigned device archives say so to xcodebuild The compiler's generated project now leaves device builds signable, so an archive in Xcode signs with the team chosen there. Without --team-id (no team, or --provision, which signs at export) the archive passes CODE_SIGNING_ALLOWED=NO itself. --- lib/services/compiled-release-service.ts | 4 +++- test/services/compiled-release-service.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 11e2eaf068..563de051a7 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -232,6 +232,8 @@ export class CompiledReleaseService { // 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 ? [ @@ -240,7 +242,7 @@ export class CompiledReleaseService { `DEVELOPMENT_TEAM=${buildData.teamId}`, "-allowProvisioningUpdates", ] - : []; + : ["CODE_SIGNING_ALLOWED=NO"]; this.$fs.deleteDirectory(archivePath); await this.$xcodebuildCommandService.executeCommand( [ diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index e27fea893c..5c83e8f137 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -358,6 +358,7 @@ describe("compiledReleaseService", () => { assert.lengthOf(xcodebuildCalls, 1); assert.include(xcodebuildCalls[0], "archive"); 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$/); }); @@ -372,6 +373,7 @@ describe("compiledReleaseService", () => { ); assert.lengthOf(xcodebuildCalls, 2); + assert.include(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=NO"); assert.isFalse( xcodebuildCalls[0].some((arg) => arg.startsWith("PROVISIONING_PROFILE"), From 31ab3e7a568243244a853d510f26c441125f8f3d Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 8 Oct 2026 21:03:36 -0700 Subject: [PATCH 06/16] compiled release: build the project or workspace the compiler generated The compiler generates the Xcode project and names it in ns-native-project.json; XcodeGen runs here only for a compiler that does not. --- lib/services/compiled-release-service.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 563de051a7..7913a2184d 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -170,13 +170,14 @@ export class CompiledReleaseService { const projectRoot = this.getProjectRoot(projectData, "ios"); const name = projectData.projectName; - // A project with CocoaPods or Swift packages is generated and integrated by - // the compiler, which names the workspace to build. + // 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 workspace = this.$fs.exists(integrated) - ? this.$fs.readJson(integrated).workspace + const generated: { workspace?: string; project?: string } | null = this.$fs.exists(integrated) + ? this.$fs.readJson(integrated) : null; - if (!workspace) { + const workspace = generated?.workspace ?? null; + if (!generated) { try { await this.$childProcess.spawnFromEvent( "xcodegen", @@ -196,7 +197,7 @@ export class CompiledReleaseService { const projectArgs = [ ...(workspace ? ["-workspace", workspace] - : ["-project", `${name}.xcodeproj`]), + : ["-project", generated?.project ?? `${name}.xcodeproj`]), "-scheme", name, "-configuration", From f86e31bb62fae738fecd9d8f008c275cbc0937da Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 9 Oct 2026 06:32:19 -0700 Subject: [PATCH 07/16] feat: ns compiled verify compares a compiled release with its JavaScript release Builds both releases of the project, then runs the project's @nativescript/compiler ns-compiled-verify on them: the same steps (the project's verify.json, or a shot after launch) on one iOS Simulator, compared pixel by pixel, failing where a screenshot differs or an app stops running. --- .../project/testing/compiled-verify.md | 46 ++++++++++ lib/bootstrap.ts | 1 + lib/commands/compiled-verify.ts | 89 +++++++++++++++++++ lib/services/compiled-release-service.ts | 12 ++- 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 docs/man_pages/project/testing/compiled-verify.md create mode 100644 lib/commands/compiled-verify.ts 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/lib/bootstrap.ts b/lib/bootstrap.ts index b606e0b8a9..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"); 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/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 7913a2184d..a787b1374a 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -142,10 +142,20 @@ export class CompiledReleaseService { } 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["ns-native"]); + return path.join(packageDir, bin[name]); } private getPackageDir(projectData: IProjectData): string { From 857e0bb052b5ba927e66bc1537b9e6d813bd1649 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 9 Oct 2026 11:02:02 -0700 Subject: [PATCH 08/16] Compiled builds run the project's before- and after-build hooks The same buildIOS and buildAndroid hooks the platform services run, with the same arguments; projectRoot is the compiled project. --- lib/services/compiled-release-service.ts | 34 +++++++++++++++++++---- test/services/compiled-release-service.ts | 31 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index a787b1374a..22cb01238e 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -1,7 +1,7 @@ import * as path from "path"; import { resolvePackagePath } from "@rigor789/resolve-package-path"; import { COMPILER_PACKAGE_NAME } from "../constants"; -import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; +import { IChildProcess, IErrors, IFileSystem, IHooksService } from "../common/declarations"; import { IPlatformData } from "../definitions/platform"; import { IProjectData, IProjectDataService } from "../definitions/project"; import { @@ -12,6 +12,7 @@ import { 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; @@ -34,6 +35,7 @@ export class CompiledReleaseService { private $exportOptionsPlistService: IExportOptionsPlistService, private $fs: IFileSystem, private $gradleCommandService: IGradleCommandService, + public $hooksService: IHooksService, private $iOSProvisionService: IOSProvisionService, private $logger: ILogger, private $mobileHelper: Mobile.IMobileHelper, @@ -127,11 +129,11 @@ export class CompiledReleaseService { this.$fs.deleteDirectory(path.join(projectRoot, "build")); } - const packageFile = this.$mobileHelper.isAndroidPlatform( - platformData.platformNameLowerCase, - ) - ? await this.buildAndroid(projectData, buildData) - : await this.buildIOS(projectData, buildData); + 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}.`, @@ -141,6 +143,26 @@ export class CompiledReleaseService { 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"); } diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index 5c83e8f137..bbedcd6933 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -19,6 +19,7 @@ let gradleCalls: { args: string[]; options: IGradleCommandOptions }[]; let xcodebuildCalls: string[][]; let writtenFiles: { [path: string]: string }; let warnings: string[]; +let hookCalls: { name: string; args: any }[]; function createProjectData(nsConfig: any = {}): any { return { @@ -93,6 +94,15 @@ function createService(nsConfig: any = {}): CompiledReleaseService { 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"); @@ -131,6 +141,7 @@ describe("compiledReleaseService", () => { xcodebuildCalls = []; writtenFiles = {}; warnings = []; + hookCalls = []; }); afterEach(() => { @@ -346,6 +357,26 @@ describe("compiledReleaseService", () => { ); }); + 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(); From b06fc18a4e4e20254aaf1d464eaaf6a9d3d459ed Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 11 Oct 2026 00:25:10 -0700 Subject: [PATCH 09/16] Compiled Android builds name the TypeScript line of each Kotlin compile error Gradle's output streams as before and is kept; when the build fails, the compiler's ns-native-retrace --build-log maps each Kotlin error in a generated file to the TypeScript line it came from (source-lines.json). --- lib/services/compiled-release-service.ts | 62 ++++++++++++++++++++--- test/services/compiled-release-service.ts | 52 +++++++++++++++++-- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 22cb01238e..930d3fd5c9 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -414,6 +414,35 @@ export class CompiledReleaseService { 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, @@ -437,13 +466,32 @@ export class CompiledReleaseService { ); } - await this.$gradleCommandService.executeCommand(args, { - cwd: projectRoot, - message: "Gradle build (compiled release)...", - gradlePath: - buildData.gradlePath ?? - path.join(this.getPackageDir(projectData), "kit-android", "gradlew"), - }); + // 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. diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index bbedcd6933..76f60ec5b2 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -1,4 +1,5 @@ import { assert } from "chai"; +import { EventEmitter } from "events"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -20,6 +21,9 @@ 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 { @@ -36,7 +40,7 @@ function createProjectData(nsConfig: any = {}): any { function createService(nsConfig: any = {}): CompiledReleaseService { const injector: IInjector = new Yok(); - injector.register("childProcess", { + const childProcess = Object.assign(new EventEmitter(), { spawnFromEvent: async ( command: string, args: string[], @@ -44,9 +48,10 @@ function createService(nsConfig: any = {}): CompiledReleaseService { options: any, ) => { spawnCalls.push({ command, args, options }); - return { stdout: "", stderr: "", exitCode: 0 }; + return { stdout: retraceOutput, stderr: "", exitCode: 0 }; }, }); + injector.register("childProcess", childProcess); injector.register("errors", { fail: (message: string) => { throw new Error(message); @@ -69,6 +74,12 @@ function createService(nsConfig: any = {}): CompiledReleaseService { 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", { @@ -80,6 +91,8 @@ function createService(nsConfig: any = {}): CompiledReleaseService { 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) => @@ -120,7 +133,10 @@ function installCompilerPackage(): string { path.join(packageDir, "package.json"), JSON.stringify({ name: "@nativescript/compiler", - bin: { "ns-native": "bin/ns-native.js" }, + bin: { + "ns-native": "bin/ns-native.js", + "ns-native-retrace": "bin/ns-native-retrace.js", + }, }), ); return fs.realpathSync(packageDir); @@ -138,6 +154,9 @@ describe("compiledReleaseService", () => { ); spawnCalls = []; gradleCalls = []; + errorsLogged = []; + gradleOutput = null; + retraceOutput = ""; xcodebuildCalls = []; writtenFiles = {}; warnings = []; @@ -325,6 +344,33 @@ describe("compiledReleaseService", () => { ); }); + 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(); From a81126bb08bbb85b05658a69099b0503390bae38 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 12 Oct 2026 22:52:33 -0700 Subject: [PATCH 10/16] Release runs launch the app only on the devices it was deployed to With --device, ns run --release (and --compiled) deployed to the chosen device but then launched on every attached device of the platform, failing on the ones without the app. --- lib/helpers/livesync-command-helper.ts | 32 +++++++++++++++----------- 1 file changed, 19 insertions(+), 13 deletions(-) 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() { From 625124cc630dc34d348a71e981346d6ebc679080 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 12 Oct 2026 22:53:38 -0700 Subject: [PATCH 11/16] ns create runs the template's hooks when created inside another project The hooks directory was fixed by the first hook call, which inside a folder whose package.json names @nativescript/core resolved to that enclosing project, so the new app's after-createProject hook (which writes webpack.config.js) never ran. --- lib/common/services/hooks-service.ts | 27 +++++++++------------------ test/compat/legacy-hooks.ts | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 18 deletions(-) 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/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, From 87869181e7b69380ca00127d4740230e1c8485c7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 13 Oct 2026 10:45:48 -0700 Subject: [PATCH 12/16] Compiled iOS simulator builds compile only this Mac's architecture A simulator build of a compiled release passes ARCHS=$(NATIVE_ARCH_ACTUAL): the generic simulator destination otherwise builds arm64 and x86_64 whatever ONLY_ACTIVE_ARCH says, doubling the compile, and a simulator app only runs on the Mac that built it. Device archives are unchanged. --- lib/services/compiled-release-service.ts | 3 +++ test/services/compiled-release-service.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 930d3fd5c9..732b24ca59 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -245,6 +245,9 @@ export class CompiledReleaseService { ...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)..." }, diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index 76f60ec5b2..ea27f41500 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -397,6 +397,7 @@ describe("compiledReleaseService", () => { 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$/, @@ -434,6 +435,7 @@ describe("compiledReleaseService", () => { 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); From 13bfad741ec1f5ea8b51280ac7139d100cf5e5ea Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 14 Oct 2026 22:40:38 -0700 Subject: [PATCH 13/16] ns compiled report turns a failed --compiled build or a compiled app's runtime error into a deduplicated NativeScript issue --- .../project/testing/compiled-report.md | 43 + lib/bootstrap.ts | 2 + lib/commands/compiled-report.ts | 234 +++++ lib/services/compiled-release-service.ts | 109 ++- lib/services/compiled-report-service.ts | 807 +++++++++++++++++ test/services/compiled-release-service.ts | 64 +- test/services/compiled-report-service.ts | 808 ++++++++++++++++++ 7 files changed, 2049 insertions(+), 18 deletions(-) create mode 100644 docs/man_pages/project/testing/compiled-report.md create mode 100644 lib/commands/compiled-report.ts create mode 100644 lib/services/compiled-report-service.ts create mode 100644 test/services/compiled-report-service.ts diff --git a/docs/man_pages/project/testing/compiled-report.md b/docs/man_pages/project/testing/compiled-report.md new file mode 100644 index 0000000000..77062c1bae --- /dev/null +++ b/docs/man_pages/project/testing/compiled-report.md @@ -0,0 +1,43 @@ +<% if (isJekyll) { %>--- +title: ns compiled report +position: 21 +---<% } %> + +# ns compiled report + +### Description + +Reports a `--compiled` problem to NativeScript as a GitHub issue on NativeScript/NativeScript. When a compiled build fails (the compiler refuses a construct, or Swift, Kotlin, Gradle or Xcode fails on the generated code), the CLI keeps what went wrong in `platforms/compiled//report.json` and says to run this command. For an error a compiled app throws at runtime, pass it with `--error `. + +The report holds the CLI, `@nativescript/compiler` and `@nativescript/core` versions, the framework and its version, the platform, Xcode or Gradle, the Android Gradle Plugin and Kotlin, the OS, the project's dependencies with their versions, the diagnostic and the source location it names. Paths are made relative to the project; the home folder, user names, tokens and passwords are removed. Its title carries a signature, a short hash of the kind of failure and its message with names and numbers abstracted, so the same problem in different apps finds the same issue. + +Nothing leaves the machine until you have seen the report and agreed. The command then searches the repository's open and closed issues for the signature: if one exists, it offers to add a +1 comment with your environment, or to open it. Otherwise it files the issue with `gh`, when it is installed and signed in, or opens a prefilled issue in the browser. The source lines around the location (three either side) are added only if you agree a second time; `--yes` never skips that question. In a terminal that cannot answer, it prints the report and the issue URL and sends nothing. + +Each issue filed this way becomes a case in the compiler's tests (`packages/compiler/tests/diff/cases`), then a fix, and the generated table of what `--compiled` supports is updated. + +### Commands + +Usage | Synopsis +---|--- +The last failed build | `$ ns compiled report []` +A runtime error | `$ ns compiled report --error ` + +### Arguments + +`` is `ios` or `android`. Without it, the command reports the platform whose build failed last. + +### Options + +* `--error ` - Reports the runtime error in ``. `--error=-` reads it from stdin; stdin is then not a terminal, so the report and its URL are only printed. +* `--yes` - Sends the report without asking first. You are still asked before any of your code is included. +* `--dry-run` - Prints the title, labels, body and issue URL, and sends nothing. + +<% if(isHtml) { %> + +### Related Commands + +Command | Description +----------|---------- +[build](build.html) | Builds the project, `--compiled` to compile it to native code. +[compiled verify](compiled-verify.html) | Compares a compiled release with its JavaScript release. +<% } %> diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 0e34542877..94807e02a0 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -185,6 +185,7 @@ 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("compiled|report", "./commands/compiled-report"); injector.requireCommand("preview", "./commands/preview"); @@ -425,6 +426,7 @@ injector.require( "./services/bundler/vite-hmr-port-service", ); injector.require("compiledReleaseService", "./services/compiled-release-service"); +injector.require("compiledReportService", "./services/compiled-report-service"); injector.require( "applePortalSessionService", diff --git a/lib/commands/compiled-report.ts b/lib/commands/compiled-report.ts new file mode 100644 index 0000000000..b00440162c --- /dev/null +++ b/lib/commands/compiled-report.ts @@ -0,0 +1,234 @@ +import * as fs from "fs"; +import { StringCommandParameter } from "../common/command-params"; +import { IErrors, IFileSystem, IOpener } from "../common/declarations"; +import { ICommand, ICommandParameter } from "../common/definitions/commands"; +import { IInjector } from "../common/definitions/yok"; +import { OptionType } from "../common/enums"; +import { isInteractive } from "../common/helpers"; +import { injector } from "../common/yok"; +import { IOptions } from "../declarations"; +import { IProjectData } from "../definitions/project"; +import { + CompiledReportService, + ICompiledReport, + ICompiledReportDraft, + IReportedIssue, + ISSUES_REPO, + newIssueUrl, +} from "../services/compiled-report-service"; + +/** + * `ns compiled report [ios|android]`: turns the last failed `--compiled` build + * (or, with `--error `, a compiled app's runtime error) into an issue on + * NativeScript/NativeScript. The report is shown before anything is sent, and the + * app's code is included only after a second confirmation, which `--yes` never skips. + */ +export class CompiledReportCommand implements ICommand { + public allowedParameters: ICommandParameter[] = [ + new StringCommandParameter(this.$injector), + ]; + public dashedOptions = { + error: { type: OptionType.String, hasSensitiveValue: true }, + yes: { type: OptionType.Boolean, alias: "y", hasSensitiveValue: false }, + }; + + constructor( + private $compiledReportService: CompiledReportService, + private $errors: IErrors, + private $fs: IFileSystem, + private $injector: IInjector, + private $logger: ILogger, + private $opener: IOpener, + private $options: IOptions & { error?: string; yes?: boolean }, + private $projectData: IProjectData, + private $prompter: IPrompter, + ) {} + + public async execute(args: string[]): Promise { + const platform = args[0]?.toLowerCase(); + if (platform && platform !== "ios" && platform !== "android") { + this.$errors.fail( + `ns compiled report takes ios or android, not ${args[0]}.`, + ); + } + this.$projectData.initializeProjectData(); + const draft = this.readDraft(platform); + const report = await this.$compiledReportService.createReport( + this.$projectData, + draft, + ); + const body = this.$compiledReportService.formatBody(report, false); + const { url, truncated } = newIssueUrl(report.title, body, report.labels); + + this.printReport(report, body); + if (report.code) { + this.$logger.info( + `The code at ${report.location.file}:${report.location.line} is not in the report; you would be asked separately before it is added.`, + ); + } + if (this.$options.dryRun || !isInteractive()) { + this.$logger.info( + `\nNew issue URL${truncated ? " (body cut to fit)" : ""}:\n${url}`, + ); + this.$logger.info( + this.$options.dryRun + ? "Dry run: nothing was sent." + : "Nothing was sent: run 'ns compiled report' in a terminal to send it, or open the URL above.", + ); + return; + } + + if ( + !this.$options.yes && + !(await this.$prompter.confirm( + `Search github.com/${ISSUES_REPO} for this signature and, if it is new, file it?`, + () => false, + )) + ) { + this.$logger.info("Nothing was sent."); + return; + } + + const useGh = await this.$compiledReportService.hasGh(); + const found = await this.$compiledReportService.findIssues( + report.signature, + useGh, + ); + if (found === null) { + this.$logger.warn( + "Could not search GitHub for existing issues (offline?).", + ); + } + if (found?.length) { + await this.handleExisting(report, found, useGh); + return; + } + + await this.fileNew(report, useGh); + } + + private readDraft(platform: string | undefined): ICompiledReportDraft { + const errorSource = this.$options.error; + if (errorSource !== undefined) { + if (!platform) { + this.$errors.fail( + "Name the platform the error came from: ns compiled report ios|android --error .", + ); + } + const output = + errorSource === "-" + ? fs.readFileSync(0, "utf8") + : this.$fs.exists(errorSource) + ? this.$fs.readText(errorSource) + : null; + if (output === null) { + this.$errors.fail(`No file ${errorSource}.`); + } + return { + kind: "runtime", + platform, + createdAt: new Date().toISOString(), + output, + }; + } + + const draft = this.$compiledReportService.loadDraft( + this.$projectData, + platform, + ); + if (!draft) { + this.$errors.fail( + `No failed --compiled build${platform ? ` for ${platform}` : ""} to report. ` + + "Build with --compiled first, or pass a runtime error with --error .", + ); + } + + return draft; + } + + private printReport(report: ICompiledReport, body: string): void { + this.$logger.info( + [ + `Title: ${report.title}`, + `Labels: ${report.labels.join(", ")}`, + "----- body -----", + body, + "----------------", + ].join("\n"), + ); + } + + private async handleExisting( + report: ICompiledReport, + found: IReportedIssue[], + useGh: boolean, + ): Promise { + this.$logger.info( + `Already reported:\n${found.map((issue) => ` #${issue.number} (${issue.state.toLowerCase()}) ${issue.title}\n ${issue.url}`).join("\n")}`, + ); + const issue = + found.find((i) => i.state.toLowerCase() === "open") ?? found[0]; + const comment = `Add a +1 comment to #${issue.number} with this environment (no code)`; + const open = `Open #${issue.number} in the browser`; + const choice = await this.$prompter.promptForChoice("What next?", [ + ...(useGh ? [comment] : []), + open, + "Nothing", + ]); + if ( + choice === comment && + (await this.$compiledReportService.commentOnIssue( + this.$projectData, + report, + issue, + )) + ) { + this.$logger.info(`Added a +1 to ${issue.url}. Thank you.`); + } else if (choice === open) { + this.$opener.open(issue.url); + } + } + + private async fileNew( + report: ICompiledReport, + useGh: boolean, + ): Promise { + let includeCode = false; + if (report.code) { + this.$logger.info(`\n${report.location.file}:\n${report.code}`); + includeCode = await this.$prompter.confirm( + `Include these lines of ${report.location.file} in the public issue?`, + () => false, + ); + } + const body = this.$compiledReportService.formatBody(report, includeCode); + const created = useGh + ? await this.$compiledReportService.createIssue( + this.$projectData, + report, + body, + ) + : null; + if (created) { + this.$logger.info(`Filed ${created}. Thank you.`); + return; + } + + const { url, truncated } = newIssueUrl(report.title, body, report.labels); + this.$logger.info(`Opening a prefilled issue in the browser:\n${url}`); + if (truncated) { + this.$logger.warn( + `The body was cut to fit the URL; paste the rest from ${this.writeFullBody(report, body)}.`, + ); + } + this.$opener.open(url); + } + + private writeFullBody(report: ICompiledReport, body: string): string { + const file = `${this.$compiledReportService.getReportDir(this.$projectData, report.platform)}/report.md`; + this.$fs.writeFile(file, body); + return file; + } +} + +injector.registerCommand("compiled|report", CompiledReportCommand); diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 732b24ca59..975149485b 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -11,6 +11,7 @@ import { } from "../definitions/build"; import { IGradleCommandService } from "../definitions/gradle"; import { IOSProvisionService } from "./ios-provision-service"; +import { CompiledReportService } from "./compiled-report-service"; import { injector } from "../common/yok"; import { hook } from "../common/helpers"; @@ -31,6 +32,7 @@ export interface ICompiledReleaseOptions { export class CompiledReleaseService { constructor( private $childProcess: IChildProcess, + private $compiledReportService: CompiledReportService, private $errors: IErrors, private $exportOptionsPlistService: IExportOptionsPlistService, private $fs: IFileSystem, @@ -104,14 +106,77 @@ export class CompiledReleaseService { projectData.projectIdentifiers[platform], ]; + this.$compiledReportService.clearDraft(projectData, platform); this.$logger.info(`Compiling the app to native code (${platform})...`); + const eventName = "compiledReleaseCompilerOutput"; + const capture = this.captureOutput(eventName); + let result; try { - await this.$childProcess.spawnFromEvent(process.execPath, args, "close", { - cwd: projectData.projectDir, - stdio: "inherit", + result = await this.$childProcess.spawnFromEvent( + process.execPath, + args, + "close", + { cwd: projectData.projectDir, stdio: ["inherit", "inherit", "pipe"] }, + { throwError: false, emitOptions: { eventName } }, + ); + } finally { + capture.stop(); + } + if (result.exitCode !== 0) { + const hint = this.$compiledReportService.saveDraft( + projectData, + platform, + "compile-refused", + capture.output.join(""), + ); + this.$errors.fail( + `The compile to native code failed (exit code ${result.exitCode}).${hint ? `\n${hint}` : ""}`, + ); + } + } + + /** The child process's output, shown as it comes and kept for the report a failed build leaves. */ + private captureOutput(eventName: string): { output: string[]; stop: () => void } { + const output: string[] = []; + 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); + return { + output, + stop: () => this.$childProcess.removeListener(eventName, onOutput), + }; + } + + /** An xcodebuild run that compiles the generated code; its failure leaves a report draft. */ + private async compileWithXcodebuild( + projectData: IProjectData, + args: string[], + options: { cwd: string; message: string }, + ): Promise { + const eventName = "compiledReleaseXcodebuildOutput"; + const capture = this.captureOutput(eventName); + let result; + try { + result = await this.$xcodebuildCommandService.executeCommand(args, { + ...options, + stdio: "pipe", + spawnOptions: { throwError: false, emitOptions: { eventName } }, }); - } catch (err) { - this.$errors.fail(`The compile to native code failed. ${err.message}`); + } finally { + capture.stop(); + } + if (result.exitCode !== 0) { + const hint = this.$compiledReportService.saveDraft( + projectData, + "ios", + "native-compile", + capture.output.join(""), + ); + this.$errors.fail( + `xcodebuild failed with exit code ${result.exitCode}.${hint ? `\n${hint}` : ""}`, + ); } } @@ -240,7 +305,8 @@ export class CompiledReleaseService { ]; if (!buildData.buildForDevice && !buildData.buildForAppStore) { - await this.$xcodebuildCommandService.executeCommand( + await this.compileWithXcodebuild( + projectData, [ ...projectArgs, "-destination", @@ -280,7 +346,8 @@ export class CompiledReleaseService { ] : ["CODE_SIGNING_ALLOWED=NO"]; this.$fs.deleteDirectory(archivePath); - await this.$xcodebuildCommandService.executeCommand( + await this.compileWithXcodebuild( + projectData, [ ...projectArgs, "-destination", @@ -422,10 +489,10 @@ export class CompiledReleaseService { projectData: IProjectData, projectRoot: string, log: string, - ): Promise { + ): Promise { const errors = log.split("\n").filter((line) => /^e: /.test(line)); if (!errors.length || !this.$fs.exists(path.join(projectRoot, "source-lines.json"))) { - return; + return []; } const logFile = path.join(projectRoot, "build", "compile-errors.log"); this.$fs.writeFile(logFile, errors.join("\n") + "\n"); @@ -441,8 +508,10 @@ export class CompiledReleaseService { if (mapped.length) { this.$logger.error(`Kotlin errors in your source:\n${mapped.join("\n")}`); } + return mapped; } catch (err) { this.$logger.trace(`Mapping Kotlin errors to source lines failed: ${err.message}`); + return []; } } @@ -472,13 +541,8 @@ export class CompiledReleaseService { // 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); + const capture = this.captureOutput(eventName); try { await this.$gradleCommandService.executeCommand(args, { cwd: projectRoot, @@ -490,10 +554,21 @@ export class CompiledReleaseService { spawnOptions: { throwError: true, emitOptions: { eventName } }, }); } catch (err) { - await this.printKotlinErrorsInSource(projectData, projectRoot, output.join("")); + const log = capture.output.join(""); + const mapped = await this.printKotlinErrorsInSource(projectData, projectRoot, log); + // The TypeScript lines first: the report's diagnostic and location come from the first lines that name one. + const hint = this.$compiledReportService.saveDraft( + projectData, + "android", + "native-compile", + [...mapped, log].join("\n"), + ); + if (hint) { + err.message = `${err.message}\n${hint}`; + } throw err; } finally { - this.$childProcess.removeListener(eventName, onOutput); + capture.stop(); } // The generated project is the app module itself, so AGP names its diff --git a/lib/services/compiled-report-service.ts b/lib/services/compiled-report-service.ts new file mode 100644 index 0000000000..1055439b9e --- /dev/null +++ b/lib/services/compiled-report-service.ts @@ -0,0 +1,807 @@ +import * as crypto from "crypto"; +import * as os from "os"; +import * as path from "path"; +import { resolvePackagePath } from "@rigor789/resolve-package-path"; +import { COMPILER_PACKAGE_NAME } from "../constants"; +import { + IChildProcess, + IFileSystem, + ISysInfo, + Server, +} from "../common/declarations"; +import { IProjectData } from "../definitions/project"; +import { injector } from "../common/yok"; + +export type CompiledReportKind = + "compile-refused" | "native-compile" | "runtime"; + +export const ISSUES_REPO = "NativeScript/NativeScript"; +// GitHub answers longer issues/new URLs with 414. +const MAX_URL_LENGTH = 8000; +const SEARCH_TIMEOUT_MS = 5000; +const MAX_DRAFT_OUTPUT = 200 * 1024; +const MAX_DIAGNOSTICS = 10; + +export interface ICompiledReportDraft { + kind: CompiledReportKind; + platform: string; + createdAt: string; + output: string; +} + +export interface ICompiledDiagnostic { + location?: string; + message: string; + /** The source text the compiler quotes after a refusal: code, so it is shared only with consent. */ + snippet?: string; +} + +export interface ICompiledReport { + kind: CompiledReportKind; + platform: string; + signature: string; + title: string; + labels: string[]; + environment: [string, string][]; + plugins: [string, string][]; + diagnostics: ICompiledDiagnostic[]; + location?: { file: string; line: number }; + /** The location's line and three either side, numbered; shared only with consent. */ + code?: string; +} + +export interface IReportedIssue { + number: number; + title: string; + state: string; + url: string; +} + +const KIND_DESCRIPTIONS: Record = { + "compile-refused": "The compiler refused a construct in the app", + "native-compile": "The generated Swift or Kotlin did not compile", + runtime: "A compiled app failed at runtime", +}; + +const FRAMEWORKS: { name: string; markers: string[]; versionOf: string }[] = [ + { + name: "angular", + markers: ["@nativescript/angular"], + versionOf: "@angular/core", + }, + { name: "vue", markers: ["nativescript-vue"], versionOf: "nativescript-vue" }, + { + name: "svelte", + markers: ["@nativescript-community/svelte-native", "svelte-native"], + versionOf: "svelte", + }, + { name: "react", markers: ["react-nativescript"], versionOf: "react" }, + { + name: "solid", + markers: ["@nativescript-community/solid-js", "dominative"], + versionOf: "solid-js", + }, +]; + +const SOURCE_EXTENSIONS = "ts|tsx|js|jsx|mjs|vue|svelte|xml|html|css|scss"; +const SOURCE_REFERENCE = new RegExp( + `((?:file://)?[^\\s()'"\`]+\\.(?:${SOURCE_EXTENSIONS})):(\\d+)(?::\\d+)?`, +); +const LOCATED_LINE = + /^(?:e: |error: )?(?:file:\/\/)?(\S+?:\d+(?::\d+)?):? (?:error: )?(.+)$/; +const REFUSAL = /^(.* is not supported in a release build(?: yet)?): (.+)$/; + +const SECRETS: [RegExp, string][] = [ + [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + "", + ], + [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, ""], + [/\bAKIA[0-9A-Z]{16}\b/g, ""], + [/\bAIza[0-9A-Za-z_-]{35}\b/g, ""], + [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, ""], + [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, ""], + [/\bsk-[A-Za-z0-9_-]{20,}/g, ""], + [/\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g, ""], + [/(\/\/[^\s:/@]+:)[^\s@/]+@/g, "$1@"], + [ + /\b((?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|auth)[\w.-]*["']?\s*[=:]\s*)(["']?)[^\s"',;]+\2/gi, + "$1", + ], +]; + +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + +/** Paths made relative to the project, the home folder and user names hidden, and secrets removed. */ +export function redact( + text: string, + projectDir: string, + homeDir = os.homedir(), +): string { + let result = text; + for (const dir of [projectDir, homeDir]) { + if (!dir) { + continue; + } + const replacement = dir === projectDir ? "" : "~/"; + result = result + .split(`file://${dir}/`) + .join(replacement) + .split(`${dir}/`) + .join(replacement) + .split(dir) + .join(dir === projectDir ? "." : "~"); + } + result = result + .replace(/(\/(?:Users|home))\/[^/\s]+/g, "$1/") + .replace(/([A-Za-z]:\\Users)\\[^\\\s]+/g, "$1\\"); + for (const [pattern, replacement] of SECRETS) { + result = result.replace(pattern, replacement); + } + + return result; +} + +/** The message with what varies between apps abstracted: quoted text, paths, numbers and the app's identifiers. */ +export function abstractMessage(message: string): string { + return message + .replace(/(['"`‘“])(?:(?!\1).)*?(['"`’”])/g, "") + .replace(/(?:[\w.~-]*\/)+[\w.-]+/g, "") + .replace(/\b\d+(?:\.\d+)*\b/g, "") + .replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+/g, "") + .replace(/\b[a-z_$][\w$]*(?:[A-Z_$]|\d)[\w$]*\b/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** What kind of construct a message names: an API (`Utils.ios`, `Array.prototype.slice`) or an error class, kept as is. */ +export function constructOf(message: string): string { + const what = message + .split(/ is not supported| does not | cannot |: /)[0] + .trim(); + return /^[A-Z][\w$]*(?:\.[\w$]+)*$/.test(what) ? what : ""; +} + +export function computeSignature( + kind: CompiledReportKind, + message: string, +): string { + return crypto + .createHash("sha256") + .update(`${kind}\n${abstractMessage(message)}\n${constructOf(message)}`) + .digest("hex") + .slice(0, 6); +} + +function parseDiagnostic(line: string): ICompiledDiagnostic { + const located = LOCATED_LINE.exec(line); + const message = located ? located[2] : line; + const refusal = REFUSAL.exec(message); + return { + location: located?.[1], + message: refusal ? refusal[1] : message, + snippet: refusal?.[2], + }; +} + +/** The lines of a failed build's output, or of a pasted runtime error, that say what went wrong. */ +export function extractDiagnostics( + kind: CompiledReportKind, + output: string, +): string[] { + const lines = stripAnsi(output) + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim()); + const isStackLine = (line: string) => /^\s+at /.test(line); + let found: string[]; + if (kind === "runtime") { + const start = lines.findIndex((line) => + /\b(?:[A-Z]\w*(?:Error|Exception)|Fatal error|Uncaught)\b/.test(line), + ); + found = + start === -1 + ? lines.slice(0, 15) + : [ + lines[start].replace( + /^.*?(?=(?:Uncaught )?\b[A-Z][\w.]*(?:Error|Exception)\b|Fatal error)/, + "", + ), + ...lines.slice(start + 1, start + 15), + ]; + } else if (kind === "compile-refused") { + found = lines.filter( + (line) => + /^\S+:\d+(?::\d+)?: /.test(line) || + /is not supported in a release build|does not apply/.test(line), + ); + if (!found.length) { + const start = lines.findIndex((line) => /^\w*Error: /.test(line)); + found = + start === -1 + ? lines.slice(-5) + : lines + .slice(start) + .filter( + (line) => !isStackLine(line) && !/^Node\.js v/.test(line), + ); + } + // Without --all-errors the compiler throws its first refusal, which Node prints after "Error: ". + found = found.map((line) => line.replace(/^Error: /, "")); + } else { + found = lines.filter((line) => /: error: |^e: |^error: /.test(line)); + if (!found.length) { + const wrong = lines.findIndex((line) => /What went wrong:/.test(line)); + found = + wrong !== -1 ? lines.slice(wrong + 1, wrong + 4) : lines.slice(-10); + } + } + + return [...new Set(found.map((line) => line.trim()))].slice( + 0, + MAX_DIAGNOSTICS, + ); +} + +export function newIssueUrl( + title: string, + body: string, + labels: string[], +): { url: string; truncated: boolean } { + const base = + `https://github.com/${ISSUES_REPO}/issues/new?title=${encodeURIComponent(title)}` + + `&labels=${encodeURIComponent(labels.join(","))}&body=`; + const note = "\n\n_(The report was cut to fit a URL.)_"; + let text = body; + let truncated = false; + while ((base + encodeURIComponent(text)).length > MAX_URL_LENGTH) { + const lines = text.split("\n"); + truncated = true; + text = lines + .slice(0, Math.max(1, Math.floor(lines.length * 0.9))) + .join("\n"); + if (lines.length === 1) { + text = text.slice(0, Math.floor(text.length * 0.9)); + } + if ((base + encodeURIComponent(text + note)).length <= MAX_URL_LENGTH) { + text += note; + break; + } + } + + return { url: base + encodeURIComponent(text), truncated }; +} + +/** + * `ns compiled report`'s data: the draft a failed `--compiled` build leaves, + * the report made from it (environment, diagnostic, signature), and the + * GitHub side (finding an issue with the same signature, filing or +1-ing one). + */ +export class CompiledReportService { + constructor( + private $childProcess: IChildProcess, + private $fs: IFileSystem, + private $httpClient: Server.IHttpClient, + private $logger: ILogger, + private $staticConfig: Config.IStaticConfig, + private $sysInfo: ISysInfo, + ) {} + + public getReportDir(projectData: IProjectData, platform: string): string { + return path.join( + projectData.platformsDir, + "compiled", + platform.toLowerCase(), + ); + } + + private getDraftPath(projectData: IProjectData, platform: string): string { + return path.join(this.getReportDir(projectData, platform), "report.json"); + } + + /** Keeps what a failed build printed for `ns compiled report`, and returns the line that says how to report it. */ + public saveDraft( + projectData: IProjectData, + platform: string, + kind: CompiledReportKind, + output: string, + ): string { + const draft: ICompiledReportDraft = { + kind, + platform: platform.toLowerCase(), + createdAt: new Date().toISOString(), + output: output.slice(-MAX_DRAFT_OUTPUT), + }; + try { + this.$fs.writeJson(this.getDraftPath(projectData, platform), draft); + } catch (err) { + this.$logger.trace( + `Saving the compiled report draft failed: ${err.message}`, + ); + return ""; + } + + return `If this looks like a NativeScript problem, report it with 'ns compiled report ${draft.platform}' (it shows you the report before anything is sent).`; + } + + public clearDraft(projectData: IProjectData, platform: string): void { + this.$fs.deleteFile(this.getDraftPath(projectData, platform)); + } + + /** The platform's draft, or without a platform the newest one. */ + public loadDraft( + projectData: IProjectData, + platform?: string, + ): ICompiledReportDraft | null { + const drafts = (platform ? [platform] : ["ios", "android"]) + .map((p) => this.getDraftPath(projectData, p)) + .filter((file) => this.$fs.exists(file)) + .map((file) => this.$fs.readJson(file)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + return drafts[0] ?? null; + } + + public async createReport( + projectData: IProjectData, + draft: ICompiledReportDraft, + ): Promise { + const projectDir = projectData.projectDir; + const clean = (text: string) => redact(text, projectDir); + const diagnostics = extractDiagnostics(draft.kind, draft.output).map( + (line) => parseDiagnostic(clean(line)), + ); + const primary = diagnostics[0]?.message ?? "Unknown failure"; + const signature = computeSignature(draft.kind, primary); + const framework = this.detectFramework(projectData); + const headline = + primary.length > 100 ? `${primary.slice(0, 97)}...` : primary; + const location = this.findLocation(projectDir, draft.output); + + return { + kind: draft.kind, + platform: draft.platform, + signature, + title: `[compiled][${draft.platform}] ${headline} (sig ${signature})`, + labels: [ + "compiled", + `platform:${draft.platform}`, + `framework:${framework.name}`, + `kind:${draft.kind}`, + ], + environment: await this.collectEnvironment( + projectData, + draft.platform, + framework, + ), + plugins: this.collectPlugins(projectData), + diagnostics, + location, + code: location && this.readCode(projectDir, location), + }; + } + + public formatBody(report: ICompiledReport, includeCode: boolean): string { + const diagnostic = report.diagnostics + .map((d) => + [ + d.location && `${d.location}: `, + d.message, + includeCode && d.snippet ? `: ${d.snippet}` : "", + ] + .filter(Boolean) + .join(""), + ) + .join("\n"); + const sections = [ + ``, + `### What happened`, + `${KIND_DESCRIPTIONS[report.kind]} (\`${report.kind}\`, ${report.platform}).`, + "", + "~~~", + diagnostic || "(no diagnostic found in the output)", + "~~~", + ]; + if (report.location) { + sections.push( + "", + `### Where`, + `\`${report.location.file}:${report.location.line}\``, + ); + } + if (includeCode && report.code) { + sections.push( + "", + "### Code (shared with the reporter's consent)", + "~~~", + report.code, + "~~~", + ); + } + sections.push("", "### Environment", this.formatEnvironment(report)); + sections.push( + "", + `Signature \`${report.signature}\`: a hash of the kind, the message with names and numbers abstracted, and the construct; issues with the same signature are the same problem.`, + `Labels: ${report.labels.map((label) => `\`${label}\``).join(", ")}`, + "", + "_Filed with `ns compiled report`._", + ); + + return sections.join("\n"); + } + + public formatComment(report: ICompiledReport): string { + const primary = report.diagnostics[0]; + return [ + `+1 from \`ns compiled report\` (sig ${report.signature}, ${report.platform}).`, + "", + primary + ? `~~~\n${primary.location ? `${primary.location}: ` : ""}${primary.message}\n~~~\n` + : "", + this.formatEnvironment(report), + ].join("\n"); + } + + private formatEnvironment(report: ICompiledReport): string { + const rows = report.environment.map( + ([name, value]) => `| ${name} | ${value} |`, + ); + const plugins = report.plugins.map( + ([name, version]) => `- ${name} ${version}`, + ); + return [ + "| | |", + "| --- | --- |", + ...rows, + "", + `
Dependencies (${plugins.length})`, + "", + ...plugins, + "", + "
", + ].join("\n"); + } + + /** Whether `gh` is installed and signed in to github.com. */ + public async hasGh(): Promise { + const result = await this.gh([ + "auth", + "status", + "--hostname", + "github.com", + ]); + return result.exitCode === 0; + } + + /** Open and closed issues whose title has the signature; null when the search could not run. */ + public async findIssues( + signature: string, + useGh: boolean, + ): Promise { + const query = `"sig ${signature}" in:title`; + if (useGh) { + const result = await this.gh([ + "issue", + "list", + "--repo", + ISSUES_REPO, + "--state", + "all", + "--search", + query, + "--json", + "number,title,state,url", + "--limit", + "5", + ]); + if (result.exitCode === 0) { + try { + return JSON.parse(result.stdout); + } catch { + return null; + } + } + } + + const url = `https://api.github.com/search/issues?q=${encodeURIComponent(`repo:${ISSUES_REPO} is:issue ${query}`)}&per_page=5`; + try { + const response = await Promise.race([ + this.$httpClient.httpRequest({ + url, + method: "GET", + headers: { Accept: "application/vnd.github+json" }, + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("timed out")), + SEARCH_TIMEOUT_MS, + ).unref(), + ), + ]); + const items: any[] = JSON.parse(response.body).items ?? []; + return items.map((item) => ({ + number: item.number, + title: item.title, + state: item.state, + url: item.html_url, + })); + } catch (err) { + this.$logger.trace( + `Searching GitHub for the report's signature failed: ${err.message}`, + ); + return null; + } + } + + /** Files the issue with `gh`; returns its URL, or null when gh failed. */ + public async createIssue( + projectData: IProjectData, + report: ICompiledReport, + body: string, + ): Promise { + const bodyFile = this.writeBodyFile( + projectData, + report.platform, + "report.md", + body, + ); + const args = [ + "issue", + "create", + "--repo", + ISSUES_REPO, + "--title", + report.title, + "--body-file", + bodyFile, + ]; + let result = await this.gh([ + ...args, + ...report.labels.flatMap((label) => ["--label", label]), + ]); + // A label the repository lacks, or one the reporter may not set, fails the whole create. + if (result.exitCode !== 0 && /label/i.test(result.stderr)) { + this.$logger.warn( + "GitHub refused the labels; filing without them (they are listed in the body).", + ); + result = await this.gh(args); + } + if (result.exitCode !== 0) { + this.$logger.warn(`gh issue create failed: ${result.stderr.trim()}`); + return null; + } + + return result.stdout.trim().split("\n").pop(); + } + + public async commentOnIssue( + projectData: IProjectData, + report: ICompiledReport, + issue: IReportedIssue, + ): Promise { + const bodyFile = this.writeBodyFile( + projectData, + report.platform, + "report-comment.md", + this.formatComment(report), + ); + const result = await this.gh([ + "issue", + "comment", + String(issue.number), + "--repo", + ISSUES_REPO, + "--body-file", + bodyFile, + ]); + if (result.exitCode !== 0) { + this.$logger.warn(`gh issue comment failed: ${result.stderr.trim()}`); + } + + return result.exitCode === 0; + } + + private writeBodyFile( + projectData: IProjectData, + platform: string, + name: string, + body: string, + ): string { + const file = path.join(this.getReportDir(projectData, platform), name); + this.$fs.writeFile(file, body); + return file; + } + + private gh(args: string[]) { + return this.$childProcess.spawnFromEvent( + "gh", + args, + "close", + {}, + { throwError: false }, + ); + } + + private findLocation( + projectDir: string, + output: string, + ): { file: string; line: number } | undefined { + for (const line of stripAnsi(output).split(/\r?\n/)) { + const match = SOURCE_REFERENCE.exec(line); + if (!match) { + continue; + } + const file = path.resolve(projectDir, match[1].replace(/^file:\/\//, "")); + const relative = path.relative(projectDir, file); + if ( + relative.startsWith("..") || + path.isAbsolute(relative) || + relative.startsWith(`platforms${path.sep}`) || + relative.includes(path.join("node_modules", COMPILER_PACKAGE_NAME)) + ) { + continue; + } + return { + file: relative.split(path.sep).join("/"), + line: Number(match[2]), + }; + } + + return undefined; + } + + private readCode( + projectDir: string, + location: { file: string; line: number }, + ): string | undefined { + const file = path.join(projectDir, location.file); + if (!this.$fs.exists(file)) { + return undefined; + } + const lines = this.$fs.readText(file).split(/\r?\n/); + const first = Math.max(1, location.line - 3); + const last = Math.min(lines.length, location.line + 3); + const width = String(last).length; + const numbered = []; + for (let n = first; n <= last; n++) { + numbered.push( + `${String(n).padStart(width)} ${n === location.line ? ">" : "|"} ${lines[n - 1]}`, + ); + } + + return redact(numbered.join("\n"), projectDir); + } + + private readPackageJson(projectDir: string): any { + const file = path.join(projectDir, "package.json"); + return this.$fs.exists(file) ? this.$fs.readJson(file) : {}; + } + + private installedVersion( + projectData: IProjectData, + name: string, + ): string | undefined { + const pkg = this.readPackageJson(projectData.projectDir); + try { + const dir = resolvePackagePath(name, { paths: [projectData.projectDir] }); + if (dir) { + return this.$fs.readJson(path.join(dir, "package.json")).version; + } + } catch (err) { + this.$logger.trace(`Reading ${name}'s version failed: ${err.message}`); + } + + return pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]; + } + + public detectFramework(projectData: IProjectData): { + name: string; + version: string; + } { + const pkg = this.readPackageJson(projectData.projectDir); + const deps = { ...pkg.devDependencies, ...pkg.dependencies }; + const framework = FRAMEWORKS.find((f) => + f.markers.some((marker) => marker in deps), + ); + if (!framework) { + return { name: "xml", version: "" }; + } + + return { + name: framework.name, + version: + this.installedVersion(projectData, framework.versionOf) ?? "unknown", + }; + } + + private collectPlugins(projectData: IProjectData): [string, string][] { + const dependencies = + this.readPackageJson(projectData.projectDir).dependencies ?? {}; + return Object.keys(dependencies) + .sort() + .map((name) => [ + name, + this.installedVersion(projectData, name) ?? "unknown", + ]); + } + + private async collectEnvironment( + projectData: IProjectData, + platform: string, + framework: { name: string; version: string }, + ): Promise<[string, string][]> { + const version = (name: string) => + this.installedVersion(projectData, name) ?? "not installed"; + const rows: [string, string][] = [ + ["NativeScript CLI", this.$staticConfig.version], + [COMPILER_PACKAGE_NAME, version(COMPILER_PACKAGE_NAME)], + ["@nativescript/core", version("@nativescript/core")], + [ + "Framework", + `${framework.name}${framework.version ? ` ${framework.version}` : ""}`, + ], + ["Platform", platform], + ]; + if (platform === "ios") { + let xcode: string; + try { + xcode = await this.$sysInfo.getXcodeVersion(); + } catch (err) { + this.$logger.trace(`Reading Xcode's version failed: ${err.message}`); + } + rows.push(["Xcode", xcode || "unknown"]); + } else { + rows.push(...this.androidToolVersions(projectData)); + } + rows.push( + ["OS", `${os.type()} ${os.release()} ${os.arch()}`], + ["Node.js", process.version], + ); + + return rows; + } + + private androidToolVersions(projectData: IProjectData): [string, string][] { + let gradle = "unknown"; + try { + const compilerDir = resolvePackagePath(COMPILER_PACKAGE_NAME, { + paths: [projectData.projectDir], + }); + const wrapper = + compilerDir && + path.join( + compilerDir, + "kit-android", + "gradle", + "wrapper", + "gradle-wrapper.properties", + ); + if (wrapper && this.$fs.exists(wrapper)) { + gradle = + /gradle-([\d.]+)-/.exec(this.$fs.readText(wrapper))?.[1] ?? gradle; + } + } catch (err) { + this.$logger.trace(`Reading Gradle's version failed: ${err.message}`); + } + + const projectRoot = this.getReportDir(projectData, "android"); + const scripts = this.$fs.exists(projectRoot) + ? this.$fs + .readDirectory(projectRoot) + .filter((file) => /\.gradle(\.kts)?$/.test(file)) + .map((file) => this.$fs.readText(path.join(projectRoot, file))) + .join("\n") + : ""; + const plugin = (id: string) => + new RegExp( + `id\\s*\\(?\\s*["']${id.replace(/\./g, "\\.")}["']\\s*\\)?\\s*version\\s*["']([^"']+)`, + ).exec(scripts)?.[1] ?? "unknown"; + + return [ + ["Gradle", gradle], + ["Android Gradle Plugin", plugin("com.android.application")], + ["Kotlin", plugin("org.jetbrains.kotlin.android")], + ]; + } +} +injector.register("compiledReportService", CompiledReportService); diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index ea27f41500..8183d71afd 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -24,6 +24,9 @@ let hookCalls: { name: string; args: any }[]; let errorsLogged: string[]; let gradleOutput: { data: string; pipe: string }[] | null; let retraceOutput: string; +let drafts: { platform: string; kind: string; output: string }[]; +let compilerFailure: string | null; +let xcodebuildFailure: string | null; function createProjectData(nsConfig: any = {}): any { return { @@ -46,12 +49,24 @@ function createService(nsConfig: any = {}): CompiledReleaseService { args: string[], _: string, options: any, + spawnOptions?: any, ) => { spawnCalls.push({ command, args, options }); + if (compilerFailure && args[0]?.endsWith("ns-native.js")) { + childProcess.emit(spawnOptions.emitOptions.eventName, { data: compilerFailure, pipe: "stderr" }); + return { stdout: "", stderr: compilerFailure, exitCode: 1 }; + } return { stdout: retraceOutput, stderr: "", exitCode: 0 }; }, }); injector.register("childProcess", childProcess); + injector.register("compiledReportService", { + clearDraft: (): void => undefined, + saveDraft: (_: any, platform: string, kind: string, output: string) => { + drafts.push({ platform, kind, output }); + return `report it with 'ns compiled report ${platform}'`; + }, + }); injector.register("errors", { fail: (message: string) => { throw new Error(message); @@ -103,8 +118,13 @@ function createService(nsConfig: any = {}): CompiledReleaseService { getProjectData: () => createProjectData(nsConfig), }); injector.register("xcodebuildCommandService", { - executeCommand: async (args: string[]) => { + executeCommand: async (args: string[], options: any) => { xcodebuildCalls.push(args); + if (xcodebuildFailure) { + childProcess.emit(options.spawnOptions.emitOptions.eventName, { data: xcodebuildFailure, pipe: "stdout" }); + return { stdout: xcodebuildFailure, stderr: "", exitCode: 65 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; }, }); injector.register("hooksService", { @@ -161,6 +181,9 @@ describe("compiledReleaseService", () => { writtenFiles = {}; warnings = []; hookCalls = []; + drafts = []; + compilerFailure = null; + xcodebuildFailure = null; }); afterEach(() => { @@ -290,6 +313,24 @@ describe("compiledReleaseService", () => { assert.include(error.message, "not visionOS"); }); + + it("keeps the compiler's errors as a report draft when it refuses the app", async () => { + installCompilerPackage(); + const service = createService(); + compilerFailure = `${projectDir}/app/main.ts:3:1: Utils.ios is not supported in a release build yet: Utils.ios\n`; + + let error: Error; + try { + await service.prepare(platformData("iOS"), createProjectData()); + } catch (err) { + error = err; + } + + assert.include(error.message, "The compile to native code failed (exit code 1)."); + assert.include(error.message, "ns compiled report ios"); + assert.deepStrictEqual(drafts, [{ platform: "ios", kind: "compile-refused", output: compilerFailure }]); + assert.deepStrictEqual(spawnCalls[0].options.stdio, ["inherit", "inherit", "pipe"]); + }); }); describe("build", () => { @@ -369,6 +410,11 @@ describe("compiledReleaseService", () => { 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)"]); + assert.include(failure.message, "ns compiled report android"); + assert.lengthOf(drafts, 1); + assert.equal(drafts[0].kind, "native-compile"); + assert.isTrue(drafts[0].output.startsWith("e: app/home.ts:7: Unresolved reference 'x'.")); + assert.include(drafts[0].output, kotlinError); }); it("builds an App Bundle for --aab", async () => { @@ -404,6 +450,22 @@ describe("compiledReleaseService", () => { ); }); + it("keeps swiftc's errors as a report draft when the Xcode build fails", async () => { + const service = createService(); + xcodebuildFailure = `${projectDir}/app/main.ts:3:5: error: cannot convert value of type 'Int' to expected argument type 'String'\n** BUILD FAILED **\n`; + + let error: Error; + try { + await service.build(platformData("iOS"), createProjectData(), { release: true }); + } catch (err) { + error = err; + } + + assert.include(error.message, "xcodebuild failed with exit code 65."); + assert.include(error.message, "ns compiled report ios"); + assert.deepStrictEqual(drafts, [{ platform: "ios", kind: "native-compile", output: xcodebuildFailure }]); + }); + it("runs the project's build hooks around the compiled build, with the compiled project as projectRoot", async () => { const service = createService(); diff --git a/test/services/compiled-report-service.ts b/test/services/compiled-report-service.ts new file mode 100644 index 0000000000..26541f962f --- /dev/null +++ b/test/services/compiled-report-service.ts @@ -0,0 +1,808 @@ +import { assert } from "chai"; +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 { FileSystem } from "../../lib/common/file-system"; +import { setIsInteractive } from "../../lib/common/helpers"; +import { + CompiledReportService, + computeSignature, + extractDiagnostics, + newIssueUrl, + redact, +} from "../../lib/services/compiled-report-service"; +import { CompiledReportCommand } from "../../lib/commands/compiled-report"; + +interface IGhCall { + args: string[]; +} + +let projectDir: string; +let ghCalls: IGhCall[]; +let ghResponses: { + [subcommand: string]: { + stdout?: string; + stderr?: string; + exitCode: number; + }[]; +}; +let httpCalls: string[]; +let httpResponse: () => Promise<{ body: string }>; +let logged: string[]; +let opened: string[]; +let confirms: { message: string; answer: boolean }[]; +let confirmAnswers: boolean[]; +let choiceAnswer: (choices: string[]) => string; +let options: any; + +const FRAMEWORK_FIXTURES: { + name: string; + dependencies: { [name: string]: string }; + versionOf?: string; +}[] = [ + { + name: "angular", + dependencies: { + "@nativescript/angular": "^20.0.0", + "@angular/core": "~20.1.0", + }, + versionOf: "@angular/core", + }, + { + name: "vue", + dependencies: { "nativescript-vue": "^3.0.0" }, + versionOf: "nativescript-vue", + }, + { + name: "svelte", + dependencies: { + "@nativescript-community/svelte-native": "^1.0.0", + svelte: "^5.0.0", + }, + versionOf: "svelte", + }, + { + name: "react", + dependencies: { "react-nativescript": "^5.0.0", react: "^18.3.1" }, + versionOf: "react", + }, + { + name: "solid", + dependencies: { + "@nativescript-community/solid-js": "^0.1.0", + "solid-js": "^1.9.0", + }, + versionOf: "solid-js", + }, + { name: "xml", dependencies: {} }, +]; + +function writeFile(relative: string, content: string): void { + const file = path.join(projectDir, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +function createFixture( + dependencies: { [name: string]: string }, + installed: { [name: string]: string } = {}, +): void { + writeFile( + "package.json", + JSON.stringify({ + name: "demo", + dependencies: { "@nativescript/core": "~9.1.0", ...dependencies }, + devDependencies: { "@nativescript/compiler": "^0.4.0" }, + }), + ); + for (const [name, version] of Object.entries({ + "@nativescript/core": "9.1.1", + "@nativescript/compiler": "0.4.2", + ...installed, + })) { + writeFile( + path.join("node_modules", name, "package.json"), + JSON.stringify({ name, version }), + ); + } + writeFile( + "app/main.ts", + [ + "import { Utils } from '@nativescript/core';", + "", + "export function start() {", + " const app = Utils.ios.getter(UIApplication, UIApplication.sharedApplication);", + " return app;", + "}", + "", + ].join("\n"), + ); +} + +const projectData = (): any => ({ + projectDir, + projectName: "demo", + platformsDir: path.join(projectDir, "platforms"), + initializeProjectData: (): void => undefined, +}); + +function createInjector(): IInjector { + const injector: IInjector = new Yok(); + injector.register("fs", FileSystem); + injector.register("childProcess", { + spawnFromEvent: async (command: string, args: string[]) => { + assert.equal(command, "gh"); + ghCalls.push({ args }); + const key = args.slice(0, 2).join(" "); + const response = ghResponses[key]?.shift() ?? { + exitCode: 1, + stderr: "not stubbed", + }; + return { stdout: "", stderr: "", ...response }; + }, + }); + injector.register("httpClient", { + httpRequest: async (request: { url: string }) => { + httpCalls.push(request.url); + return httpResponse(); + }, + }); + injector.register("logger", { + info: (message: string) => logged.push(message), + warn: (message: string) => logged.push(message), + trace: (): void => undefined, + }); + injector.register("staticConfig", { version: "9.1.2" }); + injector.register("sysInfo", { getXcodeVersion: async () => "26.0" }); + injector.register("errors", { + fail: (message: string) => { + throw new Error(message); + }, + }); + injector.register("opener", { open: (url: string) => opened.push(url) }); + injector.register("options", options); + injector.register("projectData", projectData()); + injector.register("prompter", { + confirm: async (message: string) => { + const answer = confirmAnswers.shift() ?? false; + confirms.push({ message, answer }); + return answer; + }, + promptForChoice: async (_: string, choices: string[]) => + choiceAnswer(choices), + }); + injector.register("compiledReportService", CompiledReportService); + injector.register("compiledReportCommand", CompiledReportCommand); + return injector; +} + +const refusal = () => + [ + "Compiling the app to native code (ios)...", + `${projectDir}/app/main.ts:4:15: Utils.ios is not supported in a release build yet: Utils.ios.getter(UIApplication, UIApplication.sharedApplication)`, + "file:///Users/someone/compiler/src/cli.ts:422", + " if (!args.includes('--keep-going')) throw new Error(...)", + "Error: 1 constructs the release build cannot translate yet", + " at file:///Users/someone/compiler/src/cli.ts:422:51", + "Node.js v24.4.0", + ].join("\n"); + +describe("compiledReportService", () => { + beforeEach(() => { + projectDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "compiled-report-")), + ); + ghCalls = []; + ghResponses = {}; + httpCalls = []; + httpResponse = async () => ({ body: JSON.stringify({ items: [] }) }); + logged = []; + opened = []; + confirms = []; + confirmAnswers = []; + choiceAnswer = (choices) => choices[choices.length - 1]; + options = {}; + createFixture({}); + }); + + afterEach(() => { + setIsInteractive(undefined); + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + describe("redact", () => { + it("makes project paths relative and hides the home folder, user names and secrets", () => { + const text = [ + "/work/app/src/main.ts:3: failed", + "file:///work/app/src/page.ts:9:2", + "/Users/ada/Library/Developer/Xcode", + "/home/ada/.gradle/caches", + "-Pandroid.injected.signing.store.password=hunter2", + "token: ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "https://ada:s3cret@registry.example.com/", + "AKIAABCDEFGHIJKLMNOP", + ].join("\n"); + + assert.equal( + redact(text, "/work/app", "/Users/ada"), + [ + "src/main.ts:3: failed", + "src/page.ts:9:2", + "~/Library/Developer/Xcode", + "/home//.gradle/caches", + "-Pandroid.injected.signing.store.password=", + "token: ", + "https://ada:@registry.example.com/", + "", + ].join("\n"), + ); + }); + }); + + describe("computeSignature", () => { + it("is the same for the same refusal in another app, file and line", () => { + assert.equal( + computeSignature( + "compile-refused", + "Utils.ios is not supported in a release build yet", + ), + computeSignature( + "compile-refused", + "Utils.ios is not supported in a release build yet", + ), + ); + assert.equal( + computeSignature( + "native-compile", + "cannot convert value of type 'Int' to 'myValue' at line 12", + ), + computeSignature( + "native-compile", + "cannot convert value of type 'Double' to 'otherValue' at line 40", + ), + ); + }); + + it("differs for another API or another kind", () => { + const sig = computeSignature( + "compile-refused", + "Utils.ios is not supported in a release build yet", + ); + assert.notEqual( + sig, + computeSignature( + "compile-refused", + "Utils.android is not supported in a release build yet", + ), + ); + assert.notEqual( + sig, + computeSignature( + "runtime", + "Utils.ios is not supported in a release build yet", + ), + ); + assert.match(sig, /^[0-9a-f]{6}$/); + }); + }); + + describe("extractDiagnostics", () => { + it("takes the compiler's located refusals, not its own stack", () => { + assert.deepStrictEqual(extractDiagnostics("compile-refused", refusal()), [ + `${projectDir}/app/main.ts:4:15: Utils.ios is not supported in a release build yet: Utils.ios.getter(UIApplication, UIApplication.sharedApplication)`, + ]); + }); + + it("takes the refusal the compiler throws without --all-errors", () => { + assert.deepStrictEqual( + extractDiagnostics( + "compile-refused", + "file:///c/src/swift.ts:12\n\nError: /app/main.ts:4:5: a class declared inside a function is not supported in a release build yet: class Local {}\n at x (y.js:1:1)", + ), + [ + "/app/main.ts:4:5: a class declared inside a function is not supported in a release build yet: class Local {}", + ], + ); + }); + + it("falls back to the compiler's error when nothing is located", () => { + assert.deepStrictEqual( + extractDiagnostics( + "compile-refused", + "Error: the app does not type-check as the release build sees it:\napp/main.ts(3,1): error TS2304\n at x (y.js:1:1)", + ), + [ + "the app does not type-check as the release build sees it:", + "app/main.ts(3,1): error TS2304", + ], + ); + }); + + it("takes swiftc's, Kotlin's and Gradle's errors", () => { + assert.deepStrictEqual( + extractDiagnostics( + "native-compile", + "\x1b[1m/app/main.ts:3:5: error: cannot find 'x' in scope\x1b[0m\n** BUILD FAILED **", + ), + ["/app/main.ts:3:5: error: cannot find 'x' in scope"], + ); + assert.deepStrictEqual( + extractDiagnostics( + "native-compile", + "e: file:///p/Home.kt:14:9 Unresolved reference 'x'.\nFAILURE", + ), + ["e: file:///p/Home.kt:14:9 Unresolved reference 'x'."], + ); + assert.deepStrictEqual( + extractDiagnostics( + "native-compile", + "FAILURE: Build failed with an exception.\n* What went wrong:\nExecution failed for task ':mergeReleaseResources'.\n* Try:", + ), + ["Execution failed for task ':mergeReleaseResources'.", "* Try:"], + ); + }); + + it("takes a runtime error from its first error line", () => { + assert.deepStrictEqual( + extractDiagnostics( + "runtime", + "2026-10-14 12:00:01 demo[123] CONSOLE ERROR: TypeError: Cannot read properties of undefined (reading 'name')\n at start (app/main.ts:4:9)", + ), + [ + "TypeError: Cannot read properties of undefined (reading 'name')", + "at start (app/main.ts:4:9)", + ], + ); + }); + }); + + describe("createReport", () => { + for (const fixture of FRAMEWORK_FIXTURES) { + it(`labels a ${fixture.name} project and names its version`, async () => { + const installed = fixture.versionOf + ? { [fixture.versionOf]: "1.2.3" } + : {}; + createFixture(fixture.dependencies, installed); + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + const report = await service.createReport(projectData(), { + kind: "compile-refused", + platform: "ios", + createdAt: "", + output: refusal(), + }); + + assert.deepStrictEqual(report.labels, [ + "compiled", + "platform:ios", + `framework:${fixture.name}`, + "kind:compile-refused", + ]); + const environment = new Map(report.environment); + assert.equal( + environment.get("Framework"), + fixture.versionOf ? `${fixture.name} 1.2.3` : "xml", + ); + assert.equal(environment.get("@nativescript/compiler"), "0.4.2"); + assert.equal(environment.get("@nativescript/core"), "9.1.1"); + assert.equal(environment.get("Xcode"), "26.0"); + }); + } + + it("titles the issue with the signature and keeps code out of the body unless asked", async () => { + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + const report = await service.createReport(projectData(), { + kind: "compile-refused", + platform: "ios", + createdAt: "", + output: refusal(), + }); + + assert.equal( + report.title, + `[compiled][ios] Utils.ios is not supported in a release build yet (sig ${report.signature})`, + ); + assert.deepStrictEqual(report.location, { file: "app/main.ts", line: 4 }); + assert.include( + report.code, + "4 > const app = Utils.ios.getter(UIApplication, UIApplication.sharedApplication);", + ); + assert.include( + report.code, + "1 | import { Utils } from '@nativescript/core';", + ); + const body = service.formatBody(report, false); + assert.include( + body, + "app/main.ts:4:15: Utils.ios is not supported in a release build yet\n", + ); + assert.notInclude(body, "UIApplication"); + assert.notInclude(body, projectDir); + assert.include( + service.formatBody(report, true), + "Utils.ios.getter(UIApplication", + ); + }); + + it("reads the location of a refusal the compiler prints as an error", async () => { + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + const report = await service.createReport(projectData(), { + kind: "compile-refused", + platform: "android", + createdAt: "", + output: `error: ${projectDir}/app/main.ts:4:15: Array.prototype is not supported in a release build yet: Array.prototype\n`, + }); + + assert.deepStrictEqual(report.diagnostics, [ + { + location: "app/main.ts:4:15", + message: "Array.prototype is not supported in a release build yet", + snippet: "Array.prototype", + }, + ]); + assert.equal( + report.title, + `[compiled][android] Array.prototype is not supported in a release build yet (sig ${report.signature})`, + ); + }); + + it("lists Gradle, the Android Gradle Plugin and Kotlin for Android", async () => { + writeFile( + "node_modules/@nativescript/compiler/kit-android/gradle/wrapper/gradle-wrapper.properties", + "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.14.3-bin.zip\n", + ); + writeFile( + "platforms/compiled/android/build.gradle.kts", + 'plugins {\n id("com.android.application") version "8.12.1"\n id("org.jetbrains.kotlin.android") version "2.2.20"\n}\n', + ); + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + const report = await service.createReport(projectData(), { + kind: "native-compile", + platform: "android", + createdAt: "", + output: "e: app/main.ts:4: Unresolved reference 'x'.", + }); + + const environment = new Map(report.environment); + assert.equal(environment.get("Gradle"), "8.14.3"); + assert.equal(environment.get("Android Gradle Plugin"), "8.12.1"); + assert.equal(environment.get("Kotlin"), "2.2.20"); + }); + }); + + describe("newIssueUrl", () => { + it("cuts a long body to fit GitHub's URL limit and says so", () => { + const body = Array.from( + { length: 2000 }, + (_, k) => `line ${k} of a long log`, + ).join("\n"); + + const { url, truncated } = newIssueUrl("title", body, ["compiled"]); + + assert.isTrue(truncated); + assert.isAtMost(url.length, 8000); + assert.include( + decodeURIComponent(url), + "(The report was cut to fit a URL.)", + ); + assert.isFalse(newIssueUrl("title", "short", ["compiled"]).truncated); + }); + }); + + describe("findIssues", () => { + it("searches with gh when it is signed in", async () => { + ghResponses["issue list"] = [ + { + exitCode: 0, + stdout: JSON.stringify([ + { number: 7, title: "t (sig abc123)", state: "OPEN", url: "u" }, + ]), + }, + ]; + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + assert.deepStrictEqual(await service.findIssues("abc123", true), [ + { number: 7, title: "t (sig abc123)", state: "OPEN", url: "u" }, + ]); + assert.include(ghCalls[0].args, '"sig abc123" in:title'); + assert.include(ghCalls[0].args, "all"); + assert.lengthOf(httpCalls, 0); + }); + + it("searches GitHub's public API without gh, and gives up offline", async () => { + httpResponse = async () => ({ + body: JSON.stringify({ + items: [{ number: 9, title: "t", state: "closed", html_url: "h" }], + }), + }); + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + assert.deepStrictEqual(await service.findIssues("abc123", false), [ + { number: 9, title: "t", state: "closed", url: "h" }, + ]); + assert.include( + decodeURIComponent(httpCalls[0]), + 'repo:NativeScript/NativeScript is:issue "sig abc123" in:title', + ); + + httpResponse = async () => { + throw new Error("getaddrinfo ENOTFOUND api.github.com"); + }; + assert.isNull(await service.findIssues("abc123", false)); + }); + }); + + describe("createIssue", () => { + it("files without labels when GitHub refuses them", async () => { + ghResponses["issue create"] = [ + { + exitCode: 1, + stderr: "could not add label: 'kind:runtime' not found", + }, + { + exitCode: 0, + stdout: "https://github.com/NativeScript/NativeScript/issues/1\n", + }, + ]; + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + const report = await service.createReport(projectData(), { + kind: "runtime", + platform: "ios", + createdAt: "", + output: "TypeError: x", + }); + + assert.equal( + await service.createIssue(projectData(), report, "body"), + "https://github.com/NativeScript/NativeScript/issues/1", + ); + assert.include(ghCalls[0].args, "--label"); + assert.notInclude(ghCalls[1].args, "--label"); + }); + }); +}); + +describe("ns compiled report", () => { + let draftFile: string; + + beforeEach(() => { + projectDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "compiled-report-")), + ); + ghCalls = []; + ghResponses = {}; + httpCalls = []; + httpResponse = async () => ({ body: JSON.stringify({ items: [] }) }); + logged = []; + opened = []; + confirms = []; + confirmAnswers = []; + choiceAnswer = (choices) => choices[choices.length - 1]; + options = {}; + createFixture( + { "@nativescript/angular": "^20.0.0" }, + { "@angular/core": "20.1.0" }, + ); + draftFile = path.join( + projectDir, + "platforms", + "compiled", + "ios", + "report.json", + ); + writeFile( + "platforms/compiled/ios/report.json", + JSON.stringify({ + kind: "compile-refused", + platform: "ios", + createdAt: "2026-10-14T00:00:00.000Z", + output: refusal(), + }), + ); + }); + + afterEach(() => { + setIsInteractive(undefined); + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + const run = (args: string[] = []) => + createInjector() + .resolve("compiledReportCommand") + .execute(args); + const output = () => logged.join("\n"); + + it("prints the title, labels, body and URL with --dry-run, and sends nothing", async () => { + setIsInteractive(() => true); + options.dryRun = true; + + await run(); + + assert.include( + output(), + "Title: [compiled][ios] Utils.ios is not supported in a release build yet (sig ", + ); + assert.include( + output(), + "Labels: compiled, platform:ios, framework:angular, kind:compile-refused", + ); + assert.include( + output(), + "https://github.com/NativeScript/NativeScript/issues/new?title=", + ); + assert.include(output(), "Dry run: nothing was sent."); + assert.lengthOf(ghCalls, 0); + assert.lengthOf(httpCalls, 0); + assert.lengthOf(confirms, 0); + assert.lengthOf(opened, 0); + }); + + it("sends nothing from a terminal that cannot answer", async () => { + setIsInteractive(() => false); + options.yes = true; + + await run(["ios"]); + + assert.include(output(), "Nothing was sent"); + assert.lengthOf(ghCalls, 0); + assert.lengthOf(httpCalls, 0); + }); + + it("sends nothing when the reporter declines", async () => { + setIsInteractive(() => true); + confirmAnswers = [false]; + + await run(); + + assert.lengthOf(confirms, 1); + assert.lengthOf(ghCalls, 0); + assert.lengthOf(httpCalls, 0); + }); + + it("offers a +1 on an issue with the same signature", async () => { + setIsInteractive(() => true); + options.yes = true; + ghResponses["auth status"] = [{ exitCode: 0 }]; + ghResponses["issue list"] = [ + { + exitCode: 0, + stdout: JSON.stringify([ + { + number: 42, + title: "t", + state: "OPEN", + url: "https://github.com/NativeScript/NativeScript/issues/42", + }, + ]), + }, + ]; + ghResponses["issue comment"] = [{ exitCode: 0 }]; + choiceAnswer = (choices) => choices[0]; + + await run(); + + assert.lengthOf(confirms, 0); + const comment = ghCalls.find((c) => c.args[1] === "comment"); + assert.deepStrictEqual(comment.args.slice(0, 5), [ + "issue", + "comment", + "42", + "--repo", + "NativeScript/NativeScript", + ]); + const body = fs.readFileSync(comment.args[comment.args.length - 1], "utf8"); + assert.include(body, "+1 from `ns compiled report`"); + assert.notInclude(body, "UIApplication"); + }); + + it("asks again before the code goes in, even with --yes, and files with gh", async () => { + setIsInteractive(() => true); + options.yes = true; + confirmAnswers = [false]; + ghResponses["auth status"] = [{ exitCode: 0 }]; + ghResponses["issue list"] = [{ exitCode: 0, stdout: "[]" }]; + ghResponses["issue create"] = [ + { + exitCode: 0, + stdout: "https://github.com/NativeScript/NativeScript/issues/43\n", + }, + ]; + + await run(); + + assert.lengthOf(confirms, 1); + assert.include(confirms[0].message, "Include these lines of app/main.ts"); + const create = ghCalls.find((c) => c.args[1] === "create"); + assert.includeMembers(create.args, [ + "--label", + "compiled", + "platform:ios", + "framework:angular", + "kind:compile-refused", + ]); + const body = fs.readFileSync( + create.args[create.args.indexOf("--body-file") + 1], + "utf8", + ); + assert.notInclude(body, "### Code"); + assert.include( + output(), + "Filed https://github.com/NativeScript/NativeScript/issues/43", + ); + }); + + it("opens a prefilled issue without gh, with the code the reporter agreed to", async () => { + setIsInteractive(() => true); + confirmAnswers = [true, true]; + ghResponses["auth status"] = [{ exitCode: 1, stderr: "not logged in" }]; + + await run(); + + assert.lengthOf(httpCalls, 1); + assert.lengthOf(opened, 1); + const url = new URL(opened[0]); + assert.equal( + url.searchParams.get("labels"), + "compiled,platform:ios,framework:angular,kind:compile-refused", + ); + assert.include( + url.searchParams.get("body"), + "### Code (shared with the reporter's consent)", + ); + assert.notInclude(url.searchParams.get("body"), projectDir); + }); + + it("reports a pasted runtime error with --error", async () => { + setIsInteractive(() => false); + fs.rmSync(draftFile); + writeFile( + "error.txt", + "CONSOLE ERROR: TypeError: Cannot read properties of undefined (reading 'name')\n at start (app/main.ts:4:9)\n", + ); + options.error = path.join(projectDir, "error.txt"); + + await run(["android"]); + + assert.include( + output(), + "Title: [compiled][android] TypeError: Cannot read properties of undefined (reading 'name') (sig ", + ); + assert.include(output(), "kind:runtime"); + assert.include(output(), "`app/main.ts:4`"); + }); + + it("says how to make a report when there is nothing to report", async () => { + fs.rmSync(draftFile); + + let error: Error; + try { + await run(["android"]); + } catch (err) { + error = err; + } + + assert.include( + error.message, + "No failed --compiled build for android to report.", + ); + }); +}); From 14eb85292d67b737b769276c8c4383ba601ad307 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 14 Oct 2026 23:21:31 -0700 Subject: [PATCH 14/16] =?UTF-8?q?ns=20compiled=20report=20fills=20the=20--?= =?UTF-8?q?compiled=20issue=20form,=20adds=20a=20=F0=9F=91=8D=20with=20the?= =?UTF-8?q?=20environment=20only=20when=20it=20is=20new,=20and=20keeps=20S?= =?UTF-8?q?wift=20and=20Kotlin's=20quoted=20names=20in=20the=20signature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/testing/compiled-report.md | 2 +- lib/commands/compiled-report.ts | 32 +- lib/services/compiled-report-service.ts | 361 +++++++++++++----- test/services/compiled-report-service.ts | 197 ++++++++-- 4 files changed, 459 insertions(+), 133 deletions(-) diff --git a/docs/man_pages/project/testing/compiled-report.md b/docs/man_pages/project/testing/compiled-report.md index 77062c1bae..65f218c1d5 100644 --- a/docs/man_pages/project/testing/compiled-report.md +++ b/docs/man_pages/project/testing/compiled-report.md @@ -11,7 +11,7 @@ Reports a `--compiled` problem to NativeScript as a GitHub issue on NativeScript The report holds the CLI, `@nativescript/compiler` and `@nativescript/core` versions, the framework and its version, the platform, Xcode or Gradle, the Android Gradle Plugin and Kotlin, the OS, the project's dependencies with their versions, the diagnostic and the source location it names. Paths are made relative to the project; the home folder, user names, tokens and passwords are removed. Its title carries a signature, a short hash of the kind of failure and its message with names and numbers abstracted, so the same problem in different apps finds the same issue. -Nothing leaves the machine until you have seen the report and agreed. The command then searches the repository's open and closed issues for the signature: if one exists, it offers to add a +1 comment with your environment, or to open it. Otherwise it files the issue with `gh`, when it is installed and signed in, or opens a prefilled issue in the browser. The source lines around the location (three either side) are added only if you agree a second time; `--yes` never skips that question. In a terminal that cannot answer, it prints the report and the issue URL and sends nothing. +Nothing leaves the machine until you have seen the report and agreed. The command then searches the repository's open and closed issues for the signature: if one exists, it offers to add a 👍 to it (with your environment as a comment when no earlier report has the same platform, framework and compiler and core versions), or to open it. Otherwise it files the issue with `gh`, when it is installed and signed in, or opens the repository's `--compiled` issue form, filled in, in the browser. The source lines around the location (three either side) are added only if you agree a second time; `--yes` never skips that question. In a terminal that cannot answer, it prints the report and the issue URL and sends nothing. Each issue filed this way becomes a case in the compiler's tests (`packages/compiler/tests/diff/cases`), then a fix, and the generated table of what `--compiled` supports is updated. diff --git a/lib/commands/compiled-report.ts b/lib/commands/compiled-report.ts index b00440162c..e874dd7e63 100644 --- a/lib/commands/compiled-report.ts +++ b/lib/commands/compiled-report.ts @@ -58,7 +58,11 @@ export class CompiledReportCommand implements ICommand { draft, ); const body = this.$compiledReportService.formatBody(report, false); - const { url, truncated } = newIssueUrl(report.title, body, report.labels); + const { url, truncated } = newIssueUrl( + report.title, + this.$compiledReportService.formFields(report, false), + report.labels, + ); this.printReport(report, body); if (report.code) { @@ -168,22 +172,24 @@ export class CompiledReportCommand implements ICommand { ); const issue = found.find((i) => i.state.toLowerCase() === "open") ?? found[0]; - const comment = `Add a +1 comment to #${issue.number} with this environment (no code)`; + const plusOne = `Add a 👍 to #${issue.number}, and your environment (no code) if no earlier report has it`; const open = `Open #${issue.number} in the browser`; const choice = await this.$prompter.promptForChoice("What next?", [ - ...(useGh ? [comment] : []), + ...(useGh ? [plusOne] : []), open, "Nothing", ]); - if ( - choice === comment && - (await this.$compiledReportService.commentOnIssue( + if (choice === plusOne) { + const { reacted, commented } = await this.$compiledReportService.plusOne( this.$projectData, report, issue, - )) - ) { - this.$logger.info(`Added a +1 to ${issue.url}. Thank you.`); + ); + if (reacted || commented) { + this.$logger.info( + `${commented ? "Added your environment" : "Added a 👍"} to ${issue.url}. Thank you.`, + ); + } } else if (choice === open) { this.$opener.open(issue.url); } @@ -214,11 +220,15 @@ export class CompiledReportCommand implements ICommand { return; } - const { url, truncated } = newIssueUrl(report.title, body, report.labels); + const { url, truncated } = newIssueUrl( + report.title, + this.$compiledReportService.formFields(report, includeCode), + report.labels, + ); this.$logger.info(`Opening a prefilled issue in the browser:\n${url}`); if (truncated) { this.$logger.warn( - `The body was cut to fit the URL; paste the rest from ${this.writeFullBody(report, body)}.`, + `Some fields were cut to fit the URL; paste the rest from ${this.writeFullBody(report, body)}.`, ); } this.$opener.open(url); diff --git a/lib/services/compiled-report-service.ts b/lib/services/compiled-report-service.ts index 1055439b9e..33c76d4b4e 100644 --- a/lib/services/compiled-report-service.ts +++ b/lib/services/compiled-report-service.ts @@ -16,6 +16,8 @@ export type CompiledReportKind = "compile-refused" | "native-compile" | "runtime"; export const ISSUES_REPO = "NativeScript/NativeScript"; +/** The issue form in ISSUES_REPO's .github/ISSUE_TEMPLATE: it applies `compiled`, and the repository's labeler reads its fields. */ +export const ISSUE_FORM = "compiled.yml"; // GitHub answers longer issues/new URLs with 414. const MAX_URL_LENGTH = 8000; const SEARCH_TIMEOUT_MS = 5000; @@ -39,6 +41,7 @@ export interface ICompiledDiagnostic { export interface ICompiledReport { kind: CompiledReportKind; platform: string; + framework: string; signature: string; title: string; labels: string[]; @@ -57,11 +60,35 @@ export interface IReportedIssue { url: string; } -const KIND_DESCRIPTIONS: Record = { - "compile-refused": "The compiler refused a construct in the app", - "native-compile": "The generated Swift or Kotlin did not compile", - runtime: "A compiled app failed at runtime", -}; +export type CompiledReportFields = Record< + | "platform" + | "framework" + | "kind" + | "signature" + | "diagnostic" + | "source" + | "environment", + string +>; + +/** + * The issue form's fields, by id and label, in its order. A body made of these + * sections reads like one the form produced, so the labeler and the +1 check + * read issues filed through the form, through gh, and comments alike. + */ +const FORM_FIELDS: { + id: keyof CompiledReportFields; + label: string; + render?: string; +}[] = [ + { id: "platform", label: "Platform" }, + { id: "framework", label: "Framework" }, + { id: "kind", label: "Kind" }, + { id: "signature", label: "Signature" }, + { id: "diagnostic", label: "Diagnostic", render: "text" }, + { id: "source", label: "Source" }, + { id: "environment", label: "Environment" }, +]; const FRAMEWORKS: { name: string; markers: string[]; versionOf: string }[] = [ { @@ -142,8 +169,24 @@ export function redact( return result; } -/** The message with what varies between apps abstracted: quoted text, paths, numbers and the app's identifiers. */ -export function abstractMessage(message: string): string { +/** + * The message with what varies between apps abstracted. A Swift or Kotlin + * compiler names the types and members at fault in quotes, which tell problems + * apart, so for those only paths, file names and numbers go; otherwise quoted + * text and the app's identifiers go too. + */ +export function abstractMessage( + message: string, + kind?: CompiledReportKind, +): string { + if (kind === "native-compile") { + return message + .replace(/(?:[\w.~-]*\/)+[\w.-]+/g, "") + .replace(/\b[\w-]+\.(?:kt|swift|java|ts|tsx|js|m|h)\b/g, "") + .replace(/\b\d+(?:\.\d+)*\b/g, "") + .replace(/\s+/g, " ") + .trim(); + } return message .replace(/(['"`‘“])(?:(?!\1).)*?(['"`’”])/g, "") .replace(/(?:[\w.~-]*\/)+[\w.-]+/g, "") @@ -168,7 +211,9 @@ export function computeSignature( ): string { return crypto .createHash("sha256") - .update(`${kind}\n${abstractMessage(message)}\n${constructOf(message)}`) + .update( + `${kind}\n${abstractMessage(message, kind)}\n${constructOf(message)}`, + ) .digest("hex") .slice(0, 6); } @@ -243,33 +288,71 @@ export function extractDiagnostics( ); } +/** A prefilled issue form; the longest fields are cut, a line at a time, until the URL fits. */ export function newIssueUrl( title: string, - body: string, + fields: CompiledReportFields, labels: string[], ): { url: string; truncated: boolean } { - const base = - `https://github.com/${ISSUES_REPO}/issues/new?title=${encodeURIComponent(title)}` + - `&labels=${encodeURIComponent(labels.join(","))}&body=`; - const note = "\n\n_(The report was cut to fit a URL.)_"; - let text = body; - let truncated = false; - while ((base + encodeURIComponent(text)).length > MAX_URL_LENGTH) { + const note = "\n\n_(Cut to fit a URL.)_"; + const values: Record = { ...fields }; + const cut: Record = {}; + const build = () => + `https://github.com/${ISSUES_REPO}/issues/new?` + + [ + ["template", ISSUE_FORM], + ["title", title], + ["labels", labels.join(",")], + ...Object.entries(values), + ] + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + let url = build(); + while (url.length > MAX_URL_LENGTH) { + const key = Object.keys(values).sort( + (a, b) => values[b].length - values[a].length, + )[0]; + const text = cut[key] ?? values[key]; const lines = text.split("\n"); - truncated = true; - text = lines - .slice(0, Math.max(1, Math.floor(lines.length * 0.9))) - .join("\n"); - if (lines.length === 1) { - text = text.slice(0, Math.floor(text.length * 0.9)); - } - if ((base + encodeURIComponent(text + note)).length <= MAX_URL_LENGTH) { - text += note; - break; - } + cut[key] = + lines.length > 1 + ? lines.slice(0, Math.floor(lines.length * 0.9)).join("\n") + : text.slice(0, Math.floor(text.length * 0.9)); + values[key] = cut[key] + note; + url = build(); } - return { url: base + encodeURIComponent(text), truncated }; + return { url, truncated: Object.keys(cut).length > 0 }; +} + +/** A form field's value in an issue or comment body: the text under its `### Label` heading. */ +export function readField(body: string, label: string): string | undefined { + const match = new RegExp(`^###\\s+${label}\\s*\\n+([^\\n]*)`, "m").exec( + body ?? "", + ); + return match?.[1].trim(); +} + +const majorMinor = (version: string | undefined) => + /(\d+\.\d+)/.exec(version ?? "")?.[1] ?? "?"; + +/** What tells one reporter's environment from another's: platform, framework, and the compiler's and core's major.minor. */ +export function environmentKey(body: string): string | undefined { + const platform = readField(body, "Platform"); + if (!platform) { + return undefined; + } + const row = (name: string) => + new RegExp( + `^\\|\\s*${name.replace(/[/.]/g, "\\$&")}\\s*\\|\\s*([^|]+)\\|`, + "m", + ).exec(body)?.[1]; + return [ + platform.toLowerCase(), + (readField(body, "Framework") ?? "").toLowerCase(), + majorMinor(row(COMPILER_PACKAGE_NAME)), + majorMinor(row("@nativescript/core")), + ].join("|"); } /** @@ -361,6 +444,7 @@ export class CompiledReportService { return { kind: draft.kind, platform: draft.platform, + framework: framework.name, signature, title: `[compiled][${draft.platform}] ${headline} (sig ${signature})`, labels: [ @@ -381,7 +465,11 @@ export class CompiledReportService { }; } - public formatBody(report: ICompiledReport, includeCode: boolean): string { + /** The issue form's values; the code around the location only with includeCode. */ + public formFields( + report: ICompiledReport, + includeCode: boolean, + ): CompiledReportFields { const diagnostic = report.diagnostics .map((d) => [ @@ -393,53 +481,63 @@ export class CompiledReportService { .join(""), ) .join("\n"); - const sections = [ - ``, - `### What happened`, - `${KIND_DESCRIPTIONS[report.kind]} (\`${report.kind}\`, ${report.platform}).`, - "", - "~~~", - diagnostic || "(no diagnostic found in the output)", - "~~~", - ]; - if (report.location) { - sections.push( - "", - `### Where`, - `\`${report.location.file}:${report.location.line}\``, - ); - } - if (includeCode && report.code) { - sections.push( + const source = report.location + ? [ + `\`${report.location.file}:${report.location.line}\``, + ...(includeCode && report.code + ? [ + "", + "Shared with the reporter's consent:", + "~~~", + report.code, + "~~~", + ] + : []), + ].join("\n") + : ""; + + return { + platform: report.platform, + framework: report.framework, + kind: report.kind, + signature: report.signature, + diagnostic: diagnostic || "(no diagnostic found in the output)", + source, + environment: [ + this.formatEnvironment(report), "", - "### Code (shared with the reporter's consent)", - "~~~", - report.code, - "~~~", - ); - } - sections.push("", "### Environment", this.formatEnvironment(report)); - sections.push( - "", - `Signature \`${report.signature}\`: a hash of the kind, the message with names and numbers abstracted, and the construct; issues with the same signature are the same problem.`, - `Labels: ${report.labels.map((label) => `\`${label}\``).join(", ")}`, - "", - "_Filed with `ns compiled report`._", - ); + `Labels: ${report.labels.map((label) => `\`${label}\``).join(", ")}`, + ].join("\n"), + }; + } - return sections.join("\n"); + /** The fields as the issue form writes them into an issue's body. */ + public formatBody(report: ICompiledReport, includeCode: boolean): string { + const fields = this.formFields(report, includeCode); + return [ + ...FORM_FIELDS.map(({ id, label, render }) => { + const value = fields[id] || "_No response_"; + return `### ${label}\n\n${render && fields[id] ? `\`\`\`${render}\n${value}\n\`\`\`` : value}`; + }), + "_Filed with `ns compiled report`._", + ].join("\n\n"); } + /** A +1 in an environment no earlier report has: the same sections, so later reports can tell it apart too. */ public formatComment(report: ICompiledReport): string { const primary = report.diagnostics[0]; + const fields = this.formFields(report, false); return [ - `+1 from \`ns compiled report\` (sig ${report.signature}, ${report.platform}).`, - "", + `+1 from \`ns compiled report\` (sig ${report.signature}), in an environment no earlier report has.`, + `### Platform\n\n${fields.platform}`, + `### Framework\n\n${fields.framework}`, primary - ? `~~~\n${primary.location ? `${primary.location}: ` : ""}${primary.message}\n~~~\n` + ? `### Diagnostic\n\n\`\`\`text\n${primary.location ? `${primary.location}: ` : ""}${primary.message}\n\`\`\`` : "", - this.formatEnvironment(report), - ].join("\n"); + `### Environment\n\n${this.formatEnvironment(report)}`, + ] + .filter(Boolean) + .join("\n\n"); } private formatEnvironment(report: ICompiledReport): string { @@ -574,16 +672,61 @@ export class CompiledReportService { return result.stdout.trim().split("\n").pop(); } - public async commentOnIssue( + /** + * A 👍 on the issue, and a comment with this environment only when no earlier + * report (the issue or its comments) has the same platform, framework and + * compiler and core major.minor. + */ + public async plusOne( projectData: IProjectData, report: ICompiledReport, issue: IReportedIssue, - ): Promise { + ): Promise<{ reacted: boolean; commented: boolean }> { + const reaction = await this.gh([ + "api", + "--method", + "POST", + `repos/${ISSUES_REPO}/issues/${issue.number}/reactions`, + "-f", + "content=+1", + ]); + if (reaction.exitCode !== 0) { + this.$logger.warn(`Adding the 👍 failed: ${reaction.stderr.trim()}`); + } + + const view = await this.gh([ + "issue", + "view", + String(issue.number), + "--repo", + ISSUES_REPO, + "--json", + "body,comments", + ]); + let known: Set; + try { + const { body, comments } = JSON.parse(view.stdout); + known = new Set( + [body, ...(comments ?? []).map((c: { body: string }) => c.body)] + .map(environmentKey) + .filter(Boolean), + ); + } catch { + this.$logger.trace( + `Reading #${issue.number}'s reports failed: ${view.stderr}`, + ); + return { reacted: reaction.exitCode === 0, commented: false }; + } + const comment = this.formatComment(report); + if (known.has(environmentKey(comment))) { + return { reacted: reaction.exitCode === 0, commented: false }; + } + const bodyFile = this.writeBodyFile( projectData, report.platform, "report-comment.md", - this.formatComment(report), + comment, ); const result = await this.gh([ "issue", @@ -598,7 +741,10 @@ export class CompiledReportService { this.$logger.warn(`gh issue comment failed: ${result.stderr.trim()}`); } - return result.exitCode === 0; + return { + reacted: reaction.exitCode === 0, + commented: result.exitCode === 0, + }; } private writeBodyFile( @@ -761,44 +907,67 @@ export class CompiledReportService { return rows; } + /** + * Gradle from the compiler's wrapper; the Android Gradle Plugin and Kotlin + * from the generated project when the compile got that far, else from the + * compiler's own sources, which write those versions into it. + */ private androidToolVersions(projectData: IProjectData): [string, string][] { - let gradle = "unknown"; + let compilerDir: string | undefined; try { - const compilerDir = resolvePackagePath(COMPILER_PACKAGE_NAME, { + compilerDir = resolvePackagePath(COMPILER_PACKAGE_NAME, { paths: [projectData.projectDir], }); - const wrapper = - compilerDir && - path.join( - compilerDir, - "kit-android", - "gradle", - "wrapper", - "gradle-wrapper.properties", - ); - if (wrapper && this.$fs.exists(wrapper)) { - gradle = - /gradle-([\d.]+)-/.exec(this.$fs.readText(wrapper))?.[1] ?? gradle; - } } catch (err) { - this.$logger.trace(`Reading Gradle's version failed: ${err.message}`); + this.$logger.trace( + `Resolving ${COMPILER_PACKAGE_NAME} failed: ${err.message}`, + ); } - + const read = (file: string) => + this.$fs.exists(file) ? this.$fs.readText(file) : ""; const projectRoot = this.getReportDir(projectData, "android"); - const scripts = this.$fs.exists(projectRoot) + const generated = this.$fs.exists(projectRoot) ? this.$fs .readDirectory(projectRoot) .filter((file) => /\.gradle(\.kts)?$/.test(file)) - .map((file) => this.$fs.readText(path.join(projectRoot, file))) - .join("\n") - : ""; - const plugin = (id: string) => - new RegExp( + .map((file) => read(path.join(projectRoot, file))) + : []; + const sources = [ + ...generated, + ...(compilerDir + ? [ + path.join("kit-android", "build.gradle.kts"), + path.join("dist", "android.js"), + path.join("src", "android.ts"), + ].map((file) => read(path.join(compilerDir, file))) + : []), + ]; + const plugin = (id: string) => { + const pattern = new RegExp( `id\\s*\\(?\\s*["']${id.replace(/\./g, "\\.")}["']\\s*\\)?\\s*version\\s*["']([^"']+)`, - ).exec(scripts)?.[1] ?? "unknown"; + ); + for (const source of sources) { + const version = pattern.exec(source)?.[1]; + if (version) { + return version; + } + } + return "unknown"; + }; + const wrapper = compilerDir + ? read( + path.join( + compilerDir, + "kit-android", + "gradle", + "wrapper", + "gradle-wrapper.properties", + ), + ) + : ""; return [ - ["Gradle", gradle], + ["Gradle", /gradle-([\d.]+)-/.exec(wrapper)?.[1] ?? "unknown"], ["Android Gradle Plugin", plugin("com.android.application")], ["Kotlin", plugin("org.jetbrains.kotlin.android")], ]; diff --git a/test/services/compiled-report-service.ts b/test/services/compiled-report-service.ts index 26541f962f..5f903a1671 100644 --- a/test/services/compiled-report-service.ts +++ b/test/services/compiled-report-service.ts @@ -9,6 +9,7 @@ import { setIsInteractive } from "../../lib/common/helpers"; import { CompiledReportService, computeSignature, + environmentKey, extractDiagnostics, newIssueUrl, redact, @@ -256,11 +257,24 @@ describe("compiledReportService", () => { assert.equal( computeSignature( "native-compile", - "cannot convert value of type 'Int' to 'myValue' at line 12", + "cannot convert value of type 'Int' to 'String' (Home.swift:12:4)", ), computeSignature( "native-compile", - "cannot convert value of type 'Double' to 'otherValue' at line 40", + "cannot convert value of type 'Int' to 'String' (Settings.swift:40:9)", + ), + ); + }); + + it("keeps the types and members a Swift or Kotlin error quotes", () => { + assert.notEqual( + computeSignature( + "native-compile", + "cannot convert value of type 'Int' to expected argument type 'String'", + ), + computeSignature( + "native-compile", + "cannot convert value of type 'Double' to expected argument type 'String'", ), ); }); @@ -421,8 +435,13 @@ describe("compiledReportService", () => { const body = service.formatBody(report, false); assert.include( body, - "app/main.ts:4:15: Utils.ios is not supported in a release build yet\n", + "### Diagnostic\n\n```text\napp/main.ts:4:15: Utils.ios is not supported in a release build yet\n```", + ); + assert.include( + body, + "### Platform\n\nios\n\n### Framework\n\nxml\n\n### Kind\n\ncompile-refused", ); + assert.include(body, `### Signature\n\n${report.signature}`); assert.notInclude(body, "UIApplication"); assert.notInclude(body, projectDir); assert.include( @@ -481,24 +500,96 @@ describe("compiledReportService", () => { assert.equal(environment.get("Android Gradle Plugin"), "8.12.1"); assert.equal(environment.get("Kotlin"), "2.2.20"); }); + + it("reads the Android Gradle Plugin and Kotlin from the compiler when the compile stopped before Gradle", async () => { + writeFile( + "node_modules/@nativescript/compiler/src/android.ts", + 'const settings = `plugins {\n id("com.android.application") version "8.13.0"\n id("org.jetbrains.kotlin.android") version "2.2.21"\n}`;\n', + ); + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + const report = await service.createReport(projectData(), { + kind: "compile-refused", + platform: "android", + createdAt: "", + output: + "error: app/main.ts:4:1: Array.prototype is not supported in a release build yet: Array.prototype", + }); + + const environment = new Map(report.environment); + assert.equal(environment.get("Android Gradle Plugin"), "8.13.0"); + assert.equal(environment.get("Kotlin"), "2.2.21"); + }); + }); + + describe("environmentKey", () => { + it("reads platform, framework and the compiler's and core's major.minor from a report", () => { + const body = [ + "### Platform", + "", + "ios", + "", + "### Framework", + "", + "angular", + "", + "### Environment", + "", + "| @nativescript/compiler | 0.4.2 |", + "| @nativescript/core | 9.1.1 |", + ].join("\n"); + + assert.equal(environmentKey(body), "ios|angular|0.4|9.1"); + assert.isUndefined(environmentKey("+1, same here")); + }); }); describe("newIssueUrl", () => { - it("cuts a long body to fit GitHub's URL limit and says so", () => { - const body = Array.from( + const fields = (environment: string) => ({ + platform: "ios", + framework: "vue", + kind: "runtime", + signature: "abc123", + diagnostic: "TypeError: x", + source: "", + environment, + }); + + it("fills the issue form's fields", () => { + const url = new URL( + newIssueUrl("title", fields("| a | b |"), ["compiled"]).url, + ); + + assert.equal(url.pathname, "/NativeScript/NativeScript/issues/new"); + assert.equal(url.searchParams.get("template"), "compiled.yml"); + assert.equal(url.searchParams.get("platform"), "ios"); + assert.equal(url.searchParams.get("framework"), "vue"); + assert.equal(url.searchParams.get("signature"), "abc123"); + assert.equal(url.searchParams.get("environment"), "| a | b |"); + }); + + it("cuts the longest field to fit GitHub's URL limit and says so", () => { + const long = Array.from( { length: 2000 }, - (_, k) => `line ${k} of a long log`, + (_, k) => `- dependency-${k} 1.0.0`, ).join("\n"); - const { url, truncated } = newIssueUrl("title", body, ["compiled"]); + const { url, truncated } = newIssueUrl("title", fields(long), [ + "compiled", + ]); assert.isTrue(truncated); assert.isAtMost(url.length, 8000); assert.include( - decodeURIComponent(url), - "(The report was cut to fit a URL.)", + new URL(url).searchParams.get("environment"), + "(Cut to fit a URL.)", + ); + assert.equal(new URL(url).searchParams.get("diagnostic"), "TypeError: x"); + assert.isFalse( + newIssueUrl("title", fields("short"), ["compiled"]).truncated, ); - assert.isFalse(newIssueUrl("title", "short", ["compiled"]).truncated); }); }); @@ -647,7 +738,7 @@ describe("ns compiled report", () => { ); assert.include( output(), - "https://github.com/NativeScript/NativeScript/issues/new?title=", + "https://github.com/NativeScript/NativeScript/issues/new?template=compiled.yml&title=", ); assert.include(output(), "Dry run: nothing was sent."); assert.lengthOf(ghCalls, 0); @@ -678,21 +769,72 @@ describe("ns compiled report", () => { assert.lengthOf(httpCalls, 0); }); - it("offers a +1 on an issue with the same signature", async () => { + const existing = () => [ + { + exitCode: 0, + stdout: JSON.stringify([ + { + number: 42, + title: "t", + state: "OPEN", + url: "https://github.com/NativeScript/NativeScript/issues/42", + }, + ]), + }, + ]; + + it("adds a 👍 alone when an earlier report has this environment", async () => { + setIsInteractive(() => true); + options.yes = true; + ghResponses["auth status"] = [{ exitCode: 0 }]; + ghResponses["issue list"] = existing(); + ghResponses["api --method"] = [{ exitCode: 0 }]; + ghResponses["issue view"] = [ + { + exitCode: 0, + stdout: JSON.stringify({ + body: "### Platform\n\nandroid\n\n### Framework\n\nangular\n", + comments: [ + { + body: "### Platform\n\nios\n\n### Framework\n\nangular\n\n| @nativescript/compiler | 0.4.9 |\n| @nativescript/core | 9.1.0 |", + }, + ], + }), + }, + ]; + choiceAnswer = (choices) => choices[0]; + + await run(); + + const reaction = ghCalls.find((c) => c.args[0] === "api"); + assert.deepStrictEqual(reaction.args, [ + "api", + "--method", + "POST", + "repos/NativeScript/NativeScript/issues/42/reactions", + "-f", + "content=+1", + ]); + assert.isUndefined(ghCalls.find((c) => c.args[1] === "comment")); + assert.include( + output(), + "Added a 👍 to https://github.com/NativeScript/NativeScript/issues/42", + ); + }); + + it("adds a 👍 and the environment when no earlier report has it", async () => { setIsInteractive(() => true); options.yes = true; ghResponses["auth status"] = [{ exitCode: 0 }]; - ghResponses["issue list"] = [ + ghResponses["issue list"] = existing(); + ghResponses["api --method"] = [{ exitCode: 0 }]; + ghResponses["issue view"] = [ { exitCode: 0, - stdout: JSON.stringify([ - { - number: 42, - title: "t", - state: "OPEN", - url: "https://github.com/NativeScript/NativeScript/issues/42", - }, - ]), + stdout: JSON.stringify({ + body: "### Platform\n\nios\n\n### Framework\n\nvue\n", + comments: [{ body: "same here" }], + }), }, ]; ghResponses["issue comment"] = [{ exitCode: 0 }]; @@ -701,6 +843,7 @@ describe("ns compiled report", () => { await run(); assert.lengthOf(confirms, 0); + assert.isDefined(ghCalls.find((c) => c.args[0] === "api")); const comment = ghCalls.find((c) => c.args[1] === "comment"); assert.deepStrictEqual(comment.args.slice(0, 5), [ "issue", @@ -711,7 +854,9 @@ describe("ns compiled report", () => { ]); const body = fs.readFileSync(comment.args[comment.args.length - 1], "utf8"); assert.include(body, "+1 from `ns compiled report`"); + assert.equal(environmentKey(body), "ios|angular|0.4|9.1"); assert.notInclude(body, "UIApplication"); + assert.include(output(), "Added your environment to"); }); it("asks again before the code goes in, even with --yes, and files with gh", async () => { @@ -743,7 +888,7 @@ describe("ns compiled report", () => { create.args[create.args.indexOf("--body-file") + 1], "utf8", ); - assert.notInclude(body, "### Code"); + assert.notInclude(body, "reporter's consent"); assert.include( output(), "Filed https://github.com/NativeScript/NativeScript/issues/43", @@ -764,11 +909,13 @@ describe("ns compiled report", () => { url.searchParams.get("labels"), "compiled,platform:ios,framework:angular,kind:compile-refused", ); + assert.equal(url.searchParams.get("template"), "compiled.yml"); assert.include( - url.searchParams.get("body"), - "### Code (shared with the reporter's consent)", + url.searchParams.get("source"), + "Shared with the reporter's consent:", ); - assert.notInclude(url.searchParams.get("body"), projectDir); + assert.include(url.searchParams.get("environment"), "Labels: `compiled`"); + assert.notInclude(url.search, encodeURIComponent(projectDir)); }); it("reports a pasted runtime error with --error", async () => { From 8b614fde5b3a500b207c8adf92d223b5b5b3ce37 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 15 Oct 2026 14:07:43 -0700 Subject: [PATCH 15/16] chore: release v9.2.0-alpha.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c7063478f..272af4d80f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nativescript", - "version": "9.1.2", + "version": "9.2.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nativescript", - "version": "9.1.2", + "version": "9.2.0-alpha.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index fbc3f41dd6..65d23092e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativescript", "main": "./dist/lib/nativescript-cli-lib.js", - "version": "9.1.2", + "version": "9.2.0-alpha.0", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", "bin": { From f11eb11c8b6b6b91325e0c20c8e34d554a3b3f9f Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 15 Oct 2026 14:43:05 -0700 Subject: [PATCH 16/16] ns compiled report reads an Android project's Gradle scripts without reading Gradle's .gradle folder as one --- lib/services/compiled-report-service.ts | 5 ++++- test/services/compiled-report-service.ts | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/services/compiled-report-service.ts b/lib/services/compiled-report-service.ts index 33c76d4b4e..e5fd7cd699 100644 --- a/lib/services/compiled-report-service.ts +++ b/lib/services/compiled-report-service.ts @@ -923,8 +923,11 @@ export class CompiledReportService { `Resolving ${COMPILER_PACKAGE_NAME} failed: ${err.message}`, ); } + // Gradle keeps its cache in a `.gradle` folder beside the scripts. const read = (file: string) => - this.$fs.exists(file) ? this.$fs.readText(file) : ""; + this.$fs.exists(file) && this.$fs.getFsStats(file).isFile() + ? this.$fs.readText(file) + : ""; const projectRoot = this.getReportDir(projectData, "android"); const generated = this.$fs.exists(projectRoot) ? this.$fs diff --git a/test/services/compiled-report-service.ts b/test/services/compiled-report-service.ts index 5f903a1671..0c9ddba4aa 100644 --- a/test/services/compiled-report-service.ts +++ b/test/services/compiled-report-service.ts @@ -501,6 +501,29 @@ describe("compiledReportService", () => { assert.equal(environment.get("Kotlin"), "2.2.20"); }); + it("reads the Gradle scripts beside Gradle's own .gradle folder", async () => { + writeFile( + "platforms/compiled/android/build.gradle.kts", + 'plugins {\n id("com.android.application") version "8.12.1"\n}\n', + ); + writeFile("platforms/compiled/android/.gradle/file-system.probe", ""); + const service: CompiledReportService = createInjector().resolve( + "compiledReportService", + ); + + const report = await service.createReport(projectData(), { + kind: "native-compile", + platform: "android", + createdAt: "", + output: "e: app/main.ts:4: Unresolved reference 'x'.", + }); + + assert.equal( + new Map(report.environment).get("Android Gradle Plugin"), + "8.12.1", + ); + }); + it("reads the Android Gradle Plugin and Kotlin from the compiler when the compile stopped before Gradle", async () => { writeFile( "node_modules/@nativescript/compiler/src/android.ts",