Skip to content

Commit 4689941

Browse files
committed
Include JVM test-runner in export --json output
The runner is added inside Artifacts.apply (gated on addJvmTestRunner) - after BuildInfo has been materialized, so export --json reported only user-declared test deps. Extract the runner-version selection (with legacy fallbacks for old Scala/Java) into a shared helper and call it from ScopedSources.getScopedBuildInfo and JsonProjectDescriptor.export so the test scope's dependencies reflect what scala-cli would resolve at test time. Injection is gated on scope == Test, non-empty sources, JVM platform, and a Scala (non-Java-only) build, mirroring Artifacts.
1 parent bcc09a4 commit 4689941

5 files changed

Lines changed: 261 additions & 35 deletions

File tree

modules/build/src/main/scala/scala/build/ScopedSources.scala

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ final case class ScopedSources(
9090
Sources.InMemory(
9191
Left("build-info"),
9292
os.rel / "BuildInfo.scala",
93-
value(buildInfo(combinedOptions, workspace)).generateContents().getBytes(
93+
value(buildInfo(combinedOptions, workspace, logger)).generateContents().getBytes(
9494
StandardCharsets.UTF_8
9595
),
9696
None
@@ -123,7 +123,11 @@ final case class ScopedSources(
123123
buildOptionsFor(scope)
124124
.foldRight(baseOptions)(_.orElse(_))
125125

126-
def buildInfo(baseOptions: BuildOptions, workspace: os.Path): Either[BuildException, BuildInfo] =
126+
def buildInfo(
127+
baseOptions: BuildOptions,
128+
workspace: os.Path,
129+
logger: Logger
130+
): Either[BuildException, BuildInfo] =
127131
either {
128132
def getScopedBuildInfo(scope: Scope): ScopedBuildInfo =
129133
val combinedOptions = combinedBuildOptions(scope, baseOptions)
@@ -133,7 +137,7 @@ final case class ScopedSources(
133137
unwrappedScripts.flatMap(_.valueFor(scope).toSeq).flatMap(_.originalPath.toOption))
134138
.map(_._2.toString)
135139

136-
ScopedBuildInfo(combinedOptions, sourcePaths ++ inMemoryPaths)
140+
ScopedBuildInfo.forScope(combinedOptions, sourcePaths ++ inMemoryPaths, scope, logger)
137141

138142
val baseBuildInfo = value(BuildInfo(combinedBuildOptions(Scope.Main, baseOptions), workspace))
139143

modules/cli/src/main/scala/scala/cli/exportCmd/JsonProjectDescriptor.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ final case class JsonProjectDescriptor(
1616
sourcesMain: Sources,
1717
sourcesTest: Sources
1818
): Either[BuildException, JsonProject] = {
19-
def getScopedBuildInfo(options: BuildOptions, sources: Sources) =
19+
def getScopedBuildInfo(options: BuildOptions, sources: Sources, scope: Scope) =
2020
val sourcePaths = sources.paths.map(_._1.toString)
2121
val inMemoryPaths = sources.inMemory.flatMap(_.originalPath.toSeq.map(_._2.toString))
2222

23-
ScopedBuildInfo(options, sourcePaths ++ inMemoryPaths)
23+
ScopedBuildInfo.forScope(options, sourcePaths ++ inMemoryPaths, scope, logger)
2424

2525
for {
2626
baseBuildInfo <- BuildInfo(optionsMain, workspace)
27-
mainBuildInfo = getScopedBuildInfo(optionsMain, sourcesMain)
28-
testBuildInfo = getScopedBuildInfo(optionsTest, sourcesTest)
27+
mainBuildInfo = getScopedBuildInfo(optionsMain, sourcesMain, Scope.Main)
28+
testBuildInfo = getScopedBuildInfo(optionsTest, sourcesTest, Scope.Test)
2929
} yield JsonProject(baseBuildInfo
3030
.withScope(Scope.Main.name, mainBuildInfo)
3131
.withScope(Scope.Test.name, testBuildInfo))

modules/integration/src/test/scala/scala/cli/integration/ExportJsonTestDefinitions.scala

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,139 @@ abstract class ExportJsonTestDefinitions extends ScalaCliSuite with TestScalaVer
212212
}
213213
}
214214

215+
test("export json injects JVM test-runner into test scope") {
216+
val inputs = TestInputs(
217+
os.rel / "Main.scala" ->
218+
"""object Main {
219+
| def main(args: Array[String]): Unit = println("hi")
220+
|}
221+
|""".stripMargin,
222+
os.rel / "unit.test.scala" ->
223+
s"""//> using dep org.scalameta::munit::${Constants.munitVersion}
224+
|
225+
|class MyTest extends munit.FunSuite { test("ok") { assert(true) } }
226+
|""".stripMargin
227+
)
228+
inputs.fromRoot { root =>
229+
val exportJsonProc =
230+
os.proc(TestUtil.cli, "--power", "export", "--json", ".", "--jvm", "temurin:17")
231+
.call(cwd = root)
232+
val jsonContents = readJson(exportJsonProc.out.text())
233+
val expectedFullName = s"test-runner_${Constants.scala3NextPrefix.split('.').head}"
234+
// The test scope should include both munit and the scala-cli test-runner.
235+
expect(jsonContents.contains("\"name\":\"test-runner\""))
236+
expect(jsonContents.contains(s"\"fullName\":\"$expectedFullName\""))
237+
expect(jsonContents.contains("\"groupId\":\"org.virtuslab.scala-cli\""))
238+
expect(jsonContents.contains("\"name\":\"munit\""))
239+
}
240+
}
241+
242+
test("export json includes JVM test-runner even when no test framework dep is declared") {
243+
val inputs = TestInputs(
244+
os.rel / "Main.scala" ->
245+
"""object Main {
246+
| def main(args: Array[String]): Unit = println("hi")
247+
|}
248+
|""".stripMargin,
249+
os.rel / "unit.test.scala" ->
250+
"""class MyTest { def foo() = () }
251+
|""".stripMargin
252+
)
253+
inputs.fromRoot { root =>
254+
val exportJsonProc =
255+
os.proc(TestUtil.cli, "--power", "export", "--json", ".", "--jvm", "temurin:17")
256+
.call(cwd = root)
257+
val jsonContents = readJson(exportJsonProc.out.text())
258+
expect(jsonContents.contains("\"name\":\"test-runner\""))
259+
expect(jsonContents.contains("\"groupId\":\"org.virtuslab.scala-cli\""))
260+
}
261+
}
262+
263+
test("export json includes legacy JVM test-runner for Scala 2.12") {
264+
val inputs = TestInputs(
265+
os.rel / "Main.scala" ->
266+
"""object Main {
267+
| def main(args: Array[String]): Unit = println("hi")
268+
|}
269+
|""".stripMargin,
270+
os.rel / "unit.test.scala" ->
271+
"""class MyTest { def foo() = () }
272+
|""".stripMargin
273+
)
274+
inputs.fromRoot { root =>
275+
val exportJsonProc =
276+
os.proc(
277+
TestUtil.cli,
278+
"--power",
279+
"export",
280+
"--json",
281+
".",
282+
"--jvm",
283+
"temurin:17",
284+
"--scala",
285+
Constants.scala212
286+
)
287+
.call(cwd = root)
288+
val jsonContents = readJson(exportJsonProc.out.text())
289+
expect(jsonContents.contains("\"fullName\":\"test-runner_2.12\""))
290+
expect(jsonContents.contains("\"groupId\":\"org.virtuslab.scala-cli\""))
291+
expect(jsonContents.contains(s"\"version\":\"${Constants.runnerScala2LegacyVersion}\""))
292+
}
293+
}
294+
295+
test("export json includes legacy JVM test-runner for Scala 2.13") {
296+
val inputs = TestInputs(
297+
os.rel / "Main.scala" ->
298+
"""object Main {
299+
| def main(args: Array[String]): Unit = println("hi")
300+
|}
301+
|""".stripMargin,
302+
os.rel / "unit.test.scala" ->
303+
"""class MyTest { def foo() = () }
304+
|""".stripMargin
305+
)
306+
inputs.fromRoot { root =>
307+
val exportJsonProc =
308+
os.proc(
309+
TestUtil.cli,
310+
"--power",
311+
"export",
312+
"--json",
313+
".",
314+
"--jvm",
315+
"temurin:17",
316+
"--scala",
317+
Constants.scala213
318+
)
319+
.call(cwd = root)
320+
val jsonContents = readJson(exportJsonProc.out.text())
321+
expect(jsonContents.contains("\"fullName\":\"test-runner_2.13\""))
322+
expect(jsonContents.contains("\"groupId\":\"org.virtuslab.scala-cli\""))
323+
expect(jsonContents.contains(s"\"version\":\"${Constants.runnerScala2LegacyVersion}\""))
324+
}
325+
}
326+
327+
test("export json does not inject test-runner for Native target") {
328+
val inputs = TestInputs(
329+
os.rel / "Main.scala" ->
330+
"""object Main {
331+
| def main(args: Array[String]): Unit = println("hi")
332+
|}
333+
|""".stripMargin,
334+
os.rel / "unit.test.scala" ->
335+
"""class MyTest { def foo() = () }
336+
|""".stripMargin
337+
)
338+
inputs.fromRoot { root =>
339+
val exportJsonProc =
340+
os.proc(TestUtil.cli, "--power", "export", "--json", ".", "--native")
341+
.call(cwd = root)
342+
val jsonContents = readJson(exportJsonProc.out.text())
343+
expect(!jsonContents.contains("\"name\":\"test-runner\""))
344+
expect(!jsonContents.contains("org.virtuslab.scala-cli"))
345+
}
346+
}
347+
215348
test("export json with js") {
216349
val inputs = TestInputs(
217350
os.rel / "Main.scala" ->

modules/options/src/main/scala/scala/build/Artifacts.scala

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,79 @@ object Artifacts {
118118
addScalapy: Option[String]
119119
)
120120

121+
/** Selects the test-runner module version that scala-cli would resolve at test time.
122+
*
123+
* Falls back to a legacy version when the Scala or Java version is no longer supported by the
124+
* current test-runner module. Mirrors the logic used inside [[Artifacts.apply]] so the export
125+
* stays faithful to what test-time resolution would produce.
126+
*/
127+
def jvmTestRunnerVersion(
128+
scalaVersion: String,
129+
jvmVersion: Int,
130+
logger: Logger,
131+
logLegacyWarnings: Boolean
132+
): String = {
133+
val shouldUseLegacyJava8Runners = jvmVersion < Constants.scala38MinJavaVersion
134+
val shouldUseLegacyScala3Runners =
135+
scalaVersion.startsWith("3") &&
136+
scalaVersion.coursierVersion < s"$scala3LtsPrefix.0".coursierVersion
137+
val shouldUseLegacyScala2Runners = scalaVersion.startsWith("2")
138+
val shouldUseLegacyScalaRunners = shouldUseLegacyScala3Runners || shouldUseLegacyScala2Runners
139+
val shouldUseLegacyRunners = shouldUseLegacyScalaRunners || shouldUseLegacyJava8Runners
140+
141+
val runnerLegacyVersion =
142+
if scalaVersion.startsWith("3") then runnerScala30LegacyVersion
143+
else runnerScala2LegacyVersion
144+
145+
if shouldUseLegacyRunners then {
146+
if logLegacyWarnings then {
147+
if shouldUseLegacyScalaRunners then
148+
logger.message(
149+
s"$warnPrefix Scala $scalaVersion is no longer supported by the test-runner module."
150+
)
151+
if shouldUseLegacyJava8Runners then
152+
logger.message(
153+
s"$warnPrefix Java $jvmVersion is no longer supported by the test-runner module."
154+
)
155+
logger.message(
156+
s"$warnPrefix Defaulting to a legacy test-runner module version: $runnerLegacyVersion."
157+
)
158+
if shouldUseLegacyScalaRunners then
159+
logger.message(
160+
s"$warnPrefix To use the latest test-runner, upgrade Scala to at least $scala3LtsPrefix."
161+
)
162+
if shouldUseLegacyJava8Runners then
163+
logger.message(
164+
s"$warnPrefix To use the latest test-runner, upgrade Java to at least ${Constants.defaultJavaVersion}."
165+
)
166+
}
167+
runnerLegacyVersion
168+
}
169+
else testRunnerVersion
170+
}
171+
172+
/** The test-runner dependency rendered for [[scala.build.info.ExportDependencyFormat]] consumers
173+
* (e.g. `scala-cli export --json`).
174+
*
175+
* The artifact name is `test-runner_<scalaBinaryVersion>` to match how scala-cli resolves it at
176+
* test time.
177+
*/
178+
def jvmTestRunnerExportDependency(
179+
scalaVersion: String,
180+
scalaBinaryVersion: String,
181+
jvmVersion: Int,
182+
logger: Logger
183+
): scala.build.info.ExportDependencyFormat = {
184+
import scala.build.info.{ArtifactId, ExportDependencyFormat}
185+
val version = jvmTestRunnerVersion(scalaVersion, jvmVersion, logger, logLegacyWarnings = false)
186+
val fullName = s"$testRunnerModuleName${"_"}$scalaBinaryVersion"
187+
ExportDependencyFormat(
188+
groupId = testRunnerOrganization,
189+
artifactId = ArtifactId(testRunnerModuleName, fullName),
190+
version = version
191+
)
192+
}
193+
121194
def apply(
122195
scalaArtifactsParamsOpt: Option[ScalaArtifactsParams],
123196
javacPluginDependencies: Seq[Positioned[AnyDependency]],
@@ -159,34 +232,8 @@ object Artifacts {
159232

160233
val jvmTestRunnerDependencies =
161234
if addJvmTestRunner then {
162-
val runnerLegacyVersion =
163-
if scalaVersion.startsWith("3")
164-
then runnerScala30LegacyVersion
165-
else runnerScala2LegacyVersion
166235
val testRunnerVersion0 =
167-
if shouldUseLegacyRunners then {
168-
if shouldUseLegacyScalaRunners then
169-
logger.message(
170-
s"$warnPrefix Scala $scalaVersion is no longer supported by the test-runner module."
171-
)
172-
if shouldUseLegacyJava8Runners then
173-
logger.message(
174-
s"$warnPrefix Java $jvmVersion is no longer supported by the test-runner module."
175-
)
176-
logger.message(
177-
s"$warnPrefix Defaulting to a legacy test-runner module version: $runnerLegacyVersion."
178-
)
179-
if shouldUseLegacyScalaRunners then
180-
logger.message(
181-
s"$warnPrefix To use the latest test-runner, upgrade Scala to at least $scala3LtsPrefix."
182-
)
183-
if shouldUseLegacyJava8Runners then
184-
logger.message(
185-
s"$warnPrefix To use the latest test-runner, upgrade Java to at least ${Constants.defaultJavaVersion}."
186-
)
187-
runnerLegacyVersion
188-
}
189-
else testRunnerVersion
236+
jvmTestRunnerVersion(scalaVersion, jvmVersion, logger, logLegacyWarnings = true)
190237
Seq(dep"$testRunnerOrganization::$testRunnerModuleName:$testRunnerVersion0")
191238
}
192239
else Nil

modules/options/src/main/scala/scala/build/info/ScopedBuildInfo.scala

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import coursier.maven.MavenRepository
55
import coursier.{Dependency, LocalRepositories, Repositories}
66
import dependency.AnyDependency
77

8-
import scala.build.options.{BuildOptions, ConfigMonoid}
8+
import scala.build.Logger
9+
import scala.build.options.{BuildOptions, ConfigMonoid, Platform, Scope}
910

1011
final case class ScopedBuildInfo(
1112
sources: Seq[String] = Nil,
@@ -61,6 +62,47 @@ object ScopedBuildInfo {
6162
)
6263
.reduceLeft(_ + _)
6364

65+
/** Build a [[ScopedBuildInfo]] for [[scope]] and inject the JVM test-runner dependency that
66+
* scala-cli silently adds at test time, so consumers of `export --json` see the same classpath
67+
* scala-cli would use.
68+
*
69+
* Injection conditions match [[scala.build.Artifacts.apply]]: scope is Test, the scope is
70+
* non-empty (has sources), the platform is JVM, and the build has a Scala version (i.e. is not
71+
* Java-only).
72+
*/
73+
def forScope(
74+
options: BuildOptions,
75+
sourcePaths: Seq[String],
76+
scope: Scope,
77+
logger: Logger
78+
): ScopedBuildInfo = {
79+
val base = apply(options, sourcePaths)
80+
if scope == Scope.Test && sourcePaths.nonEmpty then
81+
withJvmTestRunner(base, options, logger)
82+
else base
83+
}
84+
85+
private def withJvmTestRunner(
86+
base: ScopedBuildInfo,
87+
options: BuildOptions,
88+
logger: Logger
89+
): ScopedBuildInfo = {
90+
val isJvm = options.platform.value == Platform.JVM
91+
val scalaParams = options.scalaParams.toOption.flatten
92+
val isScalaBuild = scalaParams.nonEmpty
93+
if isJvm && isScalaBuild then {
94+
val params = scalaParams.get
95+
val dep = scala.build.Artifacts.jvmTestRunnerExportDependency(
96+
scalaVersion = params.scalaVersion,
97+
scalaBinaryVersion = params.scalaBinaryVersion,
98+
jvmVersion = options.javaHome().value.version,
99+
logger = logger
100+
)
101+
base.copy(dependencies = base.dependencies :+ dep)
102+
}
103+
else base
104+
}
105+
64106
private def scalacOptionsSettings(options: BuildOptions): ScopedBuildInfo =
65107
ScopedBuildInfo(scalacOptions = options.scalaOptions.scalacOptions.toSeq.map(_.value.value))
66108

0 commit comments

Comments
 (0)