Split out scripted test framework into its own project as an sbt plugin.

git-svn-id: https://simple-build-tool.googlecode.com/svn/trunk@846 d89573ee-9141-11dd-94d4-bdf5e562f29c
This commit is contained in:
dmharrah 2009-07-07 01:09:56 +00:00
parent 6f816b8fe5
commit 7c6f420b21
6 changed files with 446 additions and 0 deletions

View File

@ -0,0 +1,8 @@
#Project properties
#Wed Jul 01 23:57:40 EDT 2009
project.organization=org.scala-tools.sbt
project.name=test
sbt.version=0.5.1
project.version=0.5.1
scala.version=2.7.5
project.initialize=false

View File

@ -0,0 +1,7 @@
/* sbt -- Simple Build Tool
* Copyright 2009 Mark Harrah
*/
import sbt._
class SbtTest(info: ProjectInfo) extends PluginProject(info)

View File

@ -0,0 +1,109 @@
/* sbt -- Simple Build Tool
* Copyright 2008, 2009 Mark Harrah
*/
package sbt.test
import Scripted._
import java.io.File
trait ScalaScripted extends BasicScalaProject with Scripted with MavenStyleScalaPaths
{
def sbtTests = sourcePath / SbtTestDirectoryName
def scriptedDependencies = compile :: Nil
lazy val scripted = scriptedTask(scriptedDependencies : _*)
lazy val testNoScripted = super.testAction
override def testAction = testNoScripted dependsOn(scripted)
lazy val scriptedOnly = scriptedMethodTask(scriptedDependencies : _*)
override def scriptedClasspath = runClasspath +++ Path.lazyPathFinder { Path.fromFile(FileUtilities.sbtJar) :: Nil }
}
trait SbtScripted extends ScalaScripted
{
override def scriptedDependencies = testCompile :: `package` :: Nil
override def scriptedClasspath =
Path.lazyPathFinder {
val ivy = runClasspath.get.filter(_.asFile.getName.startsWith("ivy-")).toList
val builtSbtJar = (outputPath / defaultJarName)
builtSbtJar :: ivy
}
}
final case class ScriptedTest(group: String, name: String) extends NotNull
{
override def toString = group + "/" + name
}
trait Scripted extends Project with MultiTaskProject
{
def sbtTests: Path
def scriptedTask(dependencies: ManagedTask*) = compoundTask(scriptedTests(listTests, dependencies : _*))
def scriptedMethodTask(dependencies: ManagedTask*) = multiTask(listTests.map(_.toString).toList) { includeFunction =>
scriptedTests(listTests.filter(test => includeFunction(test.toString)), dependencies : _*)
}
def listTests = (new ListTests(sbtTests.asFile, include _, log)).listTests
def scriptedTests(tests: Seq[ScriptedTest], dependencies: ManagedTask*) =
{
// load ScriptedTests using a ClassLoader that loads from the project classpath so that the version
// of sbt being built is tested, not the one doing the building.
val loader = ScriptedLoader(_scriptedClasspath.toArray)
val scriptedClass = Class.forName(ScriptedClassName, true, loader)
val scriptedConstructor = scriptedClass.getConstructor(classOf[File], classOf[ClassLoader])
val rawRunner = scriptedConstructor.newInstance(sbtTests.asFile, loader)
val runner = rawRunner.asInstanceOf[{def scriptedTest(group: String, name: String, log: Logger): Option[String]}]
val startTask = task { None } named("scripted-test-start") dependsOn(dependencies : _*)
def scriptedTest(test: ScriptedTest) = task { runner.scriptedTest(test.group, test.name, log) } named test.toString dependsOn(startTask)
val testTasks = tests.map(scriptedTest)
task { None } named("scripted-test-complete") dependsOn(testTasks : _*)
}
/** The classpath to use for scripted tests. This ensures that the version of sbt being built is the one used for testing.*/
private def _scriptedClasspath =
{
val buildClasspath = FileUtilities.classLocation[Scripted]
val scalacJar = FileUtilities.scalaCompilerJar.toURI.toURL
buildClasspath :: scalacJar :: scriptedClasspath.get.map(_.asURL).toList
}
def scriptedClasspath: PathFinder = Path.emptyPathFinder
def include(test: ScriptedTest) = true
}
import scala.collection.mutable
private[test] object Scripted
{
val ScriptedClassName = "sbt.test.ScriptedTests"
val SbtTestDirectoryName = "sbt-test"
def list(directory: File, filter: java.io.FileFilter) = FileUtilities.wrapNull(directory.listFiles(filter))
}
private[test] final class ListTests(baseDirectory: File, accept: ScriptedTest => Boolean, log: Logger) extends NotNull
{
def filter = DirectoryFilter -- HiddenFileFilter
def listTests: Seq[ScriptedTest] =
{
System.setProperty("sbt.scala.version", "")
list(baseDirectory, filter) flatMap { group =>
val groupName = group.getName
listTests(group).map(ScriptedTest(groupName, _))
}
}
private[this] def listTests(group: File): Set[String] =
{
val groupName = group.getName
val allTests = list(group, filter)
if(allTests.isEmpty)
{
log.warn("No tests in test group " + groupName)
Set.empty
}
else
{
val (included, skipped) = allTests.toList.partition(test => accept(ScriptedTest(groupName, test.getName)))
if(included.isEmpty)
log.warn("Test group " + groupName + " skipped.")
else if(!skipped.isEmpty)
{
log.warn("Tests skipped in group " + group.getName + ":")
skipped.foreach(testName => log.warn(" " + testName.getName))
}
Set( included.map(_.getName) : _*)
}
}
}

View File

@ -0,0 +1,29 @@
/* sbt -- Simple Build Tool
* Copyright 2008, 2009 Mark Harrah
*/
package sbt.test
import java.net.URL
object ScriptedLoader
{
def apply(paths: Array[URL]): ClassLoader = new ScriptedLoader(paths)
}
private class ScriptedLoader(paths: Array[URL]) extends LoaderBase(paths, classOf[ScriptedLoader].getClassLoader)
{
private val delegateFor = List("sbt.Logger", "sbt.LogEvent", "sbt.SetLevel", "sbt.Success", "sbt.Log", "sbt.SetTrace", "sbt.Trace", "sbt.ControlEvent")
def doLoadClass(className: String): Class[_] =
{
// Logger needs to be loaded from the version of sbt building the project because we need to pass
// a Logger from that loader into ScriptedTests.
// All other sbt classes should be loaded from the project classpath so that we test those classes with 'scripted'
if(!shouldDelegate(className) && (className.startsWith("sbt.") || className.startsWith("scala.tools.")))
findClass(className)
else
selfLoadClass(className)
}
private def shouldDelegate(className: String) = delegateFor.exists(check => isNestedOrSelf(className, check))
private def isNestedOrSelf(className: String, checkAgainst: String) =
className == checkAgainst || className.startsWith(checkAgainst + "$")
}

View File

@ -0,0 +1,25 @@
/* sbt -- Simple Build Tool
* Copyright 2009 Mark Harrah
*/
package sbt.test
import java.io.File
final class ScriptedTests(testResources: Resources) extends NotNull
{
def this(resourceBaseDirectory: File, additional: ClassLoader) = this(new Resources(resourceBaseDirectory, additional))
def this(resourceBaseDirectory: File) = this(new Resources(resourceBaseDirectory))
val ScriptFilename = "test"
import testResources._
def scriptedTest(group: String, name: String, log: Logger): Option[String] =
readOnlyResourceDirectory(group, name).fold(err => Some(err), testDirectory => scriptedTest(testDirectory, log))
def scriptedTest(testDirectory: File, log: Logger): Option[String] =
{
(for(script <- (new TestScriptParser(testDirectory, log)).parse(new File(testDirectory, ScriptFilename)).right;
u <- withProject(testDirectory, log)(script).right )
yield u).left.toOption
}
}

View File

@ -0,0 +1,268 @@
/* sbt -- Simple Build Tool
* Copyright 2009 Mark Harrah
*/
package sbt.test
import java.io.{BufferedReader, File, InputStreamReader}
/*
statement*
statement ::= ('$' | '>') word+ '[' word ']'
word ::= [^ \[\]]+
comment ::= '#' [^ \n\r]* ('\n' | '\r' | eof)
*/
import scala.util.parsing.combinator._
import scala.util.parsing.input.Positional
import TestScriptParser._
private class TestScriptParser(baseDirectory: File, log: Logger) extends RegexParsers with NotNull
{
type Statement = Project => Either[String, ReloadProject]
type PStatement = Statement with Positional
private def evaluateList(list: List[PStatement])(p: Project): WithProjectResult[Unit] =
list match
{
case Nil => ValueResult(())
case head :: tail =>
head(p) match
{
case Left(msg) => new ErrorResult(msg)
case Right(reload) => ContinueResult(p =>evaluateList(tail)(p), reload)
}
}
def script: Parser[Project => WithProjectResult[Unit]] = rep1(space ~> statement <~ space) ^^ evaluateList
def statement: Parser[PStatement] =
positioned
{
(StartRegex ~! rep1(word) ~! "[" ~! word ~! "]") ^^
{
case start ~ command ~ open ~ result ~ close =>
val successExpected = result.toLowerCase == SuccessLiteral.toLowerCase
new Statement with Positional
{ selfPositional =>
def apply(p: Project) =
{
val result =
try
{
start match
{
case CommandStart => evaluateCommand(command, successExpected, selfPositional)(p)
case ActionStart => evaluateAction(command, successExpected)(p).toLeft(NoReload)
}
}
catch
{
case e: Exception =>
log.trace(e)
Left(e.toString)
}
result.left.map(message => linePrefix(this) + message)
}
}
}
}
private def linePrefix(p: Positional) = "{line " + p.pos.line + "} "
def space = """(\s+|(\#[^\n\r]*))*""".r
def word: Parser[String] = ("\'" ~> "[^'\n\r]*".r <~ "\'") | ("\"" ~> "[^\"\n\r]*".r <~ "\"") | WordRegex
def parse(scriptFile: File): Either[String, Project => WithProjectResult[Unit]] =
{
def parseReader(reader: java.io.Reader) =
parseAll(script, reader) match
{
case Success(result, next) => Right(result)
case err: NoSuccess =>
{
val pos = err.next.pos
Left("Could not parse test script '" + scriptFile.getCanonicalPath +
"' (" + pos.line + "," + pos.column + "): " + err.msg)
}
}
FileUtilities.readValue(scriptFile, log)(parseReader)
}
private def scriptError(message: String): Some[String] = Some("Test script error: " + message)
private def wrongArguments(commandName: String, args: List[String]): Some[String] =
scriptError("Command '" + commandName + "' does not accept arguments (found '" + spacedString(args) + "').")
private def wrongArguments(commandName: String, requiredArgs: String, args: List[String]): Some[String] =
scriptError("Wrong number of arguments to " + commandName + " command. " + requiredArgs + " required, found: '" + spacedString(args) + "'.")
private def evaluateCommand(command: List[String], successExpected: Boolean, position: Positional)(project: Project): Either[String, ReloadProject] =
{
command match
{
case "reload" :: Nil => Right(if(successExpected) new ReloadSuccessExpected(linePrefix(position)) else ReloadErrorExpected)
case x => evaluateCommandNoReload(x, successExpected)(project).toLeft(NoReload)
}
}
private def evaluateCommandNoReload(command: List[String], successExpected: Boolean)(project: Project): Option[String] =
{
evaluate(successExpected, "Command '" + command.firstOption.getOrElse("") + "'", project)
{
command match
{
case Nil => scriptError("No command specified.")
case "touch" :: paths => touch(paths, project)
case "delete" :: paths => delete(paths, project)
case "mkdir" :: paths => makeDirectories(paths, project)
case "copy-file" :: from :: to :: Nil => copyFile(from, to, project)
case "copy-file" :: args => wrongArguments("copy-file", "Two paths", args)
case "sync" :: from :: to :: Nil => sync(from, to, project)
case "sync" :: args => wrongArguments("sync", "Two directory paths", args)
case "copy" :: paths => copy(paths, project)
case "exists" :: paths => exists(paths, project)
case "absent" :: paths => absent(paths, project)
case "pause" :: Nil => readLine("Press enter to continue. "); println(); None
case "pause" :: args => wrongArguments("pause", args)
case "newer" :: a :: b :: Nil => newer(a, b, project)
case "newer" :: args => wrongArguments("newer", "Two paths", args)
case "sleep" :: time :: Nil => trap("Error while sleeping:") { Thread.sleep(time.toLong) }
case "sleep" :: args => wrongArguments("sleep", "Time in milliseconds", args)
case "exec" :: command :: args => execute(command, args, project)
case "exec" :: other => wrongArguments("exec", "Command and arguments", other)
case "reload" :: args => wrongArguments("reload", args)
case unknown :: arguments => scriptError("Unknown command " + unknown)
}
}
}
private def foreachBufferedLogger(project: Project)(f: BufferedLogger => Unit)
{
project.topologicalSort.foreach(p => p.log match { case buffered: BufferedLogger => f(buffered); case _ => () })
}
private def evaluate(successExpected: Boolean, label: String, project: Project)(body: => Option[String]): Option[String] =
{
def startRecordingLog() { foreachBufferedLogger(project)(_.startRecording()) }
def playLog() { foreachBufferedLogger(project)(_.playAll()) }
def stopLog() { foreachBufferedLogger(project)(_.stop()) }
startRecordingLog()
val result =
body match
{
case None =>
if(successExpected) None
else
{
playLog()
Some(label + " succeeded (expected failure).")
}
case Some(failure) =>
if(successExpected)
{
playLog()
Some(label + " failed (expected success): " + failure)
}
else None
}
stopLog()
result
}
private def evaluateAction(action: List[String], successExpected: Boolean)(project: Project): Option[String] =
{
def actionToString = action.mkString(" ")
action match
{
case Nil => scriptError("No action specified.")
case head :: Nil if project.taskNames.toSeq.contains(head)=>
evaluate(successExpected, "Action '" + actionToString + "'", project)(project.act(head))
case head :: tail =>
evaluate(successExpected, "Method '" + actionToString + "'", project)(project.call(head, tail.toArray))
}
}
private def spacedString[T](l: Seq[T]) = l.mkString(" ")
private def wrap(result: Option[String]) = result.flatMap(scriptError)
private def trap(errorPrefix: String)(action: => Unit) = wrap( Control.trapUnit(errorPrefix, log) { action; None } )
private def fromStrings(paths: List[String], project: Project) = paths.map(path => fromString(path, project))
private def fromString(path: String, project: Project) = Path.fromString(project.info.projectPath, path)
private def touch(paths: List[String], project: Project) =
if(paths.isEmpty)
scriptError("No paths specified for touch command.")
else
wrap(lazyFold(paths) { path => FileUtilities.touch(fromString(path, project), log) })
private def delete(paths: List[String], project: Project) =
if(paths.isEmpty)
scriptError("No paths specified for delete command.")
else
wrap(FileUtilities.clean(fromStrings(paths, project), true, log))
private def sync(from: String, to: String, project: Project) =
wrap(FileUtilities.sync(fromString(from, project), fromString(to, project), log))
private def copyFile(from: String, to: String, project: Project) =
wrap(FileUtilities.copyFile(fromString(from, project), fromString(to, project), log))
private def copy(paths: List[String], project: Project) =
paths match
{
case Nil => scriptError("No paths specified for copy command.")
case path :: Nil => scriptError("No destination specified for copy command.")
case _ =>
val mapped = fromStrings(paths, project).toArray
val last = mapped.length - 1
wrap(FileUtilities.copy(mapped.take(last), mapped(last), log).left.toOption)
}
private def makeDirectories(paths: List[String], project: Project) =
fromStrings(paths, project) match
{
case Nil => scriptError("No paths specified for mkdir command.")
case p => FileUtilities.createDirectories(p, project.log)
}
private def newer(a: String, b: String, project: Project) =
trap("Error testing if '" + a + "' is newer than '" + b + "'")
{
val pathA = fromString(a, project)
val pathB = fromString(b, project)
pathA.exists && (!pathB.exists || pathA.lastModified > pathB.lastModified)
}
private def exists(paths: List[String], project: Project) =
fromStrings(paths, project).filter(!_.exists) match
{
case Nil => None
case x => Some("File(s) did not exist: " + x.mkString("[ ", " , ", " ]"))
}
private def absent(paths: List[String], project: Project) =
fromStrings(paths, project).filter(_.exists) match
{
case Nil => None
case x => Some("File(s) existed: " + x.mkString("[ ", " , ", " ]"))
}
private def execute(command: String, args: List[String], project: Project) =
{
if(command.trim.isEmpty)
Some("Command was empty.")
else
{
Control.trapUnit("Error running command: ", project.log)
{
val builder = new java.lang.ProcessBuilder((command :: args).toArray : _*).directory(project.info.projectDirectory)
val exitValue = Process(builder) ! log
if(exitValue == 0)
None
else
Some("Nonzero exit value (" + exitValue + ")")
}
}
}
}
private object TestScriptParser
{
val SuccessLiteral = "success"
val Failure = "error"
val CommandStart = "$"
val ActionStart = ">"
val WordRegex = """[^ \[\]\s'\"][^ \[\]\s]*""".r
val StartRegex = ("[" + CommandStart + ActionStart + "]").r
final def lazyFold[T](list: List[T])(f: T => Option[String]): Option[String] =
list match
{
case Nil => None
case head :: tail =>
f(head) match
{
case None => lazyFold(tail)(f)
case x => x
}
}
}