From b23b6cfdeae0edcbe3d6f3f33cbdf673150b12ac Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Tue, 29 Jul 2025 07:31:09 -0500 Subject: [PATCH 01/13] WIP on the engine runtime --- build.sbt | 20 +- .../src/main/scala/gs/test/v0/api/Check.scala | 4 +- .../scala/gs/test/v0/api/TestFailure.scala | 6 +- .../gs/test/v0/api/TestGroupDefinition.scala | 3 +- .../gs/test/v0/runtime/SuiteExecution.scala | 1 - .../v0/runtime/engine/EngineConstants.scala | 25 +++ .../test/v0/runtime/engine/EngineStats.scala | 81 ++++++- .../test/v0/runtime/engine/GroupResult.scala | 11 + .../test/v0/runtime/engine/TestEngine.scala | 210 ++++++++++++------ project/build.properties | 2 +- project/plugins.sbt | 4 +- 11 files changed, 275 insertions(+), 92 deletions(-) create mode 100644 modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala diff --git a/build.sbt b/build.sbt index ef4cf06..d1b6ab7 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3: String = "3.5.1" +val scala3: String = "3.7.1" ThisBuild / scalaVersion := scala3 ThisBuild / versionScheme := Some("semver-spec") @@ -10,7 +10,7 @@ ThisBuild / externalResolvers := Seq( ) ThisBuild / licenses := Seq( - "MIT" -> url("https://garrity.co/MIT.html") + "MIT" -> url("https://git.garrity.co/garrity-software/gs-test/LICENSE") ) val noPublishSettings = Seq( @@ -26,25 +26,25 @@ val sharedSettings = Seq( val Deps = new { val Cats = new { - val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.12.0" - val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.5.4" + val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0" + val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3" } val Fs2 = new { - val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.10.2" + val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.12.0" } val Natchez = new { - val Core: ModuleID = "org.tpolecat" %% "natchez-core" % "0.3.6" + val Core: ModuleID = "org.tpolecat" %% "natchez-core" % "0.3.8" } val Gs = new { - val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.3.0" - val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.1" - val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.2.0" + val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.1" + val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.2" + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.1" } - val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.1" + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.1.1" } lazy val testSettings = Seq( diff --git a/modules/api/src/main/scala/gs/test/v0/api/Check.scala b/modules/api/src/main/scala/gs/test/v0/api/Check.scala index 758599e..93efe51 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/Check.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/Check.scala @@ -18,7 +18,7 @@ object Check: */ def apply[A](candidate: A): Check[A] = candidate - extension [A: ClassTag](candidate: Check[A]) + extension [A](candidate: Check[A]) /** @return * The unwrapped value of this [[Check]]. */ @@ -42,6 +42,7 @@ object Check: )( using CanEqual[A, A], + ClassTag[A], SourcePosition ): TestResult = Assertion.IsEqualTo.evaluate(candidate, expected) @@ -67,6 +68,7 @@ object Check: )( using CanEqual[A, A], + ClassTag[A], SourcePosition ): F[TestResult] = Sync[F].delay(isEqualTo(expected)) diff --git a/modules/api/src/main/scala/gs/test/v0/api/TestFailure.scala b/modules/api/src/main/scala/gs/test/v0/api/TestFailure.scala index 35f00e4..dd8de38 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/TestFailure.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/TestFailure.scala @@ -2,7 +2,8 @@ package gs.test.v0.api /** Base trait for all failures recognized by gs-test. */ -sealed trait TestFailure +sealed trait TestFailure: + def message: String object TestFailure: @@ -44,6 +45,7 @@ object TestFailure: */ case class ExceptionThrown( cause: Throwable - ) extends TestFailure + ) extends TestFailure: + override def message: String = cause.getMessage() end TestFailure diff --git a/modules/api/src/main/scala/gs/test/v0/api/TestGroupDefinition.scala b/modules/api/src/main/scala/gs/test/v0/api/TestGroupDefinition.scala index db99eea..69c22da 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/TestGroupDefinition.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/TestGroupDefinition.scala @@ -1,7 +1,6 @@ package gs.test.v0.api import cats.Show -import cats.effect.Async /** Each group is comprised of a list of [[Test]]. This list may be empty. * @@ -18,7 +17,7 @@ import cats.effect.Async * @param tests * The list of tests in this group. */ -final class TestGroupDefinition[F[_]: Async]( +final class TestGroupDefinition[F[_]]( val name: TestGroupDefinition.Name, val documentation: Option[String], val testTags: List[Tag], diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/SuiteExecution.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/SuiteExecution.scala index a6d0531..aa2a29d 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/SuiteExecution.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/SuiteExecution.scala @@ -12,6 +12,5 @@ case class SuiteExecution( countSeen: Long, countSucceeded: Long, countFailed: Long, - countIgnored: Long, executedAt: Instant ) diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala new file mode 100644 index 0000000..7f48a2d --- /dev/null +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala @@ -0,0 +1,25 @@ +package gs.test.v0.runtime.engine + +object EngineConstants: + + object Tracing: + + val RootSpan: String = "test-group" + val BeforeGroup: String = "before-group" + val AfterGroup: String = "after-group" + val BeforeTest: String = "before-test" + val AfterTest: String = "after-test" + val InGroup: String = "in-group" + val TestSpan: String = "test" + + end Tracing + + object MetaData: + + val TestGroupName: String = "test_group_name" + val TestExecutionId: String = "test_execution_id" + val TestName: String = "test_name" + + end MetaData + +end EngineConstants diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala index 9fa5093..da4eb7f 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala @@ -3,32 +3,93 @@ package gs.test.v0.runtime.engine import cats.effect.Async import cats.effect.Ref import cats.syntax.all.* +import gs.test.v0.runtime.TestExecution import java.util.concurrent.TimeUnit import scala.concurrent.duration.FiniteDuration -final class EngineStats[F[_]: Async]( - val overallDuration: Ref[F, FiniteDuration], - val countSeen: Ref[F, Long], - val countSucceeded: Ref[F, Long], - val countFailed: Ref[F, Long], - val countIgnored: Ref[F, Long] -) +/** Statistics for executed tests. Used by the [[TestEngine]]. + * + * @param overallDuration + * Duration of all recorded tests. + * @param countSeen + * Number of tests encountered. + * @param countSucceeded + * Number of tests that succeeded. + * @param countFailed + * Number of tests that failed. + */ +final class EngineStats[F[_]: Async] private ( + overallDuration: Ref[F, FiniteDuration], + countSeen: Ref[F, Long], + countSucceeded: Ref[F, Long], + countFailed: Ref[F, Long] +): + /** @return + * The accumulated duration of test executions. + */ + def duration: F[FiniteDuration] = overallDuration.get + + /** @return + * Number of tests encountered. + */ + def seen: F[Long] = countSeen.get + + /** @return + * Number of tests that succeeded. + */ + def succeeded: F[Long] = countSucceeded.get + + /** @return + * Number of tests that failed. + */ + def failed: F[Long] = countFailed.get + + /** Update the stats based on the results of an entire group. + * + * @param groupResult + * The [[GroupResult]] representing the group. + * @return + * Side-effect which updates statistic values. + */ + def updateForGroup(groupResult: GroupResult): F[Unit] = + for + _ <- overallDuration.update(base => base + groupResult.duration) + _ <- groupResult.testExecutions.map(updateForTest).sequence + yield () + + /** Update the stats based on the results of a single test. + * + * @param testExecution + * The [[TestExecution]] representing the test. + * @return + * Side-effect which updates statistic values. + */ + def updateForTest(testExecution: TestExecution): F[Unit] = + for + _ <- countSeen.update(_ + 1L) + _ <- testExecution.result match + case Left(_) => countFailed.update(_ + 1L) + case Right(_) => countSucceeded.update(_ + 1L) + yield () object EngineStats: + /** Initialize a new [[EngineStats]] instance with all values set to 0. + * + * @return + * The new [[EngineStats]] instance. + */ def initialize[F[_]: Async]: F[EngineStats[F]] = for duration <- Ref.of(FiniteDuration(0L, TimeUnit.NANOSECONDS)) seen <- Ref.of(0L) succeeded <- Ref.of(0L) failed <- Ref.of(0L) - ignored <- Ref.of(0L) yield new EngineStats[F]( overallDuration = duration, countSeen = seen, countSucceeded = succeeded, - countFailed = failed, - countIgnored = ignored + countFailed = failed ) end EngineStats diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala index f21efd8..dbd207a 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala @@ -4,6 +4,17 @@ import gs.test.v0.api.TestGroupDefinition import gs.test.v0.runtime.TestExecution import scala.concurrent.duration.FiniteDuration +/** Represents the results of executing an entire group of tests. + * + * @param name + * The name of the executed group. + * @param documentation + * The documentation for the group. + * @param duration + * The overall duration of execution. + * @param testExecutions + * List of test results. + */ final class GroupResult( val name: TestGroupDefinition.Name, val documentation: Option[String], diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index 2157d1c..9d9246e 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -15,6 +15,40 @@ import java.time.Instant import natchez.EntryPoint import natchez.Span +/** This class is responsible for executing suites of tests. + * + * ## How Execution Works + * + * Test execution starts at the group level, via a stream of + * [[TestGroupDefinition]]. Each group of tests is executed concurrently based + * on the [[EngineConfiguration]]. + * + * ### Executing a Single Group + * + * Each [[TestGroupDefinition]] is executed by executing, in order: + * + * - The `beforeGroup` effect. + * - Each test (configurable concurrency). + * - The `afterGroup` effect. + * + * The before/after effects are described at the group level. + * + * ### Executing a Single Test + * + * Each [[TestDefinition]] is executed by executing, in order: + * + * - The `beforeEachTest` effect. + * - The test code. + * - The `afterEachTest` effect. + * + * The before/after effects are described at the group level. + * + * ## OpenTelemetry Support + * + * Each [[SuiteExecution]] produces a single trace per [[TestGroupDefinition]]. + * This means that each group has a Trace ID and a tree of execution, with one + * span per test. + */ final class TestEngine[F[_]: Async]( val configuration: EngineConfiguration, timing: Timing[F], @@ -28,89 +62,73 @@ final class TestEngine[F[_]: Async]( suite: TestSuite, tests: fs2.Stream[F, TestGroupDefinition[F]] ): F[SuiteExecution] = + // TODO: REPORTING -- need interface for executedAt <- Async[F].delay(Instant.now(clock)) stats <- EngineStats.initialize[F] + // TODO: Just do telemetry for the whole damn thing. _ <- tests .mapAsync(configuration.groupConcurrency.toInt())(runGroup) - .evalTap(updateGroupStats) - .evalTap(reportGroup) - .flatMap(groupResult => fs2.Stream.emits(groupResult.testExecutions)) - .evalTap(updateTestStats) - .evalMap(reportTestExecution) + .evalTap(stats.updateForGroup) .compile .drain - overallDuration <- stats.overallDuration.get - countSeen <- stats.countSeen.get - countSucceeded <- stats.countSucceeded.get - countFailed <- stats.countFailed.get - countIgnored <- stats.countIgnored.get - yield SuiteExecution( - id = suiteExecutionIdGenerator.next(), - name = suite.name, - documentation = suite.documentation, - duration = overallDuration, - countSeen = countSeen, - countSucceeded = countSucceeded, - countFailed = countFailed, - countIgnored = countIgnored, - executedAt = executedAt - ) - - private def updateGroupStats(groupResult: GroupResult): F[Unit] = ??? - - private def updateTestStats(testExecution: TestExecution): F[Unit] = ??? - - private def reportGroup(groupResult: GroupResult): F[Unit] = ??? - - private def reportTestExecution(testExecution: TestExecution): F[Unit] = ??? - - private def runSpan[A]( - name: String, - root: Span[F], - f: F[A] - ): F[A] = - root.span(name).use(_ => f) + suiteExecution <- makeSuiteExecution(suite, stats, executedAt) + yield suiteExecution def runGroup( group: TestGroupDefinition[F] ): F[GroupResult] = - entryPoint.root("test-group").use { rootSpan => + entryPoint.root(EngineConstants.Tracing.RootSpan).use { rootSpan => for - _ <- rootSpan.put("test_group_name" -> group.name.show) + // Augment the span with all group-level metadata. + _ <- rootSpan + .put(EngineConstants.MetaData.TestGroupName -> group.name.show) + + // Start the timer for the entire group. + timer <- timing.start() + + // Run the before-group logic (in its own span). _ <- runSpan( - "before-group", + EngineConstants.Tracing.BeforeGroup, rootSpan, group.beforeGroup.getOrElse(Async[F].unit) ) - stream <- executeGroupTests(group, rootSpan) + + // Execute all tests within this group. + testExecutions <- executeGroupTests(group, rootSpan) + + // Run the after-group logic (in its own span). _ <- runSpan( - "after-group", + EngineConstants.Tracing.AfterGroup, rootSpan, group.afterGroup.getOrElse(Async[F].unit) ) - yield stream + + // Calculate the overall elapsed time for this group. + elapsed <- timer.checkpoint() + yield new GroupResult( + name = group.name, + documentation = group.documentation, + duration = elapsed.duration, + testExecutions = testExecutions + ) } private def executeGroupTests( group: TestGroupDefinition[F], rootSpan: Span[F] - ): F[GroupResult] = - rootSpan.span("group").use { groupSpan => + ): F[List[TestExecution]] = + rootSpan.span(EngineConstants.Tracing.InGroup).use { groupSpan => for + // If, for some reason, the generated span has no Trace ID, this will + // throw an exception. traceId <- rootSpan.traceId.map(parseTraceId) - timer <- timing.start() - executions <- streamGroupTests(group, groupSpan).compile.toList - elapsed <- timer.checkpoint() - yield new GroupResult( - name = group.name, - documentation = group.documentation, - duration = elapsed.duration, - testExecutions = executions - ) + executions <- streamGroupTests(traceId, group, groupSpan).compile.toList + yield executions } private def streamGroupTests( + traceId: UUID, group: TestGroupDefinition[F], groupSpan: Span[F] ): fs2.Stream[F, TestExecution] = @@ -118,13 +136,33 @@ final class TestEngine[F[_]: Async]( .emits(group.tests) .mapAsync(configuration.testConcurrency.toInt()) { test => for + // Generate a unique TestExecutionId for this execution. testExecutionId <- Async[F].delay( TestExecution.Id(testExecutionIdGenerator.next()) ) - timer <- timing.start() - _ <- group.beforeEachTest.getOrElse(Async[F].unit) - result <- runSingleTest(testExecutionId, test, groupSpan) - _ <- group.afterEachTest.getOrElse(Async[F].unit) + + // Start the timer for the test, including the before/after + // components. + timer <- timing.start() + + // Run the before-test logic (in its own span). + _ <- runSpan( + EngineConstants.Tracing.BeforeTest, + groupSpan, + group.beforeEachTest.getOrElse(Async[F].unit) + ) + + // Run the test (in its own span). + result <- runSingleTest(testExecutionId, test, groupSpan) + + // Run the after-test logic (in its own span). + _ <- runSpan( + EngineConstants.Tracing.AfterTest, + groupSpan, + group.afterEachTest.getOrElse(Async[F].unit) + ) + + // Calculate the overall elapsed time for this single test. elapsed <- timer.checkpoint() yield TestExecution( id = testExecutionId, @@ -133,7 +171,8 @@ final class TestEngine[F[_]: Async]( tags = test.tags, markers = test.markers, result = result, - traceId = ???, + // TODO TraceID isn't that useful here, need SpanID + traceId = traceId, sourcePosition = test.sourcePosition, duration = elapsed.duration ) @@ -144,13 +183,58 @@ final class TestEngine[F[_]: Async]( test: TestDefinition[F], groupSpan: Span[F] ): F[Either[TestFailure, Any]] = - groupSpan.span("test").use { span => + groupSpan.span(EngineConstants.Tracing.TestSpan).use { span => for - // TODO: Constants - _ <- span.put("test_execution_id" -> testExecutionId.show) - _ <- span.put("test_name" -> test.name.show) + _ <- span + .put(EngineConstants.MetaData.TestExecutionId -> testExecutionId.show) + _ <- span.put(EngineConstants.MetaData.TestName -> test.name.show) result <- test.unitOfWork.doWork(span) yield result } - private def parseTraceId(candidate: Option[String]): UUID = ??? + private def parseTraceId(candidate: Option[String]): UUID = + candidate.flatMap(UUID.parse) match + case Some(traceId) => traceId + case None => + throw new IllegalArgumentException( + "Created a span with an invalid Trace ID: " + candidate + ) + + private def makeSuiteExecution( + suite: TestSuite, + stats: EngineStats[F], + executedAt: Instant + ): F[SuiteExecution] = + for + overallDuration <- stats.duration + countSeen <- stats.seen + countSucceeded <- stats.succeeded + countFailed <- stats.failed + yield SuiteExecution( + id = suiteExecutionIdGenerator.next(), + name = suite.name, + documentation = suite.documentation, + duration = overallDuration, + countSeen = countSeen, + countSucceeded = countSucceeded, + countFailed = countFailed, + executedAt = executedAt + ) + + /** Run some effect as a child span for some root span. + * + * @param name + * The name of the span. + * @param root + * The root span. + * @param f + * The effect to execute in a child span. + * @return + * The contextualized effect. + */ + private def runSpan[A]( + name: String, + root: Span[F], + f: F[A] + ): F[A] = + root.span(name).use(_ => f) diff --git a/project/build.properties b/project/build.properties index 0b699c3..bbb0b60 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.2 +sbt.version=1.11.2 diff --git a/project/plugins.sbt b/project/plugins.sbt index eb382dc..83e5dc1 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -28,6 +28,6 @@ externalResolvers := Seq( "Garrity Software Releases" at "https://maven.garrity.co/gs" ) -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.1.0") -addSbtPlugin("gs" % "sbt-garrity-software" % "0.4.0") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.1") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.6.0") addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0") -- 2.43.0 From 4d0bef4d4b5d6d37d07d7007c4b9270d380e740e Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Wed, 30 Jul 2025 21:45:35 -0500 Subject: [PATCH 02/13] Minor shuffling of types to prepare for reporting. --- build.sbt | 2 +- .../src/main/scala/gs/test/v0/api}/SuiteExecution.scala | 2 +- .../src/main/scala/gs/test/v0/api}/TestExecution.scala | 7 +------ .../scala/gs/test/v0/runtime/engine/EngineResult.scala | 7 ------- .../main/scala/gs/test/v0/runtime/engine/EngineStats.scala | 2 +- .../main/scala/gs/test/v0/runtime/engine/GroupResult.scala | 2 +- .../main/scala/gs/test/v0/runtime/engine/TestEngine.scala | 4 ++-- 7 files changed, 7 insertions(+), 19 deletions(-) rename modules/{runtime/src/main/scala/gs/test/v0/runtime => api/src/main/scala/gs/test/v0/api}/SuiteExecution.scala (91%) rename modules/{runtime/src/main/scala/gs/test/v0/runtime => api/src/main/scala/gs/test/v0/api}/TestExecution.scala (91%) delete mode 100644 modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineResult.scala diff --git a/build.sbt b/build.sbt index d1b6ab7..80f8dec 100644 --- a/build.sbt +++ b/build.sbt @@ -89,6 +89,7 @@ lazy val api = project ) .settings( libraryDependencies ++= Seq( + Deps.Gs.Uuid, Deps.Cats.Core, Deps.Cats.Effect, Deps.Natchez.Core @@ -106,7 +107,6 @@ lazy val runtime = project ) .settings( libraryDependencies ++= Seq( - Deps.Gs.Uuid, Deps.Gs.Timing, Deps.Cats.Core, Deps.Cats.Effect, diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/SuiteExecution.scala b/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala similarity index 91% rename from modules/runtime/src/main/scala/gs/test/v0/runtime/SuiteExecution.scala rename to modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala index aa2a29d..64a75a6 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/SuiteExecution.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala @@ -1,4 +1,4 @@ -package gs.test.v0.runtime +package gs.test.v0.api import gs.uuid.v0.UUID import java.time.Instant diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/TestExecution.scala b/modules/api/src/main/scala/gs/test/v0/api/TestExecution.scala similarity index 91% rename from modules/runtime/src/main/scala/gs/test/v0/runtime/TestExecution.scala rename to modules/api/src/main/scala/gs/test/v0/api/TestExecution.scala index 2d779d0..cca8546 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/TestExecution.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/TestExecution.scala @@ -1,11 +1,6 @@ -package gs.test.v0.runtime +package gs.test.v0.api import cats.Show -import gs.test.v0.api.Marker -import gs.test.v0.api.PermanentId -import gs.test.v0.api.SourcePosition -import gs.test.v0.api.Tag -import gs.test.v0.api.TestFailure import gs.uuid.v0.UUID import scala.concurrent.duration.FiniteDuration diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineResult.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineResult.scala deleted file mode 100644 index 2b19bdc..0000000 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineResult.scala +++ /dev/null @@ -1,7 +0,0 @@ -package gs.test.v0.runtime.engine - -import gs.test.v0.runtime.SuiteExecution - -final class EngineResult( - val suiteExecution: SuiteExecution -) diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala index da4eb7f..e7a5b5b 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala @@ -3,7 +3,7 @@ package gs.test.v0.runtime.engine import cats.effect.Async import cats.effect.Ref import cats.syntax.all.* -import gs.test.v0.runtime.TestExecution +import gs.test.v0.api.TestExecution import java.util.concurrent.TimeUnit import scala.concurrent.duration.FiniteDuration diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala index dbd207a..e306f77 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala @@ -1,7 +1,7 @@ package gs.test.v0.runtime.engine +import gs.test.v0.api.TestExecution import gs.test.v0.api.TestGroupDefinition -import gs.test.v0.runtime.TestExecution import scala.concurrent.duration.FiniteDuration /** Represents the results of executing an entire group of tests. diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index 9d9246e..7239cfd 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -2,12 +2,12 @@ package gs.test.v0.runtime.engine import cats.effect.Async import cats.syntax.all.* +import gs.test.v0.api.SuiteExecution import gs.test.v0.api.TestDefinition +import gs.test.v0.api.TestExecution import gs.test.v0.api.TestFailure import gs.test.v0.api.TestGroupDefinition import gs.test.v0.api.TestSuite -import gs.test.v0.runtime.SuiteExecution -import gs.test.v0.runtime.TestExecution import gs.timing.v0.Timing import gs.uuid.v0.UUID import java.time.Clock -- 2.43.0 From 2b905d3fb2ff5585f52cea97f189cd9f027a81e6 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Wed, 30 Jul 2025 22:21:02 -0500 Subject: [PATCH 03/13] Working on updating models and defining the reporting module. --- build.sbt | 17 +++++- .../scala/gs/test/v0/api}/GroupResult.scala | 13 +++-- .../scala/gs/test/v0/api/SuiteExecution.scala | 22 ++++++-- .../scala/gs/test/v0/reporting/Reporter.scala | 45 ++++++++++++++++ .../test/v0/runtime/engine/EngineStats.scala | 37 +++++++------ .../test/v0/runtime/engine/TestEngine.scala | 52 ++++++++++++++++--- 6 files changed, 157 insertions(+), 29 deletions(-) rename modules/{runtime/src/main/scala/gs/test/v0/runtime/engine => api/src/main/scala/gs/test/v0/api}/GroupResult.scala (68%) create mode 100644 modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala diff --git a/build.sbt b/build.sbt index 80f8dec..e67043e 100644 --- a/build.sbt +++ b/build.sbt @@ -96,10 +96,25 @@ lazy val api = project ) ) +lazy val reporting = project + .in(file("modules/reporting")) + .dependsOn(`test-support` % "test->test") + .dependsOn(api) + .settings(sharedSettings) + .settings(testSettings) + .settings( + name := s"${gsProjectName.value}-reporting-v${semVerMajor.value}" + ) + .settings( + libraryDependencies ++= Seq( + Deps.Fs2.Core + ) + ) + lazy val runtime = project .in(file("modules/runtime")) .dependsOn(`test-support` % "test->test") - .dependsOn(api) + .dependsOn(api, reporting) .settings(sharedSettings) .settings(testSettings) .settings( diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala b/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala similarity index 68% rename from modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala rename to modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala index e306f77..6731c5b 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/GroupResult.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala @@ -1,7 +1,5 @@ -package gs.test.v0.runtime.engine +package gs.test.v0.api -import gs.test.v0.api.TestExecution -import gs.test.v0.api.TestGroupDefinition import scala.concurrent.duration.FiniteDuration /** Represents the results of executing an entire group of tests. @@ -12,6 +10,12 @@ import scala.concurrent.duration.FiniteDuration * The documentation for the group. * @param duration * The overall duration of execution. + * @param seen + * The number of tests seen. + * @param passed + * The number of tests which passed. + * @param failed + * The number of tests which failed. * @param testExecutions * List of test results. */ @@ -19,5 +23,8 @@ final class GroupResult( val name: TestGroupDefinition.Name, val documentation: Option[String], val duration: FiniteDuration, + val seen: Long, + val passed: Long, + val failed: Long, val testExecutions: List[TestExecution] ) diff --git a/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala b/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala index 64a75a6..c5f9ed2 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala @@ -4,13 +4,29 @@ import gs.uuid.v0.UUID import java.time.Instant import scala.concurrent.duration.FiniteDuration +/** Describes the overall result of execution a suite of tests. + * + * @param id + * Unique identifier for this execution. + * @param suite + * Suite-level identifiers and metadata. + * @param duration + * Overall amount of time it took to execute the suite. + * @param countSeen + * Overall number of tests seen. + * @param countPassed + * Overall number of passed tests. + * @param countFailed + * Overall number of failed tests. + * @param executedAt + * Timestamp at which this suite was executed. + */ case class SuiteExecution( id: UUID, - name: String, - documentation: Option[String], + testSuite: TestSuite, duration: FiniteDuration, countSeen: Long, - countSucceeded: Long, + countPassed: Long, countFailed: Long, executedAt: Instant ) diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala new file mode 100644 index 0000000..3ff2451 --- /dev/null +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala @@ -0,0 +1,45 @@ +package gs.test.v0.reporting + +import gs.test.v0.api.GroupResult +import gs.test.v0.api.SuiteExecution + +/** Interface for reporters - implementations that report on test results. + * + * Example implementations include writing to standard output or writing to + * some JSON-formatted file. + * + * ## Order of Operations + * + * 1. `beginReporting()` 2. `reportGroup` for each group executed 3. + * `reportSuite` 4. `endReporting()` + */ +trait Reporter[F[_]]: + /** Hook for the beginning of the reporting lifecycle. This allows + * implementations to perform "setup" actions, such as opening a JSON object + * or writing a header. + */ + def beginReporting(): F[Unit] + + /** Report the results of a single group. + * + * @param groupResult + * The [[GroupResult]] that describes results. + * @return + * Side-effect that describes the reporting operation. + */ + def reportGroup(groupResult: GroupResult): F[Unit] + + /** Report the results of an entire suite. + * + * @param suiteExecution + * The [[SuiteExecution]] that describes results. + * @return + * Side-effect that describes the reporting operation. + */ + def reportSuite(suiteExecution: SuiteExecution): F[Unit] + + /** Hook for the end of the reporting lifecycle. This allows implementations + * to perform "finish" actions, such as closing a JSON object or writing a + * footer. + */ + def endReporting(): F[Unit] diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala index e7a5b5b..9e730df 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineStats.scala @@ -13,15 +13,15 @@ import scala.concurrent.duration.FiniteDuration * Duration of all recorded tests. * @param countSeen * Number of tests encountered. - * @param countSucceeded - * Number of tests that succeeded. + * @param countPassed + * Number of tests that passed. * @param countFailed * Number of tests that failed. */ final class EngineStats[F[_]: Async] private ( overallDuration: Ref[F, FiniteDuration], countSeen: Ref[F, Long], - countSucceeded: Ref[F, Long], + countPassed: Ref[F, Long], countFailed: Ref[F, Long] ): /** @return @@ -35,9 +35,9 @@ final class EngineStats[F[_]: Async] private ( def seen: F[Long] = countSeen.get /** @return - * Number of tests that succeeded. + * Number of tests that passed. */ - def succeeded: F[Long] = countSucceeded.get + def passed: F[Long] = countPassed.get /** @return * Number of tests that failed. @@ -46,15 +46,20 @@ final class EngineStats[F[_]: Async] private ( /** Update the stats based on the results of an entire group. * - * @param groupResult - * The [[GroupResult]] representing the group. + * @param duration + * The length of time it took to execute the group. + * @param testExecutions + * The list of all [[TestExecution]] produced by the group. * @return * Side-effect which updates statistic values. */ - def updateForGroup(groupResult: GroupResult): F[Unit] = + def updateForGroup( + duration: FiniteDuration, + testExecutions: List[TestExecution] + ): F[Unit] = for - _ <- overallDuration.update(base => base + groupResult.duration) - _ <- groupResult.testExecutions.map(updateForTest).sequence + _ <- overallDuration.update(base => base + duration) + _ <- testExecutions.map(updateForTest).sequence yield () /** Update the stats based on the results of a single test. @@ -69,7 +74,7 @@ final class EngineStats[F[_]: Async] private ( _ <- countSeen.update(_ + 1L) _ <- testExecution.result match case Left(_) => countFailed.update(_ + 1L) - case Right(_) => countSucceeded.update(_ + 1L) + case Right(_) => countPassed.update(_ + 1L) yield () object EngineStats: @@ -81,14 +86,14 @@ object EngineStats: */ def initialize[F[_]: Async]: F[EngineStats[F]] = for - duration <- Ref.of(FiniteDuration(0L, TimeUnit.NANOSECONDS)) - seen <- Ref.of(0L) - succeeded <- Ref.of(0L) - failed <- Ref.of(0L) + duration <- Ref.of(FiniteDuration(0L, TimeUnit.NANOSECONDS)) + seen <- Ref.of(0L) + passed <- Ref.of(0L) + failed <- Ref.of(0L) yield new EngineStats[F]( overallDuration = duration, countSeen = seen, - countSucceeded = succeeded, + countPassed = passed, countFailed = failed ) diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index 7239cfd..c657c8e 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -2,12 +2,14 @@ package gs.test.v0.runtime.engine import cats.effect.Async import cats.syntax.all.* +import gs.test.v0.api.GroupResult import gs.test.v0.api.SuiteExecution import gs.test.v0.api.TestDefinition import gs.test.v0.api.TestExecution import gs.test.v0.api.TestFailure import gs.test.v0.api.TestGroupDefinition import gs.test.v0.api.TestSuite +import gs.test.v0.reporting.Reporter import gs.timing.v0.Timing import gs.uuid.v0.UUID import java.time.Clock @@ -51,6 +53,7 @@ import natchez.Span */ final class TestEngine[F[_]: Async]( val configuration: EngineConfiguration, + reporter: Reporter[F], timing: Timing[F], suiteExecutionIdGenerator: UUID.Generator, testExecutionIdGenerator: UUID.Generator, @@ -66,13 +69,36 @@ final class TestEngine[F[_]: Async]( for executedAt <- Async[F].delay(Instant.now(clock)) stats <- EngineStats.initialize[F] + + // Start reporting + _ <- reporter.beginReporting() + // TODO: Just do telemetry for the whole damn thing. _ <- tests .mapAsync(configuration.groupConcurrency.toInt())(runGroup) - .evalTap(stats.updateForGroup) + .evalTap(groupResult => + for + // Update the overall statistics based on this group. + _ <- stats.updateForGroup( + duration = groupResult.duration, + testExecutions = groupResult.testExecutions + ) + + // Report group level results for this group. + _ <- reporter.reportGroup(groupResult) + yield () + ) .compile .drain + + // Calculate the final summary of execution at the suite level. suiteExecution <- makeSuiteExecution(suite, stats, executedAt) + + // Report suite level results. + _ <- reporter.reportSuite(suiteExecution) + + // Finish reporting. + _ <- reporter.endReporting() yield suiteExecution def runGroup( @@ -80,6 +106,7 @@ final class TestEngine[F[_]: Async]( ): F[GroupResult] = entryPoint.root(EngineConstants.Tracing.RootSpan).use { rootSpan => for + groupStats <- EngineStats.initialize[F] // Augment the span with all group-level metadata. _ <- rootSpan .put(EngineConstants.MetaData.TestGroupName -> group.name.show) @@ -106,10 +133,24 @@ final class TestEngine[F[_]: Async]( // Calculate the overall elapsed time for this group. elapsed <- timer.checkpoint() + + // Calculate group-level statistics. + _ <- groupStats.updateForGroup( + duration = elapsed.duration, + testExecutions = testExecutions + ) + + // Extract the group statistic values for inclusion in the result.. + seen <- groupStats.seen + passed <- groupStats.passed + failed <- groupStats.failed yield new GroupResult( name = group.name, documentation = group.documentation, duration = elapsed.duration, + seen = seen, + passed = passed, + failed = failed, testExecutions = testExecutions ) } @@ -171,7 +212,7 @@ final class TestEngine[F[_]: Async]( tags = test.tags, markers = test.markers, result = result, - // TODO TraceID isn't that useful here, need SpanID + // TODO: TraceID isn't that useful here, need SpanID traceId = traceId, sourcePosition = test.sourcePosition, duration = elapsed.duration @@ -208,15 +249,14 @@ final class TestEngine[F[_]: Async]( for overallDuration <- stats.duration countSeen <- stats.seen - countSucceeded <- stats.succeeded + countPassed <- stats.passed countFailed <- stats.failed yield SuiteExecution( id = suiteExecutionIdGenerator.next(), - name = suite.name, - documentation = suite.documentation, + testSuite = suite, duration = overallDuration, countSeen = countSeen, - countSucceeded = countSucceeded, + countPassed = countPassed, countFailed = countFailed, executedAt = executedAt ) -- 2.43.0 From c2a155ceabcc6279192e046575352995db95ef98 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Thu, 31 Jul 2025 07:08:00 -0500 Subject: [PATCH 04/13] Minor model refactoring and more base implementations. --- .../scala/gs/test/v0/api/GroupResult.scala | 5 +-- .../gs/test/v0/reporting/NoopReporter.scala | 30 ++++++++++++++ .../v0/reporting/NoopResultFormatter.scala | 19 +++++++++ .../scala/gs/test/v0/reporting/Reporter.scala | 26 +++++++++++-- .../test/v0/reporting/ResultFormatter.scala | 39 +++++++++++++++++++ .../test/v0/runtime/engine/TestEngine.scala | 34 +++++++++------- 6 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala create mode 100644 modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala create mode 100644 modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala diff --git a/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala b/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala index 6731c5b..64abe7d 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala @@ -16,8 +16,6 @@ import scala.concurrent.duration.FiniteDuration * The number of tests which passed. * @param failed * The number of tests which failed. - * @param testExecutions - * List of test results. */ final class GroupResult( val name: TestGroupDefinition.Name, @@ -25,6 +23,5 @@ final class GroupResult( val duration: FiniteDuration, val seen: Long, val passed: Long, - val failed: Long, - val testExecutions: List[TestExecution] + val failed: Long ) diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala new file mode 100644 index 0000000..24742b0 --- /dev/null +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala @@ -0,0 +1,30 @@ +package gs.test.v0.reporting + +import cats.Applicative +import gs.test.v0.api.GroupResult +import gs.test.v0.api.SuiteExecution +import gs.test.v0.api.TestExecution + +/** No-op implementation of [[Reporter]]. + */ +final class NoopReporter[F[_]: Applicative] extends Reporter[F]: + + /** @inheritDocs + */ + override def beginReporting(): F[Unit] = Applicative[F].unit + + /** @inheritDocs + */ + override def reportGroup( + groupResult: GroupResult, + testExecutions: List[TestExecution] + ): F[Unit] = Applicative[F].unit + + /** @inheritDocs + */ + override def reportSuite(suiteExecution: SuiteExecution): F[Unit] = + Applicative[F].unit + + /** @inheritDocs + */ + override def endReporting(): F[Unit] = Applicative[F].unit diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala new file mode 100644 index 0000000..a52578b --- /dev/null +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala @@ -0,0 +1,19 @@ +package gs.test.v0.reporting + +import gs.test.v0.api.GroupResult +import gs.test.v0.api.SuiteExecution +import gs.test.v0.api.TestExecution + +final class NoopResultFormatter extends ResultFormatter: + + /** @inheritDocs + */ + override def formatGroupResult(groupResult: GroupResult): String = "" + + /** @inheritDocs + */ + override def formatTestExecution(testExecution: TestExecution): String = "" + + /** @inheritDocs + */ + override def formatSuiteExecution(suiteExecution: SuiteExecution): String = "" diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala index 3ff2451..e4a8866 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala @@ -1,7 +1,9 @@ package gs.test.v0.reporting +import cats.Applicative import gs.test.v0.api.GroupResult import gs.test.v0.api.SuiteExecution +import gs.test.v0.api.TestExecution /** Interface for reporters - implementations that report on test results. * @@ -10,8 +12,10 @@ import gs.test.v0.api.SuiteExecution * * ## Order of Operations * - * 1. `beginReporting()` 2. `reportGroup` for each group executed 3. - * `reportSuite` 4. `endReporting()` + * - `beginReporting()` + * - `reportGroup` for each group executed + * - `reportSuite` + * - `endReporting()` */ trait Reporter[F[_]]: /** Hook for the beginning of the reporting lifecycle. This allows @@ -23,11 +27,16 @@ trait Reporter[F[_]]: /** Report the results of a single group. * * @param groupResult - * The [[GroupResult]] that describes results. + * The [[GroupResult]] that describes the group level summary. + * @param testExecutions + * The list of [[TestExecution]] describing the result of each test. * @return * Side-effect that describes the reporting operation. */ - def reportGroup(groupResult: GroupResult): F[Unit] + def reportGroup( + groupResult: GroupResult, + testExecutions: List[TestExecution] + ): F[Unit] /** Report the results of an entire suite. * @@ -43,3 +52,12 @@ trait Reporter[F[_]]: * footer. */ def endReporting(): F[Unit] + +object Reporter: + + /** @return + * New instance of the no-op Reporter implementation. + */ + def noop[F[_]: Applicative]: Reporter[F] = new NoopReporter[F] + +end Reporter diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala new file mode 100644 index 0000000..18c3351 --- /dev/null +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala @@ -0,0 +1,39 @@ +package gs.test.v0.reporting + +import gs.test.v0.api.GroupResult +import gs.test.v0.api.SuiteExecution +import gs.test.v0.api.TestExecution + +/** Interface for formatters - implementations that transform test results into + * string representations. + * + * Example implementations include producing plain text or JSON + * representations. + */ +trait ResultFormatter: + /** Format a single [[GroupResult]] as a string. + * + * @param groupResult + * The result to format. + * @return + * The string rendition. + */ + def formatGroupResult(groupResult: GroupResult): String + + /** Format a single [[TestExecution]] as a string. + * + * @param testExecution + * The result to format. + * @return + * The string rendition. + */ + def formatTestExecution(testExecution: TestExecution): String + + /** Format a single [[SuiteExecution]] as a string. + * + * @param suiteExecution + * The result to format. + * @return + * The string rendition. + */ + def formatSuiteExecution(suiteExecution: SuiteExecution): String diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index c657c8e..050cb76 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -76,17 +76,24 @@ final class TestEngine[F[_]: Async]( // TODO: Just do telemetry for the whole damn thing. _ <- tests .mapAsync(configuration.groupConcurrency.toInt())(runGroup) - .evalTap(groupResult => - for - // Update the overall statistics based on this group. - _ <- stats.updateForGroup( - duration = groupResult.duration, - testExecutions = groupResult.testExecutions - ) + .evalTap( + ( + groupResult, + testExecutions + ) => + for + // Update the overall statistics based on this group. + _ <- stats.updateForGroup( + duration = groupResult.duration, + testExecutions = testExecutions + ) - // Report group level results for this group. - _ <- reporter.reportGroup(groupResult) - yield () + // Report group level results for this group. + _ <- reporter.reportGroup( + groupResult = groupResult, + testExecutions = testExecutions + ) + yield () ) .compile .drain @@ -103,7 +110,7 @@ final class TestEngine[F[_]: Async]( def runGroup( group: TestGroupDefinition[F] - ): F[GroupResult] = + ): F[(GroupResult, List[TestExecution])] = entryPoint.root(EngineConstants.Tracing.RootSpan).use { rootSpan => for groupStats <- EngineStats.initialize[F] @@ -150,9 +157,8 @@ final class TestEngine[F[_]: Async]( duration = elapsed.duration, seen = seen, passed = passed, - failed = failed, - testExecutions = testExecutions - ) + failed = failed + ) -> testExecutions } private def executeGroupTests( -- 2.43.0 From fb831ea7d3381af4ae66614109ae90684ccbf35c Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Wed, 13 Aug 2025 22:04:06 -0500 Subject: [PATCH 05/13] More work on the runtime. --- build.sbt | 9 + .../scala/gs/test/v0/api/GroupResult.scala | 3 +- .../scala/gs/test/v0/api/SuiteExecution.scala | 20 +- .../scala/gs/test/v0/api/TestExecution.scala | 21 +- .../gs/test/v0/reporting/NoopReporter.scala | 4 +- .../v0/reporting/NoopResultFormatter.scala | 8 + .../v0/reporting/OutputStreamReporter.scala | 118 ++++++++ .../v0/reporting/PlainResultFormatter.scala | 60 ++++ .../scala/gs/test/v0/reporting/Reporter.scala | 4 +- .../test/v0/reporting/ResultFormatter.scala | 10 + .../runtime/engine/EngineConfiguration.scala | 14 +- .../v0/runtime/engine/EngineConstants.scala | 4 +- .../test/v0/runtime/engine/TestEngine.scala | 260 ++++++++++-------- 13 files changed, 408 insertions(+), 127 deletions(-) create mode 100644 modules/reporting/src/main/scala/gs/test/v0/reporting/OutputStreamReporter.scala create mode 100644 modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala diff --git a/build.sbt b/build.sbt index e67043e..3f0e9a5 100644 --- a/build.sbt +++ b/build.sbt @@ -59,11 +59,14 @@ lazy val `gs-test` = project .aggregate( `test-support`, api, + reporting, runtime ) .settings(noPublishSettings) .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") +/** Internal project used for unit tests. + */ lazy val `test-support` = project .in(file("modules/test-support")) .settings(sharedSettings) @@ -79,6 +82,8 @@ lazy val `test-support` = project ) ) +/** Core API - the only dependency needed to write tests. + */ lazy val api = project .in(file("modules/api")) .dependsOn(`test-support` % "test->test") @@ -96,6 +101,8 @@ lazy val api = project ) ) +/** Reporting API and implementations. + */ lazy val reporting = project .in(file("modules/reporting")) .dependsOn(`test-support` % "test->test") @@ -111,6 +118,8 @@ lazy val reporting = project ) ) +/** Runtime - the dependency needed to _run_ tests. + */ lazy val runtime = project .in(file("modules/runtime")) .dependsOn(`test-support` % "test->test") diff --git a/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala b/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala index 64abe7d..ca244b8 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/GroupResult.scala @@ -24,4 +24,5 @@ final class GroupResult( val seen: Long, val passed: Long, val failed: Long -) +): + def millis: Long = duration.toMillis diff --git a/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala b/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala index c5f9ed2..7cf70b8 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/SuiteExecution.scala @@ -8,15 +8,17 @@ import scala.concurrent.duration.FiniteDuration * * @param id * Unique identifier for this execution. - * @param suite + * @param testSuite * Suite-level identifiers and metadata. + * @param traceId + * The 128-bit trace identifier used for this suite. * @param duration * Overall amount of time it took to execute the suite. - * @param countSeen + * @param seen * Overall number of tests seen. - * @param countPassed + * @param passed * Overall number of passed tests. - * @param countFailed + * @param failed * Overall number of failed tests. * @param executedAt * Timestamp at which this suite was executed. @@ -24,9 +26,11 @@ import scala.concurrent.duration.FiniteDuration case class SuiteExecution( id: UUID, testSuite: TestSuite, + traceId: String, duration: FiniteDuration, - countSeen: Long, - countPassed: Long, - countFailed: Long, + seen: Long, + passed: Long, + failed: Long, executedAt: Instant -) +): + def millis: Long = duration.toMillis diff --git a/modules/api/src/main/scala/gs/test/v0/api/TestExecution.scala b/modules/api/src/main/scala/gs/test/v0/api/TestExecution.scala index cca8546..a9a42d4 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/TestExecution.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/TestExecution.scala @@ -21,8 +21,8 @@ import scala.concurrent.duration.FiniteDuration * Markers for the test that was executed. * @param result * The result of the test. - * @param traceId - * The 128-bit trace identifier used for this test. + * @param spanId + * The 64-bit span identifier used for this test (including before/after). * @param sourcePosition * The position, in source code, of the test that was executed. * @param duration @@ -36,10 +36,23 @@ case class TestExecution( tags: List[Tag], markers: List[Marker], result: Either[TestFailure, Any], - traceId: UUID, + spanId: String, sourcePosition: SourcePosition, duration: FiniteDuration -) +): + + /** @return + * The string "passed" if the test passed, and "failed" otherwise. + */ + def textResult: String = result match { + case Left(_) => "failed" + case Right(_) => "passed" + } + + /** @return + * The duration, in milliseconds, it took this test to execute. + */ + def millis: Long = duration.toMillis object TestExecution: diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala index 24742b0..9119888 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopReporter.scala @@ -11,7 +11,7 @@ final class NoopReporter[F[_]: Applicative] extends Reporter[F]: /** @inheritDocs */ - override def beginReporting(): F[Unit] = Applicative[F].unit + override def startReport(): F[Unit] = Applicative[F].unit /** @inheritDocs */ @@ -27,4 +27,4 @@ final class NoopReporter[F[_]: Applicative] extends Reporter[F]: /** @inheritDocs */ - override def endReporting(): F[Unit] = Applicative[F].unit + override def endReport(): F[Unit] = Applicative[F].unit diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala index a52578b..3f0eaa0 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/NoopResultFormatter.scala @@ -6,6 +6,14 @@ import gs.test.v0.api.TestExecution final class NoopResultFormatter extends ResultFormatter: + /** @inheritDocs + */ + override def prefix: String = "" + + /** @inheritDocs + */ + override def suffix: String = "" + /** @inheritDocs */ override def formatGroupResult(groupResult: GroupResult): String = "" diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/OutputStreamReporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/OutputStreamReporter.scala new file mode 100644 index 0000000..c31a4e5 --- /dev/null +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/OutputStreamReporter.scala @@ -0,0 +1,118 @@ +package gs.test.v0.reporting + +import cats.effect.Async +import cats.effect.Concurrent +import cats.effect.Resource +import cats.effect.kernel.Fiber +import cats.effect.std.Queue +import cats.effect.syntax.all.* +import cats.syntax.all.* +import fs2.text +import gs.test.v0.api.GroupResult +import gs.test.v0.api.SuiteExecution +import gs.test.v0.api.TestExecution +import java.io.OutputStream + +/** Implementation of [[Reporter]] that writes bytes to an `OutputStream`. + * + * @param formatter + * The [[ResultFormatter]] used to render test results. + * @param state + * The internal state of the reporter. + */ +final class OutputStreamReporter[F[_]: Async] private ( + formatter: ResultFormatter, + state: OutputStreamReporter.State[F] +) extends Reporter[F]: + + /** @inheritDocs + */ + override def startReport(): F[Unit] = + write(formatter.prefix) + + /** @inheritDocs + */ + override def reportGroup( + groupResult: GroupResult, + testExecutions: List[TestExecution] + ): F[Unit] = + write(formatter.formatGroupResult(groupResult)) *> + testExecutions + .map(formatter.formatTestExecution) + .map(write) + .sequence + .as(()) + + /** @inheritDocs + */ + override def reportSuite(suiteExecution: SuiteExecution): F[Unit] = + write(formatter.formatSuiteExecution(suiteExecution)) + + /** @inheritDocs + */ + override def endReport(): F[Unit] = + write(formatter.suffix) + + private def write(output: String): F[Unit] = + state.queue.offer(Some(output)) + + /** Produce an effect that, when executed, will cause the underlying stream to + * terminate. After executing this effect, the `OutputStreamReporter` will no + * longer be capable of writing more output. + * + * @return + * The effect that describes the stop operation. + */ + def stop(): F[Unit] = state.queue.offer(None) + +object OutputStreamReporter: + + /** Provision a new [[OutputStreamReporter]]. + * + * @param formatter + * The [[ResultFormatter]] this reporter should use to render test results. + * @param output + * Resource which manages the `OutputStream` where bytes will be written. + * @return + * Resource which manages the [[OutputStreamReporter]]. + */ + def provision[F[_]: Concurrent: Async]( + formatter: ResultFormatter, + output: Resource[F, OutputStream] + ): Resource[F, OutputStreamReporter[F]] = + output.flatMap { os => + Resource.make(acquireReporter(formatter, os))(_.stop()) + } + + private def acquireReporter[F[_]: Concurrent: Async]( + formatter: ResultFormatter, + output: OutputStream + ): F[OutputStreamReporter[F]] = + for + queue <- Queue.unbounded[F, Option[String]] + process <- startProcess[F](queue, output) + yield new OutputStreamReporter[F]( + formatter = formatter, + state = new State[F](queue, process) + ) + + private def startProcess[F[_]: Concurrent: Async]( + queue: Queue[F, Option[String]], + output: OutputStream + ): F[Fiber[F, Throwable, Unit]] = + fs2.Stream + .fromQueueNoneTerminated(queue) + .through(text.utf8.encode) + .through( + fs2.io.writeOutputStream(Async[F].delay(output), closeAfterUse = false) + ) + .compile + .drain + .start + + private class State[F[_]]( + val queue: Queue[F, Option[String]], + val process: Fiber[F, Throwable, Unit] + ) + +end OutputStreamReporter diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala new file mode 100644 index 0000000..eef803f --- /dev/null +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala @@ -0,0 +1,60 @@ +package gs.test.v0.reporting + +import cats.syntax.all.* +import gs.test.v0.api.GroupResult +import gs.test.v0.api.SuiteExecution +import gs.test.v0.api.TestExecution +import gs.test.v0.api.TestFailure + +/** Implmentation of [[ResultFormatter]] that uses an unstructured text format. + */ +final class PlainResultFormatter extends ResultFormatter: + + /** @inheritDocs + */ + override def prefix: String = "" + + /** @inheritDocs + */ + override def suffix: String = "" + + /** @inheritDocs + */ + override def formatGroupResult(groupResult: GroupResult): String = + def gr = groupResult + s""" + Group: '${gr.name.show}' + Stats: Seen=${gr.seen} Passed=${gr.passed} Failed=${gr.failed} + Duration: ${gr.millis}ms + Docs: ${gr.documentation.getOrElse("None")} + """.stripMargin + + /** @inheritDocs + */ + override def formatTestExecution(testExecution: TestExecution): String = + def te = testExecution + s""" + Test: ${te.permanentId.show} (id=${te.id.show}) (span=${te.spanId}) + Result: *${te.textResult}* in ${te.millis}ms + Tags: ${te.tags.mkString(", ")} + Docs: ${te.documentation.getOrElse("None")}${makeFailure(te.result)} + """.stripMargin + + /** @inheritDocs + */ + override def formatSuiteExecution(suiteExecution: SuiteExecution): String = + def se = suiteExecution + s""" + Suite: '${se.testSuite.permanentId.show}' (id=${se.id.str}) (trace=${se.traceId}) + Name: ${se.testSuite.name} + Stats: Seen=${se.seen} Passed=${se.passed} Failed=${se.failed} + Duration: ${se.millis}ms + """.stripMargin + + private def makeFailure(result: Either[TestFailure, Any]): String = + result match + case Right(_) => "" + case Left(f) => + s"""\n------ + ${f.message} + """.stripMargin diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala index e4a8866..94925dd 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/Reporter.scala @@ -22,7 +22,7 @@ trait Reporter[F[_]]: * implementations to perform "setup" actions, such as opening a JSON object * or writing a header. */ - def beginReporting(): F[Unit] + def startReport(): F[Unit] /** Report the results of a single group. * @@ -51,7 +51,7 @@ trait Reporter[F[_]]: * to perform "finish" actions, such as closing a JSON object or writing a * footer. */ - def endReporting(): F[Unit] + def endReport(): F[Unit] object Reporter: diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala index 18c3351..6530d63 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/ResultFormatter.scala @@ -11,6 +11,16 @@ import gs.test.v0.api.TestExecution * representations. */ trait ResultFormatter: + /** @return + * The prefix for the format (if any). + */ + def prefix: String + + /** @return + * The suffix for the format (if any). + */ + def suffix: String + /** Format a single [[GroupResult]] as a string. * * @param groupResult diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConfiguration.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConfiguration.scala index af41202..cf64f1b 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConfiguration.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConfiguration.scala @@ -1,5 +1,8 @@ package gs.test.v0.runtime.engine +import gs.uuid.v0.UUID +import java.time.Clock + /** Used to control the behavior of some [[TestEngine]] * * @param groupConcurrency @@ -8,8 +11,17 @@ package gs.test.v0.runtime.engine * @param testConcurrency * [[ConcurrencySetting]] for tests; the number of tests allowed to execute * at the same time within some group. + * @param clock + * The `Clock` instance used to inform all date/time operations. + * @param suiteIdGenerator + * UUID provider that is used at the suite level. + * @param testIdGenerator + * UUID provider that is used at the test level. */ case class EngineConfiguration( groupConcurrency: ConcurrencySetting, - testConcurrency: ConcurrencySetting + testConcurrency: ConcurrencySetting, + clock: Clock, + suiteIdGenerator: UUID.Generator, + testIdGenerator: UUID.Generator ) diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala index 7f48a2d..e9031fd 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/EngineConstants.scala @@ -4,9 +4,11 @@ object EngineConstants: object Tracing: - val RootSpan: String = "test-group" + val RootSpan: String = "suite" + val FullGroup: String = "full-group" val BeforeGroup: String = "before-group" val AfterGroup: String = "after-group" + val FullTest: String = "full-test" val BeforeTest: String = "before-test" val AfterTest: String = "after-test" val InGroup: String = "in-group" diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index 050cb76..b12546c 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -1,6 +1,7 @@ package gs.test.v0.runtime.engine import cats.effect.Async +import cats.effect.Resource import cats.syntax.all.* import gs.test.v0.api.GroupResult import gs.test.v0.api.SuiteExecution @@ -11,8 +12,6 @@ import gs.test.v0.api.TestGroupDefinition import gs.test.v0.api.TestSuite import gs.test.v0.reporting.Reporter import gs.timing.v0.Timing -import gs.uuid.v0.UUID -import java.time.Clock import java.time.Instant import natchez.EntryPoint import natchez.Span @@ -47,75 +46,79 @@ import natchez.Span * * ## OpenTelemetry Support * - * Each [[SuiteExecution]] produces a single trace per [[TestGroupDefinition]]. - * This means that each group has a Trace ID and a tree of execution, with one - * span per test. + * Each [[SuiteExecution]] produces a single trace that encompasses all tests. + * Spans are used to designate different related portions of work. */ final class TestEngine[F[_]: Async]( val configuration: EngineConfiguration, reporter: Reporter[F], - timing: Timing[F], - suiteExecutionIdGenerator: UUID.Generator, - testExecutionIdGenerator: UUID.Generator, - clock: Clock, - val entryPoint: EntryPoint[F] + entryPoint: EntryPoint[F], + timing: Timing[F] ): + private def clock = configuration.clock + private def testIdGen = configuration.testIdGenerator + private def suiteIdGen = configuration.suiteIdGenerator + def runSuite( suite: TestSuite, tests: fs2.Stream[F, TestGroupDefinition[F]] ): F[SuiteExecution] = - // TODO: REPORTING -- need interface - for - executedAt <- Async[F].delay(Instant.now(clock)) - stats <- EngineStats.initialize[F] + entryPoint.root(EngineConstants.Tracing.RootSpan).use { rootSpan => + for + executedAt <- Async[F].delay(Instant.now(clock)) + stats <- EngineStats.initialize[F] - // Start reporting - _ <- reporter.beginReporting() + // Start reporting + _ <- reporter.startReport() - // TODO: Just do telemetry for the whole damn thing. - _ <- tests - .mapAsync(configuration.groupConcurrency.toInt())(runGroup) - .evalTap( - ( - groupResult, - testExecutions - ) => - for - // Update the overall statistics based on this group. - _ <- stats.updateForGroup( - duration = groupResult.duration, - testExecutions = testExecutions - ) + // Run all tests, group by group. + _ <- tests + .mapAsync(configuration.groupConcurrency.toInt())( + runGroup(rootSpan, _) + ) + .evalTap( + ( + groupResult, + testExecutions + ) => + for + // Update the overall statistics based on this group. + _ <- stats.updateForGroup( + duration = groupResult.duration, + testExecutions = testExecutions + ) - // Report group level results for this group. - _ <- reporter.reportGroup( - groupResult = groupResult, - testExecutions = testExecutions - ) - yield () - ) - .compile - .drain + // Report group level results for this group. + _ <- reporter.reportGroup( + groupResult = groupResult, + testExecutions = testExecutions + ) + yield () + ) + .compile + .drain - // Calculate the final summary of execution at the suite level. - suiteExecution <- makeSuiteExecution(suite, stats, executedAt) + // Calculate the final summary of execution at the suite level. + suiteExecution <- makeSuiteExecution(rootSpan, suite, stats, executedAt) - // Report suite level results. - _ <- reporter.reportSuite(suiteExecution) + // Report suite level results. + _ <- reporter.reportSuite(suiteExecution) - // Finish reporting. - _ <- reporter.endReporting() - yield suiteExecution + // Finish reporting. + _ <- reporter.endReport() + yield suiteExecution + } def runGroup( + suiteSpan: Span[F], group: TestGroupDefinition[F] ): F[(GroupResult, List[TestExecution])] = - entryPoint.root(EngineConstants.Tracing.RootSpan).use { rootSpan => + suiteSpan.span(EngineConstants.Tracing.FullGroup).use { fullGroupSpan => for groupStats <- EngineStats.initialize[F] // Augment the span with all group-level metadata. - _ <- rootSpan + _ <- fullGroupSpan .put(EngineConstants.MetaData.TestGroupName -> group.name.show) // Start the timer for the entire group. @@ -124,17 +127,17 @@ final class TestEngine[F[_]: Async]( // Run the before-group logic (in its own span). _ <- runSpan( EngineConstants.Tracing.BeforeGroup, - rootSpan, + fullGroupSpan, group.beforeGroup.getOrElse(Async[F].unit) ) // Execute all tests within this group. - testExecutions <- executeGroupTests(group, rootSpan) + testExecutions <- executeGroupTests(group, fullGroupSpan) // Run the after-group logic (in its own span). _ <- runSpan( EngineConstants.Tracing.AfterGroup, - rootSpan, + fullGroupSpan, group.afterGroup.getOrElse(Async[F].unit) ) @@ -163,66 +166,67 @@ final class TestEngine[F[_]: Async]( private def executeGroupTests( group: TestGroupDefinition[F], - rootSpan: Span[F] + fullGroupSpan: Span[F] ): F[List[TestExecution]] = - rootSpan.span(EngineConstants.Tracing.InGroup).use { groupSpan => + fullGroupSpan.span(EngineConstants.Tracing.InGroup).use { groupSpan => for // If, for some reason, the generated span has no Trace ID, this will // throw an exception. - traceId <- rootSpan.traceId.map(parseTraceId) - executions <- streamGroupTests(traceId, group, groupSpan).compile.toList + executions <- streamGroupTests(group, groupSpan).compile.toList yield executions } private def streamGroupTests( - traceId: UUID, group: TestGroupDefinition[F], groupSpan: Span[F] ): fs2.Stream[F, TestExecution] = fs2.Stream .emits(group.tests) .mapAsync(configuration.testConcurrency.toInt()) { test => - for - // Generate a unique TestExecutionId for this execution. - testExecutionId <- Async[F].delay( - TestExecution.Id(testExecutionIdGenerator.next()) + groupSpan.span(EngineConstants.Tracing.FullTest).use { fullSpan => + for + // Generate a unique TestExecutionId for this execution. + testExecutionId <- Async[F].delay( + TestExecution.Id(testIdGen.next()) + ) + + testSpanId <- fullSpan.spanId.map(parseSpanId) + + // Start the timer for the test, including the before/after + // components. + timer <- timing.start() + + // Run the before-test logic (in its own span). + _ <- runSpan( + EngineConstants.Tracing.BeforeTest, + groupSpan, + group.beforeEachTest.getOrElse(Async[F].unit) + ) + + // Run the test (in its own span). + result <- runSingleTest(testExecutionId, test, groupSpan) + + // Run the after-test logic (in its own span). + _ <- runSpan( + EngineConstants.Tracing.AfterTest, + groupSpan, + group.afterEachTest.getOrElse(Async[F].unit) + ) + + // Calculate the overall elapsed time for this single test. + elapsed <- timer.checkpoint() + yield TestExecution( + id = testExecutionId, + permanentId = test.permanentId, + documentation = test.documentation, + tags = test.tags, + markers = test.markers, + result = result, + spanId = testSpanId, + sourcePosition = test.sourcePosition, + duration = elapsed.duration ) - - // Start the timer for the test, including the before/after - // components. - timer <- timing.start() - - // Run the before-test logic (in its own span). - _ <- runSpan( - EngineConstants.Tracing.BeforeTest, - groupSpan, - group.beforeEachTest.getOrElse(Async[F].unit) - ) - - // Run the test (in its own span). - result <- runSingleTest(testExecutionId, test, groupSpan) - - // Run the after-test logic (in its own span). - _ <- runSpan( - EngineConstants.Tracing.AfterTest, - groupSpan, - group.afterEachTest.getOrElse(Async[F].unit) - ) - - // Calculate the overall elapsed time for this single test. - elapsed <- timer.checkpoint() - yield TestExecution( - id = testExecutionId, - permanentId = test.permanentId, - documentation = test.documentation, - tags = test.tags, - markers = test.markers, - result = result, - // TODO: TraceID isn't that useful here, need SpanID - traceId = traceId, - sourcePosition = test.sourcePosition, - duration = elapsed.duration - ) + } } private def runSingleTest( @@ -239,31 +243,38 @@ final class TestEngine[F[_]: Async]( yield result } - private def parseTraceId(candidate: Option[String]): UUID = - candidate.flatMap(UUID.parse) match + private def parseTraceId(candidate: Option[String]): String = + candidate match case Some(traceId) => traceId case None => - throw new IllegalArgumentException( - "Created a span with an invalid Trace ID: " + candidate - ) + throw new IllegalArgumentException("Created a span without a Trace ID!") + + private def parseSpanId(candidate: Option[String]): String = + candidate match + case Some(spanId) => spanId + case None => + throw new IllegalArgumentException("Created a span without a Span ID!") private def makeSuiteExecution( + rootSpan: Span[F], suite: TestSuite, stats: EngineStats[F], executedAt: Instant ): F[SuiteExecution] = for + traceId <- rootSpan.traceId.map(parseTraceId) overallDuration <- stats.duration - countSeen <- stats.seen - countPassed <- stats.passed - countFailed <- stats.failed + seen <- stats.seen + passed <- stats.passed + failed <- stats.failed yield SuiteExecution( - id = suiteExecutionIdGenerator.next(), + id = suiteIdGen.next(), testSuite = suite, + traceId = traceId, duration = overallDuration, - countSeen = countSeen, - countPassed = countPassed, - countFailed = countFailed, + seen = seen, + passed = passed, + failed = failed, executedAt = executedAt ) @@ -284,3 +295,36 @@ final class TestEngine[F[_]: Async]( f: F[A] ): F[A] = root.span(name).use(_ => f) + +object TestEngine: + + /** Provision a new [[TestEngine]]. + * + * @param configuration + * The [[EngineConfiguration]] used for this instance. + * @param reporter + * Resource which manages the [[Reporter]]. + * @param entryPoint + * Resource which manages the telemetry entry point. + * @param timing + * Timing controller. + * @return + * Resource which manages the [[TestEngine]]. + */ + def provision[F[_]: Async]( + configuration: EngineConfiguration, + reporter: Resource[F, Reporter[F]], + entryPoint: Resource[F, EntryPoint[F]], + timing: Timing[F] + ): Resource[F, TestEngine[F]] = + for + r <- reporter + ep <- entryPoint + yield new TestEngine( + configuration = configuration, + reporter = r, + entryPoint = ep, + timing = timing + ) + +end TestEngine -- 2.43.0 From 830105af3a91a09f286a30019cb47e09df0fd5cd Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Wed, 13 Aug 2025 22:07:08 -0500 Subject: [PATCH 06/13] Minor formatting updates. --- .scalafmt.conf | 2 +- .../scala/gs/test/v0/reporting/PlainResultFormatter.scala | 2 +- .../src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index 9c7929b..be3b2cb 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,5 +1,5 @@ // See: https://github.com/scalameta/scalafmt/tags for the latest tags. -version = 3.8.1 +version = 3.9.9 runner.dialect = scala3 maxColumn = 80 diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala index eef803f..ccf0be7 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/PlainResultFormatter.scala @@ -54,7 +54,7 @@ final class PlainResultFormatter extends ResultFormatter: private def makeFailure(result: Either[TestFailure, Any]): String = result match case Right(_) => "" - case Left(f) => + case Left(f) => s"""\n------ ${f.message} """.stripMargin diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index b12546c..7b65e2a 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -246,13 +246,13 @@ final class TestEngine[F[_]: Async]( private def parseTraceId(candidate: Option[String]): String = candidate match case Some(traceId) => traceId - case None => + case None => throw new IllegalArgumentException("Created a span without a Trace ID!") private def parseSpanId(candidate: Option[String]): String = candidate match case Some(spanId) => spanId - case None => + case None => throw new IllegalArgumentException("Created a span without a Span ID!") private def makeSuiteExecution( -- 2.43.0 From 311ab17d5f6274faf3a0b5021578355448d0a84b Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Thu, 11 Sep 2025 21:12:32 -0500 Subject: [PATCH 07/13] WIP: writing tests for the runtime and improving test data --- build.sbt | 21 +++- .../test/v0/reporting/InMemoryReporter.scala | 50 ++++++++++ .../test/v0/runtime/engine/TestEngine.scala | 12 +++ .../v0/runtime/engine/EngineStatsTests.scala | 77 +++++++++++++++ .../src/test/scala/support/Generators.scala | 99 +++++++++++++++++++ .../src/test/scala/support/Durations.scala | 11 +++ project/build.properties | 2 +- 7 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala create mode 100644 modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala create mode 100644 modules/test-data/src/test/scala/support/Generators.scala create mode 100644 modules/test-support/src/test/scala/support/Durations.scala diff --git a/build.sbt b/build.sbt index 3f0e9a5..a965705 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3: String = "3.7.1" +val scala3: String = "3.7.2" ThisBuild / scalaVersion := scala3 ThisBuild / versionScheme := Some("semver-spec") @@ -101,6 +101,24 @@ lazy val api = project ) ) +/** Internal project used for generating test data. + */ +lazy val `test-data` = project + .in(file("modules/test-data")) + .dependsOn(api) + .settings(sharedSettings) + .settings(testSettings) + .settings(noPublishSettings) + .settings( + name := s"${gsProjectName.value}-test-data" + ) + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Core, + Deps.Cats.Effect + ) + ) + /** Reporting API and implementations. */ lazy val reporting = project @@ -123,6 +141,7 @@ lazy val reporting = project lazy val runtime = project .in(file("modules/runtime")) .dependsOn(`test-support` % "test->test") + .dependsOn(`test-data` % "test->test") .dependsOn(api, reporting) .settings(sharedSettings) .settings(testSettings) diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala new file mode 100644 index 0000000..eb31865 --- /dev/null +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala @@ -0,0 +1,50 @@ +package gs.test.v0.reporting + +import cats.effect.Async +import cats.effect.Ref +import cats.effect.std.Queue +import cats.syntax.all.* +import gs.test.v0.api.GroupResult +import gs.test.v0.api.SuiteExecution +import gs.test.v0.api.TestExecution + +final class InMemoryReporter[F[_]: Async] private ( + suiteExecution: Ref[F, Option[SuiteExecution]], + groupResults: Queue[F, (GroupResult, List[TestExecution])] +) extends Reporter[F]: + + /** @inheritDocs + */ + override def startReport(): F[Unit] = Async[F].unit + + /** @inheritDocs + */ + override def reportGroup( + groupResult: GroupResult, + testExecutions: List[TestExecution] + ): F[Unit] = groupResults.offer(groupResult -> testExecutions) + + /** @inheritDocs + */ + override def reportSuite(suiteExecution: SuiteExecution): F[Unit] = + this.suiteExecution.set(Some(suiteExecution)) + + /** @inheritDocs + */ + override def endReport(): F[Unit] = Async[F].unit + + def getSuiteExecution(): F[Option[SuiteExecution]] = suiteExecution.get + + // TODO: make stream to consume and reify to list + def getGroupResults(): F[List[(GroupResult, List[TestExecution])]] = + ??? + +object InMemoryReporter: + + def initialize[F[_]: Async]: F[InMemoryReporter[F]] = + for + se <- Ref.of[F, Option[SuiteExecution]](None) + gr <- Queue.unbounded[F, (GroupResult, List[TestExecution])] + yield new InMemoryReporter(se, gr) + +end InMemoryReporter diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index 7b65e2a..b102235 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -60,6 +60,18 @@ final class TestEngine[F[_]: Async]( private def testIdGen = configuration.testIdGenerator private def suiteIdGen = configuration.suiteIdGenerator + /** Execute a suite of tests. + * + * This function only provides a summary output. Results are streamed using a + * [[Reporter]] instance. + * + * @param suite + * The metadata that describes the suite. + * @param tests + * The stream of groups that define the tests. + * @return + * Summary of the execution. + */ def runSuite( suite: TestSuite, tests: fs2.Stream[F, TestGroupDefinition[F]] diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala new file mode 100644 index 0000000..a144fa0 --- /dev/null +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala @@ -0,0 +1,77 @@ +package gs.test.v0.runtime.engine + +import cats.effect.IO +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.Size +import java.util.concurrent.TimeUnit +import munit.* +import scala.concurrent.duration.FiniteDuration +import support.* + +class EngineStatsTests extends IOSuite: + + iotest("should initialize empty stats") { + for + stats <- EngineStats.initialize[IO] + duration <- stats.duration + seen <- stats.seen + passed <- stats.passed + failed <- stats.failed + yield + assertEquals(duration, Durations.Zero) + assertEquals(seen, 0L) + assertEquals(passed, 0L) + assertEquals(failed, 0L) + } + + iotest("should update based on a group with no test executions") { + val expected = FiniteDuration(2L, TimeUnit.MILLISECONDS) + for + stats <- EngineStats.initialize[IO] + _ <- stats.updateForGroup(Durations.OneMilli, Nil) + _ <- stats.updateForGroup(Durations.OneMilli, Nil) + duration <- stats.duration + seen <- stats.seen + passed <- stats.passed + failed <- stats.failed + yield + assertEquals(duration, expected) + assertEquals(seen, 0L) + assertEquals(passed, 0L) + assertEquals(failed, 0L) + } + + iotest("should update based on a single test execution") { + for + stats <- EngineStats.initialize[IO] + _ <- stats.updateForTest(Generators.testExecutionPassed()) + _ <- stats.updateForTest(Generators.testExecutionFailed()) + duration <- stats.duration + seen <- stats.seen + passed <- stats.passed + failed <- stats.failed + yield + assertEquals(duration, Durations.Zero) + assertEquals(seen, 2L) + assertEquals(passed, 1L) + assertEquals(failed, 1L) + } + + iotest("should update based on a test group") { + val duration = Generators.testDuration() + val size = 4 + val executions = + Gen.list(Size.fixed(size), Generators.GenTestExecutionPassed).gen() + for + stats <- EngineStats.initialize[IO] + _ <- stats.updateForGroup(duration, executions) + duration <- stats.duration + seen <- stats.seen + passed <- stats.passed + failed <- stats.failed + yield + assertEquals(duration, duration) + assertEquals(seen, size.toLong) + assertEquals(passed, size.toLong) + assertEquals(failed, 0L) + } diff --git a/modules/test-data/src/test/scala/support/Generators.scala b/modules/test-data/src/test/scala/support/Generators.scala new file mode 100644 index 0000000..6d6bef6 --- /dev/null +++ b/modules/test-data/src/test/scala/support/Generators.scala @@ -0,0 +1,99 @@ +package support + +import gs.datagen.v0.* +import gs.datagen.v0.generators.Size +import gs.test.v0.api.* +import scala.concurrent.duration.FiniteDuration + +object Generators: + + val NoSourcePosition: SourcePosition = SourcePosition("TEST", 0) + + val GenTestExecutionId: Gen[TestExecution.Id] = + Gen.uuid.random().map(TestExecution.Id(_)) + + given Generated[TestExecution.Id] = Generated.of(GenTestExecutionId) + + val GenPermanentId: Gen[PermanentId] = + Gen.string.alphaNumeric(Size.Fixed(12)).map(x => PermanentId(s"pid-$x")) + + given Generated[PermanentId] = Generated.of(GenPermanentId) + + val GenTag: Gen[Tag] = + Gen.string.alphaNumeric(Size.Fixed(6)).map(x => Tag(s"tag-$x")) + + val GenTagList: Gen[List[Tag]] = Gen.list(Size.between(0, 8), GenTag) + + given Generated[Tag] = Generated.of(GenTag) + + val GenTraceId: Gen[String] = Gen.uuid.string().map(_.filterNot(_ == '-')) + + val GenSpanId: Gen[String] = GenTraceId.map(_.take(16)) + + val GenTestDuration: Gen[FiniteDuration] = + Gen.duration.finiteMilliseconds(1L, 100L) + + val GenTestResult: Gen[Either[TestFailure, Any]] = + Gen.boolean().map(makeResult) + + private def makeResult(passed: Boolean): TestResult = + passed match { + case true => Right(()) + case false => + Left(TestFailure.TestRequestedFailure("Failed", NoSourcePosition)) + } + + val InputGenTestExecution: Datagen[TestExecution, Boolean] = + for + id <- GenTestExecutionId + permanentId <- GenPermanentId + tags <- GenTagList + spanId <- GenSpanId + duration <- GenTestDuration + yield (passed: Boolean) => + TestExecution( + id = id, + permanentId = permanentId, + documentation = None, + tags = tags, + markers = Nil, + result = makeResult(passed), + spanId = spanId, + sourcePosition = NoSourcePosition, + duration = duration + ) + + val GenTestExecution: Gen[TestExecution] = + Gen.boolean().map(passed => InputGenTestExecution.generate(passed)) + + val GenTestExecutionPassed: Gen[TestExecution] = + Gen.single(true).map(InputGenTestExecution.generate) + + val GenTestExecutionFailed: Gen[TestExecution] = + Gen.single(false).map(InputGenTestExecution.generate) + + given Generated[TestExecution] = Generated.of(GenTestExecution) + + def testExecutionId(): TestExecution.Id = GenTestExecutionId.gen() + + def permanentId(): PermanentId = GenPermanentId.gen() + + def tag(): Tag = GenTag.gen() + + def tags(): List[Tag] = GenTagList.gen() + + def traceId(): String = GenTraceId.gen() + + def spanId(): String = GenSpanId.gen() + + def testDuration(): FiniteDuration = GenTestDuration.gen() + + def testExecution(): TestExecution = GenTestExecution.gen() + + def testExecutionPassed(): TestExecution = + InputGenTestExecution.generate(true) + + def testExecutionFailed(): TestExecution = + InputGenTestExecution.generate(false) + +end Generators diff --git a/modules/test-support/src/test/scala/support/Durations.scala b/modules/test-support/src/test/scala/support/Durations.scala new file mode 100644 index 0000000..5cd5715 --- /dev/null +++ b/modules/test-support/src/test/scala/support/Durations.scala @@ -0,0 +1,11 @@ +package support + +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.FiniteDuration + +object Durations: + + val Zero: FiniteDuration = FiniteDuration(0L, TimeUnit.NANOSECONDS) + val OneMilli: FiniteDuration = FiniteDuration(1L, TimeUnit.MILLISECONDS) + +end Durations diff --git a/project/build.properties b/project/build.properties index bbb0b60..5e6884d 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.11.2 +sbt.version=1.11.6 -- 2.43.0 From 3137fe4005d9ad859cdb45b719b2170961e2ff2a Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 13 Sep 2025 10:21:48 -0500 Subject: [PATCH 08/13] Successfully run an engine with no tests. --- build.sbt | 5 +- .../test/v0/reporting/InMemoryReporter.scala | 25 ++--- .../test/v0/runtime/engine/TestEngine.scala | 28 +++--- .../v0/runtime/engine/TestEngineTests.scala | 81 ++++++++++++++++ .../src/test/scala/support/Generators.scala | 20 +++- .../test/scala/support/TestEntryPoint.scala | 48 ++++++++++ .../src/test/scala/support/TestSpan.scala | 93 +++++++++++++++++++ 7 files changed, 266 insertions(+), 34 deletions(-) create mode 100644 modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala create mode 100644 modules/test-support/src/test/scala/support/TestEntryPoint.scala create mode 100644 modules/test-support/src/test/scala/support/TestSpan.scala diff --git a/build.sbt b/build.sbt index a965705..34e3c3a 100644 --- a/build.sbt +++ b/build.sbt @@ -41,7 +41,7 @@ val Deps = new { val Gs = new { val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.1" val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.2" - val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.1" + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3" } val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.1.1" @@ -78,7 +78,8 @@ lazy val `test-support` = project .settings( libraryDependencies ++= Seq( Deps.Cats.Core, - Deps.Cats.Effect + Deps.Cats.Effect, + Deps.Natchez.Core ) ) diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala index eb31865..08bde16 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala @@ -2,6 +2,7 @@ package gs.test.v0.reporting import cats.effect.Async import cats.effect.Ref +import cats.effect.Resource import cats.effect.std.Queue import cats.syntax.all.* import gs.test.v0.api.GroupResult @@ -10,7 +11,7 @@ import gs.test.v0.api.TestExecution final class InMemoryReporter[F[_]: Async] private ( suiteExecution: Ref[F, Option[SuiteExecution]], - groupResults: Queue[F, (GroupResult, List[TestExecution])] + groupResults: Queue[F, Option[(GroupResult, List[TestExecution])]] ) extends Reporter[F]: /** @inheritDocs @@ -22,7 +23,7 @@ final class InMemoryReporter[F[_]: Async] private ( override def reportGroup( groupResult: GroupResult, testExecutions: List[TestExecution] - ): F[Unit] = groupResults.offer(groupResult -> testExecutions) + ): F[Unit] = groupResults.offer(Some(groupResult -> testExecutions)) /** @inheritDocs */ @@ -31,20 +32,22 @@ final class InMemoryReporter[F[_]: Async] private ( /** @inheritDocs */ - override def endReport(): F[Unit] = Async[F].unit + override def endReport(): F[Unit] = groupResults.offer(None) def getSuiteExecution(): F[Option[SuiteExecution]] = suiteExecution.get - // TODO: make stream to consume and reify to list - def getGroupResults(): F[List[(GroupResult, List[TestExecution])]] = - ??? + def terminateAndGetResults(): F[List[(GroupResult, List[TestExecution])]] = + endReport() *> + fs2.Stream.fromQueueNoneTerminated(groupResults).compile.toList object InMemoryReporter: - def initialize[F[_]: Async]: F[InMemoryReporter[F]] = - for - se <- Ref.of[F, Option[SuiteExecution]](None) - gr <- Queue.unbounded[F, (GroupResult, List[TestExecution])] - yield new InMemoryReporter(se, gr) + def provision[F[_]: Async]: Resource[F, InMemoryReporter[F]] = + Resource.make( + for + se <- Ref.of[F, Option[SuiteExecution]](None) + gr <- Queue.unbounded[F, Option[(GroupResult, List[TestExecution])]] + yield new InMemoryReporter(se, gr) + )(_ => Async[F].unit) end InMemoryReporter diff --git a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala index b102235..9a68091 100644 --- a/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala +++ b/modules/runtime/src/main/scala/gs/test/v0/runtime/engine/TestEngine.scala @@ -1,7 +1,6 @@ package gs.test.v0.runtime.engine import cats.effect.Async -import cats.effect.Resource import cats.syntax.all.* import gs.test.v0.api.GroupResult import gs.test.v0.api.SuiteExecution @@ -51,8 +50,8 @@ import natchez.Span */ final class TestEngine[F[_]: Async]( val configuration: EngineConfiguration, - reporter: Reporter[F], - entryPoint: EntryPoint[F], + val reporter: Reporter[F], + val entryPoint: EntryPoint[F], timing: Timing[F] ): @@ -310,32 +309,29 @@ final class TestEngine[F[_]: Async]( object TestEngine: - /** Provision a new [[TestEngine]]. + /** Initialize a new [[TestEngine]]. * * @param configuration * The [[EngineConfiguration]] used for this instance. * @param reporter - * Resource which manages the [[Reporter]]. + * Reports test results. * @param entryPoint - * Resource which manages the telemetry entry point. + * Entry point for OpenTelemetry support. * @param timing * Timing controller. * @return * Resource which manages the [[TestEngine]]. */ - def provision[F[_]: Async]( + def initialize[F[_]: Async]( configuration: EngineConfiguration, - reporter: Resource[F, Reporter[F]], - entryPoint: Resource[F, EntryPoint[F]], + reporter: Reporter[F], + entryPoint: EntryPoint[F], timing: Timing[F] - ): Resource[F, TestEngine[F]] = - for - r <- reporter - ep <- entryPoint - yield new TestEngine( + ): TestEngine[F] = + new TestEngine( configuration = configuration, - reporter = r, - entryPoint = ep, + reporter = reporter, + entryPoint = entryPoint, timing = timing ) diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala new file mode 100644 index 0000000..aaf7bd1 --- /dev/null +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala @@ -0,0 +1,81 @@ +package gs.test.v0.runtime.engine + +import cats.effect.IO +import cats.effect.Resource +import gs.test.v0.api.TestGroupDefinition +import gs.test.v0.reporting.InMemoryReporter +import gs.test.v0.runtime.engine.TestEngineTests.EngineObservation +import gs.timing.v0.MonotonicProvider.ManualTickProvider +import gs.timing.v0.Timing +import gs.uuid.v0.UUID +import java.time.Clock +import munit.* +import support.* + +class TestEngineTests extends IOSuite: + + import TestEngineTests.TestData + + iotest("should run an engine with no tests") { + newEngine().use { obs => + for + suiteExecution <- obs.engine.runSuite( + suite = Generators.testSuite(), + tests = emptyStream[TestGroupDefinition[IO]] + ) + rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan) + results <- obs.reporter.terminateAndGetResults() + yield + assertEquals(rootSpan.isDefined, true) + assertEquals(results.isEmpty, true) + assertEquals(suiteExecution.seen, 0L) + assertEquals(suiteExecution.passed, 0L) + assertEquals(suiteExecution.failed, 0L) + } + } + + private def emptyStream[A]: fs2.Stream[IO, A] = + fs2.Stream.empty + + private def liftToResource[A](io: IO[A]): Resource[IO, A] = + Resource.make(io)(_ => IO.unit) + + def newEngine(): Resource[IO, EngineObservation] = + for + (tickProvider, timing) <- liftToResource(Timing.manual[IO]) + reporter <- InMemoryReporter.provision[IO] + entryPoint <- TestEntryPoint.provision() + yield EngineObservation( + tickProvider = tickProvider, + reporter = reporter, + entryPoint = entryPoint, + engine = TestEngine.initialize[IO]( + configuration = TestData.Config, + reporter = reporter, + entryPoint = entryPoint, + timing = timing + ) + ) + +object TestEngineTests: + + private object TestData: + + val Config: EngineConfiguration = EngineConfiguration( + groupConcurrency = ConcurrencySetting.Serial, + testConcurrency = ConcurrencySetting.Serial, + clock = Clock.systemUTC(), + suiteIdGenerator = UUID.Generator.version7, + testIdGenerator = UUID.Generator.version7 + ) + + end TestData + + case class EngineObservation( + tickProvider: ManualTickProvider[IO], + reporter: InMemoryReporter[IO], + entryPoint: TestEntryPoint, + engine: TestEngine[IO] + ) + +end TestEngineTests diff --git a/modules/test-data/src/test/scala/support/Generators.scala b/modules/test-data/src/test/scala/support/Generators.scala index 6d6bef6..96d10fc 100644 --- a/modules/test-data/src/test/scala/support/Generators.scala +++ b/modules/test-data/src/test/scala/support/Generators.scala @@ -64,16 +64,24 @@ object Generators: ) val GenTestExecution: Gen[TestExecution] = - Gen.boolean().map(passed => InputGenTestExecution.generate(passed)) + Gen.boolean().satisfy(InputGenTestExecution) val GenTestExecutionPassed: Gen[TestExecution] = - Gen.single(true).map(InputGenTestExecution.generate) + InputGenTestExecution.toGen(true) val GenTestExecutionFailed: Gen[TestExecution] = - Gen.single(false).map(InputGenTestExecution.generate) + InputGenTestExecution.toGen(false) given Generated[TestExecution] = Generated.of(GenTestExecution) + val GenTestSuite: Gen[TestSuite] = + for + pid <- GenPermanentId + name <- Gen.string.alphaNumeric(Size.fixed(8)) + yield TestSuite(pid, name, None) + + given Generated[TestSuite] = Generated.of(GenTestSuite) + def testExecutionId(): TestExecution.Id = GenTestExecutionId.gen() def permanentId(): PermanentId = GenPermanentId.gen() @@ -91,9 +99,11 @@ object Generators: def testExecution(): TestExecution = GenTestExecution.gen() def testExecutionPassed(): TestExecution = - InputGenTestExecution.generate(true) + GenTestExecutionPassed.gen() def testExecutionFailed(): TestExecution = - InputGenTestExecution.generate(false) + GenTestExecutionFailed.gen() + + def testSuite(): TestSuite = GenTestSuite.gen() end Generators diff --git a/modules/test-support/src/test/scala/support/TestEntryPoint.scala b/modules/test-support/src/test/scala/support/TestEntryPoint.scala new file mode 100644 index 0000000..f4f167b --- /dev/null +++ b/modules/test-support/src/test/scala/support/TestEntryPoint.scala @@ -0,0 +1,48 @@ +package support + +import cats.effect.IO +import cats.effect.kernel.Resource +import cats.effect.std.MapRef +import natchez.EntryPoint +import natchez.Kernel +import natchez.Span +import natchez.Span.Options + +final class TestEntryPoint private ( + spans: MapRef[IO, String, Option[TestSpan]] +) extends EntryPoint[IO]: + + def getSpan(name: String): IO[Option[TestSpan]] = + spans(name).get + + override def root( + name: String, + options: Options + ): Resource[IO, Span[IO]] = + TestSpan + .provisionRoot(name, spans) + .evalTap(span => spans.setKeyValue(name, span)) + + override def continue( + name: String, + kernel: Kernel, + options: Options + ): Resource[IO, Span[IO]] = + throw new IllegalStateException("Not allowed for testing.") + + override def continueOrElseRoot( + name: String, + kernel: Kernel, + options: Options + ): Resource[IO, Span[IO]] = + throw new IllegalStateException("Not allowed for testing.") + +object TestEntryPoint: + + def initialize(): IO[TestEntryPoint] = + MapRef.apply[IO, String, TestSpan].map(spans => new TestEntryPoint(spans)) + + def provision(): Resource[IO, TestEntryPoint] = + Resource.make(initialize())(_ => IO.unit) + +end TestEntryPoint diff --git a/modules/test-support/src/test/scala/support/TestSpan.scala b/modules/test-support/src/test/scala/support/TestSpan.scala new file mode 100644 index 0000000..7f9aef0 --- /dev/null +++ b/modules/test-support/src/test/scala/support/TestSpan.scala @@ -0,0 +1,93 @@ +package support + +import cats.effect.IO +import cats.effect.kernel.Resource +import cats.effect.std.MapRef +import java.net.URI +import java.util.UUID +import natchez.Kernel +import natchez.Span +import natchez.Span.Options +import natchez.TraceValue + +final class TestSpan private ( + val name: String, + val rawTraceId: String, + val rawSpanId: String, + baggage: MapRef[IO, String, Option[TraceValue]], + spans: MapRef[IO, String, Option[TestSpan]] +) extends Span[IO]: + + override def put(fields: (String, TraceValue)*): IO[Unit] = + fields.map { case (k, v) => baggage.setKeyValue(k, v) }.sequence.as(()) + + override def log(fields: (String, TraceValue)*): IO[Unit] = IO.unit + + override def log(event: String): IO[Unit] = IO.unit + + override def attachError( + err: Throwable, + fields: (String, TraceValue)* + ): IO[Unit] = IO.unit + + override def kernel: IO[Kernel] = IO(Kernel(Map.empty)) + + override def span( + name: String, + options: Options + ): Resource[IO, Span[IO]] = + TestSpan + .provision(name, rawTraceId, TestSpan.makeSpanId(), spans) + .evalTap(span => spans.setKeyValue(name, span)) + + override def traceId: IO[Option[String]] = IO(Some(rawTraceId)) + + override def spanId: IO[Option[String]] = IO(Some(rawSpanId)) + + override def traceUri: IO[Option[URI]] = IO(None) + +object TestSpan: + + def initializeRoot( + name: String, + spans: MapRef[IO, String, Option[TestSpan]] + ): IO[TestSpan] = + initialize(name, makeTraceId(), makeSpanId(), spans) + + def initialize( + name: String, + traceId: String, + spanId: String, + spans: MapRef[IO, String, Option[TestSpan]] + ): IO[TestSpan] = + MapRef.apply[IO, String, TraceValue].map { baggage => + new TestSpan( + name = name, + rawTraceId = traceId, + rawSpanId = spanId, + baggage = baggage, + spans = spans + ) + } + + def provisionRoot( + name: String, + spans: MapRef[IO, String, Option[TestSpan]] + ): Resource[IO, TestSpan] = + provision(name, makeTraceId(), makeSpanId(), spans) + + def provision( + name: String, + traceId: String, + spanId: String, + spans: MapRef[IO, String, Option[TestSpan]] + ): Resource[IO, TestSpan] = + Resource.make(initialize(name, traceId, spanId, spans))(_ => IO.unit) + + private def makeTraceId(): String = + UUID.randomUUID().toString().filterNot(_ == '-') + + private def makeSpanId(): String = + makeTraceId().take(16) + +end TestSpan -- 2.43.0 From 5294ea669a91177c6c6599d72a159cbcb3258603 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 13 Sep 2025 15:54:25 -0500 Subject: [PATCH 09/13] Now running real tests --- .../main/scala/gs/test/v0/api/Assertion.scala | 62 ---------------- .../src/main/scala/gs/test/v0/api/Check.scala | 72 +++++++++++++++---- .../main/scala/gs/test/v0/api/syntax.scala | 2 +- .../v0/api/GroupImplementationTests.scala | 8 --- .../test/v0/reporting/InMemoryReporter.scala | 27 +++++++ .../v0/runtime/engine/TestEngineTests.scala | 54 +++++++++++++- .../test/scala/support/TestEntryPoint.scala | 1 + 7 files changed, 141 insertions(+), 85 deletions(-) delete mode 100644 modules/api/src/main/scala/gs/test/v0/api/Assertion.scala diff --git a/modules/api/src/main/scala/gs/test/v0/api/Assertion.scala b/modules/api/src/main/scala/gs/test/v0/api/Assertion.scala deleted file mode 100644 index 31dbb90..0000000 --- a/modules/api/src/main/scala/gs/test/v0/api/Assertion.scala +++ /dev/null @@ -1,62 +0,0 @@ -package gs.test.v0.api - -import scala.reflect.* - -sealed abstract class Assertion(val name: String) - -object Assertion: - - private def success(): Either[TestFailure, Unit] = Right(()) - - // TODO: Use a smart rendering solution, consider diffs. - // For now, this is fine -- add a diff library later as data. - def renderInput[A](value: A): String = value.toString() - - case object IsEqualTo extends Assertion("isEqualTo"): - - def evaluate[A: ClassTag]( - candidate: A, - expected: A - )( - using - CanEqual[A, A] - )( - using - pos: SourcePosition - ): Either[TestFailure, Unit] = - if candidate == expected then success() - else - val runtimeType = classTag[A].runtimeClass.getName() - Left( - TestFailure.AssertionFailed( - assertionName = name, - inputs = Map( - "candidate" -> runtimeType, - "expected" -> runtimeType - ), - message = - s"'${renderInput(candidate)}' was not equal to '${renderInput(candidate)}'", - pos = pos - ) - ) - - case object IsTrue extends Assertion("isTrue"): - - def evaluate( - candidate: Boolean - )( - using - pos: SourcePosition - ): Either[TestFailure, Unit] = - if candidate then success() - else - Left( - TestFailure.AssertionFailed( - assertionName = name, - inputs = Map("candidate" -> "Boolean"), - message = s"Expected '$candidate' to be 'true'.", - pos = pos - ) - ) - -end Assertion diff --git a/modules/api/src/main/scala/gs/test/v0/api/Check.scala b/modules/api/src/main/scala/gs/test/v0/api/Check.scala index 93efe51..d586a3e 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/Check.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/Check.scala @@ -1,9 +1,11 @@ package gs.test.v0.api +import cats.data.EitherT import cats.effect.Sync +import scala.reflect.* import scala.reflect.ClassTag -/** Opaque type used to check candidate values against expected values. +/** Opaque type used to check obtained values against expected values. */ opaque type Check[A] = A @@ -11,18 +13,38 @@ object Check: /** Instantiate a new Check. * - * @param candidate + * @param obtained * The value to check. * @return * The new [[Check]] instance. */ - def apply[A](candidate: A): Check[A] = candidate + def apply[A](obtained: A): Check[A] = obtained - extension [A](candidate: Check[A]) + private def testPassed(): TestResult = Right(()) + + private def render[A](value: A): String = value.toString() + + private def assertionFailed( + assertionName: String, + inputs: Map[String, String], + message: String + )( + using + pos: SourcePosition + ): TestResult = Left( + TestFailure.AssertionFailed( + assertionName = assertionName, + inputs = inputs, + message = message, + pos = pos + ) + ) + + extension [A](obtained: Check[A]) /** @return * The unwrapped value of this [[Check]]. */ - def unwrap(): A = candidate + def unwrap(): A = obtained /** ## Usage * @@ -34,7 +56,7 @@ object Check: * @param expected * The expected value. * @return - * Successful test result if the candidate value is equal to the expected + * Successful test result if the obtained value is equal to the expected * value, an error describing the test failure otherwise. */ def isEqualTo( @@ -45,7 +67,18 @@ object Check: ClassTag[A], SourcePosition ): TestResult = - Assertion.IsEqualTo.evaluate(candidate, expected) + if obtained == expected then testPassed() + else + val runtimeType = classTag[A].runtimeClass.getName() + assertionFailed( + assertionName = "isEqualTo", + inputs = Map( + "obtained" -> runtimeType, + "expected" -> runtimeType + ), + message = + s"'${render(obtained)}' was not equal to '${render(expected)}'" + ) /** ## Usage * @@ -60,8 +93,8 @@ object Check: * The expected value. * @return * Effect that when evaluated will produce: successful test result if the - * candidate value is equal to the expected value, an error describing - * the test failure otherwise. + * obtained value is equal to the expected value, an error describing the + * test failure otherwise. */ def isEqualToF[F[_]: Sync]( expected: A @@ -73,7 +106,7 @@ object Check: ): F[TestResult] = Sync[F].delay(isEqualTo(expected)) - extension (check: Check[Boolean]) + extension (obtained: Check[Boolean]) /** ## Usage * @@ -83,7 +116,7 @@ object Check: * }}} * * @return - * Successful test result if the candidate value is `true`, an error + * Successful test result if the obtained value is `true`, an error * describing the test failure otherwise. */ def isTrue( @@ -91,7 +124,13 @@ object Check: using SourcePosition ): TestResult = - Assertion.IsTrue.evaluate(check) + if obtained then testPassed() + else + assertionFailed( + assertionName = "isTrue", + inputs = Map("obtained" -> "Boolean"), + message = s"Expected '$obtained' to be 'true'." + ) /** ## Usage * @@ -104,7 +143,7 @@ object Check: * * @return * Effect that when evaluated will produce: successful test result if the - * candidate value is `true`, an error describing the test failure + * obtained value is `true`, an error describing the test failure * otherwise. */ def isTrueF[F[_]: Sync]( @@ -114,4 +153,11 @@ object Check: ): F[TestResult] = Sync[F].delay(isTrue()) + def isTrueT[F[_]: Sync]( + )( + using + SourcePosition + ): EitherT[F, TestFailure, Any] = + EitherT(Sync[F].delay(isTrue())) + end Check diff --git a/modules/api/src/main/scala/gs/test/v0/api/syntax.scala b/modules/api/src/main/scala/gs/test/v0/api/syntax.scala index b20128f..0c8a41a 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/syntax.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/syntax.scala @@ -180,4 +180,4 @@ def checkAllF[F[_]: Sync]( } } -def check[A](candidate: A): Check[A] = Check(candidate) +def check[A](obtained: A): Check[A] = Check(obtained) diff --git a/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala b/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala index 2b962c3..27d0483 100644 --- a/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala +++ b/modules/api/src/test/scala/gs/test/v0/api/GroupImplementationTests.scala @@ -132,12 +132,4 @@ object GroupImplementationTests: end G3 - class G4[F[_]: Async] extends TestGroup[F]: - override def name: String = "G4" - - // TODO: Make test entrypoint and test Trace[F] - test(Ids.T4, "Effectful test").effectful { - ??? - } - end GroupImplementationTests diff --git a/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala index 08bde16..5883425 100644 --- a/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala +++ b/modules/reporting/src/main/scala/gs/test/v0/reporting/InMemoryReporter.scala @@ -9,6 +9,19 @@ import gs.test.v0.api.GroupResult import gs.test.v0.api.SuiteExecution import gs.test.v0.api.TestExecution +/** Reporter that collects all results into memory. + * + * ### Lifecycle + * + * This reporter is intended to be used to get results _exactly once_, after + * the engine has completed its work. The function `terminateAndGetResults` + * will remove all results from the internal collection. + * + * @param suiteExecution + * Collector for the [[SuiteExecution]]. + * @param groupResults + * Collector for grouped test results. + */ final class InMemoryReporter[F[_]: Async] private ( suiteExecution: Ref[F, Option[SuiteExecution]], groupResults: Queue[F, Option[(GroupResult, List[TestExecution])]] @@ -34,14 +47,28 @@ final class InMemoryReporter[F[_]: Async] private ( */ override def endReport(): F[Unit] = groupResults.offer(None) + /** @return + * The recorded [[SuiteExecution]]. This will be empty unless `reportSuite` + * was called. + */ def getSuiteExecution(): F[Option[SuiteExecution]] = suiteExecution.get + /** Immediatelly call `endReport` (to ensure terminal state) and collect all + * recorded results. This is a destructive call that will remove the results + * from this reporter. + * + * @return + * The list of extracted results. + */ def terminateAndGetResults(): F[List[(GroupResult, List[TestExecution])]] = endReport() *> fs2.Stream.fromQueueNoneTerminated(groupResults).compile.toList object InMemoryReporter: + /** @return + * Resource that provides a new [[InMemoryReporter]]. + */ def provision[F[_]: Async]: Resource[F, InMemoryReporter[F]] = Resource.make( for diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala index aaf7bd1..5b22a64 100644 --- a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala @@ -2,9 +2,10 @@ package gs.test.v0.runtime.engine import cats.effect.IO import cats.effect.Resource -import gs.test.v0.api.TestGroupDefinition +import gs.test.v0.api.* import gs.test.v0.reporting.InMemoryReporter import gs.test.v0.runtime.engine.TestEngineTests.EngineObservation +import gs.test.v0.runtime.engine.TestEngineTests.G1 import gs.timing.v0.MonotonicProvider.ManualTickProvider import gs.timing.v0.Timing import gs.uuid.v0.UUID @@ -34,6 +35,50 @@ class TestEngineTests extends IOSuite: } } + iotest("should run an engine with a single passing test") { + newEngine().use { obs => + val g1 = new G1 + val group = g1.compile() + for + suiteExecution <- obs.engine.runSuite( + suite = Generators.testSuite(), + tests = fs2.Stream.apply(group) + ) + rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan) + groupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullGroup) + beforeGroupSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.BeforeGroup + ) + afterGroupSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.AfterGroup + ) + inGroupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.InGroup) + fullTestSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullTest) + beforeTestSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.BeforeTest + ) + afterTestSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.AfterTest + ) + testSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.TestSpan) + results <- obs.reporter.terminateAndGetResults() + yield + assertEquals(rootSpan.isDefined, true) + assertEquals(groupSpan.isDefined, true) + assertEquals(beforeGroupSpan.isDefined, true) + assertEquals(afterGroupSpan.isDefined, true) + assertEquals(inGroupSpan.isDefined, true) + assertEquals(fullTestSpan.isDefined, true) + assertEquals(beforeTestSpan.isDefined, true) + assertEquals(afterTestSpan.isDefined, true) + assertEquals(testSpan.isDefined, true) + assertEquals(results.size, 1) + assertEquals(suiteExecution.seen, 1L) + assertEquals(suiteExecution.passed, 1L) + assertEquals(suiteExecution.failed, 0L) + } + } + private def emptyStream[A]: fs2.Stream[IO, A] = fs2.Stream.empty @@ -78,4 +123,11 @@ object TestEngineTests: engine: TestEngine[IO] ) + class G1 extends TestGroup[IO]: + override def name: String = "single-passing-test" + + test(pid"engine:g1", "show that true is true") { + check(true).isTrueT() + } + end TestEngineTests diff --git a/modules/test-support/src/test/scala/support/TestEntryPoint.scala b/modules/test-support/src/test/scala/support/TestEntryPoint.scala index f4f167b..432a03e 100644 --- a/modules/test-support/src/test/scala/support/TestEntryPoint.scala +++ b/modules/test-support/src/test/scala/support/TestEntryPoint.scala @@ -8,6 +8,7 @@ import natchez.Kernel import natchez.Span import natchez.Span.Options +// TODO: This doesn't account for multiple spans with the same name. final class TestEntryPoint private ( spans: MapRef[IO, String, Option[TestSpan]] ) extends EntryPoint[IO]: -- 2.43.0 From 49e9ccae415e2facf79d1c6eb61736f09b80351d Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 13 Sep 2025 21:33:59 -0500 Subject: [PATCH 10/13] Tests also fail successfully. --- .../src/main/scala/gs/test/v0/api/Check.scala | 10 ++++ .../v0/runtime/engine/TestEngineTests.scala | 58 ++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/modules/api/src/main/scala/gs/test/v0/api/Check.scala b/modules/api/src/main/scala/gs/test/v0/api/Check.scala index d586a3e..94f22eb 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/Check.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/Check.scala @@ -106,6 +106,16 @@ object Check: ): F[TestResult] = Sync[F].delay(isEqualTo(expected)) + def isEqualToT[F[_]: Sync]( + expected: A + )( + using + CanEqual[A, A], + ClassTag[A], + SourcePosition + ): EitherT[F, TestFailure, Any] = + EitherT(Sync[F].delay(isEqualTo(expected))) + extension (obtained: Check[Boolean]) /** ## Usage diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala index 5b22a64..5427faf 100644 --- a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala @@ -4,8 +4,7 @@ import cats.effect.IO import cats.effect.Resource import gs.test.v0.api.* import gs.test.v0.reporting.InMemoryReporter -import gs.test.v0.runtime.engine.TestEngineTests.EngineObservation -import gs.test.v0.runtime.engine.TestEngineTests.G1 +import gs.test.v0.runtime.engine.TestEngineTests.* import gs.timing.v0.MonotonicProvider.ManualTickProvider import gs.timing.v0.Timing import gs.uuid.v0.UUID @@ -79,6 +78,50 @@ class TestEngineTests extends IOSuite: } } + iotest("should run an engine with a single failing test") { + newEngine().use { obs => + val g2 = new G2 + val group = g2.compile() + for + suiteExecution <- obs.engine.runSuite( + suite = Generators.testSuite(), + tests = fs2.Stream.apply(group) + ) + rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan) + groupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullGroup) + beforeGroupSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.BeforeGroup + ) + afterGroupSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.AfterGroup + ) + inGroupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.InGroup) + fullTestSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullTest) + beforeTestSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.BeforeTest + ) + afterTestSpan <- obs.entryPoint.getSpan( + EngineConstants.Tracing.AfterTest + ) + testSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.TestSpan) + results <- obs.reporter.terminateAndGetResults() + yield + assertEquals(rootSpan.isDefined, true) + assertEquals(groupSpan.isDefined, true) + assertEquals(beforeGroupSpan.isDefined, true) + assertEquals(afterGroupSpan.isDefined, true) + assertEquals(inGroupSpan.isDefined, true) + assertEquals(fullTestSpan.isDefined, true) + assertEquals(beforeTestSpan.isDefined, true) + assertEquals(afterTestSpan.isDefined, true) + assertEquals(testSpan.isDefined, true) + assertEquals(results.size, 1) + assertEquals(suiteExecution.seen, 1L) + assertEquals(suiteExecution.passed, 0L) + assertEquals(suiteExecution.failed, 1L) + } + } + private def emptyStream[A]: fs2.Stream[IO, A] = fs2.Stream.empty @@ -130,4 +173,15 @@ object TestEngineTests: check(true).isTrueT() } + end G1 + + class G2 extends TestGroup[IO]: + override def name: String = "single-failing-test" + + test(pid"engine:g2", "this will fail") { + check(1).isEqualToT(2) + } + + end G2 + end TestEngineTests -- 2.43.0 From 984427fdc981cdd498aaa00b61e70ff8ba80dce0 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Mon, 15 Sep 2025 22:00:00 -0500 Subject: [PATCH 11/13] provide a list of spans so we can test more correctly --- .../v0/runtime/engine/TestEngineTests.scala | 89 ++++++++++--------- .../src/test/scala/support/SpanDb.scala | 27 ++++++ .../test/scala/support/TestEntryPoint.scala | 13 +-- .../src/test/scala/support/TestSpan.scala | 22 ++--- 4 files changed, 89 insertions(+), 62 deletions(-) create mode 100644 modules/test-support/src/test/scala/support/SpanDb.scala diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala index 5427faf..a90b1fb 100644 --- a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala @@ -18,15 +18,16 @@ class TestEngineTests extends IOSuite: iotest("should run an engine with no tests") { newEngine().use { obs => + val spanDb = obs.entryPoint.spanDb for suiteExecution <- obs.engine.runSuite( suite = Generators.testSuite(), tests = emptyStream[TestGroupDefinition[IO]] ) - rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan) + rootSpan <- spanDb.get(EngineConstants.Tracing.RootSpan) results <- obs.reporter.terminateAndGetResults() yield - assertEquals(rootSpan.isDefined, true) + assertEquals(rootSpan.size, 1) assertEquals(results.isEmpty, true) assertEquals(suiteExecution.seen, 0L) assertEquals(suiteExecution.passed, 0L) @@ -36,41 +37,43 @@ class TestEngineTests extends IOSuite: iotest("should run an engine with a single passing test") { newEngine().use { obs => - val g1 = new G1 - val group = g1.compile() + val spanDb = obs.entryPoint.spanDb + val g1 = new G1 + val group = g1.compile() + for suiteExecution <- obs.engine.runSuite( suite = Generators.testSuite(), tests = fs2.Stream.apply(group) ) - rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan) - groupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullGroup) - beforeGroupSpan <- obs.entryPoint.getSpan( + rootSpan <- spanDb.get(EngineConstants.Tracing.RootSpan) + groupSpan <- spanDb.get(EngineConstants.Tracing.FullGroup) + beforeGroupSpan <- spanDb.get( EngineConstants.Tracing.BeforeGroup ) - afterGroupSpan <- obs.entryPoint.getSpan( + afterGroupSpan <- spanDb.get( EngineConstants.Tracing.AfterGroup ) - inGroupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.InGroup) - fullTestSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullTest) - beforeTestSpan <- obs.entryPoint.getSpan( + inGroupSpan <- spanDb.get(EngineConstants.Tracing.InGroup) + fullTestSpan <- spanDb.get(EngineConstants.Tracing.FullTest) + beforeTestSpan <- spanDb.get( EngineConstants.Tracing.BeforeTest ) - afterTestSpan <- obs.entryPoint.getSpan( + afterTestSpan <- spanDb.get( EngineConstants.Tracing.AfterTest ) - testSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.TestSpan) + testSpan <- spanDb.get(EngineConstants.Tracing.TestSpan) results <- obs.reporter.terminateAndGetResults() yield - assertEquals(rootSpan.isDefined, true) - assertEquals(groupSpan.isDefined, true) - assertEquals(beforeGroupSpan.isDefined, true) - assertEquals(afterGroupSpan.isDefined, true) - assertEquals(inGroupSpan.isDefined, true) - assertEquals(fullTestSpan.isDefined, true) - assertEquals(beforeTestSpan.isDefined, true) - assertEquals(afterTestSpan.isDefined, true) - assertEquals(testSpan.isDefined, true) + assertEquals(rootSpan.size, 1) + assertEquals(groupSpan.size, 1) + assertEquals(beforeGroupSpan.size, 1) + assertEquals(afterGroupSpan.size, 1) + assertEquals(inGroupSpan.size, 1) + assertEquals(fullTestSpan.size, 1) + assertEquals(beforeTestSpan.size, 1) + assertEquals(afterTestSpan.size, 1) + assertEquals(testSpan.size, 1) assertEquals(results.size, 1) assertEquals(suiteExecution.seen, 1L) assertEquals(suiteExecution.passed, 1L) @@ -80,41 +83,43 @@ class TestEngineTests extends IOSuite: iotest("should run an engine with a single failing test") { newEngine().use { obs => - val g2 = new G2 - val group = g2.compile() + val spanDb = obs.entryPoint.spanDb + val g2 = new G2 + val group = g2.compile() for suiteExecution <- obs.engine.runSuite( suite = Generators.testSuite(), tests = fs2.Stream.apply(group) ) - rootSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.RootSpan) - groupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullGroup) - beforeGroupSpan <- obs.entryPoint.getSpan( + rootSpan <- spanDb.get(EngineConstants.Tracing.RootSpan) + groupSpan <- spanDb.get(EngineConstants.Tracing.FullGroup) + beforeGroupSpan <- spanDb.get( EngineConstants.Tracing.BeforeGroup ) - afterGroupSpan <- obs.entryPoint.getSpan( + afterGroupSpan <- spanDb.get( EngineConstants.Tracing.AfterGroup ) - inGroupSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.InGroup) - fullTestSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.FullTest) - beforeTestSpan <- obs.entryPoint.getSpan( + inGroupSpan <- spanDb.get(EngineConstants.Tracing.InGroup) + fullTestSpan <- spanDb.get(EngineConstants.Tracing.FullTest) + beforeTestSpan <- spanDb.get( EngineConstants.Tracing.BeforeTest ) - afterTestSpan <- obs.entryPoint.getSpan( + afterTestSpan <- spanDb.get( EngineConstants.Tracing.AfterTest ) - testSpan <- obs.entryPoint.getSpan(EngineConstants.Tracing.TestSpan) + testSpan <- spanDb.get(EngineConstants.Tracing.TestSpan) results <- obs.reporter.terminateAndGetResults() yield - assertEquals(rootSpan.isDefined, true) - assertEquals(groupSpan.isDefined, true) - assertEquals(beforeGroupSpan.isDefined, true) - assertEquals(afterGroupSpan.isDefined, true) - assertEquals(inGroupSpan.isDefined, true) - assertEquals(fullTestSpan.isDefined, true) - assertEquals(beforeTestSpan.isDefined, true) - assertEquals(afterTestSpan.isDefined, true) - assertEquals(testSpan.isDefined, true) + // TODO rip out a validation function for a full set of stuff. + assertEquals(rootSpan.size, 1) + assertEquals(groupSpan.size, 1) + assertEquals(beforeGroupSpan.size, 1) + assertEquals(afterGroupSpan.size, 1) + assertEquals(inGroupSpan.size, 1) + assertEquals(fullTestSpan.size, 1) + assertEquals(beforeTestSpan.size, 1) + assertEquals(afterTestSpan.size, 1) + assertEquals(testSpan.size, 1) assertEquals(results.size, 1) assertEquals(suiteExecution.seen, 1L) assertEquals(suiteExecution.passed, 0L) diff --git a/modules/test-support/src/test/scala/support/SpanDb.scala b/modules/test-support/src/test/scala/support/SpanDb.scala new file mode 100644 index 0000000..b922f5f --- /dev/null +++ b/modules/test-support/src/test/scala/support/SpanDb.scala @@ -0,0 +1,27 @@ +package support + +import cats.effect.IO +import cats.effect.std.MapRef + +final class SpanDb( + db: MapRef[IO, String, Option[List[TestSpan]]] +): + + def get(spanName: String): IO[List[TestSpan]] = + db(spanName).get.map(_.getOrElse(Nil)) + + def putSpan( + spanName: String, + span: TestSpan + ): IO[Unit] = + db(spanName).update { + case None => Some(List(span)) + case Some(spans) => Some(span :: spans) + } + +object SpanDb: + + def initialize(): IO[SpanDb] = + MapRef[IO, String, List[TestSpan]].map(db => new SpanDb(db)) + +end SpanDb diff --git a/modules/test-support/src/test/scala/support/TestEntryPoint.scala b/modules/test-support/src/test/scala/support/TestEntryPoint.scala index 432a03e..12c0907 100644 --- a/modules/test-support/src/test/scala/support/TestEntryPoint.scala +++ b/modules/test-support/src/test/scala/support/TestEntryPoint.scala @@ -2,27 +2,22 @@ package support import cats.effect.IO import cats.effect.kernel.Resource -import cats.effect.std.MapRef import natchez.EntryPoint import natchez.Kernel import natchez.Span import natchez.Span.Options -// TODO: This doesn't account for multiple spans with the same name. final class TestEntryPoint private ( - spans: MapRef[IO, String, Option[TestSpan]] + val spanDb: SpanDb ) extends EntryPoint[IO]: - def getSpan(name: String): IO[Option[TestSpan]] = - spans(name).get - override def root( name: String, options: Options ): Resource[IO, Span[IO]] = TestSpan - .provisionRoot(name, spans) - .evalTap(span => spans.setKeyValue(name, span)) + .provisionRoot(name, spanDb) + .evalTap(span => spanDb.putSpan(name, span)) override def continue( name: String, @@ -41,7 +36,7 @@ final class TestEntryPoint private ( object TestEntryPoint: def initialize(): IO[TestEntryPoint] = - MapRef.apply[IO, String, TestSpan].map(spans => new TestEntryPoint(spans)) + SpanDb.initialize().map(db => new TestEntryPoint(db)) def provision(): Resource[IO, TestEntryPoint] = Resource.make(initialize())(_ => IO.unit) diff --git a/modules/test-support/src/test/scala/support/TestSpan.scala b/modules/test-support/src/test/scala/support/TestSpan.scala index 7f9aef0..a152233 100644 --- a/modules/test-support/src/test/scala/support/TestSpan.scala +++ b/modules/test-support/src/test/scala/support/TestSpan.scala @@ -15,7 +15,7 @@ final class TestSpan private ( val rawTraceId: String, val rawSpanId: String, baggage: MapRef[IO, String, Option[TraceValue]], - spans: MapRef[IO, String, Option[TestSpan]] + spanDb: SpanDb ) extends Span[IO]: override def put(fields: (String, TraceValue)*): IO[Unit] = @@ -37,8 +37,8 @@ final class TestSpan private ( options: Options ): Resource[IO, Span[IO]] = TestSpan - .provision(name, rawTraceId, TestSpan.makeSpanId(), spans) - .evalTap(span => spans.setKeyValue(name, span)) + .provision(name, rawTraceId, TestSpan.makeSpanId(), spanDb) + .evalTap(span => spanDb.putSpan(name, span)) override def traceId: IO[Option[String]] = IO(Some(rawTraceId)) @@ -50,15 +50,15 @@ object TestSpan: def initializeRoot( name: String, - spans: MapRef[IO, String, Option[TestSpan]] + spanDb: SpanDb ): IO[TestSpan] = - initialize(name, makeTraceId(), makeSpanId(), spans) + initialize(name, makeTraceId(), makeSpanId(), spanDb) def initialize( name: String, traceId: String, spanId: String, - spans: MapRef[IO, String, Option[TestSpan]] + spanDb: SpanDb ): IO[TestSpan] = MapRef.apply[IO, String, TraceValue].map { baggage => new TestSpan( @@ -66,23 +66,23 @@ object TestSpan: rawTraceId = traceId, rawSpanId = spanId, baggage = baggage, - spans = spans + spanDb = spanDb ) } def provisionRoot( name: String, - spans: MapRef[IO, String, Option[TestSpan]] + spanDb: SpanDb ): Resource[IO, TestSpan] = - provision(name, makeTraceId(), makeSpanId(), spans) + provision(name, makeTraceId(), makeSpanId(), spanDb) def provision( name: String, traceId: String, spanId: String, - spans: MapRef[IO, String, Option[TestSpan]] + spanDb: SpanDb ): Resource[IO, TestSpan] = - Resource.make(initialize(name, traceId, spanId, spans))(_ => IO.unit) + Resource.make(initialize(name, traceId, spanId, spanDb))(_ => IO.unit) private def makeTraceId(): String = UUID.randomUUID().toString().filterNot(_ == '-') -- 2.43.0 From 9eed5cb9765a4eb3df13d03b6ba417eeeadfaf90 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Fri, 26 Sep 2025 21:44:30 -0500 Subject: [PATCH 12/13] updating versions --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 34e3c3a..4afd19d 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3: String = "3.7.2" +val scala3: String = "3.7.3" ThisBuild / scalaVersion := scala3 ThisBuild / versionScheme := Some("semver-spec") -- 2.43.0 From a2f046e8606a150026b2aba049018f9811c89026 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Fri, 3 Apr 2026 22:54:42 -0500 Subject: [PATCH 13/13] Upgrades over time --- build.sbt | 14 +++++++------- .../src/main/scala/gs/test/v0/api/TestGroup.scala | 10 +++++----- .../api/src/main/scala/gs/test/v0/api/syntax.scala | 4 ++-- .../test/v0/runtime/engine/EngineStatsTests.scala | 4 ++-- .../test/v0/runtime/engine/TestEngineTests.scala | 4 ++-- .../src/test/scala/support/Generators.scala | 10 +++++----- project/build.properties | 2 +- project/plugins.sbt | 4 ++-- 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/build.sbt b/build.sbt index 4afd19d..0eaeed8 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3: String = "3.7.3" +val scala3: String = "3.8.3" ThisBuild / scalaVersion := scala3 ThisBuild / versionScheme := Some("semver-spec") @@ -27,24 +27,24 @@ val sharedSettings = Seq( val Deps = new { val Cats = new { val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0" - val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3" + val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.7.0" } val Fs2 = new { - val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.12.0" + val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.13.0" } val Natchez = new { - val Core: ModuleID = "org.tpolecat" %% "natchez-core" % "0.3.8" + val Core: ModuleID = "org.tpolecat" %% "natchez-core" % "0.3.9" } val Gs = new { val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.1" - val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.2" - val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.3.3" + val Timing: ModuleID = "gs" %% "gs-timing-v0" % "0.1.3" + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.1" } - val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.1.1" + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.2.4" } lazy val testSettings = Seq( diff --git a/modules/api/src/main/scala/gs/test/v0/api/TestGroup.scala b/modules/api/src/main/scala/gs/test/v0/api/TestGroup.scala index 1e15638..58a0fd2 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/TestGroup.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/TestGroup.scala @@ -263,14 +263,14 @@ object TestGroup: * The function this test will execute. */ def pure(unitOfWork: => Either[TestFailure, Unit]): Unit = - effectful(Async[F].pure(unitOfWork)) + apply(Async[F].pure(unitOfWork)) /** Finalize and register this test with an effectful unit of work. * * @param unitOfWork * The function this test will execute. */ - def effectful( + def apply( unitOfWork: natchez.Trace[F] ?=> F[Either[TestFailure, Any]] ): Unit = registry.register( @@ -291,7 +291,7 @@ object TestGroup: * @param unitOfWork * The function this test will execute. */ - def apply( + def eitherT( unitOfWork: natchez.Trace[F] ?=> EitherT[F, TestFailure, Any] ): Unit = registry.register( @@ -422,7 +422,7 @@ object TestGroup: * @param unitOfWork * The function this test will execute. */ - def effectful( + def apply( unitOfWork: natchez.Trace[F] ?=> Input => F[Either[TestFailure, Any]] ): Unit = registry.register( @@ -445,7 +445,7 @@ object TestGroup: * @param unitOfWork * The function this test will execute. */ - def apply( + def eitherT( unitOfWork: natchez.Trace[F] ?=> Input => EitherT[F, TestFailure, Any] ): Unit = registry.register( diff --git a/modules/api/src/main/scala/gs/test/v0/api/syntax.scala b/modules/api/src/main/scala/gs/test/v0/api/syntax.scala index 0c8a41a..cab7127 100644 --- a/modules/api/src/main/scala/gs/test/v0/api/syntax.scala +++ b/modules/api/src/main/scala/gs/test/v0/api/syntax.scala @@ -104,7 +104,7 @@ def pass(): Either[TestFailure, Unit] = Right(()) * final class Example extends TestGroup.IO: * override def name: String = "example" * - * test(pid"ex", "Example Test").effectful { passF() } + * test(pid"ex", "Example Test") { passF() } * }}} * * @return @@ -123,7 +123,7 @@ def passF[F[_]: Applicative](): F[Either[TestFailure, Unit]] = * final class Example extends TestGroup.IO: * override def name: String = "example" * - * test(pid"ex", "Example Test") { passT() } + * test(pid"ex", "Example Test").eitherT { passT() } * }}} * * @return diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala index a144fa0..b6312f9 100644 --- a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/EngineStatsTests.scala @@ -2,7 +2,7 @@ package gs.test.v0.runtime.engine import cats.effect.IO import gs.datagen.v0.Gen -import gs.datagen.v0.generators.Size +import gs.datagen.v0.generators.Range import java.util.concurrent.TimeUnit import munit.* import scala.concurrent.duration.FiniteDuration @@ -61,7 +61,7 @@ class EngineStatsTests extends IOSuite: val duration = Generators.testDuration() val size = 4 val executions = - Gen.list(Size.fixed(size), Generators.GenTestExecutionPassed).gen() + Gen.list(Range.fixed(size), Generators.GenTestExecutionPassed).gen() for stats <- EngineStats.initialize[IO] _ <- stats.updateForGroup(duration, executions) diff --git a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala index a90b1fb..cd0e2c7 100644 --- a/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala +++ b/modules/runtime/src/test/scala/gs/test/v0/runtime/engine/TestEngineTests.scala @@ -175,7 +175,7 @@ object TestEngineTests: override def name: String = "single-passing-test" test(pid"engine:g1", "show that true is true") { - check(true).isTrueT() + check(true).isTrueF() } end G1 @@ -184,7 +184,7 @@ object TestEngineTests: override def name: String = "single-failing-test" test(pid"engine:g2", "this will fail") { - check(1).isEqualToT(2) + check(1).isEqualToF(2) } end G2 diff --git a/modules/test-data/src/test/scala/support/Generators.scala b/modules/test-data/src/test/scala/support/Generators.scala index 96d10fc..a4d790b 100644 --- a/modules/test-data/src/test/scala/support/Generators.scala +++ b/modules/test-data/src/test/scala/support/Generators.scala @@ -1,7 +1,7 @@ package support import gs.datagen.v0.* -import gs.datagen.v0.generators.Size +import gs.datagen.v0.generators.Range import gs.test.v0.api.* import scala.concurrent.duration.FiniteDuration @@ -15,14 +15,14 @@ object Generators: given Generated[TestExecution.Id] = Generated.of(GenTestExecutionId) val GenPermanentId: Gen[PermanentId] = - Gen.string.alphaNumeric(Size.Fixed(12)).map(x => PermanentId(s"pid-$x")) + Gen.string.alphaNumeric(Range.Fixed(12)).map(x => PermanentId(s"pid-$x")) given Generated[PermanentId] = Generated.of(GenPermanentId) val GenTag: Gen[Tag] = - Gen.string.alphaNumeric(Size.Fixed(6)).map(x => Tag(s"tag-$x")) + Gen.string.alphaNumeric(Range.Fixed(6)).map(x => Tag(s"tag-$x")) - val GenTagList: Gen[List[Tag]] = Gen.list(Size.between(0, 8), GenTag) + val GenTagList: Gen[List[Tag]] = Gen.list(Range.between(0, 8), GenTag) given Generated[Tag] = Generated.of(GenTag) @@ -77,7 +77,7 @@ object Generators: val GenTestSuite: Gen[TestSuite] = for pid <- GenPermanentId - name <- Gen.string.alphaNumeric(Size.fixed(8)) + name <- Gen.string.alphaNumeric(Range.fixed(8)) yield TestSuite(pid, name, None) given Generated[TestSuite] = Generated.of(GenTestSuite) diff --git a/project/build.properties b/project/build.properties index 5e6884d..08a6fc0 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.11.6 +sbt.version=1.12.8 diff --git a/project/plugins.sbt b/project/plugins.sbt index 83e5dc1..1db1aad 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -28,6 +28,6 @@ externalResolvers := Seq( "Garrity Software Releases" at "https://maven.garrity.co/gs" ) -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.3.1") -addSbtPlugin("gs" % "sbt-garrity-software" % "0.6.0") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.4") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.7.0") addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0") -- 2.43.0