diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 0655d72..684e526 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -10,6 +10,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ +import com.android.compose.screenshot.tasks.PreviewScreenshotValidationTask import java.util.Properties val abysnerVersion: String = providers.gradleProperty("abysnerVersion").get() @@ -111,8 +112,8 @@ android { experimentalProperties["android.experimental.enableScreenshotTest"] = true } -screenshotTests { - imageDifferenceThreshold = 0.001f // 0.1% +tasks.withType().configureEach { + testEngineInput.threshold = 0.001f // 0.1% } dependencies { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index a8307f7..21ec732 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -26,9 +26,3 @@ gradlePlugin { } } } - -dependencies { - // Required to compile the shadowed Renderer class. - compileOnly(libs.screenshot.validation.junit.engine) - compileOnly(libs.compose.preview.renderer) -} diff --git a/buildSrc/src/main/java/org/neotech/plugin/agent/RenderClassLoaderPatchAgent.java b/buildSrc/src/main/java/org/neotech/plugin/agent/RenderClassLoaderPatchAgent.java new file mode 100644 index 0000000..66aacd8 --- /dev/null +++ b/buildSrc/src/main/java/org/neotech/plugin/agent/RenderClassLoaderPatchAgent.java @@ -0,0 +1,100 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2026 Neotech + * + * Abysner is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License version 3, + * as published by the Free Software Foundation. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package org.neotech.plugin.agent; + +import java.lang.instrument.ClassFileTransformer; +import java.lang.instrument.Instrumentation; +import java.lang.reflect.Field; +import java.security.ProtectionDomain; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; + +/** + * JVM agent that extends the compose screenshot tests render sandbox's classloader allowlist so a + * JVM coverage agent (Kover) can run inside it. Without it, every screenshot render crashes with + * NoClassDefFoundError on the coverage agent's own runtime helper classes. + */ +public final class RenderClassLoaderPatchAgent implements ClassFileTransformer { + + private static final String RENDERER_PACKAGE = "com/android/tools/render/"; + + private static final String CONSTANTS_CLASS = "com.android.tools.render.ClassLoaderConstantsKt"; + private static final String ALLOWLIST_GETTER = "getALLOWED_PACKAGES_FROM_PARENT"; + private static final String COVERAGE_PACKAGE_PREFIX = "com.intellij.rt."; + + // Renderer classes load twice: once in the app classloader, once in the isolated one that + // actually renders. Only the isolated copy matters, but they're indistinguishable from + // here, so every classloader that defines renderer classes gets patched. + private final Set patchedLoaders = + Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())); + + public static void premain(String arguments, Instrumentation instrumentation) { + instrumentation.addTransformer(new RenderClassLoaderPatchAgent()); + } + + @Override + public byte[] transform( + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer + ) { + if (loader != null + && className != null + && className.startsWith(RENDERER_PACKAGE) + // addCoveragePackageToAllowlist() force-loads this class below. It's also under + // RENDERER_PACKAGE, so without this check that load would trigger this same + // branch again, forever. + && !className.equals(CONSTANTS_CLASS.replace('.', '/')) + && !patchedLoaders.contains(loader)) { + if (addCoveragePackageToAllowlist(loader)) { + patchedLoaders.add(loader); + } + } + // This transformer never actually changes any bytecode. Only watches for the trigger class + // to patch the loader. + return null; + } + + // FilteringClassLoader rejects any class outside this allowlist, coverage agent classes + // included. + private static boolean addCoveragePackageToAllowlist(ClassLoader loader) { + try { + // Yeah, this is all a bit hacky, easily breaks, however good enough for our setup. + Class constants = Class.forName(CONSTANTS_CLASS, true, loader); + @SuppressWarnings("unchecked") + List allowed = (List) constants.getMethod(ALLOWLIST_GETTER).invoke(null); + if (allowed.contains(COVERAGE_PACKAGE_PREFIX)) { + return true; + } + // Kotlin's listOf(vararg) returns Arrays.asList, an array-backed view. A bigger + // backing array adds to the allowlist for every render after this one. + Field backingArray = allowed.getClass().getDeclaredField("a"); + backingArray.setAccessible(true); + String[] current = (String[]) backingArray.get(allowed); + String[] extended = Arrays.copyOf(current, current.length + 1); + extended[current.length] = COVERAGE_PACKAGE_PREFIX; + backingArray.set(allowed, extended); + return true; + } catch (Throwable throwable) { + System.err.println( + "w: Unable to extend the renderer classloader allowlist, screenshot test coverage will fail with NoClassDefFoundError: " + throwable + ); + return false; + } + } +} diff --git a/buildSrc/src/main/kotlin/com/android/tools/screenshot/renderer/Renderer.kt b/buildSrc/src/main/kotlin/com/android/tools/screenshot/renderer/Renderer.kt deleted file mode 100644 index bc8305e..0000000 --- a/buildSrc/src/main/kotlin/com/android/tools/screenshot/renderer/Renderer.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.android.tools.screenshot.renderer - -import com.android.tools.render.common.PreviewScreenshot -import com.android.tools.render.common.PreviewScreenshotResult -import com.android.tools.screenshot.PreviewScreenshotTestEngineInput.RendererInput -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.Closeable -import java.io.InputStream -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.io.ObjectStreamClass -import java.io.Serializable -import java.net.URL -import java.net.URLClassLoader -import java.util.Collections -import java.util.Enumeration - -/** - * Shadowed copy of the Renderer from the Android Screenshot Validation JUnit Engine - * (com.android.tools.screenshot:screenshot-validation-junit-engine:0.0.1-alpha14). Placed in the - * same package so it takes precedence on the classpath. The only change is replacing the plain - * URLClassLoader with one that falls back to the SystemClassLoader for resource lookups, allowing - * the Kover coverage agent's ASM frame computation to resolve class resources without breaking - * layoutlib's class loading isolation. - */ -class Renderer : Closeable { - - private val isolatedClassLoaderForRendering: ClassLoader - private val rendererInstance: Closeable - - init { - val fontsPath = RendererInput.fontsPath.absolutePath.ifBlank { null } - val resourceApkPath = RendererInput.resourceApkPath.absolutePath.ifBlank { null } - val namespace = RendererInput.namespace - val classPath = (RendererInput.mainAllClassPath + RendererInput.screenshotAllClassPath) - val projectClassPath = (RendererInput.mainProjectClassPath + RendererInput.screenshotProjectClassPath) - val layoutLibClassPath = RendererInput.layoutlibClassPath - val layoutlibDataDir = RendererInput.layoutlibDataDir - - val platformClassLoader = ClassLoader::class.java.getMethod("getPlatformClassLoader").invoke(null) as ClassLoader - - val urls = layoutLibClassPath.map { it.toURI().toURL() }.toTypedArray() - // This is the only change from the original: use a resource-enhanced class loader instead - // of a plain URLClassLoader(urls, platformClassLoader). - isolatedClassLoaderForRendering = createResourceEnhancedClassLoader(urls, platformClassLoader) - - val rendererClass = isolatedClassLoaderForRendering.loadClass( - com.android.tools.render.Renderer::class.java.name - ) - - val constructor = rendererClass.getConstructor( - String::class.java, - String::class.java, - String::class.java, - List::class.java, - List::class.java, - String::class.java, - List::class.java, - List::class.java, - ) - - rendererInstance = constructor.newInstance( - fontsPath, - resourceApkPath, - namespace, - classPath.map { it.absolutePath }, - projectClassPath.map { it.absolutePath }, - layoutlibDataDir.absolutePath, - RendererInput.testRuntimeResourceDirs.map { it.absolutePath }, - RendererInput.testRuntimeRClassJars.map { it.absolutePath }, - ) as Closeable - } - - fun render(screenshot: PreviewScreenshot, outputFolderPath: String): List { - val copiedScreenshot = copyObject(screenshot, isolatedClassLoaderForRendering) - - val previewScreenshotClass = isolatedClassLoaderForRendering.loadClass(PreviewScreenshot::class.java.name) - val renderMethod = rendererInstance.javaClass.getMethod("render", previewScreenshotClass, String::class.java) - - val resultFromIsolatedLoader = renderMethod.invoke(rendererInstance, copiedScreenshot, outputFolderPath) as Serializable - - val resultInCurrentLoader = copyObject(resultFromIsolatedLoader, this.javaClass.classLoader!!) - - @Suppress("UNCHECKED_CAST") - return resultInCurrentLoader as List - } - - private fun copyObject(obj: Serializable, targetClassLoader: ClassLoader): Serializable { - val byteOut = ByteArrayOutputStream() - ObjectOutputStream(byteOut).use { it.writeObject(obj) } - val bytes = byteOut.toByteArray() - - val byteIn = ByteArrayInputStream(bytes) - val objectIn = object : ObjectInputStream(byteIn) { - override fun resolveClass(desc: ObjectStreamClass): Class<*> = - Class.forName(desc.name, false, targetClassLoader) - } - - @Suppress("UNCHECKED_CAST") - return objectIn.use { it.readObject() as Serializable } - } - - override fun close() { - rendererInstance.close() - } -} - -/** - * Not in the original Renderer. This wraps URLClassLoader with SystemClassLoader fallback for - * resource lookups. - */ -private fun createResourceEnhancedClassLoader(urls: Array, parent: ClassLoader): URLClassLoader { - val systemClassLoader = ClassLoader.getSystemClassLoader() - return object : URLClassLoader(urls, parent) { - override fun getResourceAsStream(name: String): InputStream? = - super.getResourceAsStream(name) ?: systemClassLoader.getResourceAsStream(name) - - override fun getResource(name: String): URL? = - super.getResource(name) ?: systemClassLoader.getResource(name) - - override fun getResources(name: String): Enumeration { - val parentResources = super.getResources(name).toList() - val systemResources = systemClassLoader.getResources(name).toList() - return Collections.enumeration(parentResources + systemResources) - } - } -} diff --git a/buildSrc/src/main/kotlin/org/neotech/plugin/ScreenshotTestCoveragePlugin.kt b/buildSrc/src/main/kotlin/org/neotech/plugin/ScreenshotTestCoveragePlugin.kt index 1dcd363..a5986b6 100644 --- a/buildSrc/src/main/kotlin/org/neotech/plugin/ScreenshotTestCoveragePlugin.kt +++ b/buildSrc/src/main/kotlin/org/neotech/plugin/ScreenshotTestCoveragePlugin.kt @@ -18,21 +18,19 @@ import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.tasks.testing.Test import org.gradle.process.CommandLineArgumentProvider +import org.neotech.plugin.agent.RenderClassLoaderPatchAgent +import java.io.File +import java.util.jar.Attributes +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import java.util.jar.Manifest /** - * Attaches the Kover JVM agent to Android screenshot test tasks and registers the resulting - * binary coverage reports with Kover's artifact generation tasks. This allows screenshot test - * coverage to appear in Kover reports alongside regular unit test coverage. - * - * The screenshot plugin renders composables inside layoutlib, which uses an isolated classloader - * that only sees layoutlib's own JARs. Kover's agent instruments classes by rewriting bytecode, - * and during that process it needs to load .class files as resources to resolve type hierarchies. - * The isolated classloader can't find the app's .class files, so Kover silently skips those - * classes, resulting in zero coverage. To fix this, the plugin injects a shadowed copy of the - * screenshot engine's `com.android.tools.screenshot.renderer.Renderer` class that replaces the - * classloader with one that falls back to the SystemClassLoader for resource lookups. This shadowed - * Renderer is compiled as part of buildSrc against the screenshot engine's dependencies, and its - * .class files are extracted and prepended to the test classpath at runtime. + * Attaches the Kover JVM agent to Android screenshot test tasks, plus a second agent + * ([RenderClassLoaderPatchAgent]) that extends the render sandbox's classloader allowlist so Kover + * can run inside it. Registers the resulting binary coverage reports with Kover's artifact + * generation tasks, so screenshot test coverage appears in Kover reports alongside regular unit + * test coverage. * * Requires the Kover plugin and the Compose Screenshot plugin to be applied to the same project. */ @@ -52,7 +50,7 @@ class ScreenshotTestCoveragePlugin : Plugin { return } - val rendererClassesDir = extractRendererClasses(project) + val patchAgentJar = buildPatchAgentJar(project) // Attach the Kover agent to all screenshot validation tasks. project.tasks.withType(Test::class.java) @@ -65,12 +63,6 @@ class ScreenshotTestCoveragePlugin : Plugin { binReport.get().asFile.delete() } - // Prepend the pre-compiled Renderer classes directory to the classpath so our - // shadowed Renderer takes precedence over the one in the engine JAR. - doFirst { - classpath = project.files(rendererClassesDir, classpath) - } - val argsFile = temporaryDir.resolve("kover-agent.args") doFirst { argsFile.parentFile.mkdirs() @@ -79,13 +71,21 @@ class ScreenshotTestCoveragePlugin : Plugin { writer.append("exclude=").appendLine("android.*") writer.append("exclude=").appendLine("com.android.*") writer.append("exclude=").appendLine("jdk.internal.*") + // Layoutlib renames its bundled Kotlin runtime into this package as it + // loads it. Instrumenting those copies breaks the rename. + writer.append("exclude=").appendLine($$"_layoutlib_._internal_.*") } } jvmArgumentProviders += CommandLineArgumentProvider { val agentJar = agentConfiguration.singleFile if (agentJar.exists()) { - mutableListOf("-javaagent:${agentJar.canonicalPath}=file:${argsFile.canonicalPath}") + mutableListOf( + "-javaagent:${agentJar.canonicalPath}=file:${argsFile.canonicalPath}", + "-javaagent:${patchAgentJar.canonicalPath}", + // The patch agent swaps the backing array of a java.util list. + "--add-opens=java.base/java.util=ALL-UNNAMED", + ) } else { mutableListOf() } @@ -95,15 +95,14 @@ class ScreenshotTestCoveragePlugin : Plugin { // Make Kover artifact generation tasks depend on screenshot tests and include their binary reports. project.tasks.matching { it.name.startsWith("koverGenerateArtifact") }.configureEach { val variantName = name.removePrefix("koverGenerateArtifact") - // Not every variant has a corresponding screenshot test task so we skip variants that we don't find a task for. + // Skip variants without a matching screenshot test task. val screenshotTask = project.tasks.findByName("validate${variantName}ScreenshotTest") ?: return@configureEach dependsOn(screenshotTask) - // Kover's ArtifactGenerationTask is internal, so we use reflection to access its report - // files property. There are two internal task types (one for aggregated reports, one - // for module-level reports?) with different accessor names. + // Kover's ArtifactGenerationTask is internal, so reach its report files via reflection. + // Two internal task types exist, with different accessor names. val reportFiles = (this::class.java.methods.firstOrNull { it.name == "getReportFiles" } ?: this::class.java.methods.first { it.name == "getReports" }) .invoke(this) as ConfigurableFileCollection @@ -112,31 +111,33 @@ class ScreenshotTestCoveragePlugin : Plugin { } /** - * Extracts the pre-compiled Renderer .class files from the plugin's classloader into a build - * directory. + * Packages the pre-compiled [RenderClassLoaderPatchAgent] from this plugin's own classloader + * into a JAR with a `Premain-Class` manifest entry, so it can be attached with `-javaagent`. */ - private fun extractRendererClasses(project: Project): java.io.File { - val classesDir = project.layout.buildDirectory - .dir("generated/screenshotTestCoverage/classes").get().asFile - - val classFiles = listOf( - "com/android/tools/screenshot/renderer/Renderer.class", - $$"com/android/tools/screenshot/renderer/Renderer$copyObject$objectIn$1.class", - "com/android/tools/screenshot/renderer/RendererKt.class", - $$"com/android/tools/screenshot/renderer/RendererKt$createResourceEnhancedClassLoader$1.class", - ) - - for (classFile in classFiles) { - val outputFile = classesDir.resolve(classFile) - val bytes = ScreenshotTestCoveragePlugin::class.java.classLoader - .getResourceAsStream(classFile) - ?.readBytes() - ?: throw GradleException("Could not find pre-compiled class $classFile in buildSrc, this is a plugin bug and should normally not happen.") - outputFile.parentFile.mkdirs() - outputFile.writeBytes(bytes) + private fun buildPatchAgentJar(project: Project): File { + val agentClassName = RenderClassLoaderPatchAgent::class.java.name + val agentClassResource = "${agentClassName.replace('.', '/')}.class" + + val bytes = ScreenshotTestCoveragePlugin::class.java.classLoader + .getResourceAsStream(agentClassResource) + ?.readBytes() + ?: throw GradleException("Could not find pre-compiled class $agentClassResource in buildSrc, this is a plugin bug and should normally not happen.") + + val manifest = Manifest().apply { + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" + mainAttributes[Attributes.Name("Premain-Class")] = agentClassName + } + + val jarFile = project.layout.buildDirectory + .file("generated/screenshotTestCoverage/render-classloader-patch-agent.jar").get().asFile + jarFile.parentFile.mkdirs() + JarOutputStream(jarFile.outputStream().buffered(), manifest).use { jar -> + jar.putNextEntry(JarEntry(agentClassResource)) + jar.write(bytes) + jar.closeEntry() } - return classesDir + return jarFile } private fun verifyScreenshotPluginVersion(project: Project) { @@ -150,14 +151,14 @@ class ScreenshotTestCoveragePlugin : Plugin { if (actualScreenshotVersion == null) { throw GradleException("screenshot-test-coverage requires the Compose Screenshot plugin to be applied to the same project.") } else if (actualScreenshotVersion != expectedScreenshotPluginVersion) { - throw GradleException("screenshot-test-coverage plugin requires Compose Screenshot plugin $expectedScreenshotPluginVersion, but found $actualScreenshotVersion.") + project.logger.warn( + "Warning: screenshot-test-coverage was verified against Compose Screenshot plugin $expectedScreenshotPluginVersion, but found $actualScreenshotVersion." + ) } } } /** - * The screenshot plugin version that the pre-compiled Renderer shadow was built against. If the - * project uses a different version, the plugin fails with a clear error rather than potentially - * producing mysterious runtime failures from incompatible class files. + * The screenshot plugin version this plugin's classloader patch was verified against. */ -private const val expectedScreenshotPluginVersion = "0.0.1-alpha14" +private const val expectedScreenshotPluginVersion = "0.0.1-alpha16" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71a47af..5575341 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ android-compileSdk = "37" android-minSdk = "26" android-targetSdk = "37" uiTooling = "1.12.0" -screenshot = "0.0.1-alpha14" +screenshot = "0.0.1-alpha16" startupRuntime = "1.2.0" androidx-activityCompose = "1.13.0" ####### END: Android target specific ####### @@ -64,8 +64,6 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startupRuntime" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } -screenshot-validation-junit-engine = { group = "com.android.tools.screenshot", name = "screenshot-validation-junit-engine", version.ref = "screenshot" } -compose-preview-renderer = { group = "com.android.tools.compose", name = "compose-preview-renderer", version.ref = "screenshot" } ####### END: Android target specific ####### # Graphs & Plots