From b1a072c5d9e9bff5ad399d4c51a7ce9b2f665cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Sun, 30 Aug 2026 22:36:51 +0200 Subject: [PATCH 01/13] scala.js: a test link emits the module kind the build declared (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main link path read `--module-kind`, then the project's `jsKind`. The test path declared `ScalaJsLinkConfig.Debug` and took its `CommonJSModule` along with the debug semantics it actually wanted, so a build saying `jsKind: esmodule` had its tests linked as CommonJS, with no flag able to change it and nothing saying so. Debug semantics for a test link stay: nobody wants their tests run through the optimizer, and the output directory is named after the mode, so this changes where nothing lands. The module kind is not a semantics choice — it is what the build declared. Both paths now derive it through one function, and `runScalaJsTestSuite` is handed the kind that was linked rather than deriving a second opinion from a constant. The adapter picks its `Input` from that value, and a NoModule program loaded as a module — or the reverse — fails before any test runs, so the one thing it must never be is a guess. Asserted on the emitted JavaScript, because the suite passed either way: the constant was consistent, consistently wrong, and a test that checked which config was passed around would have gone on passing throughout. Co-Authored-By: Claude Opus 5 (1M context) --- .../bleep/bsp/MultiWorkspaceBspServer.scala | 65 ++++++---- .../scala/bleep/ScalaJsTestModuleKindIT.scala | 114 ++++++++++++++++++ 2 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 bleep-tests/src/scala/bleep/ScalaJsTestModuleKindIT.scala diff --git a/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala b/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala index b8843b965..f2fcb441b 100644 --- a/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala +++ b/bleep-bsp/src/scala/bleep/bsp/MultiWorkspaceBspServer.scala @@ -1850,23 +1850,7 @@ class MultiWorkspaceBspServer( emitSourceMaps = linkOpts.sourceMaps.getOrElse(baseConfig.emitSourceMaps), minify = linkOpts.minify.getOrElse(baseConfig.minify), optimizer = linkOpts.optimize.getOrElse(baseConfig.optimizer), - // `--module-kind` first, then the project's own `jsKind`, and only then the constant. - // - // The project was never consulted: a build declaring `jsKind: esmodule` got a CommonJS link and no flag was needed to cause it, because the - // fallback was a hardcoded `CommonJSModule`. That also quietly disarmed the Closure rule below, which skips Closure for ESModule output because - // Scala.js rejects the pairing — a yaml-declared ESModule project reached it looking like CommonJS. - moduleKind = linkOpts.moduleKind - .map { - case "nomodule" => ScalaJsLinkConfig.ModuleKind.NoModule - case "esmodule" => ScalaJsLinkConfig.ModuleKind.ESModule - case _ => ScalaJsLinkConfig.ModuleKind.CommonJSModule - } - .orElse(project.platform.flatMap(_.jsKind).map { - case model.ModuleKindJS.NoModule => ScalaJsLinkConfig.ModuleKind.NoModule - case model.ModuleKindJS.CommonJSModule => ScalaJsLinkConfig.ModuleKind.CommonJSModule - case model.ModuleKindJS.ESModule => ScalaJsLinkConfig.ModuleKind.ESModule - }) - .getOrElse(baseConfig.moduleKind) + moduleKind = scalaJsModuleKind(project, linkOpts.moduleKind, baseConfig.moduleKind) ) Some(crossName -> TaskDag.LinkPlatform.ScalaJs(sjsVersion, scalaVersion, config)) @@ -2390,7 +2374,11 @@ class MultiWorkspaceBspServer( .getOrElse(throw new IllegalStateException(s"Scala.js version not found for ${crossName.value}")) val scalaVersion = project.scala.flatMap(_.version).map(_.scalaVersion).getOrElse(throw new IllegalStateException(s"Scala version not found for ${crossName.value}")) - val config = bleep.analysis.ScalaJsLinkConfig.Debug + // Debug semantics for a test link are deliberate — nobody wants their tests run through the optimizer — but the module kind is not a semantics + // choice, it is what the build declared. `Debug` carries `CommonJSModule`, and taking that wholesale meant a project declaring `jsKind: esmodule` + // had its tests linked as CommonJS with nothing to say so. + val base = bleep.analysis.ScalaJsLinkConfig.Debug + val config = base.copy(moduleKind = scalaJsModuleKind(project, None, base.moduleKind)) Some(crossName -> TaskDag.LinkPlatform.ScalaJs(sjsVersion, scalaVersion, config)) case (Some(model.PlatformId.Native), true) => @@ -2594,7 +2582,14 @@ class MultiWorkspaceBspServer( case (Some(model.PlatformId.Js), true) => runKotlinJsTestSuite(started, testTask, linkedArtifactOf(testTask.project, linkResult), testEnv, eventQueue, taskKillSignal) case (Some(model.PlatformId.Js), false) => - runScalaJsTestSuite(started, testTask, classpath, testEnv, linkResult, eventQueue, taskKillSignal) + // Read back off the platform this run's DAG was built with, so the runner is told how the program was emitted rather than deciding for + // itself. A missing entry means a Scala.js test project reached the handler without a link node, which is a broken DAG, not a default. + val moduleKind = platforms.get(testTask.project) match { + case Some(TaskDag.LinkPlatform.ScalaJs(_, _, config)) => config.moduleKind + case other => + throw new IllegalStateException(s"No Scala.js link platform for test project ${testTask.project.value}, got $other") + } + runScalaJsTestSuite(started, testTask, classpath, testEnv, linkResult, moduleKind, eventQueue, taskKillSignal) case (Some(model.PlatformId.Native), true) => runKotlinNativeTestSuite(started, testTask, linkedArtifactOf(testTask.project, linkResult), testEnv, eventQueue, taskKillSignal) case (Some(model.PlatformId.Native), false) => @@ -3560,6 +3555,31 @@ class MultiWorkspaceBspServer( private def nodeBinaryFor(started: Started, project: model.Project): String = started.pre.fetchNode(project.platform.flatMap(_.jsNodeVersion).getOrElse(bleep.constants.Node)).toAbsolutePath.toString + /** The module kind a Scala.js link emits for `project`: an explicit `--module-kind` first, then the project's own `jsKind`, and only then the base + * configuration's own default. + * + * One function for the main link and the test link because there were two, and they disagreed. The test path declared `ScalaJsLinkConfig.Debug` and took its + * `CommonJSModule` along with the debug semantics it actually wanted, so a build declaring `jsKind: esmodule` had its tests linked as CommonJS and no flag + * could change it. The test path passes `None` for the flag — `bleep test` accepts no link options — and so always gets what the build declared. + */ + private def scalaJsModuleKind( + project: model.Project, + fromFlag: Option[String], + fallback: ScalaJsLinkConfig.ModuleKind + ): ScalaJsLinkConfig.ModuleKind = + fromFlag + .map { + case "nomodule" => ScalaJsLinkConfig.ModuleKind.NoModule + case "esmodule" => ScalaJsLinkConfig.ModuleKind.ESModule + case _ => ScalaJsLinkConfig.ModuleKind.CommonJSModule + } + .orElse(project.platform.flatMap(_.jsKind).map { + case model.ModuleKindJS.NoModule => ScalaJsLinkConfig.ModuleKind.NoModule + case model.ModuleKindJS.CommonJSModule => ScalaJsLinkConfig.ModuleKind.CommonJSModule + case model.ModuleKindJS.ESModule => ScalaJsLinkConfig.ModuleKind.ESModule + }) + .getOrElse(fallback) + /** Run a Scala.js test suite: link → run via Node.js, emit events to DAG queue. */ private def runScalaJsTestSuite( started: Started, @@ -3567,6 +3587,7 @@ class MultiWorkspaceBspServer( classpath: List[Path], testEnv: Map[String, String], linkResult: Option[TaskDag.LinkResult], + moduleKind: ScalaJsLinkConfig.ModuleKind, eventQueue: Queue[IO, Option[TaskDag.DagEvent]], killSignal: Deferred[IO, KillReason] ): IO[TaskDag.TaskResult] = { @@ -3574,8 +3595,8 @@ class MultiWorkspaceBspServer( val sjsVersion = project.platform.flatMap(_.jsVersion).getOrElse { throw new IllegalStateException(s"Scala.js version not found for ${testTask.project.value}") } - // No Scala version needed here any more: it was only ever used to describe the link this function used to run itself. - val linkConfig = bleep.analysis.ScalaJsLinkConfig.Debug + // Taken from the link the DAG ran rather than declared again here. The adapter picks its `Input` from this — a NoModule program loaded as a module, or the + // reverse, fails before any test runs — so the one thing it must never be is a second opinion about how the program was emitted. for { startTs <- IO.realTime.map(_.toMillis) @@ -3598,7 +3619,7 @@ class MultiWorkspaceBspServer( ScalaJsTestRunner .runTests( mainModule, - linkConfig.moduleKind, + moduleKind, suites, eventHandler, ScalaJsTestRunner.NodeEnvironment.Node, diff --git a/bleep-tests/src/scala/bleep/ScalaJsTestModuleKindIT.scala b/bleep-tests/src/scala/bleep/ScalaJsTestModuleKindIT.scala new file mode 100644 index 000000000..638324a68 --- /dev/null +++ b/bleep-tests/src/scala/bleep/ScalaJsTestModuleKindIT.scala @@ -0,0 +1,114 @@ +package bleep + +import java.nio.file.{Files, Path} +import scala.jdk.CollectionConverters.* + +/** A Scala.js test link has to emit the module kind the build declared. + * + * The link `bleep test` runs and the link `bleep link` runs used to disagree about that. The main path read `--module-kind`, then the project's `jsKind`; the + * test path declared `ScalaJsLinkConfig.Debug` and took its `CommonJSModule` along with the debug semantics it actually wanted. So a build saying `jsKind: + * esmodule` had its tests linked as CommonJS, with no flag able to change it and nothing reporting the substitution. + * + * Asserted against the emitted JavaScript rather than the configuration, for the same reason [[ScalaJsReleaseLinkIT]] is: a test that checks which constant + * was passed around would have gone on passing throughout, because the constant was consistent — consistently wrong. + */ +class ScalaJsTestModuleKindIT extends IntegrationTestHarness { + + private val mytest = model.CrossProjectName(model.ProjectName("mytest"), None) + + private def yamlFor(jsKind: String): String = + s"""projects: + | mytest: + | dependencies: + | - org.scalameta::munit:${model.Versions.Munit} + | isTestProject: true + | platform: + | name: js + | jsVersion: ${model.Versions.ScalaJs1} + | jsNodeVersion: ${model.Versions.Node} + | jsKind: $jsKind + | scala: + | version: ${model.Versions.Scala3} + |""".stripMargin + + /** A passing suite, plus a top-level export. + * + * The export is what makes the module kind visible at all. Scala.js emits a program with no exported members almost identically under CommonJS and under + * ESModule — the difference is in how exports leave the module, so a fixture with none has nothing to tell them apart by, and the issue reporting this said + * as much. + */ + private val Source = + """package example + | + |import scala.scalajs.js.annotation.JSExportTopLevel + | + |object Exports { + | @JSExportTopLevel("greet") + | def greet(name: String): String = s"Hello, $name!" + |} + | + |class ModuleKindSuite extends munit.FunSuite { + | test("greets") { assertEquals(Exports.greet("world"), "Hello, world!") } + |} + |""".stripMargin + + /** The linked test program, wherever under `.bleep` the test run put it. Walked rather than reconstructed from the path convention, so a change to the layout + * surfaces as a different file instead of as this test quietly finding nothing. + */ + private def linkedTestJs(ws: Workspace): Path = { + val root = ws.root.resolve(".bleep") + val candidates = + Files + .walk(root) + .iterator() + .asScala + .filter(p => p.getFileName.toString == "main.js") + .toList + candidates match { + case one :: Nil => one + case Nil => fail(s"no linked main.js under ${ws.root}") + case many => fail(s"expected one linked main.js, found ${many.size}: ${many.mkString(", ")}") + } + } + + private def runTests(ws: Workspace): Unit = { + val (_, commands, _) = ws.start() + commands.test(List(mytest), watch = false, only = None, exclude = None, includeTags = None, excludeTags = None) + } + + integrationTest("a test link emits the ES module the build declared") { ws => + ws.yaml(yamlFor("esmodule")) + ws.file("mytest/src/scala/example/ModuleKindSuite.scala", Source) + + runTests(ws) + + val js = Files.readString(linkedTestJs(ws)) + // An ES module says goodbye to its exports with an `export` statement; CommonJS assigns them onto `exports`. Both markers are checked, because asserting + // only the absence of the wrong one would also pass for a linked file that exported nothing at all. + assert( + js.contains("export {") || js.linesIterator.exists(_.trim.startsWith("export ")), + s"the test link did not emit an ES module, so `jsKind: esmodule` never reached it. Tail:\n${js.takeRight(400)}" + ) + assert( + !js.contains("exports.greet"), + s"the test link emitted CommonJS exports despite `jsKind: esmodule`. Tail:\n${js.takeRight(400)}" + ) + succeed + } + + integrationTest("a test link emits CommonJS when the build declares it") { ws => + ws.yaml(yamlFor("commonjs")) + ws.file("mytest/src/scala/example/ModuleKindSuite.scala", Source) + + runTests(ws) + + val js = Files.readString(linkedTestJs(ws)) + // The other direction, and the reason this pair exists: `commonjs` was what the test link produced no matter what, so a test asserting only the ESModule + // case would leave "does it still honour the declaration when the declaration is the old constant" unchecked. + assert( + js.contains("exports.greet"), + s"the test link did not emit a CommonJS module despite `jsKind: commonjs`. Tail:\n${js.takeRight(400)}" + ) + succeed + } +} From 32e558410ed66abc30a26a369645ea323e029bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Raddum=20Berg?= Date: Sun, 30 Aug 2026 22:37:03 +0200 Subject: [PATCH 02/13] run: a program's own exit code is its answer, not a bleep failure (#670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bleep