|
| 1 | +import java.io.ByteArrayOutputStream |
| 2 | +import org.apache.tools.ant.util.TeeOutputStream |
1 | 3 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi |
2 | 4 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget |
3 | 5 |
|
@@ -181,3 +183,167 @@ tasks.named<Test>("desktopTest") { |
181 | 183 | } |
182 | 184 | enabled = !notebookSkipped |
183 | 185 | } |
| 186 | + |
| 187 | +// ---- The notebook E2E in a GraalVM native image (issue #249, #191's first milestone) ---------- |
| 188 | +// |
| 189 | +// `NotebookE2eMain` runs the same 20 scenarios as `desktopTest`, by direct calls, so they can run in a |
| 190 | +// native image. The scenes are offscreen (`ImageComposeScene`), so no window is involved. Built the way |
| 191 | +// `:sample:nativeCompile` builds its image: `native-image` called directly, because the GraalVM Gradle |
| 192 | +// plugin registers no tasks for a Kotlin Multiplatform `jvm()` target. The Compose and Skiko |
| 193 | +// configuration a closed world needs is recorded by `notebookE2eNativeAgent` into |
| 194 | +// `src/desktopTest/native-image` and committed; python-multiplatform's own FFM registrations come |
| 195 | +// from its jar's `META-INF/native-image`. |
| 196 | + |
| 197 | +val e2eTestCompilation = kotlin.targets.getByName("desktop").compilations.getByName("test") |
| 198 | +// `native-image-main/` registers the AWT-free `Dispatchers.Main` (`NotebookE2eMainDispatcher.kt`), first on |
| 199 | +// the path so it is found before kotlinx-coroutines-swing's; only the agent run and the image use it. |
| 200 | +val e2eNativeMainResources = layout.projectDirectory.dir("src/desktopTest/native-image-main") |
| 201 | +// The fork's Compose (#191): its `ui-desktop` sends the scene's main-thread work to `Dispatchers.Main` |
| 202 | +// when it is built with `-Dcompose.awt=false` (`ExtendedMainDispatcher`), where upstream's always uses |
| 203 | +// skiko's `SwingDispatcher`, which starts AWT; and AWT in a native image needs the JDK's `libawt`, |
| 204 | +// which links `libjvm`. So the image takes the modules `composeExtended.modules` names from the fork, |
| 205 | +// at `composeExtended.version` (root gradle.properties); `desktopTest` keeps upstream Compose. |
| 206 | +val composeExtendedVersion = providers.gradleProperty("composeExtended.version").orNull |
| 207 | +// The fork's group prefix: `org.jetbrains.compose` (its old coordinates) or its own, e.g. |
| 208 | +// `org.thisisthepy.compose`; a module `org.jetbrains.compose.ui:ui-desktop` is then taken as |
| 209 | +// `<prefix>.ui:ui-desktop`. |
| 210 | +val composeExtendedGroupPrefix = providers.gradleProperty("composeExtended.groupPrefix").orNull ?: "org.jetbrains.compose" |
| 211 | +val composeExtendedModules = providers.gradleProperty("composeExtended.modules").orNull |
| 212 | + ?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet().orEmpty() |
| 213 | +if (providers.gradleProperty("composeExtended.mavenLocal").orNull?.toBoolean() == true && composeExtendedVersion != null) { |
| 214 | + // A project's own repositories replace the settings' ones, so those are repeated here. |
| 215 | + repositories { |
| 216 | + mavenLocal { |
| 217 | + mavenContent { includeVersionByRegex(Regex.escape(composeExtendedGroupPrefix) + ".*", ".*", Regex.escape(composeExtendedVersion)) } |
| 218 | + } |
| 219 | + google { |
| 220 | + mavenContent { |
| 221 | + includeGroupAndSubgroups("androidx") |
| 222 | + includeGroupAndSubgroups("com.android") |
| 223 | + includeGroupAndSubgroups("com.google") |
| 224 | + } |
| 225 | + } |
| 226 | + mavenCentral() |
| 227 | + } |
| 228 | +} |
| 229 | +val e2eNativeRuntime: Configuration = configurations.getByName("desktopTestRuntimeClasspath").copyRecursive().apply { |
| 230 | + resolutionStrategy.eachDependency { |
| 231 | + if (composeExtendedVersion != null && "${requested.group}:${requested.name}" in composeExtendedModules) { |
| 232 | + useTarget("${requested.group.replaceFirst("org.jetbrains.compose", composeExtendedGroupPrefix)}:${requested.name}:$composeExtendedVersion") |
| 233 | + because("compose-multiplatform-core-extended: an AWT-free main dispatcher for a native image (#249)") |
| 234 | + } |
| 235 | + } |
| 236 | +} |
| 237 | +// The copied configuration carries the dependencies only; the main compilation's output and the |
| 238 | +// walker's composable thunks (generated bytecode, not a dependency) are added as they are for desktopTest. |
| 239 | +val e2eMainCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main") |
| 240 | +val e2eClasspath = files( |
| 241 | + e2eNativeMainResources, |
| 242 | + e2eTestCompilation.output.allOutputs, |
| 243 | + e2eMainCompilation.output.allOutputs, |
| 244 | + layout.buildDirectory.dir("generated/pythonComposableThunks/desktopMain"), |
| 245 | + e2eNativeRuntime, |
| 246 | +) |
| 247 | +val e2eMainClass = "fixture.notebook.NotebookE2eMainKt" |
| 248 | +val e2eNativeConfig = layout.projectDirectory.dir("src/desktopTest/native-image") |
| 249 | +val e2eNativeDir = layout.buildDirectory.dir("native/notebook-e2e") |
| 250 | +val e2eAppDir = layout.projectDirectory.dir("src/desktopTest/python") |
| 251 | +val e2eJdk25 = javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } |
| 252 | + |
| 253 | +fun JavaExec.notebookE2eOnTheJvm() { |
| 254 | + dependsOn(e2eTestCompilation.compileTaskProvider, installPythonxComposeWheel) |
| 255 | + classpath = e2eClasspath |
| 256 | + mainClass.set(e2eMainClass) |
| 257 | + javaLauncher.set(e2eJdk25) |
| 258 | + jvmArgs("--enable-native-access=ALL-UNNAMED", "-Djava.awt.headless=true", "-Dcompose.awt=false") |
| 259 | + systemProperty("notebookE2e.appDir", e2eAppDir.asFile.absolutePath) |
| 260 | + systemProperty("notebookE2e.sitePackages", pythonxComposeSitePackages.get().asFile.absolutePath) |
| 261 | + pythonxComposeWheel?.let { systemProperty("notebookE2e.wheel", it.absolutePath) } |
| 262 | + pythonxComposeWheelError?.let { systemProperty("notebookE2e.wheelError", it) } |
| 263 | +} |
| 264 | + |
| 265 | +tasks.register<JavaExec>("notebookE2eJvm") { |
| 266 | + group = "verification" |
| 267 | + description = "Runs NotebookE2eMain on the JVM: the 20 scenarios outside JUnit, before a native image is built from it." |
| 268 | + notebookE2eOnTheJvm() |
| 269 | +} |
| 270 | + |
| 271 | +tasks.register<JavaExec>("notebookE2eNativeAgent") { |
| 272 | + group = "native" |
| 273 | + description = "Runs NotebookE2eMain under native-image's tracing agent and writes the configuration it records into src/desktopTest/native-image." |
| 274 | + notebookE2eOnTheJvm() |
| 275 | + doFirst { e2eNativeConfig.asFile.mkdirs() } |
| 276 | + jvmArgs("-agentlib:native-image-agent=config-output-dir=${e2eNativeConfig.asFile.absolutePath}") |
| 277 | +} |
| 278 | + |
| 279 | +pythonBindings { |
| 280 | + nativeImageDirectory.set(e2eNativeDir) |
| 281 | +} |
| 282 | + |
| 283 | +val notebookE2eNativeStage by tasks.registering(Sync::class) { |
| 284 | + group = "native" |
| 285 | + description = "Puts the pythonx-compose wheel and the notebook's app beside the native image." |
| 286 | + dependsOn(installPythonxComposeWheel) |
| 287 | + from(pythonxComposeSitePackages) { into("site-packages") } |
| 288 | + from(e2eAppDir) { into("app") } |
| 289 | + into(layout.buildDirectory.dir("native/notebook-e2e-files")) |
| 290 | +} |
| 291 | + |
| 292 | +val notebookE2eNativeCompile by tasks.registering(Exec::class) { |
| 293 | + group = "native" |
| 294 | + description = "Builds the notebook E2E as a GraalVM native image (build/native/notebook-e2e/notebook-e2e)." |
| 295 | + dependsOn(e2eTestCompilation.compileTaskProvider, "stageNativeImagePythonHome", notebookE2eNativeStage) |
| 296 | + inputs.files(e2eClasspath) |
| 297 | + inputs.dir(e2eNativeConfig) |
| 298 | + outputs.file(e2eNativeDir.map { it.file("notebook-e2e") }) |
| 299 | + val launcher = e2eJdk25 |
| 300 | + val outDir = e2eNativeDir |
| 301 | + val staged = layout.buildDirectory.dir("native/notebook-e2e-files") |
| 302 | + doFirst { outDir.get().asFile.mkdirs() } |
| 303 | + executable = launcher.get().metadata.installationPath.asFile.resolve("bin/native-image").absolutePath |
| 304 | + args( |
| 305 | + "-cp", e2eClasspath.asPath, |
| 306 | + "--no-fallback", |
| 307 | + "--enable-native-access=ALL-UNNAMED", |
| 308 | + "-Djava.awt.headless=true", |
| 309 | + // The fork's switch: `ExtendedAwt.available` is a constant false, so nothing reaches AWT. |
| 310 | + "-Dcompose.awt=false", |
| 311 | + "-H:ConfigurationFileDirectories=${e2eNativeConfig.asFile.absolutePath}", |
| 312 | + "-o", outDir.get().asFile.resolve("notebook-e2e").absolutePath, |
| 313 | + e2eMainClass, |
| 314 | + ) |
| 315 | + doLast { |
| 316 | + project.copy { from(staged); into(outDir) } |
| 317 | + } |
| 318 | +} |
| 319 | + |
| 320 | +tasks.register<Exec>("notebookE2eNativeRun") { |
| 321 | + group = "verification" |
| 322 | + description = "Runs the native image on its own (no JAVA_HOME, no PYTHONHOME) and requires 20 of 20 scenarios to pass and no libjvm." |
| 323 | + dependsOn(notebookE2eNativeCompile) |
| 324 | + val dir = e2eNativeDir |
| 325 | + workingDir(dir) |
| 326 | + executable = dir.get().asFile.resolve("notebook-e2e").absolutePath |
| 327 | + environment.remove("JAVA_HOME") |
| 328 | + environment.remove("PYTHONHOME") |
| 329 | + val captured = ByteArrayOutputStream() |
| 330 | + standardOutput = TeeOutputStream(System.out, captured) |
| 331 | + isIgnoreExitValue = true |
| 332 | + doLast { |
| 333 | + val out = captured.toString() |
| 334 | + val summary = out.lineSequence().lastOrNull { it.startsWith("NOTEBOOK_E2E:") } |
| 335 | + ?: throw GradleException("the native image printed no NOTEBOOK_E2E summary") |
| 336 | + if (summary != "NOTEBOOK_E2E: 20 passed, 0 failed of 20") throw GradleException("notebook E2E in the native image: $summary") |
| 337 | + val otool = ProcessBuilder("otool", "-L", executable).redirectErrorStream(true).start() |
| 338 | + val linked = otool.inputStream.bufferedReader().readText().also { otool.waitFor() } |
| 339 | + if ("libjvm" in linked) throw GradleException("the native image links libjvm:\n$linked") |
| 340 | + logger.lifecycle("notebook E2E native image: $summary; no libjvm") |
| 341 | + } |
| 342 | +} |
| 343 | + |
| 344 | +tasks.register("notebookE2eNativeClasspath") { |
| 345 | + group = "native" |
| 346 | + description = "Lists the Compose artefacts the notebook E2E's native image is built from." |
| 347 | + val files = e2eNativeRuntime |
| 348 | + doLast { files.files.map { it.name }.filter { it.startsWith("ui") || it.startsWith("foundation") || it.startsWith("skiko") }.sorted().forEach { println(it) } } |
| 349 | +} |
0 commit comments