SharedTypesSmokeTest.kt
src/jvmMain/kotlin/afield/runtime/kotlin/SharedTypesSmokeTest.kt · 235 lines
package afield.runtime.kotlin
import afield.app.server.SessionInformation
import afield.document.graph.Box
import afield.app.diagnostics.named
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.plus
import afield.data.table.rem
import afield.data.table.times
import afield.document.graph.box
import afield.runtime.execution.plus
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.plus
import afield.runtime.lifecycle.rem
import afield.ui.notation.with
import afield.audio.synth.*
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.io.File
import java.net.URLClassLoader
import java.nio.file.Files
import kotlin.reflect.full.createType
FIELD_TEST id: kotlin.shared-types.smoke runner: jvm-main entrypoint: afield.runtime.kotlin.SharedTypesSmokeTest tags: [smoke, kotlin, repl, exported] timeoutSec: 420 expect: exitCode: 0 stdoutContains: - "Shared types smoke passed"
End-to-end smoke for SharedTypes: publishing user-declared Kotlin types that every box can name.
The assertions cover the whole arc the feature exists for:
testPublishProducesLoadableClasses— publish compiles and lands on the shared loader.testPublishedTypeIsExportable— the payoff for@exported. A published type passes ExportedTypeNames, so it crosses between boxes with its real type where a box-declared class can only ever cross asAny?. This is the join between the two halves of the work.testFreshEngineGetsFullFidelity— named arguments,copy, destructuring and the data-classtoStringall survive, which only happens because these are real compiled classes.
measureRepublish is a measurement rather than an assertion, in the style of
SharedTypesClasspathSpike: it reports whether a second publish of the same class name actually
takes effect. It may well not — gen-1 was added to the classloader before gen-2, and URL
search order is first-wins — and if so that is a real constraint on the feature, not a test bug.
object SharedTypesSmokeTest
object SharedTypesSmokeTest {
private const val SOURCE_V1 = """
package shared
data class Foo(val a: Double, val b: Double)
"""
private const val SOURCE_V2 = """
package shared
data class Foo(val a: Double, val b: Double) {
val sum: Double get() = a + b
}
"""
private lateinit var workspace: File
fun runTests(): Boolean
fun runTests(): Boolean {
return try {
println("Running shared types smoke...")
workspace = Files.createTempDirectory("shared-types-smoke").toFile()
// Mirror what KotlinInterface2's init would build, so publish targets the loader that
// every engine in this process derives its compile classpath from.
KotlinInterface2.sharedClassLoader = URLClassLoader(
arrayOf(),
Thread.currentThread().contextClassLoader
)
val first = testPublishProducesLoadableClasses()
testReadOnlyPublication()
testPublishedTypeIsExportable()
testFreshEngineGetsFullFidelity()
measureRepublish(first)
println("✅ Shared types smoke passed")
true
} catch (t: Throwable) {
println("❌ Shared types smoke failed: ${t.message}")
t.printStackTrace()
false
}
}
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
check(runTests()) { "Shared types smoke failed" }
}
refreshOthers=false throughout: there is no document session in a bare jvm-main test.
source — 42 lines, from line 112
private fun publish(source: String) =
SharedTypes.publish(source, workspace, except = null, refreshOthers = false)
private fun testPublishProducesLoadableClasses(): SharedTypes.Publication {
val published = publish(SOURCE_V1)
require(published.ok) { "publish failed: ${published.log}" }
require(published.classFiles.any { it == "Foo.class" }) {
"expected Foo.class, got ${published.classFiles}"
}
require(published.dir?.let { File(it, "shared/Foo.class").exists() } == true) {
"Foo.class is not at the package-qualified path in ${published.dir}"
}
// Runtime half: the shared loader reaches it, so every box's snippet loader will too.
val loaded = KotlinInterface2.sharedClassLoader!!.loadClass("shared.Foo")
require(loaded.name == "shared.Foo") { "unexpected class: ${loaded.name}" }
println(" published gen-${published.generation} -> ${published.classFiles}, loadable as ${loaded.name}")
return published
}
private fun testReadOnlyPublication() {
val document = Files.createTempDirectory("shared-types-readonly-smoke").toFile()
val metadata = File(document, "metadata.edn").apply { writeText("{}\n") }
val before = metadata.readBytes()
val root = Box("root")
val session = SessionInformation(document.absolutePath, document.name, root = root, readOnly = true)
root.inside = session
val published = SharedTypes.publish(
"package readonlyshared; data class Sample(val value: Int)", document,
refreshOthers = false, session = session
)
require(published.ok) { "read-only publish failed: ${published.log}" }
val output = requireNotNull(published.dir)
require(!output.toPath().startsWith(document.toPath())) { "classes were written into the document" }
require(document.list()!!.toList() == listOf("metadata.edn") && metadata.readBytes().contentEquals(before)) {
"read-only publication changed the document"
}
val type = KotlinInterface2.sharedClassLoader!!.loadClass("readonlyshared.Sample")
require(type.getConstructor(Int::class.javaPrimitiveType).newInstance(7).toString() == "Sample(value=7)")
println(" read-only publication: real classes load from temporary storage; document unchanged")
document.deleteRecursively()
}
The join with Phase 0. A published type is nameable, so @exported carries it with its real
type — which is precisely what a box-declared class cannot do.
private fun testPublishedTypeIsExportable() {
val loader = KotlinInterface2.sharedClassLoader!!
val fooType = loader.loadClass("shared.Foo").kotlin.createType()
val rendered = ExportedTypeNames.render(fooType, loader)
require(rendered == "shared.Foo") {
"a published type must be nameable for @exported, got <$rendered>"
}
println(" @exported would carry it as: $rendered")
// And a List of it, to show the argument survives rather than being erased to a star.
val listOfFoo = List::class.createType(
listOf(kotlin.reflect.KTypeProjection.invariant(fooType))
)
val renderedList = ExportedTypeNames.render(listOfFoo, loader)
require(renderedList == "kotlin.collections.List<shared.Foo>") {
"expected List<shared.Foo>, got <$renderedList>"
}
println(" ...and List<shared.Foo> as: $renderedList")
}
The niceties: named arguments, copy, destructuring, data-class toString.
private fun testFreshEngineGetsFullFidelity() {
val ki = KotlinInterface2(filePrefix = "shared-types-smoke-1")
val basic = eval(ki, "shared.Foo(a = 1.0, b = 2.0).toString()")
require(basic.contains("Foo(a=1.0, b=2.0)")) { "named args / toString failed: $basic" }
val copied = eval(ki, "shared.Foo(a = 1.0, b = 2.0).copy(b = 5.0).toString()")
require(copied.contains("Foo(a=1.0, b=5.0)")) { "copy with named arg failed: $copied" }
val destructured = eval(ki, "val (x, y) = shared.Foo(3.0, 4.0); x + y")
require(destructured.contains("7")) { "destructuring failed: $destructured" }
println(" fresh engine: named args, copy and destructuring all work")
}
MEASUREMENT, not an assertion. Publishing shared.Foo a second time writes gen-2, but
gen-1 is already ahead of it on the classloader and on the compile classpath, and URL search
order is first-wins. If the new member is invisible, re-publishing a changed type needs a
new shared classloader — i.e. a document-wide restart, not a per-box one.
private fun measureRepublish(first: SharedTypes.Publication) {
val second = publish(SOURCE_V2)
require(second.ok) { "second publish failed to compile: ${second.log}" }
require(second.generation > first.generation) {
"generation did not advance: ${first.generation} -> ${second.generation}"
}
println(" re-published as gen-${second.generation} (gen-${first.generation} still on the loader)")
require(second.shadowed == listOf("shared.Foo")) {
"a re-publish must report what it shadows rather than fail silently, got ${second.shadowed}"
}
println(" publish reported the shadowing: ${second.shadowed}")
val ki = KotlinInterface2(filePrefix = "shared-types-smoke-2")
val outcome = try {
eval(ki, "shared.Foo(1.0, 2.0).sum")
} catch (t: Throwable) {
"threw ${t::class.simpleName}: ${t.message}"
}
val visible = outcome.contains("3")
println(" probe: new member visible after re-publish = $visible ($outcome)")
println(
if (visible) {
"VERDICT: re-publish takes effect — generation directories are enough."
} else {
"VERDICT: re-publish is SHADOWED by the earlier generation — changing an existing " +
"shared type needs a new shared classloader (document-wide restart), not just a " +
"new generation directory."
}
)
}
private fun eval(ki: KotlinInterface2, code: String): String =
runBlocking { withTimeout(180_000) { ki.exec(code) } }?.toString() ?: "<null>"
}