Status: Ready for implementation — design drafted and all feasibility spikes resolved 2026-08-05. §6 test-result correlation confirmed live for all three IDEs (VS/VS Code via a
dotnet testspike, Rider independently via Chris's devcontainer run); §7 items 1, 3, 5 resolved by decompilation; item 6 resolved as a live-confirmed design constraint (target the whole parameterized method, not one row). Item 2 (Rider's exact test-framework plugin-API accessor) turned out, on later investigation (2026-08-27), to be based on a wrong hypothesis — see §6's correction: Rider's unit testing has no JVM-side extension point for native tool-window integration at all, not pursued. Item 4 (breakpoint/DAP) remains explicitly out of scope. Neither blocked the design actually shipped (own-execution CodeLens/ CodeVision for all three IDEs, later reverted for VS Code specifically — see §5's VS Code note). Audience: Core team contributors Tracks: #262 Scope decisions confirmed by Chris (2026-08-05): in scope now (feasibility spike first, not deferred); Scenario Outline example rows follow theallowRowTestsreqnroll.jsonsetting, not a fixed UX choice; generated tests live in<feature>.feature.cs, a build output, method names derived from scenario titles; breakpoint/DAP support (§7 item 4, Appendix B) explicitly out of scope for this issue; VS Code goes with Option 2 — noTestController, CodeLens + custom gutter decorations only, no native Test Explorer tree presence (§5/§6) — chosen over owning aTestControllerto avoid duplicate entries against the .NET/C# Dev Kit testing extension's own listing of the same generated methods, since VS Code's Testing API has no way to delegate execution to another extension's controller and read its result back (confirmed againstvscode.d.tsand VS Code's own command source — see §5).VS Code — final status (issue #504, 2026-08-27): no run mechanism of its own. The full arc: shipped as Option 2 above → migrated to an owned
TestControllerwhen #504 showed C# Dev Kit's discovery already addresses.featurelocations → reconsidered and fully reverted (this update) once live use showed theTestControllerwasn't adding value C# Dev Kit's own gutter/Test Explorer integration doesn't already provide. VS Code's Reqnroll extension now has no Run/Debug/CodeLens/TestController for scenarios at all — entirely deferred to C# Dev Kit, including its known discovery flakiness (§6's live-testing note on this being unreliable across a VS Code relaunch stands, but is accepted as C# Dev Kit's own problem to fix, not this extension's to work around). VS and Rider are unaffected by this — see their own sections in §5/§6.
Three IDE-side, mostly-independent UX pieces, all keyed off the same underlying question — "which
generated C# test method does this .feature scenario (or Scenario Outline example row) correspond
to?":
- Run/debug gutter affordance on each
Scenario:/Scenario Outline:line, invoking the IDE's own native test runner against the mapped method. - Pass/fail indicator on the same line, reflecting the last run's outcome for that scenario (or, for an Outline, an aggregate/per-row breakdown).
- Failed-step gutter mark on the specific step line a failed scenario stopped at, with a hover tooltip carrying the captured error.
None of this is an LSP feature — LSP has no run/debug/test-result vocabulary. What is shared and worth building once is the mapping layer (§3): every IDE needs the same scenario→test-method resolution, and getting it right (Outline row-vs-parameterized behavior, generated-name sanitization) is the one piece of this issue that's fiddly enough to be worth centralizing rather than reimplementing three times.
Confirmed by decompiling Reqnroll.Generator.Generation.UnitTestMethodGenerator
(Reqnroll.Generator.dll, shipped in the Reqnroll.Tools.MsBuild.Generation package) and
cross-checking against this repo's own generated .feature.cs specs — not inferred from docs.
Regular scenario. Test method name = scenario.Name.ToIdentifier() (the generator's own
identifier-sanitization helper — strips/replaces characters invalid in a C# identifier). The
DisplayName trait/attribute carries the scenario title verbatim. Every generated step statement is
wrapped in its own #line N "path/to/File.feature" pragma pointing at that step's real source line.
Scenario Outline, allowRowTests = true (the default we observed live in this repo's own specs —
xUnit [Theory] + one [InlineData(...)] per Examples: row; NUnit/MSTest providers use their own
row-attribute equivalents through the same IUnitTestGeneratorProvider.SetRow call). One method,
named scenarioOutline.Name.ToIdentifier(). DisplayName is the outline title for every row — a
specific row has no name of its own; its identity at runtime comes only from the parameter values in
that row's data attribute. The method's steps still get per-step #line pragmas, but those point at
the Outline's own step lines (shared across all rows), not at anything row-specific.
Scenario Outline, allowRowTests = false (GenerateScenarioOutlineExamplesAsIndividualMethods).
One method per example row, named:
{scenario.Name.ToIdentifier()}_{exampleSetIdentifier}_{variantName.ToIdentifier()}
variantName= the row's first cell value, if first-cell values are unique across theExamples:block; otherwise"Variant {index}"(0-based).exampleSetIdentifier= theExamples:block's ownName:if given;null(folded out of the name entirely) if there is exactly one unnamed block;"ExampleSet {n}"if there are multiple unnamed blocks.DisplayName="{scenario title}: {variantName}".
Row-tests mode is not just the allowRowTests setting. Decompiled from
UnitTestFeatureGenerator.CreateTestClassStructure, the actual flag passed into
TestClassGenerationContext is:
_testGeneratorProvider.GetTraits().HasFlag(UnitTestGeneratorTraits.RowTests) && _reqnrollConfiguration.AllowRowTests— an AND of the config setting and the target framework provider's own declared capability. All
five of Reqnroll's shipped providers currently declare RowTests, so in practice this AND is a no-op
today (every supported framework supports row tests) — but the resolver should still implement it as an
AND against a real capability lookup, not hardcode "always true", since that's one provider-version
bump away from changing per framework.
Declaring class name — confirmed by decompiling UnitTestFeatureGenerator.GenerateUnitTestFixture:
string.Format(TestClassNameFormat, reqnrollFeature.Name.ToIdentifier()) with
TestClassNameFormat = "{0}Feature" by default, i.e. {feature.Name.ToIdentifier()}Feature — matches
the observed sample (Discovery - Platform Compatibility → Discovery_PlatformCompatibilityFeature).
Namespace defaults to ReqnrollTests unless the MSBuild-driven code generation supplies a target
namespace (normally the project's root namespace plus relative folder path) — the resolver needs that
from existing project/namespace-resolution infrastructure, not from this generator class itself.
Row-attribute name per test-framework provider — decompiled directly from each provider's SetRow.
Caveat that cost a wrong first pass: several frameworks resolve their actual provider type by a
string key (UseUnitTestProvider("mstest")/"nunit"/"xunit"/etc.) registered by a small
per-framework Reqnroll.<Framework>.Generator.ReqnrollPlugin.dll, separate from the core
Reqnroll.Generator.dll. For MSTest specifically, that string key resolves to MsTestV2GeneratorProvider
(or MsTestV4GeneratorProvider when the project's TargetMsTestVersion is 4+, a subclass that only
overrides class-cleanup/display-name details) — not the plain MsTestGeneratorProvider base class
that also lives in Reqnroll.Generator.dll and is what a naive type-name search finds first. That base
class does throw NotSupportedException from SetRow/SetRowTest and declares GetTraits() => None,
but it's dead weight from this project's perspective — never the type actually instantiated for a
real MSTest project. Confirmed by decompiling MsTestV2GeneratorProvider, which does support row
tests via DataRowAttribute and declares RowTests | ParallelExecution, same as every other framework.
NUnit/xUnit's plugin DLLs register by name only (no version-specific subclass indirection), so their
Reqnroll.Generator.dll types decompiled below are confirmed correct as-is; TUnit/xUnit.v3 register
their provider type directly rather than by name, same conclusion.
| Framework | Row attribute | GetTraits() |
Notes |
|---|---|---|---|
| xUnit (v2) | Xunit.InlineDataAttribute |
RowTests | ParallelExecution |
Method carries Xunit.SkippableTheoryAttribute |
| xUnit.v3 | Xunit.InlineDataAttribute |
includes RowTests |
Method carries Xunit.TheoryAttribute (not the Skippable variant — v3 handles skip via the attribute's own Skip property) |
| NUnit3 | NUnit.Framework.TestCaseAttribute |
includes RowTests |
Method carries NUnit.Framework.TestAttribute |
| TUnit | TUnit.Core.ArgumentsAttribute |
includes RowTests |
Method carries TUnit.Core.TestAttribute + TUnit.Core.DisplayNameAttribute |
MSTest (MsTestV2GeneratorProvider/MsTestV4GeneratorProvider) |
Microsoft.VisualStudio.TestTools.UnitTesting.DataRowAttribute |
RowTests | ParallelExecution |
Method carries TestMethodAttribute. Quirk: for a single-column Examples: table, the generator inserts an unused dummy string parameter (notUsed6248) ahead of the tags parameter — a generator implementation detail, not something the resolver needs to reproduce, just be aware a single-arg row's parameter count differs from other frameworks' |
This resolves §7 item 5 for all five frameworks. The row-attribute allowlist in §3 should be seeded
directly from this table — and the resolver's own framework-capability lookup should be driven by the
same provider-name resolution mechanism (UseUnitTestProvider/TargetMsTestVersion), not by naively
matching the first class it finds with a plausible name, per the MSTest lesson above.
Flagged by Chris (2026-08-05) and confirmed against Reqnroll's actual open source
(reqnroll/Reqnroll, Plugins/Reqnroll.ExternalData), not taken on description alone. The
ExternalData plugin lets an Examples: block's row data live in an external file (CSV/XLS/etc.)
instead of the .feature file, selected by a tag. Its ExternalDataTestGenerator.ParseContent override
parses the .feature file completely normally first, then runs IncludeExternalDataTransformation on
the resulting in-memory AST, injecting synthetic TableRows read from the external file — entirely
inside Reqnroll's own generator pipeline, strictly after the point where the LSP server's own,
independent Gherkin parse of the same .feature text would already be done. The LSP server's AST never
sees the injected rows; they exist only in the generator's private copy.
The tracing turned up a wrinkle sharper than "an Examples: block shows as header-only on screen":
IncludeExternalDataTransformation.GetTransformedScenarioOutline explicitly handles a Scenario Outline: with no Examples: block at all (scenarioOutline.Examples == null || !scenarioOutline.Examples.Any()) by falling through to the same transform used for a genuinely plain
Scenario:, which constructs a brand-new ScenarioOutline from scratch using the external file's own
columns as the header. A plain Scenario: block, with the tag applied and no visible Outline
keyword or Examples: block whatsoever, can generate as a fully row-tests-parameterized method — the
on-screen file gives no indication it's parameterized at all beyond the step text possibly containing
<placeholder> tokens.
Why this doesn't break the design, and why it would have if §3 had gone the other way: the
architecture decision above — read the generated .feature.cs via Roslyn rather than predict names from
the .feature file's own structure — means the resolver was never going to trust the .feature file's
Examples-row count or Scenario/Scenario Outline keyword to determine parameterization in the first
place. It has to anyway, now, for a real reason rather than a defensive one: Tier 1 discovers whether a
scenario is parameterized and how many generated methods exist by finding methods on the class,
either by exact predicted name (row-tests mode: scenario.Name.ToIdentifier()) or by name-prefix match
(individual-methods mode: every method matching {scenario.Name.ToIdentifier()}_.*) — never by counting
.feature-file rows. This was already the plan; ExternalData is the concrete case that makes it a
requirement rather than a preference. One place in Tier 2 needs an explicit scope note as a result: the
"Nth row-attribute always corresponds to the Nth Examples: row in the .feature file" positional
claim (below) is about correlating a specific selected .feature row to a row-attribute index for
per-row addressing — and per-row addressing was already ruled out entirely in §7 item 6 (native runners
don't support it reliably even in the ordinary case). For an ExternalData scenario there's no .feature
row to select in the first place — no on-screen line exists to attach a per-row gutter action to — so
this doesn't need separate handling: the UI-level "only offer whole-scenario run, never per-row" behavior
for such scenarios falls out for free from gutter icons being rendered against the LSP server's own
Gherkin parse, which has nothing to render at a row that was never there.
What the resolver must not do: treat "0 rows visible in the .feature file's Examples: block" or
"no Examples: block at all" as "0 targets" or "not parameterized, use the plain-scenario naming rule."
Both are true of the .feature file's own AST and both are wrong conclusions about the generated code.
Resolution must always go through the generated .feature.cs, never short-circuit on the .feature
file's own shape. This generalizes past ExternalData specifically — any other generator plugin (first-
or third-party) that transforms the AST between parse and codegen has the same property, and the design
already accounts for the general case, not just this one plugin.
Two ways to compute the scenario→test-method mapping. Recommendation: read the generated
.feature.cs code-behind, don't re-predict it.
| Predict the name (reimplement §2's rules) | Parse the generated .feature.cs |
|
|---|---|---|
| Accuracy | Drifts silently if Reqnroll changes its sanitization/naming rules across versions | Always matches whatever this project's installed Reqnroll generator actually produced |
| Availability | Works even before a build | Requires the project to have been built at least once (code-behind is a build output) |
| Complexity | Have to track allowRowTests, ToIdentifier() semantics, and class-naming per Reqnroll version |
Ordinary Roslyn parse — same toolkit CSharpBindingDiscoveryService/StepDefinitionFileParser already use for .cs scanning |
The repo already has Roslyn-based .cs scanning infrastructure (LSP.Core/Parsing/CSharp) built for
binding discovery. Parsing <feature>.feature.cs reuses that pattern and stays correct automatically
as Reqnroll's generator evolves, at the cost of only working post-build (acceptable: a run/debug gutter
action is meaningless before the project has ever built anyway, since there's no test to run).
Predicting names is the fallback if we later find .feature.cs isn't reliably present at the point the
gutter needs to render (e.g., before any build in a fresh clone) — worth revisiting if that turns out
to matter in practice, but not the starting design.
Correction (issue #491, 2026-08-26): "ordinary Roslyn parse" in the trade-off table above
understates the real cost at scale. RunTestCodeLensService calls reqnroll/resolveTestTargets
once per scenario/Outline/Examples-row in a file, and the original implementation re-read and
re-parsed the same, unchanged <feature>.feature.cs from scratch on every single one of those
calls. For a large stress-corpus feature file (~1,300 rows) this meant ~1,300 sequential Roslyn
parses of the same file, pegging a CPU core for the file's entire time open. The fix is a shared
ICSharpSyntaxTreeCache (LSP.Core/Parsing/CSharp/, see Architecture §3 item
4) that both ScenarioTestTargetResolver
and CSharpAttributeLiteralResolver (the Rename feature's analogous per-attribute case) now read
through, so repeated resolutions against the same unchanged file within one logical operation
reuse the same parse. The "post-build only" trade-off itself is unaffected by this — the cache's
disk-mode freshness check (last-write-time) is what makes it safe to cache across an actual
rebuild without a dedicated file watcher.
Correction (issue #495, 2026-08-26): #492's shared syntax-tree cache made one
reqnroll/resolveTestTargets call cheap (~150ms → ~3.6ms), but every IDE-side caller still issued
one such call per scenario in the whole file on every recompute, not just for the scenario(s)
actually needed — a leftover "resolve everything, then filter" shape from before per-line/per-lens
callers existed. On the VeryLargeFeature stress corpus (~2,000+ scenarios) that still meant a
30-45s wall-clock walk per recompute, independently slow enough to exceed VS's own classic-CodeLens
per-data-point timeout (~26s) regardless of how cheap any one resolution had become. Root-caused and
fixed per client, since each platform's own extensibility contract determines what's actually
possible:
- VS (classic CodeLens) —
RunTestCodeLensServicesplit intoGetTargetsForLineAsync(fileUri, line)(one resolution, called by each line's ownRunTestCodeLensDataPoint.GetDataAsync) andGetTagLocationsAsync(fileUri)(symbol tree only, zeroresolveTestTargetscalls, used only byRunTestCodeLensTaggerProviderto know which lines get a tag placement). The async data-point API was already per-line; the fix was ending the whole-file walk each data point triggered to serve itself, not adding new async plumbing. - VS Code —
runCodeLens.ts'sCodeLensProvidernow implements the standard two-phase contract:provideCodeLensesplaces one unresolved lens per scenario symbol (symbol tree only), andresolveCodeLens— which VS Code calls lazily, only for lenses that actually scroll into view — is where the singlereqnroll/resolveTestTargetscall for that one lens happens. VS Code's own API already supported this; the previous implementation simply never used theresolveCodeLenshalf of it. - Rider — IntelliJ's
CodeVisionProvider.computeCodeVision(editor, uiData)has no visible-range parameter and no per-entry resolve phase; it is always asked for the whole document, on the platform's own schedule. There is no lever to make Rider ask for only the visible lines. Instead,RunTestTargetCache(Rider/testrunner/) memoizes each(uri, line)'s resolved targets, keyed by a cheap identity string (scenario kind + name) built from the symbol tree —RunLensSupport.computeEntriesstill walks every scenario symbol on every call (unavoidable on this platform), but only re-sendsreqnroll/resolveTestTargetsfor a scenario whose identity actually changed since the last walk. The cache is invalidated wholesale by the samereqnroll/refreshCodeLensesnotification the sibling Hook/StepUsages CodeVision providers already act on (ReqnrollCodeLensRefreshInterceptor) — the real signal that an underlying resolution changed independent of the.featurefile's own text (e.g. a[Binding]method renamed in.cs).
Net effect: VS and VS Code now issue reqnroll/resolveTestTargets proportional to the number of
currently visible Run lenses, not the scenario count of the whole file. Rider still walks the whole
symbol tree per recompute (platform limitation) but skips the RPC for everything that hasn't changed.
"Parse the code-behind" is not one uniform operation, though — it splits into two tiers with very different stability, because Reqnroll ships five test-framework providers (xUnit, xUnit.v3, NUnit, MSTest, TUnit) with different attribute vocabularies and argument shapes that can also change across provider versions:
Tier 1 — method/class existence and FQN. Entirely framework-agnostic: CreateTestMethod/
CreateScenarioOutlineTestMethod (§2) always emit a plain C# method/class declaration regardless of
which IUnitTestGeneratorProvider is active — only the attributes decorating the method are
framework-specific. A Roslyn symbol walk that just resolves "does a method named X exist on type Y,
and what's its FQN" needs no per-framework knowledge at all. This alone is sufficient for: a plain
scenario, and "run the whole Outline" (invoke the Theory/parameterized method as a unit) in row-tests
mode.
Tier 2 — per-row correlation for row-tests Outlines. Assumes the .feature file's own Examples:
rows are what generated the row-attributes in document order — true for ordinary Outlines, not true
for a scenario transformed by an AST-injecting generator plugin like Reqnroll.ExternalData (see the
dedicated subsection above), where the .feature file may show zero rows or no Examples: block at
all. This is fine in practice only because per-row addressing was already ruled out for every IDE in §7
item 6 regardless of this case — Tier 2's positional correlation is used for counting rows to decide
between "run the whole method" and nothing more, not for selecting a specific row to run, so it degrades
safely (falls back to "run the whole method," same as the ordinary per-row-addressing fallback) rather
than needing separate handling for the ExternalData case. This is where framework-specific attribute
shape would normally bite — but it can be sidestepped almost entirely. The decompiled generator
(GenerateScenarioOutlineExamplesAsRowTests, §2) iterates scenarioOutline.Examples then each
example.TableBody in document order, calling _unitTestGeneratorProvider.SetRow exactly once per
row, in that order, with no reordering afterward. That ordering guarantee means: the Nth row-attribute
instance on the generated method always corresponds to the Nth Examples: row in the .feature file
— a fact of the generator's own control flow, not something that has to be reverse-engineered from
attribute argument contents. So the resolver never needs to parse InlineData("net8.0", "0", ...)-style
argument literals at all — it only needs to count row-attribute instances on the method, keyed by
the allowlist confirmed in §2 (InlineDataAttribute for xUnit/xUnit.v3, TestCaseAttribute for NUnit3,
ArgumentsAttribute for TUnit, DataRowAttribute for MSTest), and correlate by position against the
.feature file's own Examples: rows — which the LSP server already has from its native Gherkin parse,
independent of the C# side entirely.
That allowlist is fully known now (§2 table) — bounded by Reqnroll's five supported providers, and only
needs a new entry when Reqnroll adds or drops a supported test-framework provider, not on ordinary
framework point releases, since a given installed Reqnroll version's provider always emits one fixed
attribute type for that framework. LSP.Core/TestTargets/ needs a small
IReadOnlyDictionary<TestFramework, string> RowAttributeTypeName table seeded from §2, sourced from
whatever mechanism the project's referenced test framework is already detected by (assumption to
confirm — F2 binding discovery may already resolve this from package references; if not, it needs its
own detection step here — and, per the MSTest lesson in §2, that detection needs to resolve the actual
provider the project's Reqnroll.<Framework>.Generator.ReqnrollPlugin wires up by name, including
MSTest's TargetMsTestVersion-driven V2-vs-V4 split, not just the referenced NuGet package). The
resolver's row-tests-vs-individual-methods branch should still replicate the generator's own
GetTraits().HasFlag(RowTests) && config.AllowRowTests logic (§2) rather than assuming row tests are
always available — currently true for all five frameworks, but worth keeping as a real capability check
rather than a hardcoded assumption.
Residual risk this does not eliminate, and the sharper version of the concern: even with positional row-correlation solved on our side, invoking "run row N specifically" still goes through each IDE's native test-explorer API, and some of those APIs address a specific parameterized case only by matching the runner's own formatted display name for that case (which itself embeds the row's argument values, formatted per test-framework-and-version) rather than by a stable positional index. If that's the only addressing mechanism a given IDE/framework combination exposes, framework-version formatting differences leak back in at the invocation layer even though our resolver itself stayed framework-agnostic there. This needs checking per IDE alongside the §6 test-result-channel spike — if positional/index-based invocation isn't reliably available, the fallback is "Outline gutter runs the whole parameterized method, not an individual row" rather than attempting brittle display-name matching.
New core service, LSP.Core/TestTargets/:
public interface IScenarioTestTargetResolver
{
IReadOnlyList<ScenarioTestTarget> Resolve(Uri featureUri, GherkinRange scenarioRange);
}
public sealed record ScenarioTestTarget(
string DeclaringTypeFullName, // e.g. Discovery_PlatformCompatibilityFeature
string MethodName, // generated method name
bool IsParameterized, // true for the row-tests Outline case
IReadOnlyDictionary<string,string>? RowArguments, // present only when resolving a specific example row
int? RowIndex); // 0-based index into the data-attribute list, for parameterized methodsFor a plain scenario this resolves to exactly one target. For a Scenario Outline, resolving at the
Scenario Outline: line returns every row's target (so the IDE can offer "run all rows"); resolving at
an individual Examples: row returns just that row's target — in row-tests mode, same MethodName for
every row, distinguished by RowIndex/RowArguments; in individual-methods mode, a distinct
MethodName per row.
Following the existing custom-request pattern (F17's
reqnroll/goToHooks, F25's
reqnroll/goToMatchingScenarios), named after the message per [[protocol-handler-naming]]:
| Direction | Method | Purpose |
|---|---|---|
| Client → Server | reqnroll/resolveTestTargets (uri, range) |
Resolve the generated test method(s) for a scenario or Outline range |
| Server → Client | ScenarioTestTarget[] |
See §3 shape |
Handler: ResolveTestTargetsHandler in LSP.Server/Features/TestTargets/, thin wrapper delegating to
IScenarioTestTargetResolver. No new registration-option surface needed (not a standard LSP capability;
a plain workspace/executeCommand-style custom request is enough, matching F17/F25).
| VS Code | Visual Studio | Rider |
|---|---|---|
🔧 Plugin — CodeLens + custom gutter decorations (own execution, no TestController) |
🔧 Plugin — Test Explorer editor-margin KnownMonikers |
🔧 Plugin — CodeVisionProvider (as-built; see note below — RunLineMarkerContributor wasn't viable) |
All three reuse each platform's own run/debug/pass/fail glyph set rather than inventing Reqnroll-branded icons (see the issue's own survey — no distinct Gherkin/BDD icon convention exists elsewhere to match), except VS Code (below), which owns its glyph rendering since it isn't going through the native Testing UI.
VS Code note: historical record only, not current. Everything below in this VS Code bullet (Option 2, the CodeLens, the analysis that led to it) describes what was actually shipped and later removed. See the top-of-document status block ("VS Code — final status") for where this landed: no run mechanism of VS Code's own at all, fully deferred to C# Dev Kit. Kept here because the
vscode.testsAPI research is still accurate and would need re-doing if this is ever revisited.
-
VS Code — decided against delegating to C# Dev Kit's
TestController(Option 2). Investigated whether the CodeLens could invoke the existing .NET/C# Dev Kit testing extension's own run/debug machinery directly, mirroring VS's approach (below) — checked againstvscode.d.tsand VS Code's own command source rather than assumed. Two findings closed this off:- No cross-controller result read. The entire
vscode.testsnamespace exports exactlycreateTestController— there is no API to enumerate another extension's controller or read itsTestRunresults. Each controller's pass/fail/message data is private to the extension that created it. (Confirmed directly againstvscode.d.ts; an earlier guess that atests.testResultssnapshot API existed was wrong and has been removed from this doc.) - Triggering via the generic run-at-cursor command is possible but comes with a real cost, and
still doesn't solve (1).
testing.runAtCursor/debugAtCursor(ExecuteTestAtCursorin VS Code's owntestExplorerActions.ts) dispatch through the centralITestService, which does span all registered controllers — so it could reach a test item C# Dev Kit's controller owns. But it requires a real, focused code editor at the exact cursor position (codeEditorService.getActiveCodeEditor()editor.getPosition()) — there's no command form that accepts an arbitrary URI+range without actual editor focus. Triggering it from a.featureCodeLens would mean navigating to the generated method's line in.feature.csfirst, likely a visible tab switch away from the.featurefile the user is looking at. And even if that UX cost were accepted, finding (1) still means we'd have no way to read the result back afterward.
Decision (2026-08-05): Option 2 — own execution, no
TestController, no native Test Explorer tree presence.▶ Run/🐛 DebugCodeLensper scenario/row (same shape as F18's step-usageCodeLensProvider— new provider, no new plumbing pattern), callingreqnroll/resolveTestTargetsto resolve each lens, then shelling todotnet test --filter "FullyQualifiedName=..."directly against the resolvedDeclaringTypeFullName/MethodName(confirmed to precisely target one method — §6). Pass/fail and failed-step state are tracked entirely in our own extension state and rendered via customTextEditorDecorationTypegutter icons plus the CodeLens label, not throughvscode.TestRun/TestMessage. Trades away native Test Explorer tree presence for avoiding duplicate entries against C# Dev Kit's own listing of the same generated methods, and keeps VS Code's design fully within our own extension's control — no dependency on another extension's behavior or its future changes. Correction (issue #495): the resolution call happens inresolveCodeLens, notprovideCodeLenses— VS Code calls the former lazily, only for lenses that scroll into view, soprovideCodeLensesitself only ever places unresolved lens ranges from the symbol tree. See the §3 correction above. - No cross-controller result read. The entire
-
Rider: as-built, a
CodeVisionProvider(RunTestCodeVisionProvider/RunLensSupport), not theRunLineMarkerContributororiginally proposed here — this plugin registers.featurewith noParserDefinition/PSI tree at all (seeReqnrollFeatureLanguage's own doc comment), andRunLineMarkerContributoris inherently PSI-based, soCodeVisionProvider(operates onEditor/Documentoffsets, same as every other.featureeditor feature in this plugin) is used instead — the same substitution already made for the closely analogous hook-match-count lens (HookCodeVisionProvider). Rendered inline rather than as a gutter icon as a result (Rider'sCodeVisionProviderAPI doesn't offer a gutter-icon presentation), using ▶/✓/✗ glyphs in place ofAllIcons.Actions.Execute/TestState.Green2/Red2. Invokes Rider's native JVM-side test runner against the resolved method (RunTestRunner). Note the currentreqnroll/Reqnroll.Riderplugin has no scenario-level run marker today (reqnroll/Reqnroll.Rider#8), so there's no existing behavior to match or avoid conflicting with. See the §3 correction above for how this provider avoids re-resolving every scenario on every recompute, givenCodeVisionProviderhas no visible-range or per-entry-resolve hook to lean on the way VS Code'sresolveCodeLensdoes. -
Visual Studio — resolved, §7 item 3. There is no separate "Test Explorer editor margin" extension point to investigate — decompiling VS 18's own
Microsoft.VisualStudio.TestWindow.CodeLens.dllshows that VS's built-in run/debug/pass-fail affordance for ordinary[Fact]/[TestMethod]/[Test]methods is itself a classic CodeLens data-point provider (TestStatusProvider : AbstractTestProvider : IAsyncCodeLensDataPointProvider,[ContentType("CSharp")]/"Basic"/"C/C++"-scoped). It's the exact same API family (Microsoft.VisualStudio.Language.CodeLens) F24 already built a bridge for — no new extension-point research needed, reuse F24'sHookCodeLensDataPointProviderpattern (in-processITaggerProvider+ out-of-processIAsyncCodeLensDataPointProvider) directly, scoped toGherkincontent instead ofCSharp.Better still,
TestStatusProvider's glyphs come fromKnownMonikers.StatusOK/StatusError/StatusWarning/StatusAlert(not theKnownMonikers.RunTest/TestPassed/... set originally assumed here), and — this is the useful part — its "Run"/"Debug" actions in the Details popup are wired to VS's own internal Test Explorer commands, not anything provider-specific:new CodeLensDetailEntryCommand { CommandId = 898, CommandName = ".TestExplorer.RunTestsFromCodeLens", CommandSet = Guid.Parse("1E198C22-5980-4E7E-92F3-F73168D1FB63") } new CodeLensDetailEntryCommand { CommandId = 899, CommandName = ".TestExplorer.DebugTestsFromCodeLens", CommandSet = /* same GUID */ }
invoked via
CodeLensDetailPaneCommand { CommandId = ..., CommandArgs = new[] { testMethodIdentifier } }, wheretestMethodIdentifieris aMicrosoft.VisualStudio.TestWindow.TestMethodIdentifier(OutputFilePath, NormalizedFullyQualifiedName, ManagedType, ManagedMethod)— fields that map directly ontoScenarioTestTarget.DeclaringTypeFullName/MethodName(§3) plus the test assembly's build output path (already known to the LSP server's project model). Our own custom CodeLens data point for.featurescenarios can emit aCodeLensDetailPaneCommandagainst these same command IDs, letting Test Explorer itself do the actual run/debug — no VS-specific run invocation logic needed at all, just building the rightTestMethodIdentifier.TestMethodIdentifieraddresses a method, not an individual parameterized-test row (itsEqualsonly comparesOutputFilePath+ManagedType+ManagedMethodor the FQN) — confirms the §3 residual risk finding for VS specifically: row-tests Outline invocation targets "run the whole parameterized method," not one row, via this path.
§3 already has the resolver return every row's ScenarioTestTarget when reqnroll/resolveTestTargets
is called at the Scenario Outline: line, specifically so a gutter action there can run every example.
What that action actually does with those targets splits by generation mode:
Row-tests mode (default) — free. All targets share one MethodName. "Run all examples" is
identical to the plain "run this method" action already described above for a single scenario — the
native runner executes every InlineData/TestCase/DataRow/Arguments row as part of running that
one method. No extra invocation logic needed in any IDE; the Outline-level gutter action and the
single-scenario gutter action are the same code path.
Individual-methods mode (allowRowTests = false) — needs a real multi-target invocation, one per IDE:
- VS Code: owns its own
dotnet testinvocation, so this is the simplest case — OR the resolved targets' exact FQNs into one filter expression,dotnet test --filter "FullyQualifiedName=A|FullyQualifiedName=B|...", and parse each method's own stdout block from the combined output. (A~-contains prefix filter on the shared{scenario.Name.ToIdentifier()}_prefix, e.g.FullyQualifiedName~ThisScenario_, would also work and needs fewer terms, at the cost of a small collision risk if another scenario's generated name happens to contain the same substring — the exact-FQN OR list is safer and preferred.) - Visual Studio:
CodeLensDetailPaneCommand.CommandArgsis an array — the decompiledTestStatusProvideronly showed a single-element usage (new TestMethodIdentifier[1] { testMethod }), but.TestExplorer.RunTestsFromCodeLens/DebugTestsFromCodeLensare the same commands Test Explorer's own "run selected tests" multi-select action drives, so passing an N-elementTestMethodIdentifier[]built from all N resolved targets is expected to work the same way. Not yet confirmed — the VS live check already needed for §6's ServiceHub wiring should also verify multi-targetCommandArgsspecifically, since only the single-target shape has been observed in the decompiled source. - Rider: whether a single
RunLineMarkerContributoraction can target multiple discovered test items in one invocation, or whether it's constrained to one per marker, hasn't been checked — a follow-up for whoever implements the Rider side, likely resolvable by looking at how Rider's own built-in[Fact]/[Test]-class-level line marker (which already offers "run all tests in this class") is implemented, since that's the same shape of problem.
Substantially de-risked by research below, but each IDE still needs one live confirmation before implementation — none of this has been run against a real Reqnroll-generated test yet. Status per IDE:
Visual Studio pass/fail glyph — reconsidered and implemented via reflection (issue #504 follow-up,
2026-08-27). Initially decided against (see the superseded reasoning originally here): unlike
TestMethodIdentifier (public, already used by the shipped Run/Debug delegation — see §5),
ICodeLensTestInformationService, CodeLensTestInformationProxy, CodeLensTestInformationCallbackService,
and RemoteTestWindowServiceProvider are all internal to Microsoft.VisualStudio.TestWindow.Internal.dll —
not part of the public extensibility surface, no compile-time contract, no deprecation notice if a VS
servicing update reshapes or removes any of it. Chris subsequently decided the glyph was worth the risk
provided any failure degrades gracefully rather than crashing the CodeLens host.
As-built: RunTestOutcomeBridge (src/VisualStudio/.../RunTestCodeLens/RunTestOutcomeBridge.cs)
reflects into RemoteTestWindowServiceProvider.Instance.GetServiceStreamAsync → constructs a
CodeLensTestInformationProxy over the returned stream (passing a null callback target — this bridge
only polls, it never subscribes to change notifications) → invokes ICodeLensTestInformationService.GetTestOutcomeAsync
via the (also internal) interface's MethodInfo, since the concrete proxy implements it as an explicit
interface member. The outcome enum value is read back by name (.ToString()) rather than cast to the
real TestOutcome type, so even a renamed/reshaped enum degrades to "unrecognized" instead of an
InvalidCastException. Every step is wrapped in one try/catch; the first failure anywhere in the
chain sets a permanent-for-the-process _unavailable flag — no retry storm, no per-call reflection cost
once the API is known gone, and Run/Debug (§5, unaffected — public API only) keep working regardless.
Deliberately does not call the simpler AbstractTestProvider.GetServiceProxyAsync convenience
method VS's own TestStatusProvider uses: that method depends on a private static VS-process-id field
only populated once VS's own CSharp/Basic/C/C++-scoped test CodeLens providers have themselves run in
this ServiceHub host — not guaranteed for a user who only opens .feature files. The glyph mapping
itself (TestOutcome → KnownMonikers.StatusOK/StatusError/StatusWarning) needs no reflection —
KnownMonikers is a fully public, stable API, mirroring TestStatusProvider.ToImageId.
Visual Studio — API shape confirmed by decompilation; the stack-trace question is now live-tested,
and the answer is a correction, not a confirmation. The same ICodeLensTestInformationService that
backs TestStatusProvider (§5) is reachable over the identical out-of-process CodeLens ServiceHub
channel F24 already wired up (RemoteTestWindowServiceProvider → GetServiceStreamAsync →
ICodeLensTestInformationProxy) — now consumed via reflection, see the correction immediately above.
Task<TestOutcome> GetTestOutcomeAsync(Guid dataPointId, TestMethodIdentifier testMethod, CancellationToken ct);
Task<ICollection<CodeLensTestDetail>> GetTestDetailsAsync(TestMethodIdentifier testMethod, int limit, CancellationToken ct);GetTestOutcomeAsync gives the aggregate pass/fail/skip for the pass/fail gutter indicator directly —
no separate result channel to build, it's the same bridge as the run/debug affordance. GetTestDetailsAsync
returns CodeLensTestDetail { TestCaseRecord, TestResultRecords: ICollection<TestResultRecord>, Outcome, Duration }
— TestResultRecords is a collection, one entry per parameterized-test row for a row-tests Outline
method, each carrying Outcome, ErrorMessage, ErrorStackTrace, StandardOutput, StandardError,
DisplayName, TestCaseDisplayName (decompiled field list, Microsoft.VisualStudio.TestWindow.Records.TestResultRecord).
Live-tested (2026-08-05): built a throwaway Reqnroll 3.3.4 + xUnit project with a scenario whose
middle step (When) deliberately throws, ran dotnet test, and inspected the actual failure output.
The hypothesis that ErrorStackTrace would report the failing step's .feature:line — reasoned from
the #line-pragma mechanism in §7 item 4 — is wrong. The stack trace does contain a .feature:line
frame, but decompiling the generated .feature.cs explains why it's the wrong line: every step's
#line N pragma is immediately followed by #line hidden (§2's generator behavior), and the scenario's
final await this.ScenarioCleanupAsync(); call — where a previously-caught step exception is actually
re-thrown — sits inside that trailing hidden region. The CLR attributes the re-throw frame to the
last non-hidden #line before it, which is always the scenario's last step, regardless of which
step actually failed. Confirmed directly: a 3-step scenario failing on step 2 reported .feature:line 6
(the Then line) in ErrorStackTrace, not line 5 (the failing When). This is a generation artifact,
not a per-scenario coincidence — it will misattribute the failure to the wrong step on every scenario
where the failing step isn't the last one. ErrorStackTrace/PDB sequence points are not usable for the
failed-step mark.
The reliable signal, found in the same spike: Reqnroll's own step-by-step trace, already present in
TestResultRecord.StandardOutput by default — no reqnroll.json opt-in required (none was configured
in the throwaway project and the trace appeared anyway). It pairs each step's keyword+text with a
-> done: ... (0.0s) / -> error: ... (0.0s) / -> skipped ... outcome line, in step execution order:
Given a passing step
-> done: StepDefinitions.GivenAPassingStep() (0.0s)
When a failing step is executed
-> error: deliberate failure for stack trace inspection (0.0s)
Then this line is never reached
-> skipped because of previous errors
This is directly parseable and correlates unambiguously to the .feature file's own step order — which
the LSP server already has from its native Gherkin parse (§3), no C#-side line mapping needed at all.
This corrects and replaces the original §6(c) hypothesis (Reqnroll's trace.traceTimings config as an
"opt-in" requirement) — the default step trace already carries what's needed.
Full outcome-prefix vocabulary (decompiled from Reqnroll.Tracing.TestTracer, not inferred from
one example — the throwaway spike only exercised done/error/skipped; the Rider live check below
exercised two more the parser needs to handle):
| Prefix | Emitted by | Meaning |
|---|---|---|
done: {match} ({duration}s) |
TraceStepDone |
Step passed |
error: {message} ({duration}s) |
TraceError (via WriteErrorMessage) |
Step threw |
skipped: {message} |
TraceStepSkipped |
Step explicitly skipped (e.g. via a skip exception) |
skipped because of previous errors |
TraceStepSkippedBecauseOfPreviousErrors |
Step never ran — an earlier step in the scenario already failed |
pending: {match}: {message} |
TraceStepPending |
Step bound but marked pending (PendingStepException) |
binding error: {message} |
TraceBindingError |
Step text matched more than one binding (AmbiguousBindingException) or another binding-resolution failure |
undefined: {skeleton} |
TraceNoMatchingStepDefinition |
No binding matches the step text at all |
The parser needs to branch on all seven, not just the three seen in the original spike — binding error
and undefined in particular are failures with no underlying step method to attribute a stack trace
to at all, reinforcing that stdout-trace parsing (not stack-trace inspection) has to be the mechanism,
since those two outcomes have no C# frame to misattribute in the first place.
VS Code — decided: own execution, no TestController (Option 2, confirmed 2026-08-05). The Testing
API's vscode.TestMessage.location field would have been the exact primitive for the failed-step mark,
but as §5 details, vscode.tests exposes no cross-controller result read at all (confirmed against
vscode.d.ts), and the generic testing.runAtCursor delegation path requires real editor focus and
still wouldn't solve that read problem — so this design does not go through the Testing API or
TestController at all, and does not attempt to invoke or observe the .NET/C# Dev Kit extension's own
controller.
Live-tested (2026-08-05): dotnet test --filter "FullyQualifiedName=Namespace.ClassFeature.MethodName"
correctly and precisely targeted the one generated method, matching exactly the
DeclaringTypeFullName/MethodName shape ScenarioTestTarget (§3) already produces — no
display-name matching or extra lookup needed. Our CodeLens's run/debug handler shells to this directly,
captures stdout, and parses Reqnroll's -> done:/-> error:/-> skipped lines in order against the
.feature file's own step list (§6, above). Pass/fail state and the failed-step location are rendered
via our own TextEditorDecorationType gutter icons (with a hover message carrying the captured error
text) and CodeLens label updates — entirely within our own extension, no vscode.TestRun/TestMessage
involved, and no presence in the native Testing panel. What's left is standard implementation work
(decoration lifecycle on file edit/close, debounce during a run, dotnet test --filter invocation for
the debug case via a debug-adapter launch config rather than plain dotnet test) — no open design
question remains for VS Code.
Rider — live-confirmed (2026-08-05, via the devcontainer, Chris driving). Chris built the Rider
devcontainer, ran a real SampleReqnrollSolution with an ambiguous-binding scenario (two step bindings
both matching "the second number is 5") through Rider's native Test Runner window, and reported back the
Test Runner's output pane directly (screenshot). This independently confirms both findings from the
dotnet test spike above, on a completely different test host/IDE:
- Same stack-trace misattribution. The
Reqnroll.AmbiguousBindingException's stack trace reportedSecondFeature.feature:line 10— the scenario's last step ("Then another unused expression") — not line 7, the actual step that failed ("And the second number is 5"). Same#line hidden-region artifact as VS/dotnet test; confirms this is a property of Reqnroll's generated code, not anything IDE- or test-host-specific, so the design's "don't use stack traces for step attribution" conclusion applies uniformly across all three IDEs. - Same step-trace stdout, confirmed live. Rider's console pane showed the identical
-> done:/-> skipped because of previous errorslines, plus two outcomes the earlier spike hadn't exercised —-> binding error: ...(this scenario's actual failure) and-> undefined: ...(a deliberately-unbound step also present in the sample feature) — both now in the confirmed vocabulary table above. Rider's own test-runner captures and displays Reqnroll's default stdout trace exactly likedotnet testdoes — no Rider-specific signal needed, noreqnroll.jsonopt-in required. - Row-test naming, confirmed live. The Test Runner tree showed a row-tests Outline's parameterized
entry named
adding numbers(first: "50", second: "5", result: "1", __pickleIndex: "0", exampleTags: [])— Rider identifies a specific row by a formatted display name embedding the row's argument values, not a positional index. This is live confirmation of the §3/§7 item 6 residual risk: targeting one specific Outline row via Rider's native runner would need to match on this formatted name (itself built from the row's arguments), not a stable index — reinforcing "run the whole parameterized method, not one row" as the safer default for Rider's row-tests Outline invocation, same conclusion reached for VS'sTestMethodIdentifier. - Clicking the failed test navigates to the last step too (Chris confirmed) — consistent with the
stack-trace finding; this closes off "click-to-navigate" as a failed-step-mark mechanism for Rider the
same way it was already closed off for VS/
ErrorStackTrace.
What's still genuinely unconfirmed: the exact plugin-API shape for consuming this programmatically
— whether it's SMTRunnerEventsListener/SMTestProxy as hypothesized, and the specific method/field
that exposes the captured stdout (something like SMTestProxy.getOutput()/printOn(Printer) in
IntelliJ Platform's usual shape, not decompiled or verified this session). This is now a narrow,
low-risk "look up the exact API surface" task rather than an open feasibility question — the UI
evidence above proves the data is there and captured; only the plugin-facing accessor for it needs
confirming, and that's answerable by decompiling intellij-community's smRunner module (a follow-up
task, not something blocking the design any further).
Correction (issue #504, 2026-08-27 — Rider research spike, unattended, bytecode inspection via the
devcontainer's Gradle cache, no live UI session): the SMTestProxy hypothesis above is wrong. Rider's
.NET unit testing is not built on IntelliJ Platform's generic SMTRunnerEventsListener/SMTestProxy
test-framework integration at all — that's the JVM-language (Java/Kotlin/etc.) test-framework mechanism.
Decompiling intellij.rider.jar and product.jar from the actual pinned Rider 2024.3.5 distribution
(technique: [[rider-bytecode-inspection-technique]]) shows a Rider-specific, entirely separate system
under com.jetbrains.rider.unitTesting.* (frontend UI: tool window, tree, actions — ~150 classes) and
com.jetbrains.rider.model.RdUnitTest* (the protocol model: RdUnitTestSession, RdUnitTestHost,
RdUnitTestResultData, RdUnitTestTreeDescriptor, etc., all *.Pregenerated.kt — RD-protocol-generated
code, confirmed by class name and by RdUnitTestSession extending RdBindableBase).
Why this matters — this is not just a wrong accessor name, it changes the feasibility conclusion
entirely. RdUnitTestSession's only outbound actions are RdSignal<Unit> fields named _run/_debug
— the JVM frontend can only fire a run/debug signal on a session; it cannot construct one. Every
RdUnitTestSession/tree node the tool window ever shows originates from the .NET backend
(ReSharperHost) and is synced to the JVM frontend over the RD protocol, one-directionally, as
already-discovered data. There is no JVM-side extension point anywhere in com.jetbrains.rider.unitTesting
for a plugin to inject a synthetic test element, a custom provider, or a fake session — the frontend is
a thin, protocol-driven renderer of backend-owned state, not an extensible discovery/registration surface.
What this means for Reqnroll scenarios appearing in Rider's native Unit Tests tool window: it would
require a ReSharperHost backend plugin — a JetBrains.ReSharper.*-SDK, C#-based .NET plugin
implementing that side's own IUnitTestProvider/IUnitTestElement extensibility (the same mechanism
NUnit/xUnit/MSTest's built-in Rider support presumably uses, unverified — not reachable from this
devcontainer's JVM-only bytecode). This is a different plugin architecture and SDK entirely from
src/Rider's existing pure-Kotlin frontend plugin (com.reqnroll.ide.rider.*) — a new project with its
own build/package/versioning story, not a follow-up feature inside the existing one. That's a
qualitatively larger undertaking than anything else done for issue #504 across all three IDEs — closer
in scope to standing up a new subsystem than extending an existing one.
Decision: not pursuing this now. Rider keeps its existing CodeVisionProvider-based own-execution
Run lens (RunTestCodeVisionProvider/RunLensSupport/RunTestRunner — unchanged, still the right
design for what it does) with no native Unit Tests tool window presence. Arrived at the same practical
outcome as VS Code's #504 reversal (no TestController/native-tree integration), but via a completely
different reason: VS Code's case was "not worth it, C# Dev Kit already covers this adequately"; Rider's
case is "the extensibility point needed doesn't exist on the JVM side at all — it would need a new,
separately-scoped C# backend plugin project." If this is ever revisited, the concrete starting points
are: (1) confirm whether IUnitTestProvider/IUnitTestElement are indeed public, third-party-usable
ReSharper SDK interfaces (not decompiled/verified this session — only the JVM frontend was inspected,
since that's what the devcontainer's Gradle cache actually contains); (2) check whether the legacy
reqnroll/Reqnroll.Rider plugin (predates this repo, referenced at
reqnroll/Reqnroll.Rider#8) ever had a
ReSharperHost-side component, as a possible starting point or prior-art reference.
Presentation target (from the issue's own follow-up comment and the legacy Rider plugin's confirmed source): a gutter icon at the failed step's line — not inline diagnostics, not line-background highlighting — with a hover tooltip showing the failure output. Registered at info/hint severity, not error severity, to stay visually low-noise against genuine diagnostics (F3's error/warning squiggles).
What still needs a live session, concretely:
Visual StudioResolved (2026-08-27) — pass/fail glyph implemented via reflection, guarded so any future shape change degrades to "no glyph" rather than throwing (see §6 correction above,RunTestOutcomeBridge). Run/Debug delegation to Test Explorer (§5) is unaffected either way — it only ever needed the publicTestMethodIdentifier/CodeLensDetailPaneCommandsurface.VS CodeResolved — Option 2 decided (own execution viadotnet test --filter, noTestController, no native Testing panel presence), after confirming viavscode.d.tsand VS Code's own command source that there's no way to delegate to C# Dev Kit's controller and read its result back. No open design question or live-testing need remains for VS Code.RiderResolved (2026-08-05 live-testing; 2026-08-27 native-tree feasibility). Chris ran a real ambiguous-binding scenario through the devcontainer's Rider Test Runner and confirmed both the stack-trace misattribution and the stdout step-trace independently, on a different test host than thedotnet testspike — this part of the design (own-execution, stdout parsing) is unaffected. TheSMTestProxyplugin-API accessor mentioned as a follow-up below is now known to be the wrong hypothesis — see the correction above: Rider's unit testing has no JVM-side extension point at all, native tool-window integration is not pursued, decision recorded above.
All three IDEs' test-result correlation designs are now closed out — §6 is fully resolved.
Class-name generation ruleResolved. Confirmed by decompilingUnitTestFeatureGenerator.GenerateUnitTestFixture:{feature.Name.ToIdentifier()}Feature(TestClassNameFormat = "{0}Feature"by default). See §2.§6 test-result channelResolved for all three IDEs. A livedotnet testspike (throwaway Reqnroll+xUnit project, deliberately-failing scenario) settled the two biggest unknowns:ErrorStackTrace/#line-mapped frames do not reliably identify the failing step (they always attribute to the scenario's last step, a#line hidden-region artifact — corrects the original optimistic hypothesis); the real signal is Reqnroll's own default step-trace stdout (full seven-outcome vocabulary decompiled fromTestTracer, §6), present with noreqnroll.jsonopt-in, anddotnet test --filter "FullyQualifiedName=..."precisely targets a resolvedScenarioTestTarget. Chris then independently confirmed both findings live in Rider via the devcontainer (real ambiguous-binding scenario, same misattribution, same stdout trace, plus the row-test formatted-display-name finding). VS Code's directly-shelleddotnet test(§5, Option 2) and Rider's Test Runner both consume this signal. VS'sICodeLensTestInformationService/TestResultRecord.StandardOutputpath (2026-08-27, see §6 correction) is now consumed via a guarded reflection bridge (RunTestOutcomeBridge) rather thanTestResultRecord.StandardOutput's step-trace parsing — the outcome-only glyph doesn't need the failed-step detail that stdout parsing was originally for. The Rider-side plugin-API-accessor lookup this line used to flag as an open follow-up is resolved as a non-issue (2026-08-27): Rider's unit testing isn't built onSMTestProxyat all (§6 correction) and native tool-window integration isn't being pursued, so there's no accessor left to look up — Rider keeps its existing own-executionCodeVisionProviderdisplay, which already has the stdout it needs. 2a.VS CodeResolved — Option 2, own execution, noTestControllervs. own-execution design forkTestController(2026-08-05).vscode.testsexposes no way to read another extension's controller results (confirmed againstvscode.d.ts— an earlier guess that atests.testResultssnapshot API existed was wrong, corrected here), and the generictesting.runAtCursordelegation path requires real editor focus and still wouldn't solve the read problem. Chose to avoid duplicate Test Explorer entries against C# Dev Kit's own listing over gaining native Testing-panel presence — see §5.VS editor-margin extension pointResolved — there is no separate extension point. DecompilingMicrosoft.VisualStudio.TestWindow.CodeLens.dllshows VS's own run/debug/pass-fail affordance for ordinary tests (TestStatusProvider) is itself a classic CodeLens data-point provider — the exact API family F24 already bridged. See §5's rewritten Visual Studio bullet.- Out of scope for now — relationship to Appendix B "Debug Support for Feature Files." Reqnroll's
#linepragmas mean the compiled test assembly's PDB already carries.feature-relative sequence points — the same mechanism Razor/T4 use for direct template debugging. This suggests breakpoint support may be a narrower "register.featureas a valid breakpoint source / path-map it to the native debugger" problem per IDE, not a from-scratch DAP implementation. Explicitly descoped from this issue's implementation (2026-08-05) — recorded here as a lead for whoever picks up the Appendix B item separately, not something #262 needs to spike or deliver. No dependency runs the other way: #262's run/debug/pass-fail/failed-step work stands on its own without this. Per-framework row-attribute allowlistResolved (§3 Tier 2, §2 table). All five providers' actualSetRowdecompiled —InlineDataAttribute(xUnit/xUnit.v3),TestCaseAttribute(NUnit3),ArgumentsAttribute(TUnit),DataRowAttribute(MSTest, viaMsTestV2GeneratorProvider/MsTestV4GeneratorProvider— not theMsTestGeneratorProviderbase class, a first-pass mistake corrected in §2; that base class is superseded/unused and itsNotSupportedExceptiondoesn't apply to real MSTest projects). All five frameworks support row tests. Still open: whether the project's active test framework — including MSTest'sTargetMsTestVersion-driven provider split — is resolvable from existing project-reference detection (F2 binding discovery) or needs its own detection step here.Row-level invocation addressing, per IDEResolved (falls back to "run all rows"). VS'sTestMethodIdentifier(decompiled, §5) addresses a method, not a row. Rider's Test Runner (live-confirmed, §6) names a row by a formatted display string embedding its arguments, not a stable index. Neither gives reliable positional addressing, so the design commits to: a row-tests Outline's gutter targets the whole parameterized method by default; per-row addressing is not attempted in the initial implementation for any IDE (VS Code's own-execution design, §5, could add it later viadotnet test --filterwith aDisplayName-based filter expression per row, since it owns the invocation — worth revisiting post-implementation, not blocking now).- Multi-target "run all examples" invocation for individual-methods-mode Outlines, per IDE (§5, new
subsection). VS Code's own-execution design handles this trivially (OR'd
dotnet test --filterexpression). VS's multi-elementTestMethodIdentifier[]CommandArgsis plausible but unconfirmed — only a single-target usage was observed in the decompiledTestStatusProvidersource; fold into the same live VS check §6 already calls for. Rider's multi-target line-marker capability is unchecked entirely — a follow-up for Rider implementation, likely answerable by looking at how Rider's own built-in per-class "run all tests" line marker works. Neither blocks starting implementation, since row-tests mode (the framework default) doesn't need this at all. AST-transforming generator plugins (e.g.Resolved (2026-08-05, flagged by Chris). Confirmed against Reqnroll's actual source that such plugins (which injectReqnroll.ExternalData)Examples:rows into the AST after parsing but before codegen, so the.featurefile can show zero rows or noExamples:block at all) don't require design changes — the resolver was already going to determine parameterization from the generated.feature.cs, never from the.featurefile's own row count, and per-row addressing was already ruled out entirely (item 6), so this degrades safely to the existing "run the whole method"/"run all methods" fallback with no special-casing needed. See §2's dedicated subsection and §3 Tier 2's scope note. Worth a specs/unit-test fixture in §8 exercising a scenario with anExternalData-style tag and zero visible rows, to lock this in as a regression case rather than leaving it as an untested inference.
- Core unit —
ScenarioTestTargetResolverTests: feed hand-built.feature.csfixtures (or a small generated corpus covering plain scenario / row-tests Outline / individual-methods Outline with named and unnamedExamples:blocks) and assert the resolvedScenarioTestTarget[]. Per [[core-tests-avoid-stubidescope]], build inputs directly rather than throughVsxStubs. - Server unit —
ResolveTestTargetsHandlerTests: URI/range handling, absent-buffer and not-yet-built (no.feature.cspresent) cases. - Acceptance (specs) — a
.featurespec exercisingreqnroll/resolveTestTargetsagainst a real generated fixture project (reusing the sample-project-generator infrastructure already used by F2 discovery specs), covering all three Outline naming modes from §2, plus a fixture usingReqnroll.ExternalData(or a hand-rolled AST-injecting test double, to avoid an external-file dependency in the test project) with a plainScenario:and zero visibleExamples:rows, asserting the resolver still finds the generated row-tests method via Tier 1 rather than concluding "0 targets" from the.featurefile's own row count — locks in §7 item 8 as a regression case, not just an untested inference. - Per-IDE run/debug and result-correlation glue is IDE-side only and follows [[vs-extension-testing-strategy]] — native-runner invocation and result-event handling aren't practically unit-testable; verify live per IDE instead.