[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
```
This commit is contained in:
bitloi 2026-01-23 21:22:50 -05:00 committed by GitHub
parent 64dadd6459
commit f8704752e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 211 additions and 33 deletions

View File

@ -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(

View File

@ -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
)
}

View File

@ -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,
)
}
}

View File

@ -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 {

View File

@ -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(", ")}")