KotlinJsRemoteLifecycleSmokeTest.kt
src/jvmMain/kotlin/afield/examples/KotlinJsRemoteLifecycleSmokeTest.kt · 170 lines
package afield.examples
import afield.lang.remotejs.KotlinJsHeadProfiles
import afield.lang.remotejs.KotlinToJsCompiler
import org.json.JSONObject
import java.io.File
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import afield.media.image.util.NodeTool
FIELD_TEST id: remotejs.kotlinjs-lifecycle runner: jvm-main entrypoint: afield.examples.KotlinJsRemoteLifecycleSmokeTest tags: [smoke, remotejs, kotlinjs, lifecycle] timeoutSec: 120 expect: exitCode: 0 stdoutContains: - "KotlinJsRemoteLifecycleSmokeTest ok"
object KotlinJsRemoteLifecycleSmokeTest
Executes compiled Kotlin/JS against the browser head's REAL runners.js lifecycle.
Guards the contract: - Execute runs ordinary code but onStart/onFrame/onStop are inert. - Launch runs onStart once, advances onFrame through the head service, and reports RUN-START. - Relaunch cleans the previous generation before installing the next. - Stop runs cleanup exactly once and prevents later frame callbacks.
fun main(args: Array<String>)
body — 136 lines, from line 34
@JvmStatic
fun main(args: Array<String>) {
val node = NodeTool.resolveNode() ?: run {
println("skipping lifecycle host execution: ${NodeTool.unavailableMessage()}")
println("KotlinJsRemoteLifecycleSmokeTest ok")
return
}
val root = repoRoot()
val runners = File(root, "src/commonMain/resources/extensions/remote_ar/field/runners.js")
require(runners.isFile) { "missing browser lifecycle runtime: $runners" }
val source = """
@kotlin.js.JsName("record")
external fun record(value: String)
fun main() {
record("body")
onStart {
record("start")
onFrame { frame ->
record("frame:" + frame.frameNumber)
if (frame.frameNumber == 0) {
onFrame { nested -> record("nested:" + nested.frameNumber) }
}
}
onStop { record("stop") }
}
}
""".trimIndent()
val selection = KotlinJsHeadProfiles.resolve(source)
val prepared = selection.profile.prepareSource(selection.source)
val result = KotlinToJsCompiler.compile(
prepared,
moduleName = "remote_lifecycle",
wrapImplicitMain = false,
)
require(result.success && result.js != null) {
"lifecycle sample did not compile:\n${result.rawLog.takeLast(1800)}"
}
val nodeSource = """
const events = [];
const messages = [];
const window = globalThis;
const _field = { send(_address, payload) { messages.push(payload.kind); } };
function record(value) { events.push(String(value)); }
${runners.readText()}
const compiled = ${JSONObject.quote(result.js)};
const envelope = {
codeName:"box_lifecycle|uid",
lifecycleName:"box_lifecycle|uid::kotlinjs::music",
returnTo:"return",
launchable:false
};
function evaluate(launchable) {
envelope.launchable = launchable;
const lifecycle = __fieldBeginLifecycle(envelope, error => { throw error; });
try {
eval(compiled);
__fieldSealLifecycle(lifecycle);
__fieldCommitLifecycle(lifecycle);
} catch (error) {
__fieldAbortLifecycle(lifecycle);
throw error;
}
}
// Execute: body runs, launch-only work is completely inert.
evaluate(false);
if (events.join(",") !== "body") throw new Error("execute leaked lifecycle work: " + events);
if (Object.keys(__tasks).length !== 0) throw new Error("execute installed a task");
// Launch: onStart once, then one callback per host frame.
evaluate(true);
if (__tasks[envelope.lifecycleName] == null || __tasks[envelope.codeName] != null) {
throw new Error("lifecycle did not use the strand-specific task key");
}
__service();
__service();
if (events.join(",") !== "body,body,start,frame:0,frame:1,nested:1") {
throw new Error("launch/frame order wrong: " + events);
}
if (messages.join(",") !== "RUN-START") throw new Error("missing RUN-START: " + messages);
// Relaunch: cleanup old generation before the new body/start.
evaluate(true);
if (events.slice(-3).join(",") !== "stop,body,start") {
throw new Error("relaunch did not clean before start: " + events);
}
__service();
if (events[events.length - 1] !== "frame:0") throw new Error("new generation did not restart frame count");
// Stop is immediate/idempotent and no frame runs afterwards.
if (!__fieldStopLifecycle(envelope.lifecycleName)) throw new Error("stop did not find lifecycle");
const stopped = events.join(",");
__fieldStopLifecycle(envelope.lifecycleName);
__service();
if (events.join(",") !== stopped) throw new Error("work continued after stop: " + events);
if (events.filter(x => x === "stop").length !== 2) throw new Error("cleanup count wrong: " + events);
if (messages.join(",") !== "RUN-START,RUN-STOP,RUN-START,RUN-STOP") {
throw new Error("running-state sequence wrong: " + messages);
}
console.log("browser-lifecycle-ok " + events.join("|"));
""".trimIndent()
val script = Files.createTempFile("field-kotlinjs-lifecycle", ".js").toFile()
try {
script.writeText(nodeSource)
val process = ProcessBuilder(node, script.absolutePath).redirectErrorStream(true).start()
val finished = process.waitFor(30, TimeUnit.SECONDS)
if (!finished) process.destroyForcibly()
val output = process.inputStream.bufferedReader().readText().trim()
require(finished && process.exitValue() == 0 && output.contains("browser-lifecycle-ok")) {
"browser lifecycle host test failed:\n$output"
}
println(output)
} finally {
script.delete()
}
println("KotlinJsRemoteLifecycleSmokeTest ok")
}
private fun repoRoot(): File {
var at = File(System.getProperty("user.dir")).absoluteFile
repeat(8) {
if (File(at, "settings.gradle.kts").isFile) return at
at = at.parentFile ?: return@repeat
}
error("could not locate Field repository root from ${System.getProperty("user.dir")}")
}
}