Skip to content

Commit f44be9a

Browse files
committed
Add checkComposeUiTestConfigurationForJs task to validate JS browser test setup
Ensure JS browser tests for Compose UI that depend on Skiko properly declare an executable binary. Fails with a clear error message if the configuration is invalid. Adds related test case (`testJsNoExecutableTests`). Fixes https://youtrack.jetbrains.com/issue/CMP-4906.
1 parent 8173a65 commit f44be9a

7 files changed

Lines changed: 181 additions & 0 deletions

File tree

gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/web/internal/configureWebApplication.kt

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,31 @@
55

66
package org.jetbrains.compose.web.internal
77

8+
import org.gradle.api.DefaultTask
9+
import org.gradle.api.GradleException
810
import org.gradle.api.Project
911
import org.gradle.api.artifacts.Configuration
1012
import org.gradle.api.artifacts.ResolvedDependency
1113
import org.gradle.api.artifacts.UnresolvedDependency
1214
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
15+
import org.gradle.api.provider.Property
1316
import org.gradle.api.provider.Provider
1417
import org.gradle.api.tasks.Copy
18+
import org.gradle.api.tasks.Input
19+
import org.gradle.api.tasks.TaskAction
1520
import org.gradle.language.jvm.tasks.ProcessResources
21+
import org.gradle.work.DisableCachingByDefault
1622
import org.jetbrains.compose.ComposeBuildConfig
1723
import org.jetbrains.compose.ComposeExtension
1824
import org.jetbrains.compose.internal.utils.detachedComposeDependency
1925
import org.jetbrains.compose.internal.utils.file
2026
import org.jetbrains.compose.internal.utils.registerTask
2127
import org.jetbrains.compose.web.WebExtension
2228
import org.jetbrains.compose.web.tasks.UnpackSkikoWasmRuntimeTask
29+
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
30+
import org.jetbrains.kotlin.gradle.targets.js.ir.Executable
2331
import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrTarget
32+
import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest
2433

2534
internal fun Project.configureWeb(
2635
composeExt: ComposeExtension,
@@ -112,6 +121,82 @@ internal fun configureWebApplication(
112121
}
113122
}
114123
}
124+
125+
configureComposeUiTestExecutableCheck(project, target)
126+
}
127+
}
128+
129+
private fun configureComposeUiTestExecutableCheck(
130+
project: Project,
131+
target: KotlinJsIrTarget,
132+
) {
133+
val titledTargetName = target.name.replaceFirstChar { it.titlecase() }
134+
val checkTask = project.registerTask<CheckComposeUiTestExecutableTask>(
135+
"checkComposeUiTestConfigurationFor$titledTargetName"
136+
) {
137+
targetName.set(target.name)
138+
// Computed lazily, after all `afterEvaluate`s: `binaries.executable()` may be declared
139+
// after this plugin runs, so the binaries set can still be empty here. Reading these
140+
// through providers (instead of in the task action) also keeps the task free of
141+
// Project/target references, so it stays compatible with the configuration cache.
142+
testDependsOnSkiko.set(project.provider { project.testCompilationDependsOnSkiko(target) })
143+
hasExecutableBinary.set(
144+
project.provider { target.binaries.withType(Executable::class.java).isNotEmpty() }
145+
)
146+
}
147+
148+
project.tasks.withType(KotlinJsTest::class.java).configureEach { testTask ->
149+
val compilation = testTask.compilation
150+
// Browser test tasks (Karma) are named "<target>BrowserTest"; node tests don't run Compose UI.
151+
if (compilation.target == target &&
152+
compilation.compilationName == KotlinCompilation.TEST_COMPILATION_NAME &&
153+
testTask.name.endsWith("BrowserTest")
154+
) {
155+
testTask.dependsOn(checkTask)
156+
}
157+
}
158+
}
159+
160+
/**
161+
* Compose UI browser tests must be bundled by webpack to load the Skiko runtime, which only
162+
* happens when the target declares an executable `binaries.executable()`. When a target that
163+
* depends on Skiko has no executable, this task fails with an actionable message instead of
164+
* letting the tests fail in a confusing way.
165+
*/
166+
@DisableCachingByDefault(because = "Not worth caching: only validates the configuration")
167+
internal abstract class CheckComposeUiTestExecutableTask : DefaultTask() {
168+
@get:Input
169+
abstract val targetName: Property<String>
170+
171+
@get:Input
172+
abstract val testDependsOnSkiko: Property<Boolean>
173+
174+
@get:Input
175+
abstract val hasExecutableBinary: Property<Boolean>
176+
177+
@TaskAction
178+
fun check() {
179+
if (!hasExecutableBinary.get() && testDependsOnSkiko.get()) {
180+
val target = targetName.get()
181+
throw GradleException(
182+
"Compose UI tests for the '$target' target are not bundled with webpack: " +
183+
"no executable binary is declared, so the Skiko runtime required by Compose UI " +
184+
"cannot be loaded and the tests may fail. Add `binaries.executable()` to the " +
185+
"'$target' target. See https://youtrack.jetbrains.com/issue/CMP-4906"
186+
)
187+
}
188+
}
189+
}
190+
191+
private fun Project.testCompilationDependsOnSkiko(target: KotlinJsIrTarget): Boolean {
192+
val testCompilation = target.compilations.findByName(KotlinCompilation.TEST_COMPILATION_NAME)
193+
?: return false
194+
return listOf(
195+
testCompilation.compileDependencyConfigurationName, testCompilation.runtimeDependencyConfigurationName
196+
).mapNotNull { name ->
197+
configurations.findByName(name)
198+
}.any { configuration ->
199+
configuration.allDependenciesDescriptors.any(::isSkikoDependency)
115200
}
116201
}
117202

gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/GradlePluginTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ class GradlePluginTest : GradlePluginTestBase() {
114114
}
115115
}
116116

117+
//https://youtrack.jetbrains.com/issue/CMP-4906
118+
@Test
119+
fun testJsNoExecutableTests() = with(
120+
testProject("misc/jsNoExecutableTests")
121+
) {
122+
// The project depends on Compose UI (Skiko) but does not declare `binaries.executable()`,
123+
// so the tests are not bundled with webpack and cannot run. The check task must fail with
124+
// an actionable message instead of letting the tests fail in a confusing way.
125+
gradleFailure("jsBrowserTest").checks {
126+
check.taskFailed(":checkComposeUiTestConfigurationForJs")
127+
check.logContains(
128+
"Compose UI tests for the 'js' target are not bundled with webpack: " +
129+
"no executable binary is declared, so the Skiko runtime required by Compose UI " +
130+
"cannot be loaded and the tests may fail. Add `binaries.executable()` to the " +
131+
"'js' target. See https://youtrack.jetbrains.com/issue/CMP-4906"
132+
)
133+
}
134+
}
135+
117136
@Test
118137
fun testOldComposePluginError() = with(testProject("misc/oldComposePlugin")) {
119138
gradleFailure("tasks").checks {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
plugins {
2+
id "org.jetbrains.kotlin.multiplatform"
3+
id "org.jetbrains.kotlin.plugin.compose"
4+
id "org.jetbrains.compose"
5+
}
6+
7+
kotlin {
8+
js { browser() }
9+
10+
sourceSets {
11+
commonMain.dependencies {
12+
api("org.jetbrains.compose.runtime:runtime:COMPOSE_VERSION_PLACEHOLDER")
13+
api("org.jetbrains.compose.ui:ui:COMPOSE_VERSION_PLACEHOLDER")
14+
api("org.jetbrains.compose.foundation:foundation:COMPOSE_VERSION_PLACEHOLDER")
15+
}
16+
17+
commonTest.dependencies {
18+
implementation(kotlin("test"))
19+
implementation("org.jetbrains.compose.ui:ui-test:COMPOSE_VERSION_PLACEHOLDER")
20+
}
21+
}
22+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
kotlin.daemon.jvmargs=-Xmx8G
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
pluginManagement {
2+
plugins {
3+
id 'org.jetbrains.kotlin.multiplatform' version 'KOTLIN_VERSION_PLACEHOLDER'
4+
id 'org.jetbrains.kotlin.plugin.compose' version 'KOTLIN_VERSION_PLACEHOLDER'
5+
id 'org.jetbrains.compose' version 'COMPOSE_GRADLE_PLUGIN_VERSION_PLACEHOLDER'
6+
}
7+
repositories {
8+
mavenLocal()
9+
gradlePluginPortal()
10+
mavenCentral()
11+
google()
12+
maven {
13+
url 'https://packages.jetbrains.team/maven/p/cmp/dev'
14+
}
15+
}
16+
}
17+
dependencyResolutionManagement {
18+
repositories {
19+
mavenCentral()
20+
google()
21+
maven {
22+
url 'https://packages.jetbrains.team/maven/p/cmp/dev'
23+
}
24+
mavenLocal()
25+
}
26+
}
27+
rootProject.name = "jsNoExecutableTests"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
2+
import androidx.compose.foundation.text.BasicText
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.ui.Modifier
5+
6+
@Composable
7+
fun ReversedTextView(text: String, modifier: Modifier = Modifier) {
8+
BasicText(text.reversed(), modifier)
9+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import androidx.compose.ui.test.ExperimentalTestApi
2+
import androidx.compose.ui.test.onNodeWithText
3+
import androidx.compose.ui.test.runComposeUiTest
4+
import kotlin.test.*
5+
6+
@OptIn(ExperimentalTestApi::class)
7+
class MainTest {
8+
9+
@Test
10+
fun testReversedTextView() = runComposeUiTest {
11+
setContent {
12+
ReversedTextView("Hello")
13+
}
14+
15+
onNodeWithText("olleH").assertExists()
16+
}
17+
18+
}

0 commit comments

Comments
 (0)