From 7c6f420b21b522a53596e70067d2de07153bf851 Mon Sep 17 00:00:00 2001 From: dmharrah Date: Tue, 7 Jul 2009 01:09:56 +0000 Subject: [PATCH] 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 --- scripted/project/build.properties | 8 + scripted/project/build/SbtTest.scala | 7 + scripted/src/main/scala/Scripted.scala | 109 +++++++ scripted/src/main/scala/ScriptedLoader.scala | 29 ++ scripted/src/main/scala/ScriptedTests.scala | 25 ++ .../src/main/scala/TestScriptParser.scala | 268 ++++++++++++++++++ 6 files changed, 446 insertions(+) create mode 100644 scripted/project/build.properties create mode 100644 scripted/project/build/SbtTest.scala create mode 100644 scripted/src/main/scala/Scripted.scala create mode 100644 scripted/src/main/scala/ScriptedLoader.scala create mode 100644 scripted/src/main/scala/ScriptedTests.scala create mode 100644 scripted/src/main/scala/TestScriptParser.scala diff --git a/scripted/project/build.properties b/scripted/project/build.properties new file mode 100644 index 000000000..c096cde6d --- /dev/null +++ b/scripted/project/build.properties @@ -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 diff --git a/scripted/project/build/SbtTest.scala b/scripted/project/build/SbtTest.scala new file mode 100644 index 000000000..89fd5a110 --- /dev/null +++ b/scripted/project/build/SbtTest.scala @@ -0,0 +1,7 @@ +/* sbt -- Simple Build Tool + * Copyright 2009 Mark Harrah + */ + +import sbt._ + +class SbtTest(info: ProjectInfo) extends PluginProject(info) \ No newline at end of file diff --git a/scripted/src/main/scala/Scripted.scala b/scripted/src/main/scala/Scripted.scala new file mode 100644 index 000000000..76190ce17 --- /dev/null +++ b/scripted/src/main/scala/Scripted.scala @@ -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) : _*) + } + } +} \ No newline at end of file diff --git a/scripted/src/main/scala/ScriptedLoader.scala b/scripted/src/main/scala/ScriptedLoader.scala new file mode 100644 index 000000000..4ef45e3b7 --- /dev/null +++ b/scripted/src/main/scala/ScriptedLoader.scala @@ -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 + "$") +} \ No newline at end of file diff --git a/scripted/src/main/scala/ScriptedTests.scala b/scripted/src/main/scala/ScriptedTests.scala new file mode 100644 index 000000000..4c3982bfa --- /dev/null +++ b/scripted/src/main/scala/ScriptedTests.scala @@ -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 + } +} diff --git a/scripted/src/main/scala/TestScriptParser.scala b/scripted/src/main/scala/TestScriptParser.scala new file mode 100644 index 000000000..a87e34de6 --- /dev/null +++ b/scripted/src/main/scala/TestScriptParser.scala @@ -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 + } + } +}