Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
56 changes: 56 additions & 0 deletions .github/workflows/bobcat-samples.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: bobcat-samples

# GH-5082: the Bobcat samples (src/Samples/Bobcat) moved here from the Bobcat repository with the Critter Stack
# vocabulary. They are not in wolverine.slnx, so no other job touches them. This one builds every sample project and
# runs the specs of every runnable one through the Nuke build's BobcatSamples target (build/BobcatSamples.cs), which
# is also where the reasoning lives: why building was never enough, the quarantine list and its "stale quarantine
# fails too" rule, the database per sample, and BankAccountES's Fisher leg. `./build.sh BobcatSamples` runs the same
# thing locally; it starts the docker-compose Postgres (5433) itself, or reuses one already listening there.

on:
push:
branches: [ main ]
paths:
- 'src/**'
- 'build/**'
- 'Directory.Packages.props'
- 'NuGet.config'
- 'docker-compose.yml'
- '.github/workflows/bobcat-samples.yml'
pull_request:
branches: [ main ]
paths:
- 'src/**'
- 'build/**'
- 'Directory.Packages.props'
- 'NuGet.config'
- 'docker-compose.yml'
- '.github/workflows/bobcat-samples.yml'
workflow_dispatch:

env:
# A spec host publishes its progress to a Bobcat monitor when one answers. Nothing listens here.
BOBCAT_MONITOR: "0"

jobs:
samples:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1

- name: Setup .NET 10
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x

- name: Build and run every Bobcat sample
run: ./build.sh BobcatSamples

- name: Stop containers
if: always()
run: docker compose down
10 changes: 6 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,16 @@
<!-- Drives test-host executables as supervised worker processes from the Nuke build
(build/SupervisedTests.cs): class-partitioned parallel workers, per-lane environments,
and honest retry reporting. Referenced ONLY by the build project. -->
<PackageVersion Include="Bobcat.Supervisor" Version="1.0.0-rc.1" />
<PackageVersion Include="Bobcat.Supervisor" Version="1.0.0-rc.2" />
<!-- Bobcat itself, for the leadership-election compliance specifications in Wolverine.ComplianceTests and
for WolverineFx.Bobcat / WolverineFx.Bobcat.Http, their tests and the samples that dogfood them (GH-4833).
Everything that takes Bobcat is on one version; the 0.60.4 hold for WolverineFx.Bobcat ended with
1.0.0-rc.1, which fixed JasperFx/bobcat#672. -->
<PackageVersion Include="Bobcat" Version="1.0.0-rc.1" />
<PackageVersion Include="Bobcat.Xunit" Version="1.0.0-rc.1" />
<PackageVersion Include="Bobcat.Generators" Version="1.0.0-rc.1" />
<PackageVersion Include="Bobcat" Version="1.0.0-rc.2" />
<PackageVersion Include="Bobcat.Xunit" Version="1.0.0-rc.2" />
<PackageVersion Include="Bobcat.Generators" Version="1.0.0-rc.2" />
<!-- The eventmodelers.ai board reader WolverineFx.Bobcat.EventModel's spec writer reads with (GH-5082) -->
<PackageVersion Include="Bobcat.EventModel" Version="1.0.0-rc.2" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="JasperFx" Version="2.90.2" />
Expand Down
172 changes: 172 additions & 0 deletions build/BobcatSamples.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using System.Collections.Generic;
using System.Linq;
using Npgsql;
using Nuke.Common;
using Nuke.Common.IO;
using Nuke.Common.Tooling;
using Nuke.Common.Tools.DotNet;
using Serilog;
using static Nuke.Common.Tools.DotNet.DotNetTasks;

// GH-5082: the Bobcat samples (src/Samples/Bobcat), moved here from the Bobcat repository with the Critter Stack
// vocabulary they are written against (JasperFx/bobcat#744), along with the targets that build and run them.
// `./build.sh BobcatSamples` is what bobcat-samples.yml runs.
//
// The samples are not in wolverine.slnx: each has its own .slnx, and they are written as consumers write
// applications. So Compile never touches them, and that gap once let nine of them rot undetected in Bobcat.
// Compiling was never enough to call a sample fixed, either: OutboxDemo compiled clean and could not start
// (Wolverine 6 stopped shipping the runtime compiler), and half the samples sat on an older Marten and died on a
// TypeLoadException the first time they opened a session. So these targets build every sample project AND run the
// specs of every runnable one.
//
// The sample specs are Microsoft.Testing.Platform hosts, so `dotnet test` runs them and one scenario is one test
// node. They need Postgres and nothing else.
partial class Build
{
AbsolutePath BobcatSamplesDirectory => RootDirectory / "src" / "Samples" / "Bobcat";

[Parameter("Postgres the Bobcat sample specs run against, without a database name - each sample gets its own")]
readonly string BobcatSamplesPostgres = "Host=localhost;Port=5433;Username=postgres;Password=postgres";

// Sample projects that do not compile today, named individually rather than skipped as a group. Two rules, and
// the second is the one that matters:
//
// * a project outside this list that fails -> new rot, fail the build
// * a project inside this list that BUILDS -> the list is stale, fail the build
//
// A quarantine that only ever forgives is how a permanently-red job becomes background noise. This one has to
// shrink: fix a sample, and the build tells you to delete its line. Paths are relative to the repository root,
// e.g. "src/Samples/Bobcat/OutboxDemo/Tests/Tests.csproj".
static readonly string[] QuarantinedBobcatSamples =
[
];

// Each runnable sample and the database it uses, matching the default in its own Program.cs. Separate databases
// rather than one shared: MeetingGroupMonolith puts its event store in a schema called `payments`, which is also
// the schema PaymentsMonolith uses for everything, so a shared database would have each one's between-scenario
// reset wiping the other's events.
//
// BankAccountES runs its Marten leg here and its Fisher leg in TestBobcatSamplesOnFisher, which is the point of
// that sample: the same handlers against two stores.
static readonly (string Sample, string Database)[] RunnableBobcatSamples =
[
("CqrsMinimalApi", "cqrs_minimal_api"),
("BookingMonolith", "booking"),
("CleanArchitectureTodos", "clean_architecture_todos"),
("MoreSpeakers", "more_speakers"),
("PaymentsMonolith", "inflow"),
("EcommerceModularMonolith", "ecommerce"),
("MeetingGroupMonolith", "meeting_groups"),
("OutboxDemo", "outbox_demo"),
("BankAccountES", "bank_account"),
("EcommerceMicroservices", "ecommerce_catalog"),
("ProjectManagement", "project_management"),
];

Target CompileBobcatSamples => _ => _
.Executes(() =>
{
var regressions = new List<string>();
var recovered = new List<string>();

foreach (var project in BobcatSamplesDirectory.GlobFiles("**/*.csproj").OrderBy(p => p.ToString()))
{
var name = RootDirectory.GetRelativePathTo(project).ToString().Replace('\\', '/');
var quarantined = QuarantinedBobcatSamples.Contains(name);

if (tryRunSample(() => DotNetBuild(s => s.SetProjectFile(project).SetConfiguration(Configuration))))
{
if (quarantined)
{
Log.Error("{Project} builds now - remove it from QuarantinedBobcatSamples", name);
recovered.Add(name);
}
}
else if (quarantined)
{
Log.Warning("known broken (quarantined): {Project}", name);
}
else
{
Log.Error("{Project} failed to build", name);
regressions.Add(name);
}
}

Log.Information("quarantined: {Count}", QuarantinedBobcatSamples.Length);
Assert.Empty(regressions, $"Sample(s) newly broken: {string.Join(", ", regressions)}");
Assert.Empty(recovered, $"Quarantined sample(s) now build - shrink the list: {string.Join(", ", recovered)}");
});

Target TestBobcatSamples => _ => _
.DependsOn(CompileBobcatSamples)
.Executes(() =>
{
// Reused when something already answers on its port (another worktree's stack, a CI service).
StartDockerServices("postgresql");

// Created up front because a CI service container only ever makes one database. Tolerates one that
// already exists, so a local rerun works.
using (var connection = new NpgsqlConnection($"{BobcatSamplesPostgres};Database=postgres"))
{
connection.Open();
foreach (var (_, database) in RunnableBobcatSamples)
{
using var exists = new NpgsqlCommand("select 1 from pg_database where datname = @name", connection);
exists.Parameters.AddWithValue("name", database);
if (exists.ExecuteScalar() != null) continue;

using var create = new NpgsqlCommand($"create database \"{database}\"", connection);
create.ExecuteNonQuery();
}
}

var failed = new List<string>();
foreach (var (sample, database) in RunnableBobcatSamples)
{
Log.Information("Running the {Sample} specs", sample);
var passed = tryRunSample(() => DotNetTest(s => s
.SetProcessAdditionalArguments("--project", BobcatSamplesDirectory / sample / "Tests")
.SetConfiguration(Configuration)
.SetProcessEnvironmentVariable("ConnectionStrings__Marten", $"{BobcatSamplesPostgres};Database={database}")));

if (!passed) failed.Add(sample);
}

Assert.Empty(failed, $"Sample(s) failed their specs: {string.Join(", ", failed)}");
});

// No services at all - a SQLite file is the whole store, which is the argument for Fisher as the inner-loop
// store, and a sample whose point is "the same handlers run on two stores" is only proved by running both.
Target TestBobcatSamplesOnFisher => _ => _
.DependsOn(CompileBobcatSamples)
.After(TestBobcatSamples)
.Executes(() =>
{
var database = TemporaryDirectory / "bank_account.db";
database.DeleteFile();

DotNetTest(s => s
.SetProcessAdditionalArguments("--project", BobcatSamplesDirectory / "BankAccountES" / "Tests")
.SetConfiguration(Configuration)
.SetProcessEnvironmentVariable("EventStore", "Fisher")
.SetProcessEnvironmentVariable("ConnectionStrings__Fisher", $"Data Source={database}"));
});

// What bobcat-samples.yml runs.
Target BobcatSamples => _ => _
.DependsOn(CompileBobcatSamples, TestBobcatSamples, TestBobcatSamplesOnFisher);

static bool tryRunSample(System.Action action)
{
try
{
action();
return true;
}
catch (ProcessException)
{
return false;
}
}
}
9 changes: 7 additions & 2 deletions build/CITargets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,11 @@ void runAwsShard(AbsolutePath project, Func<WorkerTest, bool> testFilter = null)
{
var tests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.Tests" / "Wolverine.Bobcat.Tests.csproj";
var httpTests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.Http.Tests" / "Wolverine.Bobcat.Http.Tests.csproj";
// GH-5082: the store-only Critter Stack vocabulary moved here from Bobcat core, with its tests (Marten on
// Postgres, Fisher on SQLite). [PostgresFact] never skips with CI=true, so a missing database fails.
var storeOnlyTests = RootDirectory / "src" / "Bobcat" / "Bobcat.CritterStackScenario.Tests" / "Bobcat.CritterStackScenario.Tests.csproj";
// GH-5082: the spec writer (write-specs), which moved here from bobcat import-event-model --specs
var specWriterTests = RootDirectory / "src" / "Bobcat" / "Wolverine.Bobcat.EventModel.Tests" / "Wolverine.Bobcat.EventModel.Tests.csproj";

// The samples dogfooding the library. Neither ran in any CI lane before, which is how both
// sat unable to start a host (no runtime compiler since GH-2876, and the test assembly
Expand All @@ -1053,14 +1058,14 @@ void runAwsShard(AbsolutePath project, Func<WorkerTest, bool> testFilter = null)
var processManager = RootDirectory / "src" / "Samples" / "ProcessManagerViaHandlers" /
"ProcessManagerViaHandlers.Tests" / "ProcessManagerViaHandlers.Tests.csproj";

BuildTestProjects(tests, httpTests, incidents, processManager);
BuildTestProjects(tests, httpTests, storeOnlyTests, specWriterTests, incidents, processManager);

// The store-agnostic specs (GH-4892, GH-4920, GH-4921) run on Marten, Polecat AND Fisher, and
// Polecat needs SQL Server. Without it every Polecat fixture fails to start, which surfaced as a
// NullReferenceException in DisposeAsync rather than as the connection failure it was.
StartDockerServices("postgresql", "sqlserver");

RunTestProjects([tests, httpTests, incidents, processManager]);
RunTestProjects([tests, httpTests, storeOnlyTests, specWriterTests, incidents, processManager]);
});

Target CIKafka => _ => _
Expand Down
3 changes: 2 additions & 1 deletion build/build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,8 @@ partial class Build : NukeBuild
Solution.Persistence.Fisher.Wolverine_Fisher,
Solution.Persistence.InMemory.Wolverine_InMemory,
Solution.Bobcat.Wolverine_Bobcat,
Solution.Bobcat.Wolverine_Bobcat_Http
Solution.Bobcat.Wolverine_Bobcat_Http,
Solution.Bobcat.Wolverine_Bobcat_EventModel
};

// GH-3905: a project can declare a <PackageId> and still never ship, because the Pack list above
Expand Down
5 changes: 3 additions & 2 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,8 +651,9 @@ A few things to know:

### More WolverineFx.Bobcat: several stores, context, and foreign events <Badge type="tip" text="6.50" />

`WolverineSpec` and `WolverineScenario` inherit everything Bobcat's `CritterStackSpec` does, and a few of those steps
are worth knowing about from the Wolverine side:
`WolverineSpec` and `WolverineScenario` inherit everything the store-only `CritterStackSpec` does (it ships in WolverineFx.Bobcat
too, under the same `Bobcat.CritterStack` namespace, since it moved out of Bobcat core), and a few of those steps are worth
knowing about from the Wolverine side:

* **Several event stores.** On a host with a main store and an ancillary one, such as Marten's
`AddMartenStore<IInvoicingStore>()`, each Given and Then goes to the store whose registrations claim its type. When the
Expand Down
24 changes: 19 additions & 5 deletions docs/tutorials/event-modeling.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,14 +264,13 @@ dotnet tool install -g Bobcat.Console
```

Then point it at the board export and the application project. For CritterCrush, we only want two of the board's
chapters, and we want the specifications too:
chapters:

```bash
bobcat import-event-model K9CRUSH.emlang.v3.yaml \
--model CritterCrush --namespace CritterCrush \
--out ./CritterCrush \
--chapter BookingAppointments VolunteeringAndHomeChecks \
--specs --store marten
--chapter BookingAppointments VolunteeringAndHomeChecks
```

::: warning
Expand Down Expand Up @@ -363,8 +362,23 @@ settles it. That's what makes the import usable from an AI agent or from Stoat.

## Generated Specifications

With `--specs`, the import also writes one [WolverineFx.Bobcat](https://bobcat.jasperfx.net/integrations/wolverine)
specification per example on the board into a sibling `CritterCrush.Specs` project, with a folder per chapter. The
Wolverine's `write-specs` command writes one [WolverineFx.Bobcat](https://bobcat.jasperfx.net/integrations/wolverine)
specification per example on the board into a sibling `CritterCrush.Specs` project, with a folder per chapter, and the
spec project itself when there is none yet. It comes with the `WolverineFx.Bobcat.EventModel` package (it was
`bobcat import-event-model --specs` before the spec writer moved to Wolverine). Reference that package from the
application, then run it with the same board and the same flags you gave the import:

```bash
dotnet run --project ./CritterCrush -- write-specs K9CRUSH.emlang.v3.yaml \
--model CritterCrush --namespace CritterCrush \
--out ./CritterCrush \
--chapter BookingAppointments VolunteeringAndHomeChecks \
--store marten
```

A specification can name a member the board only gives in an example, so the command merges those members into the
stubs the import wrote, the same additive way a re-import does. Running it again adds the specifications for the
board's new examples and changes nothing else. The
`AppointmentConfirmed` example from the board becomes this:

```csharp
Expand Down
Loading
Loading