SPIKE: Kekiri as a native MTP test runner - #37
Draft
chris-peterson wants to merge 7 commits into
Draft
Conversation
chris-peterson
force-pushed
the
spike/mtp-native-runner
branch
from
August 20, 2026 18:15
6dedcd8 to
51de69d
Compare
A working ITestFramework on Microsoft.Testing.Platform, to see what a BDD runner that owns its own output and navigation would look like. Not for merge, and deliberately outside Kekiri.slnx so a solution-wide dotnet test stays green -- the example fails one scenario on purpose, because terminal reporters only print a test's output when it fails, and that output is the point. The runner is four files, and the example project references neither xunit nor NUnit nor Microsoft.NET.Test.Sdk. Discovery and execution were always Kekiri's work; what owning the TestNode buys is that the Gherkin shape travels as data rather than as console text. The scenario title becomes the display name, the Given/When/Then lines ride along as StandardOutputProperty so a failure shows the scenario instead of only a stack trace, the feature becomes a trait for grouping, and [CallerFilePath] on [Scenario] gives an IDE somewhere to navigate without reading a PDB. Three things the protocol won't do, each found by trying it: - A container node counts as a test. Publishing a "Feature:" node to group scenarios made --list-tests report 5 tests for 3 scenarios, so grouping has to be a trait. - ParentTestNodeUid exists on TestNodeUpdateMessage and the docs describe it as enabling future tree-based features, but the terminal reporter flattens it today. A real Feature -> Scenario -> Step tree is a capability without a consumer. - DisplayName cannot hold newlines; they render as literal escapes. Also worth recording: TestMethodIdentifierProperty takes a method-arity argument that the published signature in the docs omits. IDE behavior is unverified and is the open question. Everything here was checked through the terminal reporter and --list-tests; whether Rider, Visual Studio and VS Code group by the Feature trait or honour the file location can't be answered from a shell. The runner reaches the Gherkin text through an InternalsVisibleTo and an internal IReportTarget, which is the one change outside the spike directory. Supporting this properly would mean a public contract for "what steps did this scenario run", plus step-level timing -- TimingProperty already takes StepTimingInfo[], and the docs call it out for exactly this shape, but Kekiri times the scenario rather than the steps.
The scenario text becomes the progress report instead of something only a
failure reveals:
Feature: Addition
Scenario: Adding 50 and 70
Given a calculator
And the user enters 50
And the user enters 70
When adding
Then the result is 120
passed (2ms)
This works because the platform's own reporter prints nothing per test at
default verbosity, so the Gherkin is the output rather than competing with
it. It goes through IOutputDevice -- the service extensions use to write to
the terminal -- rather than Console.Write, so the platform still owns
ordering and redirection.
Kekiri wraps a step failure in its own exception whose first line names the
scenario, so the summary line walks to the innermost exception; otherwise
every failure reads "Error in 'Adding_two_numbers':" and never says why.
The limitation worth knowing: dotnet test swallows this. MTP substitutes a
passthrough output device there, and --output Detailed doesn't bring it back
(that flag also collides with dotnet test's own --output). So the format is
available to a direct run and a watch loop, not to CI through dotnet test as
it stands. IDEs are a separate question either way, since they read the
protocol's nodes rather than this console stream -- which is why the step
text is also attached as StandardOutputProperty.
A step failure arrives wrapped twice: ThenFailed names the step, and
ScenarioException prefixes the scenario. Reported as-is, the reader met the
wrapper's message and five frames of Kekiri internals before the arrow that
led to what actually went wrong.
Error in 'Adding_two_numbers':
'the result is 120' failed
at Kekiri.Impl.ScenarioRunner.InvokeThensAsync()
at Kekiri.Impl.ScenarioRunner.RunAsync()
at Kekiri.ScenarioBase.RunAsync()
at Kekiri.ScenarioBase.RunAsync()
at Kekiri.Mtp.Scenario.RunAsync()
---> System.Exception: Expected 120 but got 50
The node now carries the innermost exception, so the first line is the cause
and the top of the stack is the step the reader wrote:
Expected 120 but got 50
at ...Adding_two_numbers.the_result_is_120() in Adding_two_numbers.cs:38
Nothing is lost by dropping the wrapper, because the scenario and step names
are what the Gherkin output is already showing -- and it now marks the failing
step in place rather than appending a verdict at the end:
When adding
x Then the result is 120
Expected 120 but got 50
Finding which step failed needed the step name, which existed only inside the
message text. ScenarioException carries it as StepName now, rather than the
runner matching on prose that exists to be read. Both types are internal, so
no public surface changes, and the message strings are untouched -- but this
is a change outside the spike directory and belongs on the migration branch
rather than here if it's kept.
Cyan feature headers, green on a passing verdict, red on a failing step and its cause. RSpec's colours, so a run is scannable without reading it. Set through FormattedTextOutputDeviceData.ForegroundColor rather than by writing ANSI, which leaves the platform deciding when colour is wanted: piping the run emits no escape codes at all, and --no-ansi and CI are handled without this code knowing about either. The cost is that checking it needs a terminal, since a captured run is colourless by design -- script -q /dev/null against the executable shows the codes. The blank separator lines are written separately from the coloured text. With the newline inside the coloured span the escape landed before it, so a colour code sat on an empty line; it rendered correctly but read badly in a captured run.
Matches the MTP example alongside it. The shipped packages stay on net8.0; only this example project moves, and a net10.0 consumer of net8.0 packages is the normal direction. CI installs both runtimes already.
Rider ran the executable and rendered its output, but listed no tests for the project, which makes the whole approach a non-starter however good the console output is. Microsoft.NET.Test.Sdk is what was missing, and it is the only thing in the graph that sets IsTestProject=true. The Testing Platform capabilities Rider talks over -- TestingPlatformServer and TestContainer, declared by Microsoft.Testing.Platform's own targets -- already arrived through the ProjectReference to Kekiri.Mtp, so those were never the gap; a two-project probe confirms IsTestingPlatformApplication is true without the package. JetBrains names the same package as the workaround in RIDER-129745. Referencing it means GenerateProgramFile=false, since Program.cs writes its own Main. With a tree to look at, three things it showed were worth fixing: The tree grouped by the full namespace path and then the declaring fixture. Gherkin has two levels, so the feature now goes into TestMethodIdentifierProperty as the type with an empty namespace, and the tree reads Addition -> Adding 50 and 70. A fixture is a C# artifact rather than part of the spec, and one feature's scenarios are usually spread over several of them. Identity still travels in Uid and navigation in TestFileLocationProperty, so neither depends on that property naming a real type. Names arrived as identifiers. Names.Sentence renders both conventions a suite mixes -- Adding_two_numbers and AddingTwoNumbers both read "Adding two numbers" -- and leaves an all-capitals run alone so HTTP stays HTTP. A failure carried the frames Kekiri used to reach the step method underneath the step itself. StackTraceFilter drops those, as xUnit and NUnit both do. It has to hand the reporter a substitute exception overriding StackTrace, because FailedTestNodeStateProperty takes the trace off the Exception it carries. Which field Rider reads the grouping from is still untested: the Uid spells out the same type, so only one of the two has to be right.
chris-peterson
force-pushed
the
spike/mtp-native-runner
branch
from
August 21, 2026 13:58
51de69d to
13951c2
Compare
Pointing the runner at a 2839-scenario suite found the gaps four example scenarios never could, and the shape that came out of fixing them is a product rather than a spike. This commit is that: what the real suite demanded, and the name and packaging it gets as a result. Kekiri's runners lived inside a host test framework, so the packages were split along that seam: an engine, plus one runner package per framework. With discovery and execution owned outright there is one runner, and the split has nothing left to express -- the IoC packages are the only other consumers of the engine, and they are used exclusively from inside a scenario project that already carries it. Engine and runner fold into a single `Behavior` package, with `Behavior.Autofac` and `Behavior.ServiceProvider` beside it, all at 0.1.0. The new name comes with new package ids on purpose. `Kekiri`, `Kekiri.Xunit`, `Kekiri.NUnit` and the `Kekiri.IoC.*` packages stay on NuGet at the versions they are today, so an existing suite cannot be moved onto this by an update -- migrating is an explicit act, and docs/migrating.md is what it takes. The repository keeps its name, so bookmarks, the docs site path and the wiki all still resolve. What the suite run demanded, in the order it surfaced: Example rows. 2397 of those 2839 tests are [Example] rows, so the runner reached almost none of the suite without them. There is no separate outline attribute: xUnit needed Fact and Theory to be different things and dispatched discovery per attribute, and neither constraint survives owning discovery, so [Scenario] is the only marker and its [Example] rows decide whether it expands. Two things the naive version got wrong: an attribute argument keeps the type its literal had, so an [Example(2)] against a decimal parameter arrives boxed as int and MethodInfo.Invoke rejects it -- reflection does none of the widening a call site would; and ordering rows by their title alphabetized them, so the tree disagreed with the Examples table. Reaching the recorded Gherkin was reflecting for a non-public field, which finds nothing when it is declared on a base class rather than the test's own type. The example suite never noticed because it subclasses Scenarios directly; a real one goes four deep. It is an interface now. Tags, because the suite tags scenarios with a spec reference and Gherkin tags are the same idea. [Tag(name, value)] carries any pair and [Category(value)] is the shorthand every .NET runner already spells that way; both become TestMetadataProperty. The feature joins them in FilterProperties, so a filter can name the grouping the reader sees rather than only the tags. Rider needed two corrections. Feature-as-type grouping is out: TestMethodIdentifierProperty has to name a type that exists, and substituting a feature (empty namespace, spaces in the name) costs the whole tree, which an IDE reports as no tests rather than as a bad identifier. Feature grouping rides on metadata instead, which is a value rather than an identifier. And the filter on each request was ignored, which is what made "run this one test" run all of them: TestNodeUidListFilter is how an IDE asks for one scenario. --treenode-filter is handled too, and needed registering. Failure reporting told the wrong story for an expected exception. Reporting the innermost exception is right for the step wrappers, whose whole message is "'<step>' failed", and wrong for the exception-expectation wrappers, which are themselves the diagnosis and hold the cause as their inner. Unwrapping past WrongExceptionType or ExpectedExceptionNotCaught reports the exception the scenario asked for as though it were the failure, so a test reads as failing for the reason it was written to expect. Only the step wrappers carry a StepName, so that is the discriminator rather than a list of type names. The remaining case had no message at all: ScenarioException.Message opens with an "Error in '<scenario>':" header and puts the cause on the second line, so taking the first line yielded a header naming a scenario already on screen. The class now exposes the bare Reason, since it knows both halves and splitting the string back apart would be guesswork. Also dropped from a trace: the runtime's InvokeStub_ frames, named after the type they were emitted for so they share no namespace with the System.Reflection entries already filtered; and the trace entirely when every frame is machinery, which is the truth for a compliance failure detected after the last step rather than inside one. The messages themselves are shortened to fit a tree row, and to read as one family: every one now opens with "Expected", so a truncated row still says what was wanted. They use the short type name, which WrongExceptionType already did -- the fully qualified name in the other two was the odd one out, and it cost 60 characters before the sentence reached its verb. Catch<T> keeps its type argument, so the message names the call that is missing rather than describing it. The rename broke one thing nothing covered: the frame filter dropped stack frames whose namespace started with the framework's own root, and that root is now a prefix a consumer might plausibly write -- every frame the reader wrote would have been stripped from a failure. The filter names the three internal namespaces instead, and a TUnit suite covers that along with naming, discovery, tags, filtering and execution.
chris-peterson
force-pushed
the
spike/mtp-native-runner
branch
from
August 24, 2026 23:16
13951c2 to
68fe204
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Discovery and execution are Kekiri's own work; they just reach the outside world
through xUnit's or NUnit's extensibility points, which is why
Kekiri.XunitandKekiri.NUnitboth exist to do the same job twice. This spike asks whether thehost framework is needed at all.
Kekiri.Mtpis anITestFrameworkonMicrosoft.Testing.Platform, so a test project references it and nothing else: no
xUnit, no NUnit, no VSTest adapter.
What that buys is a scenario's Gherkin shape reaching the runner, the reports and
the IDE as data rather than as console text, which is what makes a BDD-shaped
tree and an RSpec-style report possible. Not for merge: it's a spike, kept out
of
Kekiri.slnxso a solution-widedotnet teststays green.Depends on #36, which has to merge first (GitHub won't enforce that).
Review guide
Start here, the runner itself
KekiriTestFramework.csL50 is the whole contract: a discover request and a run request. L133 is where a scenario becomes aTestNodeScenario.csL42, discovery, and where a[ScenarioOutline]expands to one scenario per[Example]rowTestApplicationBuilderExtensions.csL19, the entire consumer-facing API:builder.AddKekiri()Worth your attention
FailureReport.csL66, which wrappers get unwrapped.GivenFailed/WhenFailed/ThenFailedare noise, butWrongExceptionTypeandExpectedExceptionNotCaughtare the diagnosis, and unwrapping past those reports the exception the scenario asked for as though it were the failure.StepNameis the discriminatorScenario.csL201, an[Example(2)]against adecimalparameter arrives boxed asint, because reflection does none of the widening a call site wouldScenario.csL126,TestMethodIdentifierPropertyhas to name a type that exists. Putting the feature here (empty namespace, spaces in the name) cost the entire test tree, and Rider reports that as "no tests" rather than as a bad identifier. Feature grouping rides on theFeaturetrait insteadScenarioFilter.csL15,TestNodeUidListFilteris how an IDE asks for one scenario. Ignoring the filter is what makes "run this test" run all of themChanges outside the spike directory. These live in
Kekiricore and affect thexUnit and NUnit runners' output too, so they need a call on where they belong:
ScenarioException.csL9 addsStepName(which step failed) andReason(the message without itsError in '<scenario>':header). Both are read-only additions on aninternaltypeExpectedExceptionNotCaught.csL8, the exception-expectation messages now open withExpectedand use the short type name, so a truncated tree row still says what was wanted.WrongExceptionTypealready did bothContext, at your leisure
README.md, the findings, including what the protocol won't do and what a real version would needGherkinFormatter.csL28, the RSpec-style outputKekiri.Examples.Mtp.csprojL21, two packages that look like VSTest leftovers and aren't.Microsoft.NET.Test.Sdkis the only thing that setsIsTestProject=true, andMicrosoft.Testing.Platform.MSBuildsupplies theInvokeTestingPlatformandTesttargets. Without them an IDE shows an empty test explorer while the executable runs fineApproach & trade-offs
Grouping can't be Gherkin-shaped. MTP gives a framework no way to name its own
tree levels. The namespace/type in
TestMethodIdentifierPropertyis the only leverand it must name real code; a container
TestNodegets counted as a test(
--list-testsreported 5 for 3 scenarios); andParentTestNodeUidhas noconsumer yet. So the tree is namespace/fixture, and
Featureis a trait.dotnet testswallows the RSpec output. MTP substitutes a passthrough outputdevice there, so the format is available to a direct run and a watch loop, not to
CI through
dotnet test.Validation
Run against a large unit suite
swapping
using Kekiri.Xunitforusing Kekiri.Mtpand replacing its[assembly: AssemblyFixture]with aProgram.csthat callsAutofacBootstrapper.Initializedirectly:[Scenario]plus 2397[Example]rows[Fact]/[Theory]tests, which have no equivalent here; 24 of 95 Kekiri projects have themThat exercise is what surfaced outlines, argument coercion, traits, filters and the
failure-reporting fixes. Four example scenarios found none of them.