From 3137fe4005d9ad859cdb45b719b2170961e2ff2a Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 13 Sep 2025 10:21:48 -0500 Subject: [PATCH] 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