diff --git a/.github/workflows/gradle_api.yml b/.github/workflows/gradle_api.yml new file mode 100644 index 0000000000..cecc17bd95 --- /dev/null +++ b/.github/workflows/gradle_api.yml @@ -0,0 +1,135 @@ +name: 'org.nativescript:gradle-api' + +on: + workflow_dispatch: + pull_request: + paths: + - 'gradle-api/**' + - 'vendor/gradle-plugin/**' + - 'lib/services/android-plugin-build-service.ts' + - 'lib/services/android-project-service.ts' + - 'lib/services/android/**' + - '.github/workflows/gradle_api.yml' + push: + branches: [ 'main' ] + # Path filters do not apply to tag pushes. + tags: [ 'gradle-api-v*' ] + paths: + - 'gradle-api/**' + - 'vendor/gradle-plugin/**' + - 'lib/services/android-plugin-build-service.ts' + - 'lib/services/android-project-service.ts' + - 'lib/services/android/**' + - '.github/workflows/gradle_api.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +jobs: + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + # vendor/gradle-plugin/gradle/wrapper/gradle-wrapper.jar, which the CLI copies into + # plugin builds, matches no published Gradle wrapper checksum. + allow-checksums: 414142e6bc3b156908436f13b29e198bb3d59824f9c245ef145c809ddd5fe0a3 + + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + cache-provider: basic + # Done by the wrapper-validation step, which can allow the vendored jar. + validate-wrappers: false + + # The Android SDK preinstalled on the runner (ANDROID_HOME) enables the plugin build tests. + - name: Check + working-directory: gradle-api + run: ./gradlew check --console=plain + + publish: + name: Publish to Maven Central + if: startsWith(github.ref, 'refs/tags/gradle-api-v') + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: maven-central + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + allow-checksums: 414142e6bc3b156908436f13b29e198bb3d59824f9c245ef145c809ddd5fe0a3 + + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + cache-provider: basic + cache-read-only: true + validate-wrappers: false + + - name: Check the tag matches the version + working-directory: gradle-api + env: + TAG: ${{ github.ref_name }} + run: | + version="$(sed -n 's/^VERSION_NAME=//p' gradle.properties)" + if [ "$TAG" != "gradle-api-v$version" ]; then + echo "::error::Tag $TAG does not match VERSION_NAME=$version in gradle-api/gradle.properties" + exit 1 + fi + case "$version" in + *-SNAPSHOT) + echo "::error::$version is a snapshot version" + exit 1 + ;; + esac + + - name: Publish + working-directory: gradle-api + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_CENTRAL_SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_CENTRAL_SIGNING_KEY_PASSWORD }} + run: | + # The build skips signing without a key, which Maven Central would only reject later. + for name in ORG_GRADLE_PROJECT_mavenCentralUsername ORG_GRADLE_PROJECT_mavenCentralPassword ORG_GRADLE_PROJECT_signingInMemoryKey; do + if [ -z "${!name}" ]; then + echo "::error::$name is empty; check the maven-central environment's secrets" + exit 1 + fi + done + ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache --console=plain diff --git a/.gitignore b/.gitignore index 2c4b2d2127..1c1878346e 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,9 @@ CLAUDE.local.md .env.* !.env.example .envrc + +# org.nativescript:gradle-api +gradle-api/build/ +gradle-api/.gradle/ +gradle-api/.kotlin/ +gradle-api/samples/**/build/ diff --git a/gradle-api/.gitattributes b/gradle-api/.gitattributes new file mode 100644 index 0000000000..4e03091347 --- /dev/null +++ b/gradle-api/.gitattributes @@ -0,0 +1,2 @@ +gradlew.bat text eol=crlf +*.jar binary diff --git a/gradle-api/README.md b/gradle-api/README.md new file mode 100644 index 0000000000..fdd6951ce7 --- /dev/null +++ b/gradle-api/README.md @@ -0,0 +1,312 @@ +# org.nativescript:gradle-api + +A small, versioned Gradle library that NativeScript plugins call from their Android Gradle files +instead of guessing where things are on disk. + +```groovy +// platforms/android/include.gradle +apply plugin: "org.nativescript.api" + +def ns = nativescript +logger.info("App_Resources: ${ns.appResourcesDir}") +``` + +- **For plugin authors:** [Why](#why), [Adopting it](#adopting-it), [API](#api), + [Recipes](#recipes), [Compatibility](#compatibility), [Rules](#rules-for-plugin-authors). +- **For CLI/runtime maintainers:** [How it works](#how-it-works), [The facts file](#the-facts-file), + [Versioning rules](#versioning-rules), [Releasing](#releasing). + +## Why + +Plugin Gradle files today find things by building paths themselves: + +```groovy +// a real-world include.gradle +def widgetsDir = "$USER_PROJECT_ROOT/node_modules/@nativescript/core/platforms/android" +// another one +maven { url = project.file('../../../node_modules/detox/Detox-android') } +// and the most common one +if (!project.hasProperty("tempBuild")) { apply plugin: 'com.google.gms.google-services' } +``` + +Every one of these bakes in a detail the CLI should be free to change: where `node_modules` is, +where the CLI writes a plugin's built `.aar`, how many `../` lead to the project root, or what the +`tempBuild` property means. This library answers those questions for you. The CLI tells the library +where things are; on CLIs that don't, the library works it out the way those CLIs always did. + +## Adopting it + +Minimum Android runtime: **8.4** (Gradle 7.5, JDK 11), the oldest toolchain the library is built +for. If your plugin still supports older runtimes, keep your current Gradle code for them: adding the +library to `buildscript.gradle` breaks their builds. + +It works with every NativeScript CLI. CLI 9.2+ gives it exact answers; older CLIs use the fallback. + +**1. Put the library on the build classpath** in `platforms/android/buildscript.gradle`: + +```groovy +repositories { + mavenCentral() +} +dependencies { + classpath "org.nativescript:gradle-api:" +} +``` + +(No version is published yet; this README is part of the proposal.) + +Pin the **lowest** version that has what you need, never a range or `+`. Every plugin in an app adds +the library to the same classpath and Gradle keeps the highest version, which is always backwards +compatible. + +**2. Apply it at the top of `platforms/android/include.gradle`:** + +```groovy +apply plugin: "org.nativescript.api" +``` + +Applying it again from several plugins is fine. + +**3. Use `nativescript`** in the rest of the file (see [API](#api) and [Recipes](#recipes)). + +### The one thing to know: your plugin's own `.aar` build + +The CLI builds a plugin's own Android sources into an `.aar` in a separate, temporary Gradle build. +In that build, **only the `dependencies {}` and `repositories {}` blocks of your `include.gradle` +run**: the CLI copies them out of the file. Nothing else in the file runs there, including the +`apply plugin:` line. + +On CLI 9.2+ with `android.readOnlyNodeModules` enabled, the CLI loads your `buildscript.gradle` in +that build and applies the library for you. On older CLIs it doesn't, so the library isn't +available there. Inside those two blocks, look the extension up instead of assuming it exists, and +keep the old code as the fallback: + +```groovy +dependencies { + def ns = project.extensions.findByName("nativescript") + if (ns != null && ns.pluginBuild) { + compileOnly ns.plugin("@nativescript/core").artifacts + } else if (project.hasProperty("tempBuild")) { + compileOnly fileTree(dir: "$USER_PROJECT_ROOT/node_modules/@nativescript/core/platforms/android", include: "*.aar") + } +} +``` + +Elsewhere in `include.gradle` (outside those two blocks) you can use `nativescript` directly. + +Never refer to the library's classes from `include.gradle` (`import org.nativescript.gradle...`, +`NativeScript.of(project)`): Gradle compiles applied scripts with a class loader that can't see +them, even when the plugin itself is applied. Always go through the `nativescript` extension. +`NativeScript.of(project)` is for Gradle code written in Java or Kotlin. + +The CLI finds those blocks by searching for the first occurrence of the words `dependencies` and +`repositories` in the file, so keep those words out of comments and code above the real blocks, and +never put a `buildscript {}` block in `include.gradle`. + +## API + +`apiLevel` is `1` for everything below. New methods raise it; nothing is ever removed or changed. + +| Member | Returns | +|---|---| +| `nativescript.apiLevel` | The API level of the library on the classpath. | +| `nativescript.has(String)` | Whether the CLI guarantees a capability. Always `false` on CLIs before 9.2. | +| `nativescript.pluginBuild` | `true` in the CLI's temporary build of some plugin's `.aar`. | +| `nativescript.self` | In a plugin build, the plugin being built (see below). `null` in the app build. | +| `nativescript.isBuildingPlugin(String)` | Whether the plugin build is building that npm package. | +| `nativescript.projectDir` | The NativeScript project root (the folder with the app's `package.json`). | +| `nativescript.appDir` | The app source folder (`appPath` in `nativescript.config`). | +| `nativescript.appResourcesDir` | `App_Resources`. | +| `nativescript.plugin(String)` | A native plugin by npm name. Throws if the app doesn't have it. | +| `nativescript.findPlugin(String)` | Same, or `null`. | +| `nativescript.resolvePackage(String)` | Any installed npm package's folder. Throws if not found. | +| `nativescript.findPackage(String)` | Same, or `null`. | + +A plugin (`PluginInfo`): + +| Member | Returns | +|---|---| +| `name` | The npm package name. | +| `packageDir` | The installed package. Treat it as read-only. | +| `nativeDir` | Where Gradle reads the plugin's Android files from. | +| `artifacts` | Every `.aar` and `.jar` under `nativeDir`, as a lazy `FileCollection`. | +| `aarName`, `aar` | The name and location of the `.aar` the CLI builds from the plugin's sources (the file only exists if it has sources). `null` on CLIs before 9.2, and for installed packages the CLI doesn't list as plugins. | +| `options` | Per-plugin options. Reserved; empty for now. | + +`plugin` / `findPlugin` know the plugins the CLI lists (packages declaring Android support in their +`package.json`) plus any installed package with a `platforms/android` folder, with every CLI version. +For any other npm package, use `resolvePackage` / `findPackage`. + +**`pluginBuild` and `self` in practice.** Your full `include.gradle` runs in the app build and in +the `.aar` builds of plugins that *depend on you*. So a full `include.gradle` that sees +`pluginBuild == true` is always running inside another plugin's build, and `self` is that other +plugin. This is what the common `tempBuild` check was really testing: + +```groovy +// apply the Google Services plugin only to the app, not to dependents' aar builds +if (!nativescript.pluginBuild) { + apply plugin: "com.google.gms.google-services" +} +``` + +`resolvePackage` looks for `node_modules/` from the project root upwards, like Node. It's +best effort: package managers that don't hoist (pnpm's default layout) may not resolve packages +your app doesn't depend on directly. + +## Recipes + +**Compile against another plugin's Android library (e.g. core's widgets) in your own `.aar` build:** +the guarded block in [The one thing to know](#the-one-thing-to-know-your-plugins-own-aar-build). +Use `compileOnly`, not `implementation`: an Android library can't bundle local `.aar` files, and the +app build already links every plugin. + +**Read a file from App_Resources:** + +```groovy +def config = new File(nativescript.appResourcesDir, "Android/my-plugin.json") +``` + +**Point at another npm package's Maven repository** (detox, react-native style): + +```groovy +repositories { + def ns = project.extensions.findByName("nativescript") + def detox = ns != null ? ns.findPackage("detox") : file("$USER_PROJECT_ROOT/node_modules/detox") + maven { url = new File(detox, "Detox-android") } +} +``` + +**Only in the app build:** `if (!nativescript.pluginBuild) { ... }`. + +## Compatibility + +| | CLI 9.1 and older | CLI 9.2+ | CLI 9.2+ with `readOnlyNodeModules` | +|---|---|---|---| +| Plugin not using the library | Unchanged | Unchanged | Gradle reads its files from a copy in `platforms/android/.ns-plugins/`, and its own `buildscript.gradle` also runs in its own `.aar` build | +| Plugin using the library, app build | Fallback rules | Facts from the CLI | Facts from the CLI | +| Plugin using the library, its own `.aar` build | Library not loaded: the guarded fallback runs | Library not loaded: the guarded fallback runs | Library loaded | + +The fallback rules are exactly what CLIs up to 9.1 do: plugins live in +`node_modules//platforms/android`, the project root is `USER_PROJECT_ROOT`, the app and +App_Resources come from `-PappPath` / `-PappResourcesPath`, and `tempBuild` marks a plugin build. + +Building `platforms/android` from Android Studio works too: CLI 9.2+ leaves the facts file there +after `ns prepare android`, so the library has the same answers without the CLI's command-line +flags. + +### For app developers: keeping `node_modules` read-only (opt-in) + +```ts +// nativescript.config.ts +export default { + android: { readOnlyNodeModules: true }, +} satisfies NativeScriptConfig; +``` + +With this, the CLI never writes into `node_modules`. It copies each native plugin's +`platforms/android` and `package.json` into `platforms/android/.ns-plugins//`, builds plugin +`.aar`s there, and points the Android build at the copies. No runtime change is needed (checked +against the runtime's Gradle templates from 6.5.3 to current), and Android Studio builds use the +copies too. Earlier builds may have left `.aar` files inside `node_modules`; +reinstall `node_modules` once after turning this on to get rid of them. + +## Rules for plugin authors + +- Pin the lowest library version you need; no ranges. +- Everything in the version you pin is always there: an app may end up with a newer library, never + an older one. To use something newer only when it's available, check `apiLevel` (or `has(...)`) + first. +- Never build paths into `node_modules` or into the CLI's output folders; ask the library. +- In `dependencies {}` / `repositories {}`, use the guarded lookup shown above. + +## How it works + +``` +nativescript.config.ts ─┐ +package.json files ─────┼─> ns prepare android ─> platforms/android/nativescript-gradle.json +App_Resources ──────────┘ (and dependencies.json, as before) + │ + -Pnativescript.factsFile │ or found next to the app build + ▼ + plugin buildscript.gradle ─> org.nativescript:gradle-api ─> `nativescript` extension + │ + no facts file (CLI ≤ 9.1)│ + ▼ + fallback: dependencies.json, USER_PROJECT_ROOT, tempBuild +``` + +The CLI decides everything (where plugins are, what they're called, project layout) and writes the +answers down. Gradle only reads them. The library hides whether the answer came from the facts file +or from the fallback. + +## The facts file + +`platforms/android/nativescript-gradle.json`, written by `ns prepare android` before any plugin is +built. The schema is [`schema/nativescript-gradle.schema.json`](schema/nativescript-gradle.schema.json). + +```json +{ + "schemaVersion": 1, + "cli": { "version": "9.2.0" }, + "runtime": { "name": "@nativescript/android", "version": "9.0.0" }, + "capabilities": [], + "project": { "root": "../..", "appDir": "../../src", "appResourcesDir": "../../App_Resources" }, + "plugins": [ + { + "name": "@nativescript/core", + "packageDir": "../../node_modules/@nativescript/core", + "nativeDir": "../../node_modules/@nativescript/core/platforms/android", + "aarName": "core", + "aar": "../../node_modules/@nativescript/core/platforms/android/core.aar", + "dependencies": [], + "options": {} + } + ] +} +``` + +- Paths are relative to the file, with forward slashes, so the folder can move. +- `dependenciesJson.sha256` is the hash of the `dependencies.json` the CLI wrote next to it. A CLI + that doesn't know about this file (9.1 and older, e.g. after a downgrade) still rewrites + `dependencies.json` but leaves this file behind; the library notices the mismatch, warns, and uses + the fallback rules instead of outdated answers. +- `capabilities` lists behaviours the CLI guarantees, so the library can tell "not set" apart from + "this CLI doesn't know about it". Empty in version 1. +- `options` is reserved for per-plugin settings from `nativescript.config`. + +## Versioning rules + +These are what keep old plugins working on new CLIs and new plugins working on old ones. + +**Facts file:** fields are only added. A field never changes meaning and is never removed. +`schemaVersion` goes up when fields are added; readers accept any version and read what they know. +Paths are relative. Anything a newer CLI does differently is expressed as data in the file, not as a +rule readers must know. + +**Library:** the public API only grows. Old methods keep working forever; behaviour that depends on +the CLI checks the facts file or a capability before relying on it. The library compiles for Java +11 and the Gradle 7.5 API, the oldest toolchain supported NativeScript runtimes use. + +## Releasing + +Builds and tests run in `.github/workflows/gradle_api.yml`. Pushing a tag `gradle-api-v` +that matches `VERSION_NAME` in `gradle.properties` publishes to Maven Central, from the protected +`maven-central` GitHub environment. It needs these environment secrets: + +- `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`: a Central Portal user token with publishing + rights on the `org.nativescript` namespace. +- `MAVEN_CENTRAL_SIGNING_KEY`, `MAVEN_CENTRAL_SIGNING_KEY_PASSWORD`: the release signing key + (ASCII-armored) and its passphrase. The public key must be on keys.openpgp.org and + keyserver.ubuntu.com. + +Publishing uses `com.vanniktech.maven.publish` 0.35.0, the newest release that runs on the Gradle +8.14 wrapper; moving to Gradle 9 allows newer ones. + +Local development needs JDK 17 installed (the functional tests run Gradle 7.5.1, which doesn't +start on newer JDKs): + +```bash +cd gradle-api +./gradlew check # unit + functional tests (AGP tests need ANDROID_HOME) +./gradlew publishToMavenLocal # try it from a local app +``` diff --git a/gradle-api/build.gradle.kts b/gradle-api/build.gradle.kts new file mode 100644 index 0000000000..be4e172382 --- /dev/null +++ b/gradle-api/build.gradle.kts @@ -0,0 +1,125 @@ +plugins { + `java-gradle-plugin` + id("com.vanniktech.maven.publish") version "0.35.0" +} + +// Plugins on NativeScript Android runtimes back to 8.4 build with Gradle 7.5 on JDK 11. +val oldestSupportedGradle = "7.5.1" +val oldestSupportedJava = 11 + +tasks.withType().configureEach { + options.release = oldestSupportedJava + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) +} + +tasks.javadoc { + (options as StandardJavadocDocletOptions).apply { + encoding = "UTF-8" + addStringOption("-release", oldestSupportedJava.toString()) + addBooleanOption("Xdoclint:all,-missing", true) + } + exclude("org/nativescript/gradle/internal/**") +} + +// java-gradle-plugin adds the running Gradle's API to `api`; compile against the oldest supported +// one instead so that using anything newer fails here rather than in a user's build. +configurations.named("api") { + withDependencies { removeIf { it is FileCollectionDependency } } +} + +val functionalTest: SourceSet by sourceSets.creating + +dependencies { + compileOnly("dev.gradleplugins:gradle-api:$oldestSupportedGradle") + + testImplementation(gradleApi()) + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + "functionalTestImplementation"(gradleTestKit()) + "functionalTestImplementation"(platform("org.junit:junit-bom:5.13.4")) + "functionalTestImplementation"("org.junit.jupiter:junit-jupiter") + "functionalTestRuntimeOnly"("org.junit.platform:junit-platform-launcher") +} + +gradlePlugin { + plugins { + create("nativescript") { + id = "org.nativescript.api" + implementationClass = "org.nativescript.gradle.NativeScriptPlugin" + displayName = "NativeScript Gradle API" + description = providers.gradleProperty("POM_DESCRIPTION").get() + } + } +} + +// Functional tests consume the library the way plugins do, from a Maven repository, so they see +// the published metadata and the real buildscript class loader scoping. +val testRepo = layout.buildDirectory.dir("test-repo") + +publishing { + repositories { + maven { + name = "testRepo" + url = uri(testRepo) + } + } +} + +val cleanTestRepo by tasks.registering(Delete::class) { + delete(testRepo) +} + +tasks.withType().configureEach { + if (name.endsWith("ToTestRepoRepository")) { + dependsOn(cleanTestRepo) + } +} + +// A release version is signed with keys only the publish job has; the test repository needs none. +tasks.withType().configureEach { + val signingKey = providers.gradleProperty("signingInMemoryKey") + onlyIf("a signing key is configured") { signingKey.isPresent } +} + +tasks.withType().configureEach { + useJUnitPlatform() + testLogging { + events("failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} + +val functionalTestTask = tasks.register("functionalTest") { + description = "Runs the TestKit tests against the library published to the test repository." + group = LifecycleBasePlugin.VERIFICATION_GROUP + testClassesDirs = functionalTest.output.classesDirs + classpath = functionalTest.runtimeClasspath + shouldRunAfter(tasks.test) + + dependsOn("publishAllPublicationsToTestRepoRepository") + // The repository itself differs on every publish: snapshot file names carry a timestamp. + inputs.files(tasks.jar).withNormalizer(ClasspathNormalizer::class) + inputs.dir("samples").withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir("../vendor/gradle-plugin").withPathSensitivity(PathSensitivity.RELATIVE) + + // Gradle 7.5 cannot run on JDK 21+, and TestKit starts its builds on the test JVM. + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(17) } + + val androidHome = providers.environmentVariable("ANDROID_HOME") + inputs.property("androidHomeSet", androidHome.map { true }.orElse(false)) + + systemProperty("nativescript.testRepo", testRepo.get().asFile.absolutePath) + systemProperty("nativescript.libraryVersion", providers.gradleProperty("VERSION_NAME").get()) + systemProperty("nativescript.samplesDir", file("samples").absolutePath) + systemProperty("nativescript.vendorGradlePluginDir", file("../vendor/gradle-plugin").absolutePath) + val gradleVersions = providers.gradleProperty("functionalTestGradleVersions").orElse("7.5.1,8.14.3,9.8.1") + inputs.property("gradleVersions", gradleVersions) + systemProperty("nativescript.gradleVersions", gradleVersions.get()) +} + +tasks.check { + dependsOn(functionalTestTask) +} diff --git a/gradle-api/gradle.properties b/gradle-api/gradle.properties new file mode 100644 index 0000000000..03d7f45232 --- /dev/null +++ b/gradle-api/gradle.properties @@ -0,0 +1,27 @@ +org.gradle.caching=true +org.gradle.configuration-cache=true + +GROUP=org.nativescript +POM_ARTIFACT_ID=gradle-api +VERSION_NAME=0.1.0-SNAPSHOT + +POM_NAME=NativeScript Gradle API +POM_DESCRIPTION=Gradle API for NativeScript plugins: project paths, installed plugins and CLI capabilities, from the facts the NativeScript CLI writes or from older CLIs' build files. +POM_INCEPTION_YEAR=2026 +POM_URL=https://github.com/NativeScript/nativescript-cli + +POM_LICENSE_NAME=The Apache Software License, Version 2.0 +POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENSE_DIST=repo + +POM_SCM_URL=https://github.com/NativeScript/nativescript-cli +POM_SCM_CONNECTION=scm:git:git://github.com/NativeScript/nativescript-cli.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/NativeScript/nativescript-cli.git + +POM_DEVELOPER_ID=nativescript +POM_DEVELOPER_NAME=NativeScript team +POM_DEVELOPER_URL=https://github.com/NativeScript + +mavenCentralPublishing=true +mavenCentralAutomaticPublishing=true +signAllPublications=true diff --git a/gradle-api/gradle/wrapper/gradle-wrapper.jar b/gradle-api/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..1b33c55baa Binary files /dev/null and b/gradle-api/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle-api/gradle/wrapper/gradle-wrapper.properties b/gradle-api/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..78cb6e16a4 --- /dev/null +++ b/gradle-api/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradle-api/gradlew b/gradle-api/gradlew new file mode 100755 index 0000000000..23d15a9367 --- /dev/null +++ b/gradle-api/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradle-api/gradlew.bat b/gradle-api/gradlew.bat new file mode 100644 index 0000000000..db3a6ac207 --- /dev/null +++ b/gradle-api/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gradle-api/samples/sample-plugin/package.json b/gradle-api/samples/sample-plugin/package.json new file mode 100644 index 0000000000..6724fd6bc2 --- /dev/null +++ b/gradle-api/samples/sample-plugin/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nativescript/gradle-api-sample", + "version": "1.0.0", + "private": true, + "description": "Example NativeScript plugin using org.nativescript:gradle-api from its Android Gradle files.", + "license": "Apache-2.0", + "nativescript": { + "platforms": { + "android": "8.4.0" + } + } +} diff --git a/gradle-api/samples/sample-plugin/platforms/android/buildscript.gradle b/gradle-api/samples/sample-plugin/platforms/android/buildscript.gradle new file mode 100644 index 0000000000..c242271f51 --- /dev/null +++ b/gradle-api/samples/sample-plugin/platforms/android/buildscript.gradle @@ -0,0 +1,10 @@ +// Applied to the app's buildscript (and, with newer CLIs, to this plugin's own aar build), so the +// library is on the classpath before include.gradle runs. When several plugins declare it, Gradle +// uses the highest version. +repositories { + mavenCentral() +} + +dependencies { + classpath "org.nativescript:gradle-api:0.1.0" +} diff --git a/gradle-api/samples/sample-plugin/platforms/android/include.gradle b/gradle-api/samples/sample-plugin/platforms/android/include.gradle new file mode 100644 index 0000000000..7bc4bd54a8 --- /dev/null +++ b/gradle-api/samples/sample-plugin/platforms/android/include.gradle @@ -0,0 +1,13 @@ +apply plugin: "org.nativescript.api" + +// The CLI builds this plugin's aar in a separate project that runs only the first block below, not +// the rest of this file, and newer CLIs apply the library there only on request. Look the extension +// up by name and keep the old CLI branch so the block works in every such build. +dependencies { + def ns = project.extensions.findByName("nativescript") + if (ns != null && ns.pluginBuild) { + compileOnly ns.plugin("@nativescript/core").artifacts + } else if (project.hasProperty("tempBuild")) { + compileOnly fileTree(dir: "$USER_PROJECT_ROOT/node_modules/@nativescript/core/platforms/android", include: "*.aar") + } +} diff --git a/gradle-api/schema/nativescript-gradle.schema.json b/gradle-api/schema/nativescript-gradle.schema.json new file mode 100644 index 0000000000..3884356a29 --- /dev/null +++ b/gradle-api/schema/nativescript-gradle.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nativescript.org/schemas/nativescript-gradle.schema.json", + "title": "NativeScript Gradle facts", + "description": "Written by the NativeScript CLI at prepare time to platforms/android/nativescript-gradle.json and read by org.nativescript:gradle-api. Fields are only ever added: readers must ignore unknown fields, and a field never changes meaning. Every path is relative to the directory containing this file and uses forward slashes.", + "type": "object", + "required": ["schemaVersion", "dependenciesJson", "project", "plugins"], + "properties": { + "schemaVersion": { + "description": "Bumped only when a field is added. Readers accept any version and read the fields they know.", + "type": "integer", + "minimum": 1 + }, + "cli": { + "type": "object", + "properties": { + "version": { "type": "string" } + } + }, + "runtime": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + } + }, + "dependenciesJson": { + "description": "The dependencies.json the CLI wrote next to this file, before this file. A CLI that does not write this file still rewrites dependencies.json but leaves this file behind, so readers ignore this file when dependencies.json no longer has this hash.", + "type": "object", + "required": ["sha256"], + "properties": { + "sha256": { + "description": "Lowercase hex SHA-256 of the exact bytes of dependencies.json.", + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "capabilities": { + "description": "Behaviours the CLI that wrote this file guarantees. Lets readers tell 'not set' apart from 'this CLI does not know about it'.", + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "project": { + "type": "object", + "required": ["root", "appDir", "appResourcesDir"], + "properties": { + "root": { + "description": "The NativeScript project root (the folder with the app's package.json).", + "$ref": "#/$defs/path" + }, + "appDir": { "$ref": "#/$defs/path" }, + "appResourcesDir": { "$ref": "#/$defs/path" } + } + }, + "plugins": { + "description": "Every npm package with Android native code, in dependency order (a plugin comes after the plugins it depends on).", + "type": "array", + "items": { "$ref": "#/$defs/plugin" } + } + }, + "$defs": { + "path": { + "type": "string", + "pattern": "^[^\\\\]*$" + }, + "plugin": { + "type": "object", + "required": ["name", "packageDir", "nativeDir", "aarName", "aar", "dependencies"], + "properties": { + "name": { + "description": "The npm package name.", + "type": "string" + }, + "packageDir": { + "description": "The installed npm package. Read-only.", + "$ref": "#/$defs/path" + }, + "nativeDir": { + "description": "The directory Gradle reads the plugin's Android files from: the package's platforms/android, or the CLI's copy of it when node_modules is kept read-only.", + "$ref": "#/$defs/path" + }, + "aarName": { + "description": "File name, without extension, of the aar the CLI builds from the plugin's sources. Unique across the project.", + "type": "string" + }, + "aar": { + "description": "Where the CLI puts that aar. The file only exists if the plugin has sources to build.", + "$ref": "#/$defs/path" + }, + "dependencies": { + "description": "Names of the other entries in plugins that this plugin depends on.", + "type": "array", + "items": { "type": "string" } + }, + "options": { + "description": "Per-plugin options for the plugin's own Gradle code. Reserved; always empty for now.", + "type": "object" + } + } + } + } +} diff --git a/gradle-api/settings.gradle.kts b/gradle-api/settings.gradle.kts new file mode 100644 index 0000000000..54c0e01236 --- /dev/null +++ b/gradle-api/settings.gradle.kts @@ -0,0 +1,14 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "gradle-api" diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/AppBuildTest.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/AppBuildTest.java new file mode 100644 index 0000000000..62c1c200cc --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/AppBuildTest.java @@ -0,0 +1,150 @@ +package org.nativescript.gradle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * The app build: plugins' buildscript.gradle and include.gradle applied to {@code :app} the way the + * Android runtime's template does, without the Android Gradle Plugin. + */ +class AppBuildTest { + enum Cli { + /** CLI 9.1 and older: no facts file. */ + LEGACY, + /** A facts-writing CLI, which passes the file's path. */ + FACTS_PROPERTY, + /** platforms/android opened without the CLI, e.g. in Android Studio. */ + FACTS_FOUND, + /** An older CLI prepared the project after a facts-writing one: facts left behind. */ + STALE_FACTS; + + boolean usesFacts() { + return this == FACTS_PROPERTY || this == FACTS_FOUND; + } + } + + @TempDir + Path tempDir; + + static Stream cases() { + List cases = new ArrayList<>(); + for (String gradleVersion : Fixtures.gradleVersions()) { + for (Cli cli : Cli.values()) { + cases.add(Arguments.of(gradleVersion, cli)); + } + } + return cases.stream(); + } + + @ParameterizedTest(name = "Gradle {0}, {1}") + @MethodSource("cases") + void answersFromTheAppBuild(String gradleVersion, Cli cli) throws Exception { + Path project = tempDir.toRealPath().resolve("project"); + Path platformsAndroid = project.resolve("platforms/android"); + Fixtures.copyTree(fixture().resolve("platforms/android"), platformsAndroid); + Fixtures.copyTree(fixture().resolve("packages"), project.resolve("node_modules")); + Path pluginB = project.resolve("node_modules/fixture-plugin-b/platforms/android/buildscript.gradle"); + Fixtures.write(pluginB, Fixtures.read(pluginB).replace("@LIBRARY_VERSION@", Fixtures.libraryVersion())); + Path core = Fixtures.installCore(project); + Path sample = Fixtures.installSample(project); + Fixtures.write(project.resolve("package.json"), "{ \"name\": \"fixture-app\" }\n"); + Path dependenciesJson = Fixtures.write(platformsAndroid.resolve("dependencies.json"), String.join("\n", + "[", + " { \"name\": \"@nativescript/core\", \"directory\": \"../../node_modules/@nativescript/core\", \"dependencies\": [] },", + " { \"name\": \"" + Fixtures.SAMPLE_NAME + "\", \"directory\": \"../../node_modules/" + Fixtures.SAMPLE_NAME + "\", \"dependencies\": [\"@nativescript/core\"] },", + " { \"name\": \"fixture-plugin-b\", \"directory\": \"../../node_modules/fixture-plugin-b\", \"dependencies\": [] }", + "]", + "")); + + List arguments = new ArrayList<>(List.of( + "help", "--offline", "--stacktrace", "--warning-mode=fail", + "--init-script", Fixtures.path(Fixtures.writeInitScript(tempDir)))); + Path factsFile = platformsAndroid.resolve("nativescript-gradle.json"); + if (cli != Cli.LEGACY) { + Fixtures.write(factsFile, String.join("\n", + "{", + " \"schemaVersion\": 1,", + " \"dependenciesJson\": { \"sha256\": \"" + Fixtures.sha256(dependenciesJson) + "\" },", + " \"capabilities\": [\"fixture.capability\"],", + " \"project\": { \"root\": \"../..\", \"appDir\": \"../../src\", \"appResourcesDir\": \"../../src/App_Resources\" },", + " \"plugins\": [", + " { \"name\": \"@nativescript/core\", \"packageDir\": \"../../node_modules/@nativescript/core\",", + " \"nativeDir\": \"../../node_modules/@nativescript/core/platforms/android\", \"aarName\": \"core\",", + " \"aar\": \"../../node_modules/@nativescript/core/platforms/android/core.aar\", \"dependencies\": [],", + " \"options\": { \"flag\": true } },", + " { \"name\": \"" + Fixtures.SAMPLE_NAME + "\", \"packageDir\": \"../../node_modules/" + Fixtures.SAMPLE_NAME + "\",", + " \"nativeDir\": \".ns-plugins/sample/platforms/android\", \"aarName\": \"sample\",", + " \"aar\": \".ns-plugins/sample/platforms/android/sample.aar\", \"dependencies\": [\"@nativescript/core\"] }", + " ]", + "}", + "")); + if (cli == Cli.FACTS_PROPERTY) { + arguments.add("-Pnativescript.factsFile=" + Fixtures.path(factsFile)); + } + if (cli == Cli.STALE_FACTS) { + Fixtures.write(dependenciesJson, Fixtures.read(dependenciesJson) + "\n"); + } + } + + BuildResult result = GradleRunner.create() + .withGradleVersion(gradleVersion) + .withProjectDir(platformsAndroid.toFile()) + .withArguments(arguments) + .forwardOutput() + .build(); + Map probes = Fixtures.probes(result); + + boolean facts = cli.usesFacts(); + String staleWarning = "Ignoring " + Fixtures.path(factsFile) + ": it does not match " + Fixtures.path(dependenciesJson) + + ", which a different NativeScript CLI wrote. Using the fallback rules."; + assertEquals(cli == Cli.STALE_FACTS, result.getOutput().contains(staleWarning), result.getOutput()); + Path appDir = project.resolve(facts ? "src" : "app"); + assertEquals("true", probes.get("typed"), "NativeScript.of returns the extension"); + assertEquals("true", probes.get("b.sameExtension"), "a second plugin's include.gradle sees the same extension"); + assertEquals("false", probes.get("b.libraryClassVisible"), "include.gradle cannot see the library's classes"); + assertEquals(Fixtures.path(project), probes.get("b.projectDir")); + assertEquals("1", probes.get("apiLevel")); + assertEquals("false", probes.get("pluginBuild")); + assertEquals("null", probes.get("self")); + assertEquals("false", probes.get("buildingSample")); + assertEquals(Fixtures.path(project), probes.get("projectDir")); + assertEquals(Fixtures.path(appDir), probes.get("appDir")); + assertEquals(Fixtures.path(appDir.resolve("App_Resources")), probes.get("appResourcesDir")); + assertEquals(String.valueOf(facts), probes.get("has")); + assertEquals(Fixtures.path(core), probes.get("core.packageDir")); + assertEquals(Fixtures.path(core.resolve("platforms/android")), probes.get("core.nativeDir")); + assertEquals("inner.aar,widgets-release.aar", probes.get("core.artifacts")); + assertEquals(facts ? "core" : "null", probes.get("core.aarName")); + assertEquals(facts ? Fixtures.path(core.resolve("platforms/android/core.aar")) : "null", probes.get("core.aar")); + assertEquals(facts ? "[flag:true]" : "[:]", probes.get("core.options")); + assertEquals( + facts ? Fixtures.path(platformsAndroid.resolve(".ns-plugins/sample/platforms/android")) + : Fixtures.path(sample.resolve("platforms/android")), + probes.get("sample.nativeDir")); + assertEquals(Fixtures.path(project.resolve("node_modules/js-only")), probes.get("jsOnly")); + assertEquals("null", probes.get("jsOnlyPlugin"), "a package without native code is not a plugin"); + assertEquals("null", probes.get("missingPlugin")); + assertEquals("null", probes.get("missingPackage")); + String error = probes.get("pluginError"); + assertTrue(error.contains("'not-installed'") && error.contains("@nativescript/core, " + Fixtures.SAMPLE_NAME), + "error names the plugin and lists the known ones: " + error); + } + + private static Path fixture() throws URISyntaxException { + return new File(AppBuildTest.class.getResource("/app-fixture").toURI()).toPath(); + } +} diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/ExtractedIncludeGradleTest.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/ExtractedIncludeGradleTest.java new file mode 100644 index 0000000000..62a882dfeb --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/ExtractedIncludeGradleTest.java @@ -0,0 +1,58 @@ +package org.nativescript.gradle; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * The golden file the plugin build test appends must be what the CLI extracts from the sample's + * include.gradle; the CLI's own tests check it produces the same file. + */ +class ExtractedIncludeGradleTest { + @Test + void goldenFileMatchesTheSample() throws Exception { + String includeGradle = Fixtures.read(Fixtures.samplesDir().resolve("sample-plugin/platforms/android/include.gradle")); + String golden = Fixtures.read(PluginBuildTest.extractedIncludeGradle()); + + assertEquals(golden, String.join("\n", extract(includeGradle))); + } + + // A port of getIncludeGradleCompileDependenciesScope and getScope in + // lib/services/android-plugin-build-service.ts. + private static List extract(String content) { + List result = new ArrayList<>(); + if (content.indexOf("dependencies") == -1) { + return result; + } + if (content.indexOf("repositories") >= 0) { + result.add(scope("repositories", content)); + } + result.add(scope("dependencies", content)); + return result; + } + + private static String scope(String name, String content) { + StringBuilder result = new StringBuilder(); + boolean foundFirstBracket = false; + int openBrackets = 0; + for (int i = content.indexOf(name); i != -1 && i < content.length(); i++) { + char c = content.charAt(i); + if (c == '{') { + if (openBrackets == 0) { + foundFirstBracket = true; + } + openBrackets++; + } + if (c == '}') { + openBrackets--; + } + result.append(c); + if (openBrackets == 0 && foundFirstBracket) { + break; + } + } + return result.toString(); + } +} diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/Fixtures.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/Fixtures.java new file mode 100644 index 0000000000..215024bb5f --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/Fixtures.java @@ -0,0 +1,161 @@ +package org.nativescript.gradle; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.gradle.testkit.runner.BuildResult; + +final class Fixtures { + static final String SAMPLE_NAME = "@nativescript/gradle-api-sample"; + + private Fixtures() {} + + static String requiredProperty(String name) { + String value = System.getProperty(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException("System property " + name + " is not set; run through the functionalTest task."); + } + return value; + } + + static Path testRepo() { + return Path.of(requiredProperty("nativescript.testRepo")); + } + + static String libraryVersion() { + return requiredProperty("nativescript.libraryVersion"); + } + + static Path samplesDir() { + return Path.of(requiredProperty("nativescript.samplesDir")); + } + + static Path vendorGradlePluginDir() { + return Path.of(requiredProperty("nativescript.vendorGradlePluginDir")); + } + + static List gradleVersions() { + return Arrays.asList(requiredProperty("nativescript.gradleVersions").split(",")); + } + + /** Makes every buildscript resolve the library from the test repository first. */ + static Path writeInitScript(Path dir) { + String repo = testRepo().toUri().toString(); + return write(dir.resolve("test-repo.init.gradle"), String.join("\n", + "allprojects {", + " buildscript {", + " repositories {", + " maven { url = '" + repo + "' }", + " }", + " }", + "}", + "")); + } + + /** Copies the sample plugin into node_modules, pointing its buildscript at the version under test. */ + static Path installSample(Path projectDir) { + Path target = projectDir.resolve("node_modules").resolve(SAMPLE_NAME); + copyTree(samplesDir().resolve("sample-plugin"), target); + Path buildscript = target.resolve("platforms/android/buildscript.gradle"); + String text = read(buildscript); + String replaced = text.replaceAll("org\\.nativescript:gradle-api:[^\"']+", "org.nativescript:gradle-api:" + libraryVersion()); + if (replaced.equals(text) && !text.contains(libraryVersion())) { + throw new IllegalStateException("No org.nativescript:gradle-api classpath dependency in " + buildscript); + } + write(buildscript, replaced); + return target; + } + + /** A stand-in for @nativescript/core: a package whose native directory holds archives. */ + static Path installCore(Path projectDir) { + Path core = projectDir.resolve("node_modules/@nativescript/core"); + write(core.resolve("package.json"), "{ \"name\": \"@nativescript/core\", \"version\": \"9.0.0\" }\n"); + write(core.resolve("platforms/android/widgets-release.aar"), "not really an aar"); + write(core.resolve("platforms/android/nested/inner.aar"), "not really an aar"); + return core; + } + + static Path write(Path file, String content) { + try { + Files.createDirectories(file.getParent()); + Files.write(file, content.getBytes(StandardCharsets.UTF_8)); + return file; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + static String read(Path file) { + try { + return new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + static void copyTree(Path source, Path target) { + try (Stream paths = Files.walk(source)) { + for (Path path : paths.collect(Collectors.toList())) { + Path destination = target.resolve(source.relativize(path).toString()); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Lines printed as {@code NS|key|value}, by key. A key printed twice must print the same value. */ + static Map probes(BuildResult result) { + Map values = new LinkedHashMap<>(); + for (String line : result.getOutput().split("\\R")) { + if (!line.startsWith("NS|")) { + continue; + } + String[] parts = line.split("\\|", 3); + String previous = values.put(parts[1], parts.length > 2 ? parts[2] : ""); + if (previous != null && !previous.equals(values.get(parts[1]))) { + throw new AssertionError("Probe " + parts[1] + " printed both '" + previous + "' and '" + values.get(parts[1]) + "'"); + } + } + return values; + } + + static String sha256(Path file) { + try { + StringBuilder hex = new StringBuilder(); + for (byte b : MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file))) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + static String path(Path path) { + return path.toAbsolutePath().normalize().toFile().getPath(); + } + + static String path(File file) { + return path(file.toPath()); + } +} diff --git a/gradle-api/src/functionalTest/java/org/nativescript/gradle/PluginBuildTest.java b/gradle-api/src/functionalTest/java/org/nativescript/gradle/PluginBuildTest.java new file mode 100644 index 0000000000..b298ac7f0f --- /dev/null +++ b/gradle-api/src/functionalTest/java/org/nativescript/gradle/PluginBuildTest.java @@ -0,0 +1,126 @@ +package org.nativescript.gradle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * The CLI's build of a plugin's aar: the CLI's own template from vendor/gradle-plugin, with the + * extracted block of the sample's include.gradle appended, as the CLI does. + */ +class PluginBuildTest { + // The Android Gradle Plugin version decides which Gradle versions can run this build. + private static final String ANDROID_GRADLE_PLUGIN = "8.12.1"; + private static final String GRADLE = "8.14.3"; + + private static final String PROBE = String.join("\n", + "", + "def nsProbe = project.extensions.findByName('nativescript')", + "println \"NS|extension|${nsProbe != null}\"", + "if (nsProbe != null) {", + " println \"NS|pluginBuild|${nsProbe.pluginBuild}\"", + " println \"NS|self|${nsProbe.self?.name}\"", + " println \"NS|buildingSample|${nsProbe.isBuildingPlugin('" + Fixtures.SAMPLE_NAME + "')}\"", + " println \"NS|projectDir|${nsProbe.projectDir}\"", + " println \"NS|appDir|${nsProbe.appDir}\"", + "}", + "println \"NS|compileOnly|\" + configurations.compileOnly.dependencies.withType(FileCollectionDependency)" + + ".collectMany { it.files.files }.collect { it.name }.sort().join(',')", + ""); + + @TempDir + Path tempDir; + + @BeforeAll + static void requireAndroidSdk() { + String androidHome = System.getenv("ANDROID_HOME"); + assumeTrue(androidHome != null && new File(androidHome).isDirectory(), "ANDROID_HOME is not set"); + } + + @ParameterizedTest(name = "readOnlyNodeModules={0}") + @ValueSource(booleans = {false, true}) + void configuresThePluginBuild(boolean readOnlyNodeModules) throws Exception { + Path project = tempDir.toRealPath().resolve("project"); + Path platformsAndroid = project.resolve("platforms/android"); + Fixtures.installCore(project); + Fixtures.installSample(project); + Fixtures.write(project.resolve("package.json"), "{ \"name\": \"fixture-app\" }\n"); + Fixtures.write(platformsAndroid.resolve("dependencies.json"), String.join("\n", + "[", + " { \"name\": \"@nativescript/core\", \"directory\": \"../../node_modules/@nativescript/core\", \"dependencies\": [] },", + " { \"name\": \"" + Fixtures.SAMPLE_NAME + "\", \"directory\": \"../../node_modules/" + Fixtures.SAMPLE_NAME + "\", \"dependencies\": [\"@nativescript/core\"] }", + "]", + "")); + + Path pluginDir = project.resolve("platforms/tempPlugin/gradle-api-sample"); + Path vendor = Fixtures.vendorGradlePluginDir(); + for (String name : List.of("build.gradle", "settings.gradle", "gradle.properties")) { + String text = Fixtures.read(vendor.resolve(name)) + .replace("{{pluginName}}", Fixtures.SAMPLE_NAME) + .replace("{{pluginNamespace}}", "org.nativescript.gradle_api_sample") + .replace("{{runtimeAndroidPluginVersion}}", ANDROID_GRADLE_PLUGIN); + assertFalse(text.contains("{{"), "unknown placeholder left in " + name); + Fixtures.write(pluginDir.resolve(name), text); + } + Path buildGradle = pluginDir.resolve("build.gradle"); + Fixtures.write(buildGradle, Fixtures.read(buildGradle) + "\n" + Fixtures.read(extractedIncludeGradle()) + "\n" + PROBE); + Fixtures.write(pluginDir.resolve("src/main/AndroidManifest.xml"), + "\n"); + + List arguments = new ArrayList<>(List.of( + "help", "--stacktrace", + "--init-script", Fixtures.path(Fixtures.writeInitScript(tempDir)), + "-PtempBuild=true", + "-PappPath=" + Fixtures.path(project.resolve("app")), + "-PappResourcesPath=" + Fixtures.path(project.resolve("app/App_Resources")))); + if (readOnlyNodeModules) { + arguments.add("-Pnativescript.readOnlyNodeModules=true"); + } + Map environment = new HashMap<>(System.getenv()); + environment.put("USER_PROJECT_ROOT", Fixtures.path(project)); + environment.put("USER_PROJECT_PLATFORMS_ANDROID", Fixtures.path(platformsAndroid)); + + BuildResult result = GradleRunner.create() + .withGradleVersion(GRADLE) + .withProjectDir(pluginDir.toFile()) + .withArguments(arguments) + .withEnvironment(environment) + .forwardOutput() + .build(); + Map probes = Fixtures.probes(result); + + if (!readOnlyNodeModules) { + // The template only runs the plugin's own buildscript.gradle on request, so the + // library is not on the classpath and the block takes its old CLI branch. + assertEquals("false", probes.get("extension")); + assertEquals("widgets-release.aar", probes.get("compileOnly")); + return; + } + assertEquals("true", probes.get("extension")); + assertEquals("true", probes.get("pluginBuild")); + assertEquals(Fixtures.SAMPLE_NAME, probes.get("self")); + assertEquals("true", probes.get("buildingSample")); + assertEquals(Fixtures.path(project), probes.get("projectDir")); + assertEquals(Fixtures.path(project.resolve("app")), probes.get("appDir")); + // Only the library's artifacts include archives below the top level. + assertEquals("inner.aar,widgets-release.aar", probes.get("compileOnly")); + } + + static Path extractedIncludeGradle() throws URISyntaxException { + return new File(PluginBuildTest.class.getResource("/extracted/sample-plugin-include.gradle").toURI()).toPath(); + } +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/package.json b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/package.json new file mode 100644 index 0000000000..66669d27af --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/package.json @@ -0,0 +1 @@ +{ "name": "fixture-plugin-b", "version": "1.0.0" } diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/buildscript.gradle b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/buildscript.gradle new file mode 100644 index 0000000000..a7a528e6f5 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/buildscript.gradle @@ -0,0 +1,3 @@ +dependencies { + classpath "org.nativescript:gradle-api:@LIBRARY_VERSION@" +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/include.gradle b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/include.gradle new file mode 100644 index 0000000000..f0ddb0584e --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/fixture-plugin-b/platforms/android/include.gradle @@ -0,0 +1,17 @@ +// A second plugin applying the library to the same project, after the sample has. +apply plugin: "org.nativescript.api" + +def nsFromB = project.extensions.getByName("nativescript") +println "NS|b.sameExtension|${nativescript.is(nsFromB)}" +println "NS|b.projectDir|${nsFromB.projectDir}" + +// Scripts applied with `apply from` get their own class loader, which does not see the +// classes buildscript.gradle put on the project's classpath, even though the plugin id resolves. +def libraryClassVisible +try { + Class.forName("org.nativescript.gradle.NativeScript") + libraryClassVisible = true +} catch (ClassNotFoundException ignored) { + libraryClassVisible = false +} +println "NS|b.libraryClassVisible|$libraryClassVisible" diff --git a/gradle-api/src/functionalTest/resources/app-fixture/packages/js-only/package.json b/gradle-api/src/functionalTest/resources/app-fixture/packages/js-only/package.json new file mode 100644 index 0000000000..49e0840168 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/packages/js-only/package.json @@ -0,0 +1 @@ +{ "name": "js-only", "version": "1.0.0" } diff --git a/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/app/build.gradle b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/app/build.gradle new file mode 100644 index 0000000000..e5cf426701 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/app/build.gradle @@ -0,0 +1,67 @@ +// Mirrors how the @nativescript/android app template's app/build.gradle applies plugins' Gradle +// files: buildscript.gradle to this project's buildscript, include.gradle from inside `android {}`. +// The template reads the root project's properties implicitly, which Gradle 9 deprecates; reading +// them through rootProject keeps the build free of deprecations so the tests can fail on any. +buildscript { + def applyBuildScriptConfigurations = { -> + rootProject.nativescriptDependencies.each { dep -> + def pathToPluginBuildScriptGradle = "$rootDir/${dep.directory}/${rootProject.PLATFORMS_ANDROID}/buildscript.gradle" + def pluginBuildScriptGradle = file(pathToPluginBuildScriptGradle) + if (pluginBuildScriptGradle.exists()) { + apply from: pathToPluginBuildScriptGradle, to: buildscript + } + } + } + applyBuildScriptConfigurations() +} + +// Stands in for the Android Gradle Plugin's extension, so include.gradle runs with the same +// closure delegation as in a real app build. +class FakeAndroidExtension { + String namespace +} + +extensions.create("android", FakeAndroidExtension) + +def applyPluginGradleConfigurations = { -> + rootProject.nativescriptDependencies.each { dep -> + def includeGradlePath = "$rootDir/${dep.directory}/${rootProject.PLATFORMS_ANDROID}/include.gradle" + if (file(includeGradlePath).exists()) { + apply from: includeGradlePath + } + } +} + +android { + namespace = "org.nativescript.fixture" + applyPluginGradleConfigurations() +} + +def ns = extensions.getByName("nativescript") +println "NS|typed|${org.nativescript.gradle.NativeScript.of(project).is(ns)}" +println "NS|apiLevel|${ns.apiLevel}" +println "NS|pluginBuild|${ns.pluginBuild}" +println "NS|self|${ns.self}" +println "NS|buildingSample|${ns.isBuildingPlugin('@nativescript/gradle-api-sample')}" +println "NS|projectDir|${ns.projectDir}" +println "NS|appDir|${ns.appDir}" +println "NS|appResourcesDir|${ns.appResourcesDir}" +println "NS|has|${ns.has('fixture.capability')}" +def core = ns.plugin("@nativescript/core") +println "NS|core.packageDir|${core.packageDir}" +println "NS|core.nativeDir|${core.nativeDir}" +println "NS|core.artifacts|${core.artifacts.files.collect { it.name }.sort().join(',')}" +println "NS|core.aarName|${core.aarName}" +println "NS|core.aar|${core.aar}" +println "NS|core.options|${core.options}" +println "NS|sample.nativeDir|${ns.plugin('@nativescript/gradle-api-sample').nativeDir}" +println "NS|jsOnly|${ns.findPackage('js-only')}" +println "NS|jsOnlyPlugin|${ns.findPlugin('js-only')}" +println "NS|missingPlugin|${ns.findPlugin('not-installed')}" +println "NS|missingPackage|${ns.findPackage('not-installed')}" +try { + ns.plugin("not-installed") + println "NS|pluginError|none" +} catch (GradleException e) { + println "NS|pluginError|${e.message}" +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/build.gradle b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/build.gradle new file mode 100644 index 0000000000..c0b59b083d --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/build.gradle @@ -0,0 +1,62 @@ +// Mirrors the parts of the @nativescript/android app template's root build.gradle that the library +// and plugins' Gradle files rely on. +import groovy.json.JsonSlurper + +import java.nio.file.Paths + +buildscript { + def initialize = { -> + def dependenciesJson = file("$rootDir/dependencies.json") + project.ext.nativescriptDependencies = new JsonSlurper().parseText(dependenciesJson.text) + project.ext.PLATFORMS_ANDROID = "platforms/android" + project.ext.USER_PROJECT_ROOT = "$rootDir/../.." + + project.ext.getAppPath = { -> + def relativePathToApp = "app" + def nsConfigFile = file("$USER_PROJECT_ROOT/nsconfig.json") + def nsConfig + + if (nsConfigFile.exists()) { + nsConfig = new JsonSlurper().parseText(nsConfigFile.getText("UTF-8")) + } + + if (project.hasProperty("appPath")) { + relativePathToApp = appPath + } else if (nsConfig != null && nsConfig.appPath != null) { + relativePathToApp = nsConfig.appPath + } + + project.ext.appPath = Paths.get(USER_PROJECT_ROOT).resolve(relativePathToApp).toAbsolutePath() + + return project.ext.appPath + } + + project.ext.getAppResourcesPath = { -> + def relativePathToAppResources + def absolutePathToAppResources + def nsConfigFile = file("$USER_PROJECT_ROOT/nsconfig.json") + def nsConfig + + if (nsConfigFile.exists()) { + nsConfig = new JsonSlurper().parseText(nsConfigFile.getText("UTF-8")) + } + + if (project.hasProperty("appResourcesPath")) { + relativePathToAppResources = appResourcesPath + absolutePathToAppResources = Paths.get(USER_PROJECT_ROOT).resolve(relativePathToAppResources).toAbsolutePath() + } else if (nsConfig != null && nsConfig.appResourcesPath != null) { + relativePathToAppResources = nsConfig.appResourcesPath + absolutePathToAppResources = Paths.get(USER_PROJECT_ROOT).resolve(relativePathToAppResources).toAbsolutePath() + } else { + absolutePathToAppResources = "${getAppPath()}/App_Resources" + } + + project.ext.appResourcesPath = absolutePathToAppResources + + return absolutePathToAppResources + } + } + + initialize() + getAppResourcesPath() +} diff --git a/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/settings.gradle b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/settings.gradle new file mode 100644 index 0000000000..0c6d0a2de5 --- /dev/null +++ b/gradle-api/src/functionalTest/resources/app-fixture/platforms/android/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "fixture-app" +include ":app" diff --git a/gradle-api/src/functionalTest/resources/extracted/sample-plugin-include.gradle b/gradle-api/src/functionalTest/resources/extracted/sample-plugin-include.gradle new file mode 100644 index 0000000000..fb5fb51f2e --- /dev/null +++ b/gradle-api/src/functionalTest/resources/extracted/sample-plugin-include.gradle @@ -0,0 +1,8 @@ +dependencies { + def ns = project.extensions.findByName("nativescript") + if (ns != null && ns.pluginBuild) { + compileOnly ns.plugin("@nativescript/core").artifacts + } else if (project.hasProperty("tempBuild")) { + compileOnly fileTree(dir: "$USER_PROJECT_ROOT/node_modules/@nativescript/core/platforms/android", include: "*.aar") + } +} \ No newline at end of file diff --git a/gradle-api/src/main/java/org/nativescript/gradle/NativeScript.java b/gradle-api/src/main/java/org/nativescript/gradle/NativeScript.java new file mode 100644 index 0000000000..25b8998e74 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/NativeScript.java @@ -0,0 +1,43 @@ +package org.nativescript.gradle; + +import org.gradle.api.GradleException; +import org.gradle.api.Project; + +/** + * Entry point for Java and Kotlin code, such as another Gradle plugin built on this library. + * + *

Scripts applied with {@code apply from}, which include every plugin's {@code include.gradle}, + * get their own class loader and cannot see this class even when {@code buildscript.gradle} put the + * library on the project's classpath. They apply the plugin by id and use the extension + * dynamically, through {@code project.extensions.getByName("nativescript")}. + */ +public final class NativeScript { + private NativeScript() {} + + /** + * The {@value NativeScriptExtension#NAME} extension of {@code project}, applying {@link + * NativeScriptPlugin} first if needed. + * + * @param project the project, normally {@code :app} in the app build + * @return the extension + * @throws GradleException if the project's extension comes from another copy of this library, + * loaded by a different class loader + */ + public static NativeScriptExtension of(Project project) { + Object extension = project.getExtensions().findByName(NativeScriptExtension.NAME); + if (extension == null) { + project.getPluginManager().apply(NativeScriptPlugin.class); + extension = project.getExtensions().getByName(NativeScriptExtension.NAME); + } + if (extension instanceof NativeScriptExtension) { + return (NativeScriptExtension) extension; + } + throw new GradleException("The '" + NativeScriptExtension.NAME + "' extension of " + project + + " is a " + extension.getClass().getName() + " (class loader " + extension.getClass().getClassLoader() + + "), not this copy of org.nativescript:gradle-api's " + NativeScriptExtension.class.getName() + + ". Either something else registered an extension of that name, or another copy of the library" + + " in a different class loader did; then put the library on the classpath of the buildscript that" + + " applies it, or use the extension dynamically through project.extensions.getByName(\"" + + NativeScriptExtension.NAME + "\")."); + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptExtension.java b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptExtension.java new file mode 100644 index 0000000000..6f133975de --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptExtension.java @@ -0,0 +1,171 @@ +package org.nativescript.gradle; + +import java.io.File; + +/** + * What a NativeScript plugin's Gradle code can ask about the project being built, registered as + * the {@value #NAME} extension by {@link NativeScriptPlugin}. + * + *

Answers come from the facts file the NativeScript CLI writes ({@code + * platforms/android/nativescript-gradle.json}) or, with CLIs that do not write one (9.1 and older), + * from the same properties and files those CLIs' Gradle templates use. A facts file that such a CLI + * left behind no longer matches the {@code dependencies.json} next to it, and is ignored with a + * warning. A plugin written against + * this API therefore builds with old and new CLIs alike. + * + *

Everything is worked out on first use, during configuration, and does not change afterwards. + * Read what a task needs while configuring it rather than calling this from a task action. + * + *

Two builds can use this API: + * + *

    + *
  • the app build ({@code platforms/android}), where every plugin's {@code include.gradle} is + * applied to the {@code :app} project; + *
  • a plugin build, the throwaway project in which the CLI compiles one plugin's sources into + * an aar. Only the {@code repositories} and {@code dependencies} blocks of that plugin's own + * {@code include.gradle} run there, but the {@code include.gradle} files of the plugins it + * depends on run in full. + *
+ * + *

Not intended to be implemented outside this library; methods may be added in later versions. + * Check {@link #getApiLevel()} before calling a method newer than the version a plugin declares, + * because another plugin may have put a newer version of this library on the classpath, never an + * older one. + * + *

{@code
+ * apply plugin: "org.nativescript.api"
+ *
+ * dependencies {
+ *     def ns = project.extensions.findByName("nativescript")
+ *     if (ns != null && ns.pluginBuild) {
+ *         compileOnly ns.plugin("@nativescript/core").artifacts
+ *     }
+ * }
+ * }
+ */ +public interface NativeScriptExtension { + /** The name the extension is registered under. */ + String NAME = "nativescript"; + + /** + * The API level this copy of the library implements: 1 for the methods documented as such, and + * one more for each later version that adds methods. + * + * @return the API level, at least 1 + * @since API level 1 + */ + int getApiLevel(); + + /** + * Whether the CLI that prepared the project guarantees a behaviour. Capability names are listed + * in the facts file's schema documentation. + * + * @param capability the capability name + * @return true if the CLI declared it; always false with CLIs that do not write a facts file + * @since API level 1 + */ + boolean has(String capability); + + /** + * Whether this Gradle build is the CLI's build of one plugin's aar rather than the app build. + * + * @return true in a plugin build + * @since API level 1 + */ + boolean isPluginBuild(); + + /** + * In a plugin build, the plugin whose aar is being built. A dependency's {@code include.gradle} + * applied there sees the plugin that depends on it, not itself. + * + * @return that plugin, or null in the app build or if the CLI did not name the plugin + * @since API level 1 + */ + PluginInfo getSelf(); + + /** + * Whether this is the plugin build of the given plugin. + * + * @param npmName the plugin's npm package name, such as {@code @nativescript/core} + * @return true only in that plugin's own aar build + * @since API level 1 + */ + boolean isBuildingPlugin(String npmName); + + /** + * The NativeScript project root: the directory with the app's {@code package.json}. + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getProjectDir(); + + /** + * The app's source directory ({@code appPath} in the NativeScript config). + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getAppDir(); + + /** + * The app's {@code App_Resources} directory ({@code appResourcesPath} in the NativeScript config). + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getAppResourcesDir(); + + /** + * A plugin of the project: a package the CLI lists as having Android native code or, failing + * that, an installed package (found as in {@link #findPackage(String)}) with a {@code + * platforms/android} directory. The same rule applies with every CLI version, so a plugin gets + * the same answer whichever CLI prepared the project; only the details of the CLI's list, such + * as {@link PluginInfo#getAarName()}, depend on the version. + * + * @param npmName the npm package name, such as {@code @nativescript/core} + * @return the plugin + * @throws org.gradle.api.GradleException if there is no such plugin; the message lists the + * plugins that are known + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + PluginInfo plugin(String npmName); + + /** + * Like {@link #plugin(String)}, but returns null when there is no such plugin. + * + * @param npmName the npm package name, such as {@code @nativescript/core} + * @return the plugin, or null + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + PluginInfo findPlugin(String npmName); + + /** + * The directory of any installed npm package, native plugin or not. + * + *

Packages the CLI lists as plugins resolve to the directory it recorded. Others are looked + * up the way Node does: {@code node_modules/} in the project root, then in each parent + * directory. This is best effort: layouts that keep packages out of the project's {@code + * node_modules}, such as pnpm's isolated mode for packages the app does not depend on directly, + * may not resolve. + * + * @param npmName the npm package name + * @return the package directory + * @throws org.gradle.api.GradleException if the package is not found + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + File resolvePackage(String npmName); + + /** + * Like {@link #resolvePackage(String)}, but returns null when the package is not found. + * + * @param npmName the npm package name + * @return the package directory, or null + * @throws IllegalArgumentException if {@code npmName} is not an npm package name + * @since API level 1 + */ + File findPackage(String npmName); +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptPlugin.java b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptPlugin.java new file mode 100644 index 0000000000..d04366103a --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/NativeScriptPlugin.java @@ -0,0 +1,26 @@ +package org.nativescript.gradle; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.nativescript.gradle.internal.DefaultNativeScriptExtension; + +/** + * Registers the {@value NativeScriptExtension#NAME} extension, plugin id {@code + * org.nativescript.api}. + * + *

Every plugin of an app applies this to the same {@code :app} project; applying it again, or + * when the extension already exists, does nothing. + */ +public class NativeScriptPlugin implements Plugin { + /** Creates the plugin; Gradle does this when it is applied. */ + public NativeScriptPlugin() {} + + @Override + public void apply(Project project) { + if (project.getExtensions().findByName(NativeScriptExtension.NAME) != null) { + return; + } + project.getExtensions() + .add(NativeScriptExtension.class, NativeScriptExtension.NAME, new DefaultNativeScriptExtension(project)); + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/PluginInfo.java b/gradle-api/src/main/java/org/nativescript/gradle/PluginInfo.java new file mode 100644 index 0000000000..9339666971 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/PluginInfo.java @@ -0,0 +1,82 @@ +package org.nativescript.gradle; + +import java.io.File; +import java.util.Map; +import org.gradle.api.file.FileCollection; + +/** + * A plugin of the NativeScript project: an npm package with Android native code. + * + *

Not intended to be implemented outside this library; methods may be added in later versions. + * + * @see NativeScriptExtension#plugin(String) + */ +public interface PluginInfo { + /** + * The npm package name. + * + * @return the name, such as {@code @nativescript/core} + * @since API level 1 + */ + String getName(); + + /** + * The installed npm package. Treat it as read-only: the CLI may keep {@code node_modules} + * read-only and work on a copy of the native files instead. + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getPackageDir(); + + /** + * The directory the build reads the plugin's Android files from: the package's {@code + * platforms/android}, or the CLI's copy of it. + * + * @return an absolute, normalized directory + * @since API level 1 + */ + File getNativeDir(); + + /** + * Every {@code .aar} and {@code .jar} under the {@linkplain #getNativeDir() native directory}, + * including the aar the CLI builds from the plugin's sources once it exists. Evaluated lazily. + * + *

Meant for {@code compileOnly} in a dependent plugin's build: the app build already + * packages these files. + * + * @return a live view of those files + * @since API level 1 + */ + FileCollection getArtifacts(); + + /** + * Options the project sets for this plugin. Values are JSON values: {@code String}, {@code + * Boolean}, {@code Integer}, {@code Long}, {@code java.math.BigInteger}, {@code + * java.math.BigDecimal}, {@code null}, and unmodifiable {@code List}s and {@code Map}s of them. + * + * @return an unmodifiable map; empty with CLIs that do not write a facts file + * @since API level 1 + */ + Map getOptions(); + + /** + * File name, without extension, of the aar the CLI builds from the plugin's sources; unique + * across the project. + * + * @return the name, or null with CLIs that do not write a facts file, and for installed + * packages the CLI does not list as plugins + * @since API level 1 + */ + String getAarName(); + + /** + * Where the CLI puts the aar it builds from the plugin's sources. The file only exists once + * built, and only if the plugin has sources. + * + * @return the file, or null with CLIs that do not write a facts file, and for installed + * packages the CLI does not list as plugins + * @since API level 1 + */ + File getAar(); +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/BuildInputs.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/BuildInputs.java new file mode 100644 index 0000000000..f7c03d1ded --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/BuildInputs.java @@ -0,0 +1,51 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import org.gradle.api.Project; +import org.gradle.api.plugins.ExtraPropertiesExtension; +import org.gradle.api.provider.ProviderFactory; + +/** What {@link Resolution} reads from the build. */ +public interface BuildInputs { + /** + * An extra property of the project or, failing that, of its nearest ancestor that has one; else + * a Gradle property ({@code -P}, gradle.properties). Null when absent. + */ + Object property(String name); + + /** An environment variable, null when absent or empty. */ + String environmentVariable(String name); + + /** The root project's directory. */ + File rootDir(); + + static BuildInputs of(Project project) { + ProviderFactory providers = project.getProviders(); + File rootDir = project.getRootDir(); + return new BuildInputs() { + // Not Project.findProperty: its implicit lookup in parent projects is deprecated since + // Gradle 9.x, and the templates set their extra properties on the root project. + @Override + public Object property(String name) { + for (Project p = project; p != null; p = p.getParent()) { + ExtraPropertiesExtension extra = p.getExtensions().getExtraProperties(); + if (extra.has(name)) { + return extra.get(name); + } + } + return providers.gradleProperty(name).getOrNull(); + } + + @Override + public String environmentVariable(String name) { + String value = providers.environmentVariable(name).getOrNull(); + return value == null || value.isEmpty() ? null : value; + } + + @Override + public File rootDir() { + return rootDir; + } + }; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultNativeScriptExtension.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultNativeScriptExtension.java new file mode 100644 index 0000000000..c34cd15e85 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultNativeScriptExtension.java @@ -0,0 +1,134 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.gradle.api.GradleException; +import org.gradle.api.Project; +import org.gradle.api.model.ObjectFactory; +import org.nativescript.gradle.NativeScriptExtension; +import org.nativescript.gradle.PluginInfo; + +public final class DefaultNativeScriptExtension implements NativeScriptExtension { + public static final int API_LEVEL = 1; + + private final BuildInputs inputs; + private final ObjectFactory objects; + private final Map pluginInfos = new ConcurrentHashMap<>(); + private volatile Resolution resolution; + + public DefaultNativeScriptExtension(Project project) { + this(BuildInputs.of(project), project.getObjects()); + } + + public DefaultNativeScriptExtension(BuildInputs inputs, ObjectFactory objects) { + this.inputs = inputs; + this.objects = objects; + } + + // Resolved on first use rather than when applied: in the plugin build the library is applied + // reflectively, and plugins may apply it before the template has set its extra properties. + private Resolution resolution() { + Resolution result = resolution; + if (result == null) { + synchronized (this) { + result = resolution; + if (result == null) { + result = Resolution.resolve(inputs); + resolution = result; + } + } + } + return result; + } + + @Override + public int getApiLevel() { + return API_LEVEL; + } + + @Override + public boolean has(String capability) { + return resolution().getCapabilities().contains(capability); + } + + @Override + public boolean isPluginBuild() { + return resolution().isPluginBuild(); + } + + @Override + public PluginInfo getSelf() { + String name = resolution().getPluginName(); + return name != null ? findPlugin(name) : null; + } + + @Override + public boolean isBuildingPlugin(String npmName) { + return npmName != null && npmName.equals(resolution().getPluginName()); + } + + @Override + public File getProjectDir() { + return resolution().getProjectDir(); + } + + @Override + public File getAppDir() { + return resolution().getAppDir(); + } + + @Override + public File getAppResourcesDir() { + return resolution().getAppResourcesDir(); + } + + @Override + public PluginInfo plugin(String npmName) { + PluginInfo plugin = findPlugin(npmName); + if (plugin == null) { + Resolution r = resolution(); + String list = r.getFacts() != null ? r.getFacts().getFile().toString() : "dependencies.json"; + throw new GradleException("NativeScript plugin '" + npmName + "' is not listed in " + list + + ", nor installed with a platforms/android directory in " + + new File(r.getProjectDir(), "node_modules") + " or a parent directory" + + ". Known plugins: " + + (r.getPlugins().isEmpty() ? "none" : String.join(", ", r.getPlugins().keySet())) + "."); + } + return plugin; + } + + @Override + public PluginInfo findPlugin(String npmName) { + NodePackages.validate(npmName); + PluginInfo cached = pluginInfos.get(npmName); + if (cached != null) { + return cached; + } + PluginData data = resolution().findPlugin(npmName); + if (data == null) { + return null; + } + return pluginInfos.computeIfAbsent(npmName, name -> new DefaultPluginInfo(data, objects)); + } + + @Override + public File resolvePackage(String npmName) { + File dir = findPackage(npmName); + if (dir == null) { + throw new GradleException("npm package '" + npmName + "' is not installed in " + + new File(getProjectDir(), "node_modules") + " or a parent directory."); + } + return dir; + } + + @Override + public File findPackage(String npmName) { + return resolution().findPackage(npmName); + } + + @Override + public String toString() { + return "extension '" + NAME + "'"; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultPluginInfo.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultPluginInfo.java new file mode 100644 index 0000000000..453fdea331 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/DefaultPluginInfo.java @@ -0,0 +1,59 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.util.Map; +import org.gradle.api.file.FileCollection; +import org.gradle.api.model.ObjectFactory; +import org.nativescript.gradle.PluginInfo; + +final class DefaultPluginInfo implements PluginInfo { + private final PluginData data; + private final FileCollection artifacts; + + DefaultPluginInfo(PluginData data, ObjectFactory objects) { + this.data = data; + this.artifacts = objects.fileTree() + .from(data.getNativeDir()) + .matching(patterns -> patterns.include("**/*.aar", "**/*.jar")); + } + + @Override + public String getName() { + return data.getName(); + } + + @Override + public File getPackageDir() { + return data.getPackageDir(); + } + + @Override + public File getNativeDir() { + return data.getNativeDir(); + } + + @Override + public FileCollection getArtifacts() { + return artifacts; + } + + @Override + public Map getOptions() { + return data.getOptions(); + } + + @Override + public String getAarName() { + return data.getAarName(); + } + + @Override + public File getAar() { + return data.getAar(); + } + + @Override + public String toString() { + return "NativeScript plugin " + data; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/Facts.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/Facts.java new file mode 100644 index 0000000000..59706db2ae --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/Facts.java @@ -0,0 +1,315 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.gradle.api.GradleException; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; + +/** + * The CLI's {@code nativescript-gradle.json}, described by {@code schema/nativescript-gradle.schema.json}. + * + *

Fields are only ever added to the format, so unknown fields are ignored and a newer {@code + * schemaVersion} is read like this one. + */ +public final class Facts { + /** The newest schemaVersion this library knows every field of. */ + public static final int KNOWN_SCHEMA_VERSION = 1; + + private static final Logger LOGGER = Logging.getLogger(Facts.class); + + // Checked against the file's whole content, not its mtime: the class loader holding this cache + // outlives builds in the daemon, and a rewrite can keep the size and land in the same mtime tick. + private static final Map CACHE = new ConcurrentHashMap<>(); + + private final File file; + private final int schemaVersion; + private final String dependenciesJsonSha256; + private final Set capabilities; + private final File projectRoot; + private final File appDir; + private final File appResourcesDir; + private final Map plugins; + + private Facts( + File file, + int schemaVersion, + String dependenciesJsonSha256, + Set capabilities, + File projectRoot, + File appDir, + File appResourcesDir, + Map plugins) { + this.file = file; + this.schemaVersion = schemaVersion; + this.dependenciesJsonSha256 = dependenciesJsonSha256; + this.capabilities = Collections.unmodifiableSet(capabilities); + this.projectRoot = projectRoot; + this.appDir = appDir; + this.appResourcesDir = appResourcesDir; + this.plugins = Collections.unmodifiableMap(plugins); + } + + /** + * Reads {@code file}, reusing the previous result while its content is unchanged. + * + * @throws GradleException if the file cannot be read or is not valid facts + */ + public static Facts read(File file) { + File absolute = file.getAbsoluteFile(); + String key = absolute.getPath(); + byte[] content; + try { + content = Files.readAllBytes(absolute.toPath()); + } catch (IOException e) { + throw new GradleException("Could not read NativeScript facts file " + absolute + ": " + e, e); + } + CachedFacts cached = CACHE.get(key); + if (cached != null && Arrays.equals(cached.content, content)) { + return cached.facts; + } + Facts facts = parse(absolute, new String(content, StandardCharsets.UTF_8)); + CACHE.put(key, new CachedFacts(content, facts)); + return facts; + } + + /** + * Parses facts whose relative paths are relative to {@code file}'s directory. Does not touch the + * file system. + * + * @throws GradleException if {@code text} is not valid facts + */ + public static Facts parse(File file, String text) { + Object root; + try { + root = Json.parse(text); + } catch (Json.ParseException e) { + throw invalid(file, e.getMessage()); + } + Path base = file.getAbsoluteFile().toPath().getParent(); + Reader reader = new Reader(file, base); + Map top = reader.object(root, "the top level"); + + int schemaVersion = reader.integer(top, "schemaVersion", ""); + if (schemaVersion < 1) { + throw invalid(file, "schemaVersion must be at least 1"); + } + if (schemaVersion > KNOWN_SCHEMA_VERSION) { + LOGGER.info( + "{} has schemaVersion {}; this version of org.nativescript:gradle-api knows up to {}" + + " and ignores the fields it does not know.", + file, + schemaVersion, + KNOWN_SCHEMA_VERSION); + } + + // Required by the schema, but a reader must stay usable if a later writer drops it. + String dependenciesJsonSha256 = null; + if (top.containsKey("dependenciesJson")) { + Map dependenciesJson = reader.object(top.get("dependenciesJson"), "dependenciesJson"); + dependenciesJsonSha256 = reader.requiredString(dependenciesJson, "sha256", "dependenciesJson."); + if (!dependenciesJsonSha256.matches("[0-9a-f]{64}")) { + throw invalid(file, "dependenciesJson.sha256 must be 64 lowercase hex digits"); + } + } + + Set capabilities = new LinkedHashSet<>(); + if (top.containsKey("capabilities")) { + List list = reader.array(top.get("capabilities"), "capabilities"); + for (int i = 0; i < list.size(); i++) { + capabilities.add(reader.string(list.get(i), "capabilities[" + i + "]")); + } + } + + Map project = reader.object(top.get("project"), "project"); + File projectRoot = reader.path(project, "root", "project."); + File appDir = reader.path(project, "appDir", "project."); + File appResourcesDir = reader.path(project, "appResourcesDir", "project."); + + Map plugins = new LinkedHashMap<>(); + List pluginList = reader.array(top.get("plugins"), "plugins"); + for (int i = 0; i < pluginList.size(); i++) { + String at = "plugins[" + i + "]"; + Map entry = reader.object(pluginList.get(i), at); + String prefix = at + "."; + String name = reader.requiredString(entry, "name", prefix); + List deps = reader.array(entry.get("dependencies"), prefix + "dependencies"); + List dependencies = new ArrayList<>(); + for (int d = 0; d < deps.size(); d++) { + dependencies.add(reader.string(deps.get(d), prefix + "dependencies[" + d + "]")); + } + Map options = + entry.containsKey("options") ? reader.object(entry.get("options"), prefix + "options") : Collections.emptyMap(); + PluginData plugin = new PluginData( + name, + reader.path(entry, "packageDir", prefix), + reader.path(entry, "nativeDir", prefix), + reader.requiredString(entry, "aarName", prefix), + reader.path(entry, "aar", prefix), + dependencies, + options); + if (plugins.put(name, plugin) != null) { + throw invalid(file, "plugin '" + name + "' is listed more than once"); + } + } + + return new Facts( + file, schemaVersion, dependenciesJsonSha256, capabilities, projectRoot, appDir, appResourcesDir, plugins); + } + + private static GradleException invalid(File file, String problem) { + return new GradleException("Invalid NativeScript facts file " + file + ": " + problem); + } + + /** + * Why these facts no longer describe the project, or null if they still do: a CLI that does not + * write facts rewrites the dependencies.json next to them but leaves them behind. Reads that + * file on every call, so the answer is never cached. + * + * @throws GradleException if dependencies.json exists but cannot be read + */ + public String staleness() { + if (dependenciesJsonSha256 == null) { + return null; + } + File dependenciesJson = new File(file.getAbsoluteFile().getParentFile(), "dependencies.json"); + if (!dependenciesJson.isFile()) { + return "there is no " + dependenciesJson + " next to it"; + } + byte[] content; + try { + content = Files.readAllBytes(dependenciesJson.toPath()); + } catch (IOException e) { + throw new GradleException("Could not read " + dependenciesJson + ": " + e, e); + } + if (sha256(content).equals(dependenciesJsonSha256)) { + return null; + } + return "it does not match " + dependenciesJson + ", which a different NativeScript CLI wrote"; + } + + static String sha256(byte[] content) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Every Java platform provides SHA-256", e); + } + StringBuilder hex = new StringBuilder(64); + for (byte b : digest.digest(content)) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } + + public File getFile() { + return file; + } + + public int getSchemaVersion() { + return schemaVersion; + } + + /** Null if the writer did not record it. */ + public String getDependenciesJsonSha256() { + return dependenciesJsonSha256; + } + + public Set getCapabilities() { + return capabilities; + } + + public File getProjectRoot() { + return projectRoot; + } + + public File getAppDir() { + return appDir; + } + + public File getAppResourcesDir() { + return appResourcesDir; + } + + /** By npm name, in the order the CLI listed them. */ + public Map getPlugins() { + return plugins; + } + + private static final class CachedFacts { + final byte[] content; + final Facts facts; + + CachedFacts(byte[] content, Facts facts) { + this.content = content; + this.facts = facts; + } + } + + private static final class Reader { + private final File file; + private final Path base; + + Reader(File file, Path base) { + this.file = file; + this.base = base; + } + + @SuppressWarnings("unchecked") + Map object(Object value, String what) { + if (!(value instanceof Map)) { + throw invalid(file, what + " must be an object"); + } + return (Map) value; + } + + @SuppressWarnings("unchecked") + List array(Object value, String what) { + if (!(value instanceof List)) { + throw invalid(file, what + " must be an array"); + } + return (List) value; + } + + String string(Object value, String what) { + if (!(value instanceof String)) { + throw invalid(file, what + " must be a string"); + } + return (String) value; + } + + String requiredString(Map object, String key, String prefix) { + String value = string(object.get(key), prefix + key); + if (value.isEmpty()) { + throw invalid(file, prefix + key + " must not be empty"); + } + return value; + } + + int integer(Map object, String key, String prefix) { + Object value = object.get(key); + if (!(value instanceof Integer)) { + throw invalid(file, prefix + key + " must be an integer"); + } + return (Integer) value; + } + + File path(Map object, String key, String prefix) { + return base.resolve(requiredString(object, key, prefix)).normalize().toFile(); + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/Json.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/Json.java new file mode 100644 index 0000000000..89ebffca44 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/Json.java @@ -0,0 +1,366 @@ +package org.nativescript.gradle.internal; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Strict RFC 8259 JSON parser. + * + *

The library must not depend on a JSON library, nor on Groovy's: Gradle 7 ships Groovy 3 and + * Gradle 8+ ship Groovy 4, under different package names. + * + *

Objects become unmodifiable {@link LinkedHashMap}s (a repeated key keeps the last value, as + * {@code JSON.parse} does), arrays unmodifiable lists, integers {@link Integer}, {@link Long} or + * {@link BigInteger} (the smallest that fits), other numbers {@link BigDecimal}, and {@code null} + * stays {@code null}. A leading byte order mark is skipped. + */ +public final class Json { + private static final int MAX_DEPTH = 512; + + private final String text; + private int pos; + private int depth; + + private Json(String text) { + this.text = text; + } + + /** + * Parses one JSON value, which must be the whole of {@code text} apart from whitespace. + * + * @throws ParseException if {@code text} is not valid JSON + */ + public static Object parse(String text) { + Json parser = new Json(text); + if (!text.isEmpty() && text.charAt(0) == 0xFEFF) { + parser.pos = 1; + } + parser.skipWhitespace(); + Object value = parser.readValue(); + parser.skipWhitespace(); + if (parser.pos < text.length()) { + throw parser.error("Unexpected " + parser.describeCurrent() + " after the JSON value"); + } + return value; + } + + private Object readValue() { + if (pos >= text.length()) { + throw error("Unexpected end of input, expected a value"); + } + char c = text.charAt(pos); + switch (c) { + case '{': + return readObject(); + case '[': + return readArray(); + case '"': + return readString(); + case 't': + expectLiteral("true"); + return Boolean.TRUE; + case 'f': + expectLiteral("false"); + return Boolean.FALSE; + case 'n': + expectLiteral("null"); + return null; + default: + if (c == '-' || (c >= '0' && c <= '9')) { + return readNumber(); + } + throw error("Unexpected " + describeCurrent() + ", expected a value"); + } + } + + private Map readObject() { + enter(); + pos++; + Map result = new LinkedHashMap<>(); + skipWhitespace(); + if (peek() == '}') { + pos++; + depth--; + return Collections.unmodifiableMap(result); + } + while (true) { + skipWhitespace(); + if (peek() != '"') { + throw error("Unexpected " + describeCurrent() + ", expected a string key"); + } + String key = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + result.put(key, readValue()); + skipWhitespace(); + if (peek() == ',') { + pos++; + } else if (peek() == '}') { + pos++; + depth--; + return Collections.unmodifiableMap(result); + } else { + throw error("Unexpected " + describeCurrent() + ", expected ',' or '}'"); + } + } + } + + private List readArray() { + enter(); + pos++; + List result = new ArrayList<>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + depth--; + return Collections.unmodifiableList(result); + } + while (true) { + skipWhitespace(); + result.add(readValue()); + skipWhitespace(); + if (peek() == ',') { + pos++; + } else if (peek() == ']') { + pos++; + depth--; + return Collections.unmodifiableList(result); + } else { + throw error("Unexpected " + describeCurrent() + ", expected ',' or ']'"); + } + } + } + + private String readString() { + pos++; + StringBuilder sb = new StringBuilder(); + while (true) { + if (pos >= text.length()) { + throw error("Unterminated string"); + } + char c = text.charAt(pos); + if (c == '"') { + pos++; + return sb.toString(); + } + if (c < 0x20) { + throw error("Unescaped control character U+" + String.format("%04X", (int) c) + " in string"); + } + if (c != '\\') { + sb.append(c); + pos++; + continue; + } + pos++; + if (pos >= text.length()) { + throw error("Unterminated string"); + } + char escaped = text.charAt(pos); + switch (escaped) { + case '"': + case '\\': + case '/': + sb.append(escaped); + break; + case 'b': + sb.append('\b'); + break; + case 'f': + sb.append('\f'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + sb.append(readUnicodeEscape()); + continue; + default: + throw error("Invalid escape sequence '\\" + escaped + "'"); + } + pos++; + } + } + + private char readUnicodeEscape() { + int start = pos + 1; + if (start + 4 > text.length()) { + throw error("Incomplete \\u escape"); + } + int value = 0; + for (int i = start; i < start + 4; i++) { + char c = text.charAt(i); + int digit = c < 0x80 ? Character.digit(c, 16) : -1; + if (digit < 0) { + pos = i; + throw error("Invalid hex digit " + describeCurrent() + " in \\u escape"); + } + value = value * 16 + digit; + } + pos = start + 4; + return (char) value; + } + + private Number readNumber() { + int start = pos; + boolean integral = true; + if (peek() == '-') { + pos++; + } + if (peek() == '0') { + pos++; + if (isDigit(peek())) { + throw error("Leading zeros are not allowed"); + } + } else if (isDigit(peek())) { + skipDigits(); + } else { + throw error("Unexpected " + describeCurrent() + ", expected a digit"); + } + if (peek() == '.') { + integral = false; + pos++; + if (!isDigit(peek())) { + throw error("Unexpected " + describeCurrent() + ", expected a digit after '.'"); + } + skipDigits(); + } + if (peek() == 'e' || peek() == 'E') { + integral = false; + pos++; + if (peek() == '+' || peek() == '-') { + pos++; + } + if (!isDigit(peek())) { + throw error("Unexpected " + describeCurrent() + ", expected a digit in the exponent"); + } + skipDigits(); + } + String literal = text.substring(start, pos); + if (!integral) { + return new BigDecimal(literal); + } + BigInteger value = new BigInteger(literal); + if (value.bitLength() < 32) { + return value.intValue(); + } + if (value.bitLength() < 64) { + return value.longValue(); + } + return value; + } + + private void skipDigits() { + while (isDigit(peek())) { + pos++; + } + } + + private static boolean isDigit(int c) { + return c >= '0' && c <= '9'; + } + + private void expectLiteral(String literal) { + for (int i = 0; i < literal.length(); i++) { + if (peek() != literal.charAt(i)) { + throw error("Unexpected " + describeCurrent() + ", expected '" + literal + "'"); + } + pos++; + } + } + + private void expect(char c) { + if (peek() != c) { + throw error("Unexpected " + describeCurrent() + ", expected '" + c + "'"); + } + pos++; + } + + private void enter() { + if (++depth > MAX_DEPTH) { + throw error("Nesting deeper than " + MAX_DEPTH + " levels"); + } + } + + /** The current character, or -1 at the end of the input. */ + private int peek() { + return pos < text.length() ? text.charAt(pos) : -1; + } + + private void skipWhitespace() { + while (pos < text.length()) { + char c = text.charAt(pos); + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { + return; + } + pos++; + } + } + + private String describeCurrent() { + if (pos >= text.length()) { + return "end of input"; + } + char c = text.charAt(pos); + if (c < 0x20 || c == 0x7F) { + return "character U+" + String.format("%04X", (int) c); + } + return "character '" + c + "'"; + } + + private ParseException error(String message) { + int line = 1; + int column = 1; + for (int i = 0; i < pos && i < text.length(); i++) { + if (text.charAt(i) == '\n') { + line++; + column = 1; + } else { + column++; + } + } + return new ParseException(message, pos, line, column); + } + + /** Invalid JSON, with the position of the first offending character. */ + public static final class ParseException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final int offset; + private final int line; + private final int column; + + ParseException(String message, int offset, int line, int column) { + super(message + " at line " + line + ", column " + column); + this.offset = offset; + this.line = line; + this.column = column; + } + + /** Zero-based character offset into the parsed text. */ + public int getOffset() { + return offset; + } + + /** One-based line number. */ + public int getLine() { + return line; + } + + /** One-based column number, counted in UTF-16 characters. */ + public int getColumn() { + return column; + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/NodePackages.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/NodePackages.java new file mode 100644 index 0000000000..714c1dd23c --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/NodePackages.java @@ -0,0 +1,41 @@ +package org.nativescript.gradle.internal; + +import java.io.File; + +/** Node's lookup of installed packages: {@code node_modules/} in a directory or any ancestor. */ +public final class NodePackages { + private NodePackages() {} + + /** + * The directory of the installed package {@code name} as seen from {@code fromDir}, or null. + * + * @throws IllegalArgumentException if {@code name} is not an npm package name + */ + public static File find(File fromDir, String name) { + validate(name); + for (File dir = fromDir.getAbsoluteFile(); dir != null; dir = dir.getParentFile()) { + File candidate = new File(new File(dir, "node_modules"), name); + if (new File(candidate, "package.json").isFile()) { + return candidate.toPath().normalize().toFile(); + } + } + return null; + } + + static void validate(String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("An npm package name must not be empty"); + } + String[] parts = name.split("/", -1); + boolean scoped = name.startsWith("@"); + boolean valid = scoped ? parts.length == 2 && parts[0].length() > 1 : parts.length == 1; + for (String part : parts) { + if (part.isEmpty() || part.equals(".") || part.equals("..") || part.indexOf('\\') >= 0 || part.indexOf(':') >= 0) { + valid = false; + } + } + if (!valid) { + throw new IllegalArgumentException("'" + name + "' is not an npm package name"); + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/PluginData.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/PluginData.java new file mode 100644 index 0000000000..a4ffd0754e --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/PluginData.java @@ -0,0 +1,69 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** What is known about one npm package with Android native code. Immutable. */ +public final class PluginData { + private final String name; + private final File packageDir; + private final File nativeDir; + private final String aarName; + private final File aar; + private final List dependencies; + private final Map options; + + public PluginData( + String name, + File packageDir, + File nativeDir, + String aarName, + File aar, + List dependencies, + Map options) { + this.name = name; + this.packageDir = packageDir; + this.nativeDir = nativeDir; + this.aarName = aarName; + this.aar = aar; + this.dependencies = Collections.unmodifiableList(dependencies); + this.options = Collections.unmodifiableMap(options); + } + + public String getName() { + return name; + } + + public File getPackageDir() { + return packageDir; + } + + public File getNativeDir() { + return nativeDir; + } + + /** Null unless the CLI wrote facts. */ + public String getAarName() { + return aarName; + } + + /** Null unless the CLI wrote facts. */ + public File getAar() { + return aar; + } + + public List getDependencies() { + return dependencies; + } + + public Map getOptions() { + return options; + } + + @Override + public String toString() { + return name + " (" + nativeDir + ")"; + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/internal/Resolution.java b/gradle-api/src/main/java/org/nativescript/gradle/internal/Resolution.java new file mode 100644 index 0000000000..3b527fb7ba --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/internal/Resolution.java @@ -0,0 +1,332 @@ +package org.nativescript.gradle.internal; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.gradle.api.GradleException; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; + +/** + * Everything the extension answers, worked out once from the CLI's facts file or, for CLIs that do + * not write one (9.1 and older), from the same inputs their Gradle templates read. + */ +public final class Resolution { + public static final String FACTS_FILE_NAME = "nativescript-gradle.json"; + public static final String FACTS_FILE_PROPERTY = "nativescript.factsFile"; + + private static final String PLATFORMS_ANDROID = "platforms/android"; + private static final Logger LOGGER = Logging.getLogger(Resolution.class); + + private final Facts facts; + private final boolean pluginBuild; + private final String pluginName; + private final File projectDir; + private final File appDir; + private final File appResourcesDir; + private final Map plugins; + private final Set capabilities; + + private Resolution( + Facts facts, + boolean pluginBuild, + String pluginName, + File projectDir, + File appDir, + File appResourcesDir, + Map plugins, + Set capabilities) { + this.facts = facts; + this.pluginBuild = pluginBuild; + this.pluginName = pluginName; + this.projectDir = projectDir; + this.appDir = appDir; + this.appResourcesDir = appResourcesDir; + this.plugins = plugins; + this.capabilities = capabilities; + } + + public static Resolution resolve(BuildInputs inputs) { + // The CLI passes -PtempBuild=true only to the throwaway project that builds a plugin's aar, + // whose template also sets PLUGIN_NAME. + boolean pluginBuild = inputs.property("tempBuild") != null; + String pluginName = pluginBuild ? asString(inputs.property("PLUGIN_NAME")) : null; + + File factsFile = locateFacts(inputs, pluginBuild); + Facts facts = factsFile != null ? Facts.read(factsFile) : null; + String staleness = facts != null ? facts.staleness() : null; + if (staleness != null) { + LOGGER.warn("Ignoring {}: {}. Using the fallback rules.", factsFile, staleness); + facts = null; + } + if (facts != null) { + return new Resolution( + facts, + pluginBuild, + pluginName, + facts.getProjectRoot(), + facts.getAppDir(), + facts.getAppResourcesDir(), + facts.getPlugins(), + facts.getCapabilities()); + } + return resolveLegacy(inputs, pluginBuild, pluginName); + } + + private static File locateFacts(BuildInputs inputs, boolean pluginBuild) { + String explicit = asString(inputs.property(FACTS_FILE_PROPERTY)); + if (explicit != null) { + File file = absolute(inputs.rootDir(), explicit); + if (!file.isFile()) { + throw new GradleException("-P" + FACTS_FILE_PROPERTY + " points to " + file + ", which does not exist."); + } + return file; + } + // Lets the app build find the facts without -P, as when Android Studio opens platforms/android. + File inRoot = new File(inputs.rootDir(), FACTS_FILE_NAME); + if (inRoot.isFile()) { + return inRoot.getAbsoluteFile(); + } + File platformsAndroid = explicitPlatformsAndroid(inputs, pluginBuild); + if (platformsAndroid != null) { + File file = new File(platformsAndroid, FACTS_FILE_NAME); + if (file.isFile()) { + return file; + } + } + return null; + } + + private static Resolution resolveLegacy(BuildInputs inputs, boolean pluginBuild, String pluginName) { + File rootDir = inputs.rootDir(); + File projectDir = legacyProjectDir(inputs, pluginBuild); + + // dependencies.json directories are relative to the folder holding it: the app's + // platforms/android, which is the app build's root but not the plugin build's. + File dependenciesBase; + if (pluginBuild) { + File platformsAndroid = explicitPlatformsAndroid(inputs, true); + dependenciesBase = platformsAndroid != null ? platformsAndroid : normalize(new File(projectDir, PLATFORMS_ANDROID)); + } else { + dependenciesBase = normalize(rootDir); + } + Map plugins = readDependenciesJson(new File(dependenciesBase, "dependencies.json")); + + NsConfig nsConfig = new NsConfig(new File(projectDir, "nsconfig.json")); + String appPath = asString(inputs.property("appPath")); + if (appPath == null) { + appPath = nsConfig.get("appPath"); + } + File appDir = absolute(projectDir, appPath != null ? appPath : "app"); + + String appResourcesPath = asString(inputs.property("appResourcesPath")); + if (appResourcesPath == null) { + appResourcesPath = nsConfig.get("appResourcesPath"); + } + File appResourcesDir = appResourcesPath != null + ? absolute(projectDir, appResourcesPath) + : normalize(new File(appDir, "App_Resources")); + + return new Resolution( + null, pluginBuild, pluginName, projectDir, appDir, appResourcesDir, plugins, Collections.emptySet()); + } + + private static File legacyProjectDir(BuildInputs inputs, boolean pluginBuild) { + File rootDir = inputs.rootDir(); + String value = asString(inputs.property("USER_PROJECT_ROOT")); + if (value == null && pluginBuild) { + value = inputs.environmentVariable("USER_PROJECT_ROOT"); + } + if (value != null) { + return absolute(rootDir, value); + } + // The templates' own defaults: platforms/android, and platforms/tempPlugin/. + return normalize(new File(rootDir, pluginBuild ? "../../.." : "../..")); + } + + /** USER_PROJECT_PLATFORMS_ANDROID, which only the plugin build template defines. */ + private static File explicitPlatformsAndroid(BuildInputs inputs, boolean pluginBuild) { + String value = asString(inputs.property("USER_PROJECT_PLATFORMS_ANDROID")); + if (value == null && pluginBuild) { + value = inputs.environmentVariable("USER_PROJECT_PLATFORMS_ANDROID"); + } + return value != null ? absolute(inputs.rootDir(), value) : null; + } + + private static Map readDependenciesJson(File file) { + if (!file.isFile()) { + return Collections.emptyMap(); + } + Object root; + try { + root = Json.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new GradleException("Could not read " + file + ": " + e, e); + } catch (Json.ParseException e) { + throw new GradleException("Invalid " + file + ": " + e.getMessage(), e); + } + if (!(root instanceof List)) { + throw new GradleException("Invalid " + file + ": expected an array"); + } + File base = file.getAbsoluteFile().getParentFile(); + Map plugins = new LinkedHashMap<>(); + for (Object item : (List) root) { + if (!(item instanceof Map)) { + throw new GradleException("Invalid " + file + ": every entry must be an object"); + } + Map entry = (Map) item; + if (!(entry.get("name") instanceof String) || !(entry.get("directory") instanceof String)) { + throw new GradleException("Invalid " + file + ": every entry needs a string name and directory"); + } + String name = (String) entry.get("name"); + File packageDir = absolute(base, (String) entry.get("directory")); + List dependencies = new ArrayList<>(); + if (entry.get("dependencies") instanceof List) { + for (Object dependency : (List) entry.get("dependencies")) { + if (dependency instanceof String) { + dependencies.add((String) dependency); + } + } + } + plugins.putIfAbsent(name, new PluginData( + name, + packageDir, + normalize(new File(packageDir, PLATFORMS_ANDROID)), + null, + null, + dependencies, + Collections.emptyMap())); + } + return Collections.unmodifiableMap(plugins); + } + + /** Null in legacy mode. */ + public Facts getFacts() { + return facts; + } + + public boolean isPluginBuild() { + return pluginBuild; + } + + /** The plugin whose aar this build makes; null outside plugin builds. */ + public String getPluginName() { + return pluginName; + } + + public File getProjectDir() { + return projectDir; + } + + public File getAppDir() { + return appDir; + } + + public File getAppResourcesDir() { + return appResourcesDir; + } + + public Set getCapabilities() { + return capabilities; + } + + /** The plugins the CLI listed, by npm name. */ + public Map getPlugins() { + return plugins; + } + + /** + * A plugin the CLI listed, else an installed package that has a {@code platforms/android} + * directory. The fallback applies with and without facts, so that a plugin's Gradle code gets the + * same answers from every CLI: neither list need contain every package a plugin build compiles + * against. + */ + public PluginData findPlugin(String name) { + NodePackages.validate(name); + PluginData listed = plugins.get(name); + if (listed != null) { + return listed; + } + File packageDir = NodePackages.find(projectDir, name); + if (packageDir == null) { + return null; + } + File nativeDir = normalize(new File(packageDir, PLATFORMS_ANDROID)); + if (!nativeDir.isDirectory()) { + return null; + } + return new PluginData( + name, + packageDir, + nativeDir, + null, + null, + Collections.emptyList(), + Collections.emptyMap()); + } + + /** The installed package's directory, null if not found. */ + public File findPackage(String name) { + NodePackages.validate(name); + PluginData listed = plugins.get(name); + if (listed != null) { + return listed.getPackageDir(); + } + return NodePackages.find(projectDir, name); + } + + private static String asString(Object value) { + if (value == null) { + return null; + } + String string = value.toString(); + return string.isEmpty() ? null : string; + } + + private static File absolute(File base, String path) { + return base.toPath().resolve(path).toAbsolutePath().normalize().toFile(); + } + + private static File normalize(File file) { + return file.toPath().toAbsolutePath().normalize().toFile(); + } + + /** The nsconfig.json the templates fall back to, read only if needed. */ + private static final class NsConfig { + private final File file; + private Map values; + + NsConfig(File file) { + this.file = file; + } + + String get(String key) { + if (values == null) { + values = read(); + } + Object value = values.get(key); + return value instanceof String && !((String) value).isEmpty() ? (String) value : null; + } + + private Map read() { + if (!file.isFile()) { + return Collections.emptyMap(); + } + try { + Object root = Json.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + return root instanceof Map ? (Map) root : Collections.emptyMap(); + } catch (IOException e) { + throw new GradleException("Could not read " + file + ": " + e, e); + } catch (Json.ParseException e) { + throw new GradleException("Invalid " + file + ": " + e.getMessage(), e); + } + } + } +} diff --git a/gradle-api/src/main/java/org/nativescript/gradle/package-info.java b/gradle-api/src/main/java/org/nativescript/gradle/package-info.java new file mode 100644 index 0000000000..41731116a5 --- /dev/null +++ b/gradle-api/src/main/java/org/nativescript/gradle/package-info.java @@ -0,0 +1,28 @@ +/** + * Gradle API for NativeScript plugins. + * + *

A plugin puts the library on the build's classpath from its {@code + * platforms/android/buildscript.gradle}: + * + *

{@code
+ * repositories { mavenCentral() }
+ * dependencies { classpath "org.nativescript:gradle-api:" }
+ * }
+ * + *

and applies it at the top of its {@code platforms/android/include.gradle}: + * + *

{@code
+ * apply plugin: "org.nativescript.api"
+ * }
+ * + *

The {@link org.nativescript.gradle.NativeScriptExtension nativescript} extension then answers + * questions about the project. {@code include.gradle} cannot see the library's classes (see {@link + * org.nativescript.gradle.NativeScript}), so it uses the extension dynamically: + * + *

{@code
+ * def ns = project.extensions.getByName("nativescript")
+ * }
+ * + *

Everything outside this package is internal. + */ +package org.nativescript.gradle; diff --git a/gradle-api/src/test/java/org/nativescript/gradle/internal/FactsTest.java b/gradle-api/src/test/java/org/nativescript/gradle/internal/FactsTest.java new file mode 100644 index 0000000000..c2ffdc200b --- /dev/null +++ b/gradle-api/src/test/java/org/nativescript/gradle/internal/FactsTest.java @@ -0,0 +1,476 @@ +package org.nativescript.gradle.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.gradle.api.GradleException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class FactsTest { + @TempDir + Path tempDir; + + private Path projectDir; + private Path platformsAndroid; + private File factsFile; + + @BeforeEach + void setUp() throws IOException { + projectDir = tempDir.toRealPath(); + platformsAndroid = projectDir.resolve("platforms/android"); + Files.createDirectories(platformsAndroid); + factsFile = platformsAndroid.resolve("nativescript-gradle.json").toFile(); + } + + @Test + void readsBasicFacts() throws IOException { + copyResource("/facts/v1-basic.json", factsFile.toPath()); + + Facts facts = Facts.read(factsFile); + + assertEquals(factsFile, facts.getFile()); + assertEquals(1, facts.getSchemaVersion()); + assertTrue(facts.getCapabilities().isEmpty()); + assertEquals(projectDir.toFile(), facts.getProjectRoot()); + assertEquals(projectDir.resolve("src").toFile(), facts.getAppDir()); + assertEquals(projectDir.resolve("App_Resources").toFile(), facts.getAppResourcesDir()); + assertEquals( + Arrays.asList("@nativescript/core", "@foo/plugin-x", "@bar/plugin-x"), + new ArrayList<>(facts.getPlugins().keySet())); + + PluginData core = facts.getPlugins().get("@nativescript/core"); + assertEquals("@nativescript/core", core.getName()); + assertEquals(projectDir.resolve("node_modules/@nativescript/core").toFile(), core.getPackageDir()); + assertEquals( + projectDir.resolve("node_modules/@nativescript/core/platforms/android").toFile(), core.getNativeDir()); + assertEquals("core", core.getAarName()); + assertEquals( + projectDir.resolve("node_modules/@nativescript/core/platforms/android/core.aar").toFile(), core.getAar()); + assertEquals(Collections.emptyList(), core.getDependencies()); + + PluginData x = facts.getPlugins().get("@foo/plugin-x"); + assertEquals(projectDir.resolve("node_modules/@foo/plugin-x").toFile(), x.getPackageDir()); + assertEquals(platformsAndroid.resolve(".ns-plugins/foo_plugin_x/platforms/android").toFile(), x.getNativeDir()); + assertEquals("foo_plugin_x", x.getAarName()); + assertEquals( + platformsAndroid.resolve(".ns-plugins/foo_plugin_x/platforms/android/foo_plugin_x.aar").toFile(), + x.getAar()); + assertEquals(Collections.singletonList("@nativescript/core"), x.getDependencies()); + assertTrue(x.getOptions().isEmpty()); + + assertThrows(UnsupportedOperationException.class, () -> x.getOptions().put("k", "v")); + assertThrows(UnsupportedOperationException.class, () -> x.getDependencies().add("y")); + assertThrows(UnsupportedOperationException.class, () -> facts.getPlugins().remove("@foo/plugin-x")); + assertThrows(UnsupportedOperationException.class, () -> facts.getCapabilities().add("c")); + } + + @Test + void readsNewerSchemaVersionAndIgnoresUnknownFields() throws IOException { + copyResource("/facts/v2-unknown-fields.json", factsFile.toPath()); + + Facts facts = Facts.read(factsFile); + + assertEquals(2, facts.getSchemaVersion()); + assertEquals(Collections.singleton("some.future.capability"), facts.getCapabilities()); + assertEquals(projectDir.toFile(), facts.getProjectRoot()); + assertEquals(projectDir.resolve("app").toFile(), facts.getAppDir()); + assertEquals(projectDir.resolve("app/App_Resources").toFile(), facts.getAppResourcesDir()); + assertEquals(Collections.singletonList("nativescript-plugin"), new ArrayList<>(facts.getPlugins().keySet())); + PluginData plugin = facts.getPlugins().get("nativescript-plugin"); + assertEquals("nativescript_plugin", plugin.getAarName()); + assertEquals(Collections.singletonMap("someOption", Arrays.asList("a", "b")), plugin.getOptions()); + } + + @Test + void readsFactsFromResourceInPlace() throws Exception { + File resource = new File(getClass().getResource("/facts/v1-basic.json").toURI()); + + Facts facts = Facts.read(resource); + + Path dir = resource.getAbsoluteFile().toPath().getParent(); + assertEquals(dir.resolve("../..").normalize().toFile(), facts.getProjectRoot()); + assertEquals(3, facts.getPlugins().size()); + } + + @Test + void optionsDefaultToEmptyWhenAbsent() { + Map plugin = pluginFields("p"); + plugin.remove("options"); + + Facts facts = parse(document(PROJECT, "[" + object(plugin) + "]")); + + assertTrue(facts.getPlugins().get("p").getOptions().isEmpty()); + assertTrue(facts.getCapabilities().isEmpty()); + } + + @Test + void keepsAbsolutePathsAbsolute() { + Path elsewhere = projectDir.resolve("elsewhere"); + String project = "{\"root\": " + quote(elsewhere) + ", \"appDir\": " + quote(elsewhere.resolve("app")) + + ", \"appResourcesDir\": " + quote(elsewhere.resolve("res")) + "}"; + Map plugin = pluginFields("p"); + plugin.put("nativeDir", quote(elsewhere.resolve("native"))); + + Facts facts = parse(document(project, "[" + object(plugin) + "]")); + + assertEquals(elsewhere.toFile(), facts.getProjectRoot()); + assertEquals(elsewhere.resolve("app").toFile(), facts.getAppDir()); + assertEquals(elsewhere.resolve("res").toFile(), facts.getAppResourcesDir()); + assertEquals(elsewhere.resolve("native").toFile(), facts.getPlugins().get("p").getNativeDir()); + } + + @Test + void normalizesRelativePaths() { + String project = + "{\"root\": \"./a/../../..\", \"appDir\": \"../../x/./y/../app\", \"appResourcesDir\": \"res/\"}"; + + Facts facts = parse(document(project, "[]")); + + assertEquals(projectDir.toFile(), facts.getProjectRoot()); + assertEquals(projectDir.resolve("x/app").toFile(), facts.getAppDir()); + assertEquals(platformsAndroid.resolve("res").toFile(), facts.getAppResourcesDir()); + } + + @Test + void acceptsBomAndCapabilities() { + Facts facts = parse("{\"schemaVersion\": 1, \"capabilities\": [\"a\", \"b\"], \"project\": " + PROJECT + + ", \"plugins\": []}"); + + assertEquals(Arrays.asList("a", "b"), new ArrayList<>(facts.getCapabilities())); + } + + @Test + void reportsMissingSchemaVersion() { + assertInvalid("{\"project\": " + PROJECT + ", \"plugins\": []}", "schemaVersion must be an integer"); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-1"}) + void reportsSchemaVersionBelowOne(String version) { + assertInvalid(document(version, PROJECT, "[]"), "schemaVersion must be at least 1"); + } + + @ParameterizedTest + @ValueSource(strings = {"\"1\"", "1.5", "null", "true", "2147483648"}) + void reportsNonIntegerSchemaVersion(String version) { + assertInvalid(document(version, PROJECT, "[]"), "schemaVersion must be an integer"); + } + + @ParameterizedTest + @ValueSource(strings = {"[]", "1", "\"facts\"", "null"}) + void reportsNonObjectTopLevel(String text) { + assertInvalid(text, "the top level must be an object"); + } + + @Test + void reportsMissingProject() { + assertInvalid("{\"schemaVersion\": 1, \"plugins\": []}", "project must be an object"); + } + + @Test + void reportsNonObjectProject() { + assertInvalid(document("\"..\"", "[]"), "project must be an object"); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "{\"root\": 1, \"appDir\": \"a\", \"appResourcesDir\": \"r\"}|project.root must be a string", + "{\"root\": \"\", \"appDir\": \"a\", \"appResourcesDir\": \"r\"}|project.root must not be empty", + "{\"appDir\": \"a\", \"appResourcesDir\": \"r\"}|project.root must be a string", + "{\"root\": \".\", \"appResourcesDir\": \"r\"}|project.appDir must be a string", + "{\"root\": \".\", \"appDir\": \"a\"}|project.appResourcesDir must be a string", + "{\"root\": \".\", \"appDir\": \"a\", \"appResourcesDir\": \"\"}|project.appResourcesDir must not be empty", + }) + void reportsInvalidProjectFields(String project, String problem) { + assertInvalid(document(project, "[]"), problem); + } + + @ParameterizedTest + @ValueSource(strings = {"{}", "\"p\"", "null"}) + void reportsNonArrayPlugins(String plugins) { + assertInvalid(document(PROJECT, plugins), "plugins must be an array"); + } + + @Test + void reportsMissingPlugins() { + assertInvalid("{\"schemaVersion\": 1, \"project\": " + PROJECT + "}", "plugins must be an array"); + } + + @Test + void reportsNonObjectPlugin() { + assertInvalid(document(PROJECT, "[" + object(pluginFields("a")) + ", \"b\"]"), "plugins[1] must be an object"); + } + + @ParameterizedTest + @CsvSource({ + "name, plugins[0].name must be a string", + "packageDir, plugins[0].packageDir must be a string", + "nativeDir, plugins[0].nativeDir must be a string", + "aarName, plugins[0].aarName must be a string", + "aar, plugins[0].aar must be a string", + "dependencies, plugins[0].dependencies must be an array", + }) + void reportsMissingPluginField(String field, String problem) { + Map plugin = pluginFields("p"); + plugin.remove(field); + assertInvalid(document(PROJECT, "[" + object(plugin) + "]"), problem); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "name|\"\"|plugins[0].name must not be empty", + "aarName|\"\"|plugins[0].aarName must not be empty", + "nativeDir|\"\"|plugins[0].nativeDir must not be empty", + "nativeDir|3|plugins[0].nativeDir must be a string", + "dependencies|[\"a\", 1]|plugins[0].dependencies[1] must be a string", + "dependencies|\"a\"|plugins[0].dependencies must be an array", + "options|[]|plugins[0].options must be an object", + "options|null|plugins[0].options must be an object", + }) + void reportsInvalidPluginField(String field, String value, String problem) { + Map plugin = pluginFields("p"); + plugin.put(field, value); + assertInvalid(document(PROJECT, "[" + object(plugin) + "]"), problem); + } + + @Test + void reportsNonArrayCapabilities() { + assertInvalid( + "{\"schemaVersion\": 1, \"capabilities\": \"a\", \"project\": " + PROJECT + ", \"plugins\": []}", + "capabilities must be an array"); + } + + @Test + void reportsNonStringCapability() { + assertInvalid( + "{\"schemaVersion\": 1, \"capabilities\": [\"a\", 1], \"project\": " + PROJECT + ", \"plugins\": []}", + "capabilities[1] must be a string"); + } + + @Test + void reportsDuplicatePluginName() { + String plugins = "[" + object(pluginFields("a")) + ", " + object(pluginFields("b")) + ", " + + object(pluginFields("a")) + "]"; + assertInvalid(document(PROJECT, plugins), "plugin 'a' is listed more than once"); + } + + @Test + void reportsInvalidJsonWithPosition() { + GradleException e = assertThrows( + GradleException.class, () -> Facts.parse(factsFile, "{\n \"schemaVersion\": 1,\n}")); + assertTrue(e.getMessage().startsWith("Invalid NativeScript facts file " + factsFile + ": "), e.getMessage()); + assertTrue(e.getMessage().contains("expected a string key at line 3, column 1"), e.getMessage()); + } + + @Test + void readReusesResultWhileFileIsUnchanged() throws IOException { + write(factsFile, minimal("\"a\"")); + + Facts first = Facts.read(factsFile); + + assertSame(first, Facts.read(factsFile)); + assertSame(first, Facts.read(new File(factsFile.getPath()))); + } + + @Test + void readReparsesWhenLengthChanges() throws IOException { + write(factsFile, minimal("\"a\"")); + Facts first = Facts.read(factsFile); + long lastModified = factsFile.lastModified(); + + write(factsFile, minimal("\"a\", \"longer\"")); + assertTrue(factsFile.setLastModified(lastModified)); + + Facts second = Facts.read(factsFile); + assertNotSame(first, second); + assertEquals(Arrays.asList("a", "longer"), new ArrayList<>(second.getCapabilities())); + } + + @Test + void readReparsesWhenContentChangesWithSameLengthAndModificationTime() throws IOException { + write(factsFile, minimal("\"a\"")); + Facts first = Facts.read(factsFile); + long lastModified = factsFile.lastModified(); + + write(factsFile, minimal("\"b\"")); + assertTrue(factsFile.setLastModified(lastModified)); + + Facts second = Facts.read(factsFile); + assertNotSame(first, second); + assertEquals(Collections.singleton("b"), second.getCapabilities()); + } + + @Test + void readReusesResultWhenOnlyModificationTimeChanges() throws IOException { + write(factsFile, minimal("\"a\"")); + Facts first = Facts.read(factsFile); + + assertTrue(factsFile.setLastModified(factsFile.lastModified() + 10_000)); + + assertSame(first, Facts.read(factsFile)); + } + + @Test + void readReportsMissingFile() { + File missing = platformsAndroid.resolve("missing.json").toFile(); + GradleException e = assertThrows(GradleException.class, () -> Facts.read(missing)); + assertTrue(e.getMessage().startsWith("Could not read NativeScript facts file " + missing), e.getMessage()); + } + + @Test + void readReportsInvalidFacts() throws IOException { + write(factsFile, "{\"schemaVersion\": 1}"); + GradleException e = assertThrows(GradleException.class, () -> Facts.read(factsFile)); + assertEquals("Invalid NativeScript facts file " + factsFile + ": project must be an object", e.getMessage()); + } + + private static final String PROJECT = + "{\"root\": \"../..\", \"appDir\": \"../../app\", \"appResourcesDir\": \"../../app/App_Resources\"}"; + + private static Map pluginFields(String name) { + Map fields = new LinkedHashMap<>(); + fields.put("name", "\"" + name + "\""); + fields.put("packageDir", "\"../../node_modules/" + name + "\""); + fields.put("nativeDir", "\"../../node_modules/" + name + "/platforms/android\""); + fields.put("aarName", "\"" + name + "\""); + fields.put("aar", "\"../../node_modules/" + name + "/platforms/android/" + name + ".aar\""); + fields.put("dependencies", "[]"); + fields.put("options", "{}"); + return fields; + } + + private static String object(Map fields) { + return fields.entrySet().stream() + .map(e -> "\"" + e.getKey() + "\": " + e.getValue()) + .collect(Collectors.joining(", ", "{", "}")); + } + + private static String document(String project, String plugins) { + return document("1", project, plugins); + } + + private static final String EMPTY_ARRAY_SHA256 = "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570"; + + @Test + void sha256IsLowercaseHex() { + assertEquals(EMPTY_ARRAY_SHA256, Facts.sha256("[]\n".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void readsDependenciesJsonHash() throws IOException { + copyResource("/facts/v1-basic.json", factsFile.toPath()); + + assertEquals(EMPTY_ARRAY_SHA256, Facts.read(factsFile).getDependenciesJsonSha256()); + } + + @Test + void toleratesMissingDependenciesJsonHash() { + assertEquals(null, parse(minimal("")).getDependenciesJsonSha256()); + } + + @ParameterizedTest + @ValueSource(strings = { + "\"x\"", + "{}", + "{\"sha256\": 1}", + "{\"sha256\": \"37517E5F3DC66819F61F5A7BB8ACE1921282415F10551D2DEFA5C3EB0985B570\"}", + "{\"sha256\": \"37517e5f\"}" + }) + void rejectsMalformedDependenciesJsonHash(String value) { + GradleException e = assertThrows(GradleException.class, () -> parse(withHash(value))); + assertTrue(e.getMessage().startsWith("Invalid NativeScript facts file " + factsFile + ": dependenciesJson"), + e.getMessage()); + } + + @Test + void currentWhenDependenciesJsonMatches() throws IOException { + write(platformsAndroid.resolve("dependencies.json").toFile(), "[]\n"); + + assertEquals(null, parse(withHash("{\"sha256\": \"" + EMPTY_ARRAY_SHA256 + "\"}")).staleness()); + } + + @Test + void staleWhenDependenciesJsonDiffers() throws IOException { + File dependenciesJson = platformsAndroid.resolve("dependencies.json").toFile(); + write(dependenciesJson, "[ ]\n"); + + assertEquals("it does not match " + dependenciesJson + ", which a different NativeScript CLI wrote", + parse(withHash("{\"sha256\": \"" + EMPTY_ARRAY_SHA256 + "\"}")).staleness()); + } + + @Test + void staleWhenDependenciesJsonIsMissing() { + File dependenciesJson = platformsAndroid.resolve("dependencies.json").toFile(); + + assertEquals("there is no " + dependenciesJson + " next to it", + parse(withHash("{\"sha256\": \"" + EMPTY_ARRAY_SHA256 + "\"}")).staleness()); + } + + @Test + void trustedWithoutHashWhateverDependenciesJsonHolds() throws IOException { + assertEquals(null, parse(minimal("")).staleness()); + write(platformsAndroid.resolve("dependencies.json").toFile(), "[{\"name\": \"other\"}]"); + assertEquals(null, parse(minimal("")).staleness()); + } + + private static String withHash(String dependenciesJson) { + return "{\"schemaVersion\": 1, \"dependenciesJson\": " + dependenciesJson + ", \"project\": " + PROJECT + + ", \"plugins\": []}"; + } + + private static String document(String schemaVersion, String project, String plugins) { + return "{\"schemaVersion\": " + schemaVersion + ", \"project\": " + project + ", \"plugins\": " + plugins + "}"; + } + + private static String minimal(String capabilities) { + return "{\"schemaVersion\": 1, \"capabilities\": [" + capabilities + "], \"project\": " + PROJECT + + ", \"plugins\": []}"; + } + + private static String quote(Path path) { + return "\"" + path.toString().replace("\\", "\\\\") + "\""; + } + + private Facts parse(String text) { + return Facts.parse(factsFile, text); + } + + private void assertInvalid(String text, String problem) { + GradleException e = assertThrows(GradleException.class, () -> parse(text)); + assertEquals("Invalid NativeScript facts file " + factsFile + ": " + problem, e.getMessage()); + } + + private static void write(File file, String text) throws IOException { + Files.write(file.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + + private void copyResource(String name, Path target) throws IOException { + try (InputStream in = getClass().getResourceAsStream(name)) { + Files.copy(in, target); + } + } +} diff --git a/gradle-api/src/test/java/org/nativescript/gradle/internal/JsonTest.java b/gradle-api/src/test/java/org/nativescript/gradle/internal/JsonTest.java new file mode 100644 index 0000000000..076279370c --- /dev/null +++ b/gradle-api/src/test/java/org/nativescript/gradle/internal/JsonTest.java @@ -0,0 +1,253 @@ +package org.nativescript.gradle.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class JsonTest { + + @Test + void parsesLiterals() { + assertEquals(Boolean.TRUE, Json.parse("true")); + assertEquals(Boolean.FALSE, Json.parse("false")); + assertNull(Json.parse("null")); + assertEquals("hello", Json.parse("\"hello\"")); + assertEquals("", Json.parse("\"\"")); + assertEquals(42, Json.parse("42")); + } + + @Test + void parsesEmptyContainers() { + assertEquals(Collections.emptyMap(), Json.parse("{}")); + assertEquals(Collections.emptyList(), Json.parse("[]")); + assertEquals(Collections.emptyMap(), Json.parse("{ \n }")); + assertEquals(Collections.emptyList(), Json.parse("[\t]")); + } + + @Test + void parsesNestedStructures() { + Object value = Json.parse( + "{\"a\": [1, {\"b\": null, \"c\": [true, false]}, \"s\"], \"d\": {\"e\": {}}, \"f\": [[]]}"); + + Map root = asMap(value); + assertEquals(Arrays.asList("a", "d", "f"), new ArrayList<>(root.keySet())); + List a = asList(root.get("a")); + assertEquals(3, a.size()); + assertEquals(1, a.get(0)); + Map inner = asMap(a.get(1)); + assertTrue(inner.containsKey("b")); + assertNull(inner.get("b")); + assertEquals(Arrays.asList(true, false), inner.get("c")); + assertEquals("s", a.get(2)); + assertEquals(Collections.emptyMap(), asMap(root.get("d")).get("e")); + assertEquals(Collections.singletonList(Collections.emptyList()), root.get("f")); + } + + @Test + void keepsObjectKeyOrder() { + Map map = asMap(Json.parse("{\"z\": 1, \"a\": 2, \"m\": 3}")); + assertEquals(Arrays.asList("z", "a", "m"), new ArrayList<>(map.keySet())); + } + + @Test + void repeatedKeyKeepsLastValueAtFirstPosition() { + Map map = asMap(Json.parse("{\"a\": 1, \"b\": 2, \"a\": 3}")); + assertEquals(3, map.get("a")); + assertEquals(Arrays.asList("a", "b"), new ArrayList<>(map.keySet())); + } + + @Test + void resultsAreUnmodifiable() { + Map map = asMap(Json.parse("{\"a\": [1], \"b\": {}}")); + assertThrows(UnsupportedOperationException.class, () -> map.put("c", 1)); + assertThrows(UnsupportedOperationException.class, () -> asList(map.get("a")).add(2)); + assertThrows(UnsupportedOperationException.class, () -> asMap(map.get("b")).put("x", 1)); + assertThrows(UnsupportedOperationException.class, () -> asMap(Json.parse("{}")).put("x", 1)); + assertThrows(UnsupportedOperationException.class, () -> asList(Json.parse("[]")).add(1)); + } + + @Test + void integersUseTheSmallestTypeThatFits() { + assertEquals(Integer.valueOf(0), Json.parse("0")); + assertEquals(Integer.valueOf(0), Json.parse("-0")); + assertEquals(Integer.valueOf(Integer.MAX_VALUE), Json.parse("2147483647")); + assertEquals(Integer.valueOf(Integer.MIN_VALUE), Json.parse("-2147483648")); + assertEquals(Long.valueOf(2147483648L), Json.parse("2147483648")); + assertEquals(Long.valueOf(-2147483649L), Json.parse("-2147483649")); + assertEquals(Long.valueOf(Long.MAX_VALUE), Json.parse("9223372036854775807")); + assertEquals(Long.valueOf(Long.MIN_VALUE), Json.parse("-9223372036854775808")); + assertEquals(new BigInteger("9223372036854775808"), Json.parse("9223372036854775808")); + assertEquals(new BigInteger("-9223372036854775809"), Json.parse("-9223372036854775809")); + assertEquals(new BigInteger("123456789012345678901234567890"), Json.parse("123456789012345678901234567890")); + } + + @Test + void fractionsAndExponentsAreBigDecimals() { + assertEquals(new BigDecimal("1.5"), Json.parse("1.5")); + assertEquals(new BigDecimal("-0.25"), Json.parse("-0.25")); + assertEquals(new BigDecimal("1e3"), Json.parse("1e3")); + assertEquals(new BigDecimal("3.5E+2"), Json.parse("3.5E+2")); + assertEquals(new BigDecimal("2e-2"), Json.parse("2e-2")); + assertInstanceOf(BigDecimal.class, Json.parse("1.0")); + assertInstanceOf(BigDecimal.class, Json.parse("-0.0")); + assertEquals(0, new BigDecimal("350").compareTo((BigDecimal) Json.parse("3.5E+2"))); + } + + @Test + void decodesEscapes() { + assertEquals("\"\\/\b\f\n\r\t", Json.parse("\"\\\"\\\\\\/\\b\\f\\n\\r\\t\"")); + assertEquals("\u00e9", Json.parse("\"\\u00e9\"")); + assertEquals("\u00e9", Json.parse("\"\\u00E9\"")); + assertEquals("\uD83D\uDE00", Json.parse("\"\\uD83D\\uDE00\"")); + assertEquals("a\u0000b", Json.parse("\"a\\u0000b\"")); + assertEquals("x\ny", Json.parse("\"x\\u000ay\"")); + } + + @Test + void passesThroughNonAsciiCharacters() { + assertEquals("caf\u00e9 \uD83D\uDE00 \u65e5\u672c", Json.parse("\"caf\u00e9 \uD83D\uDE00 \u65e5\u672c\"")); + } + + @Test + void skipsLeadingByteOrderMark() { + assertEquals(Collections.emptyMap(), Json.parse("\uFEFF{}")); + assertEquals(1, Json.parse("\uFEFF 1 ")); + } + + @Test + void byteOrderMarkIsOnlySkippedAtTheStart() { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse(" \uFEFF1")); + assertEquals(1, e.getOffset()); + } + + @Test + void skipsJsonWhitespace() { + assertEquals(Arrays.asList(1, 2), Json.parse(" \t\r\n[ 1 ,\n\t2\r\n]\n ")); + assertEquals(Collections.singletonMap("a", "b"), Json.parse("{\n\t\"a\"\r\n :\t\"b\" }")); + } + + @ParameterizedTest + @ValueSource(strings = {"\f1", "\u00a01", "1\u000b"}) + void rejectsNonJsonWhitespace(String text) { + assertThrows(Json.ParseException.class, () -> Json.parse(text)); + } + + @Test + void acceptsNestingOf512Levels() { + String text = "[".repeat(512) + "]".repeat(512); + Object value = Json.parse(text); + for (int i = 0; i < 511; i++) { + value = asList(value).get(0); + } + assertEquals(Collections.emptyList(), value); + } + + @Test + void rejectsNestingOf513Levels() { + Json.ParseException e = assertThrows( + Json.ParseException.class, () -> Json.parse("[".repeat(513) + "]".repeat(513))); + assertEquals(512, e.getOffset()); + assertTrue(e.getMessage().startsWith("Nesting deeper than 512 levels"), e.getMessage()); + } + + @Test + void depthCountsObjectsAndArraysTogether() { + StringBuilder ok = new StringBuilder(); + for (int i = 0; i < 256; i++) { + ok.append("{\"a\":["); + } + ok.append("]}".repeat(256)); + Json.parse(ok.toString()); + + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse("[" + ok + "]")); + assertTrue(e.getMessage().startsWith("Nesting deeper"), e.getMessage()); + } + + static Stream invalidDocuments() { + return Stream.of( + Arguments.of("trailing comma in object", "{\"a\":1,}", 7, 1, 8, "expected a string key"), + Arguments.of("trailing comma in array", "[1,]", 3, 1, 4, "expected a value"), + Arguments.of("leading zero", "01", 1, 1, 2, "Leading zeros"), + Arguments.of("negative leading zero", "-01", 2, 1, 3, "Leading zeros"), + Arguments.of("no digit after dot", "1.", 2, 1, 3, "after '.'"), + Arguments.of("no exponent digit", "1e", 2, 1, 3, "exponent"), + Arguments.of("no exponent digit after sign", "1e+", 3, 1, 4, "exponent"), + Arguments.of("lone minus", "-", 1, 1, 2, "expected a digit"), + Arguments.of("leading plus", "+1", 0, 1, 1, "expected a value"), + Arguments.of("leading dot", ".5", 0, 1, 1, "expected a value"), + Arguments.of("unterminated string", "\"abc", 4, 1, 5, "Unterminated string"), + Arguments.of("unterminated after backslash", "\"abc\\", 5, 1, 6, "Unterminated string"), + Arguments.of("raw tab in string", "\"a\tb\"", 2, 1, 3, "U+0009"), + Arguments.of("raw newline in string", "\"a\nb\"", 2, 1, 3, "U+000A"), + Arguments.of("invalid escape", "\"\\x\"", 2, 1, 3, "Invalid escape sequence '\\x'"), + Arguments.of("bad hex digit", "\"\\u12G4\"", 5, 1, 6, "Invalid hex digit character 'G'"), + Arguments.of("single quotes", "'a'", 0, 1, 1, "character '''"), + Arguments.of("NaN", "NaN", 0, 1, 1, "expected a value"), + Arguments.of("Infinity", "-Infinity", 1, 1, 2, "expected a digit"), + Arguments.of("truncated literal", "tru", 3, 1, 4, "expected 'true'"), + Arguments.of("misspelled literal", "nul1", 3, 1, 4, "expected 'null'"), + Arguments.of("trailing garbage", "{} x", 3, 1, 4, "after the JSON value"), + Arguments.of("two values", "1 2", 2, 1, 3, "after the JSON value"), + Arguments.of("empty input", "", 0, 1, 1, "end of input"), + Arguments.of("whitespace only", " \n ", 4, 2, 2, "end of input"), + Arguments.of("missing colon", "{\"a\" 1}", 5, 1, 6, "expected ':'"), + Arguments.of("unquoted key", "{a:1}", 1, 1, 2, "expected a string key"), + Arguments.of("missing comma in array", "[1 2]", 3, 1, 4, "expected ',' or ']'"), + Arguments.of("missing comma in object", "{\"a\":1 \"b\":2}", 7, 1, 8, "expected ',' or '}'"), + Arguments.of("unclosed array", "[1", 2, 1, 3, "end of input"), + Arguments.of("unclosed object", "{\"a\":1", 6, 1, 7, "end of input"), + Arguments.of("comment", "[1] // no", 4, 1, 5, "after the JSON value"), + Arguments.of("multi-line", "{\n \"a\": 1,\n \"b\": x\n}", 19, 3, 8, "character 'x'"), + Arguments.of("CRLF line ending", "[\r\n x]", 4, 2, 2, "character 'x'")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidDocuments") + void reportsWhereInvalidJsonFails( + String description, String text, int offset, int line, int column, String messagePart) { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse(text)); + assertEquals(offset, e.getOffset(), "offset"); + assertEquals(line, e.getLine(), "line"); + assertEquals(column, e.getColumn(), "column"); + assertTrue(e.getMessage().contains(messagePart), e.getMessage()); + assertTrue(e.getMessage().endsWith(" at line " + line + ", column " + column), e.getMessage()); + } + + @Test + void rejectsIncompleteUnicodeEscape() { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse("\"\\u12\"")); + assertTrue(e.getMessage().startsWith("Incomplete \\u escape"), e.getMessage()); + } + + @Test + void describesControlCharactersByCodePoint() { + Json.ParseException e = assertThrows(Json.ParseException.class, () -> Json.parse("[1\u0001]")); + assertTrue(e.getMessage().contains("character U+0001"), e.getMessage()); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return (Map) assertInstanceOf(Map.class, value); + } + + @SuppressWarnings("unchecked") + private static List asList(Object value) { + return (List) assertInstanceOf(List.class, value); + } +} diff --git a/gradle-api/src/test/java/org/nativescript/gradle/internal/ResolutionTest.java b/gradle-api/src/test/java/org/nativescript/gradle/internal/ResolutionTest.java new file mode 100644 index 0000000000..fa36c1520c --- /dev/null +++ b/gradle-api/src/test/java/org/nativescript/gradle/internal/ResolutionTest.java @@ -0,0 +1,733 @@ +package org.nativescript.gradle.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.gradle.api.GradleException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ResolutionTest { + @TempDir + Path tempDir; + + /** Real path of the temp dir, which on macOS sits behind the /var symlink. */ + private Path base; + + private Path project; + private Path platformsAndroid; + private Path tempPluginRoot; + + @BeforeEach + void setUp() throws IOException { + base = tempDir.toRealPath(); + project = base.resolve("proj"); + platformsAndroid = project.resolve("platforms/android"); + tempPluginRoot = project.resolve("platforms/tempPlugin/p"); + Files.createDirectories(platformsAndroid); + Files.createDirectories(tempPluginRoot); + } + + @Nested + class LegacyAppBuild { + @Test + void resolvesFromTemplateInputs() throws IOException { + write(platformsAndroid.resolve("dependencies.json"), "[" + + "{\"name\": \"@scope/a\", \"directory\": \"../../node_modules/@scope/a\", \"dependencies\": []}," + + "{\"name\": \"b\", \"directory\": \"../../node_modules/b\", \"dependencies\": [\"@scope/a\"]}" + + "]"); + FakeInputs inputs = appBuild().property("USER_PROJECT_ROOT", platformsAndroid + "/../.."); + + Resolution resolution = Resolution.resolve(inputs); + + assertNull(resolution.getFacts()); + assertFalse(resolution.isPluginBuild()); + assertNull(resolution.getPluginName()); + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("app").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("app/App_Resources").toFile(), resolution.getAppResourcesDir()); + assertTrue(resolution.getCapabilities().isEmpty()); + assertEquals(Arrays.asList("@scope/a", "b"), new ArrayList<>(resolution.getPlugins().keySet())); + + PluginData a = resolution.getPlugins().get("@scope/a"); + assertEquals("@scope/a", a.getName()); + assertEquals(project.resolve("node_modules/@scope/a").toFile(), a.getPackageDir()); + assertEquals(project.resolve("node_modules/@scope/a/platforms/android").toFile(), a.getNativeDir()); + assertNull(a.getAarName()); + assertNull(a.getAar()); + assertTrue(a.getOptions().isEmpty()); + assertEquals(Collections.emptyList(), a.getDependencies()); + assertEquals(Collections.singletonList("@scope/a"), resolution.getPlugins().get("b").getDependencies()); + } + + @Test + void defaultsProjectDirToTwoLevelsUp() { + assertEquals(project.toFile(), Resolution.resolve(appBuild()).getProjectDir()); + } + + @Test + void ignoresEnvironmentVariables() throws IOException { + Path elsewhere = Files.createDirectories(base.resolve("elsewhere")); + writeFacts(elsewhere.resolve(Resolution.FACTS_FILE_NAME), elsewhere, "[]"); + FakeInputs inputs = appBuild() + .env("USER_PROJECT_ROOT", elsewhere.toString()) + .env("USER_PROJECT_PLATFORMS_ANDROID", elsewhere.toString()); + + Resolution resolution = Resolution.resolve(inputs); + + assertNull(resolution.getFacts()); + assertEquals(project.toFile(), resolution.getProjectDir()); + } + + @Test + void ignoresPluginNameOutsidePluginBuilds() { + assertNull(Resolution.resolve(appBuild().property("PLUGIN_NAME", "x")).getPluginName()); + } + + @Test + void resolvesRelativeAppPathProperty() { + Resolution resolution = Resolution.resolve(appBuild().property("appPath", "src")); + + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("src/App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void keepsAbsoluteAppPathProperty() { + Path app = base.resolve("other/app"); + + Resolution resolution = Resolution.resolve(appBuild().property("appPath", app.toString())); + + assertEquals(app.toFile(), resolution.getAppDir()); + assertEquals(app.resolve("App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + // The Android template stores appPath in ext as a java.nio.file.Path, not a String. + @Test + void acceptsPathObjectsAsProperties() { + Path app = base.resolve("other/app"); + + Resolution resolution = Resolution.resolve( + appBuild().property("appPath", app).property("appResourcesPath", Path.of("res"))); + + assertEquals(app.toFile(), resolution.getAppDir()); + assertEquals(project.resolve("res").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void resolvesAppResourcesPathProperty() { + assertEquals( + project.resolve("res").toFile(), + Resolution.resolve(appBuild().property("appResourcesPath", "res")).getAppResourcesDir()); + Path absolute = base.resolve("abs/res"); + assertEquals( + absolute.toFile(), + Resolution.resolve(appBuild().property("appResourcesPath", absolute.toString())) + .getAppResourcesDir()); + } + + @Test + void readsPathsFromNsConfig() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"source\", \"appResourcesPath\": \"resources\"}"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(project.resolve("source").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void derivesAppResourcesFromNsConfigAppPath() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"source\"}"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(project.resolve("source").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("source/App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void propertiesWinOverNsConfig() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"source\", \"appResourcesPath\": \"resources\"}"); + + Resolution resolution = + Resolution.resolve(appBuild().property("appPath", "src").property("appResourcesPath", "res")); + + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("res").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void treatsEmptyPropertiesAndNsConfigValuesAsAbsent() throws IOException { + write(project.resolve("nsconfig.json"), "{\"appPath\": \"\", \"appResourcesPath\": 5}"); + + Resolution resolution = Resolution.resolve(appBuild().property("appPath", "")); + + assertEquals(project.resolve("app").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("app/App_Resources").toFile(), resolution.getAppResourcesDir()); + } + + @Test + void reportsInvalidNsConfig() throws IOException { + Path nsconfig = project.resolve("nsconfig.json"); + write(nsconfig, "{\"appPath\": "); + + GradleException e = assertThrows(GradleException.class, () -> Resolution.resolve(appBuild())); + assertTrue(e.getMessage().contains(nsconfig.toString()), e.getMessage()); + } + + @Test + void hasNoPluginsWithoutDependenciesJson() { + assertTrue(Resolution.resolve(appBuild()).getPlugins().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { + "[{\"name\": \"a\", ", + "{}", + "[1]", + "[{\"name\": \"a\"}]", + "[{\"name\": 1, \"directory\": \"x\"}]", + }) + void reportsInvalidDependenciesJson(String text) throws IOException { + Path file = platformsAndroid.resolve("dependencies.json"); + write(file, text); + + GradleException e = assertThrows(GradleException.class, () -> Resolution.resolve(appBuild())); + assertTrue(e.getMessage().startsWith("Invalid " + file), e.getMessage()); + } + } + + @Nested + class LegacyPluginBuild { + @Test + void resolvesFromProperties() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + FakeInputs inputs = pluginBuild() + .property("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString()) + .property("USER_PROJECT_ROOT", project.toString()); + + Resolution resolution = Resolution.resolve(inputs); + + assertNull(resolution.getFacts()); + assertTrue(resolution.isPluginBuild()); + assertEquals("@scope/p", resolution.getPluginName()); + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("app").toFile(), resolution.getAppDir()); + assertEquals(Collections.singleton("x"), resolution.getPlugins().keySet()); + assertEquals( + project.resolve("node_modules/x/platforms/android").toFile(), + resolution.getPlugins().get("x").getNativeDir()); + } + + @Test + void fallsBackToEnvironmentVariables() throws IOException { + Path otherProject = Files.createDirectories(base.resolve("other")); + Path otherAndroid = Files.createDirectories(base.resolve("elsewhere/android")); + writeDependencies(otherAndroid, "../modules/x"); + + Resolution resolution = Resolution.resolve(pluginBuild() + .env("USER_PROJECT_PLATFORMS_ANDROID", otherAndroid.toString()) + .env("USER_PROJECT_ROOT", otherProject.toString())); + + assertEquals(otherProject.toFile(), resolution.getProjectDir()); + assertEquals( + base.resolve("elsewhere/modules/x").toFile(), + resolution.getPlugins().get("x").getPackageDir()); + } + + @Test + void propertiesWinOverEnvironmentVariables() throws IOException { + Path otherProject = Files.createDirectories(base.resolve("other")); + Path otherAndroid = Files.createDirectories(base.resolve("elsewhere/android")); + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeDependencies(otherAndroid, "../modules/y"); + + Resolution resolution = Resolution.resolve(pluginBuild() + .property("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString()) + .property("USER_PROJECT_ROOT", project.toString()) + .env("USER_PROJECT_PLATFORMS_ANDROID", otherAndroid.toString()) + .env("USER_PROJECT_ROOT", otherProject.toString())); + + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(Collections.singleton("x"), resolution.getPlugins().keySet()); + } + + @Test + void defaultsToThreeLevelsUpAndItsPlatformsAndroid() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + + Resolution resolution = Resolution.resolve(pluginBuild()); + + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("node_modules/x").toFile(), resolution.getPlugins().get("x").getPackageDir()); + } + + @Test + void doesNotReadDependenciesJsonFromItsOwnRoot() throws IOException { + writeDependencies(tempPluginRoot, "../../node_modules/x"); + + assertTrue(Resolution.resolve(pluginBuild()).getPlugins().isEmpty()); + } + } + + @Nested + class FactsLookup { + @Test + void usesFactsWhoseDependenciesJsonHashMatches() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFactsMatchingDependencies(platformsAndroid, "\"from-facts\""); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertEquals(Collections.singleton("from-facts"), resolution.getCapabilities()); + } + + @Test + void ignoresFactsWhenDependenciesJsonChangedSince() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFactsMatchingDependencies(platformsAndroid, "\"from-facts\""); + writeDependencies(platformsAndroid, "../../node_modules/rewritten"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.getFacts()); + assertTrue(resolution.getCapabilities().isEmpty()); + assertEquals(Collections.singleton("rewritten"), resolution.getPlugins().keySet()); + } + + @Test + void ignoresFactsWhenDependenciesJsonIsMissing() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFactsMatchingDependencies(platformsAndroid, "\"from-facts\""); + Files.delete(platformsAndroid.resolve("dependencies.json")); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.getFacts()); + assertTrue(resolution.getPlugins().isEmpty()); + } + + @Test + void ignoresStaleExplicitFactsFileInsteadOfFailing() throws IOException { + Path dir = Files.createDirectories(base.resolve("explicit")); + writeDependencies(dir, "../project/node_modules/x"); + Path explicit = writeFactsMatchingDependencies(dir, "\"explicit\""); + writeDependencies(dir, "../project/node_modules/changed"); + + Resolution resolution = + Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, explicit.toString())); + + assertNull(resolution.getFacts()); + assertTrue(resolution.getCapabilities().isEmpty()); + } + + @Test + void trustsFactsWithoutDependenciesJsonHash() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]", "\"no-hash\""); + writeDependencies(platformsAndroid, "../../node_modules/rewritten"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertEquals(Collections.singleton("no-hash"), resolution.getCapabilities()); + } + + @Test + void explicitPropertyWins() throws IOException { + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]", "\"in-root\""); + Path explicit = base.resolve("explicit/facts.json"); + Files.createDirectories(explicit.getParent()); + writeFacts(explicit, project, "[]", "\"explicit\""); + + Resolution resolution = + Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, explicit.toString())); + + assertEquals(explicit.toFile(), resolution.getFacts().getFile()); + assertEquals(Collections.singleton("explicit"), resolution.getCapabilities()); + } + + @Test + void resolvesRelativePropertyAgainstRootDir() throws IOException { + Path facts = platformsAndroid.resolve("sub/facts.json"); + Files.createDirectories(facts.getParent()); + writeFacts(facts, project, "[]"); + + Resolution resolution = + Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, "sub/facts.json")); + + assertEquals(facts.toFile(), resolution.getFacts().getFile()); + } + + @Test + void reportsMissingExplicitFile() { + Path missing = base.resolve("missing.json"); + + GradleException e = assertThrows( + GradleException.class, + () -> Resolution.resolve(appBuild().property(Resolution.FACTS_FILE_PROPERTY, missing.toString()))); + assertTrue(e.getMessage().contains(missing.toString()), e.getMessage()); + } + + @Test + void findsFactsInRootDir() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(factsFile, project, "[" + plugin("@s/listed", "../../node_modules/@s/listed") + "]", "\"cap\""); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertEquals(factsFile.toFile(), resolution.getFacts().getFile()); + assertFalse(resolution.isPluginBuild()); + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + assertEquals(project.resolve("src/App_Resources").toFile(), resolution.getAppResourcesDir()); + assertEquals(Collections.singleton("cap"), resolution.getCapabilities()); + assertEquals(Collections.singleton("@s/listed"), resolution.getPlugins().keySet()); + assertEquals("s_listed", resolution.getPlugins().get("@s/listed").getAarName()); + } + + @Test + void factsTakePrecedenceOverDependenciesJson() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/legacy"); + writeFacts( + platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), + project, + "[" + plugin("from-facts", "../../node_modules/from-facts") + "]"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(Collections.singleton("from-facts"), resolution.getPlugins().keySet()); + } + + @Test + void factsTakePrecedenceOverTemplateProperties() throws IOException { + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + + Resolution resolution = Resolution.resolve(appBuild() + .property("USER_PROJECT_ROOT", base.toString()) + .property("appPath", "elsewhere")); + + assertEquals(project.toFile(), resolution.getProjectDir()); + assertEquals(project.resolve("src").toFile(), resolution.getAppDir()); + } + + @Test + void pluginBuildFindsFactsInUserProjectPlatformsAndroidProperty() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(factsFile, project, "[]", "\"cap\""); + + Resolution resolution = Resolution.resolve( + pluginBuild().property("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString())); + + assertEquals(factsFile.toFile(), resolution.getFacts().getFile()); + assertTrue(resolution.isPluginBuild()); + assertEquals("@scope/p", resolution.getPluginName()); + assertEquals(Collections.singleton("cap"), resolution.getCapabilities()); + } + + @Test + void pluginBuildFindsFactsInUserProjectPlatformsAndroidEnvironmentVariable() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(factsFile, project, "[]"); + + Resolution resolution = Resolution.resolve( + pluginBuild().env("USER_PROJECT_PLATFORMS_ANDROID", platformsAndroid.toString())); + + assertEquals(factsFile.toFile(), resolution.getFacts().getFile()); + } + + @Test + void pluginBuildWithoutFactsLocationFallsBackToLegacy() throws IOException { + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + + assertNull(Resolution.resolve(pluginBuild()).getFacts()); + } + + @Test + void reportsInvalidFacts() throws IOException { + Path factsFile = platformsAndroid.resolve(Resolution.FACTS_FILE_NAME); + write(factsFile, "{}"); + + GradleException e = assertThrows(GradleException.class, () -> Resolution.resolve(appBuild())); + assertTrue(e.getMessage().startsWith("Invalid NativeScript facts file " + factsFile), e.getMessage()); + } + } + + @Nested + class PackageLookup { + @Test + void findPluginReturnsListedPlugin() throws IOException { + writeDependencies(platformsAndroid, "../../node_modules/x"); + Resolution resolution = Resolution.resolve(appBuild()); + + assertSame(resolution.getPlugins().get("x"), resolution.findPlugin("x")); + } + + @Test + void findPluginFallsBackToInstalledPackageWithoutFacts() throws IOException { + Path pkg = installPackage(project, "@s/unlisted"); + Files.createDirectories(pkg.resolve("platforms/android")); + + PluginData plugin = Resolution.resolve(appBuild()).findPlugin("@s/unlisted"); + + assertNotNull(plugin); + assertEquals("@s/unlisted", plugin.getName()); + assertEquals(pkg.toFile(), plugin.getPackageDir()); + assertEquals(pkg.resolve("platforms/android").toFile(), plugin.getNativeDir()); + assertNull(plugin.getAarName()); + assertNull(plugin.getAar()); + assertTrue(plugin.getDependencies().isEmpty()); + assertTrue(plugin.getOptions().isEmpty()); + } + + @Test + void findPluginIgnoresInstalledPackageWithoutNativeDir() throws IOException { + installPackage(project, "js-only"); + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.findPlugin("js-only")); + assertNotNull(resolution.findPackage("js-only")); + } + + @Test + void findPluginFallsBackToInstalledPackageWithFacts() throws IOException { + Path pkg = installPackage(project, "@s/unlisted"); + Files.createDirectories(pkg.resolve("platforms/android")); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + Resolution resolution = Resolution.resolve(appBuild()); + + PluginData plugin = resolution.findPlugin("@s/unlisted"); + + assertNotNull(resolution.getFacts()); + assertNotNull(plugin); + assertEquals(pkg.toFile(), plugin.getPackageDir()); + assertEquals(pkg.resolve("platforms/android").toFile(), plugin.getNativeDir()); + assertNull(plugin.getAarName()); + assertNull(plugin.getAar()); + assertTrue(plugin.getOptions().isEmpty()); + } + + @Test + void findPluginWithFactsIgnoresInstalledPackageWithoutNativeDir() throws IOException { + installPackage(project, "js-only"); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), project, "[]"); + Resolution resolution = Resolution.resolve(appBuild()); + + assertNotNull(resolution.getFacts()); + assertNull(resolution.findPlugin("js-only")); + assertNull(resolution.findPlugin("not-installed")); + } + + @Test + void findPluginWithFactsPrefersListedDataOverInstalledPackage() throws IOException { + Path installed = installPackage(project, "@s/listed"); + Files.createDirectories(installed.resolve("platforms/android")); + writeFacts( + platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), + project, + "[" + plugin("@s/listed", "../../vendor/listed") + "]"); + + PluginData plugin = Resolution.resolve(appBuild()).findPlugin("@s/listed"); + + assertEquals(project.resolve("vendor/listed/platforms/android").toFile(), plugin.getNativeDir()); + assertEquals("s_listed", plugin.getAarName()); + } + + @Test + void findPluginWalksUpLikeNode() throws IOException { + Path pkg = installPackage(base, "hoisted"); + Files.createDirectories(pkg.resolve("platforms/android")); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(pkg.toFile(), resolution.findPlugin("hoisted").getPackageDir()); + assertEquals(pkg.toFile(), resolution.findPackage("hoisted")); + } + + @Test + void nearestInstallWins() throws IOException { + installPackage(base, "dup"); + Path near = installPackage(project, "dup"); + + assertEquals(near.toFile(), Resolution.resolve(appBuild()).findPackage("dup")); + } + + @Test + void directoryWithoutPackageJsonIsNotAPackage() throws IOException { + Files.createDirectories(project.resolve("node_modules/empty")); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.findPlugin("empty")); + assertNull(resolution.findPackage("empty")); + } + + @Test + void returnsNullWhenNotInstalled() { + Resolution resolution = Resolution.resolve(appBuild()); + + assertNull(resolution.findPlugin("nativescript-gradle-api-test-not-installed")); + assertNull(resolution.findPackage("@nativescript-gradle-api-test/not-installed")); + } + + @Test + void findPackageReturnsListedPackageDirOverNodeLookup() throws IOException { + installPackage(project, "@s/listed"); + writeFacts( + platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), + project, + "[" + plugin("@s/listed", "../../vendor/listed") + "]"); + + Resolution resolution = Resolution.resolve(appBuild()); + + assertEquals(project.resolve("vendor/listed").toFile(), resolution.findPackage("@s/listed")); + } + + @Test + void findPackageFindsScopedInstalledPackage() throws IOException { + Path pkg = installPackage(project, "@scope/name"); + + assertEquals(pkg.toFile(), Resolution.resolve(appBuild()).findPackage("@scope/name")); + } + + @Test + void lookupStartsAtFactsProjectRoot() throws IOException { + Path otherProject = Files.createDirectories(base.resolve("other")); + Path pkg = installPackage(otherProject, "elsewhere"); + writeFacts(platformsAndroid.resolve(Resolution.FACTS_FILE_NAME), otherProject, "[]"); + + assertEquals(pkg.toFile(), Resolution.resolve(appBuild()).findPackage("elsewhere")); + } + + @ParameterizedTest + @ValueSource(strings = {"", "../x", "@scope", "@scope/", "a/b", "@a/b/c", "a\\b", "..", ".", "@/x", "@s/..", "c:x"}) + void rejectsInvalidNames(String name) { + Resolution resolution = Resolution.resolve(appBuild()); + + assertThrows(IllegalArgumentException.class, () -> resolution.findPlugin(name)); + assertThrows(IllegalArgumentException.class, () -> resolution.findPackage(name)); + } + + @Test + void rejectsNullName() { + Resolution resolution = Resolution.resolve(appBuild()); + + assertThrows(IllegalArgumentException.class, () -> resolution.findPlugin(null)); + assertThrows(IllegalArgumentException.class, () -> resolution.findPackage(null)); + } + } + + private FakeInputs appBuild() { + return new FakeInputs(platformsAndroid.toFile()); + } + + private FakeInputs pluginBuild() { + return new FakeInputs(tempPluginRoot.toFile()).property("tempBuild", "true").property("PLUGIN_NAME", "@scope/p"); + } + + private static Path installPackage(Path dir, String name) throws IOException { + Path pkg = dir.resolve("node_modules").resolve(name); + Files.createDirectories(pkg); + write(pkg.resolve("package.json"), "{\"name\": \"" + name + "\"}"); + return pkg; + } + + private static void writeDependencies(Path dir, String directory) throws IOException { + String name = directory.substring(directory.lastIndexOf('/') + 1); + write(dir.resolve("dependencies.json"), + "[{\"name\": \"" + name + "\", \"directory\": \"" + directory + "\", \"dependencies\": []}]"); + } + + /** Writes facts whose project root is {@code root}, given relative to the file's directory. */ + private static void writeFacts(Path file, Path root, String plugins, String... capabilities) throws IOException { + String rootPath = file.getParent().relativize(root).toString().replace('\\', '/'); + if (rootPath.isEmpty()) { + rootPath = "."; + } + write(file, "{\"schemaVersion\": 1, \"capabilities\": [" + String.join(", ", capabilities) + "]," + + " \"project\": {\"root\": \"" + rootPath + "\", \"appDir\": \"" + rootPath + "/src\"," + + " \"appResourcesDir\": \"" + rootPath + "/src/App_Resources\"}," + + " \"plugins\": " + plugins + "}"); + } + + /** + * Writes facts next to {@code dir}'s current dependencies.json, recording its hash as the CLI + * does, with the project root at {@code project}. + */ + private Path writeFactsMatchingDependencies(Path dir, String capability) throws IOException { + Path file = dir.resolve(Resolution.FACTS_FILE_NAME); + writeFacts(file, project, "[]", capability); + String sha256 = Facts.sha256(Files.readAllBytes(dir.resolve("dependencies.json"))); + String text = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + write(file, text.replaceFirst("\\{", "{\"dependenciesJson\": {\"sha256\": \"" + sha256 + "\"}, ")); + return file; + } + + private static String plugin(String name, String packageDir) { + String aarName = name.replace("@", "").replace('/', '_'); + return "{\"name\": \"" + name + "\", \"packageDir\": \"" + packageDir + "\"," + + " \"nativeDir\": \"" + packageDir + "/platforms/android\", \"aarName\": \"" + aarName + "\"," + + " \"aar\": \"" + packageDir + "/platforms/android/" + aarName + ".aar\", \"dependencies\": []}"; + } + + private static void write(Path file, String text) throws IOException { + Files.write(file, text.getBytes(StandardCharsets.UTF_8)); + } + + private static final class FakeInputs implements BuildInputs { + private final Map properties = new HashMap<>(); + private final Map environment = new HashMap<>(); + private final File rootDir; + + FakeInputs(File rootDir) { + this.rootDir = rootDir; + } + + FakeInputs property(String name, Object value) { + properties.put(name, value); + return this; + } + + FakeInputs env(String name, String value) { + environment.put(name, value); + return this; + } + + @Override + public Object property(String name) { + return properties.get(name); + } + + @Override + public String environmentVariable(String name) { + return environment.get(name); + } + + @Override + public File rootDir() { + return rootDir; + } + } +} diff --git a/gradle-api/src/test/resources/facts/v1-basic.json b/gradle-api/src/test/resources/facts/v1-basic.json new file mode 100644 index 0000000000..6d6cf52acf --- /dev/null +++ b/gradle-api/src/test/resources/facts/v1-basic.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "cli": { "version": "9.2.0" }, + "runtime": { "name": "@nativescript/android", "version": "9.0.0" }, + "dependenciesJson": { "sha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" }, + "capabilities": [], + "project": { + "root": "../..", + "appDir": "../../src", + "appResourcesDir": "../../App_Resources" + }, + "plugins": [ + { + "name": "@nativescript/core", + "packageDir": "../../node_modules/@nativescript/core", + "nativeDir": "../../node_modules/@nativescript/core/platforms/android", + "aarName": "core", + "aar": "../../node_modules/@nativescript/core/platforms/android/core.aar", + "dependencies": [], + "options": {} + }, + { + "name": "@foo/plugin-x", + "packageDir": "../../node_modules/@foo/plugin-x", + "nativeDir": ".ns-plugins/foo_plugin_x/platforms/android", + "aarName": "foo_plugin_x", + "aar": ".ns-plugins/foo_plugin_x/platforms/android/foo_plugin_x.aar", + "dependencies": ["@nativescript/core"], + "options": {} + }, + { + "name": "@bar/plugin-x", + "packageDir": "../../node_modules/@bar/plugin-x", + "nativeDir": "../../node_modules/@bar/plugin-x/platforms/android", + "aarName": "bar_plugin_x", + "aar": "../../node_modules/@bar/plugin-x/platforms/android/bar_plugin_x.aar", + "dependencies": [], + "options": {} + } + ] +} diff --git a/gradle-api/src/test/resources/facts/v2-unknown-fields.json b/gradle-api/src/test/resources/facts/v2-unknown-fields.json new file mode 100644 index 0000000000..aaa5c9297f --- /dev/null +++ b/gradle-api/src/test/resources/facts/v2-unknown-fields.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 2, + "dependenciesJson": { "sha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" }, + "capabilities": ["some.future.capability"], + "someFutureSection": { "nested": [1, 2, { "deep": true }], "flag": null }, + "project": { + "root": "../..", + "appDir": "../../app", + "appResourcesDir": "../../app/App_Resources", + "futureField": "ignored" + }, + "plugins": [ + { + "name": "nativescript-plugin", + "packageDir": "../../node_modules/nativescript-plugin", + "nativeDir": "../../node_modules/nativescript-plugin/platforms/android", + "aarName": "nativescript_plugin", + "aar": "../../node_modules/nativescript-plugin/platforms/android/nativescript_plugin.aar", + "dependencies": [], + "options": { "someOption": ["a", "b"] }, + "futureField": 3.5e2 + } + ] +} diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 154a325910..9ce26ff518 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -48,6 +48,10 @@ injector.require( "gradleBuildArgsService", "./services/android/gradle-build-args-service", ); +injector.require( + "androidPluginMirrorService", + "./services/android/android-plugin-mirror-service", +); injector.require( "androidBundleToolService", "./services/android/android-bundle-tool-service", diff --git a/lib/common/helpers.ts b/lib/common/helpers.ts index 9ee0886f8f..f10e720172 100644 --- a/lib/common/helpers.ts +++ b/lib/common/helpers.ts @@ -125,6 +125,71 @@ function sanitizePluginName(pluginName: string): string { return pluginName.split("/").pop(); } +function getScopeQualifiedPluginName(pluginName: string): string | null { + const parts = pluginName.split("/"); + if (parts.length < 2 || !parts[0].startsWith("@")) { + return null; + } + + const scope = parts[0].slice(1).replace(/[^A-Za-z0-9_]/g, "_"); + return `${scope}_${getShortPluginName(pluginName)}`; +} + +// The aar name doubles as a Java package segment (the `org.nativescript.` +// namespace fallback), which cannot start with a digit. +function withoutLeadingDigit(name: string): string { + return /^[0-9]/.test(name) ? `_${name}` : name; +} + +/** + * Maps each plugin name to a file-system and Java-package safe name for its aar + * that is unique among `pluginNames`. A plugin whose short name is not shared + * keeps `getShortPluginName(name)`, so projects without clashes keep their names. + * Throws if two plugins still end up with the same name. + */ +export function getUniqueShortPluginNames( + pluginNames: string[], +): Map { + const sortedNames = _.sortBy(_.uniq(pluginNames)); + const namesByShortName = _.groupBy(sortedNames, getShortPluginName); + const result = new Map(); + const owners = new Map(); + + for (const pluginName of sortedNames) { + const shortName = getShortPluginName(pluginName); + const aarName = + namesByShortName[shortName].length === 1 + ? shortName + : withoutLeadingDigit( + getScopeQualifiedPluginName(pluginName) ?? shortName, + ); + + const owner = owners.get(aarName); + if (owner !== undefined) { + throw new Error( + `The plugins "${owner}" and "${pluginName}" both need the Android aar name "${aarName}". Remove one of them from the project.`, + ); + } + + owners.set(aarName, pluginName); + result.set(pluginName, aarName); + } + + return result; +} + +/** + * Every aar name `getUniqueShortPluginNames` can give this plugin, depending on + * which other plugins are installed. + */ +export function getCandidateAarNames(pluginName: string): string[] { + const shortName = getShortPluginName(pluginName); + return _.uniq([ + shortName, + withoutLeadingDigit(getScopeQualifiedPluginName(pluginName) ?? shortName), + ]); +} + export async function executeActionByChunks( initialData: T[] | IDictionary, chunkSize: number, diff --git a/lib/common/test/unit-tests/helpers.ts b/lib/common/test/unit-tests/helpers.ts index 39db1dbdf2..ee5aa542f3 100644 --- a/lib/common/test/unit-tests/helpers.ts +++ b/lib/common/test/unit-tests/helpers.ts @@ -1111,4 +1111,129 @@ const test = require("./test");`, assert.deepStrictEqual(seen.sink, ["kept"]); }); }); + + describe("getUniqueShortPluginNames", () => { + const toObject = (names: string[]) => + Object.fromEntries(helpers.getUniqueShortPluginNames(names)); + + it("keeps the short name of plugins without a clash", () => { + assert.deepStrictEqual( + toObject([ + "@nativescript/core", + "nativescript-plugin", + "@scope/my-plugin", + "9-lives", + ]), + { + "@nativescript/core": "core", + "nativescript-plugin": "nativescript_plugin", + "@scope/my-plugin": "my_plugin", + "9-lives": "9_lives", + }, + ); + }); + + it("qualifies clashing scoped plugins with their scope", () => { + assert.deepStrictEqual( + toObject(["@foo/plugin-x", "@bar/plugin-x", "@nativescript/core"]), + { + "@foo/plugin-x": "foo_plugin_x", + "@bar/plugin-x": "bar_plugin_x", + "@nativescript/core": "core", + }, + ); + }); + + it("lets an unscoped plugin keep its name when it clashes with a scoped one", () => { + assert.deepStrictEqual(toObject(["plugin-x", "@foo/plugin-x"]), { + "plugin-x": "plugin_x", + "@foo/plugin-x": "foo_plugin_x", + }); + }); + + it("replaces characters a scope may contain but a name may not", () => { + assert.deepStrictEqual(toObject(["@my.org~x/plugin", "@a/plugin"]), { + "@my.org~x/plugin": "my_org_x_plugin", + "@a/plugin": "a_plugin", + }); + }); + + it("prefixes clashing names that would start with a digit", () => { + assert.deepStrictEqual(toObject(["@1password/plugin", "@a/plugin"]), { + "@1password/plugin": "_1password_plugin", + "@a/plugin": "a_plugin", + }); + assert.deepStrictEqual(toObject(["9-lives", "@a/9-lives"]), { + "9-lives": "_9_lives", + "@a/9-lives": "a_9_lives", + }); + }); + + it("fails when two plugins still share a name", () => { + assert.throws( + () => + helpers.getUniqueShortPluginNames(["@foo/plugin-x", "@foo/plugin_x"]), + /"@foo\/plugin-x" and "@foo\/plugin_x"/, + ); + assert.throws( + () => + helpers.getUniqueShortPluginNames([ + "foo-plugin-x", + "@foo/plugin-x", + "@bar/plugin-x", + ]), + /"@foo\/plugin-x" and "foo-plugin-x".*"foo_plugin_x"/, + ); + }); + + it("does not depend on the order of the plugins", () => { + const names = ["@foo/plugin-x", "plugin-x", "@bar/plugin-x", "other"]; + const expected = toObject(names); + assert.deepStrictEqual(toObject([...names].reverse()), expected); + assert.deepStrictEqual( + toObject([names[2], names[0], names[3], names[1]]), + expected, + ); + assert.throws( + () => + helpers.getUniqueShortPluginNames(["@foo/plugin_x", "@foo/plugin-x"]), + /"@foo\/plugin-x" and "@foo\/plugin_x"/, + ); + }); + }); + + describe("getCandidateAarNames", () => { + it("lists the short and the scope-qualified name of a scoped plugin", () => { + assert.deepStrictEqual(helpers.getCandidateAarNames("@foo/plugin-x"), [ + "plugin_x", + "foo_plugin_x", + ]); + }); + + it("lists the short name of an unscoped plugin", () => { + assert.deepStrictEqual(helpers.getCandidateAarNames("plugin-x"), [ + "plugin_x", + ]); + assert.deepStrictEqual(helpers.getCandidateAarNames("9-lives"), [ + "9_lives", + "_9_lives", + ]); + }); + + it("covers every name getUniqueShortPluginNames gives", () => { + const projects = [ + ["@foo/plugin-x"], + ["@foo/plugin-x", "@bar/plugin-x"], + ["plugin-x", "@foo/plugin-x"], + ["@1a/plugin", "@b/plugin", "plugin", "9-lives", "@c/9-lives"], + ]; + for (const project of projects) { + for (const [name, aarName] of helpers.getUniqueShortPluginNames( + project, + )) { + assert.include(helpers.getCandidateAarNames(name), aarName); + } + } + }); + }); }); diff --git a/lib/constants.ts b/lib/constants.ts index 7420893fab..6a595c714c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -73,6 +73,8 @@ export const CONFIG_FILE_NAME_DISPLAY = "nativescript.config.(js|ts)"; export const CONFIG_FILE_NAME_JS = "nativescript.config.js"; export const CONFIG_FILE_NAME_TS = "nativescript.config.ts"; export const DEPENDENCIES_JSON_NAME = "dependencies.json"; +export const GRADLE_FACTS_FILE_NAME = "nativescript-gradle.json"; +export const ANDROID_PLUGIN_MIRRORS_DIR_NAME = ".ns-plugins"; export const APK_EXTENSION_NAME = ".apk"; export const AAB_EXTENSION_NAME = ".aab"; export const APKS_EXTENSION_NAME = ".apks"; diff --git a/lib/definitions/android-plugin-migrator.d.ts b/lib/definitions/android-plugin-migrator.d.ts index f5ad873307..084db0e617 100644 --- a/lib/definitions/android-plugin-migrator.d.ts +++ b/lib/definitions/android-plugin-migrator.d.ts @@ -3,6 +3,20 @@ import { IProjectDir } from "../common/declarations"; interface IPluginBuildOptions extends IAndroidBuildOptions { projectDir?: string; + /** + * File name (without extension) of the built aar and of its temp build dir. + * Defaults to the plugin's short name. + */ + aarName?: string; + /** + * The project keeps node_modules read-only, so `aarOutputDir` is a CLI-owned + * copy of the plugin's platforms/android. + */ + readOnlyNodeModules?: boolean; + /** + * The project's nativescript-gradle.json, passed to the plugin's Gradle build when it exists. + */ + gradleFactsFile?: string; } interface IAndroidBuildOptions { @@ -17,6 +31,33 @@ interface IAndroidBuildOptions { interface IAndroidPluginBuildService { buildAar(options: IPluginBuildOptions): Promise; migrateIncludeGradle(options: IPluginBuildOptions): boolean; + /** + * Whether the plugin has sources to build but `aarOutputDir` lacks its aar + * or still holds one under another of its candidate names. + */ + hasOutdatedAar(options: IPluginBuildOptions): boolean; +} + +interface IAndroidPluginMirror { + pluginName: string; + /** + * The installed npm package. + */ + packageDir: string; + mirrorDir: string; + /** + * Paths relative to the package's platforms/android that are not copied. + */ + excludedNativeFiles?: string[]; +} + +interface IAndroidPluginMirrorService { + /** + * Brings `/` in line with each plugin's package.json and + * platforms/android, and removes mirrors of plugins not in `mirrors`. + * @returns The names of the plugins whose mirror changed. + */ + syncMirrors(mirrorsRoot: string, mirrors: IAndroidPluginMirror[]): string[]; } /** @@ -49,4 +90,14 @@ interface IBuildAndroidPluginData extends Partial { * Optional custom Gradle arguments. */ gradleArgs?: string; + + /** + * The project keeps node_modules read-only (`android.readOnlyNodeModules`). + */ + readOnlyNodeModules?: boolean; + + /** + * Absolute path of the project's nativescript-gradle.json, when it exists. + */ + gradleFactsFile?: string; } diff --git a/lib/definitions/gradle-facts.d.ts b/lib/definitions/gradle-facts.d.ts new file mode 100644 index 0000000000..d6f7f99670 --- /dev/null +++ b/lib/definitions/gradle-facts.d.ts @@ -0,0 +1,35 @@ +/** + * Contents of platforms/android/nativescript-gradle.json, read by + * org.nativescript:gradle-api. Mirrors gradle-api/schema/nativescript-gradle.schema.json: + * fields are only ever added, and every path is relative to the file's + * directory with forward slashes. + */ +export interface INativeScriptGradleFacts { + schemaVersion: number; + cli?: { version?: string }; + runtime?: { name?: string; version?: string }; + capabilities?: string[]; + /** + * Identifies the dependencies.json written together with these facts, so a + * reader can tell when a CLI that does not write facts has replaced it since. + */ + dependenciesJson: { sha256: string }; + project: INativeScriptGradleFactsProject; + plugins: INativeScriptGradleFactsPlugin[]; +} + +export interface INativeScriptGradleFactsProject { + root: string; + appDir: string; + appResourcesDir: string; +} + +export interface INativeScriptGradleFactsPlugin { + name: string; + packageDir: string; + nativeDir: string; + aarName: string; + aar: string; + dependencies: string[]; + options?: Record; +} diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..559c5bdce6 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -179,6 +179,12 @@ interface INsConfigAndroid extends INsConfigPlaform { * Custom runtime package name */ runtimePackageName?: string; + + /** + * Never write into node_modules: each plugin's Android files are copied to + * `platforms/android/.ns-plugins/` and its aar is built there. + */ + readOnlyNodeModules?: boolean; } interface INsConfigHooks { diff --git a/lib/services/android-plugin-build-service.ts b/lib/services/android-plugin-build-service.ts index 88098d55b0..19a25ad082 100644 --- a/lib/services/android-plugin-build-service.ts +++ b/lib/services/android-plugin-build-service.ts @@ -8,7 +8,12 @@ import { PLUGIN_BUILD_DATA_FILENAME, SCOPED_ANDROID_RUNTIME_NAME, } from "../constants"; -import { getShortPluginName, hook, quoteString } from "../common/helpers"; +import { + getCandidateAarNames, + getShortPluginName, + hook, + quoteString, +} from "../common/helpers"; import { Builder, parseString } from "xml2js"; import { IRuntimeGradleVersions, @@ -226,19 +231,27 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { const androidSourceDirectories = this.getAndroidSourceDirectories( options.platformsAndroidDirPath, ); - const shortPluginName = getShortPluginName(options.pluginName); - const pluginTempDir = path.join(options.tempPluginDirPath, shortPluginName); + const aarName = this.getAarName(options); + const pluginTempDir = path.join(options.tempPluginDirPath, aarName); const pluginSourceFileHashesInfo = await this.getSourceFilesHashes( options.platformsAndroidDirPath, - shortPluginName, + options.pluginName, + aarName, ); + if ( + options.aarName && + options.aarOutputDir && + (manifestFilePath || androidSourceDirectories.length) + ) { + this.removeStaleAars(options, aarName); + } + const shouldBuildAar = await this.shouldBuildAar({ manifestFilePath, androidSourceDirectories, pluginTempDir, - pluginSourceDir: options.platformsAndroidDirPath, - shortPluginName, + aarPath: path.join(this.getAarOutputDir(options), `${aarName}.aar`), fileHashesInfo: pluginSourceFileHashesInfo, }); @@ -249,7 +262,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { await this.updateManifest( manifestFilePath, pluginTempMainSrcDir, - shortPluginName, + aarName, ); this.copySourceSetDirectories( androidSourceDirectories, @@ -260,24 +273,112 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { options.platformsAndroidDirPath, options.projectDir, options.pluginName, + aarName, ); - await this.buildPlugin({ + const pluginBuildData: IBuildAndroidPluginData = { gradlePath: options.gradlePath, gradleArgs: options.gradleArgs, pluginDir: pluginTempDir, pluginName: options.pluginName, projectDir: options.projectDir, - }); - this.$watchIgnoreListService.addFileToIgnoreList( - path.join(options.aarOutputDir, `${shortPluginName}.aar`), - ); - this.copyAar(shortPluginName, pluginTempDir, options.aarOutputDir); + }; + if (options.readOnlyNodeModules) { + pluginBuildData.readOnlyNodeModules = true; + } + if (options.gradleFactsFile && this.$fs.exists(options.gradleFactsFile)) { + pluginBuildData.gradleFactsFile = path.resolve(options.gradleFactsFile); + } + await this.buildPlugin(pluginBuildData); + if (!options.readOnlyNodeModules) { + this.$watchIgnoreListService.addFileToIgnoreList( + path.join(options.aarOutputDir, `${aarName}.aar`), + ); + } + this.copyAar(aarName, pluginTempDir, options.aarOutputDir); this.writePluginHashInfo(pluginSourceFileHashesInfo, pluginTempDir); } return shouldBuildAar; } + public hasOutdatedAar(options: IPluginBuildOptions): boolean { + const aarOutputDir = this.getAarOutputDir(options); + if ( + !this.$fs.exists(options.platformsAndroidDirPath) || + (!this.getManifest(options.platformsAndroidDirPath) && + !this.getAndroidSourceDirectories(options.platformsAndroidDirPath) + .length) + ) { + return false; + } + + const aarName = this.getAarName(options); + return ( + !this.$fs.exists(path.join(aarOutputDir, `${aarName}.aar`)) || + this.getStaleAarPaths(options, aarName).length > 0 + ); + } + + private getAarName(options: IPluginBuildOptions): string { + return options.aarName ?? getShortPluginName(options.pluginName); + } + + private getAarOutputDir(options: IPluginBuildOptions): string { + return options.aarOutputDir || options.platformsAndroidDirPath; + } + + private getStaleAarPaths( + options: IPluginBuildOptions, + aarName: string, + ): string[] { + const aarOutputDir = this.getAarOutputDir(options); + return getCandidateAarNames(options.pluginName) + .filter((candidate) => candidate !== aarName) + .map((candidate) => path.join(aarOutputDir, `${candidate}.aar`)) + .filter((candidatePath) => this.$fs.exists(candidatePath)); + } + + /** + * Gradle templates add every aar in a plugin's platforms/android to the app, + * so an aar the CLI built for this plugin under another candidate name would + * duplicate its classes. + */ + private removeStaleAars(options: IPluginBuildOptions, aarName: string): void { + for (const staleAarPath of this.getStaleAarPaths(options, aarName)) { + if (!options.readOnlyNodeModules) { + const staleName = path.basename(staleAarPath, ".aar"); + const previousOutput = path.join( + options.tempPluginDirPath, + staleName, + "build", + "outputs", + "aar", + `${staleName}-release.aar`, + ); + if (!this.isSameFileContent(staleAarPath, previousOutput)) { + this.$errors.fail( + `${staleAarPath} looks like an aar built for ${options.pluginName} under an older name and would add duplicate classes to the app. Delete it or run \`ns clean\`.`, + ); + } + + this.$watchIgnoreListService.addFileToIgnoreList(staleAarPath); + } + + this.$logger.trace(`Removing stale plugin aar ${staleAarPath}`); + this.$fs.deleteFile(staleAarPath); + } + } + + private isSameFileContent(first: string, second: string): boolean { + if (!this.$fs.exists(first) || !this.$fs.exists(second)) { + return false; + } + + const firstContent = this.$fs.readFile(first) as Buffer; + const secondContent = this.$fs.readFile(second) as Buffer; + return firstContent.equals(secondContent); + } + private cleanPluginDir(pluginTempDir: string): void { // In case plugin was already built in the current process, we need to clean the old sources as they may break the new build. this.$fs.deleteDirectory(pluginTempDir); @@ -286,15 +387,17 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { private getSourceFilesHashes( pluginTempPlatformsAndroidDir: string, - shortPluginName: string, + pluginName: string, + aarName: string, ): Promise { - const pathToAar = path.join( - pluginTempPlatformsAndroidDir, - `${shortPluginName}.aar`, + const aarPaths = new Set( + _.uniq([aarName, ...getCandidateAarNames(pluginName)]).map((name) => + path.join(pluginTempPlatformsAndroidDir, `${name}.aar`), + ), ); const pluginNativeDataFiles = this.$fs.enumerateFilesInDirectorySync( pluginTempPlatformsAndroidDir, - (file: string, stat: IFsStats) => file !== pathToAar, + (file: string, stat: IFsStats) => !aarPaths.has(file), ); return this.$filesHashService.generateHashes(pluginNativeDataFiles); } @@ -311,8 +414,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { manifestFilePath: string; androidSourceDirectories: string[]; pluginTempDir: string; - pluginSourceDir: string; - shortPluginName: string; + aarPath: string; fileHashesInfo: IStringDictionary; }): Promise { let shouldBuildAar = @@ -321,9 +423,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { if ( shouldBuildAar && this.$fs.exists(opts.pluginTempDir) && - this.$fs.exists( - path.join(opts.pluginSourceDir, `${opts.shortPluginName}.aar`), - ) + this.$fs.exists(opts.aarPath) ) { const buildDataFile = this.getPathToPluginBuildDataFile( opts.pluginTempDir, @@ -401,6 +501,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { platformsAndroidDirPath: string, projectDir: string, pluginName: string, + aarName: string, ): Promise { const gradleTemplatePath = path.resolve( path.join(__dirname, "../../vendor/gradle-plugin"), @@ -424,9 +525,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { this.replaceFileContent(buildGradlePath, "{{pluginName}}", pluginName); this.replaceFileContent(settingsGradlePath, "{{pluginName}}", pluginName); - // gets the package from the AndroidManifest to use as the namespace or fallback to the `org.nativescript.${shortPluginName}` - const shortPluginName = getShortPluginName(pluginName); - + // gets the package from the AndroidManifest to use as the namespace or fallback to the `org.nativescript.${aarName}` const manifestPath = path.join( pluginTempDir, "src", @@ -435,7 +534,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { ); const manifestContent = this.$fs.readText(manifestPath); - let packageName = `org.nativescript.${shortPluginName}`; + let packageName = `org.nativescript.${aarName}`; const xml = await this.getXml(manifestContent); if (xml["manifest"]) { if (xml["manifest"]["$"]["package"]) { @@ -817,6 +916,16 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { `-PappResourcesPath=${this.$projectData.getAppResourcesDirectoryPath()}`, ]; + if (pluginBuildSettings.gradleFactsFile) { + localArgs.push( + `-Pnativescript.factsFile=${pluginBuildSettings.gradleFactsFile}`, + ); + } + + if (pluginBuildSettings.readOnlyNodeModules) { + localArgs.push("-Pnativescript.readOnlyNodeModules=true"); + } + if (pluginBuildSettings.gradleArgs) { localArgs.push(pluginBuildSettings.gradleArgs); } diff --git a/lib/services/android-project-service.ts b/lib/services/android-project-service.ts index d4ba573a08..99f05725c9 100644 --- a/lib/services/android-project-service.ts +++ b/lib/services/android-project-service.ts @@ -6,7 +6,11 @@ import * as semver from "semver"; import * as projectServiceBaseLib from "./platform-project-service-base"; import { DeviceAndroidDebugBridge } from "../common/mobile/android/device-android-debug-bridge"; import { Configurations, LiveSyncPaths } from "../common/constants"; -import { hook } from "../common/helpers"; +import { + getCandidateAarNames, + getUniqueShortPluginNames, + hook, +} from "../common/helpers"; import { performanceLog } from ".././common/decorators"; import { IProjectData, @@ -37,8 +41,15 @@ import { } from "../common/declarations"; import { IAndroidPluginBuildService, + IAndroidPluginMirrorService, IPluginBuildOptions, } from "../definitions/android-plugin-migrator"; +import { INodeModulesDependenciesBuilder } from "../definitions/platform"; +import { + createGradleFacts, + getGradleFactsFilePath, + serializeGradleFacts, +} from "./android/gradle-facts"; import { IFilesHashService } from "../definitions/files-hash-service"; import { IGradleCommandService, @@ -54,6 +65,30 @@ interface NativeDependency { dependencies: string[]; } +interface IAndroidPluginLocation { + aarName: string; + packageDir: string; + /** + * Where Gradle reads the plugin's Android files and where its aar is built to. + */ + nativeDir: string; + /** + * Set when node_modules is read-only. + */ + mirrorDir?: string; +} + +interface IAndroidPluginLayout { + key: string; + platformDir: string; + readOnlyNodeModules: boolean; + plugins: Map; + /** + * Plugins whose mirror changed since they were last prepared. + */ + changedMirrors: Set; +} + // // we sort the native dependencies topologically to make sure they are processed in the right order // native dependenciess need to be sorted so the deepst dependencies are built before it's parents @@ -155,10 +190,14 @@ export class AndroidProjectService private $gradleCommandService: IGradleCommandService, private $gradleBuildService: IGradleBuildService, private $analyticsService: IAnalyticsService, + private $androidPluginMirrorService: IAndroidPluginMirrorService, + private $staticConfig: Config.IStaticConfig, ) { super($fs, $projectDataService); } + private pluginLayout: IAndroidPluginLayout = null; + private _platformData: IPlatformData = null; public getPlatformData(projectData: IProjectData): IPlatformData { if (!projectData && !this._platformData) { @@ -694,23 +733,210 @@ export class AndroidProjectService pluginData, AndroidProjectService.ANDROID_PLATFORM_NAME, ); - if (this.$fs.exists(pluginPlatformsFolderPath)) { - const options: IPluginBuildOptions = { - gradlePath: this.$options.gradlePath, - gradleArgs: this.$options.gradleArgs, - projectDir: projectData.projectDir, - pluginName: pluginData.name, - platformsAndroidDirPath: pluginPlatformsFolderPath, - aarOutputDir: pluginPlatformsFolderPath, - tempPluginDirPath: path.join(projectData.platformsDir, "tempPlugin"), - }; + if (!this.$fs.exists(pluginPlatformsFolderPath)) { + return; + } - if (await this.$androidPluginBuildService.buildAar(options)) { - this.$logger.info(`Built aar for ${options.pluginName}`); - } + const layout = this.getPluginLayout(projectData); + const plugin = layout.plugins.get(pluginData.name); + if (!plugin && layout.readOnlyNodeModules) { + // Gradle only reads the plugins listed in dependencies.json, and with + // read-only node_modules there is nowhere else to build this one to. + this.$logger.trace( + `Skipping ${pluginData.name}: it does not declare Android support in its package.json.`, + ); + return; + } + + layout.changedMirrors.delete(pluginData.name); + const options = this.getPluginBuildOptions( + pluginData, + pluginPlatformsFolderPath, + projectData, + layout, + ); + + if (await this.$androidPluginBuildService.buildAar(options)) { + this.$logger.info(`Built aar for ${options.pluginName}`); + } + + this.$androidPluginBuildService.migrateIncludeGradle({ + ...options, + platformsAndroidDirPath: options.aarOutputDir, + }); + } + + public shouldRepreparePlugin( + pluginData: IPluginData, + projectData: IProjectData, + ): boolean { + const pluginPlatformsFolderPath = this.getPluginPlatformsFolderPath( + pluginData, + AndroidProjectService.ANDROID_PLATFORM_NAME, + ); + const layout = this.getPluginLayout(projectData); + if (!layout.plugins.has(pluginData.name)) { + return false; + } + + return ( + layout.changedMirrors.has(pluginData.name) || + this.$androidPluginBuildService.hasOutdatedAar( + this.getPluginBuildOptions( + pluginData, + pluginPlatformsFolderPath, + projectData, + layout, + ), + ) + ); + } + + private getPluginBuildOptions( + pluginData: IPluginData, + pluginPlatformsFolderPath: string, + projectData: IProjectData, + layout: IAndroidPluginLayout, + ): IPluginBuildOptions { + const plugin = layout.plugins.get(pluginData.name); + const options: IPluginBuildOptions = { + gradlePath: this.$options.gradlePath, + gradleArgs: this.$options.gradleArgs, + projectDir: projectData.projectDir, + pluginName: pluginData.name, + platformsAndroidDirPath: pluginPlatformsFolderPath, + aarOutputDir: layout.readOnlyNodeModules + ? plugin.nativeDir + : pluginPlatformsFolderPath, + tempPluginDirPath: path.join(projectData.platformsDir, "tempPlugin"), + gradleFactsFile: getGradleFactsFilePath(layout.platformDir), + }; + + if (plugin) { + options.aarName = plugin.aarName; + } + + if (layout.readOnlyNodeModules) { + options.readOnlyNodeModules = true; + } + + return options; + } + + private getAndroidPlatformDir(projectData: IProjectData): string { + return this.$options.hostProjectPath + ? this.$options.hostProjectPath + : path.join( + projectData.platformsDir, + AndroidProjectService.ANDROID_PLATFORM_NAME, + ); + } + + private isReadOnlyNodeModules(projectData: IProjectData): boolean { + return projectData.nsConfig?.android?.readOnlyNodeModules === true; + } + + private getPluginLayoutKey(projectData: IProjectData): string { + return JSON.stringify([ + projectData.projectDir, + this.getAndroidPlatformDir(projectData), + this.isReadOnlyNodeModules(projectData), + ]); + } + + /** + * Normally computed by beforePrepareAllPlugins; computed here from the + * project's production dependencies when a plugin is prepared without it. + */ + private getPluginLayout(projectData: IProjectData): IAndroidPluginLayout { + if ( + !this.pluginLayout || + this.pluginLayout.key !== this.getPluginLayoutKey(projectData) + ) { + const dependencies = this.$injector + .resolve( + "nodeModulesDependenciesBuilder", + ) + .getProductionDependencies( + projectData.projectDir, + projectData.ignoredDependencies, + ); + this.pluginLayout = this.createPluginLayout( + projectData, + this.filterUniqueDependencies(dependencies).filter( + AndroidProjectService.isNativeAndroidDependency, + ), + ); + } + + return this.pluginLayout; + } + + private createPluginLayout( + projectData: IProjectData, + nativeDependencyData: IDependencyData[], + ): IAndroidPluginLayout { + const platformDir = this.getAndroidPlatformDir(projectData); + const readOnlyNodeModules = this.isReadOnlyNodeModules(projectData); + const mirrorsRoot = path.join( + platformDir, + constants.ANDROID_PLUGIN_MIRRORS_DIR_NAME, + ); + + let aarNames: Map; + try { + aarNames = getUniqueShortPluginNames( + nativeDependencyData.map((dep) => dep.name), + ); + } catch (err) { + this.$errors.fail(err.message); + } + + const plugins = new Map(); + for (const dep of nativeDependencyData) { + const aarName = aarNames.get(dep.name); + const mirrorDir = readOnlyNodeModules + ? path.join(mirrorsRoot, aarName) + : undefined; + plugins.set(dep.name, { + aarName, + packageDir: dep.directory, + mirrorDir, + nativeDir: path.join( + mirrorDir ?? dep.directory, + constants.PLATFORMS_DIR_NAME, + AndroidProjectService.ANDROID_PLATFORM_NAME, + ), + }); + } - this.$androidPluginBuildService.migrateIncludeGradle(options); + let changedMirrors: string[] = []; + if (readOnlyNodeModules) { + changedMirrors = this.$androidPluginMirrorService.syncMirrors( + mirrorsRoot, + nativeDependencyData.map((dep) => { + const aarName = aarNames.get(dep.name); + return { + pluginName: dep.name, + packageDir: dep.directory, + mirrorDir: plugins.get(dep.name).mirrorDir, + excludedNativeFiles: getCandidateAarNames(dep.name) + .filter((candidate) => candidate !== aarName) + .map((candidate) => `${candidate}.aar`), + }; + }), + ); + } else if (this.$fs.exists(mirrorsRoot)) { + this.$fs.deleteDirectory(mirrorsRoot); } + + return { + key: this.getPluginLayoutKey(projectData), + platformDir, + readOnlyNodeModules, + plugins, + changedMirrors: new Set(changedMirrors), + }; } public async processConfigurationFilesFromAppResources(): Promise { @@ -762,12 +988,7 @@ export class AndroidProjectService projectData: IProjectData, dependencies: IDependencyData[], ): IDependencyData[] { - const platformDir = this.$options.hostProjectPath - ? this.$options.hostProjectPath - : path.join( - projectData.platformsDir, - AndroidProjectService.ANDROID_PLATFORM_NAME, - ); + const platformDir = this.getAndroidPlatformDir(projectData); const dependenciesJsonPath = path.join( platformDir, constants.DEPENDENCIES_JSON_NAME, @@ -776,11 +997,17 @@ export class AndroidProjectService AndroidProjectService.isNativeAndroidDependency, ); + const layout = this.createPluginLayout(projectData, nativeDependencyData); + this.pluginLayout = layout; + let nativeDependencies = nativeDependencyData.map( ({ name, directory, dependencies }) => { return { name, - directory: path.relative(platformDir, directory), + directory: path.relative( + platformDir, + layout.plugins.get(name).mirrorDir ?? directory, + ), dependencies: dependencies.filter((dep) => { // filter out transient dependencies that don't have native dependencies return ( @@ -795,6 +1022,7 @@ export class AndroidProjectService nativeDependencies = topologicalSortNativeDependencies(nativeDependencies); const jsonContent = JSON.stringify(nativeDependencies, null, 4); this.$fs.writeFile(dependenciesJsonPath, jsonContent); + this.writeGradleFacts(projectData, layout, nativeDependencies, jsonContent); // we sort all the dependencies to respect the topological sorting of the native dependencies return dependencies.sort(function (a, b) { @@ -805,6 +1033,47 @@ export class AndroidProjectService }); } + private writeGradleFacts( + projectData: IProjectData, + layout: IAndroidPluginLayout, + sortedNativeDependencies: NativeDependency[], + dependenciesJsonContent: string, + ): void { + const runtimePackage = this.$projectDataService.getRuntimePackage( + projectData.projectDir, + constants.PlatformTypes.android, + ); + const facts = createGradleFacts({ + factsDir: path.resolve(layout.platformDir), + cliVersion: this.$staticConfig.version, + runtime: { name: runtimePackage?.name, version: runtimePackage?.version }, + projectDir: path.resolve(projectData.projectDir), + appDir: path.resolve(projectData.getAppDirectoryPath()), + appResourcesDir: path.resolve(projectData.getAppResourcesDirectoryPath()), + dependenciesJsonContent, + plugins: sortedNativeDependencies.map((dep) => { + const plugin = layout.plugins.get(dep.name); + return { + name: dep.name, + packageDir: path.resolve(plugin.packageDir), + nativeDir: path.resolve(plugin.nativeDir), + aarName: plugin.aarName, + dependencies: dep.dependencies, + }; + }), + }); + + // Rewritten only on change so Gradle tasks that take it as an input stay up to date. + const factsPath = getGradleFactsFilePath(layout.platformDir); + const content = serializeGradleFacts(facts); + if ( + !this.$fs.exists(factsPath) || + this.$fs.readText(factsPath) !== content + ) { + this.$fs.writeFile(factsPath, content); + } + } + private static isNativeAndroidDependency({ nativescript, }: IDependencyData): boolean { diff --git a/lib/services/android/android-plugin-mirror-service.ts b/lib/services/android/android-plugin-mirror-service.ts new file mode 100644 index 0000000000..f2f19e3c19 --- /dev/null +++ b/lib/services/android/android-plugin-mirror-service.ts @@ -0,0 +1,184 @@ +import * as path from "path"; +import { createHash } from "crypto"; +import { IFileSystem } from "../../common/declarations"; +import { injector } from "../../common/yok"; +import { + IAndroidPluginMirror, + IAndroidPluginMirrorService, +} from "../../definitions/android-plugin-migrator"; +import { PACKAGE_JSON_FILE_NAME, PLATFORMS_DIR_NAME } from "../../constants"; + +interface IMirroredFile { + sha1: string; +} + +interface IMirrorManifest { + files: { [relativePath: string]: IMirroredFile }; +} + +const MIRROR_MANIFEST_FILE_NAME = ".ns-mirror.json"; +const NATIVE_DIR = `${PLATFORMS_DIR_NAME}/android`; + +/** + * Keeps CLI-owned copies of plugins' Android files so prepare and build never + * write into node_modules. + * + * The manifest records the upstream content of every copied file. Mirror + * files are compared against it rather than against their own content because + * the CLI rewrites some of them (include.gradle) and adds its own (the built + * aar). Copies keep the upstream mtime, and files whose upstream content is + * unchanged are never rewritten, so Gradle's path and mtime based inputs stay + * up to date across syncs. + */ +export class AndroidPluginMirrorService implements IAndroidPluginMirrorService { + constructor( + private $fs: IFileSystem, + private $logger: ILogger, + ) {} + + public syncMirrors( + mirrorsRoot: string, + mirrors: IAndroidPluginMirror[], + ): string[] { + this.removeUnknownMirrors(mirrorsRoot, mirrors); + + return mirrors + .filter((mirror) => this.syncMirror(mirror)) + .map((mirror) => mirror.pluginName); + } + + private removeUnknownMirrors( + mirrorsRoot: string, + mirrors: IAndroidPluginMirror[], + ): void { + if (!this.$fs.exists(mirrorsRoot)) { + return; + } + + const knownDirs = new Set( + mirrors.map((mirror) => path.resolve(mirror.mirrorDir)), + ); + for (const entry of this.$fs.readDirectory(mirrorsRoot)) { + const entryPath = path.join(mirrorsRoot, entry); + if (!knownDirs.has(path.resolve(entryPath))) { + this.$logger.trace(`Removing unused plugin mirror ${entryPath}`); + this.$fs.deleteDirectory(entryPath); + } + } + } + + private syncMirror(mirror: IAndroidPluginMirror): boolean { + const manifestPath = path.join(mirror.mirrorDir, MIRROR_MANIFEST_FILE_NAME); + const previous = this.readManifest(manifestPath); + const next: IMirrorManifest = { files: {} }; + const excluded = new Set( + (mirror.excludedNativeFiles ?? []).map( + (file) => `${NATIVE_DIR}/${toPosix(file)}`, + ), + ); + let changed = false; + + for (const relativePath of this.getUpstreamFiles(mirror.packageDir)) { + if (excluded.has(relativePath)) { + continue; + } + + const source = path.join(mirror.packageDir, relativePath); + const destination = path.join(mirror.mirrorDir, relativePath); + const stat = this.$fs.getFsStats(source); + const known = previous.files[relativePath]; + // Always hashed: some installers give every file the same fixed mtime, so a + // same-size edit (a version bump in include.gradle) would look unchanged. + const sha1 = this.getSha1(source); + + if (!known || known.sha1 !== sha1 || !this.$fs.exists(destination)) { + this.$fs.deleteFile(destination); + this.$fs.copyFile(source, destination); + // Read-only stores (pnpm, Nix, Bazel) ship 0444 files; the CLI rewrites + // some mirrored files and replaces others on the next sync. + this.$fs.chmod(destination, stat.mode | 0o200); + this.$fs.utimes(destination, stat.atime, stat.mtime); + changed = true; + } + + next.files[relativePath] = { sha1 }; + } + + for (const relativePath of Object.keys(previous.files)) { + if (!next.files[relativePath]) { + this.deleteMirroredFile(mirror.mirrorDir, relativePath); + changed = true; + } + } + + const manifestContent = JSON.stringify(next, null, "\t"); + if ( + !this.$fs.exists(manifestPath) || + this.$fs.readText(manifestPath) !== manifestContent + ) { + this.$fs.writeFile(manifestPath, manifestContent); + } + + return changed; + } + + private getUpstreamFiles(packageDir: string): string[] { + const files: string[] = []; + if (this.$fs.exists(path.join(packageDir, PACKAGE_JSON_FILE_NAME))) { + files.push(PACKAGE_JSON_FILE_NAME); + } + + const nativeDir = path.join(packageDir, NATIVE_DIR); + if (this.$fs.exists(nativeDir)) { + for (const file of this.$fs.enumerateFilesInDirectorySync(nativeDir)) { + files.push(toPosix(path.relative(packageDir, file))); + } + } + + return files.sort(); + } + + private deleteMirroredFile(mirrorDir: string, relativePath: string): void { + const filePath = path.join(mirrorDir, relativePath); + this.$fs.deleteFile(filePath); + + const root = path.resolve(mirrorDir); + let dir = path.dirname(path.resolve(filePath)); + while ( + dir !== root && + dir.startsWith(root) && + this.$fs.exists(dir) && + this.$fs.isEmptyDir(dir) + ) { + this.$fs.deleteDirectory(dir); + dir = path.dirname(dir); + } + } + + private readManifest(manifestPath: string): IMirrorManifest { + try { + if (this.$fs.exists(manifestPath)) { + const manifest = this.$fs.readJson(manifestPath); + if (manifest && manifest.files) { + return manifest; + } + } + } catch (err) { + this.$logger.trace( + `Ignoring unreadable plugin mirror manifest ${manifestPath}: ${err}`, + ); + } + + return { files: {} }; + } + + private getSha1(filePath: string): string { + return createHash("sha1").update(this.$fs.readFile(filePath)).digest("hex"); + } +} + +function toPosix(filePath: string): string { + return filePath.split(path.sep).join("/"); +} + +injector.register("androidPluginMirrorService", AndroidPluginMirrorService); diff --git a/lib/services/android/gradle-build-args-service.ts b/lib/services/android/gradle-build-args-service.ts index eef6461fd8..9dcdb15356 100644 --- a/lib/services/android/gradle-build-args-service.ts +++ b/lib/services/android/gradle-build-args-service.ts @@ -2,9 +2,16 @@ import * as path from "path"; import { Configurations } from "../../common/constants"; import { IGradleBuildArgsService } from "../../definitions/gradle"; import { IAndroidBuildData } from "../../definitions/build"; -import { IHooksService, IAnalyticsService } from "../../common/declarations"; +import { + IHooksService, + IAnalyticsService, + IFileSystem, +} from "../../common/declarations"; import { injector } from "../../common/yok"; -import { IProjectDataService } from "../../definitions/project"; +import { IProjectData, IProjectDataService } from "../../definitions/project"; +import { IOptions } from "../../declarations"; +import { PlatformTypes } from "../../constants"; +import { getGradleFactsFilePath } from "./gradle-facts"; export class GradleBuildArgsService implements IGradleBuildArgsService { constructor( @@ -13,6 +20,8 @@ export class GradleBuildArgsService implements IGradleBuildArgsService { private $staticConfig: Config.IStaticConfig, private $projectDataService: IProjectDataService, private $logger: ILogger, + private $fs: IFileSystem, + private $options: IOptions, ) {} public async getBuildTaskArgs( @@ -55,6 +64,12 @@ export class GradleBuildArgsService implements IGradleBuildArgsService { `-PappPath=${projectData.getAppDirectoryPath()}`, `-PappResourcesPath=${projectData.getAppResourcesDirectoryPath()}`, ); + + const factsFile = this.getGradleFactsFile(projectData); + if (factsFile) { + args.push(`-Pnativescript.factsFile=${factsFile}`); + } + if (buildData.gradleArgs) { args.push(buildData.gradleArgs); } @@ -72,6 +87,19 @@ export class GradleBuildArgsService implements IGradleBuildArgsService { return args; } + private getGradleFactsFile(projectData: IProjectData): string | null { + const platformDir = + this.$options.hostProjectPath || + (projectData.platformsDir && + path.join(projectData.platformsDir, PlatformTypes.android)); + if (!platformDir) { + return null; + } + + const factsFile = getGradleFactsFilePath(platformDir); + return this.$fs.exists(factsFile) ? factsFile : null; + } + private getBuildLoggingArgs(): string[] { const args = []; diff --git a/lib/services/android/gradle-facts.ts b/lib/services/android/gradle-facts.ts new file mode 100644 index 0000000000..8df23c25bb --- /dev/null +++ b/lib/services/android/gradle-facts.ts @@ -0,0 +1,89 @@ +import * as path from "path"; +import { createHash } from "crypto"; +import { GRADLE_FACTS_FILE_NAME } from "../../constants"; +import { + INativeScriptGradleFacts, + INativeScriptGradleFactsPlugin, +} from "../../definitions/gradle-facts"; + +export const GRADLE_FACTS_SCHEMA_VERSION = 1; + +export interface IGradleFactsPluginInput { + name: string; + packageDir: string; + nativeDir: string; + aarName: string; + dependencies: string[]; +} + +export interface IGradleFactsInput { + /** + * Directory of the facts file; every path in the facts is relative to it. + */ + factsDir: string; + cliVersion: string; + runtime: { name?: string; version?: string }; + projectDir: string; + appDir: string; + appResourcesDir: string; + /** + * The exact text written to dependencies.json alongside these facts. + */ + dependenciesJsonContent: string; + /** + * In dependency order. + */ + plugins: IGradleFactsPluginInput[]; +} + +export function toFactsPath(factsDir: string, target: string): string { + return path.relative(factsDir, target).split(path.sep).join("/") || "."; +} + +export function createGradleFacts( + input: IGradleFactsInput, +): INativeScriptGradleFacts { + const rel = (target: string) => toFactsPath(input.factsDir, target); + + const runtime: INativeScriptGradleFacts["runtime"] = {}; + if (input.runtime?.name) { + runtime.name = input.runtime.name; + } + if (input.runtime?.version) { + runtime.version = input.runtime.version; + } + + return { + schemaVersion: GRADLE_FACTS_SCHEMA_VERSION, + cli: { version: input.cliVersion }, + runtime, + capabilities: [], + dependenciesJson: { + sha256: createHash("sha256") + .update(input.dependenciesJsonContent, "utf8") + .digest("hex"), + }, + project: { + root: rel(input.projectDir), + appDir: rel(input.appDir), + appResourcesDir: rel(input.appResourcesDir), + }, + plugins: input.plugins.map((plugin): INativeScriptGradleFactsPlugin => ({ + name: plugin.name, + packageDir: rel(plugin.packageDir), + nativeDir: rel(plugin.nativeDir), + aarName: plugin.aarName, + aar: rel(path.join(plugin.nativeDir, `${plugin.aarName}.aar`)), + dependencies: plugin.dependencies, + options: {}, + })), + }; +} + +export function serializeGradleFacts(facts: INativeScriptGradleFacts): string { + return JSON.stringify(facts, null, "\t") + "\n"; +} + +export function getGradleFactsFilePath(androidPlatformDir: string): string { + return path.resolve(androidPlatformDir, GRADLE_FACTS_FILE_NAME); +} diff --git a/package-lock.json b/package-lock.json index 7c7063478f..d01ff023a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "@types/xml2js": "0.4.14", "@types/yargs": "17.0.33", "@types/yazl": "^3.3.0", + "ajv": "8.20.0", "braces": ">=3.0.3", "chai": "5.3.3", "chai-as-promised": "8.0.2", @@ -2136,6 +2137,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -3657,6 +3675,13 @@ "node": ">=8.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3673,6 +3698,23 @@ "node": ">=8.6.0" } }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4704,6 +4746,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-nice": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", @@ -6827,6 +6876,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", diff --git a/package.json b/package.json index fbc3f41dd6..d8556adf77 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@types/xml2js": "0.4.14", "@types/yargs": "17.0.33", "@types/yazl": "^3.3.0", + "ajv": "8.20.0", "braces": ">=3.0.3", "chai": "5.3.3", "chai-as-promised": "8.0.2", diff --git a/test/plugins-service.ts b/test/plugins-service.ts index edf62e97b0..5b944691a3 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -180,6 +180,7 @@ function createTestInjector() { "androidPluginBuildService", stubs.AndroidPluginBuildServiceStub, ); + testInjector.register("androidPluginMirrorService", {}); testInjector.register("analyticsSettingsService", {}); testInjector.register( "androidResourcesMigrationService", diff --git a/test/services/android-gradle-facts.ts b/test/services/android-gradle-facts.ts new file mode 100644 index 0000000000..3a39ed0cb5 --- /dev/null +++ b/test/services/android-gradle-facts.ts @@ -0,0 +1,524 @@ +import { assert } from "chai"; +import * as path from "path"; +import { mkdtempSync, readFileSync, realpathSync, statSync } from "fs"; +import { tmpdir } from "os"; +import { createHash } from "crypto"; +import Ajv2020, { ValidateFunction } from "ajv/dist/2020"; +import { Yok } from "../../lib/common/yok"; +import { FileSystem } from "../../lib/common/file-system"; +import { AndroidProjectService } from "../../lib/services/android-project-service"; +import { AndroidPluginMirrorService } from "../../lib/services/android/android-plugin-mirror-service"; +import { IFileSystem } from "../../lib/common/declarations"; +import { IDependencyData } from "../../lib/declarations"; +import { IPluginData } from "../../lib/definitions/plugins"; +import { IProjectData } from "../../lib/definitions/project"; +import { IPluginBuildOptions } from "../../lib/definitions/android-plugin-migrator"; +import { INativeScriptGradleFacts } from "../../lib/definitions/gradle-facts"; +import * as stubs from "../stubs"; + +const schemaPath = path.resolve( + __dirname, + "../../../gradle-api/schema/nativescript-gradle.schema.json", +); + +interface IFixturePlugin { + name: string; + dependencies?: string[]; + nativeFiles?: { [relativePath: string]: string }; + native?: boolean; +} + +describe("android plugin layout and Gradle facts", () => { + let fs: IFileSystem; + let service: AndroidProjectService; + let projectDir: string; + let platformDir: string; + let dependencies: IDependencyData[]; + let buildAarCalls: IPluginBuildOptions[]; + let migrateIncludeGradleCalls: IPluginBuildOptions[]; + let outdatedAar: boolean; + + const validateFacts = (() => { + let validate: ValidateFunction; + return (facts: unknown) => { + if (!validate) { + const ajv = new Ajv2020({ allErrors: true }); + validate = ajv.compile(JSON.parse(readFileSync(schemaPath, "utf8"))); + } + const valid = validate(facts); + assert.isTrue(valid, JSON.stringify(validate.errors, null, 2)); + }; + })(); + + function createProjectData(readOnlyNodeModules?: boolean): IProjectData { + return ({ + projectDir, + platformsDir: path.join(projectDir, "platforms"), + nsConfig: + readOnlyNodeModules === undefined + ? {} + : { android: { readOnlyNodeModules } }, + ignoredDependencies: [], + getAppDirectoryPath: () => path.join(projectDir, "src"), + getAppResourcesDirectoryPath: () => + path.join(projectDir, "App_Resources"), + }); + } + + function addPlugin(plugin: IFixturePlugin): IDependencyData { + const directory = path.join(projectDir, "node_modules", plugin.name); + const nativescript = + plugin.native === false ? {} : { platforms: { android: "8.0.0" } }; + fs.writeJson(path.join(directory, "package.json"), { + name: plugin.name, + version: "1.0.0", + nativescript, + }); + for (const [file, content] of Object.entries(plugin.nativeFiles ?? {})) { + fs.writeFile( + path.join(directory, "platforms", "android", ...file.split("/")), + content, + ); + } + + const dependency = { + name: plugin.name, + directory, + depth: 0, + version: "1.0.0", + nativescript, + dependencies: plugin.dependencies ?? [], + }; + dependencies.push(dependency); + return dependency; + } + + function pluginData(name: string): IPluginData { + const directory = path.join(projectDir, "node_modules", name); + return ({ + name, + fullPath: directory, + pluginPlatformsFolderPath: (platform: string) => + path.join(directory, "platforms", platform), + }); + } + + const readFacts = (): INativeScriptGradleFacts => + fs.readJson(path.join(platformDir, "nativescript-gradle.json")); + const readDependenciesJson = () => + fs.readText(path.join(platformDir, "dependencies.json")); + const dependenciesJsonSha256 = () => + createHash("sha256") + .update(readFileSync(path.join(platformDir, "dependencies.json"))) + .digest("hex"); + + beforeEach(() => { + projectDir = realpathSync( + mkdtempSync(path.join(tmpdir(), "gradle-facts-")), + ); + platformDir = path.join(projectDir, "platforms", "android"); + dependencies = []; + buildAarCalls = []; + migrateIncludeGradleCalls = []; + outdatedAar = false; + + const injector = new Yok(); + injector.register("injector", injector); + injector.register("fs", FileSystem); + injector.register("logger", stubs.LoggerStub); + injector.register("errors", stubs.ErrorsStub); + injector.register("options", {}); + injector.register("staticConfig", { version: "9.9.9" }); + injector.register("projectDataService", { + getRuntimePackage: () => ({ + name: "@nativescript/android", + version: "9.0.0", + }), + }); + injector.register("nodeModulesDependenciesBuilder", { + getProductionDependencies: () => dependencies, + }); + injector.register("androidPluginBuildService", { + buildAar: async (options: IPluginBuildOptions) => { + buildAarCalls.push(options); + return false; + }, + migrateIncludeGradle: (options: IPluginBuildOptions) => { + migrateIncludeGradleCalls.push(options); + return false; + }, + hasOutdatedAar: () => outdatedAar, + }); + injector.register("androidPluginMirrorService", AndroidPluginMirrorService); + for (const unused of [ + "androidToolsInfo", + "devicePlatformsConstants", + "platformEnvironmentRequirements", + "androidResourcesMigrationService", + "filesHashService", + "gradleCommandService", + "gradleBuildService", + "analyticsService", + ]) { + injector.register(unused, {}); + } + injector.register("androidProjectService", AndroidProjectService); + + fs = injector.resolve("fs"); + service = injector.resolve("androidProjectService"); + }); + + function addFixturePlugins() { + addPlugin({ + name: "@nativescript/core", + nativeFiles: { "include.gradle": "android {}" }, + }); + addPlugin({ + name: "@foo/plugin-x", + dependencies: ["@nativescript/core", "lodash"], + nativeFiles: { + "AndroidManifest.xml": "", + "libs/x.jar": "jar", + }, + }); + addPlugin({ + name: "@bar/plugin-x", + nativeFiles: { "res/values/strings.xml": "" }, + }); + addPlugin({ name: "lodash", native: false }); + } + + describe("default mode", () => { + it("writes the same dependencies.json as before", async () => { + addPlugin({ + name: "nativescript-plugin", + dependencies: ["@nativescript/core", "lodash"], + nativeFiles: { "AndroidManifest.xml": "" }, + }); + addPlugin({ + name: "@nativescript/core", + nativeFiles: { "include.gradle": "android {}" }, + }); + addPlugin({ name: "lodash", native: false }); + + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + + const relative = (name: string) => + path.relative(platformDir, path.join(projectDir, "node_modules", name)); + assert.equal( + readDependenciesJson(), + JSON.stringify( + [ + { + name: "@nativescript/core", + directory: relative("@nativescript/core"), + dependencies: [], + }, + { + name: "nativescript-plugin", + directory: relative("nativescript-plugin"), + dependencies: ["@nativescript/core"], + }, + ], + null, + 4, + ), + ); + assert.isFalse(fs.exists(path.join(platformDir, ".ns-plugins"))); + }); + + it("writes facts that point at the packages and match the schema", async () => { + addFixturePlugins(); + + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + + const facts = readFacts(); + validateFacts(facts); + assert.deepStrictEqual(Object.keys(facts), [ + "schemaVersion", + "cli", + "runtime", + "capabilities", + "dependenciesJson", + "project", + "plugins", + ]); + assert.deepStrictEqual(facts, { + schemaVersion: 1, + cli: { version: "9.9.9" }, + runtime: { name: "@nativescript/android", version: "9.0.0" }, + capabilities: [], + dependenciesJson: { sha256: dependenciesJsonSha256() }, + project: { + root: "../..", + appDir: "../../src", + appResourcesDir: "../../App_Resources", + }, + plugins: [ + { + name: "@nativescript/core", + packageDir: "../../node_modules/@nativescript/core", + nativeDir: + "../../node_modules/@nativescript/core/platforms/android", + aarName: "core", + aar: "../../node_modules/@nativescript/core/platforms/android/core.aar", + dependencies: [], + options: {}, + }, + { + name: "@foo/plugin-x", + packageDir: "../../node_modules/@foo/plugin-x", + nativeDir: "../../node_modules/@foo/plugin-x/platforms/android", + aarName: "foo_plugin_x", + aar: "../../node_modules/@foo/plugin-x/platforms/android/foo_plugin_x.aar", + dependencies: ["@nativescript/core"], + options: {}, + }, + { + name: "@bar/plugin-x", + packageDir: "../../node_modules/@bar/plugin-x", + nativeDir: "../../node_modules/@bar/plugin-x/platforms/android", + aarName: "bar_plugin_x", + aar: "../../node_modules/@bar/plugin-x/platforms/android/bar_plugin_x.aar", + dependencies: [], + options: {}, + }, + ], + }); + assert.deepStrictEqual( + JSON.parse(readDependenciesJson()).map((dep: any) => dep.name), + facts.plugins.map((plugin) => plugin.name), + ); + }); + + it("does not rewrite unchanged facts", async () => { + addFixturePlugins(); + const factsPath = path.join(platformDir, "nativescript-gradle.json"); + + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + fs.utimes(factsPath, new Date(1_000_000), new Date(1_000_000)); + await service.beforePrepareAllPlugins(createProjectData(), dependencies); + + assert.equal(statSync(factsPath).mtime.getTime(), 1_000_000); + }); + + it("builds each plugin into its own package under its unique aar name", async () => { + addFixturePlugins(); + const projectData = createProjectData(); + await service.beforePrepareAllPlugins(projectData, dependencies); + + await service.preparePluginNativeCode( + pluginData("@foo/plugin-x"), + projectData, + ); + + const nativeDir = path.join( + projectDir, + "node_modules", + "@foo", + "plugin-x", + "platforms", + "android", + ); + assert.deepStrictEqual(buildAarCalls, [ + { + gradlePath: undefined, + gradleArgs: undefined, + projectDir, + pluginName: "@foo/plugin-x", + platformsAndroidDirPath: nativeDir, + aarOutputDir: nativeDir, + tempPluginDirPath: path.join(projectDir, "platforms", "tempPlugin"), + gradleFactsFile: path.join(platformDir, "nativescript-gradle.json"), + aarName: "foo_plugin_x", + }, + ]); + assert.equal( + migrateIncludeGradleCalls[0].platformsAndroidDirPath, + nativeDir, + ); + }); + + it("works out the aar names when plugins are prepared on their own", async () => { + addFixturePlugins(); + + await service.preparePluginNativeCode( + pluginData("@bar/plugin-x"), + createProjectData(), + ); + + assert.equal(buildAarCalls[0].aarName, "bar_plugin_x"); + }); + + it("asks for a reprepare when the plugin's aar is outdated", async () => { + addFixturePlugins(); + const projectData = createProjectData(); + await service.beforePrepareAllPlugins(projectData, dependencies); + + assert.isFalse( + service.shouldRepreparePlugin(pluginData("@foo/plugin-x"), projectData), + ); + outdatedAar = true; + assert.isTrue( + service.shouldRepreparePlugin(pluginData("@foo/plugin-x"), projectData), + ); + }); + + it("fails when two plugins cannot get distinct aar names", async () => { + addPlugin({ name: "@foo/plugin-x", nativeFiles: { "a.txt": "" } }); + addPlugin({ name: "@foo/plugin_x", nativeFiles: { "a.txt": "" } }); + + await assert.isRejected( + service.beforePrepareAllPlugins(createProjectData(), dependencies), + /"@foo\/plugin-x" and "@foo\/plugin_x"/, + ); + }); + }); + + describe("read-only node_modules", () => { + it("points dependencies.json and the facts at CLI-owned copies", async () => { + addFixturePlugins(); + + await service.beforePrepareAllPlugins( + createProjectData(true), + dependencies, + ); + + assert.deepStrictEqual(JSON.parse(readDependenciesJson()), [ + { + name: "@nativescript/core", + directory: path.join(".ns-plugins", "core"), + dependencies: [], + }, + { + name: "@foo/plugin-x", + directory: path.join(".ns-plugins", "foo_plugin_x"), + dependencies: ["@nativescript/core"], + }, + { + name: "@bar/plugin-x", + directory: path.join(".ns-plugins", "bar_plugin_x"), + dependencies: [], + }, + ]); + + const facts = readFacts(); + validateFacts(facts); + assert.equal(facts.dependenciesJson.sha256, dependenciesJsonSha256()); + assert.deepStrictEqual(facts.plugins[1], { + name: "@foo/plugin-x", + packageDir: "../../node_modules/@foo/plugin-x", + nativeDir: ".ns-plugins/foo_plugin_x/platforms/android", + aarName: "foo_plugin_x", + aar: ".ns-plugins/foo_plugin_x/platforms/android/foo_plugin_x.aar", + dependencies: ["@nativescript/core"], + options: {}, + }); + + const mirrorDir = path.join(platformDir, ".ns-plugins", "foo_plugin_x"); + assert.equal( + fs.readJson(path.join(mirrorDir, "package.json")).name, + "@foo/plugin-x", + ); + assert.equal( + fs.readText( + path.join(mirrorDir, "platforms", "android", "libs", "x.jar"), + ), + "jar", + ); + }); + + it("builds from the package into the copy", async () => { + addFixturePlugins(); + const projectData = createProjectData(true); + await service.beforePrepareAllPlugins(projectData, dependencies); + + await service.preparePluginNativeCode( + pluginData("@foo/plugin-x"), + projectData, + ); + + const mirrorNativeDir = path.join( + platformDir, + ".ns-plugins", + "foo_plugin_x", + "platforms", + "android", + ); + assert.deepInclude(buildAarCalls[0], { + platformsAndroidDirPath: path.join( + projectDir, + "node_modules", + "@foo", + "plugin-x", + "platforms", + "android", + ), + aarOutputDir: mirrorNativeDir, + aarName: "foo_plugin_x", + readOnlyNodeModules: true, + }); + assert.equal( + migrateIncludeGradleCalls[0].platformsAndroidDirPath, + mirrorNativeDir, + ); + }); + + it("asks for a reprepare of plugins whose copy changed until they are prepared", async () => { + addFixturePlugins(); + const projectData = createProjectData(true); + const foo = pluginData("@foo/plugin-x"); + + await service.beforePrepareAllPlugins(projectData, dependencies); + assert.isTrue(service.shouldRepreparePlugin(foo, projectData)); + + await service.preparePluginNativeCode(foo, projectData); + assert.isFalse(service.shouldRepreparePlugin(foo, projectData)); + + await service.beforePrepareAllPlugins(projectData, dependencies); + assert.isFalse(service.shouldRepreparePlugin(foo, projectData)); + }); + + it("skips plugins that Gradle does not read", async () => { + addFixturePlugins(); + fs.writeFile( + path.join( + projectDir, + "node_modules", + "lodash", + "platforms", + "android", + "AndroidManifest.xml", + ), + "", + ); + const projectData = createProjectData(true); + await service.beforePrepareAllPlugins(projectData, dependencies); + + await service.preparePluginNativeCode(pluginData("lodash"), projectData); + + assert.deepStrictEqual(buildAarCalls, []); + }); + + it("drops the copies when switched off", async () => { + addFixturePlugins(); + await service.beforePrepareAllPlugins( + createProjectData(true), + dependencies, + ); + assert.isTrue(fs.exists(path.join(platformDir, ".ns-plugins"))); + + await service.beforePrepareAllPlugins( + createProjectData(false), + dependencies, + ); + + assert.isFalse(fs.exists(path.join(platformDir, ".ns-plugins"))); + validateFacts(readFacts()); + assert.equal( + readFacts().plugins[0].nativeDir, + "../../node_modules/@nativescript/core/platforms/android", + ); + }); + }); +}); diff --git a/test/services/android-plugin-build-service.ts b/test/services/android-plugin-build-service.ts index c2107cf4c1..e434506f85 100644 --- a/test/services/android-plugin-build-service.ts +++ b/test/services/android-plugin-build-service.ts @@ -24,6 +24,10 @@ describe("androidPluginBuildService", () => { const pluginName = "my-plugin"; const shortPluginName = getShortPluginName(pluginName); let spawnFromEventCalled = false; + let lastGradleArgs: string[] = []; + let builtAarContent = ""; + let ignoredFiles: string[] = []; + let hashedFiles: string[] = []; let fs: IFileSystem; let androidBuildPluginService: AndroidPluginBuildService; let tempFolder: string; @@ -46,6 +50,8 @@ describe("androidPluginBuildService", () => { }): IPluginBuildOptions { options = options || {}; spawnFromEventCalled = false; + lastGradleArgs = []; + builtAarContent = ""; tempFolder = mkdtempSync( path.join(tmpdir(), "androidPluginBuildService-temp-"), ); @@ -75,27 +81,31 @@ describe("androidPluginBuildService", () => { const testInjector: IInjector = new stubs.InjectorStub(); testInjector.register("fs", FsLib.FileSystem); testInjector.register("childProcess", { - spawnFromEvent: async (command: string): Promise => { - const finalAarName = `${shortPluginName}-release.aar`; + spawnFromEvent: async ( + command: string, + args: string[], + ): Promise => { + const pluginDir = args[args.indexOf("-p") + 1]; const aar = path.join( - tempFolder, - shortPluginName, + pluginDir, "build", "outputs", "aar", - finalAarName, + `${path.basename(pluginDir)}-release.aar`, ); - fs.writeFile(aar, ""); + fs.writeFile(aar, builtAarContent); spawnFromEventCalled = command.indexOf("gradlew") !== -1; + lastGradleArgs = args; return null; }, }); testInjector.register("packageManager", setupNpm(options)); testInjector.register("projectData", stubs.ProjectDataStub); testInjector.register("filesHashService", { - generateHashes: async ( - files: string[], - ): Promise => ({}), + generateHashes: async (files: string[]): Promise => { + hashedFiles = files; + return {}; + }, getChanges: async ( files: string[], oldHashes: IStringDictionary, @@ -106,8 +116,9 @@ describe("androidPluginBuildService", () => { ): boolean => !!options.hasChangesInShasums, }); + ignoredFiles = []; testInjector.register("watchIgnoreListService", { - addFileToIgnoreList: () => ({}), + addFileToIgnoreList: (file: string) => ignoredFiles.push(file), }); fs = testInjector.resolve("fs"); @@ -415,6 +426,206 @@ dependencies { }); }); + describe("aar names", () => { + const scopedPluginName = "@foo/my-plugin"; + const qualifiedName = "foo_my_plugin"; + + function setupScoped( + overrides?: Partial, + ): IPluginBuildOptions { + return { + ...setup({ addManifest: true }), + pluginName: scopedPluginName, + aarName: qualifiedName, + ...overrides, + }; + } + + function writePreviousOutput(name: string, content: string) { + fs.writeFile( + path.join( + tempFolder, + name, + "build", + "outputs", + "aar", + `${name}-release.aar`, + ), + content, + ); + } + + it("uses aarName for the temp project, the aar and the namespace fallback", async () => { + const config = setupScoped(); + + assert.isTrue(await androidBuildPluginService.buildAar(config)); + + const buildGradle = fs.readText( + path.join(tempFolder, qualifiedName, "build.gradle"), + ); + assert.include( + buildGradle, + `namespace "org.nativescript.${qualifiedName}"`, + ); + assert.isTrue(fs.exists(path.join(pluginFolder, `${qualifiedName}.aar`))); + assert.isFalse(fs.exists(path.join(tempFolder, shortPluginName))); + assert.isFalse( + fs.exists(path.join(pluginFolder, `${shortPluginName}.aar`)), + ); + assert.deepStrictEqual(ignoredFiles, [ + path.join(pluginFolder, `${qualifiedName}.aar`), + ]); + }); + + it("looks for the previous aar in aarOutputDir", async () => { + const outputDir = mkdtempSync(path.join(tmpdir(), "aar-output-")); + const config: IPluginBuildOptions = { + ...setup({ addManifest: true, addPreviousBuildInfo: true }), + aarOutputDir: outputDir, + }; + + await androidBuildPluginService.buildAar(config); + assert.isTrue(spawnFromEventCalled); + assert.isTrue(fs.exists(path.join(outputDir, `${shortPluginName}.aar`))); + + spawnFromEventCalled = false; + await androidBuildPluginService.buildAar(config); + assert.isFalse(spawnFromEventCalled); + }); + + it("does not hash any aar the plugin could have been built to", async () => { + const config = setupScoped(); + for (const name of [shortPluginName, qualifiedName, "unrelated"]) { + fs.writeFile(path.join(pluginFolder, `${name}.aar`), name); + } + writePreviousOutput(shortPluginName, shortPluginName); + + await androidBuildPluginService.buildAar(config); + + assert.include(hashedFiles, path.join(pluginFolder, "unrelated.aar")); + assert.notInclude( + hashedFiles, + path.join(pluginFolder, `${shortPluginName}.aar`), + ); + assert.notInclude( + hashedFiles, + path.join(pluginFolder, `${qualifiedName}.aar`), + ); + }); + + it("deletes an aar the CLI built under the plugin's other name", async () => { + const config = setupScoped(); + const staleAar = path.join(pluginFolder, `${shortPluginName}.aar`); + fs.writeFile(staleAar, "previous build"); + writePreviousOutput(shortPluginName, "previous build"); + + await androidBuildPluginService.buildAar(config); + + assert.isFalse(fs.exists(staleAar)); + assert.include(ignoredFiles, staleAar); + assert.isTrue(fs.exists(path.join(pluginFolder, `${qualifiedName}.aar`))); + }); + + it("fails on an aar under the plugin's other name that the CLI did not build", async () => { + const config = setupScoped(); + const staleAar = path.join(pluginFolder, `${shortPluginName}.aar`); + fs.writeFile(staleAar, "shipped"); + writePreviousOutput(shortPluginName, "something else"); + + let error: Error = null; + try { + await androidBuildPluginService.buildAar(config); + } catch (err) { + error = err; + } + + assert.include(error?.message, staleAar); + assert.include(error?.message, "Delete it or run `ns clean`"); + assert.isTrue(fs.exists(staleAar)); + assert.isFalse(spawnFromEventCalled); + }); + + it("leaves other candidate aars alone when no aarName is given", async () => { + const config: IPluginBuildOptions = { + ...setup({ addManifest: true }), + pluginName: scopedPluginName, + }; + const otherAar = path.join(pluginFolder, `${qualifiedName}.aar`); + fs.writeFile(otherAar, "kept"); + + await androidBuildPluginService.buildAar(config); + + assert.isTrue(fs.exists(otherAar)); + assert.isTrue( + fs.exists(path.join(pluginFolder, `${shortPluginName}.aar`)), + ); + }); + + it("builds into the CLI-owned output dir when node_modules is read-only", async () => { + const mirrorDir = mkdtempSync(path.join(tmpdir(), "aar-mirror-")); + const config = setupScoped({ + aarOutputDir: mirrorDir, + readOnlyNodeModules: true, + }); + const staleAar = path.join(mirrorDir, `${shortPluginName}.aar`); + fs.writeFile(staleAar, "not ours"); + + await androidBuildPluginService.buildAar(config); + + assert.isFalse(fs.exists(staleAar)); + assert.isTrue(fs.exists(path.join(mirrorDir, `${qualifiedName}.aar`))); + assert.deepStrictEqual(fs.readDirectory(pluginFolder), [ + "AndroidManifest.xml", + ]); + assert.include(lastGradleArgs, "-Pnativescript.readOnlyNodeModules=true"); + assert.deepStrictEqual(ignoredFiles, []); + }); + + it("passes the facts file to Gradle only when it exists", async () => { + const factsFile = path.join( + mkdtempSync(path.join(tmpdir(), "facts-")), + "nativescript-gradle.json", + ); + const config = setupScoped({ gradleFactsFile: factsFile }); + + await androidBuildPluginService.buildAar(config); + assert.isTrue(spawnFromEventCalled); + assert.isFalse( + lastGradleArgs.some((arg) => arg.startsWith("-Pnativescript.")), + ); + + fs.writeFile(factsFile, "{}"); + fs.deleteDirectory(path.join(tempFolder, qualifiedName)); + await androidBuildPluginService.buildAar(config); + assert.include(lastGradleArgs, `-Pnativescript.factsFile=${factsFile}`); + }); + + describe("hasOutdatedAar", () => { + it("is false for a plugin without sources to build", () => { + const config: IPluginBuildOptions = { + ...setup({ addIncludeGradle: true }), + aarName: shortPluginName, + }; + assert.isFalse(androidBuildPluginService.hasOutdatedAar(config)); + }); + + it("is true while the aar is missing", () => { + const config = setupScoped(); + assert.isTrue(androidBuildPluginService.hasOutdatedAar(config)); + + fs.writeFile(path.join(pluginFolder, `${qualifiedName}.aar`), ""); + assert.isFalse(androidBuildPluginService.hasOutdatedAar(config)); + }); + + it("is true while an aar under another candidate name is there", () => { + const config = setupScoped(); + fs.writeFile(path.join(pluginFolder, `${qualifiedName}.aar`), ""); + fs.writeFile(path.join(pluginFolder, `${shortPluginName}.aar`), ""); + assert.isTrue(androidBuildPluginService.hasOutdatedAar(config)); + }); + }); + }); + describe("migrateIncludeGradle", () => { it("if there is a legacy include.gradle file", async () => { const config: IPluginBuildOptions = setup({ diff --git a/test/services/android-project-service.ts b/test/services/android-project-service.ts index 1961548012..1389666200 100644 --- a/test/services/android-project-service.ts +++ b/test/services/android-project-service.ts @@ -62,6 +62,7 @@ const createTestInjector = (): IInjector => { testInjector.register("staticConfig", { TRACK_FEATURE_USAGE_SETTING_NAME: "TrackFeatureUsage", }); + testInjector.register("androidPluginMirrorService", {}); return testInjector; }; @@ -102,7 +103,7 @@ describe("androidProjectService", () => { childProcess = injector.resolve("childProcess"); const getPlatformDataStub: sinon.SinonStub = sandbox.stub( androidProjectService, - "getPlatformData" + "getPlatformData", ); getPlatformDataStub.callsFake(() => { return { @@ -120,7 +121,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -136,7 +137,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -152,7 +153,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -169,7 +170,7 @@ describe("androidProjectService", () => { await androidProjectService.buildProject( "local/local", projectData, - buildConfig + buildConfig, ); //assert @@ -182,30 +183,30 @@ describe("androidProjectService", () => { const pathToAppResourcesDir = path.join(projectDir, "app", "App_Resources"); const pathToAppResourcesAndroid = path.join( pathToAppResourcesDir, - "Android" + "Android", ); const pathToPlatformsAndroid = path.join( projectDir, "platforms", - "android" + "android", ); const pathToResDirInPlatforms = path.join( pathToPlatformsAndroid, "app", "src", "main", - "res" + "res", ); const valuesV27Path = path.join(pathToResDirInPlatforms, "values-v27"); const valuesV28Path = path.join(pathToResDirInPlatforms, "values-v28"); const libsPath = path.join(pathToResDirInPlatforms, "libs"); const drawableHdpiPath = path.join( pathToResDirInPlatforms, - "drawable-hdpi" + "drawable-hdpi", ); const drawableLdpiPath = path.join( pathToResDirInPlatforms, - "drawable-ldpi" + "drawable-ldpi", ); let deletedDirs: string[] = []; let copiedFiles: { @@ -232,7 +233,7 @@ describe("androidProjectService", () => { }; fs.copyFile = ( sourceFileName: string, - destinationFileName: string + destinationFileName: string, ): void => { copiedFiles.push({ sourceFileName, destinationFileName }); }; @@ -245,7 +246,7 @@ describe("androidProjectService", () => { const androidToolsInfo = injector.resolve("androidToolsInfo"); androidToolsInfo.getToolsInfo = ( - config?: IProjectDir + config?: IProjectDir, ): IAndroidToolsInfoData => { return { compileSdkVersion, @@ -256,12 +257,12 @@ describe("androidProjectService", () => { describe("when new Android App_Resources structure is detected (post {N} 4.0 structure)", () => { const pathToSrcDirInAppResources = path.join( pathToAppResourcesAndroid, - "src" + "src", ); beforeEach(() => { const androidResourcesMigrationService = injector.resolve( - "androidResourcesMigrationService" + "androidResourcesMigrationService", ); androidResourcesMigrationService.hasMigrated = () => true; }); @@ -277,7 +278,7 @@ describe("androidProjectService", () => { "platforms", "android", "app", - "src" + "src", ), }, ]); @@ -333,7 +334,7 @@ describe("androidProjectService", () => { beforeEach(() => { const androidResourcesMigrationService = injector.resolve( - "androidResourcesMigrationService" + "androidResourcesMigrationService", ); androidResourcesMigrationService.hasMigrated = () => false; }); diff --git a/test/services/android/android-plugin-mirror-service.ts b/test/services/android/android-plugin-mirror-service.ts new file mode 100644 index 0000000000..0988810cf3 --- /dev/null +++ b/test/services/android/android-plugin-mirror-service.ts @@ -0,0 +1,194 @@ +import { assert } from "chai"; +import * as path from "path"; +import { mkdtempSync, statSync, utimesSync } from "fs"; +import { tmpdir } from "os"; +import { Yok } from "../../../lib/common/yok"; +import { FileSystem } from "../../../lib/common/file-system"; +import { AndroidPluginMirrorService } from "../../../lib/services/android/android-plugin-mirror-service"; +import { IFileSystem } from "../../../lib/common/declarations"; +import { IAndroidPluginMirror } from "../../../lib/definitions/android-plugin-migrator"; +import * as stubs from "../../stubs"; + +describe("androidPluginMirrorService", () => { + let fs: IFileSystem; + let mirrorService: AndroidPluginMirrorService; + let packageDir: string; + let mirrorsRoot: string; + let mirror: IAndroidPluginMirror; + + const upstream = (...parts: string[]) => path.join(packageDir, ...parts); + const mirrored = (...parts: string[]) => + path.join(mirror.mirrorDir, ...parts); + const mtimeOf = (file: string) => statSync(file).mtime.getTime(); + const setMtime = (file: string, seconds: number) => + utimesSync(file, seconds, seconds); + + beforeEach(() => { + const injector = new Yok(); + injector.register("injector", injector); + injector.register("fs", FileSystem); + injector.register("logger", stubs.LoggerStub); + injector.register("androidPluginMirrorService", AndroidPluginMirrorService); + fs = injector.resolve("fs"); + mirrorService = injector.resolve("androidPluginMirrorService"); + + const root = mkdtempSync(path.join(tmpdir(), "plugin-mirror-")); + packageDir = path.join(root, "node_modules", "@foo", "plugin-x"); + mirrorsRoot = path.join(root, "platforms", "android", ".ns-plugins"); + mirror = { + pluginName: "@foo/plugin-x", + packageDir, + mirrorDir: path.join(mirrorsRoot, "foo_plugin_x"), + excludedNativeFiles: ["plugin_x.aar"], + }; + + fs.writeJson(upstream("package.json"), { name: "@foo/plugin-x" }); + fs.writeFile(upstream("README.md"), "not mirrored"); + fs.writeFile(upstream("platforms", "android", "include.gradle"), "a"); + fs.writeFile(upstream("platforms", "android", "libs", "x.jar"), "jar"); + fs.writeFile(upstream("platforms", "android", "plugin_x.aar"), "stale"); + fs.writeFile(upstream("platforms", "ios", "Podfile"), "not mirrored"); + for (const file of [ + upstream("package.json"), + upstream("platforms", "android", "include.gradle"), + upstream("platforms", "android", "libs", "x.jar"), + ]) { + setMtime(file, 1_000_000); + } + }); + + it("copies package.json and platforms/android, keeping upstream mtimes", () => { + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + + assert.deepStrictEqual(fs.readJson(mirrored("package.json")), { + name: "@foo/plugin-x", + }); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "a", + ); + assert.equal( + fs.readText(mirrored("platforms", "android", "libs", "x.jar")), + "jar", + ); + assert.equal( + mtimeOf(mirrored("platforms", "android", "libs", "x.jar")), + 1_000_000_000, + ); + assert.isFalse(fs.exists(mirrored("README.md"))); + assert.isFalse(fs.exists(mirrored("platforms", "ios"))); + assert.isFalse(fs.exists(mirrored("platforms", "android", "plugin_x.aar"))); + }); + + it("only rewrites files whose upstream content changed", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.writeFile( + mirrored("platforms", "android", "include.gradle"), + "rewritten by the CLI", + ); + setMtime(upstream("package.json"), 2_000_000); + + assert.deepStrictEqual( + mirrorService.syncMirrors(mirrorsRoot, [mirror]), + [], + ); + assert.equal(mtimeOf(mirrored("package.json")), 1_000_000_000); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "rewritten by the CLI", + ); + + fs.writeFile(upstream("platforms", "android", "include.gradle"), "b"); + setMtime(upstream("platforms", "android", "include.gradle"), 3_000_000); + + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "b", + ); + assert.equal( + mtimeOf(mirrored("platforms", "android", "include.gradle")), + 3_000_000_000, + ); + assert.equal( + mtimeOf(mirrored("platforms", "android", "libs", "x.jar")), + 1_000_000_000, + ); + }); + + it("restores a mirrored file that was deleted", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.deleteFile(mirrored("platforms", "android", "libs", "x.jar")); + + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + assert.equal( + fs.readText(mirrored("platforms", "android", "libs", "x.jar")), + "jar", + ); + }); + + it("deletes files removed upstream but keeps files the CLI added", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.writeFile(mirrored("platforms", "android", "foo_plugin_x.aar"), "built"); + fs.deleteDirectory(upstream("platforms", "android", "libs")); + + assert.deepStrictEqual(mirrorService.syncMirrors(mirrorsRoot, [mirror]), [ + "@foo/plugin-x", + ]); + assert.isFalse(fs.exists(mirrored("platforms", "android", "libs"))); + assert.equal( + fs.readText(mirrored("platforms", "android", "foo_plugin_x.aar")), + "built", + ); + }); + + it("recopies a file whose content changed without changing its size or mtime", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + const includeGradle = upstream("platforms", "android", "include.gradle"); + fs.writeFile(includeGradle, "b"); + setMtime(includeGradle, 1_000_000); + + const changed = mirrorService.syncMirrors(mirrorsRoot, [mirror]); + + assert.deepStrictEqual(changed, ["@foo/plugin-x"]); + assert.equal( + fs.readText(mirrored("platforms", "android", "include.gradle")), + "b", + ); + }); + + it("removes the mirrors of plugins that are gone", () => { + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + assert.isTrue(fs.exists(mirror.mirrorDir)); + + mirrorService.syncMirrors(mirrorsRoot, []); + + assert.isFalse(fs.exists(mirror.mirrorDir)); + assert.isTrue(fs.exists(packageDir)); + }); + + it("never writes into the package", () => { + const before = fs.enumerateFilesInDirectorySync(packageDir).map((file) => ({ + file, + content: fs.readText(file), + mtime: mtimeOf(file), + })); + + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + fs.writeFile(mirrored("platforms", "android", "include.gradle"), "x"); + mirrorService.syncMirrors(mirrorsRoot, [mirror]); + + const after = fs.enumerateFilesInDirectorySync(packageDir).map((file) => ({ + file, + content: fs.readText(file), + mtime: mtimeOf(file), + })); + assert.deepStrictEqual(after, before); + }); +}); diff --git a/test/services/android/gradle-build-args-service.ts b/test/services/android/gradle-build-args-service.ts index ab87f5bbb2..8bb7c5764c 100644 --- a/test/services/android/gradle-build-args-service.ts +++ b/test/services/android/gradle-build-args-service.ts @@ -27,6 +27,8 @@ function createTestInjector(): IInjector { getProjectData: () => projectData, }); injector.register("hooksService", stubs.HooksServiceStub); + injector.register("fs", { exists: () => false }); + injector.register("options", {}); injector.register("gradleBuildArgsService", GradleBuildArgsService); injector.register("analyticsService", stubs.AnalyticsService); injector.register("staticConfig", { @@ -216,6 +218,51 @@ describe("GradleBuildArgsService", () => { } }); + describe("Gradle facts", () => { + const factsFile = path.resolve( + "/path/to/projectDir/platforms/android/nativescript-gradle.json", + ); + + const getArgs = async (existing: string[], options: object = {}) => { + const injector = createTestInjector(); + injector.resolve("logger").getLevel = (): string => "INFO"; + injector.register("fs", { + exists: (file: string) => existing.includes(file), + }); + injector.register("options", options); + const service: IGradleBuildArgsService = injector.resolve( + "gradleBuildArgsService", + ); + return service.getBuildTaskArgs({ release: false }); + }; + + it("passes the facts file when it exists", async () => { + const args = await getArgs([factsFile]); + assert.deepStrictEqual( + args, + ["assembleDebug"] + .concat(expectedInfoLoggingArgs) + .concat(expectedDebugBuildArgs) + .concat(`-Pnativescript.factsFile=${factsFile}`), + ); + }); + + it("does not pass a facts file that does not exist", async () => { + const args = await getArgs([]); + assert.isFalse(args.some((arg) => arg.includes("factsFile"))); + }); + + it("looks for the facts file in the host project", async () => { + const hostProjectPath = path.resolve("/path/to/host"); + const hostFactsFile = path.join( + hostProjectPath, + "nativescript-gradle.json", + ); + const args = await getArgs([hostFactsFile], { hostProjectPath }); + assert.include(args, `-Pnativescript.factsFile=${hostFactsFile}`); + }); + }); + describe("getCleanTaskArgs", async () => { const testCases = [ { diff --git a/test/services/gradle-api-include-gradle-extraction.ts b/test/services/gradle-api-include-gradle-extraction.ts new file mode 100644 index 0000000000..f71f379710 --- /dev/null +++ b/test/services/gradle-api-include-gradle-extraction.ts @@ -0,0 +1,42 @@ +import { assert } from "chai"; +import { readFileSync } from "fs"; +import * as path from "path"; +import { AndroidPluginBuildService } from "../../lib/services/android-plugin-build-service"; + +// gradle-api's plugin-build functional tests run this golden file in place of the CLI's +// extraction, so the two must not drift. +describe("include.gradle extraction used by gradle-api's functional tests", () => { + // Tests run compiled from dist/test, three levels below the repo root. + const gradleApiDir = path.join(__dirname, "..", "..", "..", "gradle-api"); + + it("extracts exactly the golden blocks from the sample plugin's include.gradle", () => { + const includeGradle = readFileSync( + path.join( + gradleApiDir, + "samples", + "sample-plugin", + "platforms", + "android", + "include.gradle", + ), + "utf8", + ); + const golden = readFileSync( + path.join( + gradleApiDir, + "src", + "functionalTest", + "resources", + "extracted", + "sample-plugin-include.gradle", + ), + "utf8", + ); + + const service = Object.create(AndroidPluginBuildService.prototype); + const blocks: string[] = + service.getIncludeGradleCompileDependenciesScope(includeGradle); + + assert.equal(blocks.join("\n"), golden); + }); +}); diff --git a/test/stubs.ts b/test/stubs.ts index c21b07ce22..35baf6cc3a 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -750,6 +750,10 @@ export class AndroidPluginBuildServiceStub implements IAndroidPluginBuildService migrateIncludeGradle(options: IPluginBuildOptions): boolean { return true; } + + hasOutdatedAar(options: IPluginBuildOptions): boolean { + return false; + } } export class PlatformProjectServiceStub diff --git a/vendor/gradle-plugin/build.gradle b/vendor/gradle-plugin/build.gradle index ee6de65efd..9687767509 100644 --- a/vendor/gradle-plugin/build.gradle +++ b/vendor/gradle-plugin/build.gradle @@ -120,6 +120,16 @@ buildscript { } } + // Opt-in: published plugins' buildscript.gradle files were written for the app build + // and may not expect to run in their own aar build. + if (project.findProperty("nativescript.readOnlyNodeModules") == "true") { + def ownBuildScriptGradle = file("${getDepPlatformDir(pluginData)}/buildscript.gradle") + if (ownBuildScriptGradle.exists()) { + outLogger.withStyle(Style.SuccessHeader).println "\t + applying the plugin's own buildscript from ${ownBuildScriptGradle}" + apply from: ownBuildScriptGradle, to: buildscript + } + } + def pathToPluginBuildScriptGradle = "$rootDir/buildscript.gradle" def pluginBuildScriptGradle = file(pathToPluginBuildScriptGradle) if (pluginBuildScriptGradle.exists()) { @@ -131,6 +141,16 @@ buildscript { } +// Only the dependencies/repositories blocks of the plugin's include.gradle are copied into this +// build, so its top-level `apply plugin: "org.nativescript.api"` never runs here. Applying the +// library whenever a buildscript.gradle put it on the classpath keeps those blocks able to use it. +if (project.findProperty("nativescript.readOnlyNodeModules") == "true") { + try { + apply plugin: Class.forName("org.nativescript.gradle.NativeScriptPlugin", true, buildscript.classLoader) + } catch (ClassNotFoundException ignored) { + } +} + def pluginDependencies allprojects {