diff --git a/src/main/groovy/com/deploygate/gradle/plugins/Config.groovy b/src/main/groovy/com/deploygate/gradle/plugins/Config.groovy index a1b923c9..3765b498 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/Config.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/Config.groovy @@ -27,8 +27,4 @@ class Config { VERSION_NAME = "unavailable" } } - - static boolean shouldOpenAppDetailAfterUpload() { - return System.getenv(DeployGatePlugin.ENV_NAME_OPEN_APP_DETAIL_AFTER_UPLOAD) - } } diff --git a/src/main/groovy/com/deploygate/gradle/plugins/DeployGatePlugin.groovy b/src/main/groovy/com/deploygate/gradle/plugins/DeployGatePlugin.groovy index c57757e8..b63f7ca4 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/DeployGatePlugin.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/DeployGatePlugin.groovy @@ -6,6 +6,8 @@ import static com.deploygate.gradle.plugins.internal.agp.AndroidGradlePlugin.and import static com.deploygate.gradle.plugins.internal.agp.AndroidGradlePlugin.androidBundleTaskName import static com.deploygate.gradle.plugins.internal.gradle.ProviderFactoryUtils.environmentVariable +import com.deploygate.gradle.plugins.artifacts.AabInfo +import com.deploygate.gradle.plugins.artifacts.ApkInfo import com.deploygate.gradle.plugins.artifacts.DefaultPresetAabInfo import com.deploygate.gradle.plugins.artifacts.DefaultPresetApkInfo import com.deploygate.gradle.plugins.dsl.DeployGateExtension @@ -55,16 +57,30 @@ class DeployGatePlugin implements Plugin { setupExtension(project) - GradleCompat.init(project) - // the presence of the value is same to the existence of the directory. + // Defer file operations to execution time for Configuration Cache compatibility Provider credentialDirPathProvider = project.providers.systemProperty("user.home").map { home -> - File f = new File(home, '.dg') - (f.directory || f.mkdirs()) ? f.absolutePath : null + new File(home, '.dg').absolutePath + } + + // Detect AGP version for HttpClient + def agpVersionProvider = project.providers.provider { + try { + def agpPlugin = project.plugins.findPlugin("com.android.application") + if (agpPlugin) { + return AndroidGradlePlugin.getVersionString(agpPlugin.class.classLoader) + } + } catch (Throwable ignored) { + } + return "unknown" } def httpClientProvider = project.gradle.sharedServices.registerIfAbsent("httpclient", HttpClient) { spec -> spec.parameters.endpoint.set(environmentVariable(project.providers, "TEST_SERVER_URL").orElse(Config.getDEPLOYGATE_ROOT())) + spec.parameters.agpVersion.set(agpVersionProvider) + spec.parameters.pluginVersion.set(project.providers.provider { Config.VERSION }) + spec.parameters.pluginVersionCode.set(project.providers.provider { Config.VERSION_CODE.toString() }) + spec.parameters.pluginVersionName.set(project.providers.provider { Config.VERSION_NAME }) } def localServerProvider = project.gradle.sharedServices.registerIfAbsent("httpserver", LocalServer) { spec -> @@ -72,9 +88,16 @@ class DeployGatePlugin implements Plugin { spec.parameters.credentialsDirPath.set(credentialDirPathProvider) } + // Use Provider API for configuration cache compatibility + def extension = project.extensions.getByName(EXTENSION_NAME) as DeployGateExtension + def appOwnerNameProvider = project.providers.provider { extension.appOwnerName } + def apiTokenProvider = project.providers.provider { extension.apiToken } + def endpointProvider = project.providers.provider { extension.endpoint } + def openBrowserProvider = environmentVariable(project.providers, ENV_NAME_OPEN_APP_DETAIL_AFTER_UPLOAD).map { it?.toBoolean() ?: false } + def loginTaskProvider = project.tasks.register(Constants.LOGIN_TASK_NAME, LoginTask) { task -> - task.explicitAppOwnerName.set(project.deploygate.appOwnerName) - task.explicitApiToken.set(project.deploygate.apiToken) + task.explicitAppOwnerName.set(appOwnerNameProvider) + task.explicitApiToken.set(apiTokenProvider) task.credentialsDirPath.set(credentialDirPathProvider) task.httpClient.set(httpClientProvider) task.localServer.set(localServerProvider) @@ -98,7 +121,7 @@ class DeployGatePlugin implements Plugin { task.group = Constants.TASK_GROUP_NAME } - project.deploygate.deployments.configureEach { NamedDeployment deployment -> + extension.deployments.configureEach { NamedDeployment deployment -> project.tasks.named(Constants.SUFFIX_APK_TASK_NAME).configure { task -> task.dependsOn(Constants.uploadApkTaskName(deployment.name)) } @@ -106,29 +129,39 @@ class DeployGatePlugin implements Plugin { project.tasks.named(Constants.SUFFIX_AAB_TASK_NAME).configure { task -> task.dependsOn(Constants.uploadAabTaskName(deployment.name)) } - + project.tasks.register(Constants.uploadApkTaskName(deployment.name), UploadApkTask) { task -> + task.description = "Deploy assembled ${deployment.name} APK to DeployGate" + task.group = Constants.TASK_GROUP_NAME + if (!deployment.skipAssemble) { - task.logger.debug("${deployment.name} required assmble but ignored") + task.logger.debug("${deployment.name} required assemble but ignored") } task.credentials.set(loginTaskProvider.map { it.credentials }) task.deployment.copyFrom(deployment) task.apkInfo.set(new DefaultPresetApkInfo(deployment.name)) task.httpClient.set(httpClientProvider) + task.endpoint.set(endpointProvider) + task.openBrowserAfterUpload.set(openBrowserProvider) task.usesService(httpClientProvider) task.dependsOn(loginTaskProvider) } project.tasks.register(Constants.uploadAabTaskName(deployment.name), UploadAabTask) { task -> + task.description = "Deploy bundled ${deployment.name} AAB to DeployGate" + task.group = Constants.TASK_GROUP_NAME + if (!deployment.skipAssemble) { - task.logger.debug("${deployment.name} required assmble but ignored") + task.logger.debug("${deployment.name} required assemble but ignored") } task.credentials.set(loginTaskProvider.map { it.credentials }) task.deployment.copyFrom(deployment) task.aabInfo.set(new DefaultPresetAabInfo(deployment.name)) task.httpClient.set(httpClientProvider) + task.endpoint.set(endpointProvider) + task.openBrowserAfterUpload.set(openBrowserProvider) task.usesService(httpClientProvider) task.dependsOn(loginTaskProvider) } @@ -139,10 +172,13 @@ class DeployGatePlugin implements Plugin { def variantProxy = new IApplicationVariantImpl(variant) namedOrRegister(project, Constants.uploadApkTaskName(variantProxy.name), UploadApkTask).configure { task -> + task.description = "Deploy assembled ${variantProxy.name} APK to DeployGate" task.credentials.set(loginTaskProvider.map { it.credentials }) - task.apkInfo.set(variantProxy.packageApplicationTaskProvider().map {getApkInfo(it, variantProxy.name) }) + task.apkInfo.set(createApkInfoProvider(variantProxy, agpVersionProvider)) task.httpClient.set(httpClientProvider) + task.endpoint.set(endpointProvider) + task.openBrowserAfterUpload.set(openBrowserProvider) task.usesService(httpClientProvider) if (deployment.skipAssemble.get()) { @@ -153,10 +189,13 @@ class DeployGatePlugin implements Plugin { } namedOrRegister(project, Constants.uploadAabTaskName(variantProxy.name), UploadAabTask).configure { task -> + task.description = "Deploy bundled ${variantProxy.name} AAB to DeployGate" task.credentials.set(loginTaskProvider.map { it.credentials }) - task.aabInfo.set(variantProxy.packageApplicationTaskProvider().map {getAabInfo(it, variantProxy.name, project.buildDir) }) + task.aabInfo.set(createAabInfoProvider(variantProxy, agpVersionProvider, project)) task.httpClient.set(httpClientProvider) + task.endpoint.set(endpointProvider) + task.openBrowserAfterUpload.set(openBrowserProvider) task.usesService(httpClientProvider) if (deployment.skipAssemble.get()) { @@ -182,4 +221,42 @@ class DeployGatePlugin implements Plugin { // TODO we should use ExtensionSyntax as the 1st argument but we need to investigate the expected side effects first. project.extensions.create(DeployGateExtension, EXTENSION_NAME, DeployGateExtension, deployments) } + + /** + * Creates a provider for APK info that properly chains the package task provider + * with the AGP version provider for configuration cache compatibility. + * + * @param variantProxy The application variant proxy + * @param agpVersionProvider Provider for the AGP version string + * @return Provider for ApkInfo + */ + private static Provider createApkInfoProvider( + @NotNull IApplicationVariantImpl variantProxy, + @NotNull Provider agpVersionProvider) { + return variantProxy.packageApplicationTaskProvider().flatMap { packageTask -> + agpVersionProvider.map { agpVersion -> + getApkInfo(packageTask, variantProxy.name, agpVersion) + } + } + } + + /** + * Creates a provider for AAB info that properly chains the package task provider + * with the AGP version provider for configuration cache compatibility. + * + * @param variantProxy The application variant proxy + * @param agpVersionProvider Provider for the AGP version string + * @param project The project instance for accessing build directory + * @return Provider for AabInfo + */ + private static Provider createAabInfoProvider( + @NotNull IApplicationVariantImpl variantProxy, + @NotNull Provider agpVersionProvider, + @NotNull Project project) { + return variantProxy.packageApplicationTaskProvider().flatMap { packageTask -> + agpVersionProvider.map { agpVersion -> + getAabInfo(packageTask, variantProxy.name, project.layout.buildDirectory.get().asFile, agpVersion) + } + } + } } diff --git a/src/main/groovy/com/deploygate/gradle/plugins/artifacts/AabInfo.groovy b/src/main/groovy/com/deploygate/gradle/plugins/artifacts/AabInfo.groovy index cad8d02c..070ecaa9 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/artifacts/AabInfo.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/artifacts/AabInfo.groovy @@ -1,12 +1,21 @@ package com.deploygate.gradle.plugins.artifacts +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.jetbrains.annotations.NotNull import org.jetbrains.annotations.Nullable interface AabInfo { + @Input @NotNull String getVariantName() + @InputFile + @Optional + @PathSensitive(PathSensitivity.ABSOLUTE) @Nullable File getAabFile() } \ No newline at end of file diff --git a/src/main/groovy/com/deploygate/gradle/plugins/artifacts/ApkInfo.groovy b/src/main/groovy/com/deploygate/gradle/plugins/artifacts/ApkInfo.groovy index f1ed56ae..919b2e4d 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/artifacts/ApkInfo.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/artifacts/ApkInfo.groovy @@ -1,16 +1,27 @@ package com.deploygate.gradle.plugins.artifacts +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.jetbrains.annotations.NotNull import org.jetbrains.annotations.Nullable interface ApkInfo { + @Input @NotNull String getVariantName() + @InputFile + @Optional + @PathSensitive(PathSensitivity.ABSOLUTE) @Nullable File getApkFile() + @Input boolean isSigningReady() + @Input boolean isUniversalApk() } \ No newline at end of file diff --git a/src/main/groovy/com/deploygate/gradle/plugins/artifacts/PackageAppTaskCompat.groovy b/src/main/groovy/com/deploygate/gradle/plugins/artifacts/PackageAppTaskCompat.groovy index ac452cc7..ca38aeeb 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/artifacts/PackageAppTaskCompat.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/artifacts/PackageAppTaskCompat.groovy @@ -9,12 +9,12 @@ class PackageAppTaskCompat { } @NotNull - static ApkInfo getApkInfo(@NotNull /* PackageApplication */ packageAppTask, @NotNull String variantName) { + static ApkInfo getApkInfo(@NotNull /* PackageApplication */ packageAppTask, @NotNull String variantName, @NotNull String agpVersion) { // outputScope is retrieved by the reflection - Collection apkNames = getApkNames(packageAppTask) + Collection apkNames = getApkNames(packageAppTask, agpVersion) File outputDir = getOutputDirectory(packageAppTask) boolean isUniversal = apkNames.size() == 1 - boolean isSigningReady = hasSigningConfig(packageAppTask) + boolean isSigningReady = hasSigningConfig(packageAppTask, agpVersion) return new DirectApkInfo( variantName, @@ -25,12 +25,12 @@ class PackageAppTaskCompat { } @NotNull - static AabInfo getAabInfo(@NotNull /* PackageApplication */ packageAppTask, @NotNull String variantName, @NotNull File buildDir) { + static AabInfo getAabInfo(@NotNull /* PackageApplication */ packageAppTask, @NotNull String variantName, @NotNull File buildDir, @NotNull String agpVersion) { final String aabName // TODO Use Artifact API // outputScope is retrieved by the reflection - Collection apkNames = getApkNames(packageAppTask) + Collection apkNames = getApkNames(packageAppTask, agpVersion) aabName = ((String) apkNames[0]).replaceFirst("\\.apk\$", ".aab") def outputDir = new File(buildDir, "outputs/bundle/${variantName}") @@ -42,8 +42,8 @@ class PackageAppTaskCompat { } @PackageScope - static boolean hasSigningConfig(packageAppTask) { - if (AndroidGradlePlugin.isInternalSigningConfigData()) { + static boolean hasSigningConfig(packageAppTask, String agpVersion) { + if (AndroidGradlePlugin.isInternalSigningConfigData(agpVersion)) { return packageAppTask.signingConfigVersions.any { it.exists() } } else { return packageAppTask.signingConfigData.resolve() != null @@ -54,8 +54,8 @@ class PackageAppTaskCompat { return packageAppTask.outputDirectory.getAsFile().get() } - static Collection getApkNames(packageAppTask) { - if (AndroidGradlePlugin.hasOutputsHandlerApiOnPackageApplication()) { + static Collection getApkNames(packageAppTask, String agpVersion) { + if (AndroidGradlePlugin.hasOutputsHandlerApiOnPackageApplication(agpVersion)) { return packageAppTask.outputsHandler.get().getOutputs { true }.collect { it.outputFileName } } else { return packageAppTask.variantOutputs.get().collect { it.outputFileName.get() } diff --git a/src/main/groovy/com/deploygate/gradle/plugins/dsl/DeployGateExtension.groovy b/src/main/groovy/com/deploygate/gradle/plugins/dsl/DeployGateExtension.groovy index 75b02a7c..af7f497e 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/dsl/DeployGateExtension.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/dsl/DeployGateExtension.groovy @@ -10,6 +10,8 @@ class DeployGateExtension implements ExtensionSyntax { String appOwnerName + String endpoint = 'https://deploygate.com' + @NotNull private final NamedDomainObjectContainer deployments diff --git a/src/main/groovy/com/deploygate/gradle/plugins/internal/agp/AndroidGradlePlugin.groovy b/src/main/groovy/com/deploygate/gradle/plugins/internal/agp/AndroidGradlePlugin.groovy index dadc988e..9105811c 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/internal/agp/AndroidGradlePlugin.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/internal/agp/AndroidGradlePlugin.groovy @@ -8,21 +8,17 @@ import org.jetbrains.annotations.NotNull import org.slf4j.Logger class AndroidGradlePlugin { - private static VersionString AGP_VERSION - static void ifPresent(@NotNull Project project, @NotNull Action onFound) { try { def agpPlugin = project.plugins.findPlugin("com.android.application") if (agpPlugin) { - AGP_VERSION = VersionString.tryParse(getVersionString(agpPlugin.class.classLoader)) checkModelLevel(agpPlugin.class.classLoader, project.logger) onFound.execute("dummy") } else { project.plugins.matching { it.class.name == "com.android.build.gradle.AppPlugin" }.whenPluginAdded { Plugin plugin -> project.logger.warn("com.android.application should be applied before DeployGate plugin") - AGP_VERSION = VersionString.tryParse(getVersionString(plugin.class.classLoader)) - checkModelLevel(agpPlugin.class.classLoader, project.logger) + checkModelLevel(plugin.class.classLoader, project.logger) onFound.execute("dummy") } } @@ -31,27 +27,41 @@ class AndroidGradlePlugin { } } - static VersionString getVersion() { - if (!AGP_VERSION) { - AGP_VERSION = VersionString.tryParse(getVersionString()) - } - - return AGP_VERSION + /** + * Parses the AGP version string into a VersionString object. + * This method no longer caches the version in a static field to support configuration cache. + * + * @param versionString The AGP version string to parse (e.g., "8.1.0") + * @return Parsed VersionString object, or null if parsing fails + * @since 3.0.0 + */ + static VersionString getVersion(@NotNull String versionString) { + return VersionString.tryParse(versionString) } /** + * Checks if AGP uses internal signing config data structure. + * This change was introduced in AGP 8.3.0. + * + * @param versionString The AGP version string + * @return true if AGP version is 8.3.0 or higher * @since AGP 8.3.0 https://cs.android.com/android-studio/platform/tools/base/+/ff361912406f0eafc42b6ff2a293ee8a17ff77ee:build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/PackageAndroidArtifact.kt;dlc=c2e97e2ca61a5575ccfb48f9528a11c38d651841 */ - static boolean isInternalSigningConfigData() { - def version = getVersion() + static boolean isInternalSigningConfigData(@NotNull String versionString) { + def version = getVersion(versionString) return version.major >= 8 && version.minor >= 3 } /** + * Checks if AGP has the OutputsHandler API on PackageApplication task. + * This API was introduced in AGP 8.1.0. + * + * @param versionString The AGP version string + * @return true if AGP version is 8.1.0 or higher * @since AGP 8.1.0 https://android.googlesource.com/platform/tools/base/+/da5cbdf59f91f7480a5d9615a20f766d19c6034a%5E%21/#F32 */ - static boolean hasOutputsHandlerApiOnPackageApplication() { - def version = getVersion() + static boolean hasOutputsHandlerApiOnPackageApplication(@NotNull String versionString) { + def version = getVersion(versionString) return version.major >= 8 && version.minor >= 1 } @@ -71,7 +81,7 @@ class AndroidGradlePlugin { * @param classLoader might be based on AGP's class loader * @return */ - private static String getVersionString(ClassLoader classLoader) { + static String getVersionString(ClassLoader classLoader) { try { return classLoader.loadClass("com.android.Version").getField("ANDROID_GRADLE_PLUGIN_VERSION").get(null) } catch (Throwable ignored) { diff --git a/src/main/groovy/com/deploygate/gradle/plugins/internal/gradle/GradleCompat.groovy b/src/main/groovy/com/deploygate/gradle/plugins/internal/gradle/GradleCompat.groovy index cb2ebfcc..75823dea 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/internal/gradle/GradleCompat.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/internal/gradle/GradleCompat.groovy @@ -6,30 +6,39 @@ import org.gradle.api.provider.Provider import org.jetbrains.annotations.NotNull class GradleCompat { - private static VersionString GRADLE_VERSION - private GradleCompat() { } + /** + * @deprecated No longer needed for initialization + */ + @Deprecated static void init(@NotNull Project project) { - GRADLE_VERSION = VersionString.tryParse(project.gradle.gradleVersion) + // No-op for backwards compatibility } + /** + * Get the current Gradle version from the project + * @param project the project to get the version from + * @return the parsed version string + */ @NotNull - static VersionString getVersion() { - if (!GRADLE_VERSION) { - throw new IllegalStateException("must be initialized") - } - - return GRADLE_VERSION + static VersionString getVersion(@NotNull Project project) { + return VersionString.tryParse(project.gradle.gradleVersion) } + /** + * Handle forUseAtConfigurationTime compatibility. + * This method was removed in Gradle 7.0. + * We need to check the Gradle version at runtime. + */ static Provider forUseAtConfigurationTime(Provider provider) { - if (getVersion().major >= 7) { - // removed since 7.0 ref: https://github.com/gradle/gradle/issues/15600 - return provider - } else { + try { + // Try to call forUseAtConfigurationTime if it exists (Gradle < 7.0) return provider.forUseAtConfigurationTime() + } catch (MissingMethodException e) { + // Method doesn't exist in Gradle 7.0+, just return the provider + return provider } } } diff --git a/src/main/groovy/com/deploygate/gradle/plugins/internal/http/HttpClient.java b/src/main/groovy/com/deploygate/gradle/plugins/internal/http/HttpClient.java index 9c9a87e8..c992bf06 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/internal/http/HttpClient.java +++ b/src/main/groovy/com/deploygate/gradle/plugins/internal/http/HttpClient.java @@ -1,7 +1,5 @@ package com.deploygate.gradle.plugins.internal.http; -import com.deploygate.gradle.plugins.Config; -import com.deploygate.gradle.plugins.internal.agp.AndroidGradlePlugin; import com.deploygate.gradle.plugins.tasks.inputs.Credentials; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -40,6 +38,14 @@ public abstract class HttpClient implements BuildService, Aut interface Params extends BuildServiceParameters { Property getEndpoint(); + + Property getAgpVersion(); + + Property getPluginVersion(); + + Property getPluginVersionCode(); + + Property getPluginVersionName(); } @NotNull private final org.apache.hc.client5.http.classic.HttpClient httpClient; @@ -50,17 +56,15 @@ public HttpClient() { this.endpoint = getParameters().getEndpoint().get(); List headers = new ArrayList<>(); + String versionCode = getParameters().getPluginVersionCode().get(); + String version = getParameters().getPluginVersion().get(); + String versionName = getParameters().getPluginVersionName().get(); + + headers.add(new BasicHeader("X-DEPLOYGATE-CLIENT-ID", "gradle-plugin/" + versionCode)); headers.add( - new BasicHeader( - "X-DEPLOYGATE-CLIENT-ID", "gradle-plugin/" + Config.getVERSION_CODE())); - headers.add( - new BasicHeader( - "X-DEPLOYGATE-CLIENT-VERSION-NAME", - Config.getVERSION() + "-" + Config.getVERSION_NAME())); - headers.add( - new BasicHeader( - "X-DEPLOYGATE-GRADLE-PLUGIN-AGP-VERSION", - String.valueOf(AndroidGradlePlugin.getVersion()))); + new BasicHeader("X-DEPLOYGATE-CLIENT-VERSION-NAME", version + "-" + versionName)); + String agpVersion = getParameters().getAgpVersion().getOrElse("unknown"); + headers.add(new BasicHeader("X-DEPLOYGATE-GRADLE-PLUGIN-AGP-VERSION", agpVersion)); RequestConfig requestConfig = RequestConfig.custom() @@ -74,7 +78,9 @@ public HttpClient() { this.httpClient = HttpClientBuilder.create() .useSystemProperties() - .setUserAgent("gradle-deploygate-plugin/" + Config.getVERSION()) + .setUserAgent( + "gradle-deploygate-plugin/" + + getParameters().getPluginVersion().get()) .setDefaultHeaders(headers) .setDefaultRequestConfig(requestConfig) .build(); diff --git a/src/main/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtils.groovy b/src/main/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtils.groovy index 464bd8fd..da1a2a4e 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtils.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtils.groovy @@ -1,32 +1,142 @@ package com.deploygate.gradle.plugins.internal.utils +import static com.deploygate.gradle.plugins.internal.gradle.ProviderFactoryUtils.environmentVariable + +import com.deploygate.gradle.plugins.internal.gradle.GradleCompat +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory import org.jetbrains.annotations.NotNull class BrowserUtils { @NotNull - private static String getOS_NAME() { + static String getOS_NAME() { return (System.getProperty("os.name") ?: "unknown").toLowerCase(Locale.US) } + // Legacy methods for backward compatibility with tests static boolean openBrowser(@NotNull String url) { - if (hasBrowser()) { - try { - if (isExecutableOnMacOS()) { - return openBrowserForMac(url) - } else if (isExecutableOnWindows()) { - return openBrowserForWindows(url) - } else if (isExecutableOnLinux()) { - return openBrowserForLinux(url) - } else { - return false - } - } catch (ignored) { - } + return openBrowserLegacy(url) + } + + static boolean hasBrowser() { + return hasBrowserLegacy() + } + + static boolean isExecutableOnLinux() { + return getOS_NAME().startsWith("linux") && isDisplayAvailable() + } + + static boolean isExecutableOnMacOS() { + return getOS_NAME().startsWith("mac") + } + + static boolean isExecutableOnWindows() { + return getOS_NAME().startsWith("windows") + } + + static boolean isDisplayAvailable() { + String display = System.getenv("DISPLAY") + return display != null && !display.trim().isEmpty() + } + + static boolean isCiEnvironment() { + System.getenv('CI') == "true" || System.getenv('JENKINS_URL') + } + + /** + * Opens a browser with the specified URL using configuration cache compatible approach. + * This method uses Gradle's Provider API to defer environment and system property access + * until task execution time, ensuring compatibility with configuration cache. + * + * @param url The URL to open in the browser + * @param providers The ProviderFactory to access environment variables and system properties + * @return true if the browser was successfully opened, false otherwise + * @since 3.0.0 + */ + static boolean openBrowser(@NotNull String url, @NotNull ProviderFactory providers) { + def osNameProvider = GradleCompat.forUseAtConfigurationTime(providers.systemProperty("os.name")) + def displayProvider = environmentVariable(providers, "DISPLAY") + def ciProvider = environmentVariable(providers, "CI") + def jenkinsUrlProvider = environmentVariable(providers, "JENKINS_URL") + return openBrowser(url, osNameProvider, displayProvider, ciProvider, jenkinsUrlProvider) + } + + /** + * Checks if a browser is available in the current environment using configuration cache compatible approach. + * This method uses Gradle's Provider API to defer environment checks until task execution time. + * + * @param providers The ProviderFactory to access environment variables and system properties + * @return true if a browser is available and the environment is not CI, false otherwise + * @since 3.0.0 + */ + static boolean hasBrowser(@NotNull ProviderFactory providers) { + def osNameProvider = GradleCompat.forUseAtConfigurationTime(providers.systemProperty("os.name")) + def displayProvider = environmentVariable(providers, "DISPLAY") + def ciProvider = environmentVariable(providers, "CI") + def jenkinsUrlProvider = environmentVariable(providers, "JENKINS_URL") + return hasBrowser(osNameProvider, displayProvider, ciProvider, jenkinsUrlProvider) + } + + @NotNull + private static String getOSNameFromProvider(Provider osNameProvider) { + return (osNameProvider.getOrElse("unknown")).toLowerCase(Locale.US) + } + + /** + * Opens a browser with the specified URL using individual Provider instances. + * This overload provides fine-grained control over provider sources for advanced use cases. + * + * @param url The URL to open in the browser + * @param osNameProvider Provider for the OS name system property + * @param displayProvider Provider for the DISPLAY environment variable + * @param ciProvider Provider for the CI environment variable + * @param jenkinsUrlProvider Provider for the JENKINS_URL environment variable + * @return true if the browser was successfully opened, false otherwise + * @since 3.0.0 + */ + static boolean openBrowser(@NotNull String url, Provider osNameProvider, Provider displayProvider, Provider ciProvider, Provider jenkinsUrlProvider) { + if (hasBrowser(osNameProvider, displayProvider, ciProvider, jenkinsUrlProvider)) { + def osName = getOSNameFromProvider(osNameProvider) + return executeBrowserCommand(url, osName, + isExecutableOnLinux(osNameProvider, displayProvider)) } false } + // Legacy method without providers + private static boolean openBrowserLegacy(@NotNull String url) { + if (hasBrowserLegacy()) { + return executeBrowserCommand(url, OS_NAME, isExecutableOnLinux()) + } + false + } + + /** + * Executes the appropriate browser command based on the operating system. + * This method contains the common logic for opening browsers across different OS. + * + * @param url The URL to open + * @param osName The normalized OS name (lowercase) + * @param isLinuxExecutable Whether Linux is executable (has display) + * @return true if browser was opened successfully, false otherwise + */ + private static boolean executeBrowserCommand(@NotNull String url, @NotNull String osName, boolean isLinuxExecutable) { + try { + if (osName.startsWith("mac")) { + return openBrowserForMac(url) + } else if (osName.startsWith("windows")) { + return openBrowserForWindows(url) + } else if (osName.startsWith("linux") && isLinuxExecutable) { + return openBrowserForLinux(url) + } else { + return false + } + } catch (ignored) { + return false + } + } + static boolean openBrowserForMac(@NotNull String url) { return ['open', url].execute().waitFor() == 0 } @@ -48,28 +158,43 @@ class BrowserUtils { } } - static boolean hasBrowser() { + /** + * Checks if a browser is available using individual Provider instances. + * This method evaluates OS compatibility and CI environment status using providers. + * + * @param osNameProvider Provider for the OS name system property + * @param displayProvider Provider for the DISPLAY environment variable (Linux) + * @param ciProvider Provider for the CI environment variable + * @param jenkinsUrlProvider Provider for the JENKINS_URL environment variable + * @return true if a browser is available and not in CI environment, false otherwise + * @since 3.0.0 + */ + static boolean hasBrowser(Provider osNameProvider, Provider displayProvider, Provider ciProvider, Provider jenkinsUrlProvider) { + !isCiEnvironment(ciProvider, jenkinsUrlProvider) && (isExecutableOnMacOS(osNameProvider) || isExecutableOnWindows(osNameProvider) || isExecutableOnLinux(osNameProvider, displayProvider)) + } + + private static boolean hasBrowserLegacy() { !isCiEnvironment() && (isExecutableOnMacOS() || isExecutableOnWindows() || isExecutableOnLinux()) } - static boolean isExecutableOnLinux() { - return OS_NAME.startsWith("linux") && isDisplayAvailable() + static boolean isExecutableOnLinux(Provider osNameProvider, Provider displayProvider) { + return getOSNameFromProvider(osNameProvider).startsWith("linux") && isDisplayAvailable(displayProvider) } - static boolean isExecutableOnMacOS() { - return OS_NAME.startsWith("mac") + static boolean isExecutableOnMacOS(Provider osNameProvider) { + return getOSNameFromProvider(osNameProvider).startsWith("mac") } - static boolean isExecutableOnWindows() { - return OS_NAME.startsWith("windows") + static boolean isExecutableOnWindows(Provider osNameProvider) { + return getOSNameFromProvider(osNameProvider).startsWith("windows") } - static boolean isDisplayAvailable() { - String display = System.getenv("DISPLAY") + static boolean isDisplayAvailable(Provider displayProvider) { + String display = displayProvider.getOrNull() return display != null && !display.trim().isEmpty() } - static boolean isCiEnvironment() { - System.getenv('CI') == "true" || System.getenv('JENKINS_URL') + static boolean isCiEnvironment(Provider ciProvider, Provider jenkinsUrlProvider) { + ciProvider.getOrElse("") == "true" || jenkinsUrlProvider.isPresent() } } diff --git a/src/main/groovy/com/deploygate/gradle/plugins/tasks/LoginTask.java b/src/main/groovy/com/deploygate/gradle/plugins/tasks/LoginTask.java index c37a905d..06193ce9 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/tasks/LoginTask.java +++ b/src/main/groovy/com/deploygate/gradle/plugins/tasks/LoginTask.java @@ -36,6 +36,8 @@ public abstract class LoginTask extends DefaultTask { @NotNull private final Credentials credentials; + @NotNull private final ProviderFactory providerFactory; + @Inject public LoginTask( @NotNull ObjectFactory objectFactory, @NotNull ProviderFactory providerFactory) { @@ -54,6 +56,8 @@ public LoginTask( credentials = objectFactory.newInstance(Credentials.class); + this.providerFactory = providerFactory; + setDescription( "Check the configured credentials and launch the authentication flow if they are" + " not enough."); @@ -173,7 +177,7 @@ public void execute() { + " persists."); } - System.out.printf(Locale.US, "Welcome %s!%n", store.getName()); + getLogger().lifecycle("Welcome {}!", store.getName()); // We can set the values unless they are found because of the idempotency. setIfAbsent(credentials.getAppOwnerName(), store.getName()); @@ -187,7 +191,7 @@ public void execute() { */ @VisibleForTesting boolean setupCredential() { - if (BrowserUtils.hasBrowser()) { + if (BrowserUtils.hasBrowser(providerFactory)) { return setupBrowser(); } else { return setupTerminal(); @@ -230,11 +234,13 @@ private void openBrowser(int port) { String url = getHttpClient().get().buildURI(params, "cli", "login").toString(); - if (!BrowserUtils.openBrowser(url)) { + if (!BrowserUtils.openBrowser(url, providerFactory)) { getLogger().error("Could not open a browser on current environment."); - System.out.println( - "Please log in to DeployGate by opening the following URL on your browser:"); - System.out.println(url); + getLogger() + .lifecycle( + "Please log in to DeployGate by opening the following URL on your" + + " browser:"); + getLogger().lifecycle(url); } } } diff --git a/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadAabTask.groovy b/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadAabTask.groovy index e4458a58..47e716db 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadAabTask.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadAabTask.groovy @@ -8,6 +8,7 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested import org.gradle.api.tasks.TaskAction import org.jetbrains.annotations.NotNull import org.jetbrains.annotations.VisibleForTesting @@ -27,7 +28,7 @@ abstract class UploadAabTask extends UploadArtifactTask { ) } - @Internal + @Nested final Property aabInfo @Inject @@ -48,10 +49,4 @@ abstract class UploadAabTask extends UploadArtifactTask { doUpload(inputParams) } - - @Internal - @Override - String getDescription() { - return "Deploy bundled ${inputParamsProvider.get().variantName} to DeployGate" - } } diff --git a/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadApkTask.groovy b/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadApkTask.groovy index 9971b2e1..4e7bb0d7 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadApkTask.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadApkTask.groovy @@ -8,6 +8,7 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested import org.gradle.api.tasks.TaskAction import org.jetbrains.annotations.NotNull import org.jetbrains.annotations.VisibleForTesting @@ -27,7 +28,7 @@ abstract class UploadApkTask extends UploadArtifactTask { ) } - @Internal + @Nested final Property apkInfo @Inject @@ -42,19 +43,6 @@ abstract class UploadApkTask extends UploadArtifactTask { return apkInfo.map { apk -> createInputParams(apk, deployment) } } - @Internal - @Override - String getDescription() { - def inputParams = inputParamsProvider.get() - - if (inputParams.isSigningReady) { - return "Deploy assembled ${inputParams.variantName} to DeployGate" - } else { - // require signing config to build a signed APKs - return "Deploy assembled ${inputParams.variantName} to DeployGate (requires valid signingConfig setting)" - } - } - @TaskAction void execute() { def inputParams = inputParamsProvider.get() diff --git a/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTask.groovy b/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTask.groovy index a47899ca..06fbcf9e 100644 --- a/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTask.groovy +++ b/src/main/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTask.groovy @@ -13,6 +13,7 @@ import org.gradle.api.file.RegularFile import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory import org.gradle.api.tasks.* import org.jetbrains.annotations.NotNull import org.jetbrains.annotations.Nullable @@ -70,9 +71,19 @@ abstract class UploadArtifactTask extends DefaultTask { @Internal final Property httpClient + @Input + final Property endpoint + + @Input + @Optional + final Property openBrowserAfterUpload + @OutputFile final Provider response + @javax.inject.Inject + abstract ProviderFactory getProviderFactory() + UploadArtifactTask(@NotNull ObjectFactory objectFactory, @NotNull ProjectLayout projectLayout) { super() group = Constants.TASK_GROUP_NAME @@ -80,6 +91,8 @@ abstract class UploadArtifactTask extends DefaultTask { credentials = objectFactory.property(Credentials) deployment = objectFactory.newInstance(DeploymentConfiguration) httpClient = objectFactory.property(HttpClient) + endpoint = objectFactory.property(String) + openBrowserAfterUpload = objectFactory.property(Boolean) response = projectLayout.buildDirectory.file([ "deploygate", @@ -115,8 +128,9 @@ abstract class UploadArtifactTask extends DefaultTask { def hasNotified = httpClient.get().lifecycleNotificationClient.notifyOnSuccessOfArtifactUpload(uploadResponse.typedResponse.application.path) - if (!hasNotified && (Config.shouldOpenAppDetailAfterUpload() || uploadResponse.typedResponse.application.revision == 1)) { - BrowserUtils.openBrowser "${project.deploygate.endpoint}${uploadResponse.typedResponse.application.path}" + def shouldOpenBrowser = openBrowserAfterUpload.getOrElse(false) + if (!hasNotified && (shouldOpenBrowser || uploadResponse.typedResponse.application.revision == 1)) { + BrowserUtils.openBrowser("${endpoint.get()}${uploadResponse.typedResponse.application.path}", providerFactory) } } catch (Throwable e) { logger.debug(e.message, e) diff --git a/src/test/groovy/com/deploygate/gradle/plugins/ConfigurationCacheSpec.groovy b/src/test/groovy/com/deploygate/gradle/plugins/ConfigurationCacheSpec.groovy new file mode 100644 index 00000000..3541e3fc --- /dev/null +++ b/src/test/groovy/com/deploygate/gradle/plugins/ConfigurationCacheSpec.groovy @@ -0,0 +1,331 @@ +package com.deploygate.gradle.plugins + +import org.gradle.testkit.runner.GradleRunner +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Tests to verify the plugin's compatibility with Gradle's configuration cache feature. + * These tests ensure that the plugin can be used with --configuration-cache flag + * without any configuration cache problems. + */ +class ConfigurationCacheSpec extends Specification { + + @Rule + TemporaryFolder testProjectDir = new TemporaryFolder() + + File buildFile + File settingsFile + + def setup() { + buildFile = testProjectDir.newFile('build.gradle') + settingsFile = testProjectDir.newFile('settings.gradle') + def localPropertiesFile = testProjectDir.newFile('local.properties') + + settingsFile << ''' + rootProject.name = 'test-project' + ''' + + // Set Android SDK location + def androidHome = System.getenv("ANDROID_HOME") ?: "${System.getProperty('user.home')}/Android/Sdk" + localPropertiesFile << "sdk.dir=${androidHome}" + } + + /** + * Creates the plugin classpath for GradleRunner. + * This method loads the plugin classpath from the test resources. + */ + private List createPluginClasspath() { + def pluginClasspathResource = getClass().classLoader.getResource("plugin-classpath.txt") + + if (pluginClasspathResource == null) { + throw new IllegalStateException( + "Did not find plugin classpath resource, run `createClasspathManifest` gradle task.") + } + + return pluginClasspathResource.readLines().collect { new File(it) } + } + + @Unroll + def "plugin supports configuration cache with #taskName task"() { + given: "A project with the DeployGate plugin applied" + // Add Android plugin repository + buildFile << """ + buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.2.0' + classpath files(${createPluginClasspath().collect { "'${it.absolutePath}'" + }.join(', ') + }) + } + } + + apply plugin: 'com.android.application' + apply plugin: 'deploygate' + + repositories { + google() + mavenCentral() + } + + android { + namespace 'com.example.test' + compileSdkVersion 33 + + defaultConfig { + applicationId "com.example.test" + minSdkVersion 21 + targetSdkVersion 33 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + } + } + } + + deploygate { + appOwnerName = "test-owner" + apiToken = "test-token" + + deployments { + debug { + skipAssemble = true + } + release { + skipAssemble = true + } + } + } + """ + +when: "Running the task with configuration cache enabled" +def result = GradleRunner.create() + .withProjectDir(testProjectDir.root) + .withPluginClasspath(createPluginClasspath()) + .withArguments('--configuration-cache', taskName, '--dry-run') + .build() + +then: "The task runs successfully without configuration cache problems" +result.output.contains('Configuration cache entry stored') +!result.output.contains('Configuration cache problems found') + +when: "Running the same task again to reuse the configuration cache" +def cachedResult = GradleRunner.create() + .withProjectDir(testProjectDir.root) + .withPluginClasspath(createPluginClasspath()) + .withArguments('--configuration-cache', taskName, '--dry-run') + .build() + +then: "The configuration cache is reused successfully" +cachedResult.output.contains('Configuration cache entry reused') +!cachedResult.output.contains('Configuration cache problems found') + +where: +taskName << [ + 'loginDeployGate', + 'logoutDeployGate', + 'uploadDeployGateDebug', + 'uploadDeployGateRelease' +] +} + +def "plugin properly handles environment variables with configuration cache"() { +given: "A project using environment variables" +buildFile << """ + buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.2.0' + classpath files(${createPluginClasspath().collect { "'${it.absolutePath}'" + }.join(', ') +}) + } + } + + apply plugin: 'com.android.application' + apply plugin: 'deploygate' + + repositories { + google() + mavenCentral() + } + + android { + namespace 'com.example.test' + compileSdkVersion 33 + + defaultConfig { + applicationId "com.example.test" + minSdkVersion 21 + targetSdkVersion 33 + } + } + + deploygate { + // These will be read from environment variables + } + """ + +and: "Environment variables are set" +def env = [ +'DEPLOYGATE_APP_OWNER_NAME': 'env-owner', +'DEPLOYGATE_API_TOKEN': 'env-token', +'DEPLOYGATE_OPEN_BROWSER': 'false' +] + +when: "Running with configuration cache" +def result = GradleRunner.create() +.withProjectDir(testProjectDir.root) +.withPluginClasspath(createPluginClasspath()) +.withEnvironment(env) +.withArguments('--configuration-cache', 'loginDeployGate', '--dry-run') +.build() + +then: "Environment variables are properly handled" +result.output.contains('Configuration cache entry stored') +!result.output.contains('Configuration cache problems found') +} + +def "plugin BuildServices work correctly with configuration cache"() { +given: "A project that uses HttpClient BuildService" +buildFile << """ + buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.2.0' + classpath files(${createPluginClasspath().collect { "'${it.absolutePath}'" +}.join(', ') +}) + } + } + + apply plugin: 'com.android.application' + apply plugin: 'deploygate' + + repositories { + google() + mavenCentral() + } + + android { + namespace 'com.example.test' + compileSdkVersion 33 + + defaultConfig { + applicationId "com.example.test" + minSdkVersion 21 + targetSdkVersion 33 + } + } + + deploygate { + appOwnerName = "test-owner" + apiToken = "test-token" + } + + tasks.register('testBuildService') { + doLast { + println "BuildService test task executed" + } + dependsOn 'loginDeployGate' + } + """ + +when: "Running custom task with configuration cache" +def result = GradleRunner.create() +.withProjectDir(testProjectDir.root) +.withPluginClasspath(createPluginClasspath()) +.withArguments('--configuration-cache', 'testBuildService', '--dry-run') +.build() + +then: "BuildServices are properly registered and reused" +result.output.contains('Configuration cache entry stored') +!result.output.contains('Configuration cache problems found') +} + +def "provider chains work correctly with configuration cache"() { +given: "A project with custom deployments" +buildFile << """ + buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:4.2.0' + classpath files(${createPluginClasspath().collect { "'${it.absolutePath}'" +}.join(', ') +}) + } + } + + apply plugin: 'com.android.application' + apply plugin: 'deploygate' + + repositories { + google() + mavenCentral() + } + + android { + namespace 'com.example.test' + compileSdkVersion 33 + + defaultConfig { + applicationId "com.example.test" + minSdkVersion 21 + targetSdkVersion 33 + } + + buildTypes { + release { + minifyEnabled false + } + } + } + + deploygate { + appOwnerName = "test-owner" + apiToken = "test-token" + + deployments { + customRelease { + message = "Custom release build" + skipAssemble = false + distribution { + key = "dist-key" + releaseNote = "Release notes" + } + } + } + } + """ + +when: "Running deployment task with configuration cache" +def result = GradleRunner.create() +.withProjectDir(testProjectDir.root) +.withPluginClasspath(createPluginClasspath()) +.withArguments('--configuration-cache', 'uploadDeployGateCustomRelease', '--dry-run') +.build() + +then: "Complex provider chains are handled correctly" +result.output.contains('Configuration cache entry stored') +!result.output.contains('Configuration cache problems found') +} +} \ No newline at end of file diff --git a/src/test/groovy/com/deploygate/gradle/plugins/TestHelper.groovy b/src/test/groovy/com/deploygate/gradle/plugins/TestHelper.groovy new file mode 100644 index 00000000..8450145d --- /dev/null +++ b/src/test/groovy/com/deploygate/gradle/plugins/TestHelper.groovy @@ -0,0 +1,197 @@ +package com.deploygate.gradle.plugins + +import org.gradle.api.Project +import org.gradle.api.provider.ProviderFactory +import org.gradle.testfixtures.ProjectBuilder + +/** + * Test helper utilities for creating test fixtures and reducing boilerplate in tests. + * Provides factory methods for common test scenarios. + */ +class TestHelper { + + /** + * Creates a project with the DeployGate plugin applied and basic Android configuration. + * + * @param projectName The name of the test project + * @param config Optional closure for additional project configuration + * @return Configured Project instance + */ + static Project createAndroidProjectWithPlugin(String projectName = "test-project", Closure config = null) { + def project = ProjectBuilder.builder() + .withName(projectName) + .build() + + // Apply Android plugin first (required by DeployGate plugin) + project.apply plugin: 'com.android.application' + + // Apply DeployGate plugin + project.apply plugin: DeployGatePlugin + + // Basic Android configuration + project.android { + namespace 'com.example.test' + compileSdkVersion 33 + + defaultConfig { + applicationId "com.example.test" + minSdkVersion 21 + targetSdkVersion 33 + versionCode 1 + versionName "1.0" + } + } + + // Apply additional configuration if provided + if (config) { + project.configure(project, config) + } + + return project + } + + /** + * Creates a project with mocked providers for testing configuration cache scenarios. + * + * @param environmentVars Map of environment variable names to values + * @param systemProperties Map of system property names to values + * @return Project with configured providers + */ + static Project createProjectWithMockedProviders( + Map environmentVars = [:], + Map systemProperties = [:]) { + + def project = ProjectBuilder.builder().build() + + // Create a custom provider factory that returns our test values + def providers = project.providers + + // Note: In real tests, we can't easily override provider values, + // but this shows the pattern for test setup + + return project + } + + /** + * Creates test credentials for DeployGate tasks. + * + * @param appOwnerName The app owner name + * @param apiToken The API token + * @return Map containing credential values + */ + static Map createTestCredentials( + String appOwnerName = "test-owner", + String apiToken = "test-token") { + return [ + appOwnerName: appOwnerName, + apiToken: apiToken + ] + } + + /** + * Creates a minimal build.gradle content for testing. + * + * @param additionalConfig Additional configuration to add to the build file + * @return Build file content as a string + */ + static String createBuildFileContent(String additionalConfig = "") { + return """ + plugins { + id 'com.android.application' + id 'deploygate' + } + + android { + namespace 'com.example.test' + compileSdkVersion 33 + + defaultConfig { + applicationId "com.example.test" + minSdkVersion 21 + targetSdkVersion 33 + versionCode 1 + versionName "1.0" + } + } + + deploygate { + appOwnerName = "test-owner" + apiToken = "test-token" + } + + ${additionalConfig} + """.stripIndent() + } + + /** + * Creates environment variables map for testing browser detection. + * + * @param ci Whether to simulate CI environment + * @param display Display value for Linux environments + * @return Map of environment variables + */ + static Map createBrowserEnvironment( + boolean ci = false, + String display = null) { + def env = [:] + + if (ci) { + env['CI'] = 'true' + } + + if (display != null) { + env['DISPLAY'] = display + } + + return env + } + + /** + * Creates a mock HTTP client configuration for testing. + * + * @param endpoint The test server endpoint + * @return Map of HTTP client parameters + */ + static Map createHttpClientConfig(String endpoint = "https://test.deploygate.com") { + return [ + endpoint: endpoint, + agpVersion: "8.1.0", + pluginVersion: "test-version", + pluginVersionCode: "1", + pluginVersionName: "test" + ] + } + + /** + * Helper to create provider-based test values. + * + * @param providers ProviderFactory instance + * @param value The value to wrap in a provider + * @return Provider containing the value + */ + static def createProvider(ProviderFactory providers, Object value) { + return providers.provider { value } + } + + /** + * Creates a test deployment configuration. + * + * @param name Deployment name + * @param message Deployment message + * @param skipAssemble Whether to skip assembly + * @return Deployment configuration string + */ + static String createDeploymentConfig( + String name = "debug", + String message = "Test deployment", + boolean skipAssemble = true) { + return """ + deployments { + ${name} { + message = "${message}" + skipAssemble = ${skipAssemble} + } + } + """ + } +} \ No newline at end of file diff --git a/src/test/groovy/com/deploygate/gradle/plugins/internal/http/ApiClientSpec.groovy b/src/test/groovy/com/deploygate/gradle/plugins/internal/http/ApiClientSpec.groovy index 47d6d844..b0cc9f3a 100644 --- a/src/test/groovy/com/deploygate/gradle/plugins/internal/http/ApiClientSpec.groovy +++ b/src/test/groovy/com/deploygate/gradle/plugins/internal/http/ApiClientSpec.groovy @@ -29,7 +29,11 @@ class ApiClientSpec extends Specification { credentials.appOwnerName.set(appOwnerName) credentials.apiToken.set(apiToken) def client = project.gradle.sharedServices.registerIfAbsent("httpclient", HttpClient) { spec -> - spec.parameters.endpoint.set(System.getenv("TEST_SERVER_URL")) + spec.parameters.endpoint.set(System.getenv("TEST_SERVER_URL") ?: "https://deploygate.com") + spec.parameters.agpVersion.set("unknown") + spec.parameters.pluginVersion.set("test") + spec.parameters.pluginVersionCode.set("1") + spec.parameters.pluginVersionName.set("test") }.get().getApiClient(credentials) and: diff --git a/src/test/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtilsConfigurationCacheSpec.groovy b/src/test/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtilsConfigurationCacheSpec.groovy new file mode 100644 index 00000000..368224ad --- /dev/null +++ b/src/test/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtilsConfigurationCacheSpec.groovy @@ -0,0 +1,136 @@ +package com.deploygate.gradle.plugins.internal.utils + +import com.deploygate.gradle.plugins.internal.gradle.GradleCompat +import org.gradle.api.Project +import org.gradle.api.provider.ProviderFactory +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Tests for BrowserUtils provider-based API. + * These are unit tests that verify the provider-based methods work correctly. + * Note: Configuration cache restrictions only apply during actual Gradle builds, + * not in unit tests, so we focus on API functionality here. + */ +class BrowserUtilsConfigurationCacheSpec extends Specification { + + Project project + ProviderFactory providers + + def setup() { + project = ProjectBuilder.builder().build() + providers = project.providers + } + + @Unroll + def "hasBrowser with ProviderFactory works correctly in different environments"() { + given: "Mock providers with controlled values" + def osNameProvider = providers.provider { osName } + def displayProvider = providers.provider { display } + def ciProvider = providers.provider { ci } + def jenkinsUrlProvider = providers.provider { jenkinsUrl } + + when: "Checking for browser availability" + def result = BrowserUtils.hasBrowser(osNameProvider, displayProvider, ciProvider, jenkinsUrlProvider) + + then: "Result matches expected behavior" + result == expectedResult + + where: + osName | display | ci | jenkinsUrl | expectedResult + "Mac OS X" | null | "false" | null | true + "Windows" | null | "false" | null | true + "Linux" | ":0" | "false" | null | true + "Linux" | null | "false" | null | false + "Mac OS X" | null | "true" | null | false + "Windows" | null | "false" | "http://ci" | false + } + + def "openBrowser with ProviderFactory executes correctly"() { + given: "A test URL" + def url = "https://deploygate.com/test" + + when: "Checking if browser would be opened (without actually opening)" + // We test the logic by checking preconditions instead of actually opening browser + def wouldOpen = BrowserUtils.hasBrowser(providers) + + then: "Browser availability is correctly determined" + wouldOpen != null + // The actual browser opening would only happen if browser is available + // We don't actually call openBrowser to avoid side effects in tests + } + + @Unroll + def "provider-based method #methodName handles null providers gracefully"() { + when: "Calling method with null providers" + method.call() + + then: "Appropriate exception is thrown" + thrown(NullPointerException) + + where: + methodName | method + "hasBrowser" | { -> BrowserUtils.hasBrowser((ProviderFactory) null) } + "openBrowser" | { -> BrowserUtils.openBrowser("url", (ProviderFactory) null) } + } + + def "legacy methods still work for backward compatibility"() { + when: "Using legacy methods" + def hasBrowser = BrowserUtils.hasBrowser() + def osName = BrowserUtils.OS_NAME + + then: "Methods work as before" + hasBrowser != null + osName != null + } + + def "provider chains are properly constructed"() { + given: "Mock providers that don't access actual environment" + def osNameProvider = providers.provider { "Mac OS X" } + def displayProvider = providers.provider { ":0" } + def ciProvider = providers.provider { "false" } + def jenkinsUrlProvider = providers.provider { null } + + when: "Using provider-based overload" + def result = BrowserUtils.hasBrowser(osNameProvider, displayProvider, ciProvider, jenkinsUrlProvider) + + then: "Result is computed correctly" + result == true + } + + def "CI environment detection works with providers"() { + given: "Providers for CI environment variables" + def ciProvider = providers.provider { "true" } + def jenkinsUrlProvider = providers.provider { null } + + when: "Checking if CI environment" + def isCi = BrowserUtils.isCiEnvironment(ciProvider, jenkinsUrlProvider) + + then: "CI environment is detected" + isCi == true + } + + def "display availability check works with providers"() { + given: "Display provider" + def displayProvider = providers.provider { ":0.0" } + + when: "Checking display availability" + def hasDisplay = BrowserUtils.isDisplayAvailable(displayProvider) + + then: "Display is detected as available" + hasDisplay == true + } + + def "OS detection methods work with providers"() { + given: "OS name providers for different systems" + def macProvider = providers.provider { "Mac OS X" } + def windowsProvider = providers.provider { "Windows 10" } + def linuxProvider = providers.provider { "Linux" } + + expect: "Correct OS is detected" + BrowserUtils.isExecutableOnMacOS(macProvider) == true + BrowserUtils.isExecutableOnWindows(windowsProvider) == true + BrowserUtils.isExecutableOnLinux(linuxProvider, providers.provider { ":0" }) == true + } +} \ No newline at end of file diff --git a/src/test/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtilsSpec.groovy b/src/test/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtilsSpec.groovy index 541f3c24..d9456c3c 100644 --- a/src/test/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtilsSpec.groovy +++ b/src/test/groovy/com/deploygate/gradle/plugins/internal/utils/BrowserUtilsSpec.groovy @@ -19,10 +19,19 @@ class BrowserUtilsSpec extends Specification { -> [waitFor: { -> 0 }] } - BrowserUtils.metaClass.static.hasBrowser = { -> hasBrowser } + // Mock the CI environment to be false for testing browser functionality + BrowserUtils.metaClass.static.isCiEnvironment = { -> false } + BrowserUtils.metaClass.static.hasBrowserLegacy = { -> hasBrowser } BrowserUtils.metaClass.static.isExecutableOnMacOS = { -> onMacOS } BrowserUtils.metaClass.static.isExecutableOnWindows = { -> onWindows } BrowserUtils.metaClass.static.isExecutableOnLinux = { -> onLinux } + BrowserUtils.metaClass.static.getOS_NAME = { + -> + if (onMacOS) return "mac" + if (onWindows) return "windows" + if (onLinux) return "linux" + return "unknown" + } expect: BrowserUtils.openBrowser(url) == result diff --git a/src/test/groovy/com/deploygate/gradle/plugins/tasks/TaskTestHelper.groovy b/src/test/groovy/com/deploygate/gradle/plugins/tasks/TaskTestHelper.groovy new file mode 100644 index 00000000..2264f2ff --- /dev/null +++ b/src/test/groovy/com/deploygate/gradle/plugins/tasks/TaskTestHelper.groovy @@ -0,0 +1,156 @@ +package com.deploygate.gradle.plugins.tasks + +import com.deploygate.gradle.plugins.internal.http.HttpClient +import com.deploygate.gradle.plugins.tasks.inputs.Credentials +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.testfixtures.ProjectBuilder + +/** + * Helper class for creating and configuring tasks in tests. + * Reduces boilerplate code for task setup. + */ +class TaskTestHelper { + + /** + * Creates a configured UploadApkTask for testing. + * + * @param project The project to create the task in + * @param taskName The name of the task + * @param config Optional configuration closure + * @return Configured UploadApkTask + */ + static UploadApkTask createUploadApkTask( + Project project, + String taskName = "testUploadApk", + Closure config = null) { + + def task = project.tasks.create(taskName, UploadApkTask) + + // Set required properties + task.credentials.set(createTestCredentials(project)) + task.httpClient.set(createMockHttpClient(project)) + task.endpoint.set(project.providers.provider { "https://test.deploygate.com" }) + task.openBrowserAfterUpload.set(project.providers.provider { false }) + + if (config) { + project.configure(task, config) + } + + return task + } + + /** + * Creates a configured UploadAabTask for testing. + * + * @param project The project to create the task in + * @param taskName The name of the task + * @param config Optional configuration closure + * @return Configured UploadAabTask + */ + static UploadAabTask createUploadAabTask( + Project project, + String taskName = "testUploadAab", + Closure config = null) { + + def task = project.tasks.create(taskName, UploadAabTask) + + // Set required properties + task.credentials.set(createTestCredentials(project)) + task.httpClient.set(createMockHttpClient(project)) + task.endpoint.set(project.providers.provider { "https://test.deploygate.com" }) + task.openBrowserAfterUpload.set(project.providers.provider { false }) + + if (config) { + project.configure(task, config) + } + + return task + } + + /** + * Creates a configured LoginTask for testing. + * + * @param project The project to create the task in + * @param taskName The name of the task + * @return Configured LoginTask + */ + static LoginTask createLoginTask( + Project project, + String taskName = "testLogin") { + + def task = project.tasks.create(taskName, LoginTask) + + task.explicitAppOwnerName.set("test-owner") + task.explicitApiToken.set("test-token") + task.httpClient.set(createMockHttpClient(project)) + + return task + } + + /** + * Creates test credentials. + * + * @param project The project for object creation + * @param appOwnerName The app owner name + * @param apiToken The API token + * @return Provider of Credentials + */ + static Provider createTestCredentials( + Project project, + String appOwnerName = "test-owner", + String apiToken = "test-token") { + + def credentials = project.objects.newInstance(Credentials) + credentials.appOwnerName.set(project.providers.provider { appOwnerName }) + credentials.apiToken.set(project.providers.provider { apiToken }) + + return project.providers.provider { credentials } + } + + /** + * Creates a mock HttpClient provider for testing. + * + * @param project The project for provider creation + * @return Provider of HttpClient + */ + static Provider createMockHttpClient(Project project) { + // In real tests, this would be properly mocked + // For now, return a provider that would need to be stubbed + return project.providers.provider { null as HttpClient } + } + + /** + * Creates test input parameters for UploadArtifactTask. + * + * @param artifactPath Path to the artifact file + * @param variantName The variant name + * @return InputParams instance + */ + static UploadArtifactTask.InputParams createTestInputParams( + String artifactPath, + String variantName = "debug") { + + def params = new UploadArtifactTask.InputParams() + params.variantName = variantName + params.isSigningReady = true + params.isUniversalApk = true + params.artifactFilePath = artifactPath + params.message = "Test upload" + + return params + } + + /** + * Creates a test artifact file. + * + * @param directory The directory to create the file in + * @param filename The artifact filename + * @return The created file + */ + static File createTestArtifact(File directory, String filename = "test.apk") { + def file = new File(directory, filename) + file.text = "Test artifact content" + return file + } +} \ No newline at end of file diff --git a/src/test/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTaskSpec.groovy b/src/test/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTaskSpec.groovy index df9f09ff..d4fd0680 100644 --- a/src/test/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTaskSpec.groovy +++ b/src/test/groovy/com/deploygate/gradle/plugins/tasks/UploadArtifactTaskSpec.groovy @@ -1,5 +1,6 @@ package com.deploygate.gradle.plugins.tasks +import com.deploygate.gradle.plugins.TestHelper import com.deploygate.gradle.plugins.dsl.DeployGateExtension import com.deploygate.gradle.plugins.dsl.NamedDeployment import com.deploygate.gradle.plugins.internal.credentials.CliCredentialStore @@ -20,13 +21,25 @@ import spock.lang.Specification class UploadArtifactTaskSpec extends Specification { static class UploadArtifactTaskStub extends UploadArtifactTask { @Internal - final Provider inputParamsProvider + Provider inputParamsProvider + + private final ProviderFactory providerFactory @Inject - UploadArtifactTaskStub(@NotNull ObjectFactory objectFactory, @NotNull ProviderFactory providerFactory, @NotNull ProjectLayout projectLayout, @NotNull InputParams inputParams) { + UploadArtifactTaskStub(@NotNull ObjectFactory objectFactory, @NotNull ProviderFactory providerFactory, @NotNull ProjectLayout projectLayout) { super(objectFactory, projectLayout) + this.providerFactory = providerFactory + this.inputParamsProvider = providerFactory.provider { null } + } + + void setInputParams(InputParams inputParams) { this.inputParamsProvider = providerFactory.provider { inputParams } } + + @Override + ProviderFactory getProviderFactory() { + return providerFactory + } } @Rule @@ -52,7 +65,9 @@ class UploadArtifactTaskSpec extends Specification { ) when: "apkFile must exist" - def task = project.tasks.create("UploadArtifactTaskStub2", UploadArtifactTaskStub, inputParams) + def task = project.tasks.create("UploadArtifactTaskStub2", UploadArtifactTaskStub) { t -> + t.setInputParams(inputParams) + } and: task.doUpload(inputParams) diff --git a/src/test/resources/project/build.gradle b/src/test/resources/project/build.gradle index e4adaab8..e782f933 100644 --- a/src/test/resources/project/build.gradle +++ b/src/test/resources/project/build.gradle @@ -31,6 +31,9 @@ android { task printAGPVersion() { doLast { - println AndroidGradlePlugin.getVersion().toArtifactString() + def agpPlugin = project.plugins.findPlugin("com.android.application") + def agpVersionString = AndroidGradlePlugin.getVersionString(agpPlugin.class.classLoader) + def versionObj = AndroidGradlePlugin.getVersion(agpVersionString) + println versionObj.toArtifactString() } }