Skip to content
Merged
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import laika.config.LinkValidation
import org.typelevel.sbt.site.TypelevelSiteSettings
import sbt.librarymanagement.Configurations.ScalaDocTool
// https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway
ThisBuild / tlBaseVersion := "0.10" // your current series x.y
ThisBuild / tlBaseVersion := "0.11" // your current series x.y

ThisBuild / startYear := Some(2019)
ThisBuild / licenses := Seq(License.Apache2)
Expand Down
12 changes: 1 addition & 11 deletions docs/features/tagging.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Tagging
=======

Weaver provides some constructs to dynamically tag tests as `ignored` or `cancelled` :
Weaver provides some constructs to dynamically tag tests as `ignored` :

```scala mdoc
import weaver._
Expand All @@ -18,16 +18,6 @@ object TaggingSuite extends SimpleIOSuite {
y <- IO.delay(2)
} yield expect(x == y)
}

test("Another on CI") {
for {
onCI <- IO(sys.env.get("CI").isDefined)
_ <- cancel("not on CI").unlessA(onCI)
x <- IO.delay(1)
y <- IO.delay(2)
} yield expect(x == y)
}

}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ class WeaverRunner(cls: Class[_], @unused dummy: Boolean)
case weaver.TestStatus.Exception =>
notifier.fireTestStarted(description)
notifier.fireTestFailure(failure(outcome))
case Cancelled =>
notifier.fireTestIgnored(description)
case Ignored =>
notifier.fireTestIgnored(description)
}
Expand Down
6 changes: 3 additions & 3 deletions modules/core/shared/src/main/scala/weaver/Comparison.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import munit.diff.Diffs
* A type class used to compare two instances of the same type and construct an
* informative report.
*
* If the comparison succeeds with [[Result.Success]] then no report is printed.
* If the comparison fails with [[Result.Failure]], then the report is printed
* with the test failure.
* If the comparison succeeds with [[Comparison.Result.Success]] then no report
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scaladoc was incorrect.

* is printed. If the comparison fails with [[Comparison.Result.Failure]], then
* the report is printed with the test failure.
*
* The report is generally a diff of the `expected` and `found` values. It may
* use ANSI escape codes to add color.
Expand Down
17 changes: 12 additions & 5 deletions modules/core/shared/src/main/scala/weaver/Expectations.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import cats.data.{ NonEmptyList, Validated, ValidatedNel }
import cats.effect.Sync
import cats.syntax.all._

case class Expectations(run: ValidatedNel[AssertionException, Unit]) {
case class Expectations(run: ValidatedNel[ExpectationFailed, Unit]) {
self =>

/**
Expand Down Expand Up @@ -108,7 +108,7 @@ case class Expectations(run: ValidatedNel[AssertionException, Unit]) {
*/
def traced(loc: SourceLocation): Expectations =
Expectations(run.leftMap(_.map(e =>
e.copy(locations = e.locations.append(loc)))))
e.withLocation(loc))))

}

Expand Down Expand Up @@ -147,8 +147,8 @@ object Expectations {
override def empty: Additive =
Additive(
Expectations(
Validated.invalidNel(new AssertionException("empty",
NonEmptyList.of(loc)))))
Validated.invalidNel(new ExpectationFailed("empty",
NonEmptyList.of(loc)))))

override def combine(x: Additive, y: Additive): Additive =
Additive(
Expand All @@ -167,7 +167,7 @@ object Expectations {
val success: Expectations = Monoid[Expectations].empty

def failure(hint: String)(implicit pos: SourceLocation): Expectations =
Expectations(Validated.invalidNel(new AssertionException(
Expectations(Validated.invalidNel(new ExpectationFailed(
hint,
NonEmptyList.of(pos))))

Expand Down Expand Up @@ -279,6 +279,13 @@ object Expectations {
case Invalid(_) => success
}

/**
* Raises an error that leads to the running test being tagged as "ignored"
*/
def ignore[F[_]: Sync](reason: String)(implicit
pos: SourceLocation): F[Nothing] =
Sync[F].raiseError(new IgnoredException(reason, pos))

implicit class StringOps(str: String) {
def ignore(implicit loc: SourceLocation): TestName =
new TestName(str, loc, Set.empty).ignore
Expand Down
10 changes: 5 additions & 5 deletions modules/core/shared/src/main/scala/weaver/Formatter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ object Formatter {
import Result._

result match {
case Success => withPrefix(green("+ "))
case _: Failure | _: Failures | _: Exception => withPrefix(red("- "))
case _: Cancelled =>
withPrefix(yellow("- ")) + yellow(" !!! CANCELLED !!!")
case _: Ignored => withPrefix(yellow("- ")) + yellow(" !!! IGNORED !!!")
case Success => withPrefix(green("+ "))
case OnlyTagNotAllowedInCI(_) | Failures(_) | Exception(_) =>
withPrefix(red("- "))
case Ignored(_, _) =>
withPrefix(yellow("- ")) + yellow(" !!! IGNORED !!!")
}
}

Expand Down
153 changes: 67 additions & 86 deletions modules/core/shared/src/main/scala/weaver/Result.scala
Original file line number Diff line number Diff line change
@@ -1,57 +1,59 @@
package weaver

import scala.util.Try

import cats.data.NonEmptyList
import cats.data.Validated.{ Invalid, Valid }

sealed trait Result {
private[weaver] sealed trait Result {
def formatted: Option[String]
}

object Result {
private[weaver] object Result {
import Formatter._

def fromAssertion(assertion: Expectations): Result = assertion.run match {
case Valid(_) => Success
case Invalid(failed) =>
Failures(failed.map(ex =>
Result.Failure(ex.message, Some(ex), ex.locations.toList)))
Failures.Failure(ex.message, ex, ex.locations)))
}

case object Success extends Result {
def formatted: Option[String] = None
}

final case class Ignored(reason: Option[String], location: SourceLocation)
final case class Ignored(reason: String, location: SourceLocation)
extends Result {

def formatted: Option[String] = {
reason.map(msg => indent(msg, List(location), Console.YELLOW, TAB2))
Some(formatDescription(reason,
List(location),
Console.YELLOW,
TAB2.prefix))
}
}

final case class Cancelled(reason: Option[String], location: SourceLocation)
final case class Failures(failures: NonEmptyList[Failures.Failure])
extends Result {

def formatted: Option[String] = {
reason.map(msg => indent(msg, List(location), Console.YELLOW, TAB2))
}
}

final case class Failures(failures: NonEmptyList[Failure]) extends Result {

def formatted: Option[String] =
if (failures.size == 1) failures.head.formatted
else {
if (failures.size == 1) {
val failure = failures.head
val formattedMessage = formatDescription(
failure.msg,
failure.locations.toList,
Console.RED,
TAB2.prefix
) + DOUBLE_EOL
Some(formattedMessage)
} else {

val descriptions = failures.zipWithIndex.map {
case (failure, idx) =>
import failure._

formatDescription(
if (msg != null && msg.nonEmpty) msg else "Test failed",
location,
msg,
locations.toList,
Console.RED,
s" [$idx] "
)
Expand All @@ -61,20 +63,29 @@ object Result {
}
}

final case class Failure(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

msg: String,
source: Option[Throwable],
location: List[SourceLocation])
object Failures {
final case class Failure(
msg: String,
source: Throwable,
locations: NonEmptyList[SourceLocation])
}

final case class OnlyTagNotAllowedInCI(
location: SourceLocation)
extends Result {

def formatted: Option[String] =
Some(formatError(msg, source, location, Some(0)))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I walked through the code for formatError for this case and found it was exactly the same as formatDescription.

I think we can reserve formatError for the Exceptioncase, where were have a stack trace we want to render.

def formatted: Option[String] = {
val formattedMessage = formatDescription(
"'Only' tag is not allowed when `isCI=true`",
List(location),
Console.RED,
TAB2.prefix
) + DOUBLE_EOL
Some(formattedMessage)
}
}

final case class Exception(
source: Throwable,
location: Option[SourceLocation])
extends Result {
final case class Exception(source: Throwable) extends Result {

def formatted: Option[String] = {
val description = {
Expand All @@ -85,44 +96,30 @@ object Result {
.fold(className)(m => s"$className: $m")
}

val maxStackFrames = sys.props.get("WEAVER_MAX_STACKFRAMES").flatMap(s =>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we removing this altogether ?

Copy link
Contributor Author

@zainab-ali zainab-ali Sep 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WEAVER_MAX_STACKFRAMES is alse used in the Formatter, although I haven't checked if the code path using it is actually hit.

We're removing maxStackFrames because the Exception class never has a defined location, and so the stackTraceLimit would always be None.

If you're reviewing on GitHub, this is easier to see in the split diff view.

Try(s.trim.toInt).toOption).getOrElse(50)

val stackTraceLimit =
if (location.isDefined) Some(maxStackFrames) else None

Some(formatError(description,
Some(source),
location.toList,
stackTraceLimit))
Some(formatError(description, source))
}
}

val success: Result = Success

def from(error: Throwable): Result = {
error match {
case ex: AssertionException =>
Result.Failure(ex.message, Some(ex), ex.locations.toList)
case ex: ExpectationFailed =>
Failures(NonEmptyList.of(Failures.Failure(
ex.message,
ex,
ex.locations)))
case ex: IgnoredException =>
Result.Ignored(ex.reason, ex.location)
case ex: CanceledException =>
Result.Cancelled(ex.reason, ex.location)
case ex: WeaverException =>
Result.Exception(ex, Some(ex.getLocation))
Ignored(ex.reason, ex.location)
case other =>
Result.Exception(other, None)
Exception(other)
}
}

private def formatError(
msg: String,
source: Option[Throwable],
location: List[SourceLocation],
traceLimit: Option[Int]): String = {
private def formatError(msg: String, source: Throwable): String = {

val stackTrace = source.fold("") { ex =>
val stackTraceLines = TestErrorFormatter.formatStackTrace(ex, traceLimit)
val stackTrace = {
val stackTraceLines = TestErrorFormatter.formatStackTrace(source, None)

def traverseCauses(ex: Throwable): Vector[Throwable] = {
Option(ex.getCause) match {
Expand All @@ -131,24 +128,27 @@ object Result {
}
}

val causes = traverseCauses(ex)
val causes = traverseCauses(source)
val causeStackTraceLines = causes.flatMap { cause =>
Vector(EOL + "Caused by: " + cause.toString + EOL) ++
TestErrorFormatter.formatStackTrace(cause, traceLimit)
TestErrorFormatter.formatStackTrace(cause, None)
}

val errorOutputLines = stackTraceLines ++ causeStackTraceLines

if (errorOutputLines.nonEmpty) {
indent(errorOutputLines.mkString(EOL), Nil, Console.RED, TAB2)
formatDescription(errorOutputLines.mkString(EOL),
Nil,
Console.RED,
TAB2.prefix)
} else ""
}

val formattedMessage = indent(
if (msg != null && msg.nonEmpty) msg else "Test failed",
location,
val formattedMessage = formatDescription(
msg,
Nil,
Console.RED,
TAB2
TAB2.prefix
)

var res = formattedMessage + DOUBLE_EOL
Expand All @@ -164,38 +164,19 @@ object Result {
color: String,
prefix: String): String = {

val footer = locationFooter(location)
val lines = (message.split("\\r?\\n") ++ footer).zipWithIndex.map {
case (line, index) =>
if (index == 0)
color + prefix + line +
location
.map(l => s" (${l.fileRelativePath}:${l.line})")
.mkString("\n")
else
color + prefix + line
}

lines.mkString(EOL) + Console.RESET
}

private def indent(
message: String,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indent function has been removed, and its usages have been replaced with formatDescription.

The functions were practically identical. The only difference was the treatment of the prefix.

  • indent would assume the prefix was whitespace, and so not add it for empty lines.
  • formatDescription would always add the prefix

This change rewrites formatDescription to test if the prefix is whitespace and line is empty, and if so not add the prefix.

location: List[SourceLocation],
color: String,
width: Tabulation): String = {

val footer = locationFooter(location)
val prefixIsWhitespace = prefix.trim.isEmpty
val footer = locationFooter(location)
val lines = (message.split("\\r?\\n") ++ footer).zipWithIndex.map {
case (line, index) =>
val prefix = if (line.trim == "") "" else width.prefix
val linePrefix =
if (prefixIsWhitespace && line.trim.isEmpty) "" else prefix
if (index == 0)
color + prefix + line +
color + linePrefix + line +
location
.map(l => s" (${l.fileRelativePath}:${l.line})")
.mkString("\n")
else
color + prefix + line
color + linePrefix + line
}

lines.mkString(EOL) + Console.RESET
Expand Down
Loading