Scala.js version of the sbt testing interface that provides a standardized API for test frameworks to integrate with SBT and run tests in a Scala.js (JavaScript) environment
—
Thread-safe logging interface supporting multiple log levels and ANSI color codes for user-facing messages during test execution.
Thread-safe logging interface for providing feedback to users during test runs.
/**
* A logger through which to provide feedback to the user about a run.
* The difference between event handler and logger:
* - EventHandler: for events consumed by client software
* - Logger: for messages consumed by client user (humans)
*
* Implementations of this interface must be thread-safe.
*/
trait Logger {
/**
* True if ANSI color codes are understood by this instance.
* @return whether ANSI color codes are supported
*/
def ansiCodesSupported(): Boolean
/**
* Provide an error message.
* @param msg the error message
*/
def error(msg: String): Unit
/**
* Provide a warning message.
* @param msg the warning message
*/
def warn(msg: String): Unit
/**
* Provide an info message.
* @param msg the info message
*/
def info(msg: String): Unit
/**
* Provide a debug message.
* @param msg the debug message
*/
def debug(msg: String): Unit
/**
* Provide a stack trace.
* @param t the Throwable containing the stack trace being logged
*/
def trace(t: Throwable): Unit
}Usage Examples:
// Basic logger implementation
class ConsoleLogger(colorSupport: Boolean = true) extends Logger {
def ansiCodesSupported(): Boolean = colorSupport
def error(msg: String): Unit = {
val colored = if (ansiCodesSupported()) s"\u001B[31m[ERROR]\u001B[0m $msg" else s"[ERROR] $msg"
System.err.println(colored)
}
def warn(msg: String): Unit = {
val colored = if (ansiCodesSupported()) s"\u001B[33m[WARN]\u001B[0m $msg" else s"[WARN] $msg"
System.err.println(colored)
}
def info(msg: String): Unit = {
val colored = if (ansiCodesSupported()) s"\u001B[32m[INFO]\u001B[0m $msg" else s"[INFO] $msg"
println(colored)
}
def debug(msg: String): Unit = {
val colored = if (ansiCodesSupported()) s"\u001B[36m[DEBUG]\u001B[0m $msg" else s"[DEBUG] $msg"
println(colored)
}
def trace(t: Throwable): Unit = {
if (ansiCodesSupported()) {
System.err.println(s"\u001B[31m${t.getClass.getSimpleName}: ${t.getMessage}\u001B[0m")
} else {
System.err.println(s"${t.getClass.getSimpleName}: ${t.getMessage}")
}
t.printStackTrace()
}
}
// Usage in test tasks
class MyTask(taskDef: TaskDef) extends Task {
def execute(eventHandler: EventHandler, loggers: Array[Logger]): Array[Task] = {
val logger = loggers.headOption.getOrElse(new NoOpLogger())
logger.info(s"Starting test suite: ${taskDef.fullyQualifiedName()}")
try {
val testClass = loadTestClass(taskDef.fullyQualifiedName())
logger.debug(s"Loaded test class: ${testClass.getName}")
val results = runTests(testClass)
logger.info(s"Completed ${results.size} tests")
results.foreach { result =>
result match {
case Success(testName) =>
logger.info(s"✓ $testName")
case Failure(testName, cause) =>
logger.error(s"✗ $testName: ${cause.getMessage}")
case Error(testName, cause) =>
logger.error(s"⚠ $testName - ERROR")
logger.trace(cause)
}
}
Array.empty
} catch {
case t: Throwable =>
logger.error(s"Failed to execute test suite: ${t.getMessage}")
logger.trace(t)
Array.empty
}
}
}No-Op Logger:
class NoOpLogger extends Logger {
def ansiCodesSupported(): Boolean = false
def error(msg: String): Unit = {}
def warn(msg: String): Unit = {}
def info(msg: String): Unit = {}
def debug(msg: String): Unit = {}
def trace(t: Throwable): Unit = {}
}File Logger:
import java.io.{FileWriter, PrintWriter}
import java.time.LocalDateTime
class FileLogger(filePath: String) extends Logger with AutoCloseable {
private val writer = new PrintWriter(new FileWriter(filePath, true))
def ansiCodesSupported(): Boolean = false // Files don't support ANSI
private def writeWithTimestamp(level: String, msg: String): Unit = {
val timestamp = LocalDateTime.now().toString
writer.println(s"$timestamp [$level] $msg")
writer.flush()
}
def error(msg: String): Unit = writeWithTimestamp("ERROR", msg)
def warn(msg: String): Unit = writeWithTimestamp("WARN", msg)
def info(msg: String): Unit = writeWithTimestamp("INFO", msg)
def debug(msg: String): Unit = writeWithTimestamp("DEBUG", msg)
def trace(t: Throwable): Unit = {
writeWithTimestamp("ERROR", s"${t.getClass.getSimpleName}: ${t.getMessage}")
t.printStackTrace(writer)
writer.flush()
}
def close(): Unit = writer.close()
}Buffered Logger:
import scala.collection.mutable
class BufferedLogger extends Logger {
private val buffer = mutable.ListBuffer[LogEntry]()
sealed trait LogLevel
case object ErrorLevel extends LogLevel
case object WarnLevel extends LogLevel
case object InfoLevel extends LogLevel
case object DebugLevel extends LogLevel
case object TraceLevel extends LogLevel
case class LogEntry(level: LogLevel, message: String, timestamp: Long = System.currentTimeMillis())
def ansiCodesSupported(): Boolean = false
def error(msg: String): Unit = synchronized {
buffer += LogEntry(ErrorLevel, msg)
}
def warn(msg: String): Unit = synchronized {
buffer += LogEntry(WarnLevel, msg)
}
def info(msg: String): Unit = synchronized {
buffer += LogEntry(InfoLevel, msg)
}
def debug(msg: String): Unit = synchronized {
buffer += LogEntry(DebugLevel, msg)
}
def trace(t: Throwable): Unit = synchronized {
buffer += LogEntry(TraceLevel, s"${t.getClass.getSimpleName}: ${t.getMessage}\n${t.getStackTrace.mkString("\n")}")
}
def getEntries(): List[LogEntry] = synchronized {
buffer.toList
}
def clear(): Unit = synchronized {
buffer.clear()
}
def flushTo(target: Logger): Unit = synchronized {
buffer.foreach { entry =>
entry.level match {
case ErrorLevel => target.error(entry.message)
case WarnLevel => target.warn(entry.message)
case InfoLevel => target.info(entry.message)
case DebugLevel => target.debug(entry.message)
case TraceLevel =>
// Parse back the throwable info for trace
val lines = entry.message.split("\n")
target.error(lines.head)
// Can't recreate full throwable, just log the stack trace
lines.tail.foreach(line => target.error(s" $line"))
}
}
}
}Since Logger implementations must be thread-safe, here are patterns for concurrent access:
class ThreadSafeConsoleLogger extends Logger {
private val lock = new Object()
def ansiCodesSupported(): Boolean = true
def error(msg: String): Unit = lock.synchronized {
System.err.println(s"\u001B[31m[ERROR]\u001B[0m $msg")
}
def warn(msg: String): Unit = lock.synchronized {
System.err.println(s"\u001B[33m[WARN]\u001B[0m $msg")
}
def info(msg: String): Unit = lock.synchronized {
println(s"\u001B[32m[INFO]\u001B[0m $msg")
}
def debug(msg: String): Unit = lock.synchronized {
println(s"\u001B[36m[DEBUG]\u001B[0m $msg")
}
def trace(t: Throwable): Unit = lock.synchronized {
System.err.println(s"\u001B[31m${t.getClass.getSimpleName}: ${t.getMessage}\u001B[0m")
t.printStackTrace()
}
}Combine multiple loggers for different outputs:
class CompositeLogger(loggers: Logger*) extends Logger {
def ansiCodesSupported(): Boolean = loggers.exists(_.ansiCodesSupported())
def error(msg: String): Unit = loggers.foreach(_.error(msg))
def warn(msg: String): Unit = loggers.foreach(_.warn(msg))
def info(msg: String): Unit = loggers.foreach(_.info(msg))
def debug(msg: String): Unit = loggers.foreach(_.debug(msg))
def trace(t: Throwable): Unit = loggers.foreach(_.trace(t))
}
// Usage: log to both console and file
val compositeLogger = new CompositeLogger(
new ConsoleLogger(),
new FileLogger("test-results.log")
)Filter logs by level:
class FilteringLogger(underlying: Logger, minLevel: LogLevel) extends Logger {
sealed trait LogLevel { def priority: Int }
case object Debug extends LogLevel { val priority = 0 }
case object Info extends LogLevel { val priority = 1 }
case object Warn extends LogLevel { val priority = 2 }
case object Error extends LogLevel { val priority = 3 }
def ansiCodesSupported(): Boolean = underlying.ansiCodesSupported()
private def shouldLog(level: LogLevel): Boolean = level.priority >= minLevel.priority
def error(msg: String): Unit = if (shouldLog(Error)) underlying.error(msg)
def warn(msg: String): Unit = if (shouldLog(Warn)) underlying.warn(msg)
def info(msg: String): Unit = if (shouldLog(Info)) underlying.info(msg)
def debug(msg: String): Unit = if (shouldLog(Debug)) underlying.debug(msg)
def trace(t: Throwable): Unit = if (shouldLog(Error)) underlying.trace(t)
}
// Usage: only log warnings and errors
val filteredLogger = new FilteringLogger(new ConsoleLogger(), Warn)When ansiCodesSupported() returns true, use ANSI escape codes for colored output:
object AnsiColors {
val Reset = "\u001B[0m"
val Red = "\u001B[31m" // Error
val Yellow = "\u001B[33m" // Warning
val Green = "\u001B[32m" // Info/Success
val Cyan = "\u001B[36m" // Debug
val Blue = "\u001B[34m" // Info alternative
val Magenta = "\u001B[35m" // Special cases
val Bold = "\u001B[1m"
val Underline = "\u001B[4m"
}
class ColorfulLogger extends Logger {
def ansiCodesSupported(): Boolean = true
def error(msg: String): Unit = {
println(s"${AnsiColors.Red}${AnsiColors.Bold}[ERROR]${AnsiColors.Reset} ${AnsiColors.Red}$msg${AnsiColors.Reset}")
}
def warn(msg: String): Unit = {
println(s"${AnsiColors.Yellow}[WARN]${AnsiColors.Reset} $msg")
}
def info(msg: String): Unit = {
println(s"${AnsiColors.Green}[INFO]${AnsiColors.Reset} $msg")
}
def debug(msg: String): Unit = {
println(s"${AnsiColors.Cyan}[DEBUG]${AnsiColors.Reset} $msg")
}
def trace(t: Throwable): Unit = {
println(s"${AnsiColors.Red}${AnsiColors.Underline}${t.getClass.getSimpleName}${AnsiColors.Reset}${AnsiColors.Red}: ${t.getMessage}${AnsiColors.Reset}")
t.printStackTrace()
}
}Frameworks receive logger arrays and should use them for user communication:
class MyTestFramework extends Framework {
def runner(args: Array[String], remoteArgs: Array[String],
testClassLoader: ClassLoader): Runner = {
new MyRunner(args, remoteArgs, testClassLoader)
}
}
class MyRunner extends Runner {
def tasks(taskDefs: Array[TaskDef]): Array[Task] = {
taskDefs.map(taskDef => new MyTask(taskDef))
}
}
class MyTask(taskDef: TaskDef) extends Task {
def execute(eventHandler: EventHandler, loggers: Array[Logger]): Array[Task] = {
// Use first available logger, fallback to no-op
val logger = loggers.headOption.getOrElse(new NoOpLogger())
logger.info(s"Executing: ${taskDef.fullyQualifiedName()}")
// Execute test logic with logging
executeWithLogging(logger, eventHandler)
}
}Performance:
User Experience:
Thread Safety:
// Good: lazy evaluation and appropriate levels
def executeTest(testName: String, logger: Logger): Unit = {
logger.info(s"Starting test: $testName")
val startTime = System.currentTimeMillis()
try {
// Test execution
val result = runTest(testName)
val duration = System.currentTimeMillis() - startTime
logger.debug(s"Test $testName completed in ${duration}ms")
logger.info(s"✓ $testName")
} catch {
case t: Throwable =>
logger.error(s"✗ $testName failed: ${t.getMessage}")
logger.trace(t)
}
}Install with Tessl CLI
npx tessl i tessl/maven-org-scala-js--scalajs-test-interface