From f8704752e0a97f7a24fc015a47415a9b7ab97083 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:22:50 -0500 Subject: [PATCH] [2.x] feat: Add scriptedKeepTempDirectory setting (#8621) **Problem** When running scripted tests to debug sbt plugins, the temporary directories (`/private/var/folder/...`) are automatically deleted after tests complete. This makes it difficult to inspect the test state for debugging purposes, requiring workarounds like adding `$ pause` commands and manually copying directories. **Solution** Added a new `scriptedKeepTempDirectory` setting that allows users to preserve temporary directories after scripted tests complete. When enabled, the temporary directory path is logged so users can inspect it. Usage: ```scala scriptedKeepTempDirectory := true ``` --- build.sbt | 17 ++- main/src/main/scala/sbt/ScriptedPlugin.scala | 8 +- main/src/main/scala/sbt/ScriptedRun.scala | 95 +++++++++++++-- project/Scripted.scala | 10 +- .../sbt/scriptedtest/ScriptedTests.scala | 114 +++++++++++++++--- 5 files changed, 211 insertions(+), 33 deletions(-) diff --git a/build.sbt b/build.sbt index 75796366f..7ba45900b 100644 --- a/build.sbt +++ b/build.sbt @@ -507,8 +507,10 @@ lazy val scriptedSbtProj = (project in file("scripted-sbt")) name := "scripted-sbt", libraryDependencies ++= Seq(launcherInterface % "provided"), mimaSettings, - scriptedSbtMimaSettings, - mimaSettings, + mimaBinaryIssueFilters ++= Seq( + exclude[IncompatibleMethTypeProblem]("sbt.scriptedtest.ScriptedTests.runInParallel"), + exclude[DirectMissingMethodProblem]("sbt.scriptedtest.ScriptedTests.batchScriptedRunner"), + ), ) .dependsOn(lmCore) .configure(addSbtIO, addSbtCompilerInterface) @@ -722,6 +724,13 @@ lazy val mainProj = (project in file("main")) mimaBinaryIssueFilters ++= Vector( exclude[DirectMissingMethodProblem]("sbt.internal.ConsoleProject.*"), exclude[DirectMissingMethodProblem]("sbt.coursierint.LMCoursier.coursierConfiguration"), + exclude[DirectMissingMethodProblem]("sbt.ScriptedRun.run"), + exclude[DirectMissingMethodProblem]("sbt.ScriptedRun.invoke"), + exclude[ReversedMissingMethodProblem]("sbt.ScriptedRun.invoke"), + exclude[DirectMissingMethodProblem]("sbt.ScriptedRun#RunInParallelV1.invoke"), + exclude[DirectMissingMethodProblem]("sbt.ScriptedRun#RunInParallelV2.invoke"), + exclude[DirectMissingMethodProblem]("sbt.ScriptedRun#RunV1.invoke"), + exclude[DirectMissingMethodProblem]("sbt.ScriptedRun#RunV2.invoke"), ), ) .dependsOn(lmCore, lmIvy, lmCoursierShadedPublishing) @@ -946,7 +955,8 @@ def scriptedTask(launch: Boolean): Def.Initialize[InputTask[Unit]] = Def.inputTa .map(_.data) .filterNot(_.getName.contains("scala-compiler")), (bundledLauncherProj / Compile / packageBin).value, - streams.value.log + streams.value.log, + scriptedKeepTempDirectory.value ) } @@ -1001,6 +1011,7 @@ lazy val nonRoots = allProjects.map(p => LocalProject(p.id)) ThisBuild / scriptedBufferLog := true ThisBuild / scriptedPrescripted := { _ => } +ThisBuild / scriptedKeepTempDirectory := false def otherRootSettings = Seq( diff --git a/main/src/main/scala/sbt/ScriptedPlugin.scala b/main/src/main/scala/sbt/ScriptedPlugin.scala index 1f12dfefd..91f3c89a8 100644 --- a/main/src/main/scala/sbt/ScriptedPlugin.scala +++ b/main/src/main/scala/sbt/ScriptedPlugin.scala @@ -46,6 +46,10 @@ object ScriptedPlugin extends AutoPlugin { val scriptedRun = taskKey[ScriptedRun]("") val scriptedLaunchOpts = settingKey[Seq[String]]("options to pass to jvm launching scripted tasks") + val scriptedKeepTempDirectory = + settingKey[Boolean]( + "If true, keeps the temporary directory after scripted tests complete for debugging." + ) val scriptedDependencies = taskKey[Unit]("") val scripted = inputKey[Unit]("") } @@ -54,6 +58,7 @@ object ScriptedPlugin extends AutoPlugin { override lazy val globalSettings: Seq[Setting[?]] = Seq( scriptedBufferLog := true, scriptedLaunchOpts := Seq(), + scriptedKeepTempDirectory := false, ) override lazy val projectSettings: Seq[Setting[?]] = Seq( @@ -177,7 +182,8 @@ object ScriptedPlugin extends AutoPlugin { Fork.javaCommand((scripted / javaHome).value, "java").getAbsolutePath, scriptedLaunchOpts.value, new java.util.ArrayList[File](), - scriptedParallelInstances.value + scriptedParallelInstances.value, + scriptedKeepTempDirectory.value ) } diff --git a/main/src/main/scala/sbt/ScriptedRun.scala b/main/src/main/scala/sbt/ScriptedRun.scala index d08467e49..816e46217 100644 --- a/main/src/main/scala/sbt/ScriptedRun.scala +++ b/main/src/main/scala/sbt/ScriptedRun.scala @@ -22,6 +22,7 @@ sealed trait ScriptedRun { launchOpts: Seq[String], prescripted: java.util.List[File], instances: Int, + keepTempDirectory: Boolean, ): Unit = { try { invoke( @@ -33,6 +34,7 @@ sealed trait ScriptedRun { launchOpts.toArray, prescripted, instances, + keepTempDirectory, ) () } catch { case e: java.lang.reflect.InvocationTargetException => throw e.getCause } @@ -47,6 +49,7 @@ sealed trait ScriptedRun { launchOpts: Array[String], prescripted: java.util.List[File], instances: java.lang.Integer, + keepTempDirectory: java.lang.Boolean, ): AnyRef } @@ -64,26 +67,45 @@ object ScriptedRun { val clazz = scriptedTests.getClass if (batchExecution) try - new RunInParallelV2( + new RunInParallelV3( scriptedTests, - clazz.getMethod("runInParallel", fCls, bCls, asCls, fCls, sCls, asCls, lfCls, iCls) + clazz.getMethod("runInParallel", fCls, bCls, asCls, fCls, sCls, asCls, lfCls, iCls, bCls) ) catch { case _: NoSuchMethodException => - new RunInParallelV1( - scriptedTests, - clazz.getMethod("runInParallel", fCls, bCls, asCls, fCls, asCls, lfCls, iCls) - ) + try + new RunInParallelV2( + scriptedTests, + clazz.getMethod("runInParallel", fCls, bCls, asCls, fCls, sCls, asCls, lfCls, iCls) + ) + catch { + case _: NoSuchMethodException => + new RunInParallelV1( + scriptedTests, + clazz.getMethod("runInParallel", fCls, bCls, asCls, fCls, asCls, lfCls, iCls) + ) + } } else try - new RunV2( + new RunV3( scriptedTests, - clazz.getMethod("run", fCls, bCls, asCls, fCls, sCls, asCls, lfCls) + clazz.getMethod("run", fCls, bCls, asCls, fCls, sCls, asCls, lfCls, bCls) ) catch { case _: NoSuchMethodException => - new RunV1(scriptedTests, clazz.getMethod("run", fCls, bCls, asCls, fCls, asCls, lfCls)) + try + new RunV2( + scriptedTests, + clazz.getMethod("run", fCls, bCls, asCls, fCls, sCls, asCls, lfCls) + ) + catch { + case _: NoSuchMethodException => + new RunV1( + scriptedTests, + clazz.getMethod("run", fCls, bCls, asCls, fCls, asCls, lfCls) + ) + } } } @@ -97,6 +119,7 @@ object ScriptedRun { launchOpts: Array[String], prescripted: java.util.List[File], @unused instances: java.lang.Integer, + @unused keepTempDirectory: java.lang.Boolean, ): AnyRef = run.invoke( scriptedTests, @@ -119,6 +142,7 @@ object ScriptedRun { launchOpts: Array[String], prescripted: java.util.List[File], instances: Integer, + @unused keepTempDirectory: java.lang.Boolean, ): AnyRef = runInParallel.invoke( scriptedTests, @@ -142,6 +166,7 @@ object ScriptedRun { launchOpts: Array[String], prescripted: java.util.List[File], @unused instances: java.lang.Integer, + @unused keepTempDirectory: java.lang.Boolean, ): AnyRef = run.invoke( scriptedTests, @@ -165,6 +190,7 @@ object ScriptedRun { launchOpts: Array[String], prescripted: java.util.List[File], instances: Integer, + @unused keepTempDirectory: java.lang.Boolean, ): AnyRef = runInParallel.invoke( scriptedTests, @@ -179,4 +205,55 @@ object ScriptedRun { ) } + private class RunV3(scriptedTests: AnyRef, run: Method) extends ScriptedRun { + override protected def invoke( + resourceBaseDirectory: File, + bufferLog: java.lang.Boolean, + tests: Array[String], + launcherJar: File, + javaCommand: String, + launchOpts: Array[String], + prescripted: java.util.List[File], + @unused instances: java.lang.Integer, + keepTempDirectory: java.lang.Boolean, + ): AnyRef = + run.invoke( + scriptedTests, + resourceBaseDirectory, + bufferLog, + tests, + launcherJar, + javaCommand, + launchOpts, + prescripted, + keepTempDirectory, + ) + } + + private class RunInParallelV3(scriptedTests: AnyRef, runInParallel: Method) extends ScriptedRun { + override protected def invoke( + resourceBaseDirectory: File, + bufferLog: java.lang.Boolean, + tests: Array[String], + launcherJar: File, + javaCommand: String, + launchOpts: Array[String], + prescripted: java.util.List[File], + instances: Integer, + keepTempDirectory: java.lang.Boolean, + ): AnyRef = + runInParallel.invoke( + scriptedTests, + resourceBaseDirectory, + bufferLog, + tests, + launcherJar, + javaCommand, + launchOpts, + prescripted, + instances, + keepTempDirectory, + ) + } + } diff --git a/project/Scripted.scala b/project/Scripted.scala index 6ee328d02..635a1164c 100644 --- a/project/Scripted.scala +++ b/project/Scripted.scala @@ -21,6 +21,9 @@ trait ScriptedKeys { ) val scriptedSource = settingKey[File]("") val scriptedPrescripted = taskKey[File => Unit]("") + val scriptedKeepTempDirectory = settingKey[Boolean]( + "If true, keeps the temporary directory after scripted tests complete for debugging." + ) } object Scripted { @@ -102,7 +105,8 @@ object Scripted { sbtVersion: String, classpath: Seq[File], launcherJar: File, - logger: Logger + logger: Logger, + keepTempDirectory: Boolean ): Unit = { logger.info(s"Tests selected: ${args.mkString("\n * ", "\n * ", "\n")}") logger.info("") @@ -137,6 +141,7 @@ object Scripted { launchOpts: Array[String], prescripted: java.util.List[File], instance: Int, + keepTempDirectory: Boolean ): Unit } @@ -180,7 +185,8 @@ object Scripted { "java", launchOpts.toArray, callback, - instances + instances, + keepTempDirectory ) } catch { case ite: InvocationTargetException => throw ite.getCause } } finally { diff --git a/scripted-sbt/src/main/scala/sbt/scriptedtest/ScriptedTests.scala b/scripted-sbt/src/main/scala/sbt/scriptedtest/ScriptedTests.scala index d07cd626a..6d1a7d718 100644 --- a/scripted-sbt/src/main/scala/sbt/scriptedtest/ScriptedTests.scala +++ b/scripted-sbt/src/main/scala/sbt/scriptedtest/ScriptedTests.scala @@ -109,7 +109,8 @@ final class ScriptedTests( prescripted: File => Unit, sbtInstances: Int, prop: RemoteSbtCreatorProp, - log: Logger + log: Logger, + keepTempDirectory: Boolean = false, ): Seq[TestRunner] = { // Test group and names may be file filters (like '*') val groupAndNameDirs = { @@ -146,13 +147,25 @@ final class ScriptedTests( ) logTests(runFromSourceBasedTests.size, prop.toString) + if (keepTempDirectory && runFromSourceBasedTests.size > 1) { + sys.error( + s"scriptedKeepTempDirectory requires exactly one test, but ${runFromSourceBasedTests.size} tests were requested" + ) + } + def createTestRunners(tests: Seq[TestInfo]): Seq[TestRunner] = { tests .sortBy(_._1) .grouped(batchSize) .map { batch => () => - IO.withTemporaryDirectory { - runBatchedTests(batch, _, prescripted, prop, log) + if (keepTempDirectory) { + val tempDir = IO.createTemporaryDirectory + log.info(s"Temporary directory for scripted tests: ${tempDir.getAbsolutePath}") + runBatchedTests(batch, tempDir, prescripted, prop, log, keepTempDirectory) + } else { + IO.withTemporaryDirectory { + runBatchedTests(batch, _, prescripted, prop, log, keepTempDirectory) + } } } .toList @@ -202,7 +215,8 @@ final class ScriptedTests( tempTestDir: File, preHook: File => Unit, prop: RemoteSbtCreatorProp, - log: Logger + log: Logger, + keepTempDirectory: Boolean = false ): Seq[Option[String]] = { val runner = new BatchScriptRunner @@ -241,16 +255,18 @@ final class ScriptedTests( // Run the test and delete files (except global that holds local scala jars) val result = runOrHandleDisabled(label, tempTestDir, runTest, buffer) - val view = sbt.nio.file.FileTreeView.default - val base = tempTestDir.getCanonicalFile.toGlob - val global = base / "global" - val globalLogging = base / ** / "global-logging" - def recursiveFilter(glob: Glob): PathFilter = (glob: PathFilter) || glob / ** - val keep: PathFilter = recursiveFilter(global) || recursiveFilter(globalLogging) - val toDelete = view.list(base / **, !keep).map(_._1).sorted.reverse - toDelete.foreach { p => - try Files.deleteIfExists(p) - catch { case _: IOException => } + if (!keepTempDirectory) { + val view = sbt.nio.file.FileTreeView.default + val base = tempTestDir.getCanonicalFile.toGlob + val global = base / "global" + val globalLogging = base / ** / "global-logging" + def recursiveFilter(glob: Glob): PathFilter = (glob: PathFilter) || glob / ** + val keep: PathFilter = recursiveFilter(global) || recursiveFilter(globalLogging) + val toDelete = view.list(base / **, !keep).map(_._1).sorted.reverse + toDelete.foreach { p => + try Files.deleteIfExists(p) + catch { case _: IOException => } + } } result } @@ -352,7 +368,7 @@ object ScriptedTests extends ScriptedRunner { buffer, tests, logger, - Array(), + Array[String](), new java.util.ArrayList[File], defScalaVersion, sbtVersion, @@ -452,6 +468,32 @@ class ScriptedRunner { ) } + def run( + resourceBaseDirectory: File, + bufferLog: Boolean, + tests: Array[String], + launcherJar: File, + javaCommand: String, + launchOpts: Array[String], + prescripted: java.util.List[File], + keepTempDirectory: Boolean, + ): Unit = { + val logger = TestConsoleLogger() + run( + resourceBaseDirectory, + bufferLog, + tests, + logger, + javaCommand, + launchOpts, + prescripted, + LauncherBased(launcherJar), + Int.MaxValue, + parallelExecution = false, + keepTempDirectory, + ) + } + /** * This is the entry point used by SbtPlugin in sbt 1.2.x, 1.3.x, 1.4.x etc. * Removing this method will break scripted and sbt plugin cross building. @@ -510,6 +552,32 @@ class ScriptedRunner { ) } + def runInParallel( + resourceBaseDirectory: File, + bufferLog: Boolean, + tests: Array[String], + launcherJar: File, + javaCommand: String, + launchOpts: Array[String], + prescripted: java.util.List[File], + instance: Int, + keepTempDirectory: Boolean, + ): Unit = { + val logger = TestConsoleLogger() + runInParallel( + resourceBaseDirectory, + bufferLog, + tests, + logger, + javaCommand, + launchOpts, + prescripted, + LauncherBased(launcherJar), + instance, + keepTempDirectory, + ) + } + // This is called by project/Scripted.scala // Using java.util.List[File] to encode File => Unit def runInParallel( @@ -545,7 +613,8 @@ class ScriptedRunner { launchOpts: Array[String], prescripted: java.util.List[File], prop: RemoteSbtCreatorProp, - instances: Int + instances: Int, + keepTempDirectory: Boolean = false, ): Unit = run( baseDir, @@ -557,7 +626,8 @@ class ScriptedRunner { prescripted, prop, instances, - parallelExecution = true + parallelExecution = true, + keepTempDirectory, ) @nowarn @@ -572,6 +642,7 @@ class ScriptedRunner { prop: RemoteSbtCreatorProp, instances: Int, parallelExecution: Boolean, + keepTempDirectory: Boolean = false, ): Unit = { val addTestFile = (f: File) => { prescripted.add(f); () } val runner = new ScriptedTests(baseDir, bufferLog, javaCommand, launchOpts) @@ -588,7 +659,14 @@ class ScriptedRunner { // Choosing Int.MaxValue will make the groupSize 1 in batchScriptedRunner val groupCount = if (parallelExecution) instances else Int.MaxValue val scriptedRunners = - runner.batchScriptedRunner(scriptedTests, addTestFile, groupCount, prop, logger) + runner.batchScriptedRunner( + scriptedTests, + addTestFile, + groupCount, + prop, + logger, + keepTempDirectory + ) // Fail if user provided test patterns but none matched any existing test directories if (tests.nonEmpty && scriptedRunners.isEmpty) { sys.error(s"No tests found matching: ${tests.mkString(", ")}")