Skip to content
Browse files

New scripted test framework

  • Loading branch information...
1 parent 1454b24 commit 4023e10fd6dce49b830cc38a8c86fbe641c0cf73 @harrah harrah committed
View
25 LICENSE
@@ -0,0 +1,25 @@
+Copyright (c) 2008, 2009 Mark Harrah
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+3. The name of the author may not be used to endorse or promote products
+ derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
View
35 licenses/LICENSE_Scala
@@ -0,0 +1,35 @@
+SCALA LICENSE
+
+Copyright (c) 2002-2008 EPFL, Lausanne, unless otherwise specified.
+All rights reserved.
+
+This software was developed by the Programming Methods Laboratory of the
+Swiss Federal Institute of Technology (EPFL), Lausanne, Switzerland.
+
+Permission to use, copy, modify, and distribute this software in source
+or binary form for any purpose with or without fee is hereby granted,
+provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ 3. Neither the name of the EPFL nor the names of its contributors
+ may be used to endorse or promote products derived from this
+ software without specific prior written permission.
+
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
View
18 project/build/XSbt.scala
@@ -11,25 +11,29 @@ class XSbt(info: ProjectInfo) extends ParentProject(info)
val interfaceSub = project("interface", "Interface", new InterfaceProject(_))
- val controlSub = project(utilPath / "control", "Control", new Base(_))
- val collectionSub = project(utilPath / "collection", "Collections", new Base(_))
+ val controlSub = baseProject(utilPath / "control", "Control")
+ val collectionSub = baseProject(utilPath / "collection", "Collections")
val ioSub = project(utilPath / "io", "IO", new IOProject(_), controlSub)
- val classpathSub = project(utilPath / "classpath", "Classpath", new Base(_))
+ val classpathSub = baseProject(utilPath / "classpath", "Classpath")
val ivySub = project("ivy", "Ivy", new IvyProject(_), interfaceSub, launchInterfaceSub)
- val logSub = project(utilPath / "log", "Logging", new Base(_), interfaceSub)
+ val logSub = baseProject(utilPath / "log", "Logging", interfaceSub)
+
+ val testSub = baseProject("scripted", "Test", ioSub)
val compileInterfaceSub = project(compilePath / "interface", "Compiler Interface", new CompilerInterfaceProject(_), interfaceSub)
val taskSub = project(tasksPath, "Tasks", new TaskProject(_), controlSub, collectionSub)
val cacheSub = project(cachePath, "Cache", new CacheProject(_), taskSub, ioSub)
- val trackingSub = project(cachePath / "tracking", "Tracking", new Base(_), cacheSub)
+ val trackingSub = baseProject(cachePath / "tracking", "Tracking", cacheSub)
val compilerSub = project(compilePath, "Compile", new CompileProject(_),
launchInterfaceSub, interfaceSub, ivySub, ioSub, classpathSub, compileInterfaceSub)
val stdTaskSub = project(tasksPath / "standard", "Standard Tasks", new StandardTaskProject(_), trackingSub, compilerSub)
val distSub = project("dist", "Distribution", new DistProject(_))
+ def baseProject(path: Path, name: String, deps: Project*) = project(path, name, new TestProject(_), deps : _*)
+
/* Multi-subproject paths */
def cachePath = path("cache")
def tasksPath = path("tasks")
@@ -123,6 +127,10 @@ class XSbt(info: ProjectInfo) extends ParentProject(info)
{
override def componentID = None
}
+ class TestProject(info: ProjectInfo) extends Base(info)
+ {
+ val process = "org.scala-tools.sbt" % "process" % "0.1"
+ }
class CompilerInterfaceProject(info: ProjectInfo) extends Base(info) with SourceProject with TestWithIO with TestWithLog
{
def xTestClasspath = projectClasspath(Configurations.Test)
View
42 scripted.specification
@@ -0,0 +1,42 @@
+Scripted test framework specification:
+
+This framework is intended to be a lightweight interface to a simple scripting syntax. The framework parses statements of a simple form:
+{{{
+ statement ::= ws* startChar ws* successIndicator ws* word (ws word)* nl
+ ws ::= (' ' | '\t') ('\\' nl )?
+ word ::= [^ \n\r\[\]]+
+ successIndicator ::= '+' | '-'
+ nl = '\n' | '\r' '\n' | '\r' | EOF
+}}}
+Whitespace is used to separate words and may optionally be used between other terminals. To continue a statement on a new line, use a space followed by a backslash \ where whitespace is allowed. For example
+Single line comments are supported of the form:
+{{{
+ comment ::= '#' [^ \n\r]* nl
+}}}
+
+The framework uses handlers to interpret statements. A handler is an instance of the following interface:
+
+{{{
+trait StatementHandler
+{
+ type State
+ def initialState: State
+ def apply(command: String, arguments: List[String], state: State): State
+}
+}}}
+
+The String and List[String] arguments define the command to execute. State is the state carried throughout the execution of the script. The type State is defined by the handler and is specific to the handler (no other handlers see the state).
+
+A handler processes the inputs and either returns the new state S to continue testing with or throws an exception to indicate failure.
+
+The test framework is used by creating an instance of ScriptedRunner, which requires a map from characters to handlers. A script starts a statement with the character associated with a handler to select the handler to interpret the rest of the statement.
+
+{{{
+class ScriptedRunner(handlers: Map[Char, StatementHandler]) extends NotNull
+{
+ // file assumed to be UTF-8
+ def apply(file: File): Option[String] = ...
+ def apply(script: String): Option[String] = ...
+ def apply(reader: Reader): Option[String] = ...
+}
+}}}
View
9 scripted/CommentHandler.scala
@@ -0,0 +1,9 @@
+package xsbt.test
+
+import java.io.File
+import xsbt.{FileMapper, FileUtilities, Paths}
+
+object CommentHandler extends BasicStatementHandler
+{
+ def apply(command: String, args: List[String]) = ()
+}
View
120 scripted/FileCommands.scala
@@ -0,0 +1,120 @@
+package xsbt.test
+
+import java.io.File
+import xsbt.{FileMapper, FileUtilities, Paths}
+
+class FileCommands(baseDirectory: File) extends BasicStatementHandler
+{
+ lazy val commands = commandMap
+ def commandMap =
+ Map(
+ "touch" nonEmpty touch _,
+ "delete" nonEmpty delete _,
+ "exists" nonEmpty exists _,
+ "mkdir" nonEmpty makeDirectories _,
+ "absent" nonEmpty absent _,
+// "sync" twoArg("Two directory paths", sync _),
+ "newer" twoArg("Two paths", newer _),
+ "pause" noArg { readLine("Press enter to continue. "); println() },
+ "sleep" oneArg("Time in milliseconds", time => Thread.sleep(time.toLong) ),
+ "exec" nonEmpty(execute _ ),
+ "copy" copy (to => FileMapper.rebase(baseDirectory, to)),
+ "copy-file" twoArg("Two paths", copyFile _),
+ "copy-flat" copy (FileMapper.flat)
+ )
+
+ def apply(command: String, arguments: List[String]): Unit =
+ commands.get(command).map( _(arguments) ).getOrElse(scriptError("Unknown command " + command))
+
+ def scriptError(message: String): Some[String] = error("Test script error: " + message)
+ def spaced[T](l: Seq[T]) = l.mkString(" ")
+ def fromStrings(paths: List[String]) = paths.map(fromString)
+ def fromString(path: String) =
+ {
+ import Paths._
+ baseDirectory / path
+ }
+ def touch(paths: List[String]) = FileUtilities.touch(fromStrings(paths))
+ def delete(paths: List[String]): Unit = FileUtilities.delete(fromStrings(paths))
+ /*def sync(from: String, to: String) =
+ FileUtilities.sync(fromString(from), fromString(to), log)*/
+ def copyFile(from: String, to: String): Unit =
+ FileUtilities.copyFile(fromString(from), fromString(to))
+ def makeDirectories(paths: List[String]) =
+ FileUtilities.createDirectories(fromStrings(paths))
+ def newer(a: String, b: String) =
+ {
+ val pathA = fromString(a)
+ val pathB = fromString(b)
+ pathA.exists && (!pathB.exists || pathA.lastModified > pathB.lastModified)
+ }
+ def exists(paths: List[String])
+ {
+ val notPresent = fromStrings(paths).filter(!_.exists)
+ if(notPresent.length > 0)
+ scriptError("File(s) did not exist: " + notPresent.mkString("[ ", " , ", " ]"))
+ }
+ def absent(paths: List[String])
+ {
+ val present = fromStrings(paths).filter(_.exists)
+ if(present.length > 0)
+ scriptError("File(s) existed: " + present.mkString("[ ", " , ", " ]"))
+ }
+ def execute(command: List[String]): Unit = execute0(command.head, command.tail)
+ def execute0(command: String, args: List[String])
+ {
+ if(command.trim.isEmpty)
+ scriptError("Command was empty.")
+ else
+ {
+ val builder = new java.lang.ProcessBuilder((command :: args).toArray : _*).directory(baseDirectory)
+ val exitValue = ( Process(builder) ! )
+ if(exitValue != 0)
+ error("Nonzero exit value (" + exitValue + ")")
+ }
+ }
+
+ // these are for readability of the command list
+ implicit def commandBuilder(s: String): CommandBuilder = new CommandBuilder(s)
+ final class CommandBuilder(commandName: String) extends NotNull
+ {
+ type NamedCommand = (String, List[String] => Unit)
+ def nonEmpty(action: List[String] => Unit): NamedCommand =
+ commandName -> { paths =>
+ if(paths.isEmpty)
+ scriptError("No arguments specified for " + commandName + " command.")
+ else
+ action(paths)
+ }
+ def twoArg(requiredArgs: String, action: (String, String) => Unit): NamedCommand =
+ commandName -> {
+ case List(from, to) => copyFile(from, to)
+ case other => wrongArguments(requiredArgs, other)
+ }
+ def noArg(action: => Unit): NamedCommand =
+ commandName -> {
+ case Nil => action
+ case other => wrongArguments(other)
+ }
+ def oneArg(requiredArgs: String, action: String => Unit): NamedCommand =
+ commandName -> {
+ case List(single) => action(single)
+ case other => wrongArguments(requiredArgs, other)
+ }
+ def copy(mapper: File => FileMapper): NamedCommand =
+ commandName -> {
+ case Nil => scriptError("No paths specified for " + commandName + " command.")
+ case path :: Nil => scriptError("No destination specified for " + commandName + " command.")
+ case paths =>
+ val mapped = fromStrings(paths).toArray
+ val last = mapped.length - 1
+ val map = mapper(mapped(last))
+ import Paths._
+ FileUtilities.copy( mapped.take(last) x map )
+ }
+ def wrongArguments(args: List[String]): Some[String] =
+ scriptError("Command '" + commandName + "' does not accept arguments (found '" + spaced(args) + "').")
+ def wrongArguments(requiredArgs: String, args: List[String]): Some[String] =
+ scriptError("Wrong number of arguments to " + commandName + " command. " + requiredArgs + " required, found: '" + spaced(args) + "'.")
+ }
+}
View
39 scripted/ScriptRunner.scala
@@ -0,0 +1,39 @@
+/* sbt -- Simple Build Tool
+ * Copyright 2009 Mark Harrah
+ */
+package xsbt.test
+
+
+final class TestException(statement: Statement, msg: String, exception: Throwable)
+ extends RuntimeException(statement.linePrefix + " " + msg, exception)
+
+class ScriptRunner
+{
+ import scala.collection.mutable.HashMap
+ def apply(statements: List[(StatementHandler, Statement)])
+ {
+ val states = new HashMap[StatementHandler, Any]
+ def processStatement(handler: StatementHandler, statement: Statement)
+ {
+ val state = states.getOrElseUpdate(handler, handler.initialState).asInstanceOf[handler.State]
+ val nextState =
+ try { Right( handler(statement.command, statement.arguments, state) ) }
+ catch { case e: Exception => Left(e) }
+ nextState match
+ {
+ case Left(err) =>
+ if(statement.successExpected)
+ throw new TestException(statement, "Command failed", err)
+ else
+ ()
+ case Right(s) =>
+ if(statement.successExpected)
+ states(handler) = s
+ else
+ throw new TestException(statement, "Command succeeded but failure was expected", null)
+ }
+ }
+ statements.foreach { case (handler, _) => states(handler) = handler.initialState }
+ statements foreach( Function.tupled(processStatement) )
+ }
+}
View
16 scripted/StatementHandler.scala
@@ -0,0 +1,16 @@
+package xsbt.test
+
+trait StatementHandler
+{
+ type State
+ def initialState: State
+ def apply(command: String, arguments: List[String], state: State): State
+}
+
+trait BasicStatementHandler extends StatementHandler
+{
+ final type State = Unit
+ final def initialState = ()
+ final def apply(command: String, arguments: List[String], state: Unit): Unit= apply(command, arguments)
+ def apply(command: String, arguments: List[String]): Unit
+}
View
85 scripted/TestScriptParser.scala
@@ -0,0 +1,85 @@
+/* sbt -- Simple Build Tool
+ * Copyright 2009 Mark Harrah
+ */
+package xsbt.test
+
+import java.io.{BufferedReader, File, InputStreamReader}
+import scala.util.parsing.combinator._
+import scala.util.parsing.input.Positional
+import Character.isWhitespace
+import xsbt.FileUtilities
+
+/*
+statement*
+statement ::= startChar successChar word+ nl
+startChar ::= <single character>
+successChar ::= '+' | '-'
+word ::= [^ \[\]]+
+comment ::= '#' \S* nl
+nl ::= '\r' \'n' | '\n' | '\r' | eof
+*/
+final case class Statement(command: String, arguments: List[String], successExpected: Boolean, line: Int) extends NotNull
+{
+ def linePrefix = "{line " + line + "} "
+}
+
+private object TestScriptParser
+{
+ val SuccessLiteral = "success"
+ val FailureLiteral = "failure"
+ val WordRegex = """[^ \[\]\s'\"][^ \[\]\s]*""".r
+}
+
+import TestScriptParser._
+class TestScriptParser(handlers: Map[Char, StatementHandler]) extends RegexParsers with NotNull
+{
+ require(!handlers.isEmpty)
+ override def skipWhitespace = false
+
+ import FileUtilities.read
+ if(handlers.keys.exists(isWhitespace))
+ error("Start characters cannot be whitespace")
+ if(handlers.keys.exists(key => key == '+' || key == '-'))
+ error("Start characters cannot be '+' or '-'")
+
+ def parse(scriptFile: File): List[(StatementHandler, Statement)] = parse(read(scriptFile), Some(scriptFile.getCanonicalPath))
+ def parse(script: String): List[(StatementHandler, Statement)] = parse(script, None)
+ private def parse(script: String, label: Option[String]): List[(StatementHandler, Statement)] =
+ {
+ parseAll(statements, script) match
+ {
+ case Success(result, next) => result
+ case err: NoSuccess =>
+ {
+ val labelString = label.map("'" + _ + "' ").getOrElse("")
+ error("Could not parse test script, " + labelString + err.toString)
+ }
+ }
+ }
+
+ lazy val statements = rep1(space ~> statement <~ newline )
+ def statement: Parser[ ( StatementHandler, Statement ) ] =
+ {
+ trait PositionalStatement extends Positional {
+ def tuple: (StatementHandler, Statement )
+ }
+ positioned {
+ val command = (word | err("expected command"))
+ val arguments = rep(space ~> (word | failure("expected argument") ))
+ (successParser ~ (space ~> startCharacterParser <~ space) ~! command ~! arguments) ^^
+ {
+ case successExpected ~ start ~ command ~ arguments =>
+ new PositionalStatement {
+ def tuple = ( handlers(start), new Statement(command, arguments, successExpected, pos.line) )
+ }
+ }
+ } ^^ (_.tuple)
+ }
+ def successParser: Parser[Boolean] = ( '+' ^^^ true) | ('-' ^^^ false) | success(true)
+ def space: Parser[String] = """[ \t]*""".r
+ lazy val word: Parser[String] = ("\'" ~> "[^'\n\r]*".r <~ "\'") | ("\"" ~> "[^\"\n\r]*".r <~ "\"") | WordRegex
+ def startCharacterParser: Parser[Char] = elem("start character", handlers.contains _) |
+ ( ( newline | err("expected start character " +handlers.keys.mkString("(", "", ")") ) ) ~> failure("end of input") )
+
+ def newline = """\s*([\n\r]|$)""".r
+}
View
7 util/io/FileUtilities.scala
@@ -26,6 +26,8 @@ object FileUtilities
private val BufferSize = 8192
private val Newline = System.getProperty("line.separator")
+ def utf8 = Charset.forName("UTF-8")
+
def classLocation(cl: Class[_]): URL =
{
val codeSource = cl.getProtectionDomain.getCodeSource
@@ -66,6 +68,7 @@ object FileUtilities
(name, "")
}
+ def touch(files: Iterable[File]): Unit = files.foreach(touch)
/** Creates a file at the given location.*/
def touch(file: File)
{
@@ -78,6 +81,8 @@ object FileUtilities
else if(!file.setLastModified(System.currentTimeMillis))
error("Could not update last modified time for file " + file)
}
+ def createDirectories(dirs: Iterable[File]): Unit =
+ dirs.foreach(createDirectory)
def createDirectory(dir: File): Unit =
{
def failBase = "Could not create directory " + dir
@@ -340,7 +345,7 @@ object FileUtilities
}
}
}
- def defaultCharset = Charset.forName("UTF-8")
+ def defaultCharset = utf8
def write(toFile: File, content: String): Unit = write(toFile, content, defaultCharset)
def write(toFile: File, content: String, charset: Charset): Unit = write(toFile, content, charset, false)
def write(file: File, content: String, charset: Charset, append: Boolean)
View
2 util/io/PathMapper.scala
@@ -38,7 +38,7 @@ object FileMapper
{
def basic(newDirectory: File) = new FMapper(file => new File(newDirectory, file.getPath))
def rebase(oldBase: File, newBase: File) =
- new FMapper(file => new File(newBase, FileUtilities.relativize(oldBase, file).getOrElse(error(file + " not a descendent of " + oldBase))))
+ new FMapper(file => if(file == oldBase) newBase else new File(newBase, FileUtilities.relativize(oldBase, file).getOrElse(error(file + " not a descendent of " + oldBase))))
def flat(newDirectory: File) = new FMapper(file => new File(newDirectory, file.getName))
def apply(f: File => File) = new FMapper(f)
}
View
3 util/io/Paths.scala
@@ -14,6 +14,7 @@ object Paths
case s: Set[File] => filesToPaths(s)
case _ => new Paths(Set(fs.toSeq : _*))
}
+ def normalize(path: String): String = path.replace('/', File.separatorChar).replace('\\', File.separatorChar)
}
import Paths._
@@ -39,7 +40,7 @@ final class Path(val asFile: File) extends PathBase
{
def files = Set(asFile)
def \(subPath: String) = /(subPath)
- def /(subPath: String) = new File(asFile, subPath.replace('/', File.separatorChar).replace('\\', File.separatorChar))
+ def /(subPath: String) = new File(asFile, normalize(subPath))
def ++(files: Set[File]) = files + asFile
def ++(file: File) = Set(file, asFile)
}

0 comments on commit 4023e10

Please sign in to comment.
Something went wrong with that request. Please try again.