Skip to content

Commit cac9613

Browse files
authored
Merge pull request #250 from thisisthepy/feat/notebook-e2e-native-image
Notebook E2E in a macOS GraalVM native image (#191 milestone 1, #249)
2 parents c985469 + 58ee600 commit cac9613

8 files changed

Lines changed: 1778 additions & 0 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,17 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
561561
with the notebook's own state spelling (`main.App.messages.getValue()` / `setValue(...)`, #174)
562562
against pythonx-compose f6dd3e2. CI runs it on every push to `develop`
563563
(`desktop.yml`, job `notebook-e2e`, #213), against a wheel built from pythonx-compose `develop`.
564+
**In a GraalVM native image on macOS** (#249, #191's first milestone): `NotebookE2eMain` runs the
565+
same 20 scenarios by direct calls, and `notebookE2eNativeRun` requires 20 passed with `JAVA_HOME`
566+
and `PYTHONHOME` removed and no `libjvm` linked (only system frameworks); the prefix
567+
(`stageNativeImagePythonHome`, L-9), the wheel and the app are staged beside the executable. 20
568+
passed, 0 failed on 2026-10-17 (Liberica NIK 25.0.4.1, a 129 MB image), also from a relocated copy
569+
under `env -i`. The image takes `ui`, `ui-text` and `foundation` from compose-multiplatform-core-extended
570+
(`composeExtended.*` in `gradle.properties`, one place), because upstream Compose dispatches its
571+
main-thread work to skiko's Swing queue, which starts AWT, and AWT in an image needs the JDK's
572+
`libawt`, which links `libjvm`; the fork sends it to `Dispatchers.Main` when
573+
`compose.main.dispatcher=coroutines`, and the fixture supplies a single-thread one with no AWT.
574+
`desktopTest` keeps upstream Compose. Not on CI yet: the fork's artefacts are in mavenLocal only.
564575
- **B-11** (#192) The artifact walker skips every declaration whose package has an `internal` segment
565576
(`*.internal` and `*.internal.*`, as in `androidx.compose.ui.internal`), on jars and on klibs. That is the
566577
Kotlin `.api` dump convention: a library that puts a declaration in such a package has declared it

‎gradle.properties‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,12 @@ pythonAppleSupportBuild=b11
3434
# toolchain any existing task resolves to. See AGENTS.md §19 for why tooling lives
3535
# under /Volumes/macMini rather than the internal disk.
3636
org.gradle.java.installations.paths=/Volumes/macMini/toolchains/bellsoft-liberica-vm-full-openjdk25-25.0.4/Contents/Home
37+
38+
# compose-multiplatform-core-extended (#191, #249): the Compose modules a native-image build takes from
39+
# the fork instead of upstream, and where from. Read in one place, by the notebook E2E's native image;
40+
# the fork's coordinates and version line are changing (to 1.11.1-ext.N), and only these lines follow.
41+
composeExtended.version=1.11.1001
42+
# The group the fork publishes under, as a prefix of upstream's (the fork moves to org.thisisthepy.compose).
43+
composeExtended.groupPrefix=org.jetbrains.compose
44+
composeExtended.modules=org.jetbrains.compose.ui:ui,org.jetbrains.compose.ui:ui-desktop,org.jetbrains.compose.ui:ui-text,org.jetbrains.compose.ui:ui-text-desktop,org.jetbrains.compose.foundation:foundation,org.jetbrains.compose.foundation:foundation-desktop
45+
composeExtended.mavenLocal=true

‎python-multiplatform-compose/fixtures/notebook-e2e/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ the wheel from pythonx-compose `develop` with uv and runs this module on every p
5050
interpreter has no installer of its own. The tests then require `pythonx.compose.__file__` to be
5151
inside that directory, so a stray source checkout on `sys.path` cannot stand in for the wheel.
5252

53+
## In a GraalVM native image (macOS, #249)
54+
55+
The same 20 scenarios, built into one executable and run on their own:
56+
57+
```bash
58+
JAVA_HOME=<JDK 21> ./gradlew :python-multiplatform-compose:fixtures:notebook-e2e:notebookE2eNativeRun \
59+
-PpythonxComposeWheel=.tmp/pythonx-compose-dist --console=plain
60+
```
61+
62+
- `NotebookE2eMain` calls each scenario after its class's `@BeforeTest`, with no JUnit, and prints
63+
`NOTEBOOK_E2E: n passed, m failed of 20`. `notebookE2eJvm` runs it on the JVM first.
64+
- `notebookE2eNativeCompile` calls `native-image` from the JDK 25 toolchain (Liberica NIK Full). The
65+
configuration Compose and skiko need is `src/desktopTest/native-image`, recorded by
66+
`notebookE2eNativeAgent`; rerun that task after a dependency changes.
67+
- Beside the executable: `python-multiplatform-home/` (the plugin's `stageNativeImagePythonHome`),
68+
`site-packages/` (the wheel) and `app/`. The image finds them there with no environment.
69+
- `notebookE2eNativeRun` removes `JAVA_HOME` and `PYTHONHOME`, requires 20 passed, and fails if
70+
`otool -L` shows `libjvm`.
71+
- The image takes `ui`, `ui-text` and `foundation` from compose-multiplatform-core-extended's mavenLocal
72+
build (`composeExtended.*` in the root `gradle.properties`): upstream Compose sends its main-thread
73+
work to skiko's Swing queue, which needs AWT, and AWT needs `libjvm`. The fork sends it to
74+
`Dispatchers.Main` under `compose.main.dispatcher=coroutines`, which `NotebookE2eMainDispatcherFactory`
75+
supplies (registered by `src/desktopTest/native-image-main/` on the image's classpath only).
76+
5377
## Scenarios
5478

5579
| Notebook cells | Test | What must hold |

‎python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import java.io.ByteArrayOutputStream
2+
import org.apache.tools.ant.util.TeeOutputStream
13
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
24
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
35

@@ -181,3 +183,167 @@ tasks.named<Test>("desktopTest") {
181183
}
182184
enabled = !notebookSkipped
183185
}
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+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package fixture.notebook
2+
3+
import java.io.File
4+
import kotlin.system.exitProcess
5+
6+
/**
7+
* The notebook E2E as one executable (issue #249, #191's first milestone): the same 20 scenarios the
8+
* `desktopTest` suite runs, called directly, so they can run inside a GraalVM native image, where
9+
* there is no JUnit and no reflection to find a test with.
10+
*
11+
* Each scenario is its class's `@BeforeTest` (`host()`) and then the test method, exactly what JUnit
12+
* does for these classes (none has an `@AfterTest`). A scenario passes when it returns and fails when
13+
* it throws; the summary line is what `notebookE2eNativeRun` checks, and the exit status is non-zero
14+
* on any failure.
15+
*
16+
* The wheel's `site-packages` and the application directory are the system properties the suite
17+
* reads (`NotebookHost`); when they are not set -- a native image started by hand -- they are
18+
* `site-packages/` and `app/` beside the executable, where the build stages them.
19+
*/
20+
fun main() {
21+
// The fork's switch (compose-multiplatform-core-extended `ExtendedMainDispatcher`): Compose's
22+
// main-thread work goes to `Dispatchers.Main`, which `NotebookE2eMainDispatcherFactory` supplies
23+
// with no AWT, instead of skiko's Swing event queue. Read once, before Compose starts; upstream
24+
// Compose ignores it.
25+
System.setProperty("compose.main.dispatcher", "coroutines")
26+
besideTheExecutable("notebookE2e.sitePackages", "site-packages")
27+
besideTheExecutable("notebookE2e.appDir", "app")
28+
29+
val scenarios: List<Pair<String, () -> Unit>> = listOf(
30+
"cell05_theImportsResolveFromTheInstalledWheel" to { NotebookRootTest().run { host(); cell05_theImportsResolveFromTheInstalledWheel() } },
31+
"cell06to07_importingMainDrawsTheAppItDeclares" to { NotebookRootTest().run { host(); cell06to07_importingMainDrawsTheAppItDeclares() } },
32+
"cell09to13_theNotebookAndTheScreenShareOneState" to { NotebookRootTest().run { host(); cell09to13_theNotebookAndTheScreenShareOneState() } },
33+
"cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall" to { NotebookRootTest().run { host(); cell15to16_redeclaringTheRootChangesTheScreenWithNoUpdateCall() } },
34+
"negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen" to { NotebookRootTest().run { host(); negative_withAppStubbedOutTheRedeclarationDoesNotReachTheScreen() } },
35+
"negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration" to { NotebookRootTest().run { host(); negative_aHostHandedTheRootOnceDoesNotFollowARedeclaration() } },
36+
"cell19_text" to { NotebookPracticeTest().run { host(); cell19_text() } },
37+
"cell20_textWithAFontSize" to { NotebookPracticeTest().run { host(); cell20_textWithAFontSize() } },
38+
"cell22_anEmptyBlackButton" to { NotebookPracticeTest().run { host(); cell22_anEmptyBlackButton() } },
39+
"cell23_aButtonHoldingText" to { NotebookPracticeTest().run { host(); cell23_aButtonHoldingText() } },
40+
"cell25_aCard" to { NotebookPracticeTest().run { host(); cell25_aCard() } },
41+
"cell28_defaultIconsAddIsAnImageVector" to { NotebookPracticeTest().run { host(); cell28_defaultIconsAddIsAnImageVector() } },
42+
"cell29_anIcon" to { NotebookPracticeTest().run { host(); cell29_anIcon() } },
43+
"cell30_anIconInsideAButton" to { NotebookPracticeTest().run { host(); cell30_anIconInsideAButton() } },
44+
"cell35_aColumnOfTwoButtons" to { NotebookPracticeTest().run { host(); cell35_aColumnOfTwoButtons() } },
45+
"cell36_aColumnAlignedToTheEnd" to { NotebookPracticeTest().run { host(); cell36_aColumnAlignedToTheEnd() } },
46+
"cell37_aRowWithAnArrangement" to { NotebookPracticeTest().run { host(); cell37_aRowWithAnArrangement() } },
47+
"cell38_aRow" to { NotebookPracticeTest().run { host(); cell38_aRow() } },
48+
"cell40_aCardSpacedBySpacers" to { NotebookPracticeTest().run { host(); cell40_aCardSpacedBySpacers() } },
49+
"cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult" to {
50+
NotebookTextFieldTest().run { host(); cell32to33_aTextFieldKeepsInputMethodCompositionInComposeAndTheNotebookReadsTheResult() }
51+
},
52+
)
53+
54+
var failed = 0
55+
for ((name, scenario) in scenarios) {
56+
try {
57+
scenario()
58+
println("PASS $name")
59+
} catch (t: Throwable) {
60+
failed++
61+
println("FAIL $name: $t")
62+
t.printStackTrace(System.out)
63+
}
64+
}
65+
println("NOTEBOOK_E2E: ${scenarios.size - failed} passed, $failed failed of ${scenarios.size}")
66+
System.out.flush()
67+
exitProcess(if (failed == 0) 0 else 1)
68+
}
69+
70+
private fun besideTheExecutable(property: String, directory: String) {
71+
if (System.getProperty(property) != null) return
72+
val executable = ProcessHandle.current().info().command().orElse(null) ?: return
73+
System.setProperty(property, File(File(executable).absoluteFile.parentFile, directory).path)
74+
}

0 commit comments

Comments
 (0)