Skip to content

Commit d833cfc

Browse files
IntegrationTester.prepEval and improved WatchTests (#5363)
While debugging com-lihaoyi/os-lib#398 I encountered a lackluster developer experience when running `WatchTests`. - It took ages for the tests to fail. - When the tests failed, the amount of context was very low. This PR improves on both of those areas: - `WatchTests` timeout is now `15s` instead of `120s` when running not on CI. - If the test fails the `eval` context (things like working directory, the command line for eval and environment variables) is printed out. To achieve that, I introduced: - `prepEval` - as `eval` but doesn't actually execute immediatelly. - `withTestClues` - enhances `utest.AssertionError` thrown in the block with the given `TestValues` --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent f1d494b commit d833cfc

File tree

4 files changed

+142
-35
lines changed

4 files changed

+142
-35
lines changed

integration/invalidation/watch-source-input/src/WatchSourceInputTests.scala

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import scala.concurrent.ExecutionContext.Implicits.global
2121
*/
2222
trait WatchTests extends UtestIntegrationTestSuite {
2323

24-
val maxDuration = 120000
24+
val maxDurationMillis: Int = if (sys.env.contains("CI")) 120000 else 15000
25+
2526
def awaitCompletionMarker(tester: IntegrationTester, name: String): Unit = {
26-
val maxTime = System.currentTimeMillis() + maxDuration
27+
val maxTime = System.currentTimeMillis() + maxDurationMillis
2728
while (!os.exists(tester.workspacePath / "out" / name)) {
2829
if (System.currentTimeMillis() > maxTime) {
2930
sys.error(s"awaitCompletionMarker($name) timed out")
@@ -32,11 +33,11 @@ trait WatchTests extends UtestIntegrationTestSuite {
3233
}
3334
}
3435

35-
def testBase(show: Boolean)(f: (
36-
mutable.Buffer[String],
37-
mutable.Buffer[String],
38-
mutable.Buffer[String]
39-
) => IntegrationTester.EvalResult): Unit = {
36+
def testBase(preppedEval: IntegrationTester.PreparedEval, show: Boolean)(f: (
37+
expectedOut: mutable.Buffer[String],
38+
expectedErr: mutable.Buffer[String],
39+
expectedShows: mutable.Buffer[String]
40+
) => IntegrationTester.EvalResult): Unit = withTestClues(preppedEval.clues*) {
4041
val expectedOut = mutable.Buffer.empty[String]
4142
// Most of these are normal `println`s, so they go to `stdout` by
4243
// default unless you use `show` in which case they go to `stderr`.
@@ -61,11 +62,13 @@ trait WatchTests extends UtestIntegrationTestSuite {
6162

6263
object WatchSourceTests extends WatchTests {
6364
val tests: Tests = Tests {
64-
def testWatchSource(tester: IntegrationTester, show: Boolean) =
65-
testBase(show) { (expectedOut, expectedErr, expectedShows) =>
66-
val showArgs = if (show) Seq("show") else Nil
67-
import tester._
68-
val evalResult = Future { eval(("--watch", showArgs, "qux"), timeout = maxDuration) }
65+
def testWatchSource(tester: IntegrationTester, show: Boolean): Unit = {
66+
import tester.*
67+
val showArgs = if (show) Seq("show") else Nil
68+
val preppedEval = prepEval(("--watch", showArgs, "qux"), timeout = maxDurationMillis)
69+
70+
testBase(preppedEval, show) { (expectedOut, expectedErr, expectedShows) =>
71+
val evalResult = Future { preppedEval.run() }
6972

7073
awaitCompletionMarker(tester, "initialized0")
7174
awaitCompletionMarker(tester, "quxRan0")
@@ -125,8 +128,10 @@ object WatchSourceTests extends WatchTests {
125128
awaitCompletionMarker(tester, "initialized2")
126129
expectedOut.append("Setting up build.mill")
127130

128-
Await.result(evalResult, Duration.apply(maxDuration, SECONDS))
131+
Await.result(evalResult, Duration.apply(maxDurationMillis, SECONDS))
129132
}
133+
}
134+
130135
test("sources") {
131136

132137
// Make sure we clean up the workspace between retries
@@ -151,11 +156,13 @@ object WatchSourceTests extends WatchTests {
151156
object WatchInputTests extends WatchTests {
152157
val tests: Tests = Tests {
153158

154-
def testWatchInput(tester: IntegrationTester, show: Boolean) =
155-
testBase(show) { (expectedOut, expectedErr, expectedShows) =>
156-
val showArgs = if (show) Seq("show") else Nil
157-
import tester._
158-
val evalResult = Future { eval(("--watch", showArgs, "lol"), timeout = maxDuration) }
159+
def testWatchInput(tester: IntegrationTester, show: Boolean) = {
160+
val showArgs = if (show) Seq("show") else Nil
161+
import tester.*
162+
val preppedEval = prepEval(("--watch", showArgs, "lol"), timeout = maxDurationMillis)
163+
164+
testBase(preppedEval, show) { (expectedOut, expectedErr, expectedShows) =>
165+
val evalResult = Future { preppedEval.run() }
159166

160167
awaitCompletionMarker(tester, "initialized0")
161168
awaitCompletionMarker(tester, "lolRan0")
@@ -181,8 +188,9 @@ object WatchInputTests extends WatchTests {
181188
if (show) expectedOut.append("{}")
182189
expectedOut.append("Setting up build.mill")
183190

184-
Await.result(evalResult, Duration.apply(maxDuration, SECONDS))
191+
Await.result(evalResult, Duration.apply(maxDurationMillis, SECONDS))
185192
}
193+
}
186194

187195
test("input") {
188196

testkit/src/mill/testkit/IntegrationTester.scala

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import mill.define.Cached
66
import mill.define.SelectMode
77
import ujson.Value
88

9+
import scala.concurrent.duration.*
10+
911
/**
1012
* Helper meant for executing Mill integration tests, which runs Mill in a subprocess
1113
* against a folder with a `build.mill` and project files. Provides APIs such as [[eval]]
@@ -41,6 +43,32 @@ object IntegrationTester {
4143
def isSuccess = exitCode == 0
4244
}
4345

46+
/** An [[Impl.eval]] that is prepared for execution but haven't been executed yet. Run it with [[run]]. */
47+
case class PreparedEval(
48+
cmd: os.Shellable,
49+
env: Map[String, String],
50+
cwd: os.Path,
51+
timeout: Duration,
52+
check: Boolean,
53+
propagateEnv: Boolean = true,
54+
shutdownGracePeriod: Long = 100,
55+
run: () => EvalResult
56+
) {
57+
58+
/** Clues to use for with [[withTestClues]]. */
59+
def clues: Seq[utest.TestValue] = Seq(
60+
// Copy-pastable shell command that you can run in bash/zsh/whatever
61+
asTestValue("cmd", cmd.value.iterator.map(pprint.Util.literalize(_)).mkString(" ")),
62+
asTestValue("cmd.shellable", cmd),
63+
asTestValue(env),
64+
asTestValue(cwd),
65+
asTestValue(timeout),
66+
asTestValue(check),
67+
asTestValue(propagateEnv),
68+
asTestValue(shutdownGracePeriod)
69+
).map(tv => tv.copy(name = "eval." + tv.name))
70+
}
71+
4472
trait Impl extends AutoCloseable with IntegrationTesterBase {
4573

4674
def millExecutable: os.Path
@@ -51,12 +79,11 @@ object IntegrationTester {
5179
def debugLog = false
5280

5381
/**
54-
* Evaluates a Mill command. Essentially the same as `os.call`, except it
55-
* provides the Mill executable and some test flags and environment variables
56-
* for you, and wraps the output in a [[IntegrationTester.EvalResult]] for
57-
* convenience.
82+
* Prepares to evaluate a Mill command. Run it with [[IntegrationTester.PreparedEval.run]].
83+
*
84+
* Useful when you need the [[IntegrationTester.PreparedEval.clues]].
5885
*/
59-
def eval(
86+
def prepEval(
6087
cmd: os.Shellable,
6188
env: Map[String, String] = Map.empty,
6289
cwd: os.Path = workspacePath,
@@ -68,17 +95,72 @@ object IntegrationTester {
6895
check: Boolean = false,
6996
propagateEnv: Boolean = true,
7097
timeoutGracePeriod: Long = 100
71-
): IntegrationTester.EvalResult = {
98+
): IntegrationTester.PreparedEval = {
7299
val serverArgs = Option.when(!daemonMode)("--no-daemon")
73100

74101
val debugArgs = Option.when(debugLog)("--debug")
75102

76103
val shellable: os.Shellable =
77104
(millExecutable, serverArgs, "--ticker", "false", debugArgs, cmd)
78105

79-
val res0 = os.call(
106+
val callEnv = millTestSuiteEnv ++ env
107+
108+
def run() = {
109+
val res0 = os.call(
110+
cmd = shellable,
111+
env = callEnv,
112+
cwd = cwd,
113+
stdin = stdin,
114+
stdout = stdout,
115+
stderr = stderr,
116+
mergeErrIntoOut = mergeErrIntoOut,
117+
timeout = timeout,
118+
check = check,
119+
propagateEnv = propagateEnv,
120+
shutdownGracePeriod = timeoutGracePeriod
121+
)
122+
123+
IntegrationTester.EvalResult(
124+
res0.exitCode,
125+
fansi.Str(res0.out.text(), errorMode = fansi.ErrorMode.Strip).plainText.trim,
126+
fansi.Str(res0.err.text(), errorMode = fansi.ErrorMode.Strip).plainText.trim
127+
)
128+
}
129+
130+
PreparedEval(
80131
cmd = shellable,
81-
env = millTestSuiteEnv ++ env,
132+
env = callEnv,
133+
cwd = cwd,
134+
timeout = if (timeout == -1) Duration.Inf else timeout.millis,
135+
check = check,
136+
propagateEnv = propagateEnv,
137+
shutdownGracePeriod = timeoutGracePeriod,
138+
run = run
139+
)
140+
}
141+
142+
/**
143+
* Evaluates a Mill command. Essentially the same as `os.call`, except it
144+
* provides the Mill executable and some test flags and environment variables
145+
* for you, and wraps the output in a [[IntegrationTester.EvalResult]] for
146+
* convenience.
147+
*/
148+
def eval(
149+
cmd: os.Shellable,
150+
env: Map[String, String] = Map.empty,
151+
cwd: os.Path = workspacePath,
152+
stdin: os.ProcessInput = os.Pipe,
153+
stdout: os.ProcessOutput = os.Pipe,
154+
stderr: os.ProcessOutput = os.Pipe,
155+
mergeErrIntoOut: Boolean = false,
156+
timeout: Long = -1,
157+
check: Boolean = false,
158+
propagateEnv: Boolean = true,
159+
timeoutGracePeriod: Long = 100
160+
): IntegrationTester.EvalResult = {
161+
prepEval(
162+
cmd = cmd,
163+
env = env,
82164
cwd = cwd,
83165
stdin = stdin,
84166
stdout = stdout,
@@ -87,14 +169,8 @@ object IntegrationTester {
87169
timeout = timeout,
88170
check = check,
89171
propagateEnv = propagateEnv,
90-
shutdownGracePeriod = timeoutGracePeriod
91-
)
92-
93-
IntegrationTester.EvalResult(
94-
res0.exitCode,
95-
fansi.Str(res0.out.text(), errorMode = fansi.ErrorMode.Strip).plainText.trim,
96-
fansi.Str(res0.err.text(), errorMode = fansi.ErrorMode.Strip).plainText.trim
97-
)
172+
timeoutGracePeriod = timeoutGracePeriod
173+
).run()
98174
}
99175

100176
/**

testkit/src/mill/testkit/UtestIntegrationTestSuite.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package mill.testkit
22

33
abstract class UtestIntegrationTestSuite extends utest.TestSuite with IntegrationTestSuite {
4+
export mill.testkit.{asTestValue, withTestClues}
5+
46
protected def workspaceSourcePath: os.Path = os.Path(sys.env("MILL_TEST_RESOURCE_DIR"))
57
protected def daemonMode: Boolean = sys.env("MILL_INTEGRATION_DAEMON_MODE").toBoolean
68

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package mill.testkit
2+
3+
import pprint.{TPrint, TPrintColors}
4+
import utest.TestValue
5+
6+
def asTestValue[A](a: sourcecode.Text[A])(using typeName: TPrint[A]): TestValue =
7+
TestValue(a.source, typeName.render(using TPrintColors.BlackWhite).plainText, a.value)
8+
9+
def asTestValue[A](name: String, a: A)(using typeName: TPrint[A]): TestValue =
10+
TestValue(name, typeName.render(using TPrintColors.BlackWhite).plainText, a)
11+
12+
/** Adds the provided clues to the thrown [[utest.AssertionError]]. */
13+
def withTestClues[A](clues: TestValue*)(f: => A): A = {
14+
try f
15+
catch {
16+
case e: utest.AssertionError =>
17+
val newException = e.copy(captured = clues ++ e.captured)
18+
newException.setStackTrace(e.getStackTrace)
19+
throw newException
20+
}
21+
}

0 commit comments

Comments
 (0)