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

org.scalatest.tools.DashboardReporter.scala Maven / Gradle / Ivy

Go to download

ScalaTest is a free, open-source testing toolkit for Scala and Java programmers.

There is a newer version: 2.0.M6-SNAP3
Show newest version
/*
 * Copyright 2001-2011 Artima, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.scalatest.tools

import org.scalatest.events._
import org.scalatest.Reporter
import org.scalatest.events.MotionToSuppress
import org.scalatest.exceptions.StackDepthException

import java.io.PrintWriter
import java.io.BufferedOutputStream
import java.io.FileOutputStream
import java.io.File
import java.util.Date
import java.util.TimeZone
import java.text.SimpleDateFormat
import java.util.regex.Matcher.quoteReplacement

import scala.collection.mutable
import scala.collection.mutable.Stack
import scala.collection.mutable.ListBuffer
import scala.xml.XML
import scala.xml.NodeSeq
import scala.xml.Elem
import scala.xml.Node

/**
 * A Reporter that writes test status information in xml format
 * for use by Flex formatter.
 */
private[scalatest] class DashboardReporter(directory: String,
                                           numOldFilesToKeep: Int)
  extends Reporter
{
  final val BufferSize = 4096

  private val events = ListBuffer[Event]()
  private var index = 0
  private var timestamp = "" // gets set at RunStarting event

  private final val TimestampPattern = """\d{4}-\d{2}-\d{2}-\d{6}-\d{3}"""

  private val runsDir          = new File(directory + "/runs")
  private val durationsDir     = new File(directory + "/durations")
  private val summariesDir     = new File(directory + "/summaries")
  private val summaryFile      = new File(directory + "/summary.xml")
  private val durationsFile    = new File(directory + "/durations.xml")

  runsDir.mkdir()
  durationsDir.mkdir()
  summariesDir.mkdir()

  //
  // Records events as they are received.  Initiates processing once
  // a run-termination event comes in.
  //
  // Ignores info and markup events.
  //
  def apply(event: Event) {
    event match {
      case _: RunStarting    => timestamp = formatCurrentTime
      case _: InfoProvided   =>
      case _: ScopeOpened    =>
      case _: ScopeClosed    =>
      case _: MarkupProvided =>
      case _: RunCompleted   => writeFiles(event)
      case _: RunStopped     => writeFiles(event)
      case _: RunAborted     => writeFiles(event)
      case _ => events += event
    }
  }

  //
  // Formats current time for use as timestamp to identify current run.
  //
  // Uses GMT time zone to avoid sequencing errors that could occur with
  // time shifts due to daylight savings time or laptop travel if local
  // time zone were used.
  //
  def formatCurrentTime: String = {
    val df = new SimpleDateFormat("yyyy-MM-dd-HHmmss-SSS")
    df.setTimeZone(TimeZone.getTimeZone("GMT"))
    df.format(new Date)
  }

  //
  // Provides sequential index values for xml entries.
  //
  def nextIndex(): Int = {
    index += 1
    index
  }

  //
  // Throws exception for specified unexpected event.
  //
  def unexpectedEvent(e: Event) {
    throw new RuntimeException("unexpected event [" + e + "]")
  }

  //
  // Escapes html entities and curly braces in specified string.
  //
  def escape(s: String): String =
    scala.xml.Utility.escape(s).
      replaceAll("""\{""", """\\{""").
      replaceAll("""\}""", """\\}""")

  //
  // Formats date for inclusion in as 'date' attribute in xml.
  //
  // E.g.: "Mon May 30 10:29:58 PDT 2011"
  //
  def formatDate(timeStamp: Long): String = {
    val df = new SimpleDateFormat("EEE MMM d kk:mm:ss zzz yyyy")
    df.format(new Date(timeStamp))
  }

  //
  // Reads existing summary.xml file, or, if none exists, returns a 
  // xml containing all empty elements.
  //
  def getOldSummaryXml: Elem = {
    if (summaryFile.exists)
      XML.loadFile(summaryFile)
    else
      
        
        
        
      
  }

  //
  // If a summary file containing previous run histories exists, moves
  // it to the summaries/ subdirectory and renames it to a filename
  // containing the timestamp of the most recent run the file contains.
  //
  // Ditto for the durations.xml file.
  //
  def archiveOldFiles(oldRunsXml: NodeSeq) {
    val previousRunTimestamp = 
      if (oldRunsXml.size > 0) Some("" + oldRunsXml(0) \ "@id")
      else None

    if (previousRunTimestamp.isDefined) {
      if (summaryFile.exists)
        summaryFile.renameTo(
          new File(
            summariesDir + "/summary-" + previousRunTimestamp.get + ".xml"))

      if (durationsFile.exists)
        durationsFile.renameTo(
          new File(
            durationsDir + "/duration-" + previousRunTimestamp.get + ".xml"))

      purgeDir(summariesDir, "summary-")
      purgeDir(durationsDir, "duration-")
    }
  }

  //
  // Deletes older files from specified archive directory.
  //
  // We keep timestamped old copies of summary.xml and durations.xml in
  // summaries/ and durations/ subdirectories.  This method trims the
  // oldest archived files from those directories to maintain a specified
  // maximum number of archived copies.
  //
  def purgeDir(directory: File, prefix: String) {
    directory.
      listFiles().
      filter(_.getName.matches(prefix + TimestampPattern + """\.xml""")).
      sorted.
      dropRight(numOldFilesToKeep).
      foreach(_.delete())
  }

  //
  // Writes dashboard reporter summary, duration, and run files at completion
  // of a run.  Archives old copies of summary and duration files into
  // summaries/ and durations/ subdirectories.
  //
  def writeFiles(terminatingEvent: Event) {
    val durations     = Durations(durationsFile)
    val oldSummaryXml = getOldSummaryXml
    val oldRunsXml    = oldSummaryXml \\ "run"

    archiveOldFiles(oldRunsXml)

    val thisRunFile = new File(runsDir, "run-" + timestamp + ".xml")

    writeRunFile(terminatingEvent, thisRunFile)
    val thisRunXml = XML.loadFile(thisRunFile)

    durations.addTests(timestamp, thisRunXml)

    writeDurationsFile(durations)
    writeSummaryFile(
      terminatingEvent, oldSummaryXml, oldRunsXml, thisRunXml, durations)
  }

  //
  // Writes the durations.xml file.
  //
  def writeDurationsFile(durations: Durations) {
    writeFile("durations.xml", durations.toXml)
  }

  //
  // Writes the summary.xml file.
  //
  def writeSummaryFile(terminatingEvent: Event, oldSummaryXml: NodeSeq,
                       oldRunsXml: NodeSeq, thisRunXml: NodeSeq,
                       durations: Durations)
  {
    val SummaryTemplate =
      """|
         |  
         |$runs$  
         |  
         |$regressions$  
         |  
         |$recentlySlower$  
         |
         |""".stripMargin

    //
    // Formats a  element of summary file.
    //
    def formatRun(id: String, succeeded: String, failed: String,
                  ignored: String, canceled: String, pending: String): String =
    {
      "    \n"
    }

    //
    // Generates the summary file  element for the current run.
    //
    def genThisRun(terminatingEvent: Event): String = {
      val summaryOption = 
        terminatingEvent match {
          case e: RunCompleted => e.summary
          case e: RunAborted   => e.summary
          case e: RunStopped   => e.summary
          case _ => unexpectedEvent(terminatingEvent); None
        }
  
      val summary  = summaryOption.getOrElse(Summary(0, 0, 0, 0, 0, 0, 0))
  
      formatRun(timestamp,
                "" + summary.testsSucceededCount,
                "" + summary.testsFailedCount,
                "" + summary.testsIgnoredCount,
                "" + summary.testsCanceledCount,
                "" + summary.testsPendingCount)
    }

    //
    // Formats  elements for previous runs.
    //
    // (We could let scala do this its way, but it makes kind of a mess.)
    //
    def formatOldRuns(oldRunsXml: NodeSeq): String = {
      val buf = new StringBuilder

      for (run <- oldRunsXml) {
        val id        = "" + (run \ "@id")
        val succeeded = "" + (run \ "@succeeded")
        val failed    = "" + (run \ "@failed")
        val ignored   = "" + (run \ "@ignored")
        val canceled  = "" + (run \ "@canceled")
        val pending   = "" + (run \ "@pending")

        buf.append(
          formatRun(id, succeeded, failed, ignored, canceled, pending))
      }
      buf.toString
    }

    //
    // Generates  elements for output summary file.
    //
    def genRegressions(oldSummaryXml: NodeSeq, thisRunXml: NodeSeq): String =
    {
      //
      // Searches through regressions from previous summary to try and
      // find one matching specified test.
      //
      def getOldRegression(suite: Node, test: Node,
                           oldRegressionsXml: NodeSeq): Option[Node] =
      {
        oldRegressionsXml.find(
          node => (((node \ "@testName") == (test \ "@name")) &&
                   ((node \ "@suiteID") == (suite \ "@id"))))
      }

      //
      // Formats a  element.
      //
      def formatRegression(suite: Node, test: Node, result: String,
                           lastSucceeded: String, firstRegressed: String):
      String =
      {
        "    \n"
      }

      //
      // Gets the timestamp of the previous run.
      //
      def getLastRunId: Option[String] = {
        val previousRuns = oldSummaryXml \\ "run"

        if (previousRuns.size > 0) Some("" + (previousRuns(0) \ "@id"))
        else None
      }

      //
      // Retrieves xml of the previous run if available, else Empty.
      //
      def getLastRunXml(lastRunId: Option[String]): NodeSeq = {
        if (lastRunId.isDefined)
          XML.loadFile(directory + "/runs/run-" + lastRunId.get + ".xml")
        else
          NodeSeq.Empty
      }

      //
      // Checks xml from previous run to see if it contains a test with
      // success status that matches specified test.
      //
      def lastRunSucceeded(suite: Node, test: Node, lastRunXml: NodeSeq):
      Boolean =
      {
        var found = false
        var succeeded = false

        val oldSuitesIt = (lastRunXml \\ "suite").iterator
        while (!found && oldSuitesIt.hasNext) {
          val oldSuite = oldSuitesIt.next()

          if (oldSuite \ "@id" == suite \ "@id") {
            val oldTests = oldSuite \ "test"
            val matchingTest =
              oldTests.find(node => (node \ "@name") == test \ "@name")
            if (matchingTest.isDefined) {
              found = true
              val result = "" + matchingTest.get \ "@result"
              succeeded = (result == "succeeded")
            }
          }
        }
        succeeded
      }

      //
      // genRegressions main
      //
      val buf = new StringBuilder
      val suites = thisRunXml \\ "suite"
      val oldRegressionsXml = oldSummaryXml \\ "regressedTest"
      val lastRunId = getLastRunId
      val lastRunXml = getLastRunXml(lastRunId)

      for (suite <- suites) {
        for (test <- suite \ "test") {
          val result = "" + (test \ "@result")

          if (result != "succeeded") {
            val oldRegression = getOldRegression(suite, test,
                                                 oldRegressionsXml)
            val lastSucceeded =
              if (oldRegression.isDefined)
                "" + oldRegression.get \ "@lastSucceeded"
              else if (lastRunSucceeded(suite, test, lastRunXml))
                lastRunId.get
              else
                "never"

            val firstRegressed =
              if (oldRegression.isDefined)
                "" + oldRegression.get \ "@firstRegressed"
              else
                timestamp

            if (!((result == "pending") && (lastSucceeded == "never")))
              buf.append(
                formatRegression(
                  suite, test, result, lastSucceeded, firstRegressed))
          }
        }
      }
      buf.toString
    }

    def genRecentlySlower(durations: Durations): String = {
      var slowRecords = List[SlowRecord]()

      case class SlowRecord(suite: Durations#Suite, test: Durations#Test,
                            oldAvg: Int, newAvg: Int, percentSlower: Int)
      {
        def toXml: String = {
          val SlowerTestTemplate =
            """      
               |""".stripMargin

        SlowerTestTemplate.
          replaceFirst("""\$suiteID\$""",   quoteReplacement(suite.suiteID)).
          replaceFirst("""\$suiteName\$""", quoteReplacement(suite.suiteName)).
          replaceFirst("""\$testName\$""",  quoteReplacement(test.name)).
          replaceFirst("""\$oldAvg\$""",    "" + oldAvg).
          replaceFirst("""\$newAvg\$""",    "" + newAvg)
        }
      }

      def toXml: String = {
        val buf = new StringBuilder

        for (slowRecord <- slowRecords) buf.append(slowRecord.toXml)

        buf.toString
      }

      for (suite <- durations.suites) {
        for (test <- suite.tests) {
          if (test.runCount > 10) {
            val oldAvg = test.previousAverage
            val newAvg = test.computeNewAvg

            if ((newAvg - oldAvg > 1) && (oldAvg > 0)) {
              val percentSlower =
                (((newAvg - oldAvg).toDouble / oldAvg.toDouble) * 100).toInt

              if (percentSlower > 10)
                slowRecords ::=
                  SlowRecord(suite, test, oldAvg, newAvg, percentSlower)
            }
          }
        }
      }
      slowRecords =
        slowRecords.sortBy(r => r.percentSlower).take(20)

      toXml
    }

    //
    // writeSummaryFile main
    //
    val thisRun        = genThisRun(terminatingEvent)
    val oldRuns        = formatOldRuns(oldRunsXml)
    val regressions    = genRegressions(oldSummaryXml, thisRunXml)
    val recentlySlower = genRecentlySlower(durations)

    val summaryText =
      SummaryTemplate.
        replaceFirst("""\$runs\$""",     quoteReplacement(thisRun + oldRuns)).
        replaceFirst("""\$regressions\$""", quoteReplacement(regressions)).
        replaceFirst("""\$recentlySlower\$""",
                     quoteReplacement(recentlySlower))

    writeFile("summary.xml", summaryText)
  }

  //
  // Writes specified text to specified file in output directory.
  //
  def writeFile(filename: String, text: String) {
    val out = new PrintWriter(directory + "/" + filename)
    out.print(text)
    out.close()
  }

  //
  // Writes timestamped output file to 'runs' subdirectory beneath specified
  // output dir.  Format of file name is, e.g. for timestamp
  // "2011-10-24-105759-563", "run-2011-10-24-105759-563.xml".
  //
  // We write the file piece-by-piece directly, instead of creating a string
  // and writing that, 
  //
  def writeRunFile(event: Event, thisRunFile: File) {
    index = 0
    var suiteRecord: SuiteRecord = null
    val stack = new Stack[SuiteRecord]
    val pw =
      new PrintWriter(
        new BufferedOutputStream(
          new FileOutputStream(thisRunFile), BufferSize))

    //
    // Formats  element of output xml.
    //
    def formatSummary(event: Event): String = {
      val (summaryOption, durationOption) =
        event match {
          case e: RunCompleted => (e.summary, e.duration)
          case e: RunAborted   => (e.summary, e.duration)
          case e: RunStopped   => (e.summary, e.duration)
          case _ => unexpectedEvent(event); (None, None)
        }

      val summary  = summaryOption.getOrElse(Summary(0, 0, 0, 0, 0, 0, 0))
      val duration = durationOption.getOrElse(0)

      "\n"
    }

    //
    // Closes out a SuiteRecord.  Gets called upon receipt of a
    // SuiteCompleted or SuiteAborted event.
    //
    // If the suite being closed is nested within another suite, its
    // completed record is added to the record of the suite it is nested
    // in.  Otherwise its xml is written to the output file.
    //
    def endSuite(e: Event) {
      suiteRecord.addEndEvent(e)

      val prevRecord = stack.pop()

      if (prevRecord != null)
        prevRecord.addNestedElement(suiteRecord)
      else
        pw.print(suiteRecord.toXml)

      suiteRecord = prevRecord
    }

    //
    // writeRunFile main
    //
    pw.println("")
    pw.print(formatSummary(event))

    for (event <- events.sorted) {
      event match {
        case e: SuiteStarting  =>
          stack.push(suiteRecord)
          suiteRecord = new SuiteRecord(e)
          
        case e: TestStarting   => suiteRecord.addNestedElement(e)
        case e: TestSucceeded  => suiteRecord.addNestedElement(e)
        case e: TestIgnored    => suiteRecord.addNestedElement(e)
        case e: TestFailed     => suiteRecord.addNestedElement(e)
        case e: TestPending    => suiteRecord.addNestedElement(e)
        case e: TestCanceled   => suiteRecord.addNestedElement(e)

        case e: SuiteCompleted => endSuite(e)
        case e: SuiteAborted   => endSuite(e)

        case e: RunStarting    => unexpectedEvent(e)
        case e: RunCompleted   => unexpectedEvent(e)
        case e: RunStopped     => unexpectedEvent(e)
        case e: RunAborted     => unexpectedEvent(e)

        case e: InfoProvided   => unexpectedEvent(e)
        case e: ScopeOpened    => unexpectedEvent(e)
        case e: ScopeClosed    => unexpectedEvent(e)
        case e: MarkupProvided => unexpectedEvent(e)
      }
    }
    pw.println("")
    pw.flush()
    pw.close()
  }

  //
  // Generates xml for a TestIgnored event.
  //
  def formatTestIgnored(event: TestIgnored): String = {
    "\n"
  }

  //
  // Extracts message from specified formatter if there is one, otherwise
  // returns test name.
  //
  def testMessage(testName: String, formatter: Option[Formatter]): String = {
    val message =
      formatter match {
        case Some(IndentedText(_, rawText, _)) => rawText
        case _ => testName
      }
    escape(message)
  }

  //
  // Class that aggregates events that make up a suite.
  //
  // Holds all the events encountered from SuiteStarting through its
  // corresponding end event (e.g. SuiteCompleted).  Once the end event
  // is received, this class's toXml method can be called to generate the
  // complete xml string for the  element.
  //
  class SuiteRecord(startEvent: SuiteStarting) {
    var nestedElements = List[Any]()
    var endEvent: Event = null

    //
    // Adds either an Event or a nested SuiteRecord to this object's
    // list of elements.
    //
    def addNestedElement(element: Any) {
      nestedElements ::= element
    }

    //
    // Adds suite closing event (SuiteCompleted or SuiteAborted) to the
    // object.
    //
    def addEndEvent(event: Event) {
      def isEndEvent(e: Event): Boolean = {
        e match {
          case _: SuiteCompleted => true
          case _: SuiteAborted   => true
          case _ => false
        }
      }

      require(endEvent == null)
      require(isEndEvent(event))

      endEvent = event
    }

    //
    // Generates value to be used in  element's 'result' attribute.
    //
    def result: String = {
      endEvent match {
        case _: SuiteCompleted => "completed"
        case _: SuiteAborted   => "aborted"
        case _ => unexpectedEvent(endEvent); ""
      }
    }

    //
    // Generates xml string representation of SuiteRecord object.
    //
    def toXml: String = {
      val buf = new StringBuilder
      var testRecord: TestRecord = null

      //
      // Generates opening  element
      //
      def formatStartOfSuite: String = {
        val duration = endEvent.timeStamp - startEvent.timeStamp
        "\n" +
        "\n"
      }

      //
      // Indicates whether a test record is currently open during
      // event processing.
      //
      def inATest: Boolean =
        (testRecord != null) && (testRecord.endEvent == null)

      //
      // toXml main
      //
      if (startEvent.suiteName != "DiscoverySuite")
        buf.append(formatStartOfSuite)

      for (element <- nestedElements.reverse) {
        if (inATest) {
          testRecord.addEvent(element.asInstanceOf[Event])

          if (testRecord.isComplete)
            buf.append(testRecord.toXml)
        }
        else {
          element match {
            case e: TestIgnored    => buf.append(formatTestIgnored(e))
            case e: SuiteRecord    => buf.append(e.toXml)
            case e: TestStarting   => testRecord = new TestRecord(e)
            case _ =>
              throw new RuntimeException("unexpected [" + element + "]")
          }
        }
      }
      if (startEvent.suiteName != "DiscoverySuite")
        buf.append("\n")

      buf.toString
    }
  }

  //
  // Class that aggregates events that make up a test.
  //
  // Holds all the events encountered from TestStarting through its
  // corresponding end event (e.g. TestSucceeded).  Once the end event
  // is received, this class's toXml method can be called to generate
  // the complete xml string for the  element.
  //
  // (We no longer record info-provided or markup events for tests,
  // so nested events within the TestRecord have been removed.)
  //
  class TestRecord(startEvent: TestStarting) {
    var endEvent: Event = null

    //
    // Adds specified event to object's list of nested events.
    //
    def addEvent(event: Event) {
      def isEndEvent: Boolean = {
        event match {
          case _: TestSucceeded => true
          case _: TestFailed => true
          case _: TestPending => true
          case _: TestCanceled => true
          case _ => false
        }
      }

      if (isEndEvent)
        endEvent = event
      else
        unexpectedEvent(event)
    }

    //
    // Indicates whether an end event has been received yet for this
    // record.
    //
    def isComplete: Boolean = (endEvent != null)

    //
    // Generates value for use as 'result' attribute of  element.
    //
    def result: String = {
      endEvent match {
        case _: TestSucceeded => "succeeded"
        case _: TestFailed    => "failed"
        case _: TestPending   => "pending"
        case _: TestCanceled  => "canceled"
        case _ => unexpectedEvent(endEvent); ""
      }
    }

    object Duration {
      def unapply(event: Event): Option[Long] =
        event match {
          case TestSucceeded(_, _, _, _, _, _, _, duration, _, _, _, _, _, _)
            => duration
          case TestFailed(_, _, _, _, _, _, _, _, _, duration, _, _, _, _, _, _) 
            => duration
          case TestPending(_, _, _, _, _, _, _, duration, _, _, _, _, _) 
            => duration
          case TestCanceled(_, _, _, _, _, _, _, _, _, duration, _, _, _, _, _) 
            => duration
          case _ => None
        }
    }

    //
    // Generates initial  element of object's xml.
    //
    def formatTestStart: String = {
      val duration = 
        endEvent match {
          case Duration(d) => d
          case _ => endEvent.timeStamp - startEvent.timeStamp
        }

      "\n"
    }

    //
    // Generates  xml for a test failure.
    //
    def formatException(event: TestFailed): String = {
      val buf = new StringBuilder
      var depth = -1

      def nextDepth: Int = {
        depth += 1
        depth
      }

      buf.append("\n")
      
      if (event.throwable.isDefined) {
        val throwable = event.throwable.get
        val stackTrace = throwable.getStackTrace
        require(stackTrace.size > 0)

        buf.append("" + event.message + "\n")

        if (throwable.isInstanceOf[StackDepthException]) {
          val sde = throwable.asInstanceOf[StackDepthException]

          if (sde.failedCodeFileName.isDefined &&
              sde.failedCodeLineNumber.isDefined)
          {
            buf.append(
              "\n" +
              "" + sde.failedCodeStackDepth + "\n" +
              "" + sde.failedCodeFileName.get + "\n" +
              "" +
                sde.failedCodeLineNumber.get +
              "\n" +
              "\n")
          }
        }

        buf.append("\n")
        for (frame <- stackTrace) {
          buf.append(
            "" +
              frame.getClassName + "(" + frame.getFileName + ":" +
              frame.getLineNumber + ")" +
            "\n")
        }
        buf.append("\n")
      }
      buf.append("\n")

      buf.toString
    }

    //
    // Generates xml string representation of object.
    //
    def toXml: String = {
      val buf = new StringBuilder

      if (endEvent == null)
        throw new IllegalStateException("toXml called without endEvent")

      buf.append(formatTestStart)

      if (endEvent.isInstanceOf[TestFailed])
        buf.append(formatException(endEvent.asInstanceOf[TestFailed]))

      buf.append("\n")

      buf.toString
    }
  }
}





© 2015 - 2024 Weber Informatics LLC | Privacy Policy