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