-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Web: ensure Skiko runtime is initialized before JS browser tests start #5621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9248b6f
43920fa
c40bc14
9be189e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a big deal but |
||
| ); | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just in case,
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> { | ||
|
|
@@ -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) } | ||
| } | ||
|
|
@@ -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 |
|---|---|---|
| @@ -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 | ||
|
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" | ||
|
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() | ||
| } | ||
|
|
||
| } |
Uh oh!
There was an error while loading. Please reload this page.