Skip to content

ExportedAcrossBatchLaunchSmokeTest.kt

src/jvmMain/kotlin/afield/examples/ExportedAcrossBatchLaunchSmokeTest.kt · 200 lines

package afield.examples

import afield.document.graph.Box
import afield.tools.automation.OperationTranscript
import afield.document.persistence.SavingContext
import afield.app.server.SessionInformation
import afield.app.server.thisSessionInfo
import java.io.File
import java.nio.file.Files
import java.util.concurrent.ConcurrentHashMap
import kotlin.system.exitProcess

FIELD_TEST id: examples.exports.batch-launch runner: jvm-main entrypoint: afield.examples.ExportedAcrossBatchLaunchSmokeTest tags: [smoke, exports, context, language, regression] timeoutSec: 300 expect: exitCode: 0 stdoutContains: - "ExportedAcrossBatchLaunchSmokeTest ok"

object ExportedAcrossBatchLaunchSmokeTest

Regression test: @exported must publish when a box is launched as part of a batch (a chord — alt-drag over several boxes — or consecutive launch ops in a transcript).

Reported 2026-08-24: chording an exporting box and a reading box together ran the exporter but left the reader unable to resolve the exported _a, and re-launching the reader did not help; only re-running the exporter through a non-batch path (its own launch, or alt-0 / alt-enter execute) made _a appear.

Cause: @exported is published as a side effect of KotlinInterface2.ContextUpdater.update(), which only runs when a snippet is evaluated with updateContext = true. The exec path (standardExecutionPractice -> exec -> evaluateInternal(updateContext = true)) does that; KotlinExecution.evaluateInterleavedBatchLaunch called PreparedSnippet.eval(...) without the flag, and it defaults to false — so a batch launch refreshed neither context.vars nor @exported. (The next exec on that engine flushed the backlog, which is why re-running the exporter once "fixed" it: its second launch re-executed the preamble through exec.)

The reader box here declares nothing: it resolves _a only if the exporter's launch published the export, wrote the var Box._a by InheritanceFromContext<...>() /**/ line into its own preamble, and the reader inherited that line — the whole chain, through the real engine, in one batch. Before the fix this fails at the reader's compile (Unresolved reference '_a'); with a saved document whose exporter preamble already carries the line (the shape the report was filed with) the same cause instead surfaces at runtime as a missing inherited property. Both are covered by asserting the reader printed the value and that no box emitted an error.

object ExportedAcrossBatchLaunchSmokeTest {

    private const val SOURCE_CODE = """@exported
var a = 10.0
println("source ready a=${'$'}a")
"""

    private const val READER_CODE = """println("reader a=${'$'}{_a}")
"""

    private fun buildTranscript(): String = """
name: exported_across_batch_launch
description: Regression - a chorded (batch) launch must publish @exported so a sibling box can read it.
operations:
  - create:
      box: exp_source
      name: Export Source
      properties:
        code: |
${SOURCE_CODE.trimEnd().prependIndent("          ")}
  - create:
      box: exp_reader
      name: Export Reader
      properties:
        code: |
${READER_CODE.trimEnd().prependIndent("          ")}
  - connect:
      parent: exp_source
      child: exp_reader
  - launch:
      box: exp_source
      property: code
  - launch:
      box: exp_reader
      property: code
  - wait:
      millis: 500
""".trimIndent()

fun main(args: Array<String>)

body — 111 lines, from line 89
    @JvmStatic
    fun main(args: Array<String>) {
        startRunloopDaemon()

        val document = freshSession("exports-batch-launch")
        val macrosDir = File(document, "macros").also { it.mkdirs() }
        File(macrosDir, "exported_across_batch_launch.yaml").writeText(buildTranscript())

        val failures = mutableListOf<String>()
        try {
            val run = OperationTranscript.runMacro(document.absolutePath, "exported_across_batch_launch")

            println("run.success=${run.success} applied=${run.applied} output lines=${run.output.size}")
            run.output.forEach { println("  [${it.kind}] ${it.uid}: ${stripHtml(it.html)}") }

            if (!run.success) {
                failures.add("transcript run failed: ${run.errors.joinToString("; ").ifBlank { run.message }}")
            }

            val errorLines = run.output.filter { it.kind == "error" || it.kind == "err" }
            if (errorLines.isNotEmpty()) {
                failures.add(
                    "box execution emitted ${errorLines.size} error diagnostic(s): " +
                        errorLines.joinToString(" | ") { "${it.uid}:${it.line} ${stripHtml(it.html)}" }
                )
            }

            val text = run.output.joinToString("\n") { stripHtml(it.html) }

            // The exporter ran at all (guards against a harness problem being read as the regression).
            assertContains(
                text,
                "source ready a=10.0",
                "the exporting box did not run in the batch launch",
                failures
            )

            // The regression itself: the reader resolves the exported alias in the SAME batch.
            assertContains(
                text,
                "reader a=10.0",
                "the reader box did not see `_a` from the exporting box launched in the same batch — " +
                    "@exported was not published by the batch-launch path (PreparedSnippet.eval " +
                    "defaults to updateContext=false, so ContextUpdater.update() never ran)",
                failures
            )

            // The export's preamble declaration must have been written by the launch, not only by exec.
            val source = thisSessionInfo.get().boxes.values.firstOrNull { it.name == "Export Source" }
            val preamble = source?.getPropertyRaw("preamble", "") as? String ?: ""
            println("source preamble: ${preamble.replace("\n", " | ")}")
            if (!preamble.contains("Box._a by InheritanceFromContext<")) {
                failures.add(
                    "the exporting box's preamble did not gain the `_a` declaration during a batch " +
                        "launch (found: \"${preamble.replace("\n", " | ")}\")"
                )
            }
        } catch (t: Throwable) {
            failures.add("harness threw ${t.javaClass.simpleName}: ${t.message}")
            t.printStackTrace(System.err)
        } finally {
            thisSessionInfo.remove()
        }

        if (failures.isNotEmpty()) {
            System.err.println("ExportedAcrossBatchLaunchSmokeTest FAILED with ${failures.size} problem(s):")
            failures.forEach { System.err.println("  - $it") }
            exitProcess(1)
        }
        println("ExportedAcrossBatchLaunchSmokeTest ok")
    }

    private fun assertContains(haystack: String, needle: String, message: String, failures: MutableList<String>) {
        if (!haystack.contains(needle)) failures.add("$message (expected to find \"$needle\")")
    }

    private fun freshSession(name: String): File {
        thisSessionInfo.remove()
        val workspace = Files.createTempDirectory("afield-$name").toFile()
        workspace.deleteOnExit()
        val document = File(workspace, "doc").also { it.mkdirs() }
        File(document, SavingContext.METADATA_EDN).writeText("{:version 2 :boxes []}\n")
        val root = Box("root")
        val session = SessionInformation(
            filename = document.absolutePath,
            documentName = document.name,
            boxes = ConcurrentHashMap(),
            root = root,
            ephemeral = true,
        )
        root.inside = session
        session.boxes[root.id] = root
        thisSessionInfo.set(session)
        return document
    }

    private fun startRunloopDaemon() {
        val t = Thread({ afield.runtime.lifecycle.Runloop.runLoop.start() }, "exports-batch-launch-harness-runloop")
        t.isDaemon = true
        t.start()
        val deadline = System.currentTimeMillis() + 30_000
        while (afield.runtime.lifecycle.Runloop.runLoop.frame < 1L) {
            check(System.currentTimeMillis() < deadline) { "runloop did not start within 30s" }
            Thread.sleep(20)
        }
        println("ExportedAcrossBatchLaunchSmokeTest: runloop up (frame=${afield.runtime.lifecycle.Runloop.runLoop.frame})")
    }

    private fun stripHtml(html: String): String =
        html.replace(Regex("<[^>]+>"), " ").replace("&lt;", "<").replace("&gt;", ">").trim()
}