All Downloads are FREE. Search and download functionalities are using the official Maven repository.

mill.testkit.ExampleTester.scala Maven / Gradle / Ivy

There is a newer version: 0.12.0-RC2-17-07e173
Show newest version
package mill.testkit
import mill.util.Util
import utest._

import java.util.concurrent.{Executors, TimeoutException}
import scala.annotation.tailrec
import scala.concurrent.duration.DurationInt
import scala.concurrent.duration.FiniteDuration

/**
 * A variant of [[IntegrationTester]], [[ExampleTester]] works the same way
 * except the commands used to test the project come from a `/** Usage ... */`
 * comment inside the project's `build.mill` file. This is intended to make the
 * `build.mill` file usable as documentation, such that a reader can skim the `build.mill`
 * and see both the build configuration as well as the commands they themselves can
 * enter at the command line to exercise it.
 *
 * Implements a bash-like test DSL for educational purposes, parsed out from a
 * `Example Usage` comment in the example's `build.mill` file. Someone should be
 * able to read the `Example Usage` comment and know roughly how to execute the
 * example themselves.
 *
 * Each empty-line-separated block consists of one command (prefixed with `>`)
 * and zero or more output lines we expect to get from the comman (either stdout
 * or stderr):
 *
 * 1. If there are no expected output lines, we do not perform any assertions
 *    on the output of the command
 *
 * 2. Output lines can be prefixed by `error: ` to indicate we expect that
 *    command to fail.
 *
 * 3. `..` can be used to indicate wildcards, which match anything. These can
 *    be used alone as the entire line, or in the middle of another line
 *
 * 4. Every line of stdout/stderr output by the command must match at least
 *    one line of the expected output, and every line of expected output must
 *    match at least one line of stdout/stderr. We ignore ordering of output
 *    lines.
 *
 * For teaching purposes, the output lines do not show the entire output of
 * every command, which can be verbose and confusing. They instead contain
 * sub-strings of the command output, enough to convey the important points to
 * a learner. This is not as strict as asserting the entire command output, but
 * should be enough to catch most likely failure modes
 *
 * Because our CI needs to run on Windows, we cannot rely on just executing
 * commands in the `bash` shell, and instead we implement a janky little
 * interpreter that reads the command lines and does things in-JVM in response
 * to each one.
 */
object ExampleTester {
  def run(
      clientServerMode: Boolean,
      workspaceSourcePath: os.Path,
      millExecutable: os.Path,
      bashExecutable: String = defaultBashExecutable()
  ): Unit =
    new ExampleTester(
      clientServerMode,
      workspaceSourcePath,
      millExecutable,
      bashExecutable
    ).run()

  def defaultBashExecutable(): String = {
    if (!mill.main.client.Util.isWindows) "bash"
    else "C:\\Program Files\\Git\\usr\\bin\\bash.exe"
  }
}

class ExampleTester(
    clientServerMode: Boolean,
    val workspaceSourcePath: os.Path,
    millExecutable: os.Path,
    bashExecutable: String = ExampleTester.defaultBashExecutable()
) extends IntegrationTesterBase {
  initWorkspace()

  os.copy.over(millExecutable, workspacePath / "mill")

  val testTimeout: FiniteDuration = 5.minutes

  // Integration tests sometime hang on CI
  // The idea is to just abort and retry them after a reasonable amount of time
  @tailrec final def retryOnTimeout[T](n: Int)(body: => T): T = {

    // We use Java Future here, as it supports cancellation
    val executor = Executors.newFixedThreadPool(1)
    val fut = executor.submit { () => body }

    try fut.get(testTimeout.length, testTimeout.unit)
    catch {
      case e: TimeoutException =>
        fut.cancel(true)
        if (n > 0) {
          Console.err.println(s"Timeout occurred (${testTimeout}). Retrying..")
          retryOnTimeout(n - 1)(body)
        } else throw e
    }

  }

  def processCommandBlock(commandBlock: String): Unit = {
    val commandBlockLines = commandBlock.linesIterator.toVector

    val expectedSnippets = commandBlockLines.tail
    val (commandHead, comment) = commandBlockLines.head match {
      case s"$before#$after" => (before.trim, Some(after.trim))
      case string => (string, None)
    }

    val incorrectPlatform =
      (comment.exists(_.startsWith("windows")) && !Util.windowsPlatform) ||
        (comment.exists(_.startsWith("mac/linux")) && Util.windowsPlatform) ||
        (comment.exists(_.startsWith("--no-server")) && clientServerMode) ||
        (comment.exists(_.startsWith("not --no-server")) && !clientServerMode)

    if (!incorrectPlatform) {
      processCommand(expectedSnippets, commandHead.trim)
    }
  }

  def processCommand(
      expectedSnippets: Vector[String],
      commandStr0: String,
      check: Boolean = true
  ): Unit = {
    val commandStr = commandStr0 match {
      case s"mill $rest" => s"./mill $rest"
      case s => s
    }
    Console.err.println(s"$workspacePath> $commandStr")

    val res = os.call(
      (bashExecutable, "-c", commandStr),
      stdout = os.Pipe,
      stderr = os.Pipe,
      cwd = workspacePath,
      mergeErrIntoOut = true,
      env = Map("MILL_TEST_SUITE" -> this.getClass().toString()),
      check = false
    )

    validateEval(
      expectedSnippets,
      IntegrationTester.EvalResult(
        res.exitCode == 0,
        fansi.Str(res.out.text(), errorMode = fansi.ErrorMode.Strip).plainText,
        fansi.Str(res.err.text(), errorMode = fansi.ErrorMode.Strip).plainText
      ),
      check
    )
  }

  def validateEval(
      expectedSnippets: Vector[String],
      evalResult: IntegrationTester.EvalResult,
      check: Boolean = true
  ): Unit = {
    if (check) {
      if (expectedSnippets.exists(_.startsWith("error: "))) assert(!evalResult.isSuccess)
      else assert(evalResult.isSuccess)
    }

    val unwrappedExpected = expectedSnippets
      .map {
        case s"error: $msg" => msg
        case msg => msg
      }
      .mkString("\n")

    def plainTextLines(s: String) =
      s
        .replace("\\\\", "/") // Convert windows paths in JSON strings to Unix
        .linesIterator
        // Don't bother checking empty lines
        .filter(_.trim.nonEmpty)
        // Strip log4j noisy prefixes that differ on windows and mac/linux
        .map(ln =>
          ln.stripPrefix("[info] ").stripPrefix("info: ")
            .stripPrefix("[error] ").stripPrefix("error: ")
        )
        .toVector

    val filteredOut = plainTextLines(evalResult.out).mkString("\n")

    if (expectedSnippets.nonEmpty) {
      for (outLine <- filteredOut.linesIterator) {
        globMatchesAny(unwrappedExpected, outLine)
      }
    }

    for (expectedLine <- unwrappedExpected.linesIterator) {
      assert(filteredOut.linesIterator.exists(globMatches(expectedLine, _)))
    }
  }

  def globMatches(expected: String, line: String): Boolean = {
    StringContext
      .glob(expected.split("\\.\\.\\.", -1).toIndexedSeq, line)
      .nonEmpty
  }

  def globMatchesAny(expected: String, filtered: String): Boolean = {
    expected.linesIterator.exists(globMatches(_, filtered))
  }

  def run(): Any = {
    val parsed = ExampleParser(workspaceSourcePath)
    val usageComment = parsed.collect { case ("example", txt) => txt }.mkString("\n\n")
    val commandBlocks = ("\n" + usageComment.trim).split("\n> ").filter(_.nonEmpty)

    retryOnTimeout(3) {
      try {
        try os.remove.all(workspacePath / "out")
        catch {
          case e: Throwable => /*do nothing*/
        }

        for (commandBlock <- commandBlocks) processCommandBlock(commandBlock)
      } finally {
        if (clientServerMode) processCommand(Vector(), "./mill shutdown", check = false)
      }
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy