Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment thread
terrakok marked this conversation as resolved.

internal fun Project.configureWeb(
composeExt: ComposeExtension,
Expand All @@ -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 }
Expand Down Expand Up @@ -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<DefaultTask>("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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big deal but === wont be a real benefit for anything

);
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just in case, useConfigDirectory overrides the default directory. If a user has their own configuration in karma.config.d, as far as I understand it stops to work

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a copying configs from the default directory (if they exist). For a custom config dir we don't have an API to access it.

testTask.onTestFrameworkSet { framework ->
(framework as? KotlinKarma)?.useConfigDirectory(configDirectoryPath)
}
}
}

private const val SKIKO_GROUP = "org.jetbrains.skiko"

private fun skikoVersionProvider(project: Project): Provider<String> {
Expand All @@ -134,7 +287,7 @@ private fun isSkikoDependency(dep: DependencyDescriptor): Boolean =
dep.group == SKIKO_GROUP && dep.version != null

private val Configuration.allDependenciesDescriptors: Sequence<DependencyDescriptor>
get() = with (resolvedConfiguration.lenientConfiguration) {
get() = with(resolvedConfiguration.lenientConfiguration) {
allModuleDependencies.asSequence().map { ResolvedDependencyDescriptor(it) } +
unresolvedModuleDependencies.asSequence().map { UnresolvedDependencyDescriptor(it) }
}
Expand Down Expand Up @@ -165,4 +318,4 @@ private class UnresolvedDependencyDescriptor(private val dependency: UnresolvedD

override val version: String?
get() = dependency.selector.version
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ class GradlePluginTest : GradlePluginTestBase() {
}
}

//https://youtrack.jetbrains.com/issue/CMP-4906
Comment thread
terrakok marked this conversation as resolved.
@Test
fun testJsNoExecutableTests() = with(
testProject("misc/jsNoExecutableTests")
) {
gradle("jsBrowserTest").checks {
check.taskSuccessful(":jsBrowserTest")
}
}

@Test
fun testOldComposePluginError() = with(testProject("misc/oldComposePlugin")) {
gradleFailure("tasks").checks {
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
kotlin.daemon.jvmargs=-Xmx8G
Comment thread
terrakok marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
terrakok marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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()
}

}
Loading