CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-org-scala-js--scalajs-test-interface

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

Pending
Overview
Eval results
Files

logging.mddocs/

Logging System

Thread-safe logging interface supporting multiple log levels and ANSI color codes for user-facing messages during test execution.

Capabilities

Logger Interface

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
    }
  }
}

Logger Implementations

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"))
      }
    }
  }
}

Thread Safety

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()
  }
}

Logger Composition

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")
)

Logger Filtering

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)

ANSI Color Codes

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()
  }
}

Usage Patterns

Test Framework Integration

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)
  }
}

Best Practices

Performance:

  • Check log level before expensive string operations
  • Use lazy evaluation for debug messages
  • Batch multiple related log messages when possible

User Experience:

  • Provide meaningful, actionable messages
  • Use appropriate log levels (error for failures, info for progress)
  • Include relevant context (test names, timing, counts)
  • Format stack traces clearly

Thread Safety:

  • Always implement thread-safe logging
  • Use proper synchronization or thread-safe data structures
  • Consider performance impact of synchronization
// 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

docs

discovery.md

events.md

execution.md

framework.md

index.md

logging.md

tile.json