diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/web/internal/configureWebApplication.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/web/internal/configureWebApplication.kt index 8d7f924419a..cb221e9115c 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/web/internal/configureWebApplication.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/web/internal/configureWebApplication.kt @@ -5,6 +5,7 @@ package org.jetbrains.compose.web.internal +import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.ResolvedDependency @@ -20,7 +21,11 @@ import org.jetbrains.compose.internal.utils.file import org.jetbrains.compose.internal.utils.registerTask import org.jetbrains.compose.web.WebExtension import org.jetbrains.compose.web.tasks.UnpackSkikoWasmRuntimeTask +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrTarget +import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest +import org.jetbrains.kotlin.gradle.targets.js.testing.karma.KotlinKarma +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile internal fun Project.configureWeb( composeExt: ComposeExtension, @@ -35,7 +40,7 @@ internal fun Project.configureWeb( val compileConfiguration = compilation.compileDependencyConfigurationName val runtimeConfiguration = compilation.runtimeDependencyConfigurationName - listOf(compileConfiguration, runtimeConfiguration).mapNotNull { name -> + listOf(compileConfiguration, runtimeConfiguration).mapNotNull { name -> project.configurations.findByName(name) }.flatMap { configuration -> configuration.incoming.resolutionResult.allComponents.map { it.id } @@ -110,11 +115,159 @@ internal fun configureWebApplication( it.dependsOn(unpackRuntime) it.exclude("META-INF") } + + if (compilation.name == KotlinCompilation.TEST_COMPILATION_NAME) { + configureJsBrowserTestsSkikoLoading( + project = project, + target = target, + compilationProcessResourcesTaskName = compilation.processResourcesTaskName + ) + } } } } } + +/** + * Configures Karma test runner for Kotlin/JS browser tests to properly load Skiko runtime dependencies. + * + * This function generates a custom Karma configuration file that: + * - Locates the test entry point JavaScript file in the build output + * - Ensures Skiko runtime files (skiko.mjs, skiko.wasm, js-reexport-symbols.mjs) are served by Karma + * - Creates a loader script that intercepts Karma's test execution to wait for Skiko initialization + * - Hooks into the window.__karma__.loaded() function to ensure Skiko is ready before tests run + * + * The generated configuration ensures that Compose UI tests that depend on Skiko can properly + * initialize the graphics runtime before test execution begins, preventing race conditions + * where tests might run before Skiko's WebAssembly module is fully loaded. + * + * @param project The Gradle project being configured + * @param target The Kotlin/JS IR target being configured for testing + * @param compilationProcessResourcesTaskName The name of the task that processes resources for the test compilation, + * used to ensure Skiko resources are available before tests run + */ +private fun configureJsBrowserTestsSkikoLoading( + project: Project, + target: KotlinJsIrTarget, + compilationProcessResourcesTaskName: String +) { + val targetName = target.name.replaceFirstChar { it.titlecase() } + val configDir = project.layout.buildDirectory.dir("compose/karma-config/$targetName") + val configFile = configDir.map { it.file("compose-skiko-runtime.js") } + // KotlinKarma.useConfigDirectory() replaces the default karma.config.d directory rather than + // appending to it, so mirror the default user's configs into our directory to keep them working. + val defaultKarmaConfigDir = project.projectDir.resolve("karma.config.d") + + val generateConfigTask = project.registerTask("generateTestComposeSkikoKarmaConfigFor$targetName") { + if (defaultKarmaConfigDir.isDirectory) { + inputs.dir(defaultKarmaConfigDir) + } + outputs.dir(configDir) + doLast { + val file = configFile.get().asFile + val targetDir = file.parentFile + targetDir.mkdirs() + + defaultKarmaConfigDir.listFiles() + ?.filter { it.isFile && it.extension == "js" } + ?.forEach { it.copyTo(targetDir.resolve(it.name), overwrite = true) } + + file.writeText( + //language=JavaScript + """ + const fs = require("fs"); + const path = require("path"); + + (function(config) { + const files = config.files || []; + const testEntry = files.find((entry) => + typeof entry === "string" && + entry.endsWith(".js") && + entry.includes(path.sep + "kotlin" + path.sep) + ); + if (!testEntry) return; + + const reexportModule = path.resolve(path.dirname(testEntry), "js-reexport-symbols.mjs"); + const skikoModule = path.resolve(path.dirname(testEntry), "skiko.mjs"); + const skikoWasm = path.resolve(path.dirname(testEntry), "skiko.wasm"); + const loaderFile = path.resolve(path.dirname(testEntry), "compose-skiko-loader.js"); + if (!fs.existsSync(reexportModule)) return; + + const ensureServed = (filePath) => { + if (!fs.existsSync(filePath)) return; + const alreadyServed = files.some((entry) => + entry === filePath || + (entry && typeof entry === "object" && entry.pattern === filePath) + ); + if (!alreadyServed) { + // Serve Skiko dependencies before the test entry. Karma preserves the + // order of `files`, and the "main" entry is already present in the array. + files.unshift({ + pattern: filePath, + watched: false, + included: false, + served: true, + }); + } + }; + ensureServed(reexportModule); + ensureServed(skikoModule); + ensureServed(skikoWasm); + + fs.writeFileSync(loaderFile, ` + (function() { + if (!window.__karma__) return; + const originalLoaded = window.__karma__.loaded.bind(window.__karma__); + let skikoReady = null; + window.__karma__.loaded = function() { + if (!skikoReady) { + const servedFiles = window.__karma__.files || {}; + const reexportUrl = Object.keys(servedFiles) + .find((url) => url.endsWith("js-reexport-symbols.mjs")); + skikoReady = reexportUrl + ? import(reexportUrl).then((mod) => mod?.api?.awaitSkiko ?? Promise.resolve()) + : Promise.resolve(); + } + skikoReady.then(() => originalLoaded()).catch((error) => { + const message = error?.stack ?? String(error); + window.__karma__.error(message); + }); + }; + })(); + `.trim()); + + const hasLoader = files.some((entry) => + entry === loaderFile || + (entry && typeof entry === "object" && entry.pattern === loaderFile) + ); + if (!hasLoader) { + files.unshift(loaderFile); + } + })(config); + """.trimIndent() + ) + } + } + + project.tasks.withType(KotlinJsTest::class.java).configureEach { testTask -> + if (testTask.compilation.target != target || + testTask.compilation.compilationName != KotlinCompilation.TEST_COMPILATION_NAME + ) { + return@configureEach + } + + testTask.dependsOn(generateConfigTask) + testTask.dependsOn(compilationProcessResourcesTaskName) + + val configDirectoryPath = configDir.get().asFile + (testTask.testFramework as? KotlinKarma)?.useConfigDirectory(configDirectoryPath) + testTask.onTestFrameworkSet { framework -> + (framework as? KotlinKarma)?.useConfigDirectory(configDirectoryPath) + } + } +} + private const val SKIKO_GROUP = "org.jetbrains.skiko" private fun skikoVersionProvider(project: Project): Provider { @@ -134,7 +287,7 @@ private fun isSkikoDependency(dep: DependencyDescriptor): Boolean = dep.group == SKIKO_GROUP && dep.version != null private val Configuration.allDependenciesDescriptors: Sequence - get() = with (resolvedConfiguration.lenientConfiguration) { + get() = with(resolvedConfiguration.lenientConfiguration) { allModuleDependencies.asSequence().map { ResolvedDependencyDescriptor(it) } + unresolvedModuleDependencies.asSequence().map { UnresolvedDependencyDescriptor(it) } } @@ -165,4 +318,4 @@ private class UnresolvedDependencyDescriptor(private val dependency: UnresolvedD override val version: String? get() = dependency.selector.version -} \ No newline at end of file +} diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/GradlePluginTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/GradlePluginTest.kt index d972c4ee79d..f988b6dde5a 100644 --- a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/GradlePluginTest.kt +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/GradlePluginTest.kt @@ -114,6 +114,16 @@ class GradlePluginTest : GradlePluginTestBase() { } } + //https://youtrack.jetbrains.com/issue/CMP-4906 + @Test + fun testJsNoExecutableTests() = with( + testProject("misc/jsNoExecutableTests") + ) { + gradle("jsBrowserTest").checks { + check.taskSuccessful(":jsBrowserTest") + } + } + @Test fun testOldComposePluginError() = with(testProject("misc/oldComposePlugin")) { gradleFailure("tasks").checks { diff --git a/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/build.gradle b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/build.gradle new file mode 100644 index 00000000000..e7f5b795e4c --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/build.gradle @@ -0,0 +1,22 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.compose" + id "org.jetbrains.compose" +} + +kotlin { + js { browser() } + + sourceSets { + commonMain.dependencies { + api("org.jetbrains.compose.runtime:runtime:COMPOSE_VERSION_PLACEHOLDER") + api("org.jetbrains.compose.ui:ui:COMPOSE_VERSION_PLACEHOLDER") + api("org.jetbrains.compose.foundation:foundation:COMPOSE_VERSION_PLACEHOLDER") + } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.compose.ui:ui-test:COMPOSE_VERSION_PLACEHOLDER") + } + } +} diff --git a/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/gradle.properties b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/gradle.properties new file mode 100644 index 00000000000..3a6e0f9d822 --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/gradle.properties @@ -0,0 +1 @@ +kotlin.daemon.jvmargs=-Xmx8G \ No newline at end of file diff --git a/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/settings.gradle b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/settings.gradle new file mode 100644 index 00000000000..6a3affb8956 --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/settings.gradle @@ -0,0 +1,27 @@ +pluginManagement { + plugins { + id 'org.jetbrains.kotlin.multiplatform' version 'KOTLIN_VERSION_PLACEHOLDER' + id 'org.jetbrains.kotlin.plugin.compose' version 'KOTLIN_VERSION_PLACEHOLDER' + id 'org.jetbrains.compose' version 'COMPOSE_GRADLE_PLUGIN_VERSION_PLACEHOLDER' + } + repositories { + mavenLocal() + gradlePluginPortal() + mavenCentral() + google() + maven { + url 'https://packages.jetbrains.team/maven/p/cmp/dev' + } + } +} +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + maven { + url 'https://packages.jetbrains.team/maven/p/cmp/dev' + } + mavenLocal() + } +} +rootProject.name = "jsNoAppTests" \ No newline at end of file diff --git a/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/src/commonMain/kotlin/Main.kt b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/src/commonMain/kotlin/Main.kt new file mode 100644 index 00000000000..ebcba1bd19c --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/src/commonMain/kotlin/Main.kt @@ -0,0 +1,9 @@ + +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ReversedTextView(text: String, modifier: Modifier = Modifier) { + BasicText(text.reversed(), modifier) +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/src/commonTest/kotlin/MainTest.kt b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/src/commonTest/kotlin/MainTest.kt new file mode 100644 index 00000000000..1f0c079fc44 --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/misc/jsNoExecutableTests/src/commonTest/kotlin/MainTest.kt @@ -0,0 +1,18 @@ +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.* + +@OptIn(ExperimentalTestApi::class) +class MainTest { + + @Test + fun testReversedTextView() = runComposeUiTest { + setContent { + ReversedTextView("Hello") + } + + onNodeWithText("olleH").assertExists() + } + +} \ No newline at end of file